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---.yaml b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml index f4e861146..610e65a1d 100644 --- a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml @@ -40,6 +40,7 @@ body: - Chrome - Firefox - Edge + - Brave - Other default: 0 - type: input diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index 889ce99bd..ef5ec5286 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -19,6 +19,7 @@ body: - Chrome - Firefox - Edge + - Brave - Other default: 0 - type: dropdown 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/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 8015ec452..bd5e3ea2a 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -4,20 +4,19 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Test using Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v6 with: - node-version: "v22" + node-version: "v24" - name: Install dependencies run: npm install - name: Setup e2e environment run: bash script/setup-e2e.sh --all - - name: Start test servers - run: bash script/setup-e2e.sh --start-servers - name: Run tests env: + DEBUG: "rstest" USE_HEADLESS_PUPPETEER: true run: npm run test-e2e - name: Tests ✅ @@ -37,4 +36,4 @@ jobs: "state": "failure", "description": "Tests failed", "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - } + }' diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml new file mode 100644 index 000000000..a65a39fac --- /dev/null +++ b/.github/workflows/knip.yml @@ -0,0 +1,19 @@ +name: Knip +on: + pull_request: +concurrency: + group: knip-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + knip: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install dependencies + run: npm install + - name: Run Knip + run: npx knip --treat-config-hints-as-errors diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index 4fd25d1c8..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: "v22" + 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-edge.yml b/.github/workflows/publish-edge.yml index 1fde28733..39892ed1a 100644 --- a/.github/workflows/publish-edge.yml +++ b/.github/workflows/publish-edge.yml @@ -3,6 +3,8 @@ on: [workflow_dispatch] jobs: publish: runs-on: ubuntu-latest + env: + ACTIONS_RUNNER_DEBUG: true steps: - uses: actions/checkout@v4 with: @@ -10,7 +12,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "v22" + node-version: "v24" - name: Install dependencies run: npm install - name: Build diff --git a/.github/workflows/publish-firefox.yml b/.github/workflows/publish-firefox.yml index b1dc24e1e..b41cff429 100644 --- a/.github/workflows/publish-firefox.yml +++ b/.github/workflows/publish-firefox.yml @@ -1,26 +1,31 @@ name: Publish to Firefox Addon Store -on: [workflow_dispatch] +on: [ workflow_dispatch ] jobs: publish: runs-on: ubuntu-latest + env: + FF_MIN_VER: "140.0" steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup NodeJS - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "v22" + node-version: "v24" - name: Install dependencies run: npm install - name: Build 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/.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 66205e14f..e825c2e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,86 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.2] - 2026-05-16 + +- Urgently fixed some bugs + +## [4.3.1] - 2026-05-15 + +- Added share button on the popup page +- 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f810cac8..d4a9b6945 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ And [Chrome Extension Development Documentation](https://developer.chrome.com/do Some free open source tools are also integrated: -- Testing tool [jest](https://jestjs.io/docs/getting-started) +- Testing tool [Rstest](https://rstest.rs) - End-to-end integration testing [puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/puppeteer) - I18N tool [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox) diff --git a/README-zh.md b/README-zh.md index 40d009a7c..551b6cc6e 100644 --- a/README-zh.md +++ b/README-zh.md @@ -4,6 +4,12 @@ [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) [![](https://img.shields.io/github/v/release/sheepzh/time-tracker-4-browser)](https://github.com/sheepzh/time-tracker-4-browser/releases) +

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

+ \[ 简体中文 | [English](./README.md) \] 网费很贵是一款用于上网时间统计的浏览器插件,使用 rspack,TypeScript 和 Element-plus 进行开发。你可以在 Firefox,Chrome 和 Edge 中安装并使用它。 diff --git a/README.md b/README.md index 9da6906bf..cfdafd23f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) [![Crowdin](https://badges.crowdin.net/timer-chrome-edge-firefox/localized.svg)](https://crowdin.com/project/timer-chrome-edge-firefox) +

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

+ \[ English | [简体中文](./README-zh.md) \] Time Tracker is a browser extension to track the time you spent on all websites. It's built by rspack, TypeScript and Element-plus. And you can install it for Firefox, Chrome and Edge. 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 60007387e..000000000 --- a/jest.config.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { readFileSync } from 'fs' -import type { Config } from "jest" -import { join } from 'path' - -const tsconfig = JSON.parse(readFileSync(join(process.cwd(), 'tsconfig.json'), 'utf-8')) -const { compilerOptions } = tsconfig as { compilerOptions: { paths: { [key: string]: string[] } } } - -const { paths } = compilerOptions - -const aliasPattern = /^(@.*)\/\*$/ -const sourcePattern = /^(.*)\/\*$/ - -const moduleNameMapper: { [key: string]: string } = {} - -Object.entries(paths).forEach(([alias, sourceArr]) => { - const aliasMatch = alias.match(aliasPattern) - if (!aliasMatch) { - return - } - if (!Array.isArray(sourceArr) || sourceArr.length !== 1) { - return - } - const sourceMath = sourceArr[0]?.match(sourcePattern) - if (!sourceMath) { - return - } - const prefix = aliasMatch[1] - const pattern = `^${prefix}/(.*)$` - const source = sourceMath[1] - const sourcePath = `/${source}/$1` - moduleNameMapper[pattern] = sourcePath -}) - -console.log("The moduleNameMapper parsed from tsconfig.json: ") -console.log(moduleNameMapper) - -const config: Config = { - moduleNameMapper, - roots: [ - "/test", - "/test-e2e", - ], - testRegex: '(.+)\\.test\\.(jsx?|tsx?)$', - transform: { - "^.+\\.tsx?$": "@swc/jest" - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], -} - -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 e60ca434a..60b73098b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "timer", - "version": "3.7.15", + "name": "tt4b", + "version": "4.3.2", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -13,9 +13,9 @@ "build": "rspack --config=rspack/rspack.prod.ts", "build:firefox": "rspack --config=rspack/rspack.prod.firefox.ts", "build:safari": "rspack --config=rspack/rspack.prod.safari.ts", - "test": "jest --env=jsdom test/", - "test-c": "jest --coverage --reporters=jest-junit --env=jsdom test/", - "test-e2e": "jest test-e2e/ --runInBand", + "test": "rstest --config test/rstest.config.mts", + "test-c": "rstest --config test/rstest.config.mts --coverage --reporter=junit", + "test-e2e": "rstest --config test-e2e/rstest.config.mts", "prepare": "husky" }, "author": { @@ -28,47 +28,50 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.52.0", + "@commitlint/types": "^21.0.1", + "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", - "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.0", - "@rspack/cli": "^1.7.3", - "@rspack/core": "^1.7.3", - "@swc/core": "^1.15.10", - "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.35", + "@rsdoctor/rspack-plugin": "^1.5.11", + "@rspack/cli": "^2.0.3", + "@rspack/core": "^2.0.3", + "@rstest/core": "^0.10.0", + "@rstest/coverage-istanbul": "^0.10.0", + "@types/chrome": "0.1.42", "@types/decompress": "^4.2.7", - "@types/jest": "^30.0.0", - "@types/node": "^25.0.9", - "@types/punycode": "^2.1.4", + "@types/firefox-webext-browser": "^143.0.0", + "@types/node": "^25.8.0", "@vue/babel-plugin-jsx": "^2.0.1", - "babel-loader": "^10.0.0", - "commitlint": "^20.3.1", - "css-loader": "^7.1.2", + "babel-loader": "^10.1.1", + "commitlint": "^21.0.1", + "css-loader": "^7.1.4", "decompress": "^4.2.1", + "fake-indexeddb": "^6.2.5", + "fork-ts-checker-webpack-plugin": "^9.1.0", "husky": "^9.1.7", - "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.35.0", - "ts-loader": "^9.5.4", + "knip": "^6.14.0", + "postcss": "^8.5.14", + "postcss-loader": "^8.2.1", + "postcss-rtlcss": "^6.0.0", + "puppeteer": "^25.0.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "5.9.3" + "typescript": "6.0.3", + "unplugin-element-plus": "^0.11.2" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", + "@emotion/css": "^11.13.5", "echarts": "^6.0.0", - "element-plus": "2.13.1", - "punycode": "^2.3.1", - "vue": "^3.5.27", - "vue-router": "^4.6.4" + "element-plus": "2.14.0", + "hash.js": "^1.1.7", + "qrcode-generator": "^2.0.4", + "typescript-guard": "0.2.4", + "vue": "^3.5.34", + "vue-router": "^5.0.7" }, "engines": { "node": ">=22" } -} +} \ No newline at end of file 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 8fbcda9c7..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,11 +86,27 @@ const staticOptions: Configuration = { iterableIsArray: true, }, plugins: [ - "@vue/babel-plugin-jsx", + VueBabelPluginJsx, "@emotion/babel-plugin", ], }, - }, 'ts-loader'], + }, { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + runtime: 'preserve', + }, + }, + target: compilerOptions.target, + }, + }, + }], }, { test: /\.css$/, use: [CssExtractRspackPlugin.loader, 'css-loader', POSTCSS_LOADER_CONF], @@ -100,18 +119,79 @@ const staticOptions: Configuration = { resolve: { extensions: ['.ts', '.tsx', '.js', '.css'], tsConfig: join(__dirname, '..', 'tsconfig.json'), + conditionNames: ['import', 'module', 'browser', 'default'], + alias: { + 'element-plus/es/components/loading-service/style/css': 'element-plus/es/components/loading/style/css', + 'element-plus/es/components/loading-directive/style/css': 'element-plus/es/components/loading/style/css', + 'element-plus/es/components/auto-resizer/style/css': 'element-plus/es/components/table-v2/style/css', + }, }, optimization: { splitChunks: { chunks: chunkFilter, + maxInitialRequests: 30, + maxAsyncRequests: 30, cacheGroups: { + echarts: { + test: /[\\/]node_modules[\\/]echarts[\\/]/, + name: 'vendor/echarts', + filename: 'vendor/echarts.js', + priority: 40, + reuseExistingChunk: true, + enforce: true, + }, elementPlus: { - name: 'element-plus', test: /[\\/]node_modules[\\/]element-plus[\\/]/, + name: 'vendor/element-plus', + filename: 'vendor/element-plus.js', + priority: 39, + reuseExistingChunk: true, + enforce: true, + }, + elementIcons: { + test: /[\\/]node_modules[\\/]@element-plus[\\/]icons-vue[\\/]/, + name: 'vendor/el-icons', + filename: 'vendor/el-icons.js', + priority: 38, + reuseExistingChunk: true, + enforce: true, + }, + vue: { + test: /[\\/]node_modules[\\/](vue|@vue|vue-router|@vueuse)[\\/]/, + name: 'vendor/vue', + filename: 'vendor/vue.js', + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + dayjs: { + test: /[\\/node_modules][\\/]dayjs[\\/]/, + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + memoizeOne: { + test: /[\\/node_modules][\\/]memoize\\-one[\\/]/, + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + /** + * Exclude src/background from the default shared chunk group so those files are + * never pulled into vendor/* (merging into entry name: 'background' panics in Rspack). + */ + default: { + minChunks: 2, + priority: -20, + reuseExistingChunk: true, + test: module => !isBackgroundModule(module), }, defaultVendors: { - filename: 'vendor/[name].js' - } + test: /[\\/]node_modules[\\/]/, + filename: 'vendor/[name].js', + priority: -10, + reuseExistingChunk: true, + }, } }, }, @@ -119,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: [ @@ -136,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...', @@ -148,6 +230,17 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => { }, chunks: ['app'], }), + new HtmlRspackPlugin({ + filename: path.join('static', 'limit.html'), + title: 'Loading...', + chunks: [CONTENT_SCRIPT_LIMIT], + meta: { + viewport: { + name: "viewport", + content: 'width=device-width', + }, + } + }), new HtmlRspackPlugin({ filename: path.join('static', 'popup.html'), chunks: ['popup'], diff --git a/rspack/rspack.dev.firefox.ts b/rspack/rspack.dev.firefox.ts index ff149a7a4..adb49d4e2 100644 --- a/rspack/rspack.dev.firefox.ts +++ b/rspack/rspack.dev.firefox.ts @@ -3,14 +3,12 @@ import manifest from "../src/manifest-firefox" import generateOption from "./rspack.common" manifest.name = "IS DEV" -// Fix the crx id for development mode -manifest.key = "clbbddpinhgdejpoepalbfnkogbobfdb" // The manifest.json is different from Chrome's with add-on ID manifest.browser_specific_settings = { ...manifest.browser_specific_settings, gecko: { ...manifest.browser_specific_settings?.gecko, - id: 'timer@zhy', + id: 'tt4b@zhy', } } diff --git a/rspack/rspack.dev.ts b/rspack/rspack.dev.ts index 18ce606d4..5cf59f91e 100644 --- a/rspack/rspack.dev.ts +++ b/rspack/rspack.dev.ts @@ -1,3 +1,4 @@ +import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin" import path from "path" import manifest from "../src/manifest" import generateOption from "./rspack.common" @@ -10,4 +11,27 @@ const options = generateOption({ mode: "development", }) +const tsCheckerPlugin = new ForkTsCheckerWebpackPlugin({ + typescript: { + configOverwrite: { + compilerOptions: { + skipLibCheck: false, + }, + }, + diagnosticOptions: { + syntactic: true, + semantic: true, + declaration: true, + global: true, + }, + }, + issue: { + exclude: [ + { file: '**/node_modules/**' }, + ], + }, +}) + +options.plugins?.push(tsCheckerPlugin) + export default options diff --git a/rspack/rspack.prod.firefox.ts b/rspack/rspack.prod.firefox.ts index b36fa0cb2..b76b4c28f 100644 --- a/rspack/rspack.prod.firefox.ts +++ b/rspack/rspack.prod.firefox.ts @@ -2,6 +2,7 @@ import path from "path" import manifestFirefox from "../src/manifest-firefox" import { FileManagerPlugin } from "./plugins/file-manager" import optionGenerator from "./rspack.common" +import { enhancePluginWith } from './util' const { name, version } = require(path.join(__dirname, '..', 'package.json')) @@ -10,29 +11,10 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.firefox.zip`) const targetZipFilePath = path.resolve(marketPkgPath, 'target.firefox.zip') -const normalSourceCodePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-src.zip`) -const targetSourceCodePath = path.resolve(__dirname, '..', 'market_packages', 'target.src.zip') -const readmeForFirefox = path.join(__dirname, '..', 'doc', 'for-firefox.md') -// Temporary directory for source code to archive on Firefox -const sourceTempDir = path.resolve(__dirname, '..', 'source_temp') -const srcDir = [ - 'public', - 'src', - 'test', 'types', - 'package.json', 'package-lock.json', - 'tsconfig.json', - 'rspack', - 'jest.config.ts', - 'script', - ".gitignore", -] -const copyMapper = srcDir.map(p => { return { source: path.resolve(__dirname, '..', p), destination: path.resolve(sourceTempDir, p) } }) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { - // Archive at the end onEnd: [ - // Define plugin to archive zip for different markets { delete: [normalZipFilePath], archive: [{ @@ -43,26 +25,12 @@ const filemanagerPlugin = new FileManagerPlugin({ destination: targetZipFilePath, }] }, - // Archive source code for FireFox - { - copy: [ - { source: readmeForFirefox, destination: path.join(sourceTempDir, 'README.md') }, - ...copyMapper - ], - archive: [ - { source: sourceTempDir, destination: normalSourceCodePath }, - { source: sourceTempDir, destination: targetSourceCodePath }, - ], - delete: [sourceTempDir], - }, ] } }) const option = optionGenerator({ outputPath, manifest: manifestFirefox, mode: "production" }) -const { plugins = [] } = option -plugins.push(filemanagerPlugin) -option.plugins = plugins +enhancePluginWith(option, fileManagerPlugin) option.devtool = false -export default option \ No newline at end of file +export default option diff --git a/rspack/rspack.prod.safari.ts b/rspack/rspack.prod.safari.ts index 5a9dd8a36..e4f53201e 100644 --- a/rspack/rspack.prod.safari.ts +++ b/rspack/rspack.prod.safari.ts @@ -10,7 +10,7 @@ const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${na const options = generateOption({ outputPath, manifest, mode: "production" }) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { // Archive at the end onEnd: [ @@ -25,7 +25,7 @@ const filemanagerPlugin = new FileManagerPlugin({ }) const { plugins = [] } = options -plugins.push(filemanagerPlugin) +plugins.push(fileManagerPlugin) options.plugins = plugins export default options \ No newline at end of file diff --git a/rspack/rspack.prod.ts b/rspack/rspack.prod.ts index 47df7f112..6ef6db0c1 100644 --- a/rspack/rspack.prod.ts +++ b/rspack/rspack.prod.ts @@ -12,7 +12,7 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.mv3.zip`) const targetZipFilePath = path.resolve(marketPkgPath, `target.zip`) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { // Archive at the end onEnd: [ @@ -34,7 +34,7 @@ const filemanagerPlugin = new FileManagerPlugin({ const option = optionGenerator({ outputPath, manifest, mode: "production" }) -enhancePluginWith(option, filemanagerPlugin) +enhancePluginWith(option, fileManagerPlugin) option.devtool = false export default option \ No newline at end of file diff --git a/rspack/util.ts b/rspack/util.ts index 65114cee9..6df6c9490 100644 --- a/rspack/util.ts +++ b/rspack/util.ts @@ -1,4 +1,4 @@ -import { type RspackOptions, type RspackPluginInstance } from '@rspack/core' +import type { RspackOptions, RspackPluginInstance } from '@rspack/core' export function enhancePluginWith(option: RspackOptions, ...toPush: RspackPluginInstance[]) { const { plugins = [] } = option diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts index 2b9361f56..b446a48ac 100644 --- a/script/crowdin/client.ts +++ b/script/crowdin/client.ts @@ -79,8 +79,8 @@ class PaginationIterator { private async processBuf() { const pagination: Pagination = { offset: this.offset, limit: this.limit } const list = await this.query(pagination) - const data = list?.data - if (!data?.length) { + const data = list.data + if (!data.length) { this.isEnd = true } else { this.buf = data.map(obj => obj.data) @@ -98,7 +98,7 @@ export type NameKey = { branchId: number } -export type TranslationKey = { +type TranslationKey = { stringId: number lang: CrowdinLanguage } @@ -229,15 +229,14 @@ export class CrowdinClient { console.log("Content length: " + Object.keys(content).length) for (const [path, value] of Object.entries(content)) { const string = existStringsKeyMap[path] + if (!string) continue const patch: PatchRequest[] = [] - string?.text !== value && patch.push({ + 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,12 +279,11 @@ export class CrowdinClient { targetLanguageIds: [...ALL_CROWDIN_LANGUAGES], skipUntranslatedStrings: true, }) - const buildId = buildRes?.data?.id + const buildId = buildRes.data.id while (true) { // Wait finished const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId) - const url = res?.data?.url - if (url) return url + return res.data.url } } } @@ -296,7 +294,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 8971278c7..5e21c7e8c 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -22,6 +22,7 @@ export const ALL_CROWDIN_LANGUAGES = [ 'zh-CN', 'zh-TW', 'ja', 'pt-PT', 'uk', 'es-ES', 'de', 'fr', 'ru', 'pl', 'ar', 'tr', + 'it', ] as const /** @@ -31,9 +32,9 @@ export const ALL_CROWDIN_LANGUAGES = [ */ export type CrowdinLanguage = typeof ALL_CROWDIN_LANGUAGES[number] -export const SOURCE_LOCALE: timer.RequiredLocale = 'en' +export const SOURCE_LOCALE: tt4b.RequiredLocale = 'en' -const OPTIONAL_PLACEHOLDER: Record = { +const OPTIONAL_PLACEHOLDER: Record = { ja: 0, uk: 0, de: 0, @@ -45,27 +46,13 @@ const OPTIONAL_PLACEHOLDER: Record = { zh_CN: 0, zh_TW: 0, pt_PT: 0, - es: 0 + es: 0, + it: 0, } -export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as timer.OptionalLocale[] +export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as tt4b.OptionalLocale[] -const CROWDIN_I18N_MAP: Record = { - "zh-CN": 'zh_CN', - ja: 'ja', - 'zh-TW': 'zh_TW', - 'pt-PT': 'pt_PT', - uk: 'uk', - 'es-ES': 'es', - de: 'de', - fr: 'fr', - ru: 'ru', - ar: 'ar', - tr: 'tr', - pl: 'pl', -} - -const I18N_CROWDIN_MAP: Record = { +const I18N_CROWDIN_MAP: Record = { zh_CN: 'zh-CN', ja: 'ja', zh_TW: 'zh-TW', @@ -78,11 +65,10 @@ const I18N_CROWDIN_MAP: Record = { 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: [ @@ -97,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" /** @@ -133,13 +119,13 @@ 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 if (!existMessages) { - console.error(`Failed to find local code: dir=${dir}, filename=${filename}`) + logError(`Failed to find local code: dir=${dir}, filename=${filename}`) return } const sourceItemSet = transMsg(existMessages[SOURCE_LOCALE]) @@ -155,19 +141,23 @@ 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(`[CROWDIN-ERROR] ${msg}`) +} + function checkPlaceholder(translated: string, source: string) { const allSourcePlaceholders = Array.from(source.matchAll(/\{(.*?)\}/g)) @@ -190,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 @@ -227,8 +218,5 @@ 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/export-translation.ts b/script/crowdin/export-translation.ts index eca038ee3..115f437f7 100644 --- a/script/crowdin/export-translation.ts +++ b/script/crowdin/export-translation.ts @@ -17,7 +17,7 @@ const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip") const TEMP_DIR = join(process.cwd(), ".crowdin-temp") async function processDir(dir: Dir): Promise { - const fileSets: Record>> = {} + const fileSets: Record>> = {} for (const locale of ALL_TRANS_LOCALES) { const crowdinLang = crowdinLangOf(locale) const dirPath = join(TEMP_DIR, crowdinLang, dir) diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts index 184cd3026..56294533e 100644 --- a/script/crowdin/sync-translation.ts +++ b/script/crowdin/sync-translation.ts @@ -28,12 +28,12 @@ async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.F } const existList = await client.listTranslationByStringAndLang({ stringId: string.id, lang }) // Deleted old translations different from current - const oldByOwner = existList?.filter(t => t?.user?.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) + const oldByOwner = existList.filter(t => t.user.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) for (const toDelete of oldByOwner || []) { await client.deleteTranslation(toDelete.id) console.log(`Deleted translation by owner: stringId=${string.id}, lang=${lang}, text=${toDelete.text}`) } - if (!existList?.find(t => t.text === text)) { + if (!existList.some(t => t.text === text)) { // Create new translation await client.createTranslation({ stringId: string.id, lang }, text) console.log(`Created trans: stringId=${string.id}, lang=${lang}, text=${text}`) diff --git a/script/psl.ts b/script/psl.ts index aea4ced6d..fc33d526f 100644 --- a/script/psl.ts +++ b/script/psl.ts @@ -1,14 +1,13 @@ /** * Build psl tree */ +import { type PslTree } from '@/background/psl' import { fetchGet } from '@api/http' -import { type PslTree } from '@util/psl' import { writeFileSync } from 'fs' import path from 'path' -import punycode from "punycode" const LIST_URL = "https://publicsuffix.org/list/effective_tld_names.dat" -const JSON_PATH = path.join(__dirname, "..", "src", "util", "psl", "rules.json") +const JSON_PATH = path.join(__dirname, "..", "src", "background", "psl", "rules.json") const downloadList = async (): Promise => { const response = await fetchGet(LIST_URL) @@ -18,7 +17,7 @@ const downloadList = async (): Promise => { const parse = (tree: PslTree, parts: string[], index: number) => { if (index < 0) return const part = parts[index] - const ascii = punycode.toASCII(part) + const ascii = new URL(`http://${part}`).hostname let node = tree[ascii] if (index === 0) { if (!node) tree[ascii] = 1 @@ -58,6 +57,4 @@ async function main() { writeFileSync(JSON_PATH, JSON.stringify(tree, null, 4), { encoding: "utf-8" }) } - - main() \ No newline at end of file diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh index af80d5a6f..09b1da86e 100755 --- a/script/setup-e2e.sh +++ b/script/setup-e2e.sh @@ -139,6 +139,20 @@ install_e2e_dependencies() { log_success "E2E dependencies installed successfully" } +install_mock_server_dependencies() { + if [ -f "$PROJECT_ROOT/examples/package.json" ]; then + log_step "Installing dependencies for mock server..." + if npm install --prefix "$PROJECT_ROOT/examples" &> /dev/null; then + log_success "Mock server dependencies installed successfully" + else + log_error "Failed to install mock server dependencies" + exit 1 + fi + else + log_warning "examples/package.json not found, skipping mock server dependencies installation" + fi +} + # Upgrade e2e dependencies upgrade_e2e_dependencies() { log_step "Upgrading e2e test dependencies..." @@ -190,9 +204,31 @@ build_e2e_output() { build_npm_script "dev:e2e" "dist_e2e" true } -# Build production output (optional) -build_production_output() { - build_npm_script "build" "dist_prod" false +is_port_open() { + local port=$1 + lsof -nP -iTCP:"$port" -sTCP:LISTEN > /dev/null 2>&1 +} + +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 @@ -206,24 +242,56 @@ start_test_servers() { cd "$PROJECT_ROOT" || exit 1 - # Check if servers are already running - if pm2 list 2>/dev/null | grep -q "http-server.*12345" || pm2 list 2>/dev/null | grep -q "http-server.*12346"; then - log_warning "Test servers might already be running" - log_info "Stopping existing servers..." - pm2 stop all 2>/dev/null || true - pm2 delete all 2>/dev/null || true + log_info "Checking and starting e2e-server-1..." + remove_pm2_app_if_exists "e2e-server-1" + if is_port_open 12345; then + log_warning "Port 12345 is already in use before starting e2e-server-1" + fi + pm2 start http-server --name "e2e-server-1" -- ./examples/host -p 12345 + if ! wait_port_open 12345 5; then + log_error "e2e-server-1 failed to start on port 12345" + log_info "Check logs with: pm2 logs e2e-server-1 --lines 50" + exit 1 + fi + + if [ ! -f "$PROJECT_ROOT/examples/package.json" ]; then + log_error "examples package is missing. Please check examples/package.json" + exit 1 + fi + if [ ! -d "$PROJECT_ROOT/examples/node_modules" ]; then + log_error "examples dependencies are not installed. Please run: cd examples && npm install" + exit 1 fi - log_info "Starting test server on port 12345..." - pm2 start "http-server ./test-e2e/example -p 12345" --name "e2e-server-1" + log_info "Checking and starting e2e-server-2..." + remove_pm2_app_if_exists "e2e-server-2" + if is_port_open 12346; then + log_warning "Port 12346 is already in use before starting e2e-server-2" + fi + pm2 start http-server --name "e2e-server-2" -- ./examples/host -p 12346 + if ! wait_port_open 12346 5; then + log_error "e2e-server-2 failed to start on port 12346" + log_info "Check logs with: pm2 logs e2e-server-2 --lines 50" + exit 1 + fi - log_info "Starting test server on port 12346..." - pm2 start "http-server ./test-e2e/example -p 12346" --name "e2e-server-2" + log_info "Checking and starting gist-mock-server..." + remove_pm2_app_if_exists "gist-mock-server" + if is_port_open 12347; then + log_warning "Port 12347 is already in use before starting gist-mock-server" + fi + PORT=12347 pm2 start npm --name "gist-mock-server" --cwd "$PROJECT_ROOT/examples" -- run start:gist + if ! wait_port_open 12347 8; then + log_error "gist-mock-server failed to start on port 12347" + log_info "Check logs with: pm2 logs gist-mock-server --lines 50" + exit 1 + fi log_success "Test servers started" log_info "Server 1: http://127.0.0.1:12345" log_info "Server 2: http://127.0.0.1:12346" - log_info "To stop servers, run: pm2 stop all && pm2 delete all" + log_info "Gist mock: http://127.0.0.1:12347" + log_info "To stop servers, run: pm2 delete e2e-server-1 e2e-server-2 gist-mock-server" log_info "To view server logs, run: pm2 logs" } @@ -240,8 +308,7 @@ show_usage() { echo " --init, -i Initialize e2e environment (install dependencies if not installed)" echo " --upgrade, -u Upgrade e2e dependencies (http-server, pm2)" echo " --build, -b Build e2e output (runs 'npm run dev:e2e')" - echo " --build-prod Also build production output (runs 'npm run build')" - echo " --start-servers, -s Start test servers (http-server on ports 12345 and 12346)" + echo " --start-servers, -s Start test servers (12345/12346) and gist mock server (12347)" echo " --all, -a Run all steps (init + build + build-prod)" echo " --help, -h Show this help message" echo "" @@ -259,7 +326,6 @@ main() { local do_init=false local do_upgrade=false local do_build=false - local do_build_prod=false local do_start_servers=false # Parse arguments @@ -277,10 +343,6 @@ main() { do_build=true shift ;; - --build-prod) - do_build_prod=true - shift - ;; --start-servers|-s) do_start_servers=true shift @@ -288,7 +350,7 @@ main() { --all|-a) do_init=true do_build=true - do_build_prod=true + do_start_servers=true shift ;; --help|-h) @@ -303,7 +365,7 @@ main() { esac done - if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_build_prod" = false ] && [ "$do_start_servers" = false ]; then + if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_start_servers" = false ]; then show_usage exit 0 fi @@ -332,6 +394,7 @@ main() { # Run requested operations if [ "$do_init" = true ]; then install_e2e_dependencies + install_mock_server_dependencies fi if [ "$do_upgrade" = true ]; then @@ -342,10 +405,6 @@ main() { build_e2e_output fi - if [ "$do_build_prod" = true ]; then - build_production_output - fi - if [ "$do_start_servers" = true ]; then start_test_servers fi diff --git a/script/source-archive.sh b/script/source-archive.sh new file mode 100755 index 000000000..fd7bd06d9 --- /dev/null +++ b/script/source-archive.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p market_packages +git archive --format=zip -o market_packages/target.src.zip HEAD +zip -u -q market_packages/target.src.zip package-lock.json +zip -d -q market_packages/target.src.zip README.md 2>/dev/null || true +TMP=$(mktemp -d) +cp doc/for-firefox.md "$TMP/README.md" +( cd "$TMP" && zip -u -j -q "$ROOT/market_packages/target.src.zip" README.md ) +rm -rf "$TMP" diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 76357df59..1dc3a5d99 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -5,8 +5,8 @@ import { type Gist, type GistForm, updateGist as updateGistApi -} from "@src/api/gist" -import { CHROME_ID } from "@src/util/constant/meta" +} from "@api/gist" +import { CHROME_ID } from "@util/constant/meta" import fs from "fs" import { exitWith } from "../util/process" import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common" @@ -34,23 +34,16 @@ function parseArgv(): AddArgv { const argv = process.argv.slice(2) const [a0, a1] = argv - if (!a0) { - exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") - } + if (!a0) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") if (a0 === 'auto') { - if (!a1) exitWith("add.ts auto [dir_path]") - return { mode: 'auto', dirPath: a1 } + return a1 ? { mode: 'auto', dirPath: a1 } : exitWith("add.ts auto [dir_path]") } - if (!a1) { - exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") - } + if (!a1) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") - const browser: Browser = BROWSER_MAP[a0] - if (!browser) { - exitWith("add.ts [c/e/f] [file_name]") - } + const browser = BROWSER_MAP[a0] + if (!browser) return exitWith("add.ts [c/e/f] [file_name]") return { mode: 'manual', browser, fileName: a1 } } @@ -64,7 +57,9 @@ function detectBrowser(fileName: string): Browser | null { function sortDataByKey(data: UserCount): UserCount { const sorted: UserCount = {} - Object.keys(data).sort().forEach(key => sorted[key] = data[key]) + Object.entries(data) + .sort((a, b) => a[0].localeCompare(b[0])) + .forEach(([key, val]) => sorted[key] = val) return sorted } @@ -91,11 +86,16 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist const filename = filenameOf(browser) // 1. merge const file = gist.files[filename] - const existData = (await getJsonFileContent(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 } @@ -105,7 +105,7 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist function parseChrome(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 2)) { + if (!(lines.length > 2)) { return result } lines.slice(2).forEach(line => { @@ -124,7 +124,7 @@ function parseChrome(content: string): UserCount { function parseEdge(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 1)) { + if (!(lines.length > 1)) { return result } lines.slice(1).forEach(line => { @@ -145,7 +145,7 @@ function parseEdge(content: string): UserCount { function parseFirefox(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 4)) { + if (!(lines.length > 4)) { return result } lines.slice(4).forEach(line => { diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts index 505fd3439..a3a30cfc5 100644 --- a/script/user-chart/common.ts +++ b/script/user-chart/common.ts @@ -1,5 +1,5 @@ import { findTarget, type Gist } from "@api/gist" -import { exitWith } from "../util/process" +import { exitWith } from '../util/process' export type Browser = | 'chrome' diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 57f45b2ce..ded23a9be 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -13,9 +13,7 @@ import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type User type EcOption = ComposeOption< | LineSeriesOption | TitleComponentOption - | GridComponentOption -> - + | GridComponentOption> const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge'] const POINT_COUNT = 500 @@ -115,7 +113,10 @@ function zoom(data: T[], reduction: number): T[] { let i = 0 const newData: T[] = [] while (i < data.length) { - newData.push(data[i]) + const item = data[i] + if (item !== undefined) { + newData.push(item) + } i += reduction } return newData diff --git a/script/util/process.ts b/script/util/process.ts index ce80dc4ce..d017db756 100644 --- a/script/util/process.ts +++ b/script/util/process.ts @@ -2,7 +2,7 @@ /** * @throws Will invoke ```process.exit()``` */ -export function exitWith(msg: string) { +export function exitWith(msg: string): never { console.error(msg) process.exit() } \ No newline at end of file diff --git a/script/zip.sh b/script/zip.sh index 2491e975c..b36efda56 100755 --- a/script/zip.sh +++ b/script/zip.sh @@ -6,12 +6,20 @@ FOLDER=$( ) TARGET_PATH="${FOLDER}/aaa" -COPYFILE_DISABLE=1 tar -zcvf ${TARGET_PATH} \ - --exclude=dist*/ \ - --exclude=.git/ \ - --exclude=package-lock.json \ - --exclude=node_modules \ - --exclude=firefox_dev*/ \ - --exclude=market_packages \ - --exclude=aaa \ - ./ +EXCLUDE_ARGS="" + +if [ -f "${FOLDER}/.gitignore" ]; then + while IFS= read -r line || [ -n "$line" ]; do + [[ -z "$line" || "$line" =~ ^# ]] && continue + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$line" ] && continue + + pattern="${line#/}" + EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=${pattern}" + done < "${FOLDER}/.gitignore" +fi + +EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=.git" + +cd "${FOLDER}" +COPYFILE_DISABLE=1 tar -zcf ${TARGET_PATH} ${EXCLUDE_ARGS} ./ 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..598d04697 --- /dev/null +++ b/src/api/chrome/notifications.ts @@ -0,0 +1,25 @@ +import { IS_MV3 } from "@util/constant/environment" +import { handleError } from "./common" + +type NotificationTopic = 'time' + +export async function createNotification( + topic: NotificationTopic, + options: MakeRequired +): Promise { + if (IS_MV3) { + return await chrome.notifications.create(topic, options) + } else { + return new Promise((resolve, reject) => { + chrome.notifications.create(topic, options, (id: string) => { + const error = handleError('createNotification') + if (error) { + reject(new Error(error)) + } else { + resolve(id) + } + }) + }) + } +} + 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 3f40223cb..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,61 +7,23 @@ export function getRuntimeName(): string { return chrome.runtime.getManifest().name } -/** - * Fix proxy data failed to serialized in Firefox - */ -function cloneData(data: T | undefined): T | undefined { - if (data === undefined) return undefined - try { - return JSON.parse(JSON.stringify(data)) - } catch (cloneError) { - console.warn("Failed clone data", cloneError) - return data - } -} - -export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: T): Promise { - const request: timer.mq.Request = { code, data: cloneData(data) } - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - // timeout: no response from runtime - resolve(undefined) - }, 10_000) - try { - chrome.runtime.sendMessage(request, (response: timer.mq.Response) => { - clearTimeout(timeout) - handleError('sendMsg2Runtime') - const resCode = response?.code - resCode === 'fail' && reject(new Error(response?.msg || 'Unknown error')) - resCode === 'success' && resolve(response.data) - }) - } catch (e) { - clearTimeout(timeout) - reject('Failed to send message: ' + (e as Error)?.message || 'Unknown error') - } - }) +export function getIconUrl(): string { + return getUrl('static/images/icon.png') } -/** - * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid - * And sending messages to the runtime will be failed - */ -export async function trySendMsg2Runtime(code: timer.mq.ReqCode, data?: Req): Promise { - try { - return await sendMsg2Runtime(code, data) - } catch { - // ignored - } -} - -export function onRuntimeMessage(handler: ChromeMessageHandler): void { +export function onRuntimeMessage(handler: ChromeMessageHandler): void { // Be careful!!! // Can't use await/async in callback parameter - chrome.runtime.onMessage.addListener((message: timer.mq.Request, sender: chrome.runtime.MessageSender, sendResponse: timer.mq.Callback) => { - handler(message, sender).then((response: timer.mq.Response) => { - if (response.code === 'ignore') return - sendResponse(response) - }) + chrome.runtime.onMessage.addListener((message: 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/tab.ts b/src/api/chrome/tab.ts index a0d36d3c8..79c9e6b27 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -18,26 +18,7 @@ export function getTab(id: number): Promise { })) } -export function resetTabUrl(tabId: number, url: string): Promise { - return new Promise(resolve => chrome.tabs.update(tabId, { - url: url, - highlighted: true, - }, () => resolve())) -} - -export async function getRightOf(target: ChromeTab): Promise { - if (!target) return - const { windowId, index } = target - return new Promise(resolve => chrome.tabs.query({ windowId }, tabs => { - const rightTab = tabs - ?.sort?.((a, b) => (a?.index ?? -1) - (b?.index ?? -1)) - ?.filter?.(t => t.index > index) - ?.[0] - resolve(rightTab) - })) -} - -export function getCurrentTab(): Promise { +function getCurrentTab(): Promise { return new Promise(resolve => chrome.tabs.getCurrent(tab => { handleError("getCurrentTab") resolve(tab) @@ -82,27 +63,33 @@ export function listTabs(query?: chrome.tabs.QueryInfo): Promise { })) } -export function sendMsg2Tab(tabId: number, code: timer.mq.ReqCode, data?: T): Promise { - const request: timer.mq.Request = { code, data } +export function sendMsg2Tab(tabId: number, code: C, data?: tt4b.tab.ReqData): Promise | undefined> { + const request: tt4b.tab.Request = { code, data: data as tt4b.tab.ReqData } return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject('sendMsg2Tab timeout'), 2000) - chrome.tabs.sendMessage, timer.mq.Response>(tabId, request, response => { + chrome.tabs.sendMessage, tt4b.tab.Response>(tabId, request, response => { const sendError = handleError('sendMsg2Tab') clearTimeout(timeout) - const resCode = response?.code - resCode === 'success' && resolve(response.data) - reject(new Error(response?.msg ?? sendError ?? 'Unknown error')) + if (response?.code === 'success') { + resolve(response.data as tt4b.tab.ResData | undefined) + return + } + if (response?.code === 'fail') { + reject(new Error(response.msg ?? sendError ?? 'Unknown error')) + return + } + reject(new Error(sendError ?? 'Unknown error')) }) }) } -export async function trySendMsg2Tab( +export async function trySendMsg2Tab( tabId: number, - code: timer.mq.ReqCode, - data?: T -): Promise { + code: C, + data?: tt4b.tab.ReqData +): Promise | undefined> { try { - return await sendMsg2Tab(tabId, code, data) + return await sendMsg2Tab(tabId, code, data) } catch (e) { console.warn(`Errored to send message to tab: tabId=${tabId}, code=${code}, data=${JSON.stringify(data)}`, e) return Promise.resolve(undefined) diff --git a/src/api/chrome/tabGroups.ts b/src/api/chrome/tabGroups.ts index 42f490c87..2a748e6ad 100644 --- a/src/api/chrome/tabGroups.ts +++ b/src/api/chrome/tabGroups.ts @@ -67,18 +67,3 @@ export function isValidGroup(groupId?: number): groupId is number { return false } } - -export const cvtGroupColor = (color?: `${chrome.tabGroups.Color}`): string => { - switch (color) { - case 'grey': return '#5F6369' - case 'blue': return '#1974E8' - case 'yellow': return '#F9AB03' - case 'red': return '#DA3025' - case 'green': return '#198139' - case 'pink': return '#D01984' - case 'purple': return '#A143F5' - case 'cyan': return '#027B84' - case 'orange': return '#FA913E' - default: return '#000' - } -} diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index 8c29b079d..3b669d6b9 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -1,93 +1,37 @@ -import { IS_ANDROID, IS_FIREFOX } 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([]) +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.getAll(windows => { - handleError("listAllWindows") - resolve(windows || []) - })) -} - -export function isNoneWindowId(windowId: number) { - if (IS_ANDROID) { - return false - } - return !windowId || windowId === chrome.windows.WINDOW_ID_NONE + return new Promise(resolve => chrome.windows.getLastFocused( + { windowTypes: ['normal'] }, + ({ id }) => { + handleError('getLastFocusedId') + resolve(id) + }, + )) } -/** - * Reduce invoking to improve memory leak of Firefox - * - * @see https://github.com/sheepzh/time-tracker-4-browser/issues/599 - */ -class FocusedWindowCtx { - last?: number | undefined - listened: boolean = false - windowsTypes: `${chrome.windows.WindowType}`[] - - constructor(windowTypes: `${chrome.windows.WindowType}`[]) { - this.windowsTypes = windowTypes - } - - async apply(): Promise { - if (IS_ANDROID) { - return undefined - } - if (this.last) { - return isNoneWindowId(this.last) ? undefined : this.last - } - // init - this.last = await this.getInner() - if (!this.listened) { - // filter argument is not supported for Firefox - // @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows/onFocusChanged#addlistener_syntax - IS_FIREFOX - ? chrome.windows.onFocusChanged.addListener(wid => this.last = wid) - : chrome.windows.onFocusChanged.addListener(wid => this.last = wid, { windowTypes: this.windowsTypes }) - this.listened = true - } - return this.last - } - - private getInner(): Promise { - return new Promise(resolve => chrome.windows.getLastFocused( - // Only find normal window - { windowTypes: ['normal'] }, - window => { - handleError('getFocusedNormalWindow') - const { focused, id } = window - if (!focused || !id || isNoneWindowId(id)) { - resolve(undefined) - } else { - resolve(id) - } - } - )) - } +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) + })) } -const context = new FocusedWindowCtx(['normal']) - -export const getFocusedNormalWindowId = () => context.apply() -export async function getWindow(id: number): Promise { - if (IS_ANDROID) { - return - } - return new Promise(resolve => chrome.windows.get(id, win => resolve(win))) +export function isNoneWindowId(windowId: number | undefined) { + return windowId === undefined || windowId === chrome.windows.WINDOW_ID_NONE } -type _Handler = (windowId: number) => void - -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..f70f76586 100644 --- a/src/api/crowdin.ts +++ b/src/api/crowdin.ts @@ -5,8 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { CROWDIN_PROJECT_ID } from "@util/constant/url" -import { fetchGet } from './http' +import { createArrayGuard, createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' +import { CROWDIN_PROJECT_ID } from "../util/constant/url" + +type ListResponse = { + data: { data: T }[] +} + +const createListRespGuard = (itemGuard: TypeGuard) => createObjectGuard>({ + data: createArrayGuard(createObjectGuard({ data: itemGuard })) +}) /** * Used to obtain translation status @@ -21,37 +29,55 @@ export type TranslationStatusInfo = { translationProgress: number } -export type MemberInfo = { +const isStatusResp = createListRespGuard( + createObjectGuard({ + languageId: isString, + translationProgress: isInt, + }) +) + +type MemberInfo = { username: string joinedAt: string avatarUrl: string } +const isMembersResp = createListRespGuard( + createObjectGuard({ + username: isString, + joinedAt: isString, + avatarUrl: isString, + }) +) + export async function getTranslationStatus(): Promise { const limit = 500 - const auth = `Bearer ${PUBLIC_TOKEN}` const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/languages/progress?limit=${limit}` - const response = await fetchGet(url, { headers: { "Authorization": auth } }) - const data: { data: { data: TranslationStatusInfo }[] } = await response.json() - return data.data.map(i => i.data) + return await getList(url, isStatusResp) } export async function getMembers(): Promise { const result: MemberInfo[] = [] - const auth = `Bearer ${PUBLIC_TOKEN}` - const limit = 10 let offset = 0 while (true) { const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/members?limit=${limit}&offset=${offset}` - const response = await fetchGet(url, { headers: { "Authorization": auth } }) - const data: { data: { data: MemberInfo }[] } = await response.json() - const newItems = data?.data?.map(i => i.data) ?? [] + const newItems = await getList(url, isMembersResp) result.push(...newItems) - if (newItems.length < limit) break offset += limit } return result -} \ No newline at end of file +} + +async function getList(url: string, respGuard: TypeGuard>): Promise { + const resp = await fetch(url, { + method: 'GET', + headers: { "Authorization": `Bearer ${PUBLIC_TOKEN}` }, + }) + const json = await resp.json() + if (respGuard(json)) return json.data.map(i => i.data) + console.warn('Unexpected response from Crowdin API', json) + return [] +} diff --git a/src/api/gist.ts b/src/api/gist.ts index 86edd8bea..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..14e3744df --- /dev/null +++ b/src/api/sw/whitelist.ts @@ -0,0 +1,7 @@ +import { sendMsg2Runtime } from "./common" + +export const listWhitelist = () => sendMsg2Runtime('whitelist.all') + +export const addWhitelist = (white: string) => sendMsg2Runtime('whitelist.add', white) + +export const deleteWhitelist = (white: string) => sendMsg2Runtime('whitelist.delete', white) diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts index 6e445d419..d8cb042d3 100644 --- a/src/api/web-dav.ts +++ b/src/api/web-dav.ts @@ -3,7 +3,7 @@ * * Testing with server implemented by https://github.com/svtslv/webdav-cli */ -import { encode } from '@util/base64' +import { encodeBase64 } from '../util/encode' import { fetchDelete, fetchGet } from './http' // Only support password for now @@ -22,7 +22,7 @@ const authHeaders = (auth: WebDAVAuth): Headers => { const type = auth?.type const headers = new Headers() if (type === 'password') { - headers.set('Authorization', `Basic ${encode(`${auth?.username}:${auth?.password}`)}`) + headers.set('Authorization', `Basic ${encodeBase64(`${auth?.username}:${auth?.password}`)}`) } return headers } diff --git a/src/background/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..5ea65c59c 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_REPORT_ROUTE } from "@/shared/route" import { onIconClick } from "@api/chrome/action" import { createContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" import { createTab } from "@api/chrome/tab" import { locale } from "@i18n" import { t2Chrome } from "@i18n/chrome/t" -import { IS_ANDROID, IS_FIREFOX, IS_MV3, IS_SAFARI } from "@util/constant/environment" +import { IS_ANDROID, IS_MV3, IS_SAFARI } from "@util/constant/environment" import { CHANGE_LOG_PAGE, GITHUB_ISSUE_ADD, SOURCE_CODE_PAGE, TU_CAO_PAGE, getAppPageUrl, getGuidePageUrl, } from "@util/constant/url" -import { OPTION_ROUTE, REPORT_ROUTE } from "../pages/app/router/constants" const APP_PAGE_URL = getAppPageUrl() @@ -36,13 +36,6 @@ function titleOf(prefixEmoji: string, title: string) { } } -const sidebarProps: ChromeContextMenuCreateProps = { - id: getRuntimeId() + '_timer_menu_item_sidebar', - title: titleOf('🖱️', t2Chrome(msg => msg.base.sidebar)), - onclick: () => browser.sidebarAction.open(), - ...baseProps, -} - const allFunctionProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_app_link', title: titleOf('🏷️', t2Chrome(msg => msg.base.allFunction)), @@ -53,7 +46,7 @@ const allFunctionProps: ChromeContextMenuCreateProps = { const optionPageProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_option_link', title: titleOf('🥰', t2Chrome(msg => msg.base.option)), - onclick: () => createTab(APP_PAGE_URL + '#' + OPTION_ROUTE), + onclick: () => createTab(getAppPageUrl(APP_OPTION_ROUTE)), ...baseProps } @@ -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_REPORT_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/alarm-manager.ts b/src/background/alarm-manager.ts index e3728a8a2..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,21 +42,18 @@ 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) } }) } @@ -66,7 +63,7 @@ class AlarmManager { * * @param interval mills */ - setInterval(outerName: string, interval: number, handler: _Handler): void { + async setInterval(outerName: string, interval: number, handler: _Handler): Promise { if (!interval || !handler) { return } @@ -79,13 +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 } @@ -98,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 8175027c1..84618f279 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 { getFocusedNormalWindowId } from "@api/chrome/window" -import statDatabase from "@db/stat-database" -import optionHolder from "@service/components/option-holder" -import whitelistHolder from "@service/whitelist/holder" +import { 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 windowId = await getFocusedNormalWindowId() - if (!windowId) { - return undefined - } - const tabs = await listTabs({ active: true, windowId }) - // Fix #131 - // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG +async function findActiveTab(windowId?: number): Promise { + windowId ??= await 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 badgeTextManager = new BadgeManager() -export default badgeManager +export default badgeTextManager diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 43551fc68..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 { getSite } from "@service/site-service" -import timelineThrottler from '@service/throttler/timeline-throttler' -import whitelistHolder from "@service/whitelist/holder" -import { getAppPageUrl } from "@util/constant/url" -import { extractFileHost, extractHostname } from "@util/pattern" +import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" +import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" +import { extractSiteName } from "@util/site" import badgeManager from "./badge-manager" -import { collectIconAndAlias } from "./icon-and-alias-collector" import MessageDispatcher from "./message-dispatcher" +import { saveAlias, saveIconUrl } from "./service/site-service" +import { incVisitCount } from './track-server/normal' -const handleOpenAnalysisPage = (sender: ChromeMessageSender) => { - const { tab, url } = sender || {} - if (!url) return - const host = extractFileHost(url) || extractHostname(url)?.host - const newTabUrl = getAppPageUrl(ANALYSIS_ROUTE, { host }) +function isUrl(title: string) { + return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') +} - const tabIndex = tab?.index - const newTabIndex = tabIndex ? tabIndex + 1 : undefined - createTab({ url: newTabUrl, index: newTabIndex }) +async function collectAlias(key: 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,26 +68,7 @@ const handleInjected = async (sender: ChromeMessageSender) => { */ export default function init(dispatcher: MessageDispatcher) { dispatcher - // Judge is in whitelist - .register<{ host?: string, url?: string }, boolean>('cs.isInWhitelist', ({ host, url } = {}) => !!host && !!url && whitelistHolder.contains(host, url)) - // Need to print the information of today - .register('cs.printTodayInfo', async () => { - const option = await optionHolder.get() - return !!option.printInConsole - }) - .register('cs.getLimitedRules', url => limitService.getLimited(url)) - .register('cs.getRelatedRules', url => limitService.getRelated(url)) - .register('cs.openAnalysis', (_, sender) => handleOpenAnalysisPage(sender)) - .register('cs.openLimit', (_, sender) => handleOpenLimitPage(sender)) - .register('cs.onInjected', async (_, sender) => handleInjected(sender)) + .register('cs.injected', (_, sender) => handleInjected(sender)) // Get sites which need to count run time - .register('cs.getRunSites', async url => { - const { host } = extractHostname(url) || {} - if (!host) return null - const site: timer.site.SiteKey = { host, type: 'normal' } - const exist = await getSite(site) - return exist?.run ? site : null - }) - .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible) - .register('cs.timelineEv', ev => timelineThrottler.saveEvent(ev)) + .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible) } \ No newline at end of file diff --git a/src/background/data-cleaner.ts b/src/background/data-cleaner.ts index 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 63% rename from src/database/site-cate-database.ts rename to src/background/database/cate-database.ts index feb063b00..9c0b69336 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) { @@ -66,7 +53,7 @@ class SiteCateDatabase extends BaseDatabase { } const id = (Object.keys(items || {}).map(k => parseInt(k)).sort().reverse()?.[0] ?? 0) + 1 - items[id] = { n: name || items[id]?.n } + items[id] = { n: name ?? items[id]?.n } await this.saveItems(items) return { name, id } @@ -86,15 +73,6 @@ class SiteCateDatabase extends BaseDatabase { await this.saveItems(items) } - async importData(data: any): Promise { - let toImport = data[KEY] as Items - // Not import - if (typeof toImport !== 'object') return - const exists: Items = await this.getItems() - migrate(exists, toImport) - this.setByKey(KEY, exists) - } - async delete(id: number): Promise { const items = await this.getItems() @@ -104,6 +82,6 @@ class SiteCateDatabase extends BaseDatabase { } } -const siteCateDatabase = new SiteCateDatabase() +const cateDatabase = new CateDatabase() -export default siteCateDatabase \ No newline at end of file +export default cateDatabase \ No newline at end of file diff --git a/src/database/common/base-database.ts b/src/background/database/common/base-database.ts similarity index 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..531e0ea76 --- /dev/null +++ b/src/background/database/common/indexed-storage.ts @@ -0,0 +1,327 @@ +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> { + private DB_NAME = `tt4b_${chrome.runtime.id}` as const + + private db: IDBDatabase | undefined + private static initPromises = new Map>() + + abstract indexes: Index[] + abstract key: Key | Key[] + abstract table: Table + + protected async initDb(): Promise { + if (this.db) return this.db + + let initPromise = BaseIDBStorage.initPromises.get(this.table) + if (!initPromise) { + initPromise = this.doInitDb() + BaseIDBStorage.initPromises.set(this.table, initPromise) + } + + try { + this.db = await initPromise + this.setupDbCloseHandler(this.db) + return this.db + } catch (error) { + BaseIDBStorage.initPromises.delete(this.table) + throw error + } + } + + private setupDbCloseHandler(db: IDBDatabase): void { + db.onversionchange = () => db.close() + + db.onclose = () => { + if (this.db !== db) return + + this.db = undefined + BaseIDBStorage.initPromises.delete(this.table) + } + } + + private async doInitDb(): Promise { + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + + const 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")) + }) + + return checkDb + } + + // Only used for testing, be careful when using in production + public async clear(): Promise { + await this.withStore(store => store.clear(), 'readwrite') + } + + async upgrade(): Promise { + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + + const checkDb = await new Promise((resolve, reject) => { + const checkRequest = factory.open(this.DB_NAME) + checkRequest.onsuccess = () => resolve(checkRequest.result) + checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) + checkRequest.onblocked = () => { + console.warn(`Database check blocked for "${this.table}" (DB: ${this.DB_NAME}), waiting for other connections to close`) + } + }) + + const storeExisted = checkDb.objectStoreNames.contains(this.table) + const needUpgrade = !storeExisted || this.needUpgradeIndexes(checkDb) + + if (!needUpgrade) { + checkDb.close() + return + } + + const currentVersion = 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'}` + ) + } + }) + } + + private needUpgradeIndexes(db: IDBDatabase): boolean { + try { + const transaction = db.transaction(this.table, 'readonly') + const store = transaction.objectStore(this.table) + const indexNames = store.indexNames + + for (const index of this.indexes) { + const { key } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!indexNames.contains(idxName)) { + return true + } + } + return false + } catch (e) { + console.error("Failed to check indexes", e) + return true + } + } + + private createIndexes(store: IDBObjectStore) { + const existingIndexes = store.indexNames + + for (const index of this.indexes) { + const { key, unique } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!existingIndexes.contains(idxName)) { + store.createIndex(idxName, key, { unique }) + } + } + } + + protected async withStore(operation: (store: IDBObjectStore) => 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 58% rename from src/database/limit-database.ts rename to src/background/database/limit-database.ts index f8327bd3e..b2a3911d8 100644 --- a/src/database/limit-database.ts +++ b/src/background/database/limit-database.ts @@ -5,9 +5,13 @@ * https://opensource.org/licenses/MIT */ +import { isOptionalInt, isRecord, isVector2 } from '@util/guard' import { formatTimeYMD, MILL_PER_DAY } from "@util/time" +import { createArrayGuard, createGuard, createObjectGuard, createOptionalGuard, isBoolean, isInt, isString } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" +import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' const KEY = REMAIN_WORD_PREFIX + 'LIMIT' @@ -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: createOptionalGuard(isBoolean), + locked: createOptionalGuard(isBoolean), + weekdays: createOptionalGuard(createArrayGuard(createGuard(val => isInt(val) && val >= 0 && val <= 6))), + allowDelay: createOptionalGuard(isBoolean), + periods: createOptionalGuard(createArrayGuard(isVector2)), +}) + +const isValidImportRows = createArrayGuard(isValidRow) + type ItemValue = { /** * ID @@ -121,7 +145,8 @@ const cvtItem2Rec = (item: ItemValue): LimitRecord => { type Items = Record -function migrate(exist: Items, toMigrate: any) { +function migrate(exist: Items, toMigrate: unknown) { + if (!isRecord(toMigrate)) return const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 Object.values(toMigrate).forEach((value, idx) => { const id = idBase + idx @@ -134,12 +159,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 +200,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 +257,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 +269,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..4b6403d89 --- /dev/null +++ b/src/background/database/stat-database/classic.ts @@ -0,0 +1,274 @@ +import { log } from '@/common/logger' +import { isOptionalInt } from '@util/guard' +import { escapeRegExp } from '@util/pattern' +import { isNotZeroResult } from '@util/stat' +import { createObjectGuard } from 'typescript-guard' +import BaseDatabase from '../common/base-database' +import { REMAIN_WORD_PREFIX } from '../common/constant' +import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroResult } from './common' +import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' +import type { StatCondition, StatDatabase } from './types' + +/** + * 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..e39db8d98 --- /dev/null +++ b/src/background/database/stat-database/index.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { isOptionalInt } from '@util/guard' +import { isNotZeroResult } from '@util/stat' +import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' +import { extractNamespace, isExportData, isLegacyVersion } from '../common/migratable' +import { StorageHolder } from '../common/storage-holder' +import type { BrowserMigratable, StorageMigratable } from '../types' +import { ClassicStatDatabase, parseImportData } from './classic' +import { IDBStatDatabase } from './idb' +import type { StatCondition, StatDatabase } from './types' + +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(), + }) + private current = () => 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.forceUpdate(...rows) + } + + async exportData(): Promise { + return this.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/classic.ts b/src/background/database/timeline-database/classic.ts new file mode 100644 index 000000000..56e0877c9 --- /dev/null +++ b/src/background/database/timeline-database/classic.ts @@ -0,0 +1,66 @@ +import BaseDatabase from '../common/base-database' +import { REMAIN_WORD_PREFIX } from '../common/constant' +import type { TimelineCondition, TimelineDatabase } from './types' + +const DB_KEY = REMAIN_WORD_PREFIX + 'TL' + +type Item = { + // start + s: number + // duration + d: number +} + +type TimelineData = { + [date: string]: { + [host: string]: Item[] + } +} + +function filter(ticks: tt4b.timeline.Tick[], cond?: TimelineCondition): tt4b.timeline.Tick[] { + if (!cond) { + return ticks + } + const { start, host } = cond + + return ticks.filter(tick => { + if (start && tick.start < start) { + return false + } + if (host && tick.host !== host) { + return false + } + return true + }) +} + +/** + * @deprecated Use IDBTimelineDatabase instead, this is for old version data migration + */ +export default class ClassicTimelineDatabase extends BaseDatabase implements TimelineDatabase { + + private async getData(): Promise { + const data = await this.storage.getOne(DB_KEY) + return data ?? {} + } + + async batchSave(_ticks: tt4b.timeline.Tick[]): Promise { + console.warn("ClassicTimelineDatabase is deprecated, data will not be saved to it. This invoking is not expected") + return + } + + async select(cond?: TimelineCondition): Promise { + const data = await this.getData() + const ticks: tt4b.timeline.Tick[] = [] + Object.values(data).forEach(hostData => { + Object.entries(hostData).forEach(([host, items]) => { + items.forEach(({ s: start, d: duration }) => ticks.push({ host, start, duration })) + }) + }) + return filter(ticks, cond) + } + + async clear(): Promise { + await this.storage.remove(DB_KEY) + } +} diff --git a/src/background/database/timeline-database/idb.ts b/src/background/database/timeline-database/idb.ts new file mode 100644 index 000000000..a99bbfb16 --- /dev/null +++ b/src/background/database/timeline-database/idb.ts @@ -0,0 +1,130 @@ +import { MILL_PER_DAY, MILL_PER_SECOND } from '@util/time' +import { + BaseIDBStorage, iterateCursor, req2Promise, + type Index, type IndexResult, type Key, type Table, +} from '../common/indexed-storage' +import type { TimelineCondition, TimelineDatabase } from './types' + +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() + } + } +} + +export default class IDBTimelineDatabase extends BaseIDBStorage implements TimelineDatabase { + indexes: Index[] = [ + 'host', 'start', + ] + key: Key | Key[] = ['host', 'start'] + table: Table = 'timeline' + private cleanThrottle = new CleanThrottle() + + batchSave(ticks: 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 + } + + private judgeIndex(store: IDBObjectStore, cond?: TimelineCondition): IndexResult { + const { host, start } = cond ?? {} + if (host) { + return { + cursorReq: this.assertIndexCursor(store, 'host', IDBKeyRange.only(host)), + coverage: { host: true }, + } + } else if (start !== undefined && start > 0) { + return { + cursorReq: this.assertIndexCursor(store, 'start', IDBKeyRange.lowerBound(start, false)), + coverage: { start: true }, + } + } else { + return { cursorReq: store.openCursor() } + } + } +} \ No newline at end of file diff --git a/src/background/database/timeline-database/index.ts b/src/background/database/timeline-database/index.ts new file mode 100644 index 000000000..e7b43a8c0 --- /dev/null +++ b/src/background/database/timeline-database/index.ts @@ -0,0 +1,26 @@ +import ClassicTimelineDatabase from './classic' +import IDBTimelineDatabase from './idb' +import type { TimelineCondition, TimelineDatabase } from './types' + +class TimelineDatabaseWrapper implements TimelineDatabase { + private classic = new ClassicTimelineDatabase() + private idb = new IDBTimelineDatabase() + + batchSave(ticks: tt4b.timeline.Tick[]): Promise { + return this.idb.batchSave(ticks) + } + + select(cond?: TimelineCondition): Promise { + return this.idb.select(cond) + } + + async migrateFromClassic(): Promise { + const ticks = await this.classic.select() + await this.idb.batchSave(ticks) + await this.classic.clear() + } +} + +const timelineDatabase = new TimelineDatabaseWrapper() + +export default timelineDatabase \ No newline at end of file diff --git a/src/background/database/timeline-database/types.ts b/src/background/database/timeline-database/types.ts new file mode 100644 index 000000000..6b770e24c --- /dev/null +++ b/src/background/database/timeline-database/types.ts @@ -0,0 +1,12 @@ +export type TimelineCondition = { + host?: string + /** + * Start time in milliseconds, inclusive + */ + start?: number +} + +export interface TimelineDatabase { + batchSave(ticks: tt4b.timeline.Tick[]): Promise + select(cond?: TimelineCondition): Promise +} \ 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 56% rename from src/database/whitelist-database.ts rename to src/background/database/whitelist-database.ts index bf12e7795..47c1ccd2d 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 || []) @@ -36,30 +40,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] - const newValue = changeInfo?.newValue - const whitelists: string[] = Array.isArray(newValue) ? newValue.map(n => new String(n).toString()) : [] - changeInfo && listener(whitelists) - } - chrome.storage.onChanged.addListener(storageListener) + 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/icon-and-alias-collector.ts b/src/background/icon-and-alias-collector.ts deleted file mode 100644 index 8c1516fd3..000000000 --- a/src/background/icon-and-alias-collector.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getTab } from "@api/chrome/tab" -import { saveAlias, saveIconUrl } from "@service/site-service" -import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" -import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" -import { extractSiteName } from "@util/site" - -function isUrl(title: string) { - return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') -} - -async function collectAlias(key: timer.site.SiteKey, tabTitle: string) { - if (!tabTitle) return - if (isUrl(tabTitle)) return - const siteName = extractSiteName(tabTitle, key.host) - siteName && await saveAlias(key, siteName, true) -} - -/** - * Process the tab - */ -async function processTabInfo(tab: ChromeTab): Promise { - let { favIconUrl, url, title } = tab - if (!url || !title) return - if (isBrowserUrl(url)) return - const hostInfo = extractHostname(url) - const host = hostInfo.host - if (!host) return - // localhost hosts with Chrome use cache, so keep the favIcon url undefined - IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) - const siteKey: timer.site.SiteKey = { host, type: 'normal' } - favIconUrl && await saveIconUrl(siteKey, favIconUrl) - !IS_ANDROID - && !isBrowserUrl(url) - && isHomepage(url) - && await collectAlias(siteKey, title) -} - -/** - * Collect the favicon of host - */ -export const collectIconAndAlias = async (tabId: number) => { - if (IS_SAFARI || IS_ANDROID) return - const tab = await getTab(tabId) - tab && processTabInfo(tab) -} \ No newline at end of file diff --git a/src/background/index.ts b/src/background/index.ts index 1c6fbfa00..c717dbaa1 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -5,32 +5,25 @@ * https://opensource.org/licenses/MIT */ -import { listTabs, trySendMsg2Tab } from "@api/chrome/tab" -import { isNoneWindowId, onNormalWindowFocusChanged } from "@api/chrome/window" -import optionHolder from "@service/components/option-holder" -import { isBrowserUrl } from "@util/pattern" -import { openLog } from "../common/logger" -import BackupScheduler from "./backup-scheduler" +import { trySendMsg2Tab } from "@api/chrome/tab" +import { initBrowserAction, initSidePanel } from './action' import badgeTextManager from "./badge-manager" -import initBrowserAction from "./browser-action-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" -import handleInstall from "./install-handler" +import { initAfterInstalled } from './install-handler' import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" -import VersionMigrator from "./migrator" -import 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,11 +40,8 @@ initCsHandler(messageDispatcher) // Start server initTrackServer(messageDispatcher) -// Process version -new VersionMigrator().init() - -// Backup scheduler -new BackupScheduler().init() +// scheduler +initScheduler() // Manage the context menus initWhitelistMenuManager() @@ -65,21 +55,5 @@ new TabListener() .onUpdated((tabId, { audible }) => audible !== undefined && trySendMsg2Tab(tabId, 'syncAudible', audible)) .start() -handleInstall() - // Start message dispatcher messageDispatcher.start() - -// Listen window focus changed -onNormalWindowFocusChanged(async windowId => { - if (isNoneWindowId(windowId)) return - const tabs = await listTabs({ windowId, active: true }) - tabs.forEach(tab => { - const { url, id: tabId } = tab - if (!url || isBrowserUrl(url) || !tabId) return - badgeTextManager.updateFocus({ url, tabId }) - }) -}) - -// listen permission change event -optionHolder.listenPermChange() \ No newline at end of file diff --git a/src/background/install-handler/index.ts b/src/background/install-handler/index.ts index 426ba1ab9..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 { 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() { - 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 ca81f6ecf..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 { batchSaveSiteCate } 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 batchSaveSiteCate(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..3c6767dc2 100644 --- a/src/background/migrator/index.ts +++ b/src/background/install-handler/version/index.ts @@ -5,12 +5,12 @@ * 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 IndexedDBMigrator from './indexed-migrator' import LocalFileInitializer from "./local-file-initializer" +import type { Migrator } from "./types" import WhitelistInitializer from "./whitelist-initializer" /** @@ -27,11 +27,11 @@ class VersionManager { new LocalFileInitializer(), new WhitelistInitializer(), new CateInitializer(), - new LimitRuleMigrator(), + new IndexedDBMigrator(), ) } - private onChromeInstalled(reason: ChromeOnInstalledReason) { + handle(reason: ChromeOnInstalledReason) { const version: string = getVersion() if (reason === 'update') { // Update, process the latest version, which equals to current version @@ -41,10 +41,8 @@ class VersionManager { this.processorChain.forEach(processor => processor.onInstall()) } } - - init() { - onInstalled(reason => this.onChromeInstalled(reason)) - } } -export default VersionManager \ No newline at end of file +const versionManager = new VersionManager() + +export default versionManager \ No newline at end of file diff --git a/src/background/install-handler/version/indexed-migrator.ts b/src/background/install-handler/version/indexed-migrator.ts new file mode 100644 index 000000000..63ee0169b --- /dev/null +++ b/src/background/install-handler/version/indexed-migrator.ts @@ -0,0 +1,32 @@ +import { BaseIDBStorage } from '@db/common/indexed-storage' +import { IDBStatDatabase } from '@db/stat-database/idb' +import timelineDatabase from '@db/timeline-database' +import IDBTimelineDatabase from '@db/timeline-database/idb' +import type { Migrator } from './types' + +async function upgradeIndexedDB() { + try { + const storages: BaseIDBStorage[] = [new IDBStatDatabase(), new IDBTimelineDatabase()] + for (const storage of storages) { + await storage.upgrade() + } + console.log('IndexedDB upgraded successfully') + } catch (error) { + console.error('Failed to upgrade IndexedDB', error) + } +} + +class IndexedMigrator implements Migrator { + onInstall(): void { + } + + async onUpdate(_version: string): Promise { + await upgradeIndexedDB() + + timelineDatabase.migrateFromClassic() + .then(() => console.log('Timeline data migrated to IndexedDB')) + .catch(e => console.error('Failed to migrate timeline data to IndexedDB', e)) + } +} + +export default IndexedMigrator \ No newline at end of file diff --git a/src/background/migrator/local-file-initializer.ts b/src/background/install-handler/version/local-file-initializer.ts similarity index 97% rename from src/background/migrator/local-file-initializer.ts rename to src/background/install-handler/version/local-file-initializer.ts index 47af11916..4a19e57fa 100644 --- a/src/background/migrator/local-file-initializer.ts +++ b/src/background/install-handler/version/local-file-initializer.ts @@ -9,7 +9,7 @@ import mergeRuleDatabase from "@db/merge-rule-database" import { t2Chrome } from "@i18n/chrome/t" import { saveAlias } from '@service/site-service' import { JSON_HOST, LOCAL_HOST_PATTERN, MERGED_HOST, PDF_HOST, PIC_HOST, TXT_HOST } from "@util/constant/remain-host" -import { type Migrator } from "./common" +import { type Migrator } from "./types" /** * Process the host of local files diff --git a/src/background/migrator/common.ts b/src/background/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..f248607fd 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -5,35 +5,153 @@ * 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)) + // 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 97% rename from src/util/psl/rules.json rename to src/background/psl/rules.json index cc2ec7f03..482160e4a 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,6 +245,7 @@ "c": { "adaptable": 1, "aiven": 1, + "base44": 1, "beget": { "c": { "*": 1 @@ -261,10 +263,12 @@ }, "l": 1 }, + "claude": 1, "clerk": 1, "clerkstage": 1, "cloudflare": 1, "convex": 1, + "corespeed": 1, "csb": { "c": { "preview": 1 @@ -293,7 +297,13 @@ }, "expo": { "c": { - "staging": 1 + "on": 1, + "staging": { + "c": { + "on": 1 + }, + "l": 1 + } }, "l": 1 }, @@ -315,6 +325,7 @@ "magicpatterns": 1, "medusajs": 1, "messerli": 1, + "miren": 1, "mocha": 1, "netlify": 1, "ngrok": 1, @@ -329,6 +340,7 @@ "on-fleek": 1, "ondigitalocean": 1, "onhercules": 1, + "pplx": 1, "railway": { "c": { "up": 1 @@ -350,6 +362,7 @@ } } }, + "shiptoday": 1, "snowflake": { "c": { "*": 1, @@ -360,6 +373,7 @@ } } }, + "spawnbase": 1, "sprites": 1, "streamlit": 1, "telebit": 1, @@ -522,7 +536,7 @@ "associates": 1, "at": { "c": { - "4": 1, + "0.0.0.4": 1, "123webseite": 1, "12hp": 1, "2ix": 1, @@ -789,6 +803,11 @@ "transfer-webapp": 1 } }, + "ap-southeast-7": { + "c": { + "transfer-webapp": 1 + } + }, "ca-central-1": { "c": { "airflow": { @@ -927,6 +946,11 @@ "transfer-webapp": 1 } }, + "mx-central-1": { + "c": { + "transfer-webapp": 1 + } + }, "sa-east-1": { "c": { "airflow": { @@ -1335,6 +1359,7 @@ "ezproxy": 1 } }, + "my": 1, "myspreadshop": 1, "transurl": { "c": { @@ -1360,16 +1385,16 @@ }, "bg": { "c": { - "0": 1, - "1": 1, - "2": 1, - "3": 1, - "4": 1, - "5": 1, - "6": 1, - "7": 1, - "8": 1, - "9": 1, + "0.0.0.0": 1, + "0.0.0.1": 1, + "0.0.0.2": 1, + "0.0.0.3": 1, + "0.0.0.4": 1, + "0.0.0.5": 1, + "0.0.0.6": 1, + "0.0.0.7": 1, + "0.0.0.8": 1, + "0.0.0.9": 1, "a": 1, "b": 1, "barsy": 1, @@ -1816,6 +1841,7 @@ }, "build": { "c": { + "shiptoday": 1, "v0": 1, "windsurf": 1 }, @@ -1974,6 +2000,7 @@ "instances": 1 } }, + "sryze": 1, "twmail": 1, "uk": 1, "us": 1 @@ -2117,7 +2144,18 @@ "es-1": 1 } }, - "convex": 1, + "begetcdn": { + "c": { + "*": 1 + } + }, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "diadem": 1, "elementor": 1, "emergent": 1, @@ -2148,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 @@ -2603,6 +2662,7 @@ } }, "180r": 1, + "1cooldns": 1, "1kapp": 1, "3utilities": 1, "4u": 1, @@ -3797,7 +3857,8 @@ "s3": 1, "s3-accesspoint": 1, "s3-accesspoint-fips": 1, - "s3-fips": 1 + "s3-fips": 1, + "s3-website": 1 } }, "emrappui-prod": 1, @@ -3819,7 +3880,8 @@ "s3": 1, "s3-accesspoint": 1, "s3-accesspoint-fips": 1, - "s3-fips": 1 + "s3-fips": 1, + "s3-website": 1 } }, "emrappui-prod": 1, @@ -4100,6 +4162,21 @@ }, "l": 1 }, + "atlassian-3p": { + "c": { + "*": 1 + } + }, + "atlassian-3p-us-gov-mod": { + "c": { + "*": 1 + } + }, + "atlassian-isolated-3p": { + "c": { + "*": 1 + } + }, "atmeta": 1, "auiusercontent": { "c": { @@ -4118,6 +4195,7 @@ "balena-devices": 1, "barsycenter": 1, "barsyonline": 1, + "base44-sandbox": 1, "blogdns": 1, "blogspot": 1, "blogsyte": 1, @@ -4125,6 +4203,7 @@ "bplaced": 1, "br": 1, "builtwithdark": 1, + "bumbleshrimp": 1, "cafjs": 1, "canva-apps": 1, "canva-hosted-embed": 1, @@ -4199,8 +4278,10 @@ "dattoweb": 1, "ddnsfree": 1, "ddnsgeek": 1, + "ddnsguru": 1, "ddnsking": 1, "de": 1, + "deployagent": 1, "deus-canvas": 1, "dev-myqnapcloud": 1, "devinapps": { @@ -4227,6 +4308,7 @@ "dopaas": 1, "drayddns": 1, "dreamhosters": 1, + "drive-platform": 1, "dsmynas": 1, "durumis": 1, "dyn-o-saur": 1, @@ -4246,6 +4328,8 @@ "dyndns-wiki": 1, "dyndns-work": 1, "dynns": 1, + "dynuddns": 1, + "dynuhosting": 1, "elasticbeanstalk": { "c": { "af-south-1": 1, @@ -4479,15 +4563,6 @@ "demo": 1 } }, - "joyent": { - "c": { - "cns": { - "c": { - "*": 1 - } - } - } - }, "jpn": 1, "kasserver": 1, "kozow": 1, @@ -4532,8 +4607,12 @@ "paas": 1 } }, - "mazeplay": 1, "messwithdns": 1, + "metaaiusercontent": { + "c": { + "*": 1 + } + }, "meteorapp": { "c": { "eu": 1 @@ -4621,13 +4700,13 @@ "outsystemscloud": 1, "ownprovider": 1, "pagespeedmobilizer": 1, - "pagexl": 1, "paywhirl": { "c": { "*": 1 } }, "pgfog": 1, + "pivohosting": 1, "pixolino": 1, "playstation-cloud": 1, "pleskns": 1, @@ -4694,7 +4773,7 @@ "c": { "test": { "c": { - "001": { + "0.0.0.1": { "c": { "*": 1 } @@ -4735,7 +4814,6 @@ "simple-url": 1, "simplesite": 1, "sinaapp": 1, - "skygearapp": 1, "smushcdn": 1, "space-to-rent": 1, "stackhero-network": 1, @@ -4781,6 +4859,7 @@ "us": 1, "us1-plenit": 1, "vipsinaapp": 1, + "vivenushop": 1, "vultrobjects": { "c": { "*": 1 @@ -4803,6 +4882,7 @@ "pages": 1 } }, + "wiredbladehosting": 1, "withgoogle": 1, "withyoutube": 1, "wixsite": 1, @@ -4842,7 +4922,12 @@ }, "l": 1 }, - "company": 1, + "company": { + "c": { + "mybox": 1 + }, + "l": 1 + }, "compare": 1, "computer": 1, "comsec": 1, @@ -5016,6 +5101,11 @@ "4lima": 1, "barsy": 1, "bplaced": 1, + "bwcloud-os-instance": { + "c": { + "*": 1 + } + }, "co": 1, "com": 1, "community-pro": 1, @@ -5024,7 +5114,6 @@ "dyn": 1 } }, - "dd-dns": 1, "ddnss": { "c": { "dyn": 1, @@ -5035,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": { @@ -5085,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, @@ -5115,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, @@ -5155,7 +5234,6 @@ }, "dev": { "c": { - "12chars": 1, "barsy": 1, "bearblog": 1, "botdash": 1, @@ -5171,11 +5249,76 @@ }, "crm": { "c": { + "aa": { + "c": { + "*": 1 + } + }, + "ab": { + "c": { + "*": 1 + } + }, + "ac": { + "c": { + "*": 1 + } + }, + "ad": { + "c": { + "*": 1 + } + }, + "ae": { + "c": { + "*": 1 + } + }, + "af": { + "c": { + "*": 1 + } + }, + "ci": { + "c": { + "*": 1 + } + }, "d": { "c": { "*": 1 } }, + "pa": { + "c": { + "*": 1 + } + }, + "pb": { + "c": { + "*": 1 + } + }, + "pc": { + "c": { + "*": 1 + } + }, + "pd": { + "c": { + "*": 1 + } + }, + "pe": { + "c": { + "*": 1 + } + }, + "pf": { + "c": { + "*": 1 + } + }, "w": { "c": { "*": 1 @@ -5284,6 +5427,7 @@ "ngrok-free": 1, "pages": 1, "panel": 1, + "payload": 1, "platter-app": 1, "r2": 1, "replit": { @@ -5329,6 +5473,7 @@ } }, "vercel": 1, + "vivenushop": 1, "webhare": { "c": { "*": 1 @@ -5342,17 +5487,7 @@ "dhl": 1, "diamonds": 1, "diet": 1, - "digital": { - "c": { - "cloudapps": { - "c": { - "london": 1 - }, - "l": 1 - } - }, - "l": 1 - }, + "digital": 1, "direct": { "c": { "libp2p": 1 @@ -5363,7 +5498,13 @@ "discount": 1, "discover": 1, "dish": 1, - "diy": 1, + "diy": { + "c": { + "discourse": 1, + "imagine": 1 + }, + "l": 1 + }, "dj": 1, "dk": { "c": { @@ -5551,6 +5692,7 @@ "on": 1 } }, + "intouch": 1, "tawk": { "c": { "p": 1 @@ -5616,10 +5758,27 @@ }, "eu": { "c": { + "amazonwebservices": { + "c": { + "on": { + "c": { + "eusc-de-east-1": { + "c": { + "cognito-idp": { + "c": { + "auth": 1 + } + } + } + } + } + } + } + }, "barsy": 1, "cloudns": 1, + "deuxfleurs": 1, "directwp": 1, - "diskstation": 1, "dogado": { "c": { "jelastic": 1 @@ -5799,6 +5958,7 @@ "gouv": 1, "greta": 1, "huissier-justice": 1, + "kdns": 1, "medecin": 1, "myspreadshop": 1, "nom": 1, @@ -5820,7 +5980,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, @@ -5972,7 +6140,6 @@ "gold": 1, "goldpoint": 1, "golf": 1, - "goo": 1, "goodyear": 1, "goog": { "c": { @@ -6219,7 +6386,7 @@ }, "hu": { "c": { - "2000": 1, + "0.0.7.208": 1, "agrar": 1, "bolt": 1, "casino": 1, @@ -6434,7 +6601,7 @@ "investments": 1, "io": { "c": { - "2038": 1, + "0.0.7.246": 1, "apigee": 1, "azurecontainer": { "c": { @@ -6492,6 +6659,7 @@ "darklang": 1, "dedyn": 1, "definima": 1, + "drive-platform": 1, "editorx": 1, "edu": 1, "edugit": 1, @@ -6530,6 +6698,8 @@ "l": 1 }, "jele": 1, + "keenetic": 1, + "kiloapps": 1, "lair": { "c": { "apps": 1 @@ -6700,7 +6870,6 @@ "it": { "c": { "123homepage": 1, - "12chars": 1, "16-b": 1, "32-b": 1, "64-b": 1, @@ -9405,8 +9574,13 @@ }, "kh": { "c": { - "*": 1 - } + "com": 1, + "edu": 1, + "gov": 1, + "net": 1, + "org": 1 + }, + "l": 1 }, "ki": { "c": { @@ -9484,6 +9658,7 @@ "co": 1, "daegu": 1, "daejeon": 1, + "eliv-api": 1, "eliv-cdn": 1, "eliv-dns": 1, "es": 1, @@ -9640,6 +9815,7 @@ } }, "joinmc": 1, + "keenetic": 1, "myfritz": 1, "mypep": 1, "nftstorage": { @@ -9837,12 +10013,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, @@ -10128,7 +10310,8 @@ "forgot": 1 } }, - "ispmanager": 1 + "ispmanager": 1, + "keenetic": 1 }, "l": 1 }, @@ -10178,13 +10361,13 @@ "azurefd": 1, "azurestaticapps": { "c": { - "1": 1, - "2": 1, - "3": 1, - "4": 1, - "5": 1, - "6": 1, - "7": 1, + "0.0.0.1": 1, + "0.0.0.2": 1, + "0.0.0.3": 1, + "0.0.0.4": 1, + "0.0.0.5": 1, + "0.0.0.6": 1, + "0.0.0.7": 1, "centralus": 1, "eastasia": 1, "eastus2": 1, @@ -10264,7 +10447,12 @@ "de5": 1, "debian": 1, "definima": 1, - "deno": 1, + "deno": { + "c": { + "sandbox": 1 + }, + "l": 1 + }, "dns-cloud": 1, "dns-dynamic": 1, "dnsalias": 1, @@ -10276,6 +10464,7 @@ "dynalias": 1, "dynathome": 1, "dynu": 1, + "dynuddns": 1, "dynv6": 1, "eating-organic": 1, "edgekey": 1, @@ -10395,12 +10584,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": { @@ -10449,6 +10639,7 @@ "server-on": 1, "shopselect": 1, "siteleaf": 1, + "spryt": 1, "square7": 1, "squares": 1, "srcf": { @@ -10485,10 +10676,18 @@ "uni5": 1, "usgovcloudapi": { "c": { + "core": { + "c": { + "blob": 1, + "file": 1, + "web": 1 + } + }, "servicebus": 1 } }, "usgovcloudapp": 1, + "usgovtrafficmanager": 1, "vpndns": 1, "vps-host": { "c": { @@ -10508,7 +10707,9 @@ "c": { "core": { "c": { - "blob": 1 + "blob": 1, + "file": 1, + "web": 1 } }, "servicebus": 1 @@ -11677,7 +11878,6 @@ "collegefan": 1, "couchpotatofries": 1, "ddnss": 1, - "diskstation": 1, "dnsalias": 1, "dnsdojo": 1, "doesntexist": 1, @@ -11780,6 +11980,7 @@ "freeddns": 1, "freedesktop": 1, "from-me": 1, + "fspages": 1, "game-host": 1, "gotdns": 1, "hatenadiary": 1, @@ -11836,6 +12037,7 @@ "read-books": 1, "readmyblog": 1, "routingthecloud": 1, + "roxa": 1, "selfip": 1, "sellsyourhome": 1, "servebbs": 1, @@ -11901,14 +12103,15 @@ "c": { "aem": 1, "codeberg": 1, + "deuxfleurs": 1, "heyflow": 1, "hlx": 1, + "mybox": 1, "pdns": 1, "plesk": 1, "prvcy": 1, "rocky": 1, - "statichost": 1, - "translated": 1 + "statichost": 1 }, "l": 1 }, @@ -11978,7 +12181,7 @@ "pictet": 1, "pictures": { "c": { - "1337": 1 + "0.0.5.57": 1 }, "l": 1 }, @@ -12339,7 +12542,6 @@ "prime": 1, "pro": { "c": { - "12chars": 1, "aaa": 1, "aca": 1, "acct": 1, @@ -12350,6 +12552,7 @@ "cpa": 1, "eng": 1, "jur": 1, + "keenetic": 1, "law": 1, "med": 1, "ngrok": 1, @@ -12891,7 +13094,12 @@ }, "shopping": 1, "shouji": 1, - "show": 1, + "show": { + "c": { + "ms": 1 + }, + "l": 1 + }, "si": { "c": { "f5": 1, @@ -12919,7 +13127,13 @@ } }, "co": 1, - "convex": 1, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "cpanel": 1, "cyon": 1, "fastvps": 1, @@ -12934,12 +13148,14 @@ "novecore": 1, "omniwe": 1, "opensocial": 1, + "piebox": 1, "platformsh": { "c": { "*": 1 } }, "preview": 1, + "sol": 1, "sourcecraft": 1, "square": 1, "srht": 1, @@ -12985,7 +13201,6 @@ "edu": 1, "gouv": 1, "org": 1, - "perso": 1, "univ": 1 }, "l": 1 @@ -13017,6 +13232,7 @@ "space": { "c": { "app-ionos": 1, + "deployagent": 1, "heiyu": 1, "hf": { "c": { @@ -13196,7 +13412,8 @@ "sydney": 1, "systems": { "c": { - "knightpoint": 1 + "knightpoint": 1, + "miren": 1 }, "l": 1 }, @@ -13340,7 +13557,7 @@ }, "to": { "c": { - "611": 1, + "0.0.2.99": 1, "com": 1, "edu": 1, "gov": 1, @@ -14152,9 +14369,34 @@ }, "wa": { "c": { + "aberdeen": 1, + "bainbridge-isl": 1, + "bellevue": 1, + "bremerton": 1, "cc": 1, + "centralia": 1, + "chehalis": 1, + "forks": 1, + "gig-harbor": 1, + "hoquiam": 1, "k12": 1, - "lib": 1 + "keyport": 1, + "kingston": 1, + "lib": 1, + "olympia": 1, + "port-angeles": 1, + "port-ludlow": 1, + "port-orchard": 1, + "port-townsend": 1, + "poulsbo": 1, + "redmond": 1, + "renton": 1, + "sea": 1, + "seattle": 1, + "sequim": 1, + "shelton": 1, + "silverdale": 1, + "yarrow-point": 1 }, "l": 1 }, @@ -14335,6 +14577,7 @@ "haugiang": 1, "health": 1, "hoabinh": 1, + "hue": 1, "hungyen": 1, "id": 1, "info": 1, @@ -14437,7 +14680,6 @@ "wine": 1, "winners": 1, "wme": 1, - "wolterskluwer": 1, "woodside": 1, "work": { "c": { @@ -14689,6 +14931,8 @@ "c": { "botdash": 1, "caffeine": 1, + "exe": 1, + "opentunnel": 1, "telebit": { "c": { "*": 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 78% rename from src/service/backup/web-dav/coordinator.ts rename to src/background/service/backup/web-dav/coordinator.ts index 4979b4dc7..07c116c04 100644 --- a/src/service/backup/web-dav/coordinator.ts +++ b/src/background/service/backup/web-dav/coordinator.ts @@ -3,10 +3,11 @@ import { type WebDAVAuth, type WebDAVContext, } from "@api/web-dav" import DateIterator from "@util/date-iterator" +import { getBirthday, parseTime } from '@util/time' import { processDir } from "../common" import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" -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 @@ -88,7 +91,7 @@ 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" @@ -118,7 +121,7 @@ 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..1c527b775 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 '@/background/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..8075ac771 --- /dev/null +++ b/src/background/service/notification/browser/notifier.ts @@ -0,0 +1,61 @@ +import { createNotification } from "@api/chrome/notifications" +import { hasPerm, requestPerm } from "@api/chrome/permission" +import { getIconUrl } from "@api/chrome/runtime" +import { t } from '@i18n' +import calendarMessages from "@i18n/message/common/calendar" +import metaMessages from "@i18n/message/common/meta" +import { formatPeriodCommon } from '@util/time' +import type { NotificationData, NotificationRequest, Notifier } from '../types' + +/** + * Send notification with `chrome.notifications` API + */ +export default class BrowserNotifier implements Notifier { + /** + * Test if the permission granted, if not granted, then try to grant + */ + private async assertPerm(): Promise { + const hasPermission = await hasPerm('notifications') + if (hasPermission) { + return undefined + } + + const granted = await requestPerm('notifications') + if (!granted) { + return "Notification permission is required but was denied" + } + + return undefined + } + + /** + * Send notification with summary + * + * @param option + * @param data + */ + async send(_: NotificationRequest, data: NotificationData): Promise { + const errMsg = await this.assertPerm() + if (errMsg) return errMsg + + const { + cycle, + meta: { locale }, + summary: { focus, visit, siteCount }, + } = data + + const appName = t(metaMessages, { key: msg => msg.name }, locale) + const calendar = t(calendarMessages, { key: cycle === 'daily' ? msg => msg.range.yesterday : msg => msg.range.lastWeek }, locale) + const title = `${appName} - ${calendar}` + const focusStr = formatPeriodCommon(focus, true) + + const message = `Focus time: ${focusStr}, Visits: ${visit}, Sites: ${siteCount}` + + await createNotification('time', { + type: 'basic', + iconUrl: getIconUrl(), + title, + message, + }) + } +} diff --git a/src/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 + 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/service/throttler/firefox-throttler.ts b/src/background/service/throttler/firefox-throttler.ts similarity index 100% rename from src/service/throttler/firefox-throttler.ts rename to src/background/service/throttler/firefox-throttler.ts diff --git a/src/service/throttler/period-throttler.ts b/src/background/service/throttler/period-throttler.ts similarity index 66% rename from src/service/throttler/period-throttler.ts rename to src/background/service/throttler/period-throttler.ts index 3f43bbccf..aeedd63c7 100644 --- a/src/service/throttler/period-throttler.ts +++ b/src/background/service/throttler/period-throttler.ts @@ -1,14 +1,14 @@ import periodDatabase from '@db/period-database' -import { calculate } from '@service/components/period-calculator' +import { calculate } from '../components/period-calculator' import { FirefoxThrottler } from './firefox-throttler' -class PeriodThrottler extends FirefoxThrottler { +class PeriodThrottler extends FirefoxThrottler { public add(timestamp: number, milliseconds: number): void { const results = calculate(timestamp, milliseconds) this.save(results) } - protected doStore(data: timer.period.Result[]): void { + protected doStore(data: tt4b.period.Result[]): void { periodDatabase.accumulate(data) } } diff --git a/src/service/throttler/timeline-throttler.ts b/src/background/service/throttler/timeline-throttler.ts similarity index 80% rename from src/service/throttler/timeline-throttler.ts rename to src/background/service/throttler/timeline-throttler.ts index d816ffa51..c92d98f3e 100644 --- a/src/service/throttler/timeline-throttler.ts +++ b/src/background/service/throttler/timeline-throttler.ts @@ -2,18 +2,18 @@ import timelineDatabase from '@db/timeline-database' import { extractHostname } from '@util/pattern' import { FirefoxThrottler } from './firefox-throttler' -class TimelineThrottler extends FirefoxThrottler { - public saveEvent(ev: timer.timeline.Event) { +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: timer.timeline.Tick[] = durations.map(([start, duration]) => ({ start, duration, host })) + const ticks: tt4b.timeline.Tick[] = durations.map(([start, duration]) => ({ start, duration, host })) this.save(ticks) } - protected doStore(data: timer.timeline.Tick[]): void { + protected doStore(data: tt4b.timeline.Tick[]): void { timelineDatabase.batchSave(data) } } diff --git a/src/background/service/timeline-service.ts b/src/background/service/timeline-service.ts 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..c974138c6 --- /dev/null +++ b/src/background/service/whitelist/holder.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import db from "@db/whitelist-database" +import WhitelistProcessor from './processor' + +/** + * The singleton implementation of whitelist holder + */ +class WhitelistHolder { + private processor = new WhitelistProcessor() + private postHandlers: ArgCallback[] = [] + + constructor() { + this.rebuild() + } + + private async rebuild() { + const whitelist = await db.selectAll() + this.processor.setWhitelist(whitelist) + this.postHandlers.forEach(handler => handler(whitelist)) + } + + addPostHandler(handler: ArgCallback) { + this.postHandlers.push(handler) + } + + async add(white: string): Promise { + await db.add(white) + await this.rebuild() + } + + all(): Promise { + return db.selectAll() + } + + async remove(white: string): Promise { + await db.remove(white) + await this.rebuild() + } + + contains(host: string, url: string): boolean { + return this.processor.contains(host, url) + } + + containsHost(host: string): boolean { + return this.processor.containsHost(host) + } +} + +const whitelistHolder = new WhitelistHolder() +export default whitelistHolder \ No newline at end of file diff --git a/src/service/whitelist/processor.ts b/src/background/service/whitelist/processor.ts similarity index 92% rename from src/service/whitelist/processor.ts rename to src/background/service/whitelist/processor.ts index 27b95b280..fbad9fc93 100644 --- a/src/service/whitelist/processor.ts +++ b/src/background/service/whitelist/processor.ts @@ -30,4 +30,8 @@ export default class WhitelistProcessor { if (this.exclude.some(r => r.test(url))) return false return this.host.includes(host) || this.virtual.some(r => r.test(url)) } + + containsHost(host: string): boolean { + return this.host.includes(host) + } } diff --git a/src/background/side-panel.ts b/src/background/side-panel.ts deleted file mode 100644 index 9505167d7..000000000 --- a/src/background/side-panel.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) 2024-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { IS_MV3 } from "@util/constant/environment" - -export default function initSidePanel() { - if (!IS_MV3) return - const sidePanel = chrome.sidePanel - // sidePanel not supported for Firefox - // Avoid `chrome.sidePanel.setOptions` to skip web-ext lint - if (!sidePanel?.setOptions) return - sidePanel.setOptions({ path: "/static/side.html" }) -} \ No newline at end of file diff --git a/src/background/track-server/file-tracker.ts b/src/background/track-server/file-tracker.ts index 03b8f5af5..5f352f817 100644 --- a/src/background/track-server/file-tracker.ts +++ b/src/background/track-server/file-tracker.ts @@ -1,4 +1,5 @@ -import { getTab, onTabActivated } from '@api/chrome/tab' +import { getTab, onTabActivated, onTabUpdated } from '@api/chrome/tab' +import { onWindowFocusChanged } from '@api/chrome/window' import optionHolder from '@service/components/option-holder' import { extractFileHost } from '@util/pattern' import { handleTrackTimeEvent } from './normal' @@ -10,8 +11,8 @@ type Context = { start: number } -async function convertContext(tabId: number): Promise { - const tab = await getTab(tabId) +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 @@ -27,32 +28,64 @@ async function convertContext(tabId: number): Promise { * Local file tracker for firefox */ class FileTracker { - private enabled = false - private current: Context | null = null + #enabled = false + #current: Context | null = null + // Context saved when window loses focus, restored when focus returns + #suspended: Context | null = null + #windowFocused = true - init() { - optionHolder.get().then(v => this.enabled = v.countLocalFiles) - optionHolder.addChangeListener(v => this.enabled = v.countLocalFiles) + 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.#tick() + this.#current = await convertContext(tabId) + this.#suspended = null + }) + + onTabUpdated(async (_tabId, changeInfo, tab) => { + if (!changeInfo.url || !tab.active) return + const newContext = await convertContext(tab) + if (this.#current?.host !== newContext?.host) { + // File host changed or navigated away from file URL + this.#tick() + this.#current = newContext + this.#suspended = null + } + }) + + onWindowFocusChanged(async windowId => { + if (windowId === chrome.windows.WINDOW_ID_NONE) { + this.#tick() + this.#suspended = this.#current + this.#current = null + this.#windowFocused = false + } else if (!this.#windowFocused) { + this.#windowFocused = true + if (this.#suspended) { + // Re-validate: tab may have been closed or navigated away during blur + const suspendedTabId = this.#suspended.tab.id + this.#suspended = null + this.#current = suspendedTabId ? await convertContext(suspendedTabId) : null + } + } }) // NOTE: if migrate to MV3, this line won't work expectedly - setInterval(() => this.tick(), 1000) + setInterval(() => this.#tick(), 1000) } - private tick() { - if (!this.current) return - const { host, tab, start } = this.current + #tick() { + if (!this.#current) return + const { host, tab, start } = this.#current const end = Date.now() - this.enabled && handleTrackTimeEvent({ - host, start, end, - url: tab.url ?? '', - ignoreTabCheck: false, - }, tab) - this.current.start = end + if (this.#enabled) { + void handleTrackTimeEvent({ host, start, end, ignoreTabCheck: true }, tab) + } + this.#current.start = end } } diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts index 123d1f16c..b8648eeac 100644 --- a/src/background/track-server/group.ts +++ b/src/background/track-server/group.ts @@ -1,14 +1,22 @@ -import itemService from '@service/item-service' +import db from "@db/stat-database" +import optionHolder from '../service/components/option-holder' -function handleTabGroupRemove(group: chrome.tabGroups.TabGroup) { - itemService.batchDeleteGroupById(group.id) -} +const handleRemove = (group: chrome.tabGroups.TabGroup) => db.deleteByGroup(group.id) -export function handleTabGroupEnabled() { +function handleTabGroupsEnabled(option: tt4b.option.TrackingOption) { + // Do nothing if not enabled + if (!option.countTabGroup) return try { - chrome.tabGroups.onRemoved.removeListener(handleTabGroupRemove) - chrome.tabGroups.onRemoved.addListener(handleTabGroupRemove) + chrome.tabGroups.onRemoved.removeListener(handleRemove) + chrome.tabGroups.onRemoved.addListener(handleRemove) } catch (e) { console.warn('failed to handle event: enableTabGroup', e) } +} + +export async function initTabGroup() { + const option = await optionHolder.get() + handleTabGroupsEnabled(option) + + optionHolder.addChangeListener(newVal => handleTabGroupsEnabled(newVal)) } \ No newline at end of file diff --git a/src/background/track-server/index.ts b/src/background/track-server/index.ts index fd7c734f6..2301afbc0 100644 --- a/src/background/track-server/index.ts +++ b/src/background/track-server/index.ts @@ -1,24 +1,22 @@ -import itemService from "@service/item-service" import { IS_ANDROID, IS_FIREFOX } from '@util/constant/environment' import { isFileUrl } from '@util/pattern' import type MessageDispatcher from "../message-dispatcher" import FileTracker from './file-tracker' -import { handleTabGroupEnabled } from './group' -import { handleIncVisitEvent, handleTrackTimeEvent } from './normal' +import { initTabGroup } from './group' +import { handleTrackTimeEvent } from './normal' import { handleTrackRunTimeEvent } from './runtime' export default function initTrackServer(messageDispatcher: MessageDispatcher) { messageDispatcher - .register('cs.trackTime', (ev, sender) => { - // not to process cs events from local files for FF - if (IS_FIREFOX && isFileUrl(ev.url)) return - - handleTrackTimeEvent(ev, sender.tab) + .register('track.time', async (ev, { tab }) => { + const url = tab?.url + // Not to process event from FF file tab + if (IS_FIREFOX && url && isFileUrl(url)) return + await handleTrackTimeEvent(ev, tab) }) - .register('cs.trackRunTime', handleTrackRunTimeEvent) - .register<{ host: string, url: string }, void>('cs.incVisitCount', handleIncVisitEvent) - .register('cs.getTodayInfo', host => itemService.getResult(host, new Date())) - .register('enableTabGroup', handleTabGroupEnabled) + .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 diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index 20441b791..0ace38c9c 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -1,8 +1,10 @@ -import { getTab, listTabs, sendMsg2Tab } from "@api/chrome/tab" -import { getWindow } from "@api/chrome/window" +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { getWindow } from '@api/chrome/window' import optionHolder from "@service/components/option-holder" -import itemService, { type ItemIncContext } from "@service/item-service" -import limitService from "@service/limit-service" +import { + addFocusTime as addItemFocusTime, increaseVisit as increaseItemVisit, type ItemIncContext, +} from "@service/item-service" +import { addLimitFocusTime, incLimitVisit } from '@service/limit-service' import periodThrottler from '@service/throttler/period-throttler' import whitelistHolder from "@service/whitelist/holder" import { IS_ANDROID } from "@util/constant/environment" @@ -14,39 +16,46 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], const [start, end] = timeRange const focusTime = end - start // 1. Save async - await itemService.addFocusTime(context, focusTime) + await addItemFocusTime(context, focusTime) // 2. Process limit - const { limited, reminder } = await limitService.addFocusTime(host, url, focusTime) + const { limited, reminder } = await addLimitFocusTime(host, url, focusTime) // If time limited after this operation, send messages - limited?.length && sendLimitedMessage(limited) + limited.length && void sendLimitedMessage(limited) // If need to reminder, send messages - reminder?.items?.length && tabId && sendMsg2Tab(tabId, 'limitReminder', reminder) + reminder?.items?.length && tabId && void sendMsg2Tab(tabId, 'limitReminder', reminder) // 3. Add period time periodThrottler.add(start, focusTime) return focusTime } -export async function handleTrackTimeEvent(event: timer.core.Event, senderTab: ChromeTab | undefined): Promise { - const { url, start, end, ignoreTabCheck } = event - const { id: tabId, windowId, groupId } = senderTab ?? {} +export async function handleTrackTimeEvent(event: 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 (await tabNotActive(tabId)) return + if (!active) return } - const { protocol, host } = extractHostname(url) || {} - const option = await optionHolder.get() + const { protocol, host } = extractHostname(url) + + const { countLocalFiles } = await optionHolder.get() + if (protocol === "file" && !countLocalFiles) return - 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 }) + 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 }) } } @@ -57,15 +66,9 @@ async function windowNotFocused(winId: number | undefined): Promise { 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[]) { +async function sendLimitedMessage(items: tt4b.limit.Item[]) { const tabs = await listTabs() - if (!tabs?.length) return + if (!tabs.length) return for (const tab of tabs) { try { const { id } = tab @@ -77,17 +80,17 @@ async function sendLimitedMessage(items: timer.limit.Item[]) { } async function handleVisit(context: ItemIncContext) { - await itemService.increaseVisit(context) + await increaseItemVisit(context) const { host, url } = context - const metLimits = await limitService.incVisit(host, url) + const metLimits = await incLimitVisit(host, url) // If time limited after this operation, send messages - metLimits?.length && sendLimitedMessage(metLimits) + metLimits.length && await sendLimitedMessage(metLimits) } -export async function handleIncVisitEvent(param: { host: string, url: string }, sender: ChromeMessageSender): Promise { - const { host, url } = param - const { groupId } = sender?.tab ?? {} - const { protocol } = extractHostname(url) +export async function incVisitCount(tab: ChromeTab | undefined): Promise { + const { groupId, url } = tab ?? {} + if (!url) return + const { protocol, host } = extractHostname(url) const option = await optionHolder.get() if (protocol === "file" && !option.countLocalFiles) return await handleVisit({ host, url, groupId }) diff --git a/src/background/track-server/runtime.ts b/src/background/track-server/runtime.ts index 30df313c5..506e203bc 100644 --- a/src/background/track-server/runtime.ts +++ b/src/background/track-server/runtime.ts @@ -1,11 +1,12 @@ -import itemService from "@service/item-service" import whitelistHolder from "@service/whitelist/holder" +import FIFOCache from '@util/fifo-cache' import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" +import { addRunTime } from '../service/item-service' function splitRunTime(start: number, end: number): Record { const res: Record = {} while (start < end) { - const startOfNextDay = getStartOfDay(start).getTime() + MILL_PER_DAY + const startOfNextDay = getStartOfDay(start) + MILL_PER_DAY const newStart = Math.min(end, startOfNextDay) const runTime = newStart - start runTime && (res[formatTimeYMD(start)] = runTime) @@ -14,15 +15,15 @@ function splitRunTime(start: number, end: number): Record { return res } -const RUN_TIME_END_CACHE: { [host: string]: number } = {} +const RUN_TIME_END_CACHE = new FIFOCache(500) -export async function handleTrackRunTimeEvent(event: timer.core.Event): Promise { - const { start, end, url, host } = event || {} - if (!host || !start || !end) return +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[host] ?? 0, start) + const realStart = Math.max(RUN_TIME_END_CACHE.get(host) ?? 0, start) const byDate = splitRunTime(realStart, end) if (!Object.keys(byDate).length) return - await itemService.addRunTime(host, byDate) - RUN_TIME_END_CACHE[host] = Math.max(end, realStart) + await addRunTime(host, byDate) + RUN_TIME_END_CACHE.set(host, Math.max(end, realStart)) } \ No newline at end of file diff --git a/src/background/whitelist-menu-manager.ts b/src/background/whitelist-menu-manager.ts index e90bcb283..a9b058d03 100644 --- a/src/background/whitelist-menu-manager.ts +++ b/src/background/whitelist-menu-manager.ts @@ -8,20 +8,15 @@ import { createContextMenu, updateContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" import { getTab, onTabActivated, onTabUpdated } from "@api/chrome/tab" -import db from "@db/whitelist-database" import { t2Chrome } from "@i18n/chrome/t" -import { type ContextMenusMessage } from "@i18n/message/common/context-menus" -import optionHolder from "@service/components/option-holder" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" +import optionHolder from "./service/components/option-holder" +import whitelistHolder from './service/whitelist/holder' const menuId = '_timer_menu_item_' + getRuntimeId() let currentActiveId: number -let whitelist: string[] = [] - -const removeOrAdd = (removeOrAddFlag: boolean, white: string) => removeOrAddFlag ? db.remove(white) : db.add(white) - const menuInitialOptions: ChromeContextMenuCreateProps = { contexts: ['page', 'frame', 'selection', 'link', 'editable', 'image', 'video', 'audio'], id: menuId, @@ -31,33 +26,23 @@ const menuInitialOptions: ChromeContextMenuCreateProps = { } async function updateContextMenuInner(param: ChromeTab | number | undefined): Promise { - if (typeof param === 'number') { - // If number, get the tabInfo first - const tab = await getTab(currentActiveId) - tab && await updateContextMenuInner(tab) + const tab = typeof param === 'number' ? await getTab(currentActiveId) : param + const { url } = tab ?? {} + + const targetHost = url && !isBrowserUrl(url) ? extractHostname(url).host : undefined + const visible = (await optionHolder.get())?.displayWhitelistMenu + const changeProp: ChromeContextMenuUpdateProps = {} + if (targetHost && visible) { + const exist = whitelistHolder.containsHost(targetHost) + changeProp.visible = visible + changeProp.title = t2Chrome(root => root.contextMenus[exist ? 'removeFromWhitelist' : 'add2Whitelist']) + .replace('{host}', targetHost) + changeProp.onclick = () => exist ? whitelistHolder.remove(targetHost) : whitelistHolder.add(targetHost) } else { - const { url } = param || {} - const targetHost = url && !isBrowserUrl(url) ? extractHostname(url).host : '' - const changeProp: ChromeContextMenuUpdateProps = {} - if (!targetHost) { - // If not a valid host, hide this menu - changeProp.visible = false - } else { - // Else change the title - const visible = (await optionHolder.get())?.displayWhitelistMenu - const existsInWhitelist = whitelist.includes(targetHost) - changeProp.visible = true && visible - const titleMsgField: keyof ContextMenusMessage = existsInWhitelist ? 'removeFromWhitelist' : 'add2Whitelist' - changeProp.title = t2Chrome(root => root.contextMenus[titleMsgField]).replace('{host}', targetHost) - changeProp.onclick = () => removeOrAdd(existsInWhitelist, targetHost) - } - await updateContextMenu(menuId, changeProp) + // If not a valid host, hide this menu + changeProp.visible = false } -} - -const handleListChange = (newWhitelist: string[]) => { - whitelist = newWhitelist - updateContextMenuInner(currentActiveId) + await updateContextMenu(menuId, changeProp) } const handleTabUpdated = (tabId: number, updatedInfo: ChromeTabUpdatedInfo, tab?: ChromeTab) => { @@ -77,7 +62,7 @@ async function initWhitelistMenuManager() { createContextMenu(menuInitialOptions) onTabUpdated(handleTabUpdated) onTabActivated((_tabId, activeInfo) => handleTabActivated(activeInfo)) - db.addChangeListener(handleListChange) + whitelistHolder.addPostHandler(() => updateContextMenuInner(currentActiveId)) } export default initWhitelistMenuManager \ No newline at end of file diff --git a/src/common/logger.ts b/src/common/logger.ts index 7f4189b14..105cdb512 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -17,14 +17,6 @@ function initOpenLog() { } catch (ignored) { } } -function updateLocalStorage(openState: boolean) { - try { - openState - ? localStorage.setItem(STORAGE_KEY, STORAGE_VAL) - : localStorage.removeItem(STORAGE_KEY) - } catch (ignored) { } -} - initOpenLog() /** @@ -34,19 +26,3 @@ initOpenLog() export function log(...args: any) { OPEN_LOG && console.log(...args) } - -/** - * @since 0.0.4 - */ -export function openLog(): string { - updateLocalStorage(OPEN_LOG = true) - return 'Opened the log manually.' -} - -/** - * @since 0.0.8 - */ -export function closeLog(): string { - updateLocalStorage(OPEN_LOG = false) - return 'Closed the log manually.' -} \ No newline at end of file diff --git a/src/common/timer.ts b/src/common/timer.ts deleted file mode 100644 index be79c5f0d..000000000 --- a/src/common/timer.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getUsedStorage } from "@db/memory-detector" -import { closeLog, openLog } from "./logger" - -/** - * Show the memory info - * - * @since 0.0.9 - */ -export function showMemory() { - getUsedStorage().then(({ used, total }) => { - console.log(`\t${used} / ${total} = ${Math.round(used * 100.0 / total * 100) / 100}%`) - }) - return 'Memory used:' -} - -export type Timer = { - openLog: () => string - closeLog: () => string - showMemory: () => void -} - -/** - * @since 0.0.8 - */ -const timer = { - openLog, - closeLog, - showMemory -} as Timer - -declare global { - interface Window { - timer: Timer - } -} - -/** - * Manually open and close the log - * - * @since 0.0.8 - */ -window.timer = timer diff --git a/src/content-script/dispatcher.ts b/src/content-script/dispatcher.ts new file mode 100644 index 000000000..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 dc11cb3ae..bf05214f8 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -5,8 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { trySendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { initLocale } from "@i18n" +import Dispatcher from './dispatcher' import processLimit from "./limit" import printInfo from "./printer" import processTimeline from './timeline' @@ -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) @@ -40,32 +40,33 @@ function getOrSetFlag(): boolean { } async function main() { + const dispatcher = new Dispatcher() + // Execute in every injections const normalTracker = new NormalTracker({ - onReport: data => trySendMsg2Runtime('cs.trackTime', data), - onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChange', false), - onPause: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChange', true), + onReport: data => trySendMsg2Runtime('track.time', data), + onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', false), + onPause: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', true), }) normalTracker.init() - const runTimeTracker = new RunTimeTracker(url) - runTimeTracker.init() + dispatcher.registerAudibleChange(normalTracker) + new RunTimeTracker(url).init(dispatcher) // Execute only one time for each dom if (getOrSetFlag()) return if (!host) return - const isWhitelist = await trySendMsg2Runtime('cs.isInWhitelist', { host, url }) + const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) if (isWhitelist) return - await initLocale() - const needPrintInfo = await trySendMsg2Runtime('cs.printTodayInfo') - !!needPrintInfo && printInfo(host) - await processLimit(url) + initLocale() + printInfo(host) + await processLimit(url, dispatcher) processTimeline() // Increase visit count at the end - await trySendMsg2Runtime('cs.incVisitCount', { host, url }) + await trySendMsg2Runtime('cs.injected') } -main() \ No newline at end of file +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..fabea9e7b 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -1,42 +1,35 @@ -import { onRuntimeMessage } from "@api/chrome/runtime" -import { allMatch } from "@util/array" -import { type MaskModal, type ModalContext, type Processor } from "./common" -import ModalInstance from "./modal" -import MessageAdaptor from "./processor/message-adaptor" +import { getOption } from '@api/sw/option' +import Dispatcher from '../dispatcher' +import ModalInstance from "./modal/instance" +import MessageAdaptor from './processor/message-adaptor' import PeriodProcessor from "./processor/period-processor" import VisitProcessor from "./processor/visit-processor" -import Reminder from "./reminder" +import Reminder from './reminder' +import type { ModalContext, Processor } from './types' -export default async function processLimit(url: string) { - const modal: MaskModal = new ModalInstance(url) +export default async function processLimit(url: string, dispatcher: Dispatcher) { + const { limitDelayDuration: delayDuration } = await getOption() + const modal = new ModalInstance(url) const context: ModalContext = { modal, url } + const mesageAdaptor = new MessageAdaptor(context, delayDuration) + const visitProcessor = new VisitProcessor(context, delayDuration) + const processors: Processor[] = [ - new MessageAdaptor(context), + mesageAdaptor, + visitProcessor, new PeriodProcessor(context), - new VisitProcessor(context), - new Reminder(), ] - await Promise.all(processors.map(p => p.init())) - onRuntimeMessage(async msg => { - const results = await Promise.all(processors.map(async p => { - const { code, data } = msg || {} - return await p.handleMsg(code, data) - })) + const reminder = new Reminder() - const allIgnore = allMatch(results, r => r.code === "ignore") - if (allIgnore) return { code: "ignore" } + dispatcher + .register('limitChanged', () => void processors.forEach(p => p.onLimitChanged())) + .register('limitTimeMeet', items => void mesageAdaptor.onLimitTimeMeet(items)) + .register('limitReminder', data => void reminder.show(data)) + .register('askVisitHit', ruleId => modal.reasons.some(r => r.type === 'VISIT' && ruleId === r.id)) + .registerAudibleChange(visitProcessor.tracker) - const anyFail = allMatch(results, r => r.code === "fail") - if (anyFail) return { code: "fail" } - // Merge data of all the handlers - const items = results - .filter(r => r.code === "success") - .map(r => r.data) - .filter(r => r !== undefined && r !== null) - const data = items.length <= 1 ? items[0] : items - return { code: "success", data } - }) + return visitProcessor.tracker } diff --git a/src/content-script/limit/modal/Main.tsx b/src/content-script/limit/modal/Main.tsx index 22da582a4..ba9653a9a 100644 --- a/src/content-script/limit/modal/Main.tsx +++ b/src/content-script/limit/modal/Main.tsx @@ -1,17 +1,9 @@ import "@pages/element-ui/dark-theme.css" -import "element-plus/theme-chalk/el-button-group.css" -import "element-plus/theme-chalk/el-button.css" -import "element-plus/theme-chalk/el-descriptions-item.css" -import "element-plus/theme-chalk/el-descriptions.css" -import "element-plus/theme-chalk/el-input.css" -import "element-plus/theme-chalk/el-message-box.css" -import "element-plus/theme-chalk/el-message.css" -import "element-plus/theme-chalk/el-tag.css" import { defineComponent } from "vue" import Alert from "./components/Alert" import Footer from "./components/Footer" import Reason from "./components/Reason" -import { provideRule } from "./context" +import { provideRule } from './context' import "./style/element-base.css" import "./style/modal.css" diff --git a/src/content-script/limit/modal/bridge.ts b/src/content-script/limit/modal/bridge.ts new file mode 100644 index 000000000..3934c666e --- /dev/null +++ b/src/content-script/limit/modal/bridge.ts @@ -0,0 +1,91 @@ +import type { BridgeCode, BridgeHandler, BridgeRequest, BridgeResponse } from './types' + +type RpcBase = { + code: C + requestId: string +} + +type RpcRequest = RpcBase & { + kind: 'request' + data: BridgeRequest +} + +type RpcResponse = RpcBase & { + kind: 'response' + data: BridgeResponse +} + +const isRpcRequest = (payload: unknown): payload is RpcRequest => { + if (typeof payload !== 'object' || payload === null) return false + // Just judge the kind + return 'kind' in payload && payload.kind === 'request' +} + +const isRpcResponse = (payload: unknown): payload is RpcResponse => { + if (typeof payload !== 'object' || payload === null) return false + // Just judge the kind + return 'kind' in payload && payload.kind === 'response' +} + +export class ModalBridge { + private readonly pendingCache = new Map>>() + private readonly handlers = new Map>() + private readonly onMessageBound: (ev: MessageEvent) => void + + constructor(private origin: string, private peer: () => Window | undefined) { + this.onMessageBound = this.onMessage.bind(this) + window.addEventListener('message', this.onMessageBound) + } + + dispose(): void { + window.removeEventListener('message', this.onMessageBound) + this.pendingCache.clear() + this.handlers.clear() + } + + register(code: C, handler: BridgeHandler): ModalBridge { + this.handlers.set(code, handler as unknown as BridgeHandler) + return this + } + + request(code: C, req: BridgeRequest): Promise> { + const requestId = `${code}-${Date.now()}-${Math.random().toString(12).slice(2)}` + if (!this.peer()) return Promise.reject("Peer window is not available") + return new Promise>((resolve, reject) => { + const t = window.setTimeout(() => { + this.pendingCache.delete(requestId) + reject("Timeout") + }, 1_000) + this.pendingCache.set(requestId, msg => { + if (msg.kind !== 'response' || msg.code !== code) return + clearTimeout(t) + this.pendingCache.delete(requestId) + resolve(msg.data) + }) + this.send({ kind: 'request', code, requestId, data: req }) + }) + } + + private send(payload: RpcRequest | RpcResponse): void { + try { + this.peer()?.postMessage(payload, this.origin) + } catch { + // ignored + } + } + + private async onMessage(ev: MessageEvent) { + if (this.peer() !== ev.source) return + const { data: payload } = ev + if (isRpcRequest(payload)) { + const { code, data: reqData, requestId } = payload + const handler = this.handlers.get(code) + if (!handler) return + const data = await handler(reqData) + this.send({ kind: 'response', requestId, code, data }) + } else if (isRpcResponse(payload)) { + const pending = this.pendingCache.get(payload.requestId) + pending?.(payload) + } + } +} diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx index b8d96977e..247398a05 100644 --- a/src/content-script/limit/modal/components/Alert.tsx +++ b/src/content-script/limit/modal/components/Alert.tsx @@ -1,33 +1,32 @@ -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 { useRequest, useXsState } from "@hooks" import Box from '@pages/components/Box' import Flex from '@pages/components/Flex' -import optionHolder from "@service/components/option-holder" -import { defineComponent, type StyleValue } from "vue" - -const ICON_URL = getUrl('static/images/icon.png') - -const IMG_STYLE: StyleValue = { - width: '1.4em', - height: '1.4em', - marginInlineEnd: '.4em', -} +import Img from '@pages/components/Img' +import { defineComponent } from "vue" const _default = defineComponent(() => { const defaultPrompt = t(msg => msg.modal.defaultPrompt) const { data: prompt } = useRequest(async () => { - const option = await optionHolder.get() - return option?.limitPrompt || defaultPrompt + const option = await getOption() + return option?.limitPrompt ?? defaultPrompt }, { defaultValue: defaultPrompt }) + const isXs = useXsState() + return () => ( - - + + {t(msg => msg.meta.name)?.toUpperCase()} - + {prompt.value} diff --git a/src/content-script/limit/modal/components/Footer.tsx b/src/content-script/limit/modal/components/Footer.tsx index 798adb2f6..a1c212e16 100644 --- a/src/content-script/limit/modal/components/Footer.tsx +++ b/src/content-script/limit/modal/components/Footer.tsx @@ -1,80 +1,78 @@ -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 Flex from '@pages/components/Flex' -import optionHolder from "@service/components/option-holder" +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 () => ( - sendMsg2Runtime('cs.openAnalysis')} - > - {t(msg => msg.menu.siteAnalysis)} - + + + {t(msg => msg.menu.siteAnalysis)} + + handleMore5Minutes(rule.value, delayHandler)} - > - {t(msg => msg.modal.more5Minutes)} - - sendMsg2Runtime('cs.openLimit')} + round icon={Plus} onClick={handleDelay} > - {t(msg => msg.modal.ruleDetail)} + {t(msg => msg.modal.delay, { n: delayDuration.value })} + + + {t(msg => msg.modal.ruleDetail)} + + ) }) diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index 78bbe160a..b658c16d0 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -1,17 +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, type StyleValue } from "vue" -import { useGlobalParam, useReason, useRule } from "../context" +import { useApp, useRule } from '../context' -const DESCRIPTIONS_STYLE: StyleValue = { - width: '400px', +const useDescriptions = () => { + const isXs = useXsState() + const style = computed(() => ({ + width: isXs.value ? '90vw' : '400px', + } satisfies StyleValue)) + const size = computed(() => isXs.value ? 'small' : undefined) + return { style, size } } -const renderBaseItems = (rule: timer.limit.Rule | null, url: string) => <> +const renderBaseItems = (rule: tt4b.limit.Rule | undefined, url: string) => <> msg.limit.item.name)} labelAlign="right"> {rule?.name ?? '-'} @@ -32,20 +37,27 @@ const TimeDescriptions = defineComponent({ dataLabel: String, }, setup(props) { + const { reason, url, delayDuration } = useApp() const rule = useRule() - const reason = useReason() - const { url } = useGlobalParam() + const { style, size } = useDescriptions() - const timeLimited = computed(() => meetTimeLimit(props.time ?? 0, props.waste ?? 0, !!reason.value?.allowDelay, reason.value?.delayCount ?? 0)) + const timeLimited = computed(() => meetTimeLimit( + { wasted: props.waste ?? 0, maxLimit: (props.time ?? 0) * MILL_PER_SECOND }, + { + count: reason.value?.delayCount ?? 0, + duration: delayDuration.value, + allow: !!reason.value?.allowDelay, + }, + )) const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) return () => ( - + {renderBaseItems(rule.value, url)} {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} - {`${props.count ?? 0} ${t(msg => msg.limit.item.visits)}`} + {props.count && {t(msg => msg.shared.limit.visits, { n: props.count })}} @@ -60,7 +72,7 @@ const TimeDescriptions = defineComponent({ v-show={!!props.count || !!props.visit} type={visitLimited.value ? 'danger' : 'info'} > - {`${props.visit ?? 0} ${t(msg => msg.limit.item.visits)}`} + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} @@ -71,24 +83,17 @@ const TimeDescriptions = defineComponent({ > {reason.value?.delayCount ?? 0} - + ) }, }) const _default = defineComponent(() => { - const reason = useReason() - const rule = useRule() - const { url } = useGlobalParam() + const { reason, visitTime, url } = useApp() const type = computed(() => reason.value?.type) + const 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 () => ( @@ -98,7 +103,7 @@ const _default = defineComponent(() => { count={rule.value?.count} waste={rule.value?.waste} visit={rule.value?.visit} - ruleLabel={t(msg => msg.limit.item.daily)} + ruleLabel={t(msg => msg.shared.limit.daily)} dataLabel={t(msg => msg.calendar.range.today)} /> { count={rule.value?.weeklyCount} waste={rule.value?.weeklyWaste} visit={rule.value?.weeklyVisit} - ruleLabel={t(msg => msg.limit.item.weekly)} + ruleLabel={t(msg => msg.shared.limit.weekly)} dataLabel={t(msg => msg.calendar.range.thisWeek)} /> - + {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"> + 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..9ebee3115 --- /dev/null +++ b/src/content-script/limit/modal/instance.ts @@ -0,0 +1,193 @@ +import { getRuntimeId, getUrl } from '@api/chrome/runtime' +import { trySendMsg2Runtime } from '@api/sw/common' +import { exitFullscreen, isSameReason } from '../common' +import type { LimitReason, MaskModal } from '../types' +import { ModalBridge } from './bridge' +import { createRootElement, TAG_NAME, type RootElement } from './element' +import type { LimitReasonData } from './types' + +const MODAL_URL = getUrl('static/limit.html') +const MSG_ORIGIN = new URL(MODAL_URL).origin + +function pauseAllVideo(): void { + const elements = document?.getElementsByTagName('video') + if (!elements) return + Array.from(elements).forEach(video => { + try { + video?.pause?.() + } catch { } + }) +} + +function pauseAllAudio(): void { + const elements = document?.getElementsByTagName('audio') + if (!elements) return + Array.from(elements).forEach(audio => { + try { + audio?.pause?.() + } catch { } + }) +} + +const TYPE_SORT: { [reason in 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() + // Scroll to top + scrollTo(0, 0) + pauseAllVideo() + pauseAllAudio() + + this.rootElement && (this.rootElement.style.visibility = 'visible') + this.setReason(reason) + this.screenLocker.lock() + this.iframe && (this.iframe.style.visibility = 'visible') + } + + private hide() { + this.rootElement && (this.rootElement.style.visibility = 'hidden') + this.screenLocker.unlock() + this.iframe && (this.iframe.style.visibility = 'hidden') + this.setReason(undefined) + } + + private setReason(reason: LimitReason | undefined) { + if (!this.iframe?.contentWindow) return + this.reason = reason + this.bridge.request('reason', extractReason(reason)).catch(() => { }) + } +} + +const extractReason = (reason: LimitReason | undefined): LimitReasonData | undefined => { + if (!reason) return undefined + const { getVisitTime: _, ...rest } = reason + return rest +} + +export default ModalInstance diff --git a/src/content-script/limit/modal/style/modal.css b/src/content-script/limit/modal/style/modal.css index e86ed0a36..bc3da790d 100644 --- a/src/content-script/limit/modal/style/modal.css +++ b/src/content-script/limit/modal/style/modal.css @@ -28,4 +28,4 @@ html[data-theme=dark] body { /* Fix message-box buttons */ .el-message-box__btns { gap: 12px; -} +} \ No newline at end of file diff --git a/src/content-script/limit/modal/types.ts b/src/content-script/limit/modal/types.ts new file mode 100644 index 000000000..05b2a85de --- /dev/null +++ b/src/content-script/limit/modal/types.ts @@ -0,0 +1,17 @@ +import type { LimitReason } from '../types' + +export type LimitReasonData = Omit + +type MakeRegistry = { + [K in Code]: { req: Req; res: Res } +} + +type BridgeRegistry = + & MakeRegistry<'reason', LimitReasonData | undefined, void> + & MakeRegistry<'visitTime', void, number> + & MakeRegistry<'delay', void, void> + +export type BridgeCode = keyof BridgeRegistry +export type BridgeRequest = BridgeRegistry[C]['req'] +export type BridgeResponse = BridgeRegistry[C]['res'] +export type BridgeHandler = (req: BridgeRequest) => Awaitable> \ No newline at end of file diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 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 fc008f919..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 @@ -42,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 be7d65b00..127804c9a 100644 --- a/src/content-script/tracker/normal/idle-detector.ts +++ b/src/content-script/tracker/normal/idle-detector.ts @@ -1,5 +1,5 @@ -import { onRuntimeMessage, trySendMsg2Runtime } from '@api/chrome/runtime' -import optionHolder from "@service/components/option-holder" +import { trySendMsg2Runtime } from '@api/sw/common' +import { getOption } from '@api/sw/option' export default class IdleDetector { fullScreen: boolean = false @@ -12,15 +12,11 @@ export default class IdleDetector { lastActiveTime: number = Date.now() userActive: boolean = true - pauseTimeout: NodeJS.Timeout | undefined + pauseTimeout: ReturnType | undefined - onIdle: () => void - onActive: () => void - - constructor({ onIdle, onActive }: { onIdle: () => void, onActive: () => void }) { + constructor(private readonly onIdle: NoArgCallback, private readonly onActive: NoArgCallback) { this.onIdle = onIdle this.onActive = onActive - this.init() } needTimeout(): boolean { @@ -32,16 +28,12 @@ export default class IdleDetector { return this.lastActiveTime + this.autoPauseInterval <= Date.now() } - private async init() { - const option = await optionHolder.get() - - this.processOption(option) - this.resetTimeout() - - optionHolder.addChangeListener(opt => { - this.processOption(opt) - this.resetTimeout() - }) + async init() { + this.reset() + document.addEventListener('visibilitychange', () => document.visibilityState === 'visible' && this.reset()) + const pollInterval = setInterval(() => this.reset(), 60_000) + const stopPoll = () => clearInterval(pollInterval) + window.addEventListener('beforeunload', stopPoll) const handleActive = () => { this.lastActiveTime = Date.now() @@ -49,15 +41,14 @@ export default class IdleDetector { if (!this.needTimeout()) return if (!this.pauseTimeout) { - // Paused, so activate - this.onActive?.() + this.onActive() this.resetTimeout() } } window.addEventListener('mousedown', handleActive) window.addEventListener('mousemove', handleActive) - window.addEventListener('keypress', handleActive) + window.addEventListener('keydown', handleActive) window.addEventListener('scroll', handleActive) window.addEventListener('wheel', handleActive) document?.addEventListener('fullscreenchange', () => { @@ -66,21 +57,18 @@ export default class IdleDetector { }) trySendMsg2Runtime('cs.getAudible').then(val => this.audible = !!val) - onRuntimeMessage(async req => { - const { code, data } = req - if (code !== 'syncAudible' || typeof data !== 'boolean') return { code: 'ignore' } - this.audible = !!data - return { code: 'success' } - }) } - private processOption(option: timer.option.TrackingOption) { - this.autoPauseTracking = !!option?.autoPauseTracking - this.autoPauseInterval = option?.autoPauseInterval * 1000 + private reset() { + getOption().then(({ autoPauseTracking, autoPauseInterval }) => { + this.autoPauseTracking = autoPauseTracking + this.autoPauseInterval = autoPauseInterval * 1000 + + this.resetTimeout() + }).catch(() => { }) } private resetTimeout() { - if (!!this.pauseTimeout) { clearTimeout(this.pauseTimeout) this.pauseTimeout = undefined @@ -104,8 +92,7 @@ export default class IdleDetector { if (!this.needTimeout()) return if (this.isIdle()) { - // Idle interval meets - this.onIdle?.() + this.onIdle() } else { this.resetTimeout() } diff --git a/src/content-script/tracker/normal/index.ts b/src/content-script/tracker/normal/index.ts index 9adb167ab..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 f0543ae9e..fbbe42ec0 100644 --- a/src/content-script/tracker/run-time.ts +++ b/src/content-script/tracker/run-time.ts @@ -1,35 +1,26 @@ -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 async collect() { @@ -38,14 +29,13 @@ class RunTimeTracker { try { if (this.host) { - const event: timer.core.Event = { + const event: tt4b.core.Event = { start: lastTime, end: now, - url: this.url, ignoreTabCheck: false, host: this.host, } - await sendMsg2Runtime('cs.trackRunTime', event) + await trySendMsg2Runtime('track.runTime', event) } this.start = now } catch { diff --git a/src/content-script/types.ts b/src/content-script/types.ts new file mode 100644 index 000000000..f7423260c --- /dev/null +++ b/src/content-script/types.ts @@ -0,0 +1,3 @@ +export interface AudibleChangeHandler { + onAudibleChange(audible: boolean): void +} \ No newline at end of file diff --git a/src/database/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 f7b07f72a..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 71826de80..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, { oldValue, newValue }]) => { - const siteKey = cvt2SiteKey(k) - const oldVal = oldValue ? cvt2SiteInfo(siteKey, oldValue as _Entry) : undefined - const newVal = newValue ? cvt2SiteInfo(siteKey, newValue as _Entry) : undefined - return [oldVal, newVal] - }) - changedSites.length && listener?.(changedSites) - } - chrome.storage.onChanged.addListener(storageListener) - } -} - -const siteDatabase = new SiteDatabase() - -export default siteDatabase \ No newline at end of file diff --git a/src/database/stat-database/constants.ts b/src/database/stat-database/constants.ts deleted file mode 100644 index d1ca9f5e4..000000000 --- a/src/database/stat-database/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const GROUP_PREFIX = "_g_" \ No newline at end of file diff --git a/src/database/stat-database/filter.ts b/src/database/stat-database/filter.ts deleted file mode 100644 index 384f9a6bb..000000000 --- a/src/database/stat-database/filter.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { judgeVirtualFast } from "@util/pattern" -import { formatTimeYMD } from "@util/time" -import { type StatCondition, type StatDatabase } from "." -import { GROUP_PREFIX } from "./constants" - -type _StatCondition = StatCondition & { - // Use exact date condition - useExactDate?: boolean - // date str - exactDateStr?: string - startDateStr?: string - endDateStr?: string - // time range - timeStart?: number - timeEnd?: number - focusStart?: number - focusEnd?: number -} - -type _FilterResult = { - host: string - date: string - value: timer.core.Result -} - -function filterHost(host: string, condition: _StatCondition): boolean { - const { keys, virtual } = condition - const keyArr = typeof keys === 'string' ? [keys] : keys - // 1. virtual - if (!virtual && judgeVirtualFast(host)) return false - // 2. host - if (keyArr?.length && !keyArr.includes(host)) return false - return true -} - -function filterDate( - date: string, - { useExactDate, exactDateStr, startDateStr, endDateStr }: _StatCondition -): boolean { - if (useExactDate) { - if (exactDateStr !== date) return false - } else { - if (startDateStr && startDateStr > date) return false - if (endDateStr && endDateStr < date) return false - } - return true -} - -function filterNumberRange(val: number, [start, end]: [start?: number, end?: number]): boolean { - if (start !== null && start !== undefined && start > val) return false - if (end !== null && end !== undefined && end < val) return false - return true -} - -/** - * Filter by query parameters - * - * @param date date of item - * @param host host of item - * @param val val of item - * @param condition query parameters - * @return true if valid, or false - */ -function filterByCond(result: _FilterResult, condition: _StatCondition): boolean { - const { host, date, value } = result - const { focus, time } = value - const { timeStart, timeEnd, focusStart, focusEnd } = condition - - return filterHost(host, condition) - && filterDate(date, condition) - && filterNumberRange(time, [timeStart, timeEnd]) - && filterNumberRange(focus, [focusStart, focusEnd]) -} - - -function processDateCondition(cond: _StatCondition, paramDate?: Date | [Date?, Date?]) { - if (!paramDate) return - - if (paramDate instanceof Date) { - cond.useExactDate = true - cond.exactDateStr = formatTimeYMD(paramDate as Date) - } else { - const [startDate, endDate] = paramDate - cond.useExactDate = false - startDate && (cond.startDateStr = formatTimeYMD(startDate)) - endDate && (cond.endDateStr = formatTimeYMD(endDate)) - } -} - -function processParamTimeCondition(cond: _StatCondition, paramTime?: [number, number?]) { - if (!paramTime) return - paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) - paramTime.length >= 1 && (cond.timeStart = paramTime[0]) -} - -function processParamFocusCondition(cond: _StatCondition, paramFocus?: Vector<2>) { - if (!paramFocus) return - paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) - paramFocus.length >= 1 && (cond.focusStart = paramFocus[0]) -} - -function processCondition(condition: StatCondition): _StatCondition { - const result: _StatCondition = { ...condition } - processDateCondition(result, condition.date) - processParamTimeCondition(result, condition.timeRange) - processParamFocusCondition(result, condition.focusRange) - return result -} - -/** - * Filter by query parameters - */ -export async function filter(this: StatDatabase, condition?: StatCondition, onlyGroup?: boolean): Promise<_FilterResult[]> { - const cond = processCondition(condition ?? {}) - const items = await this.refresh() - const result: _FilterResult[] = [] - Object.entries(items).forEach(([key, value]) => { - const date = key.substring(0, 8) - let host = key.substring(8) - if (onlyGroup) { - if (host.startsWith(GROUP_PREFIX)) { - host = host.substring(GROUP_PREFIX.length) - result.push({ date, host, value: value as timer.core.Result }) - } - } else if (!host.startsWith(GROUP_PREFIX)) { - result.push({ date, host, value: value as timer.core.Result }) - } - }) - return result.filter(item => filterByCond(item, cond)) -} diff --git a/src/database/stat-database/index.ts b/src/database/stat-database/index.ts deleted file mode 100644 index 75ac11eaf..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 525970e45..26c9980d9 100644 --- a/src/i18n/chrome/index.ts +++ b/src/i18n/chrome/index.ts @@ -27,6 +27,7 @@ const _default: { [locale in FakedLocale]: any } = { ar: compile(messages.ar), tr: compile(messages.tr), pl: compile(messages.pl), + it: compile(messages.it), } export default _default \ No newline at end of file diff --git a/src/i18n/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 8ef9da038..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'), @@ -18,12 +16,11 @@ const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> ar: () => import('element-plus/es/locale/lang/ar'), tr: () => import('element-plus/es/locale/lang/tr'), pl: () => import('element-plus/es/locale/lang/pl'), + it: () => import('element-plus/es/locale/lang/it'), } -export const initElementLocale = async (app: App) => { - 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 146739205..02b60b0a3 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,26 +5,25 @@ * 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", @@ -45,7 +44,7 @@ const chrome2I18n: { [key: string]: timer.Locale } = { 'pl': 'pl', } -const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { +const translationChrome2I18n: { [key: string]: tt4b.TranslatingLocale } = { ko: 'ko', it: 'it', sv: 'sv', @@ -65,22 +64,22 @@ const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { * Codes returned by getUILanguage() are defined by Chrome browser * @see https://github.com/unicode-cldr/cldr-localenames-modern/blob/master/main/en/languages.json * But supported locale codes in Chrome extension - * @see https://developer.chrome.com/docs/extensions/reference/api/i18n#locales + * @see https://developer.chrome.com/docs/extensions/reference/i18n#locales * * They are different, so translate */ -export function chromeLocale2ExtensionLocale(chromeLocale: string): timer.Locale { - if (!chromeLocale) { - return defaultLocale - } +function chromeLocale2ExtensionLocale(chromeLocale: string): 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 @@ -96,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) @@ -117,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) @@ -133,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) @@ -144,7 +143,7 @@ export function getI18nVal( return typeof result === 'string' ? result : JSON.stringify(result) } -export type TranslateProps = { +type TranslateProps = { key: I18nKey, param?: { [key: string]: string | number } } @@ -159,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 @@ -184,7 +183,7 @@ const findParamAndReplace = (resultArr: I18nResultItem[], [key, val return temp } -export type NodeTranslateProps = { +type NodeTranslateProps = { key: I18nKey, param: { [key: string]: I18nResultItem } } diff --git a/src/i18n/message/app/about-resource.json b/src/i18n/message/app/about-resource.json index c8e087b1a..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": { @@ -219,5 +219,22 @@ "rate": "Oceń 5 gwiazdek, aby pomóc innym dowiedzieć się o tym~~", "feedback": "Zapraszamy do przesłania opinii i próśb o funkcje !!" } + }, + "it": { + "label": { + "name": "Nome", + "version": "Versione", + "website": "Sito Web", + "installation": "Installa", + "thanks": "Riconoscimenti", + "privacy": "Informativa Privacy", + "license": "Licenza", + "support": "Supporto" + }, + "text": { + "greet": "Ti piace questa estensione?", + "rate": "Valuta 5 stelle per supportarci!", + "feedback": "Le tue idee ci rendono migliori!" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/analysis-resource.json b/src/i18n/message/app/analysis-resource.json index 92fc2d34b..3b5841056 100644 --- a/src/i18n/message/app/analysis-resource.json +++ b/src/i18n/message/app/analysis-resource.json @@ -401,5 +401,36 @@ "focusTitle": "Trendy czasu przeglądania", "visitTitle": "Trendy wizyt" } + }, + "it": { + "target": { + "site": "Sito web", + "cate": "Categoria" + }, + "common": { + "focusTotal": "Tempo totale di navigazione", + "visitTotal": "Visite totali", + "merged": "Uniti", + "virtual": "Virtuale", + "hostPlaceholder": "Cerca un sito da analizzare", + "emptyDesc": "Nessun sito selezionato" + }, + "summary": { + "title": "Riepilogo", + "day": "Giorni attivi in totale", + "firstDay": "Prima visita {value}", + "calendarTitle": "Attività nelle ultime settimane" + }, + "trend": { + "title": "Tendenze", + "activeDay": "Giorni attivi", + "totalDay": "Periodo: Giorni", + "maxFocus": "Tempo di navigazione massimo giornaliero", + "averageFocus": "Tempo medio di navigazione giornaliero", + "maxVisit": "Visite massime giornaliere", + "averageVisit": "Visite medie giornaliere", + "focusTitle": "Tendenze di Navigazione nel periodo", + "visitTitle": "Tendenze Di Visite" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index 131cdf741..b529abf78 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -76,20 +76,23 @@ }, "ja": { "heatMap": { - "title0": "過去1年間に {hour} 時間以上オンラインで過ごした", - "title1": "過去 1 年間にオンラインで費やした時間は 1 時間未満" + "title0": "去年は {hour} 時間以上閲覧した", + "title1": "過去1年間の閲覧時間は1時間未満です" }, "topK": { "title": "過去 {day} 日間に最も拜訪された TOP {k}" }, "indicator": { - "installedDays": "使用 {number} 日", + "installedDays": "{number}日間使用しました", "visitCount": "{site} つのサイトへの合計 {visit} 回の拜訪", "browsingTime": "{minute} 分以上ウェブを閲覧する", "mostUse": "{start}:00 から {end}:00 までのお気に入りのインターネットアクセス" }, "monthOnMonth": { "title": "閲覧時間の月間推移" + }, + "timeline": { + "focusScore": "フォーカス度" } }, "pt_PT": { @@ -203,8 +206,8 @@ "indicator": { "installedDays": "Installé depuis {number} jours", "visitCount": "{site} sites visités {visit} fois", - "browsingTime": "Parcouru pendant {minute} minutes", - "mostUse": "Navigation favorite entre {start} et {end}" + "browsingTime": "Visité pendant {minute} minutes", + "mostUse": "Navigation la plus fréquente entre {start} et {end} heure" }, "monthOnMonth": { "title": "Tendance mensuelle du temps de navigation" @@ -235,8 +238,11 @@ "title": "Время просмотра за последние 30 дней" }, "timeline": { + "title": "Лента времени последних {n} дней", "busyScore": "Занятость", - "focusScore": "Сфокусированность" + "busyScoreDesc": "Относится к общему времени просмотра и количеству веб-сайтов в час. Смотрите исходный код для формулы расчета", + "focusScore": "Сфокусированность", + "focusScoreDesc": "Относится к общему времени непрерывного просмотра того же веб-сайта. Смотрите исходный код для формулы расчета" } }, "ar": { @@ -293,7 +299,7 @@ "indicator": { "installedDays": "Zainstalowane przez {number} dni", "visitCount": "Odwiedzono {site} różnych stron, łącznie {visit} razy", - "browsingTime": "Przeglądano przez {minute} minut", + "browsingTime": "Przeglądano przez {minute} minut(y)", "mostUse": "Najchętniej przeglądano w godzinach od {start} do {end}" }, "monthOnMonth": { @@ -306,5 +312,30 @@ "focusScore": "Skupienie", "focusScoreDesc": "Powiązane z całkowitym czasem ciągłego przebywania na tej samej stronie. Wzór znajduje się w kodzie źródłowym" } + }, + "it": { + "heatMap": { + "title0": "Navigato per {hour} ore nell'ultimo anno", + "title1": "Navigato meno di 1 ora l'anno scorso" + }, + "topK": { + "title": "TOP {k} più visitati negli ultimi {day} giorni" + }, + "indicator": { + "installedDays": "Installato per {number} giorni", + "visitCount": "Visitato {site} {visit} volte", + "browsingTime": "Navigato per {minute} minuti", + "mostUse": "Navigazione preferita tra le {start} e le {end}" + }, + "monthOnMonth": { + "title": "Tendenza del tempo di navigazione mese per mese" + }, + "timeline": { + "title": "Cronologia degli ultimi {n} giorni", + "busyScore": "Frenesia", + "busyScoreDesc": "Relativo al tempo totale di navigazione e al numero di siti web per ora. Guarda il codice sorgente per la formula di calcolo", + "focusScore": "Focalizzazione", + "focusScoreDesc": "Relativo al tempo totale di navigazione continua dello stesso sito web. Guarda il codice sorgente per la formula di calcolo" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 8970871c5..30aa75db5 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", @@ -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": { @@ -264,6 +282,7 @@ "totalMemoryAlert": "Браузер предоставляет {size}MB для хранения локальных данных по каждому расширению", "totalMemoryAlert1": "Невозможно определить максимальный объём памяти, доступный браузером", "usedMemoryAlert": "{size}MB сейчас используется", + "idbAlert": "Переместить данные для отслеживания в IndexedDB для уменьшения использования хранилища", "operationAlert": "Вы можете удалить эти неважные данные для уменьшения использования памяти", "filterItems": "Фильтровать данные", "filterFocus": "Время просмотра дня между {start} секунд и {end} секунд", @@ -322,6 +341,7 @@ "totalMemoryAlert": "Tarayıcı, her uzantı için yerel verileri depolamak üzere {size} MB alan sağlar", "totalMemoryAlert1": "Tarayıcı tarafından izin verilen maksimum depolama alanı belirlenemiyor", "usedMemoryAlert": "Şu anda {size} MB kullanılıyor", + "idbAlert": "Depolama kullanımını azaltmak için verilerinizi IndexedDB'ye taşı", "operationAlert": "Depolama alanını azaltmak için önemsiz verileri silebilirsiniz", "filterItems": "Verileri filtrele", "filterFocus": "Günün gezinme süresi {start} saniye ile {end} saniye arasındadır", @@ -351,6 +371,7 @@ "totalMemoryAlert": "Przeglądarka udostępnia {size} MB do przechowywania lokalnych danych dla każdego rozszerzenia", "totalMemoryAlert1": "Nie można określić maksymalnej ilości pamięci udostępnianej przez przeglądarkę", "usedMemoryAlert": "{size} MB jest obecnie używanych", + "idbAlert": "Przenieś dane do IndexedDB, aby zmniejszyć zużycie pamięci", "operationAlert": "Możesz usunąć te nieważne dane, aby zmniejszyć zużycie pamięci", "filterItems": "Filtruj dane", "filterFocus": "Czas przeglądania w ciągu dnia wynosi od {start} do {end} sekund", @@ -375,5 +396,38 @@ "deleteConfirm": "Wyfiltrowano w sumie {count} wpisów. Czy chcesz je usunąć?", "migrationAlert": "Przenieś dane między przeglądarkami za pomocą importu i eksportu", "importError": "Błędne rozszerzenie pliku" + }, + "it": { + "totalMemoryAlert": "Il browser fornisce {size}MB per memorizzare i dati locali per ogni estensione", + "totalMemoryAlert1": "Impossibile determinare la memoria massima disponibile consentita dal browser", + "usedMemoryAlert": "{size}MB è attualmente in uso", + "idbAlert": "Sposta i dati di tracciamento su IndexedDB per ridurre l'utilizzo dello storage", + "operationAlert": "È possibile eliminare i dati non importanti per ridurre l'utilizzo della memoria", + "filterItems": "Filtro dati", + "filterFocus": "L'ora di navigazione del giorno è tra {start} secondi e {end} secondi", + "filterTime": "Il numero di visite per il giorno è compreso tra {start} e {end}", + "filterDate": "Registrato tra {picker}", + "importOther": { + "step1": "Seleziona dati", + "step2": "Conferma dei dati", + "dataSource": "Origine dati", + "file": "File di dati", + "selectFileBtn": "Seleziona", + "conflictType": "Risoluzione dei conflitti", + "conflictTip": "Cosa fare se i dati importati sono in conflitto con i dati locali", + "overwrite": "Sovrascrivere", + "accumulate": "Accumulare", + "imported": "Importato", + "local": "Locali", + "fileNotSelected": "File non selezionato", + "conflictNotSelected": "Risoluzione dei conflitti non selezionata" + }, + "paramError": "Il parametro è errato, per favore controlla!", + "deleteConfirm": "Un totale di {count} record sono stati filtrati. Vuoi eliminarli tutti?", + "migrationAlert": "Migrare i dati tra browser utilizzando importazione ed esportazione", + "importError": "Estensione del file sbagliata", + "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 09bb02c72..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" } }, @@ -460,6 +460,7 @@ "focusAverage": "Średnio {value} dziennie" }, "period": { + "title": "Nawyki w określonym przedziale czasowym", "busiest": "Najbardziej aktywny czas dnia", "idle": "Najdłuższy czas bezczynności", "chartType": { @@ -491,5 +492,43 @@ "siteCount": "Liczba stron" } } + }, + "it": { + "common": { + "focusAverage": "{value} medio giornaliera" + }, + "period": { + "title": "Abitudine per periodi di tempo", + "busiest": "Ora più attiva della giornata", + "idle": "Periodo d'inattività più lungo", + "chartType": { + "average": "Media giornaliera", + "trend": "Tendenza", + "stack": "Stack" + }, + "sizes": { + "fifteen": "Per 15 minuti", + "halfHour": "Per mezz'ora", + "hour": "Per un'ora", + "twoHour": "Per due ore" + } + }, + "site": { + "title": "Abitudine dei siti", + "histogramTitle": "TOP {n} più visitati", + "exclusiveToday": "I dati di oggi non sono inclusi nella media", + "countTotal": "Totale visite/siti", + "siteAverage": "Visite medie a {value} siti web al giorno", + "distribution": { + "title": "Distribuzione di frequenza media giornaliera", + "aveTime": "Tempo medio di navigazione giornaliero", + "aveVisit": "Numero medio di visite giornaliere", + "tooltip": "Totale: {value} siti" + }, + "trend": { + "title": "Tendenze quotidiane", + "siteCount": "Conteggio siti web" + } + } } } \ No newline at end of file diff --git a/src/i18n/message/app/help-us-resource.json b/src/i18n/message/app/help-us-resource.json index 26a4eee41..c417ed569 100644 --- a/src/i18n/message/app/help-us-resource.json +++ b/src/i18n/message/app/help-us-resource.json @@ -35,9 +35,9 @@ "uk": { "title": "Допоможіть поліпшити переклад розширення!", "alert": { - "l1": "Щоб забезпечити кращий досвід користувача, я розміщую завдання з перекладу на Crowdin.", - "l2": "Якщо вам корисне це розширення і ви бажаєте покращити його переклад, ви можете натиснути кнопку нижче, щоб перейти на домашню сторінку проєкту на Crowdin.", - "l3": "Коли прогрес перекладу мови досягне 50%, я розгляну можливість її підтримки в цьому розширенні." + "l1": "Для забезпечення доступності для ширшого кола користувачів я пропоную можливість перекладу на Crowdin.", + "l2": "Якщо вам корисне це розширення і ви бажаєте покращити його переклад, натисніть кнопку нижче, щоб перейти на домашню сторінку проєкту на Crowdin.", + "l3": "Коли прогрес перекладу мови досягне 50%, я розгляну можливість її активації в цьому розширенні." }, "button": "Перейти на Crowdin", "loading": "Перевірка перекладу...", @@ -134,12 +134,23 @@ "pl": { "title": "Możesz pomóc w ulepszaniu tłumaczeń tego rozszerzenia!", "alert": { - "l1": "Ze względu na umiejętności językowe autora, rozszerzenie domyślnie obsługuje jedynie język chiński uproszczony i angielski, natomiast pozostałe języki są albo nieobsługiwane, albo przetłumaczone maszynowo.", - "l2": "W celu zapewnienia lepszego doświadczenia użytkownikom hostuję pliki pliki do przetłumaczenia na platformie Crowdin. Crowdin to system zarządzania tłumaczeniami, który jest darmowy dla projektów open source.", - "l3": "Jeżeli uważasz, że to rozszerzenie jest dla Ciebie przydatne i chciałbyś poprawić jego tłumaczenie, kliknij przycisk poniżej, aby przejść do strony głównej projektu na Crowdin." + "l1": "W celu zapewnienia lepszego doświadczenia użytkownikom hostuję pliki do przetłumaczenia na platformie Crowdin.", + "l2": "Jeżeli uważasz, że to rozszerzenie jest dla Ciebie przydatne i chciałbyś poprawić jego tłumaczenie, kliknij przycisk poniżej, aby przejść do strony głównej projektu na Crowdin.", + "l3": "Kiedy postęp w tłumaczeniu języka osiągnie 50%, rozważę poparcie go w tym rozszerzeniu." }, "button": "Przejdź do Crowdin", "loading": "Sprawdzanie postępu tłumaczenia...", "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/index.ts b/src/i18n/message/app/index.ts index 77cb8a8a6..d5e0ff383 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -5,13 +5,13 @@ * https://opensource.org/licenses/MIT */ -import buttonMessages, { type ButtonMessage } from "@i18n/message/common/button" -import calendarMessages, { type CalendarMessage } from "@i18n/message/common/calendar" -import itemMessages, { type ItemMessage } from "@i18n/message/common/item" -import metaMessages, { type MetaMessage } from "@i18n/message/common/meta" -import sharedMessages, { type SharedMessage } from "@i18n/message/common/shared" -import baseMessages, { type BaseMessage } from "../common/base" +import baseMessages, { type BaseMessage } from "../base" +import buttonMessages, { type ButtonMessage } from "../button" +import calendarMessages, { type CalendarMessage } from "../common/calendar" +import metaMessages, { type MetaMessage } from "../common/meta" +import sharedMessages, { type SharedMessage } from '../common/shared' import limitModalMessages, { type ModalMessage } from "../cs/modal" +import itemMessages, { type ItemMessage } from "../item" import { merge, type MessageRoot } from "../merge" import aboutMessages, { type AboutMessage } from "./about" import analysisMessages, { type AnalysisMessage } from "./analysis" diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index eb2bb22ba..7db540cc8 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -1,24 +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": "未生效" }, @@ -47,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": "未生效" }, @@ -105,25 +97,20 @@ "reminder": "距離時間限制僅剩 {min} 分鐘!" }, "en": { - "filterDisabled": "Only enabled", + "onlyEffective": "Only Effective", "wildcardTip": "You can use wildcards to match subdomains or subpages, and use \"+\" as a prefix to exclude subpages!", "emptyTips": "Click here to create one rule!", "item": { "name": "Rule name", "condition": "Restricted URL", - "daily": "Daily limit", - "weekly": "Weekly limit", "weekStartInfo": "The first day of each week is {weekStart}, you can change this value in the tracking options", "delayCount": "Delay count", "detail": "Rule detail", "visitTime": "Time limit per visit", - "period": "Blocked periods", "enabled": "Enabled", "locked": "Locked", "effectiveDay": "Effective On", - "delayAllowed": "Delayable", - "delayAllowedInfo": "If it times out, allow a temporary delay of 5 minutes", - "visits": "visits", + "allowDelay": "Allow Delay", "or": "or", "notEffective": "Not effective" }, @@ -152,29 +139,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": "効果がない" }, @@ -192,7 +176,8 @@ "deleteConfirm": "ルール [{name}] を削除しますか?", "inputTestUrl": "最初にテストする URL リンクを入力してください", "noRuleMatched": "URL がどのルールとも一致しません", - "rulesMatched": "URL は次のルールに一致します。" + "rulesMatched": "URL は次のルールに一致します。", + "timeout": "時間切れです! XD" }, "verification": { "inputTip": "ルールがトリガーされたかロックされました。続行するには、次の質問に対する回答を {second} 秒以内に入力してください: {prompt}", @@ -207,25 +192,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" }, @@ -260,26 +239,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": "Загальна інформація", @@ -292,7 +267,7 @@ "message": { "noUrl": "Не заповнена обмежена URL-адреса", "noRule": "Не заповнено жодного правила", - "deleteConfirm": "Ви дійсно хочете видалити правило {cond}?", + "deleteConfirm": "Ви хочете видалити правило {name}?", "lockConfirm": "Якщо заблоковано, всі операції потребують перевірки, навіть якщо правило не спрацьовує.", "inputTestUrl": "Спочатку введіть URL-адресу посилання для перевірки", "noRuleMatched": "URL не відповідає жодному правилу", @@ -300,34 +275,31 @@ "timeout": "Час вийшов! XD" }, "verification": { + "inputTip": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть відповідь на зазначене запитання протягом {second} секунд: {prompt}", + "inputTip2": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть зазначене протягом {second} секунд: {answer}", "pswInputTip": "Правило обмеження вже було запущено. Щоб продовжити, введіть пароль", "strictTip": "Правило обмеження вже активовано і ручне розблокування не дозволено!", "incorrectPsw": "Неправильний пароль", "incorrectAnswer": "Неправильна відповідь", + "twoFaInputTip": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть код із 6 цифр зі своєї програми автентифікації.", + "incorrect2fa": "Неправильний код 2FA", "pi": "{digitCount} цифр від {startIndex} до {endIndex} десяткової частини числа π", "confession": "Час спливає" }, "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" }, @@ -342,7 +314,7 @@ "message": { "noUrl": "URL restringida sin completar", "noRule": "No hay reglas llenadas", - "deleteConfirm": "¿Deseas eliminar la regla de {cond}?", + "deleteConfirm": "¿Deseas eliminar la regla dé {name}?", "lockConfirm": "Si está bloqueado, todas las operaciones requerirán verificación incluso si no se activa la regla.", "inputTestUrl": "Por favor, introduce primero el enlace URL a ser probado", "noRuleMatched": "La URL no sigue ninguna regla", @@ -362,24 +334,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" }, @@ -408,31 +376,27 @@ "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 !", + "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" }, @@ -447,7 +411,7 @@ "message": { "noUrl": "Aucune URL de restriction configurée", "noRule": "Aucune règle remplie", - "deleteConfirm": "Voulez-vous supprimer la règle [{name}]?", + "deleteConfirm": "Voulez-vous supprimer la règle [{name}] ?", "lockConfirm": "Si verrouillé, toutes les opérations nécessiteront une vérification, même si la règle n'est pas activée.", "inputTestUrl": "Veuillez entrer le lien URL à tester en premier", "noRuleMatched": "L'URL ne correspond à aucune règle", @@ -467,24 +431,22 @@ "reminder": "Moins de {min} minutes jusqu'à la limite de temps !" }, "ru": { - "filterDisabled": "Только включен", + "onlyEffective": "Только эффективный режим", + "wildcardTip": "Вы можете использовать шаблоны для совпадения с поддоменами или подстраницами, а также использовать «+» в качестве префикса, чтобы исключить подстраницы!", "emptyTips": "Нажмите здесь, чтобы создать новое правило!", "item": { "name": "Имя правила", "condition": "Ограниченный URL", - "daily": "Дневной лимит", - "weekly": "Недельный лимит", "weekStartInfo": "Первый день каждой недели {weekStart}, вы можете изменить это значение в настройках статистики", "delayCount": "Отложенный", "detail": "Детали правила", "visitTime": "Лимит за посещение", - "period": "Заблокированное время", "enabled": "Включено", + "locked": "Заблокировано", "effectiveDay": "Эффективный", - "delayAllowed": "Еще 5 минут", - "delayAllowedInfo": "Если время истекло, разрешите временную задержку 5 минут", - "visits": "посещения", - "or": "или" + "allowDelay": "Разрешить задержку", + "or": "или", + "notEffective": "Не активировано" }, "step": { "base": "Основная информация", @@ -498,6 +460,7 @@ "noUrl": "Ограничение URL-адресов не настроено", "noRule": "Нет заполненных правил", "deleteConfirm": "Вы хотите удалить правило [{name}]?", + "lockConfirm": "Если заблокировано, то все операции потребуют проверки, даже если правило не срабатывает.", "inputTestUrl": "Пожалуйста, введите ссылку для тестирования", "noRuleMatched": "URL не содержит правил", "rulesMatched": "URL попадает в следующие правила:", @@ -510,29 +473,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": "غير فعال" }, @@ -567,25 +526,19 @@ "reminder": "أقل من {min} دقيقة على انتهاء الوقت المحدد!" }, "tr": { - "filterDisabled": "Yalnızca etkinleştirilmiş", "wildcardTip": "Alt alan adlarını veya alt sayfaları eşleştirmek için joker karakterler kullanabilir ve alt sayfaları hariç tutmak için “+” ön ekini kullanabilirsiniz!", "emptyTips": "Bir kural oluşturmak için burayı tıklayın!", "item": { "name": "Kural adı", "condition": "Kısıtlanmış URL", - "daily": "Günlük limit", - "weekly": "Haftalık limit", "weekStartInfo": "Her haftanın ilk günü {weekStart}, bu değeri istatistik seçeneklerinden değiştirebilirsiniz", "delayCount": "Gecikme sayısı", "detail": "Kural detayı", "visitTime": "Ziyaret başına zaman sınırı", - "period": "Engellenen periyotlar", "enabled": "Etkinleştirildi", "locked": "Kilitli", "effectiveDay": "Geçerli Olduğu Gün(ler)", - "delayAllowed": "Ertelenebilir", - "delayAllowedInfo": "Zaman aşımı olursa, 5 dakikalık geçici bir gecikmeye izin verin", - "visits": "ziyaretler", + "allowDelay": "Ertelenebilir", "or": "yada", "notEffective": "Geçerli değil" }, @@ -620,24 +573,19 @@ "reminder": "Zaman sınırı dolmasına {min} dakikadan az kaldı!" }, "pl": { - "filterDisabled": "Tylko aktywne", "wildcardTip": "Możesz używać symbolu \"*\", aby dopasowywać subdomeny lub podstrony, a także użyć znaku „+” jako prefiksu, aby wykluczyć podstrony!", + "emptyTips": "Kliknij tutaj, aby utworzyć zasadę!", "item": { "name": "Nazwa zasady", "condition": "Limitowany URL", - "daily": "Dzienny limit", - "weekly": "Limit tygodniowy", "weekStartInfo": "Pierwszym dniem każdego tygodnia jest {weekStart}, możesz zmienić tę wartość w opcjach śledzenia", "delayCount": "Licznik opóźnień", "detail": "Szczegóły zasady", "visitTime": "Limit czasu na wizytę", - "period": "Godziny blokowania", "enabled": "Aktywne", "locked": "Zablokowany", "effectiveDay": "Aktywna w", - "delayAllowed": "Możliwe do przedłużenia", - "delayAllowedInfo": "Jeżeli limit zostanie osiągnięty, pozwól na przedłużenie go o 5 minut", - "visits": "wizyt", + "allowDelay": "Możliwe do przedłużenia", "or": "lub", "notEffective": "Nieskuteczny" }, @@ -670,5 +618,55 @@ "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 d8ea5907d..92b06fcbd 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -8,7 +8,7 @@ import resource from './limit-resource.json' export type LimitMessage = { - filterDisabled: string + onlyEffective: string wildcardTip: string emptyTips: string step: { @@ -19,19 +19,14 @@ export type LimitMessage = { item: { name: string condition: string - daily: string - weekly: string weekStartInfo: string visitTime: string - period: string enabled: string locked: string effectiveDay: string - delayAllowed: string - delayAllowedInfo: string + allowDelay: string delayCount: string detail: string - visits: string or: string notEffective: string } @@ -55,21 +50,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 768597798..5924e2ef5 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -7,13 +7,11 @@ "dataClear": "数据管理", "behavior": "上网行为", "habit": "上网习惯", - "limit": "时间限制", "additional": "附加功能", "siteManage": "网站管理", "whitelist": "白名单管理", "mergeRule": "子域名合并", "other": "其他", - "helpUs": "帮助翻译", "about": "关于" }, "zh_TW": { @@ -24,13 +22,11 @@ "dataClear": "儲存狀況", "behavior": "用戶行爲", "habit": "習慣分析", - "limit": "時間限制", "additional": "附加功能", "siteManage": "網站管理", "whitelist": "白名單", "mergeRule": "網站合併規則", "other": "其他", - "helpUs": "協助翻譯", "about": "關於" }, "en": { @@ -41,13 +37,11 @@ "dataClear": "Storage", "behavior": "User Behavior", "habit": "Habits", - "limit": "Time Limit", "additional": "Additional Features", "siteManage": "Site Management", "whitelist": "Whitelist", "mergeRule": "Merge-site Rules", "other": "Other Features", - "helpUs": "Help Translation", "about": "About" }, "ja": { @@ -58,13 +52,11 @@ "dataClear": "記憶状況", "behavior": "ユーザーの行動", "habit": "閲覧の習慣", - "limit": "時間制限", "additional": "その他の機能", "siteManage": "ウェブサイト管理", "whitelist": "Webホワイトリスト", "mergeRule": "ドメイン合併", "other": "その他の機能", - "helpUs": "協力する", "about": "について" }, "pt_PT": { @@ -75,13 +67,11 @@ "dataClear": "Armazenamento", "behavior": "Comportamento", "habit": "Hábitos", - "limit": "Limite de Tempo", "additional": "Funcionalidades", "siteManage": "Gestão de Sites", "whitelist": "Lista Branca", "mergeRule": "Regras de Agrupamento", "other": "Outras Opções", - "helpUs": "Ajudar a Traduzir", "about": "Sobre" }, "uk": { @@ -92,13 +82,11 @@ "dataClear": "Стан пам'яті", "behavior": "Поведінка", "habit": "Звички", - "limit": "Обмеження часу", "additional": "Додаткові функції", "siteManage": "Керування сайтами", "whitelist": "Білий список", "mergeRule": "Правила об'єднання сайтів", "other": "Інші функції", - "helpUs": "Допомогти нам", "about": "Про нас" }, "es": { @@ -109,13 +97,11 @@ "dataClear": "Estado de la memoria", "behavior": "Comportamiento del usuario", "habit": "Hábitos", - "limit": "Límite de tiempo", "additional": "Funciones adicionales", "siteManage": "Gestión de sitios", "whitelist": "Lista blanca", "mergeRule": "Reglas de fusión de sitios", "other": "Otras Funciones", - "helpUs": "Ayúdanos", "about": "Acerca de" }, "de": { @@ -126,13 +112,11 @@ "dataClear": "Speicherstatus", "behavior": "Nutzerverhalten", "habit": "Gewohnheit", - "limit": "Zeitlimit", "additional": "Zusatzfunktionen", "siteManage": "Websites", "whitelist": "Whitelist", "mergeRule": "Regeln zusammenführen", "other": "Andere Eigenschaften", - "helpUs": "Hilfe bei der Übersetzung", "about": "Über uns" }, "fr": { @@ -143,13 +127,11 @@ "dataClear": "Situation de la mémoire", "behavior": "Comportement de l'utilisateur", "habit": "Habitudes", - "limit": "Limite de temps", "additional": "Fonctionnalités supplémentaires", "siteManage": "Gestion des sites", - "whitelist": "Whitelist", + "whitelist": "Liste blanche", "mergeRule": "Fusionner les règles du site", "other": "Autres fonctionnalités", - "helpUs": "Aider à la traduction", "about": "À propos" }, "ru": { @@ -160,13 +142,11 @@ "dataClear": "Память о ситуации", "behavior": "Поведение", "habit": "Привычки", - "limit": "Ограничение по времени", "additional": "Дополнительный", "siteManage": "Управление сайтом", "whitelist": "Белый список", "mergeRule": "Объединение сайтов", "other": "Другие особенности", - "helpUs": "Помогите перевести", "about": "О нас" }, "ar": { @@ -177,13 +157,11 @@ "dataClear": "حالة الذاكرة", "behavior": "سلوك المستخدم", "habit": "العادات", - "limit": "المهلة", "additional": "ميزات إضافية", "siteManage": "أدوار النظام", "whitelist": "القائمة البيضاء", "mergeRule": "دمج قواعد الموقع", "other": "مميزات اخزي", - "helpUs": "ساعدني في التَّرْجَمَةً", "about": "حول" }, "tr": { @@ -194,13 +172,11 @@ "dataClear": "Depolama", "behavior": "Kullanıcı Davranışları", "habit": "Alışkanlıklar", - "limit": "Zaman Sınırı", "additional": "Ek Özellikler", "siteManage": "Site Yönetimi", "whitelist": "Beyaz Liste", "mergeRule": "Birleştirme Kuralları", "other": "Diğer Özellikler", - "helpUs": "Çeviriye yardım et", "about": "Hakkımızda" }, "pl": { @@ -211,13 +187,26 @@ "dataClear": "Pamięć", "behavior": "Zachowanie użytkownika", "habit": "Nawyki", - "limit": "Limit czasu", "additional": "Dodatkowe funkcje", "siteManage": "Zarządzanie stronami", "whitelist": "Whitelista", "mergeRule": "Reguły łączenia stron", "other": "Pozostałe funkcje", - "helpUs": "Pomóż w tłumaczeniu", "about": "O rozszerzeniu" + }, + "it": { + "dashboard": "Dashboard", + "data": "Miei Dati", + "dataReport": "Record", + "siteAnalysis": "Analisi Sito", + "dataClear": "Memoria", + "behavior": "Comportamento Dell'Utente", + "habit": "Abitudini", + "additional": "Funzionalità aggiuntive", + "siteManage": "Gestione del sito", + "whitelist": "Whitelist", + "mergeRule": "Raggruppa regole dei siti", + "other": "Altre funzioni", + "about": "Info su" } } \ No newline at end of file diff --git a/src/i18n/message/app/menu.ts b/src/i18n/message/app/menu.ts index 72b61bdb8..3c0807740 100644 --- a/src/i18n/message/app/menu.ts +++ b/src/i18n/message/app/menu.ts @@ -15,13 +15,11 @@ export type MenuMessage = { dataClear: string behavior: string habit: string - limit: string additional: string siteManage: string whitelist: string mergeRule: string other: string - helpUs: string about: string } diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json index d58fed4c7..418b50957 100644 --- a/src/i18n/message/app/merge-rule-resource.json +++ b/src/i18n/message/app/merge-rule-resource.json @@ -45,7 +45,7 @@ "duplicateMsg": "The rule already exists: {origin}", "addConfirmMsg": "Customized merge rules will be set for {origin}", "infoAlertTitle": "the merge rules when counting sites on this page", - "infoAlert0": "Click the [New One] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", + "infoAlert0": "Click the [New] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", "infoAlert1": "The original site can be filled with a specific site or regular expression, such as www.baidu.com, *.baidu.com, *.google.com.*, to determine which sites will match this rule while merging", "infoAlert2": "The merged site can be filled with a specific site, a number or blank", "infoAlert3": "A number means the level of merged site. For example, there is a rule '*.*.edu.cn >>> 3', then 'www.hust.edu.cn' will be merged to 'hust.edu.cn'", @@ -160,11 +160,11 @@ "addConfirmMsg": "Les règles de fusion personnalisées seront définies pour {origin}", "infoAlertTitle": "Vous pouvez définir les règles de fusion lorsque vous comptez des sites sur cette page", "infoAlert0": "Cliquez sur le bouton [Nouveau], les boîtes de saisie du site source et le site de fusion sera affiché, remplissez et enregistrez la règle", - "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion.", + "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion", "infoAlert2": "Le site fusionné peut être rempli avec un site spécifique, un numéro ou vide", "infoAlert3": "Un nombre signifie le niveau du site fusionné. Par exemple, il existe une règle '*.*.edu.cn >>> 3', alors 'www.hust.edu.cn' sera fusionné avec 'hust.edu.cn'", "infoAlert4": "Vide signifie que le site d'origine ne sera pas fusionné", - "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}.", + "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}", "tagResult": { "blank": "Non Fusionner", "level": "Garder le niveau {level}" @@ -238,6 +238,31 @@ "infoAlert0": "Kliknij przycisk [New One], pola wprowadzania strony źródłowej oraz strony scalenia zostaną wyświetlone, wypełnij i zapisz regułę", "infoAlert1": "Oryginalna strona może być wypełniona konkretną stroną lub wyrażeniem regularnym, takim jak *.baidu.com, *.google.com.*, aby określić, które witryny będą odpowiadać tej regule podczas scalania", "infoAlert2": "Scalona witryna może być wypełniona określoną witryną, liczbą lub zostawiona pusta", - "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'" + "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'", + "infoAlert4": "Puste oznacza, że oryginalna strona nie zostanie połączona", + "infoAlert5": "Jeśli żadna reguła nie jest dopasowana, będzie domyślnie przed {psl}", + "tagResult": { + "blank": "Nie scalaj", + "level": "Zachowaj poziom {level}" + } + }, + "it": { + "removeConfirmMsg": "{origin} Verrà rimosso dalle regole di unione personalizzate.", + "originPlaceholder": "Sito originale", + "mergedPlaceholder": "Uniti", + "errorOrigin": "Il formato del sito originale non è valido.", + "duplicateMsg": "La regola esiste già: {origin}", + "addConfirmMsg": "Verranno impostate regole di unione personalizzate per {origin}", + "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/operation-resource.json b/src/i18n/message/app/operation-resource.json index 2b6c0b8c7..ff1eed3c6 100644 --- a/src/i18n/message/app/operation-resource.json +++ b/src/i18n/message/app/operation-resource.json @@ -50,5 +50,9 @@ "pl": { "confirmTitle": "Potwierdzenie", "successMsg": "Pomyślnie!" + }, + "it": { + "confirmTitle": "Conferma", + "successMsg": "Con successo!" } } \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index a91b5dff0..cb1d2d639 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -2,7 +2,10 @@ "zh_CN": { "yes": "是", "no": "否", + "on": "始终开启", + "off": "始终关闭", "followBrowser": "跟随浏览器", + "permGrantConfirm": "该功能需要授予相关权限", "appearance": { "title": "外观", "displayWhitelist": "{input} 是否在 {contextMenu} 里,显示 {whitelist} 相关功能", @@ -24,11 +27,7 @@ }, "darkMode": { "label": "夜间模式 {input}", - "options": { - "on": "始终开启", - "off": "始终关闭", - "timed": "定时开启" - } + "timed": "定时开启" }, "animationDuration": "图表初始动画的时长 {input}", "sidePanel": "{input} 是否启用侧边栏视图" @@ -42,10 +41,11 @@ "localFilesInfo": "支持 PDF、图片、txt 以及 json 等格式", "countTabGroup": "{input} 是否统计标签组的时间 {info}", "tabGroupInfo": "删除标签组后,数据也会被删除", - "tabGroupsPermGrant": "该功能需要授予相关权限", "fileAccessDisabled": "目前不允许访问文件网址,请先在管理界面开启", "weekStart": "每周的第一天 {input}", - "weekStartAsNormal": "按照惯例" + "weekStartAsNormal": "按照惯例", + "storage": "将数据存储在 {input} 中", + "storageConfirm": "是否要将存储类型更改为 {type}?" }, "limit": { "prompt": "受限时显示的提示文本 {input}", @@ -66,17 +66,20 @@ "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" }, @@ -117,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": "导出设置", @@ -131,7 +150,10 @@ "zh_TW": { "yes": "是", "no": "否", + "on": "始終開啟", + "off": "始終關閉", "followBrowser": "跟隨瀏覽器", + "permGrantConfirm": "此功能需要相關權限才能運作", "appearance": { "title": "外觀設定", "displayWhitelist": "{input} 是否在 {contextMenu} 顯示 {whitelist} 功能", @@ -153,26 +175,25 @@ }, "darkMode": { "label": "深色模式 {input}", - "options": { - "on": "始終開啟", - "off": "始終關閉", - "timed": "定時開啟" - } + "timed": "定時開啟" }, - "animationDuration": "圖表動畫持續時間 {input}" + "animationDuration": "圖表動畫持續時間 {input}", + "sidePanel": "{input}是否開啟側邊面板" }, "tracking": { "title": "統計設定", "autoPauseTrack": "{input} 若 {maxTime} 內無活動 {info} 則暫停追蹤", + "noActivityInfo": "滑鼠跟鍵盤非使用中,未處於全螢幕模式,且沒有聲音撥放中", "countLocalFiles": "{input} 是否統計瀏覽器 {localFileTime} {info}", "localFileTime": "讀取本機檔案時間", "localFilesInfo": "支援 PDF、圖片、txt 及 json 等格式", "countTabGroup": "{input} 是否追蹤分頁群組的時間 {info}", "tabGroupInfo": "刪除分頁群組時,相關的時間追蹤數據也會一併清除。", - "tabGroupsPermGrant": "此功能需要相關權限才能運作", "fileAccessDisabled": "目前不允許存取檔案 URL,請至管理頁面啟用", "weekStart": "每週起始日 {input}", - "weekStartAsNormal": "依慣例" + "weekStartAsNormal": "依慣例", + "storage": "將追蹤資料儲存至{input}", + "storageConfirm": "您想將儲存格式改成{type}嗎?" }, "limit": { "prompt": "限制時顯示提示文字 {input}", @@ -201,9 +222,6 @@ "type": "雲端服務類型 {input}", "client": "用戶端識別碼 {input}", "meta": { - "none": { - "label": "關閉備份" - }, "gist": { "authInfo": "需建立至少包含 gist 權限的 token" }, @@ -244,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": "匯出設定", @@ -258,7 +292,10 @@ "en": { "yes": "Yes", "no": "No", + "on": "Always on", + "off": "Always off", "followBrowser": "Follow browser", + "permGrantConfirm": "This feature requires relevant permissions", "appearance": { "title": "Appearance", "displayWhitelist": "{input} Whether to display {whitelist} in {contextMenu}", @@ -280,11 +317,7 @@ }, "darkMode": { "label": "Dark mode {input}", - "options": { - "on": "Always on", - "off": "Always off", - "timed": "Timed on" - } + "timed": "Timed on" }, "animationDuration": "The duration of the chart's initial animation {input}", "sidePanel": "{input} Whether to enable the side panel" @@ -297,11 +330,12 @@ "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", "weekStart": "The first day for each week {input}", - "weekStartAsNormal": "As Normal" + "weekStartAsNormal": "As Normal", + "storage": "Store the tracking data in {input}", + "storageConfirm": "Do you want to change the storage type to {type}?" }, "limit": { "prompt": "Prompt displayed when restricted {input}", @@ -322,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" }, @@ -374,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", @@ -388,6 +442,8 @@ "ja": { "yes": "はい", "no": "いいえ", + "on": "常にオン", + "off": "常にオフ", "followBrowser": "ブラウザと同じ", "appearance": { "title": "外観", @@ -410,11 +466,7 @@ }, "darkMode": { "label": "ダークモード {input}", - "options": { - "on": "常にオン", - "off": "常にオフ", - "timed": "時限スタート" - } + "timed": "時限スタート" }, "animationDuration": "チャートの初期アニメーションの持続時間 {input}" }, @@ -426,7 +478,8 @@ "localFilesInfo": "PDF、画像、txt、jsonを含む", "fileAccessDisabled": "ファイル URL へのアクセスは現在許可されていません。まず管理ページで有効にしてください。", "weekStart": "週の最初の日 {input}", - "weekStartAsNormal": "いつものように" + "weekStartAsNormal": "いつものように", + "storageConfirm": "ストレージタイプを {type}に変更しますか?" }, "limit": { "prompt": "制限時に表示されるプロンプト {input}", @@ -455,9 +508,6 @@ "type": "バックアップ方法 {input}", "client": "クライアント名 {input}", "meta": { - "none": { - "label": "バックアップを有効にしない" - }, "gist": { "authInfo": "少なくとも gist 権限を持つトークンが 1 つ必要です" }, @@ -498,6 +548,20 @@ "title": "ユーザー補助機能", "chartDecal": "{input} チャートデカールを表示するかどうか" }, + "notification": { + "title": "通知", + "cycle": { + "daily": "毎日", + "weekly": "毎週" + }, + "method": { + "label": "通知方法{input}", + "browser": "ブラウザ", + "callback": { + "label": "HTTP Callback" + } + } + }, "resetButton": "リセット", "resetSuccess": "デフォルトに正常にリセット", "exportButton": "設定をエクスポート", @@ -512,7 +576,10 @@ "pt_PT": { "yes": "Sim", "no": "Não", + "on": "Sempre ativo", + "off": "Sempre inativo", "followBrowser": "Usar do navegador", + "permGrantConfirm": "Esta funcionalidade precisa de permissões relevantes", "appearance": { "title": "Aparência", "displayWhitelist": "{input} Mostrar {whitelist} no {contextMenu}", @@ -534,11 +601,7 @@ }, "darkMode": { "label": "Modo escuro {input}", - "options": { - "on": "Sempre ativo", - "off": "Sempre inativo", - "timed": "Ativo por tempo" - } + "timed": "Ativo por tempo" }, "animationDuration": "Duração da animação inicial {input}", "sidePanel": "{input} Se ativar ou não o painel lateral" @@ -552,7 +615,6 @@ "localFilesInfo": "Suporta PDF, imagens, txt e json.", "countTabGroup": "{input} Se quiser acompanhar o tempo da aba{info}", "tabGroupInfo": "Quando você excluir um grupo de tags, os dados também serão excluídos.", - "tabGroupsPermGrant": "Esta funcionalidade precisa de permissões relevantes", "fileAccessDisabled": "Acesso a URLs de ficheiro não permitido. Ative na página de gestão.", "weekStart": "Primeiro dia da semana {input}", "weekStartAsNormal": "Normal" @@ -584,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" }, @@ -641,7 +700,10 @@ "uk": { "yes": "Так", "no": "Ні", + "on": "Завжди ввімкнено", + "off": "Завжди вимкнено", "followBrowser": "Як у браузері", + "permGrantConfirm": "Ця функція потребує відповідних дозволів", "appearance": { "title": "Зовнішній вигляд", "displayWhitelist": "{input} Показувати {whitelist} в {contextMenu}", @@ -663,11 +725,7 @@ }, "darkMode": { "label": "Темний режим: {input}", - "options": { - "on": "Увімкнено", - "off": "Вимкнено", - "timed": "За розкладом" - } + "timed": "За розкладом" }, "animationDuration": "Тривалість початкової анімації діаграми {input}" }, @@ -679,7 +737,6 @@ "localFilesInfo": "Підтримуються файли PDF, зображення, текстові та формат json", "countTabGroup": "{input} Відстежувати час груп вкладок {info}", "tabGroupInfo": "Якщо видалити групу вкладок, дані також видаляться.", - "tabGroupsPermGrant": "Ця функція потребує відповідних дозволів", "fileAccessDisabled": "Доступ до URL-адрес файлу наразі не дозволено. Спершу ввімкніть на сторінці керування", "weekStart": "Перший день тижня: {input}", "weekStartAsNormal": "Типово" @@ -711,9 +768,6 @@ "type": "Тип резервного копіювання {input}", "client": "Назва клієнта {input}", "meta": { - "none": { - "label": "Завжди вимкнено" - }, "gist": { "authInfo": "Потрібно вказати токен для доступу gist" }, @@ -768,7 +822,10 @@ "es": { "yes": "Sí", "no": "No", + "on": "Siempre encendido", + "off": "Siempre apagado", "followBrowser": "Igual que el navegador", + "permGrantConfirm": "Esta función requiere permisos pertinentes", "appearance": { "title": "Apariencia", "displayWhitelist": "{input} Mostrar {whitelist} en {contextMenu}", @@ -790,11 +847,7 @@ }, "darkMode": { "label": "Modo oscuro {input}", - "options": { - "on": "Siempre encendido", - "off": "Siempre apagado", - "timed": "Cronometrado" - } + "timed": "Cronometrado" }, "animationDuration": "Duración de la animación inicial del gráfico {input}" }, @@ -806,7 +859,6 @@ "localFilesInfo": "Soporta archivos de tipos como PDF, imagen, TXT y JSON", "countTabGroup": "{input} Rastrear el tiempo de los grupos de pestañas {info}", "tabGroupInfo": "Al eliminar un grupo de pestañas, sus datos también se borrarán.", - "tabGroupsPermGrant": "Esta función requiere permisos pertinentes", "fileAccessDisabled": "Actualmente no se permite el acceso a las URL de archivos. Habilítelo primero en la página de administración", "weekStart": "El primer día de cada semana {input}", "weekStartAsNormal": "Como normalmente" @@ -838,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" }, @@ -895,7 +944,10 @@ "de": { "yes": "Ja", "no": "Nein", + "on": "Immer an", + "off": "Immer aus", "followBrowser": "Browser verfolgen", + "permGrantConfirm": "Dieses Feature benötigt entsprechende Berechtigungen", "appearance": { "title": "Aussehen", "displayWhitelist": "{input} {whitelist} in {contextMenu} anzeigen", @@ -903,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!", @@ -917,26 +969,25 @@ }, "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}" + "animationDuration": "Die Dauer der ersten Animation des Diagramms {input}", + "sidePanel": "{input} Ob das Seitenpanel aktiviert wird" }, "tracking": { "title": "Statistik", "autoPauseTrack": "{input} Tracking pausieren, wenn {maxTime} keine Aktivität erkannt wird {info}", + "noActivityInfo": "Maus und Tastatur sind inaktiv, nicht im Vollbildmodus und es gibt kein Audio", "countLocalFiles": "{input} Zeit an {localFileTime} {info} im Browser zählen", "localFileTime": "eine lokale Datei lesen", "localFilesInfo": "Unterstützt Dateitypen, wie PDF, Bilder, .txt und .json", "countTabGroup": "{input} Ob die Zeit der Tab-Gruppen {info} verfolgt werden soll", "tabGroupInfo": "Wenn Sie eine Tag-Gruppe löschen, werden auch die Daten gelöscht.", - "tabGroupsPermGrant": "Dieses Feature benötigt entsprechende Berechtigungen", "fileAccessDisabled": "Der Zugriff auf Datei-URLs ist derzeit nicht erlaubt. Bitte aktiviere ihn zuerst auf der Verwaltungsseite", "weekStart": "Erster Tag der Woche {input}", - "weekStartAsNormal": "Wie normal" + "weekStartAsNormal": "Wie normal", + "storage": "Speichern der Trackingdaten in {input}", + "storageConfirm": "Möchten Sie die Speicherart ändern zu {type}?" }, "limit": { "prompt": "Eingabeaufforderung wird angezeigt, wenn eingeschränkt {input}", @@ -957,17 +1008,20 @@ "strictTitle": "Operation bestätigen", "strictContent": "Wenn Sie diese Option wählen, wenn eine Site Tageslimit auslöst, Sie dürfen die Blockierung nur bis zum nächsten Tag manuell entsperren. Wenn Regeln nicht richtig eingerichtet sind, können sie sehr gut Ihre Routinen behindern!", "pswFormLabel": "Passwort", - "pswFormAgain": "Erneut eingeben" - } + "pswFormAgain": "Erneut eingeben", + "2fa": "Zum Entsperren ist ein 2FA‑Code erforderlich", + "twoFaTitle": "Zwei Faktor Authentifikation - 2FA nutzen", + "twoFaScanHint": "Scanne den QR‑Code mit einer Authentifikation‑App oder importiere den Einrichtungslink in einen TOTP‑fähigen Passwortmanager.", + "twoFaCopyLink": "Link kopieren", + "twoFaVerifyLabel": "Zur Bestätigung den 6‑stelligen Code eingeben" + }, + "delayDuration": "Verzögerung für {input} Minuten pro Vorgang" }, "backup": { "title": "Datensicherung", "type": "Remote Typ {input}", "client": "Kundenname {input}", "meta": { - "none": { - "label": "Immer aus" - }, "gist": { "authInfo": "Ein Token mit mindestens gist Berechtigung ist erforderlich" }, @@ -1008,6 +1062,22 @@ "title": "Barrierefreiheit", "chartDecal": "{input} Ob das Diagramm-Aufkleber angezeigt werden soll" }, + "notification": { + "title": "Benachrichtigungen", + "cycle": { + "label": "Benachrichtigungszyklus {input}", + "daily": "Täglich", + "weekly": "Wöchentlich" + }, + "method": { + "label": "Nachrichtenmethode {input}", + "browser": "Browser", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Zurücksetzen", "resetSuccess": "Auf Standardwerte zurückgesetzt!", "exportButton": "Einstellungen exportieren", @@ -1022,13 +1092,16 @@ "fr": { "yes": "Oui", "no": "Non", + "on": "Toujours activé", + "off": "Toujours éteint", "followBrowser": "Suivre le navigateur", + "permGrantConfirm": "Cette fonctionnalité nécessite des autorisations appropriées", "appearance": { "title": "Apparence", - "displayWhitelist": "{input} S'il faut afficher là {whitelist} dans {contextMenu}", - "whitelistItem": "whitelist related shortcuts", - "contextMenu": "le menu contextuel.", - "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", @@ -1038,17 +1111,13 @@ "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 activé", - "off": "Toujours éteint", - "timed": "Horaire" - } + "timed": "Horaire" }, "animationDuration": "La durée de l'animation initiale du graphique {input}", "sidePanel": "{input} Autoriser l'affichage du panneau latéral" @@ -1057,21 +1126,22 @@ "title": "Statistiques", "autoPauseTrack": "{input} Pause de suivi si aucune activité n'a détecté {info} pour {maxTime}", "noActivityInfo": "La souris et le clavier sont inactifs, pas en plein écran, et il n'y a pas de son joué", - "countLocalFiles": "{input} S'il faut compter le temps jusqu'à {localFileTime} {info} dans le navigateur", + "countLocalFiles": "{input} S'il faut compter le temps passé à {localFileTime} {info} dans le navigateur", "localFileTime": "lire un fichier local", "localFilesInfo": "Prend en charge les fichiers de types tels que PDF, image, txt et json.", "countTabGroup": "{input} Suivre le temps des groupes d'onglets {info}", "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.", + "fileAccessDisabled": "L'accès aux URL des fichiers n'est actuellement pas autorisé. Veuillez d'abord l'activer dans la page de gestion", "weekStart": "Le premier jour de chaque semaine {input}", - "weekStartAsNormal": "Comme d'habitude" + "weekStartAsNormal": "Comme d'habitude", + "storage": "Stocker les données de suivi dans {input}", + "storageConfirm": "Voulez-vous changer le type de stockage en {type} ?" }, "limit": { "prompt": "Invite affichée en cas de restriction {input}", "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", @@ -1091,12 +1161,9 @@ }, "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 +1202,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 !", @@ -1151,6 +1234,8 @@ "ru": { "yes": "Да", "no": "Нет", + "on": "Всегда включен", + "off": "Всегда выключен", "followBrowser": "Как в браузере", "appearance": { "title": "Появление", @@ -1173,50 +1258,103 @@ }, "darkMode": { "label": "Тёмный режим {input}", - "options": { - "on": "Всегда включен", - "off": "Всегда выключен", - "timed": "По времени" - } + "timed": "По времени" }, - "animationDuration": "Длительность начальной анимации графика {input}" + "animationDuration": "Длительность начальной анимации графика {input}", + "sidePanel": "{input} включает ли боковую панель" }, "tracking": { "title": "Статистика", "autoPauseTrack": "{input} Приостановить отслеживание, если нет активности {info} в течение {maxTime}", + "noActivityInfo": "Мышь и клавиатура неактивны, не в полноэкранном режиме, и нет воспроизводимого звука.", + "countLocalFiles": "{input} Отслеживать ли время, когда браущер читает {localFileTime} {info}", "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json.", "countTabGroup": "{input} Отслеживать время для групп вкладок {info}", - "weekStartAsNormal": "Как обычно (Воскресенье)" + "tabGroupInfo": "При удалении группы вкладок данные также будут удалены.", + "fileAccessDisabled": "Доступ к URL-адресам файлов в настоящее время запрещен. Пожалуйста, сначала включите его на странице управления", + "weekStart": "Первый день каждой недели {input}", + "weekStartAsNormal": "Как обычно (Воскресенье)", + "storage": "Сохранять данные отслеживания в {input}", + "storageConfirm": "Вы хотите изменить тип хранилища на {type}" }, "limit": { + "prompt": "Шаблон, отображаемый при ограниченных {input}", + "reminder": "{input} Напоминать за {minInput} минут до окончания времени", "level": { + "label": "Как разблокировать при ограничении {input}", + "nothing": "Разрешить прямую разблокировку на странице администратора", + "password": "Необходимо ввести пароль для разблокировки", + "verification": "Необходимо ввести проверочный код для разблокировки", + "passwordLabel": "Пароль для разблокировки {input}", + "verificationLabel": "Сложность проверочного кода {input}", "verificationDifficulty": { "easy": "Лёгкий", - "hard": "Сложный" - } + "hard": "Сложный", + "disgusting": "Отвратительный" + }, + "strict": "Не разрешать разблокировку при любых обстоятельствах", + "strictTitle": "Подтверждение операции", + "strictContent": "При выборе этой опции, если сайт вызывает ежедневное ограничение, вам не будет разрешено разблокировать его вручную, кроме ожидания, до следующего дня. Если правила настроены неправильно, они могут очень затруднять ваши процедуры!", + "pswFormLabel": "Пароль", + "pswFormAgain": "Введите повторно" } }, "backup": { "title": "Резервное копирование данных", + "type": "Дистанционный тип {input}", + "client": "Наименование клиента {input}", + "meta": { + "gist": { + "authInfo": "Необходим хотя бы один Gist токен с разрешением" + }, + "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": "Экспорт настроек", @@ -1231,6 +1369,8 @@ "ar": { "yes": "نعم", "no": "لا", + "on": "تفعيل دائم", + "off": "إيقاف دائم", "followBrowser": "نفس وضع المتصفح", "appearance": { "title": "المظهر", @@ -1253,11 +1393,7 @@ }, "darkMode": { "label": "الوضع الداكن {input}", - "options": { - "on": "تفعيل دائم", - "off": "إيقاف دائم", - "timed": "تفعيل مؤقت" - } + "timed": "تفعيل مؤقت" }, "animationDuration": "مدة تحريك الرسوم البيانية {input}" }, @@ -1269,7 +1405,6 @@ "localFilesInfo": "يدعم ملفات مثل PDF، الصور، txt و json.", "countTabGroup": "{input} هل يتم تتبع وقت مجموعات علامات التبويب {info}", "tabGroupInfo": "عند حذف مجموعة علامات تبويب، سيتم حذف البيانات أيضًا.", - "tabGroupsPermGrant": "هذه الميزة تتطلب أذونات ذات صلة", "fileAccessDisabled": "الوصول إلى عناوين الملفات غير مسموح به حاليًا. يرجى تفعيل الخاصية من صفحة الإدارة أولاً", "weekStart": "اليوم الأول لكل أسبوع {input}", "weekStartAsNormal": "بشكل عادي" @@ -1301,9 +1436,6 @@ "type": "نوع الربط الخارجي {input}", "client": "اسم العميل {input}", "meta": { - "none": { - "label": "معطّل دائمًا" - }, "gist": { "authInfo": "مطلوب رمز وصول واحد على الأقل مع صلاحية Gist" }, @@ -1358,7 +1490,10 @@ "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", @@ -1380,11 +1515,7 @@ }, "darkMode": { "label": "Karanlık mod {input}", - "options": { - "on": "Her zaman açık", - "off": "Her zaman kapalı", - "timed": "Zamanlanmış" - } + "timed": "Zamanlanmış" }, "animationDuration": "Grafiğin ilk animasyonunun süresi {input}", "sidePanel": "{input} Yan panelin gösterilip gösterilmeyeceğini seçin" @@ -1398,10 +1529,11 @@ "localFilesInfo": "PDF, resim, txt ve json gibi dosya türlerini destekler.", "countTabGroup": "{input} Sekme gruplarının zamanını takip etmek isteyip istemediğinizi seçin {info}", "tabGroupInfo": "Bir etiket grubunu sildiğinizde, veriler de silinir.", - "tabGroupsPermGrant": "Bu özellik ilgili izinleri gerektirir", "fileAccessDisabled": "Dosya URL'lerine erişim şu anda izin verilmiyor. Lütfen önce yönetim sayfasında bunu etkinleştirin", "weekStart": "Her haftanın ilk günü {input}", - "weekStartAsNormal": "Normal" + "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}", @@ -1430,9 +1562,6 @@ "type": "Uzaktan yedekleme tipi {input}", "client": "Uygulama adı {input}", "meta": { - "none": { - "label": "Her zaman kapalı" - }, "gist": { "authInfo": "Gist iznine sahip bir token gereklidir" }, @@ -1473,6 +1602,22 @@ "title": "Erişilebilirlik", "chartDecal": "{input} Grafik çıkartmasını gösterip göstermemeyi seçin" }, + "notification": { + "title": "Bildirim", + "cycle": { + "label": "Bildirim döngüsü {input}", + "daily": "Günlük", + "weekly": "Haftalık" + }, + "method": { + "label": "Bildirim methodu {input}", + "browser": "Tarayıcı", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Sıfırla", "resetSuccess": "Varsayılan ayarlara sıfırlama işlemi başarıyla tamamlandı!", "exportButton": "Ayarları Dışa Aktar", @@ -1487,12 +1632,17 @@ "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": { @@ -1507,34 +1657,75 @@ }, "darkMode": { "label": "Tryb ciemny {input}", - "options": { - "on": "Zawsze włączone", - "off": "Zawsze wyłączone", - "timed": "Czas na" - } + "timed": "Czas na" }, - "animationDuration": "Długość trwania początkowej animacji wykresu {input}" + "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": { - "none": { - "label": "Zawsze wyłączone" + "gist": { + "authInfo": "Wymagany jest przynajmniej jeden token z gist" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Tylko HTTP jest dostępny, ponieważ CORS nie może być skonfigurowany dla stron rozszerzenia" } }, "label": { "endpoint": "Adres punktu końcowego {info} {input}", + "path": "Ścieżka do folderu {input}", "account": "Nazwa użytkownika {input}", "password": "Hasło {input}" }, "operation": "Kopia zapasowa", "download": { "btn": "Pobierz", - "willDownload": "Do pobrania" + "willDownload": "Do pobrania", + "confirmTip": "{size} części danych z [{clientName}] zostaną pobrane" }, "clear": { - "btn": "Wyczyść" + "btn": "Wyczyść", + "confirmTip": "{rowCount} części danych dla stron {hostCount} śledzonych przez [{clientName}] zostaną usunięte!" }, "confirmStep": "Potwierdź dane", "clientTable": { @@ -1550,7 +1741,24 @@ } }, "accessibility": { - "title": "Ułatwienia dostępu" + "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!", @@ -1562,5 +1770,153 @@ "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 b067ac162..898d193af 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -9,7 +9,10 @@ import resource from './option-resource.json' export type OptionMessage = { yes: string no: string + on: string + off: string followBrowser: string + permGrantConfirm: string appearance: { title: string // whitelist @@ -33,7 +36,7 @@ export type OptionMessage = { }, darkMode: { label: string - options: Omit, 'default'> + timed: string } animationDuration: string sidePanel: string @@ -47,40 +50,45 @@ export type OptionMessage = { localFilesInfo: string countTabGroup: string tabGroupInfo: string - tabGroupsPermGrant: string fileAccessDisabled: string weekStart: string weekStartAsNormal: string + storage: string + storageConfirm: string } 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 } } @@ -117,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/report-resource.json index a257efd0b..1141f68f7 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/report-resource.json @@ -64,6 +64,7 @@ }, "ja": { "exportFileName": "私のウェブ時間データ", + "total": "合計訪問回数: {visit} 回、合計時間: {focus}", "batchDelete": { "noSelectedMsg": "最初にテーブルで削除する行にチェックマークを付けてください", "confirmMsg": "{date} の {example} のようなサイトの {count} レコードは削除されます!", @@ -251,10 +252,44 @@ }, "pl": { "exportFileName": "My_Browsing_Time", + "total": "Całkowita liczba wizyt: {visit}, całkowity czas trwania: {focus}", + "batchDelete": { + "noSelectedMsg": "Proszę najpierw wybrać wiersz, który chcesz usunąć z tabeli", + "confirmMsg": "{count} rekord/y/ów takich jak [{example}] z {date} zostaną usunięte!", + "confirmMsgAll": "{count} rekord/y/ów takich jak [{example}] zostaną usunięte!", + "confirmMsgRange": "{count} rekord/y/ów takich jak [{example}] pomiędzy {start} i {end} zostaną usunięte!" + }, "remoteReading": { + "on": "Czytanie zdalnych kopii zapasowych", + "off": "Kliknij, aby przeczytać zdalne kopie zapasowe", "table": { - "client": "Nazwa klienta" + "client": "Nazwa klienta", + "localData": "Lokalne Dane", + "value": "Wartość", + "percentage": "Procent" } - } + }, + "noMore": "Nie więcej" + }, + "it": { + "exportFileName": "My_Browsing_Time", + "total": "Visite totali: {visit} volte, durata totale: {focus}", + "batchDelete": { + "noSelectedMsg": "Si prega di selezionare la riga che si desidera eliminare nella prima tabella", + "confirmMsg": "{count} record come [{example}] di {date} saranno eliminati!", + "confirmMsgAll": "{count} record come [{example}], saranno eliminati!", + "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/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 53c5f912e..745564ff2 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -408,7 +408,10 @@ } }, "cate": { - "removeConfirm": "Вы уверены, что хотите удалить категорию: {category}?" + "name": "Наименование", + "relatedMsg": "Эта категория была связана с {siteCount} сайтами и не может быть удалена", + "removeConfirm": "Вы уверены, что хотите удалить категорию: {category}?", + "batchChange": "Изменить категории" }, "form": { "emptyAlias": "Введите название сайта", @@ -508,12 +511,89 @@ } }, "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 477ff4514..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" @@ -76,5 +76,11 @@ "hour": "Wyświetl w godzinach", "minute": "Wyświetl w minutach", "second": "Wyświetl w sekundach" + }, + "it": { + "default": "Formato orario predefinito", + "hour": "Visualizza in ore", + "minute": "Visualizza in minuti", + "second": "Visualizza in secondi" } } \ No newline at end of file diff --git a/src/i18n/message/app/time-format.ts b/src/i18n/message/app/time-format.ts index 9a8c456e7..f726bea85 100644 --- a/src/i18n/message/app/time-format.ts +++ b/src/i18n/message/app/time-format.ts @@ -7,7 +7,7 @@ import resource from './time-format-resource.json' -export type TimeFormatMessage = { [key in timer.app.TimeFormat]: string } +export type TimeFormatMessage = { [key in tt4b.app.TimeFormat]: string } const _default: Messages = resource diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json index cce864819..b574ce547 100644 --- a/src/i18n/message/app/whitelist-resource.json +++ b/src/i18n/message/app/whitelist-resource.json @@ -119,9 +119,21 @@ "pl": { "addConfirmMsg": "{url} zostanie dodany do whitelisty.", "removeConfirmMsg": "{url} zostanie usunięty z whitelisty.", + "duplicateMsg": "Duplikat", "infoAlertTitle": "Na tej stronie możesz dodawać strony do whitelisty", "infoAlert0": "Strony znajdujące się na whiteliście nie będą liczone", "infoAlert1": "Strony znajdujące się na whiteliście nie będą limitowane", + "infoAlert2": "Możesz użyć symboli wieloznacznych(*) do dopasowania wielu witryn, takich jak *.przykład.com/**, i użyć + jako prefiks do wykluczenia witryn, takich jak +potrzebny.przykład.com/**", "errorInput": "Niepoprawny URL strony" + }, + "it": { + "addConfirmMsg": "{url} sarà aggiunto alla whitelist.", + "removeConfirmMsg": "{url} sarà rimosso dalla whitelist.", + "duplicateMsg": "Duplicato", + "infoAlertTitle": "È possibile aggiungere alla whitelist questa pagina", + "infoAlert0": "I siti in whitelist non saranno conteggiati", + "infoAlert1": "I siti in whitelist non saranno conteggiati", + "infoAlert2": "È possibile utilizzare un jolly(*) per abbinare più siti, come *.example.com/**, e utilizzare + come prefisso per escludere i siti, come +need.example.com/**", + "errorInput": "URL del sito non valido" } } \ No newline at end of file diff --git a/src/i18n/message/base.ts b/src/i18n/message/base.ts new file mode 100644 index 000000000..8a3aeacbf --- /dev/null +++ b/src/i18n/message/base.ts @@ -0,0 +1 @@ +export { default, type BaseMessage } from "./common/base" diff --git a/src/i18n/message/button.ts b/src/i18n/message/button.ts new file mode 100644 index 000000000..6fb6911fe --- /dev/null +++ b/src/i18n/message/button.ts @@ -0,0 +1 @@ +export { default, type ButtonMessage } from "./common/button" diff --git a/src/i18n/message/calendar.ts b/src/i18n/message/calendar.ts new file mode 100644 index 000000000..cdfb56765 --- /dev/null +++ b/src/i18n/message/calendar.ts @@ -0,0 +1 @@ +export { default, type CalendarMessage } from "./common/calendar" diff --git a/src/i18n/message/common/base-resource.json b/src/i18n/message/common/base-resource.json index 14b2a21c9..43190cda2 100644 --- a/src/i18n/message/common/base-resource.json +++ b/src/i18n/message/common/base-resource.json @@ -5,7 +5,9 @@ "guidePage": "User Guide", "option": "Options", "sourceCode": "Source Code", - "changeLog": "Release Notes" + "changeLog": "Release Notes", + "limit": "Time Limit", + "helpUs": "Help Translation" }, "zh_CN": { "sidebar": "侧边栏", @@ -13,7 +15,9 @@ "guidePage": "使用指南", "option": "设置", "sourceCode": "源代码", - "changeLog": "版本日志" + "changeLog": "版本日志", + "limit": "时间限制", + "helpUs": "帮助翻译" }, "zh_TW": { "sidebar": "側邊欄", @@ -21,7 +25,9 @@ "guidePage": "使用指南", "option": "設定", "sourceCode": "原始碼", - "changeLog": "版本紀錄" + "changeLog": "版本紀錄", + "limit": "時間限制", + "helpUs": "協助翻譯" }, "ja": { "sidebar": "サイドバー", @@ -29,7 +35,9 @@ "guidePage": "使い方ガイド", "option": "設定", "sourceCode": "ソースコード", - "changeLog": "リリースノート" + "changeLog": "リリースノート", + "limit": "時間制限", + "helpUs": "協力する" }, "pt_PT": { "sidebar": "Barra Lateral", @@ -37,7 +45,9 @@ "guidePage": "Guia do Utilizador", "option": "Definições", "sourceCode": "Código Fonte", - "changeLog": "Notas de Lançamento" + "changeLog": "Notas de Lançamento", + "limit": "Limite de Tempo", + "helpUs": "Ajudar a Traduzir" }, "uk": { "sidebar": "Бічна панель", @@ -45,7 +55,9 @@ "guidePage": "Посібник", "option": "Налаштування", "sourceCode": "Програмний код", - "changeLog": "Журнал змін" + "changeLog": "Журнал змін", + "limit": "Обмеження часу", + "helpUs": "Допомогти нам" }, "es": { "sidebar": "Barra Lateral", @@ -53,7 +65,9 @@ "guidePage": "Guía de Usuario", "option": "Ajustes", "sourceCode": "Código Fuente", - "changeLog": "Notas de Versión" + "changeLog": "Notas de Versión", + "limit": "Límite de tiempo", + "helpUs": "Ayúdanos" }, "de": { "sidebar": "Seitenleiste", @@ -61,7 +75,9 @@ "guidePage": "Benutzeranleitung", "option": "Einstellungen", "sourceCode": "Quellcode", - "changeLog": "Versionshinweise" + "changeLog": "Versionshinweise", + "limit": "Zeitlimit", + "helpUs": "Hilfe bei der Übersetzung" }, "fr": { "sidebar": "Barre Latérale", @@ -69,7 +85,9 @@ "guidePage": "Guide d'Utilisation", "option": "Paramètres", "sourceCode": "Code Source", - "changeLog": "Notes de Version" + "changeLog": "Notes de Version", + "limit": "Limite de temps", + "helpUs": "Aider à la traduction" }, "ru": { "sidebar": "Боковая панель", @@ -77,7 +95,9 @@ "guidePage": "Руководство", "option": "Настройки", "sourceCode": "Исходный код", - "changeLog": "Версионные изменения" + "changeLog": "Версионные изменения", + "limit": "Ограничение по времени", + "helpUs": "Помогите перевести" }, "ar": { "sidebar": "الشريط الجانبي", @@ -85,7 +105,9 @@ "guidePage": "دليل الاستخدام", "option": "الإعدادات", "sourceCode": "الكود المصدري", - "changeLog": "ملاحظات الإصدار" + "changeLog": "ملاحظات الإصدار", + "limit": "المهلة", + "helpUs": "ساعدني في التَّرْجَمَةً" }, "tr": { "sidebar": "Kenar Çubuğu", @@ -93,6 +115,28 @@ "guidePage": "Kullanım Kılavuzu", "option": "Seçenekler", "sourceCode": "Kaynak Kodu", - "changeLog": "Sürüm Notları" + "changeLog": "Sürüm Notları", + "limit": "Zaman Sınırı", + "helpUs": "Çeviriye yardım et" + }, + "pl": { + "sidebar": "Pasek boczny", + "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 b29a6a85f..8cd1e0656 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -12,7 +12,7 @@ "cancel": "Cancel", "previous": "Back", "next": "Next", - "okey": "OK", + "okay": "OK", "dont": "No", "operation": "Actions", "configuration": "Settings", @@ -21,7 +21,8 @@ "batchEnable": "Batch Enable", "batchDisable": "Batch Disable", "collapse": "Collapse", - "expand": "Expand" + "expand": "Expand", + "copy": "Copy" }, "zh_CN": { "create": "新建", @@ -36,7 +37,7 @@ "cancel": "取消", "previous": "上一步", "next": "下一步", - "okey": "确定", + "okay": "确定", "dont": "取消", "operation": "操作", "configuration": "设置", @@ -45,7 +46,8 @@ "batchEnable": "批量启用", "batchDisable": "批量禁用", "collapse": "折叠", - "expand": "展开" + "expand": "展开", + "copy": "复制" }, "zh_TW": { "create": "新增", @@ -60,17 +62,20 @@ "cancel": "取消", "previous": "上一步", "next": "下一步", - "okey": "確定", + "okay": "確定", "dont": "取消", "operation": "操作", "configuration": "設定", "clear": "清除", "enable": "啟用", "batchEnable": "批次啟用", - "batchDisable": "批次停用" + "batchDisable": "批量停用", + "collapse": "收合", + "expand": "展開" }, "ja": { "create": "新規", + "add": "追加", "delete": "削除", "batchDelete": "一括削除", "modify": "編集", @@ -81,13 +86,16 @@ "cancel": "キャンセル", "previous": "戻る", "next": "次へ", - "okey": "OK", + "okay": "OK", "dont": "いいえ", "operation": "操作", "configuration": "設定", "clear": "クリア", + "enable": "有効化", "batchEnable": "一括有効", - "batchDisable": "一括無効" + "batchDisable": "一括無効", + "collapse": "折りたたむ", + "expand": "展開" }, "pt_PT": { "create": "Criar", @@ -102,7 +110,6 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Seguinte", - "okey": "OK", "dont": "Não", "operation": "Ações", "configuration": "Configurações", @@ -126,7 +133,7 @@ "cancel": "Скасувати", "previous": "Назад", "next": "Далі", - "okey": "Гаразд", + "okay": "Гаразд", "dont": "НІ", "operation": "Дії", "configuration": "Налаштування", @@ -148,7 +155,6 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Siguiente", - "okey": "OK", "dont": "No", "operation": "Acciones", "configuration": "Configuración", @@ -172,7 +178,7 @@ "cancel": "Abbrechen", "previous": "Zurück", "next": "Weiter", - "okey": "OK", + "okay": "OK", "dont": "Nein", "operation": "Aktionen", "configuration": "Einstellungen", @@ -181,7 +187,8 @@ "batchEnable": "Stapelaktivierung", "batchDisable": "Stapeldeaktivierung", "collapse": "Schließen", - "expand": "Aufklappen" + "expand": "Aufklappen", + "copy": "Kopieren" }, "fr": { "create": "Nouveau", @@ -196,7 +203,6 @@ "cancel": "Annuler", "previous": "Retour", "next": "Suivant", - "okey": "OK", "dont": "Non", "operation": "Opérations", "configuration": "Paramètres", @@ -209,6 +215,7 @@ }, "ru": { "create": "Создать", + "add": "Добавить", "delete": "Удалить", "batchDelete": "Массовое удаление", "modify": "Редактировать", @@ -219,11 +226,12 @@ "cancel": "Отменить", "previous": "Назад", "next": "Далее", - "okey": "ОК", + "okay": "ОК", "dont": "Нет", "operation": "Действия", "configuration": "Настройки", "clear": "Очистить", + "enable": "Включить", "batchEnable": "Массовое включение", "batchDisable": "Массовое отключение" }, @@ -240,7 +248,7 @@ "cancel": "إلغاء", "previous": "السابق", "next": "التالي", - "okey": "موافق", + "okay": "موافق", "dont": "لا", "operation": "إجراءات", "configuration": "إعدادات", @@ -262,7 +270,7 @@ "cancel": "İptal Et", "previous": "Geri", "next": "İleri", - "okey": "Tamam", + "okay": "Tamam", "dont": "Hayır", "operation": "Eylemler", "configuration": "Ayarlar", @@ -272,5 +280,54 @@ "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 b897fae63..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 @@ -29,6 +29,7 @@ export type ButtonMessage = { 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 75ae0a5d9..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", @@ -269,10 +276,59 @@ "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 72f6fad81..3c1f9813b 100644 --- a/src/i18n/message/common/context-menus-resource.json +++ b/src/i18n/message/common/context-menus-resource.json @@ -58,5 +58,15 @@ "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 dee10ecd0..ebecb809a 100644 --- a/src/i18n/message/common/initial-resource.json +++ b/src/i18n/message/common/initial-resource.json @@ -94,5 +94,21 @@ "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 b8bc3e475..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,10 +56,7 @@ "analysis": "分析", "deleteConfirmMsgAll": "{url} の全ての訪問記録を削除します", "deleteConfirmMsgRange": "{url} の {start} から {end} までの訪問記録を削除します", - "deleteConfirmMsg": "{url} の {date} の訪問記録を削除します", - "exportWholeData": "エクスポート", - "importWholeData": "インポート", - "importOtherData": "拡張機能からインポート" + "deleteConfirmMsg": "{url} の {date} の訪問記録を削除します" } }, "pt_PT": { @@ -82,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": { @@ -100,10 +86,7 @@ "analysis": "Аналіз", "deleteConfirmMsgAll": "Усі записи для {url} будуть видалені!", "deleteConfirmMsgRange": "Усі записи для {url} між {start} і {end} будуть видалені!", - "deleteConfirmMsg": "Запис для {url} {date} буде видалено!", - "exportWholeData": "Експортувати дані", - "importWholeData": "Імпортувати дані", - "importOtherData": "Імпорт з інших розширень" + "deleteConfirmMsg": "Запис для {url} {date} буде видалено!" } }, "es": { @@ -118,10 +101,7 @@ "analysis": "Analizar", "deleteConfirmMsgAll": "Se eliminarán todos los registros de {url}", "deleteConfirmMsgRange": "Se eliminarán los registros de {url} entre {start} y {end}", - "deleteConfirmMsg": "Se eliminará el registro de {url} del {date}", - "exportWholeData": "Exportar", - "importWholeData": "Importar", - "importOtherData": "Importar de extensiones" + "deleteConfirmMsg": "Se eliminará el registro de {url} del {date}" } }, "de": { @@ -136,10 +116,7 @@ "analysis": "Analyse", "deleteConfirmMsgAll": "Alle Datensätze von {url} werden gelöscht", "deleteConfirmMsgRange": "Alle Einträge von {url} zwischen {start} und {end} werden gelöscht", - "deleteConfirmMsg": "Der Eintrag von {url} am {date} wird gelöscht", - "exportWholeData": "Exportieren", - "importWholeData": "Importieren", - "importOtherData": "Aus Erweiterungen importieren" + "deleteConfirmMsg": "Der Eintrag von {url} am {date} wird gelöscht" } }, "fr": { @@ -150,13 +127,11 @@ "run": "Durée d'exécution", "time": "Visites", "operation": { - "add2Whitelist": "Whitelist", + "add2Whitelist": "Liste blanche", "analysis": "Analyser", "deleteConfirmMsgAll": "Tous les enregistrements de {url} seront supprimés", "deleteConfirmMsgRange": "Les enregistrements de {url} entre {start} et {end} seront supprimés", - "exportWholeData": "Exporter", - "importWholeData": "Importer", - "importOtherData": "Depuis d'autres extensions" + "deleteConfirmMsg": "L'enregistrement des visites pour [{url}] du [{date}] seront supprimés" } }, "ru": { @@ -171,10 +146,7 @@ "analysis": "Анализ", "deleteConfirmMsgAll": "Все записи {url} будут удалены", "deleteConfirmMsgRange": "Записи {url} с {start} по {end} будут удалены", - "deleteConfirmMsg": "Запись {url} за {date} будет удалена", - "exportWholeData": "Экспорт", - "importWholeData": "Импорт", - "importOtherData": "Импорт из дополнений" + "deleteConfirmMsg": "Запись {url} за {date} будет удалена" } }, "ar": { @@ -189,10 +161,7 @@ "analysis": "تحليل", "deleteConfirmMsgAll": "سيتم حذف جميع سجلات {url}", "deleteConfirmMsgRange": "سيتم حذف سجلات {url} بين {start} و{end}", - "deleteConfirmMsg": "سيتم حذف سجل {url} بتاريخ {date}", - "exportWholeData": "تصدير", - "importWholeData": "استيراد", - "importOtherData": "استيراد من إضافات أخرى" + "deleteConfirmMsg": "سيتم حذف سجل {url} بتاريخ {date}" } }, "tr": { @@ -207,10 +176,37 @@ "analysis": "Analiz et", "deleteConfirmMsgAll": "[{url}] adresine ait tüm ziyaret kayıtları silinecektir", "deleteConfirmMsgRange": "{start} ile {end} arasındaki [{url}] ziyaret kayıtları silinecektir", - "deleteConfirmMsg": "{date} tarihindeki [{url}] için ziyaret kaydı silinecektir", - "exportWholeData": "Dışa Aktar", - "importWholeData": "İçe aktar", - "importOtherData": "Uzantılardan içe aktar" + "deleteConfirmMsg": "{date} tarihindeki [{url}] için ziyaret kaydı silinecektir" + } + }, + "pl": { + "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 74b0791d7..382d71596 100644 --- a/src/i18n/message/common/locale-resource.json +++ b/src/i18n/message/common/locale-resource.json @@ -45,18 +45,19 @@ }, "tr": { "name": "Türkçe", - "comma": "," + "comma": ", " }, "pl": { "name": "Polski", - "comma": "," + "comma": ", " + }, + "it": { + "name": "italiano", + "comma": ", " }, "ko": { "name": "한국인" }, - "it": { - "name": "italiano" - }, "sv": { "name": "Sverige" }, diff --git a/src/i18n/message/common/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 45e5b580d..8a2c5d558 100644 --- a/src/i18n/message/common/meta-resource.json +++ b/src/i18n/message/common/meta-resource.json @@ -1,18 +1,18 @@ { "zh_CN": { "name": "网费很贵", - "marketName": "网费很贵 - 上网时间统计", + "marketName": "网费很贵 - 屏幕习惯分析 & 成瘾网站拦截", "description": "追踪网页时间,分析浏览习惯,防止网站沉迷,提高工作效率" }, "zh_TW": { "name": "網費很貴", - "marketName": "網費很貴 - 上網時間統計", + "marketName": "網費很貴 - 螢幕習慣分析 & 成癮網站攔截", "description": "追蹤上網時間,分析瀏覽習慣,避免沉迷網站,提升工作效率" }, "ja": { "name": "タイムトラッカー", "marketName": "タイムトラッカー - Web習慣ビルダー", - "description": "時間を追跡し、あなたの習慣を分析して、中毒性の高いサイトをブロック" + "description": "時間を追跡し、習慣を分析し、依存性のあるサイトをブロック" }, "en": { "name": "Time Tracker", @@ -26,23 +26,23 @@ }, "uk": { "name": "Веб-трекер часу", - "marketName": "Веб-трекер часу", - "description": "Відстежуйте, аналізуйте, контролюйте, а потім підвищуйте ефективність" + "marketName": "Веб-трекер часу - Творець веб-звичок", + "description": "Відстежуйте час, аналізуйте звички та блокуйте сайти, що викликають залежність" }, "es": { "name": "Rastreador de tiempo web", - "marketName": "Rastreador de tiempo web", - "description": "Realice un seguimiento, analice, controle y luego mejore la eficiencia" + "marketName": "Rastreador de tiempo web - Creador de hábitos web", + "description": "Realice un seguimiento 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": "Трекер времени", @@ -52,16 +52,21 @@ "ar": { "name": "متتبع الوقت", "marketName": "متتبع الوقت - منشئ عادات الويب", - "description": "تتبع وتحليل ومراقبة ثم تحسين الكفاءة" + "description": "تتبع الوقت، حلل عاداتك، واحظر المواقع الإدمانية" }, "tr": { "name": "Zaman Takipçisi", "marketName": "Zaman Takipçisi – Alışkanlık Oluşturucu ve Site Engelleyici", - "description": "Zamanınızı takip edin, alışkanlıklarınızı analiz edin, bağımlılık yapan siteleri engelleyin." + "description": "Zamanınızı takip edin, alışkanlıklarınızı analiz edin, bağımlılık yapan siteleri engelleyin" }, "pl": { "name": "Zegar Czasu", "marketName": "Zegar Czasu - Kreator Nawyków Internetowych", "description": "Śledź czas, analizuj swoje nawyki i blokuj uzależniające strony" + }, + "it": { + "name": "Tracker del Tempo", + "marketName": "Tracker del Tempo - Costruttore di Abitudini Web", + "description": "Traccia il tempo, analizza le tue abitudini e blocca i siti che creano dipendenza" } } \ No newline at end of file diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index 314ec4a48..d8bf773bd 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -12,6 +12,13 @@ }, "cate": { "notSet": "Not Set" + }, + "limit": { + "limited": "Limited", + "daily": "Daily limit", + "weekly": "Weekly limit", + "period": "Blocked periods", + "visits": "{n} visits" } }, "zh_CN": { @@ -27,6 +34,13 @@ }, "cate": { "notSet": "未分类" + }, + "limit": { + "limited": "已受限", + "daily": "每日上限", + "weekly": "每周上限", + "period": "不可访问的时间段", + "visits": "{n}次访问" } }, "ja": { @@ -36,11 +50,19 @@ "date": "日期", "domain": "URL", "cate": "カテゴリー", + "group": "タブグループ", "notMerge": "不合并" } }, "cate": { "notSet": "未設定" + }, + "limit": { + "limited": "制限あり", + "daily": "1日の限度", + "weekly": "週間の限度", + "period": "許可されない期間", + "visits": "{n}訪問" } }, "zh_TW": { @@ -56,6 +78,12 @@ }, "cate": { "notSet": "未設定" + }, + "limit": { + "daily": "每日上限", + "weekly": "每週上限", + "period": "限制時段", + "visits": "{n}次造訪" } }, "pt_PT": { @@ -71,6 +99,12 @@ }, "cate": { "notSet": "Não Definido" + }, + "limit": { + "daily": "Limite diário", + "weekly": "Limite semanal", + "period": "Períodos bloqueados", + "visits": "{n} visitas" } }, "uk": { @@ -86,6 +120,34 @@ }, "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": { + "limited": "Limitata", + "daily": "Limite giornaliero", + "weekly": "Limite settimanale", + "period": "Periodi bloccati", + "visits": "{n} visite" } }, "es": { @@ -101,11 +163,17 @@ }, "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", @@ -116,6 +184,13 @@ }, "cate": { "notSet": "Nicht festgelegt" + }, + "limit": { + "limited": "Begrenzt", + "daily": "Tägliches Limit", + "weekly": "Wöchentliches Limit", + "period": "Unzulässiger Zeitraum", + "visits": "{n} Besuche" } }, "fr": { @@ -131,6 +206,12 @@ }, "cate": { "notSet": "Non Défini" + }, + "limit": { + "daily": "Limite quotidienne", + "weekly": "Limite hebdomadaire", + "period": "Périodes bloquées", + "visits": "{n} visites" } }, "ru": { @@ -146,6 +227,12 @@ }, "cate": { "notSet": "Не задано" + }, + "limit": { + "daily": "Дневной лимит", + "weekly": "Недельный лимит", + "period": "Заблокированное время", + "visits": "{n} посещения" } }, "ar": { @@ -161,6 +248,12 @@ }, "cate": { "notSet": "غير مضبوط" + }, + "limit": { + "daily": "الحد اليومي", + "weekly": "الحد الأسبوعي", + "period": "فترات محظورة", + "visits": "{n} زيارة" } }, "tr": { @@ -176,6 +269,33 @@ }, "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..2d19e3169 100644 --- a/src/i18n/message/common/shared.ts +++ b/src/i18n/message/common/shared.ts @@ -3,11 +3,18 @@ import resource from "./shared-resource.json" export type SharedMessage = { merge: { mergeBy: string - mergeMethod: Record & { notMerge: string } + mergeMethod: Record & { notMerge: string } } cate: { notSet: string } + limit: { + limited: string + 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 a851db9a6..73ee5c041 100644 --- a/src/i18n/message/cs/console-resource.json +++ b/src/i18n/message/cs/console-resource.json @@ -48,6 +48,11 @@ "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..5fdfbfa88 100644 --- a/src/i18n/message/cs/index.ts +++ b/src/i18n/message/cs/index.ts @@ -1,7 +1,8 @@ import limitMessages, { type LimitMessage } from "../app/limit" import menuMessages, { type MenuMessage } from "../app/menu" -import calendarMessages, { type CalendarMessage } from "../common/calendar" +import calendarMessages, { type CalendarMessage } from "../calendar" import metaMessages, { type MetaMessage } from "../common/meta" +import sharedMessages, { type SharedMessage } from '../common/shared' import { merge, type MessageRoot } from "../merge" import 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 d7cb7a6da..2615fb8e7 100644 --- a/src/i18n/message/cs/modal-resource.json +++ b/src/i18n/message/cs/modal-resource.json @@ -1,74 +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!", - "more5Minutes": "5 dakika daha", + "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/item.ts b/src/i18n/message/item.ts new file mode 100644 index 000000000..fdc430cd1 --- /dev/null +++ b/src/i18n/message/item.ts @@ -0,0 +1 @@ +export { default, type ItemMessage } from "./common/item" diff --git a/src/i18n/message/merge.ts b/src/i18n/message/merge.ts index eef4fd75a..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, @@ -12,14 +12,15 @@ const ALL_LOCALE_VALIDATOR: { [locale in timer.Locale]: 0 } = { ar: 0, tr: 0, pl: 0, + it: 0, } -export const ALL_LOCALES: timer.Locale[] = Object.keys(ALL_LOCALE_VALIDATOR) as timer.Locale[] +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 @@ -27,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 e223ce3d7..63d98d367 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -9,7 +9,8 @@ "lastDays": "近 {n} 天数据", "allTime": "全部数据" }, - "saveAsImageTitle": "保存", + "shareTitle": "分享", + "installTip": "扫描安装 👉", "averageCount": "平均每天 {value} 次", "averageTime": "平均每天 {value}", "totalTime": "共 {totalTime}", @@ -18,6 +19,15 @@ }, "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,6 @@ "lastDays": "Les données de ces {n} derniers jours", "allTime": "Toutes les données" }, - "saveAsImageTitle": "Capture d'écran", "averageCount": "{value} fois par jour en moyenne", "averageTime": "{value} par jour en moyenne", "totalTime": "{totalTime} au total", @@ -198,7 +242,6 @@ "lastDays": "Данные за последние {n} дней", "allTime": "Все данные" }, - "saveAsImageTitle": "Снимок", "averageCount": "{value} раз в день в среднем", "averageTime": "{value} в день в среднем", "totalTime": "Всего {totalTime}", @@ -219,7 +262,6 @@ "lastDays": "بيانات آخر {n} يوم", "allTime": "كل البيانات" }, - "saveAsImageTitle": "لقطة", "averageCount": "متوسط {value} زيارة يومياً", "averageTime": "متوسط {value} يومياً", "totalTime": "الإجمالي {totalTime}", @@ -240,7 +282,6 @@ "lastDays": "Son {n} günün verileri", "allTime": "Tüm Veriler" }, - "saveAsImageTitle": "Grafiği İndir", "averageCount": "Günde ortalama {value} kez", "averageTime": "Günde ortalama {value}", "totalTime": "Toplam Zaman {totalTime}", @@ -250,5 +291,56 @@ "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 2b3a5c4b3..1e1fa85e8 100644 --- a/src/i18n/message/popup/content.ts +++ b/src/i18n/message/popup/content.ts @@ -5,13 +5,18 @@ * https://opensource.org/licenses/MIT */ -import type { PopupDuration } from '@popup/context' import resource from './content-resource.json' +type PopupDuration = + | "today" | "yesterday" | "thisWeek" | "thisMonth" + | "lastDays" + | "allTime" + export type ContentMessage = { percentage: { title: { [key in PopupDuration]: string } - saveAsImageTitle: string + shareTitle: string + installTip: string averageTime: string averageCount: string totalTime: string @@ -21,6 +26,15 @@ export type ContentMessage = { ranking: { includingCount: string } + limit: { + noData: string + newOne: string + timeUsed: string + visitUsed: string + remain: string + noLimit: string + notHit: string + } } const contentMessages = resource as Messages diff --git a/src/i18n/message/popup/footer-resource.json b/src/i18n/message/popup/footer-resource.json index 842eba522..2de89ae5e 100644 --- a/src/i18n/message/popup/footer-resource.json +++ b/src/i18n/message/popup/footer-resource.json @@ -64,5 +64,17 @@ "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 c274c2e2b..d134485f3 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -1,62 +1,73 @@ { "en": { - "rate": "Rate Us", + "rating": "Submit rating", "showSiteName": "Display site name", "showTopN": "Display top {n}", "donutChart": "Displayed as donut charts" }, "zh_CN": { - "rate": "评分", + "rating": "提交评价", "showSiteName": "显示网站名称", "showTopN": "显示前 {n} 名", "donutChart": "以圆环图显示" }, "zh_TW": { - "rate": "評分", + "rating": "送出評分", "showSiteName": "顯示網站名稱", - "showTopN": "顯示前{n}个" + "showTopN": "顯示前{n}个", + "donutChart": "以圓餅圖顯示" }, "ja": { - "rate": "評価する", + "rating": "評価を送信", "showSiteName": "サイト名を表示", - "showTopN": "トップ {n} を表示" + "showTopN": "トップ {n} を表示", + "donutChart": "ドーナツグラフとして表示" }, "pt_PT": { - "rate": "Avaliar", "showSiteName": "Mostrar nome do site", "donutChart": "Mostrado como gráfico estilo donut" }, - "uk": { - "rate": "Оцінити" - }, + "uk": {}, "es": { - "rate": "Calificar", + "rating": "Enviar Puntuación", "showSiteName": "Mostrar nombre del sitio", - "showTopN": "Mostrar primeros {n}" + "showTopN": "Mostrar primeros {n}", + "donutChart": "Mostrar como gráfico de donas" }, "de": { - "rate": "Bewerten", + "rating": "Bewertung absenden", "showSiteName": "Seitenname anzeigen", - "showTopN": "Top {n} anzeigen" + "showTopN": "Top {n} anzeigen", + "donutChart": "Als Donut-Diagramme angezeigt" }, "fr": { - "rate": "Évaluez-nous", + "rating": "Donner une note", "showSiteName": "Afficher le nom du site", "showTopN": "Afficher les {n} premiers", "donutChart": "Affichés sous forme de graphiques en anneau" }, "ru": { - "rate": "Оценить", + "rating": "Отправить оценку", "showSiteName": "Отобразить имя сайта", "showTopN": "Отображать заголовок {n}" }, - "ar": { - "rate": "قيمنا" - }, + "ar": {}, "tr": { - "rate": "Bizi Değerlendirin", + "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" + }, + "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 ab034e7d5..0f403d837 100644 --- a/src/i18n/message/popup/header.ts +++ b/src/i18n/message/popup/header.ts @@ -8,8 +8,8 @@ import resource from './header-resource.json' export type HeaderMessage = { + rating: string donutChart: string - rate: string showSiteName: string showTopN: string } diff --git a/src/i18n/message/popup/index.ts b/src/i18n/message/popup/index.ts index 890f031b0..3b220b708 100644 --- a/src/i18n/message/popup/index.ts +++ b/src/i18n/message/popup/index.ts @@ -5,17 +5,17 @@ * https://opensource.org/licenses/MIT */ -import menuMessages, { type MenuMessage } from "../app/menu" import baseMessages, { type BaseMessage } from "../common/base" import calendarMessages, { type CalendarMessage } from "../common/calendar" -import itemMessages, { type ItemMessage } from "../common/item" import metaMessages, { type MetaMessage } from "../common/meta" import sharedMessages, { type SharedMessage } from "../common/shared" +import itemMessages, { type ItemMessage } from "../item" import { merge, type MessageRoot } from "../merge" import 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 d5f38ece9..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を入力", @@ -46,5 +46,13 @@ "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 85994c87c..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,6 +23,7 @@ const _default: chrome.runtime.ManifestFirefox = { default_locale: 'en', homepage_url: homepage, manifest_version: 2, + minimum_opera_version: '140', icons: { 16: "static/images/icon-16.png", 48: "static/images/icon-48.png", @@ -38,7 +39,7 @@ const _default: chrome.runtime.ManifestFirefox = { "" ], js: [ - "content_scripts_skeleton.js", + "content_scripts.js", ], run_at: "document_start" } @@ -51,7 +52,8 @@ const _default: chrome.runtime.ManifestFirefox = { '', ], optional_permissions: [ - "tabGroups", + 'tabGroups', + 'notifications', ], browser_action: { default_popup: "static/popup_skeleton.html", @@ -62,6 +64,7 @@ const _default: chrome.runtime.ManifestFirefox = { id: '{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}', data_collection_permissions: { required: ['none'], + optional: ['technicalAndInteraction'], }, }, }, diff --git a/src/manifest.ts b/src/manifest.ts index 4a987b339..caa0e1d65 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -11,10 +11,9 @@ * @author zhy * @since 0.0.1 */ -// Not use path alias in manifest.json -import packageInfo from "./package" -import { OPTION_ROUTE } from "./pages/app/router/constants" -const { version, author: { email }, homepage } = packageInfo +import packageJson from "../package.json" +import { APP_OPTION_ROUTE } from "./shared/route" +const { version, author: { email }, homepage } = packageJson const _default: chrome.runtime.ManifestV3 = { name: '__MSG_meta_marketName__', @@ -38,7 +37,7 @@ const _default: chrome.runtime.ManifestV3 = { "" ], js: [ - "content_scripts_skeleton.js", + "content_scripts.js", ], run_at: "document_start" } @@ -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/icons/Trend.tsx b/src/pages/app/Layout/icons/Trend.tsx deleted file mode 100644 index a726c0997..000000000 --- a/src/pages/app/Layout/icons/Trend.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Trend: FunctionalComponent = () => ( - - - -) - -export default Trend \ No newline at end of file diff --git a/src/pages/app/Layout/index.tsx b/src/pages/app/Layout/index.tsx index 8b0529f6a..1bf49c714 100644 --- a/src/pages/app/Layout/index.tsx +++ b/src/pages/app/Layout/index.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { initAppContext } from "@app/context" import { css, injectGlobal } from '@emotion/css' import { ElAside, ElContainer, ElHeader, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" import { RouterView } from "vue-router" +import { initAppContext } from "../context" import HeadNav from "./menu/Nav" import SideMenu from "./menu/Side" diff --git a/src/pages/app/Layout/menu/Nav.tsx b/src/pages/app/Layout/menu/Nav.tsx index fd092329d..c143e5e38 100644 --- a/src/pages/app/Layout/menu/Nav.tsx +++ b/src/pages/app/Layout/menu/Nav.tsx @@ -5,16 +5,17 @@ * 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 { 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 { 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 { type MenuItem, navMenus } from "./item" +import { indexOfItem, type MenuItem, navMenus } from "./item" import { handleClick, initTitle } from "./route" import { colorMenu } from './style' @@ -43,7 +44,7 @@ const useStyle = () => { } const findTitle = (routePath: string, menus: MenuItem[]): string => { - const title = menus.find(v => routePath === v.route)?.title + const title = menus.find(v => 'route' in v && routePath === v.route)?.title return title ? t(title) : '' } @@ -72,7 +73,7 @@ const _default = defineComponent<{}>(() => { - + {t(msg => msg.meta.name)} @@ -88,15 +89,9 @@ const _default = defineComponent<{}>(() => {
{menus.map(item => ( - handleItemClick(item)} - > - - {h(item.icon)} - + handleItemClick(item)}> + {h(item.icon)} {t(item.title)} - {!!item.href && } ))} diff --git a/src/pages/app/Layout/menu/Side.tsx b/src/pages/app/Layout/menu/Side.tsx index d438effad..dfd082a99 100644 --- a/src/pages/app/Layout/menu/Side.tsx +++ b/src/pages/app/Layout/menu/Side.tsx @@ -6,27 +6,26 @@ */ import { getVersion } from '@api/chrome/runtime' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Expand, Fold } from '@element-plus/icons-vue' import { css } from '@emotion/css' -import { useCached } from '@hooks/useCached' -import { useState } from '@hooks/useState' +import { useCached, useState } from '@hooks' import Flex from '@pages/components/Flex' import { colorVariant } from '@pages/util/style' import { ElCollapseTransition, ElIcon, ElMenu, ElMenuItem, ElMenuItemGroup, ElScrollbar, ElText, ElTooltip, useNamespace } from "element-plus" import { defineComponent, h, nextTick, onMounted, type Ref, ref, type StyleValue, watch } from "vue" import { type Router, useRouter } from "vue-router" -import { menuGroups, type MenuItem } from "./item" +import { indexOfItem, menuGroups, type MenuItem } from "./item" import { handleClick, initTitle } from "./route" import { colorMenu } from './style' const useCollapseState = () => { - const { data: collapsed } = useCached('menu-collapsed', false) + const [collapsed, setCollapsed] = useCached('menu-collapsed', false) const [tooltipVisible, setTooltipVisible] = useState(false) const toggle = () => { setTooltipVisible(false) - nextTick(() => collapsed.value = !collapsed.value) + nextTick(() => setCollapsed(!collapsed.value)) } return { @@ -58,7 +57,7 @@ const useStyle = () => { const renderItem = (item: MenuItem, router: Router, curr: Ref) => ( handleClick(item, router, curr)} v-slots={{ default: () => {h(item.icon)}, @@ -135,7 +134,7 @@ const _default = defineComponent(() => { - + ) }) diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index f146fd20b..5f723ee7e 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -6,24 +6,21 @@ * https://opensource.org/licenses/MIT */ -import { type I18nKey } from "@app/locale" -import { ANALYSIS_ROUTE, MERGE_ROUTE } from "@app/router/constants" +import { type I18nKey } from '@app/locale' +import { ANALYSIS_ROUTE, MERGE_ROUTE } from '@app/router/constants' import { Aim, Connection, HelpFilled, Histogram, Memo, MoreFilled, Rank, SetUp, Stopwatch, Timer, View } from "@element-plus/icons-vue" +import { Trend } from '@pages/icons' import { getGuidePageUrl } from "@util/constant/url" -import { type IconProps } from "element-plus" +import { type Component } from 'vue' import About from "../icons/About" import Database from "../icons/Database" import Table from "../icons/Table" -import Trend from "../icons/Trend" import Website from "../icons/Website" import Whitelist from "../icons/Whitelist" -export type MenuItem = { +type MenuBase = { title: I18nKey - icon: IconProps | string - route?: string - href?: string - index?: string + icon: Component | string /** * Whether to support mobile * @@ -32,10 +29,18 @@ export type MenuItem = { mobile?: boolean } -export type MenuGroup = Omit & { +export type MenuItem = MenuBase & ( + | { route: string } + | { href: string } +) + +type MenuGroup = MenuBase & { + index: string children: MenuItem[] } +export const indexOfItem = (item: MenuItem) => 'route' in item ? item.route : item.href + /** * Menu items */ @@ -47,22 +52,18 @@ export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.menu.dashboard, route: '/data/dashboard', icon: Stopwatch, - mobile: true, }, { title: msg => msg.menu.dataReport, route: '/data/report', icon: Table, - mobile: true, }, { title: msg => msg.menu.siteAnalysis, route: ANALYSIS_ROUTE, icon: Trend, - mobile: true, }, { title: msg => msg.menu.dataClear, route: '/data/manage', icon: Database, - mobile: true, }] }, { title: msg => msg.menu.behavior, @@ -72,11 +73,10 @@ export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.menu.habit, route: '/behavior/habit', icon: Aim, - mobile: true, }, { - title: msg => msg.menu.limit, + title: msg => msg.base.limit, route: '/behavior/limit', - icon: Timer + icon: Timer, }] }, { title: msg => msg.menu.additional, @@ -85,20 +85,22 @@ export const menuGroups = (): MenuGroup[] => [{ children: [{ title: msg => msg.menu.siteManage, route: '/additional/site-manage', - icon: Website + icon: Website, + mobile: false, }, { title: msg => msg.menu.whitelist, route: '/additional/whitelist', - icon: Whitelist + icon: Whitelist, + mobile: false, }, { title: msg => msg.menu.mergeRule, route: MERGE_ROUTE, - icon: Rank + icon: Rank, + mobile: false, }, { title: msg => msg.base.option, route: '/additional/option', icon: SetUp, - mobile: true, }] }, { title: msg => msg.menu.other, @@ -108,17 +110,17 @@ export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.base.guidePage, href: getGuidePageUrl(), icon: Memo, - index: '_guide', + mobile: false, }, { - title: msg => msg.menu.helpUs, + title: msg => msg.base.helpUs, route: '/other/help', icon: HelpFilled, + mobile: false, }, { title: msg => msg.menu.about, route: '/other/about', icon: About, - mobile: true, }] }] -export const navMenus = (): MenuItem[] => menuGroups().flatMap(g => g.children || []).filter(m => m.mobile) \ No newline at end of file +export const navMenus = (): MenuItem[] => menuGroups().flatMap(g => g.children || []).filter(({ mobile = true }) => mobile) \ No newline at end of file diff --git a/src/pages/app/Layout/menu/route.ts b/src/pages/app/Layout/menu/route.ts index 88bc2e21e..6d6d10913 100644 --- a/src/pages/app/Layout/menu/route.ts +++ b/src/pages/app/Layout/menu/route.ts @@ -1,5 +1,5 @@ import { createTabAfterCurrent } from "@api/chrome/tab" -import { type I18nKey, t } from "@app/locale" +import { type I18nKey, t } from '@app/locale' import { type Ref } from "vue" import { type Router } from "vue-router" import { type MenuItem, menuGroups } from "./item" @@ -15,11 +15,11 @@ function openMenu(route: string, title: I18nKey, router: Router) { const openHref = (href: string) => createTabAfterCurrent(href) export function handleClick(menuItem: MenuItem, router: Router, currentActive?: Ref) { - const { route, title, href } = menuItem - if (route) { - openMenu(route, title, router) - } else if (href) { - openHref(href) + const { title } = menuItem + if ('route' in menuItem) { + openMenu(menuItem.route, title, router) + } else { + openHref(menuItem.href) currentActive && (currentActive.value = router.currentRoute?.value?.path) } } @@ -28,10 +28,9 @@ export async function initTitle(router: Router) { await router.isReady() const currentPath = router.currentRoute.value.path for (const group of menuGroups()) { - for (const { route, title } of group.children) { - const docTitle = route === currentPath && t(title) - if (docTitle) { - document.title = docTitle + for (const child of group.children) { + if ('route' in child && child.route === currentPath) { + document.title = t(child.title) return } } diff --git a/src/pages/app/components/About/DescLink.tsx b/src/pages/app/components/About/DescLink.tsx index 4f24cd8eb..3491f5d77 100644 --- a/src/pages/app/components/About/DescLink.tsx +++ b/src/pages/app/components/About/DescLink.tsx @@ -1,4 +1,4 @@ -import { useXsState } from '@hooks/useMediaSize' +import { useXsState } from "@hooks" import Flex from '@pages/components/Flex' import { ElLink } from "element-plus" import { defineComponent, h, useSlots } from "vue" @@ -8,6 +8,7 @@ const _default = defineComponent<{ href?: string, icon?: JSX.Element }>(props => const { icon, href } = props const { default: default_, } = useSlots() const isXs = useXsState() + return () => ( {icon && !isXs.value && diff --git a/src/pages/app/components/About/Description.tsx b/src/pages/app/components/About/Description.tsx index 8e7468397..5a8be7f0d 100644 --- a/src/pages/app/components/About/Description.tsx +++ b/src/pages/app/components/About/Description.tsx @@ -1,28 +1,19 @@ -import { t } from "@app/locale" +import packageInfo, { AUTHOR_EMAIL } from "@/package" +import { t } from '@app/locale' import { css } from '@emotion/css' -import { MediaSize, useMediaSize } from "@hooks" +import { MediaSize, useMediaSize } from '@hooks' import { locale } from "@i18n" import Flex from "@pages/components/Flex" -import { CoffeeIcon } from '@pages/util/icon' -import { saveFlag } from "@service/meta-service" -import packageInfo, { AUTHOR_EMAIL } from "@src/package" +import { Coffee, GitHub } from '@pages/icons' +import { rateClicked } from '@pages/util/rate' import { - BUY_ME_A_COFFEE_PAGE, - CHANGE_LOG_PAGE, - CHROME_HOMEPAGE, EDGE_HOMEPAGE, - FEEDBACK_QUESTIONNAIRE, - FIREFOX_HOMEPAGE, - getHomepageWithLocale, - GITHUB_ISSUE_ADD, - HOMEPAGE, - LICENSE_PAGE, PRIVACY_PAGE, - REVIEW_PAGE, - SOURCE_CODE_PAGE, + BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, CHROME_HOMEPAGE, EDGE_HOMEPAGE, FEEDBACK_QUESTIONNAIRE, FIREFOX_HOMEPAGE, + getHomepageWithLocale, GITHUB_ISSUE_ADD, HOMEPAGE, LICENSE_PAGE, PRIVACY_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE, } from "@util/constant/url" import { type ComponentSize, ElCard, ElDescriptions, ElDescriptionsItem, ElDivider, ElText, useNamespace } from "element-plus" import { computed, defineComponent, reactive } from "vue" import DescLink from "./DescLink" -import { Chrome, Echarts, Edge, ElementPlus, Firefox, GitHub, Vue } from './Icon' +import { Chrome, Echarts, Edge, ElementPlus, Firefox, Vue } from './Icon' import InstallationLink from "./InstallationLink" const useStyle = () => { @@ -155,7 +146,7 @@ const _default = defineComponent<{}>(() => { {locale !== 'zh_CN' && ( - {BUY_ME_A_COFFEE_PAGE} + } href={BUY_ME_A_COFFEE_PAGE}>{BUY_ME_A_COFFEE_PAGE} )} @@ -165,7 +156,7 @@ const _default = defineComponent<{}>(() => { 🌟  {t(msg => msg.about.text.greet)}  - saveFlag("rateOpen")}> + {t(msg => msg.about.text.rate)} diff --git a/src/pages/app/components/About/Icon.tsx b/src/pages/app/components/About/Icon.tsx index 777fb4a7b..17935e0c5 100644 --- a/src/pages/app/components/About/Icon.tsx +++ b/src/pages/app/components/About/Icon.tsx @@ -140,12 +140,6 @@ export const Firefox: FunctionalComponent<{}> = () => ( ) -export const GitHub: FunctionalComponent<{}> = () => ( - - - -) - export const Vue: FunctionalComponent<{}> = () => ( diff --git a/src/pages/app/components/About/InstallationLink.tsx b/src/pages/app/components/About/InstallationLink.tsx index d684a91e2..e0d38c977 100644 --- a/src/pages/app/components/About/InstallationLink.tsx +++ b/src/pages/app/components/About/InstallationLink.tsx @@ -1,5 +1,5 @@ +import { useXsState } from '@hooks' import { css } from '@emotion/css' -import { useXsState } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { colorUsage, colorVariant } from '@pages/util/style' import { computed, defineComponent, h, StyleValue, useSlots } from "vue" diff --git a/src/pages/app/components/About/index.tsx b/src/pages/app/components/About/index.tsx index 084769c31..61b320c04 100644 --- a/src/pages/app/components/About/index.tsx +++ b/src/pages/app/components/About/index.tsx @@ -1,6 +1,6 @@ import { ElScrollbar } from 'element-plus' import { type FunctionalComponent, type StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import Description from "./Description" const About: FunctionalComponent = () => ( diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx index 67e050a52..10c39fa19 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx @@ -1,35 +1,34 @@ -import { useCategory } from "@app/context" -import { t } from "@app/locale" -import { useDebounceState, useRequest } from "@hooks" +import { searchSite } from "@api/sw/site" +import { useAnalysisTarget } from '@app/components/Analysis/context' +import type { AnalysisTarget } from '@app/components/Analysis/types' +import { labelOfHostInfo } from '@app/components/Analysis/util' +import { useCategory } from '@app/context' +import { t } from '@app/locale' +import { useDebounceState, useRequest } from '@hooks' import Flex from "@pages/components/Flex" -import { selectAllSites } from "@service/site-service" -import { listHosts } from "@service/stat-service" -import { identifySiteKey, parseSiteKeyFromIdentity, SiteMap } from "@util/site" +import { CATE_NOT_SET_ID, identifySiteKey, parseSiteIdentity, SiteMap } from "@util/site" import { ElSelectV2, ElTag, useNamespace } from "element-plus" import type { OptionType } from "element-plus/es/components/select-v2/src/select.types" import { computed, defineComponent, type FunctionalComponent, onMounted, ref, type StyleValue } from "vue" -import { useAnalysisTarget } from "../../context" -import type { AnalysisTarget } from "../../types" -import { labelOfHostInfo } from "../../util" const SITE_PREFIX = 'S' const CATE_PREFIX = 'C' const cvtTarget2Key = (target: AnalysisTarget | undefined): string => { - if (target?.type === 'site') { - return `${SITE_PREFIX}${identifySiteKey(target.key)}` - } else if (target?.type === 'cate') { - return `${CATE_PREFIX}${target.key}` + const { type, key } = target ?? {} + switch (type) { + case 'site': return `${SITE_PREFIX}${identifySiteKey(key)}` + case 'cate': return `${CATE_PREFIX}${key}` + default: return '' } - return '' } const cvtKey2Target = (key: string | undefined): AnalysisTarget | undefined => { if (!key) return undefined - const prefix = key?.charAt?.(0) - const content = key?.substring(1) + const prefix = key.charAt(0) + const content = key.substring(1) if (prefix === SITE_PREFIX) { - const key = parseSiteKeyFromIdentity(content) + const key = parseSiteIdentity(content) if (key) return { type: 'site', key } } else if (prefix === CATE_PREFIX) { let cateId: number | undefined @@ -45,34 +44,14 @@ type TargetItem = AnalysisTarget & { label: string } -function collectHosts(hosts: Record, collector: SiteMap) { - Object.entries(hosts).forEach(([key, arr]) => { - const type = key as timer.site.Type - arr.forEach(host => { - const site: timer.site.SiteInfo = { host, type } - collector?.put(site, site) - }) - }) -} - -const fetchItems = async (categories: timer.site.Cate[]): Promise<[siteItems: TargetItem[], cateItems: TargetItem[]]> => { +const fetchItems = async (categories: tt4b.site.Cate[]): Promise<[siteItems: TargetItem[], cateItems: TargetItem[]]> => { // 1. query categories - const cateItems = categories?.map(({ id, name }) => ({ type: 'cate', key: id, label: name } satisfies TargetItem)) + const cateItems: TargetItem[] = categories.map(({ id, name }) => ({ type: 'cate', key: id, label: name })) // 2. query sites - const siteSet = new SiteMap() - - // 2.1 sites from hosts - const hosts = await listHosts() - collectHosts(hosts, siteSet) - - // 2.2 query sites from sites - const sites = await selectAllSites() - sites?.forEach(site => siteSet.put(site, site)) - - const siteItems = siteSet?.map((_, site) => site) - .filter(site => !!site) - .map(site => ({ type: 'site', key: site, label: labelOfHostInfo(site) }) satisfies TargetItem) + const sites = await searchSite() + const siteMap = SiteMap.identify(sites) + const siteItems: TargetItem[] = siteMap.map((_, key) => ({ type: 'site', key, label: labelOfHostInfo(key) })) return [cateItems, siteItems] } @@ -83,15 +62,15 @@ const SiteTypeTag: FunctionalComponent<{ text: string }> = ({ text }) => ( ) -const SiteOption = defineComponent<{ value: timer.site.SiteInfo }>(props => { - const alias = computed(() => props.value?.alias) - const type = computed(() => props.value?.type) +const SiteOption = defineComponent<{ value: tt4b.site.SiteInfo }>(props => { + const alias = computed(() => props.value.alias) + const type = computed(() => props.value.type) const mergedText = t(msg => msg.analysis.common.merged) const virtualText = t(msg => msg.analysis.common.virtual) return () => ( - {props.value?.host} + {props.value.host} {alias.value} @@ -111,20 +90,21 @@ const TargetSelect = defineComponent(() => { }) const { data: allItems } = useRequest( - () => fetchItems(cate.all), + () => fetchItems([...cate.all, { id: CATE_NOT_SET_ID, name: t(msg => msg.shared.cate.notSet) }]), { defaultValue: [[], []], deps: [() => cate.all] }, ) const [query, setQuery] = useDebounceState('', 50) const options = computed(() => { - const q = query.value?.trim?.() + const q = query.value.trim() let [cateItems, siteItems] = allItems.value if (q) { - siteItems = siteItems.filter(item => { - const { host, alias } = (item.key as timer.site.SiteInfo) || {} - return host?.includes?.(q) || alias?.includes?.(q) + siteItems = siteItems.filter(({ key, type }) => { + if (type !== 'site') return false + const { host, alias } = key + return host.includes(q) || alias?.includes(q) }) - cateItems = cateItems.filter(item => item.label?.includes?.(q)) + cateItems = cateItems.filter(item => item.label.includes(q)) } let res: OptionType[] = [] @@ -138,24 +118,27 @@ const TargetSelect = defineComponent(() => { label: t(msg => msg.analysis.target.site), options: siteItems.map(item => ({ value: cvtTarget2Key(item), label: item.label, data: item })), }) - if (res.length === 1) { - // Single content, not use group - res = res[0].options - } + // Single content, not use group + if (res.length === 1) res = res[0]?.options ?? [] return res }) const ns = useNamespace('select') const select = ref>() onMounted(() => { + // Do nothing if target selected if (target.value) return - let el = select.value?.$el as HTMLElement | undefined - if (!el) return + + let el = select.value?.$el + if (!(el instanceof HTMLElement)) return el.click() - const input = el.querySelector(`.${ns.e('input')}`) as HTMLInputElement - (el.querySelector(`.${ns.e('wrapper')}`) as HTMLElement)?.classList?.add?.(ns.is('focused')) - input?.click?.() - input?.focus?.() + const input = el.querySelector(`.${ns.e('input')}`) + const wrapper = el.querySelector(`.${ns.e('wrapper')}`) + if (input instanceof HTMLInputElement) { + input.click() + input.focus() + } + if (wrapper instanceof HTMLElement) wrapper.classList.add(ns.is('focused')) }) return () => ( @@ -168,7 +151,7 @@ const TargetSelect = defineComponent(() => { filterMethod={setQuery} style={{ width: '240px' } as StyleValue} defaultFirstOption - options={options.value ?? []} + options={options.value} fitInputWidth={false} v-slots={({ item }: any) => { const target = (item as any).data as TargetItem diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx index 6c8b29fea..068360266 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" +import { useAnalysisTimeFormat } from "@app/components/Analysis/context" +import TimeFormatFilterItem from '@app/components/common/filter/TimeFormatFilterItem' import Flex from "@pages/components/Flex" import { defineComponent } from "vue" -import { useAnalysisTimeFormat } from "../../context" import TargetSelect from "./TargetSelect" const AnalysisFilter = defineComponent(() => { diff --git a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts index 00d276e3a..7ae74b138 100644 --- a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts @@ -4,20 +4,17 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getRegularTextColor, getSecondaryTextColor } from "@pages/util/style" -import weekHelper from "@service/components/week-helper" +import { getWeekStartDay, getWeekStartTime } from '@api/sw/option' +import { t } from '@app/locale' +import { parseValueOfFormatter } from "@app/util/echarts" +import { periodFormatter } from '@app/util/time' +import { EchartsWrapper } from '@hooks' +import { getRegularTextColor, getSecondaryTextColor } from '@pages/util/style' import { groupBy, rotate, toMap } from "@util/array" import { formatTime, getAllDatesBetween, MILL_PER_WEEK, parseTime } from "@util/time" import type { - ComposeOption, - EffectScatterSeriesOption, - GridComponentOption, - TitleComponentOption, - TooltipComponentOption, - VisualMapComponentOption + ComposeOption, EffectScatterSeriesOption, GridComponentOption, TitleComponentOption, TooltipComponentOption, + VisualMapComponentOption, } from "echarts" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" @@ -66,22 +63,21 @@ function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substring(4, 6)))) let lastMonth: string | undefined = undefined Object.entries(xAndMonthMap).forEach(([x, monthSet]) => { - if (monthSet.size != 1) { - return - } + if (monthSet.size != 1) return const currentMonth = Array.from(monthSet)[0] - if (currentMonth === lastMonth) { - return - } + if (!currentMonth || currentMonth === lastMonth) return lastMonth = currentMonth const monthNum = parseInt(currentMonth) const label = allMonthLabel[monthNum - 1] - result[x] = label + label && (result[x] = label) }) return result } -function optionOf(data: _Value[], weekDays: string[], format: timer.app.TimeFormat, domWidth: number): EcOption { +function optionOf( + data: _Value[], weekDays: string[], + format: tt4b.app.TimeFormat, domWidth: number, +): EcOption { const xAxisLabelMap = getXAxisLabelMap(data) const axisTextColor = getSecondaryTextColor() const gridLeft = domWidth * 0.1 < MIN_GRID_LEFT_PX ? MIN_GRID_LEFT_PX : '10%' @@ -99,12 +95,12 @@ function optionOf(data: _Value[], weekDays: string[], format: timer.app.TimeForm tooltip: { borderWidth: 0, formatter: (params: TopLevelFormatterParams) => { - const parma = Array.isArray(params) ? params[0] : params - const { data } = parma - const { value } = data as any + const value = parseValueOfFormatter(params) + // todo: not safety const [_1, _2, mills, date] = value as _Value if (!mills) return '' const time = parseTime(date) + if (!time) return '' return time ? `${formatTime(time, t(msg => msg.calendar.dateFormat))}
${periodFormatter(mills, { format })}` : '' }, }, @@ -143,8 +139,8 @@ function optionOf(data: _Value[], weekDays: string[], format: timer.app.TimeForm } export type BizOption = { - rows: timer.stat.Row[] - timeFormat: timer.app.TimeFormat + rows: tt4b.stat.Row[] + timeFormat: tt4b.app.TimeFormat } class Wrapper extends EchartsWrapper { @@ -154,7 +150,7 @@ class Wrapper extends EchartsWrapper { const width = this.getDomWidth() const colNum = getWeekNum(width) const endTime = new Date() - const [startTime,] = await weekHelper.getWeekDate(endTime.getTime() - MILL_PER_WEEK * (colNum - 1)) + const startTime = await getWeekStartTime(endTime.getTime() - MILL_PER_WEEK * (colNum - 1)) const allDates = getAllDatesBetween(startTime, endTime) const value = toMap(rows, r => r.date, r => r.focus) const data: _Value[] = [] @@ -165,7 +161,7 @@ class Wrapper extends EchartsWrapper { const x = colIndex, y = 7 - (1 + weekDay) data.push([x, y, dailyMills, date]) }) - const weekStart = await weekHelper.getRealWeekStart() + const weekStart = await getWeekStartDay() const weekDays = (t(msg => msg.calendar.weekDays)?.split?.('|') || []).reverse() rotate(weekDays, weekStart, true) return optionOf(data, weekDays, timeFormat, width) diff --git a/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx b/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx index 35ca2ea6d..d9979ab43 100644 --- a/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx +++ b/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx @@ -4,8 +4,8 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { useAnalysisRows, useAnalysisTimeFormat } from "@app/components/Analysis/context" -import { useEcharts } from "@hooks/useEcharts" +import { useAnalysisRows, useAnalysisTimeFormat } from '@app/components/Analysis/context' +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx index b1d57dbe9..c9b33c7da 100644 --- a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx +++ b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx @@ -5,15 +5,15 @@ * https://opensource.org/licenses/MIT */ -import { useCategory } from "@app/context" -import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { useAnalysisTarget } from "@app/components/Analysis/context" +import { labelOfHostInfo } from "@app/components/Analysis/util" +import { useCategory } from '@app/context' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import { getSite } from '@service/site-service' +import Img from '@pages/components/Img' +import { CATE_NOT_SET_ID } from '@util/site' import { ElTag } from "element-plus" -import { computed, defineComponent, type StyleValue, toRef } from "vue" -import { useAnalysisTarget } from "../../context" -import { labelOfHostInfo } from "../../util" +import { computed, defineComponent, FunctionalComponent, type StyleValue } from "vue" const TITLE_STYLE: StyleValue = { fontSize: '26px', @@ -33,30 +33,32 @@ const SUBTITLE_STYLE: StyleValue = { margin: 0, } -const SiteInfo = defineComponent<{ value: timer.site.SiteKey }>(props => { - const key = toRef(props, 'value') - const { data: site } = useRequest(() => key.value ? getSite(key.value) : undefined, { deps: key }) - const iconUrl = computed(() => site.value?.iconUrl) - const title = computed(() => site.value?.alias ?? labelOfHostInfo(site.value)) - const subtitle = computed(() => site.value?.alias ? labelOfHostInfo(site.value) : undefined) +const SiteInfo: FunctionalComponent<{ site: tt4b.site.SiteInfo }> = ({ site }) => { + const { iconUrl, alias } = site + const label = labelOfHostInfo(site) + const [title, subtitle] = alias ? [alias, label] : [label] - return () => ( + return ( - -

{title.value}

- {subtitle &&

{subtitle.value}

} + +

{title}

+ {subtitle &&

{subtitle}

}
) -}, { props: ['value'] }) +} const CateInfo = defineComponent<{ value: number }>(props => { - const cateId = toRef(props, 'value') - const cateInst = useCategory() - const cate = computed(() => cateInst.all.find(c => c.id === cateId.value)) + const { all } = useCategory() + const cateName = computed(() => { + const cateId = props.value + return cateId === CATE_NOT_SET_ID + ? t(msg => msg.shared.cate.notSet) + : all.find(c => c.id === cateId)?.name ?? '' + }) return () => ( -

{cate.value?.name}

+

{cateName.value}

{t(msg => msg.analysis.target.cate)}
) @@ -73,7 +75,7 @@ const TargetInfo = defineComponent(() => { padding="0 25px" > {!target.value &&

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

} - {target.value?.type === 'site' && } + {target.value?.type === 'site' && } {target.value?.type === 'cate' && }
) diff --git a/src/pages/app/components/Analysis/components/Summary/index.tsx b/src/pages/app/components/Analysis/components/Summary/index.tsx index de6567344..5e1aa7034 100644 --- a/src/pages/app/components/Analysis/components/Summary/index.tsx +++ b/src/pages/app/components/Analysis/components/Summary/index.tsx @@ -4,14 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { KanbanCard, KanbanIndicatorCell } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { cvt2LocaleTime, periodFormatter } from "@app/util/time" +import { useAnalysisRows, useAnalysisTarget, useAnalysisTimeFormat } from "@app/components/Analysis/context" +import type { AnalysisTarget } from "@app/components/Analysis/types" +import { KanbanCard, KanbanIndicatorCell } from '@app/components/common/kanban' +import { t } from '@app/locale' +import { cvt2LocaleTime, periodFormatter } from '@app/util/time' import { css } from '@emotion/css' import Grid from '@pages/components/Grid' import { computed, defineComponent } from "vue" -import { useAnalysisRows, useAnalysisTarget, useAnalysisTimeFormat } from "../../context" -import { AnalysisTarget } from "../../types" import Calendar from "./Calendar" import TargetInfo from "./TargetInfo" @@ -22,12 +22,12 @@ type Summary = { firstDay?: string } -function computeSummary(target: AnalysisTarget | undefined, rows: timer.stat.Row[]): Summary | undefined { +function computeSummary(target: AnalysisTarget | undefined, rows: tt4b.stat.Row[]): Summary | undefined { if (!target) return undefined const summary: Summary = { focus: 0, visit: 0, day: 0 } - summary.firstDay = rows?.[0]?.date - rows?.forEach(({ focus, time: visit }) => { + summary.firstDay = rows[0]?.date + rows.forEach(({ focus, time: visit }) => { summary.focus += focus summary.visit += visit focus && (summary.day += 1) diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx b/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx index bdaddbe4b..7b9c4e087 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx @@ -5,8 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { type DimensionEntry, type ValueFormatter } from "@app/components/Analysis/util" -import { useEcharts } from "@hooks/useEcharts" +import type { DimensionEntry } from '@app/components/Analysis/types' +import type { ValueFormatter } from '@app/components/common/kanban/types' +import { useEcharts } from '@hooks' import { defineComponent } from "vue" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts b/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts index fbf4efddc..990377ac9 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts @@ -5,19 +5,15 @@ * https://opensource.org/licenses/MIT */ -import { type ValueFormatter } from "@app/components/Analysis/util" -import { getLineSeriesPalette, tooltipDot, tooltipFlexLine } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getRegularTextColor } from "@pages/util/style" -import { type LineSeriesOption } from "echarts/charts" -import { - type GridComponentOption, - type TitleComponentOption, - type TooltipComponentOption, -} from "echarts/components" -import { type ComposeOption } from "echarts/core" -import { type TopLevelFormatterParams } from "echarts/types/dist/shared" -import { type DimensionEntry } from "../../../util" +import type { DimensionEntry } from '@app/components/Analysis/types' +import type { ValueFormatter } from '@app/components/common/kanban/types' +import { getLineSeriesPalette, tooltipDot, tooltipFlexLine } from '@app/util/echarts' +import { EchartsWrapper } from '@hooks' +import { getRegularTextColor } from '@pages/util/style' +import type { + ComposeOption, GridComponentOption, LineSeriesOption, TitleComponentOption, TooltipComponentOption, +} from "echarts" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" type EcOption = ComposeOption< | LineSeriesOption @@ -104,7 +100,7 @@ const generateOption = ({ entries, preEntries, title, valueFormatter }: BizOptio showSymbol: false, smooth: true, lineStyle: { width: prevExistData ? 0 : 1 }, - color: PREV_COLOR.colorStops[0].color, + color: PREV_COLOR.colorStops?.[0]?.color, areaStyle: { opacity: .5, color: PREV_COLOR }, emphasis: { focus: "self" }, }, { @@ -113,7 +109,7 @@ const generateOption = ({ entries, preEntries, title, valueFormatter }: BizOptio showSymbol: false, smooth: true, lineStyle: { width: thisExistData ? 0 : 1 }, - color: THIS_COLOR.colorStops[0].color, + color: THIS_COLOR.colorStops[0]?.color, areaStyle: { color: THIS_COLOR }, emphasis: { focus: "series" }, }] diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx b/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx index 2ef0d954c..6026393e1 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx @@ -4,21 +4,18 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { formatValue, type DimensionEntry, type RingValue, type ValueFormatter } from "@app/components/Analysis/util" +import type { DimensionData } from '@app/components/Analysis/types' +import { formatValue } from "@app/components/Analysis/util" import { GRID_CELL_STYLE } from '@app/components/common/grid' -import { KanbanIndicatorCell } from "@app/components/common/kanban" -import { cvt2LocaleTime } from "@app/util/time" -import { useXsState } from "@hooks" +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import type { RingValue, ValueFormatter } from '@app/components/common/kanban/types' +import { cvt2LocaleTime } from '@app/util/time' +import { useXsState } from '@hooks' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" import { defineComponent } from "vue" import Chart from "./Chart" -export type DimensionData = { - thisPeriod: DimensionEntry[] - previousPeriod: DimensionEntry[] -} - type Props = { maxLabel: string maxValue?: number diff --git a/src/pages/app/components/Analysis/components/Trend/Filter.tsx b/src/pages/app/components/Analysis/components/Trend/Filter.tsx index e4328609b..82433d9c2 100644 --- a/src/pages/app/components/Analysis/components/Trend/Filter.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Filter.tsx @@ -7,15 +7,15 @@ import DateRangeFilterItem from '@app/components/common/filter/DateRangeFilterItem' import { t } from "@app/locale" +import type { ElDatePickerShortcut } from "@pages/element-ui/types" import { daysAgo } from "@util/time" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { defineComponent } from "vue" import { useAnalysisTrendDateRange } from "./context" -const shortcut = (agoOfStart: number) => ({ +const shortcut = (agoOfStart: number): ElDatePickerShortcut => ({ text: t(msg => msg.calendar.range.lastDays, { n: agoOfStart }), value: daysAgo(agoOfStart - 1, 0), -} satisfies Shortcut) +}) const SHORTCUTS = [7, 15, 30, 90].map(shortcut) diff --git a/src/pages/app/components/Analysis/components/Trend/Total.tsx b/src/pages/app/components/Analysis/components/Trend/Total.tsx index 232bed63d..9b12f3489 100644 --- a/src/pages/app/components/Analysis/components/Trend/Total.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Total.tsx @@ -5,15 +5,15 @@ * https://opensource.org/licenses/MIT */ -import type { RingValue } from "@app/components/Analysis/util" -import { KanbanIndicatorCell } from "@app/components/common/kanban" +import { useAnalysisTimeFormat } from '@app/components/Analysis/context' +import { GRID_CELL_STYLE } from '@app/components/common/grid' +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import type { RingValue } from '@app/components/common/kanban/types' import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import { periodFormatter } from '@app/util/time' import Flex from "@pages/components/Flex" -import { defineComponent, StyleValue } from "vue" -import { useAnalysisTimeFormat } from "../../context" -import { useAnalysisTrendRangeLength } from "./context" -import { GRID_CELL_STYLE } from "../../../common/grid" +import { defineComponent, type CSSProperties } from "vue" +import { useAnalysisTrendRangeLength } from './context' const computeDayValue = (activeDay: RingValue | undefined, rangeLength: number) => { const thisActiveDay = activeDay?.[0] @@ -22,7 +22,7 @@ const computeDayValue = (activeDay: RingValue | undefined, rangeLength: number) type Props = Record<'activeDay' | 'visit' | 'focus', RingValue> -const INDICATOR_CONTAINER_STYLE: StyleValue = { +const INDICATOR_CONTAINER_STYLE: CSSProperties = { ...GRID_CELL_STYLE, flex: 1, width: '100%', } diff --git a/src/pages/app/components/Analysis/components/Trend/context.ts b/src/pages/app/components/Analysis/components/Trend/context.ts index 2d7ba4cb8..33b79937b 100644 --- a/src/pages/app/components/Analysis/components/Trend/context.ts +++ b/src/pages/app/components/Analysis/components/Trend/context.ts @@ -5,14 +5,13 @@ * https://opensource.org/licenses/MIT */ -import { cvt2LocaleTime } from "@app/util/time" -import { useProvide, useProvider } from "@hooks" +import { useAnalysisRows } from '@app/components/Analysis/context' +import type { DimensionData, DimensionEntry } from '@app/components/Analysis/types' +import { cvt2LocaleTime } from '@app/util/time' +import { useProvide, useProvider } from '@hooks' import { toMap } from "@util/array" import { daysAgo, getAllDatesBetween, getDayLength, MILL_PER_DAY } from "@util/time" import { computed, onMounted, ref, watch, type Ref } from "vue" -import { useAnalysisRows } from "../../context" -import type { DimensionEntry } from "../../util" -import type { DimensionData } from "./Dimension" type Context = { dateRange: Ref<[Date?, Date?]> @@ -38,7 +37,7 @@ type IndicatorSet = Record] { +): [IndicatorSet | undefined, Record] { const [start, end] = dateRange || [] const allDates = start && end ? getAllDatesBetween(start, end) : [] if (!rows) { @@ -68,10 +67,11 @@ function computeIndicatorSet( focusMax = visitMax = { date: undefined, value: undefined } activeDay = focusTotal = visitTotal = 0 - const fullPeriodRow: Record = {} + const fullPeriodRow: Record = {} allDates.forEach(date => { const row = periodRowMap[date] - if (!(fullPeriodRow[date] = row)) return + if (!row) return + fullPeriodRow[date] = row const { focus, time: visit } = row focus > (focusMax.value ?? Number.MIN_SAFE_INTEGER) && (focusMax = { value: focus, date }) visit > (visitMax.value ?? Number.MIN_SAFE_INTEGER) && (visitMax = { value: visit, date }) diff --git a/src/pages/app/components/Analysis/components/Trend/index.tsx b/src/pages/app/components/Analysis/components/Trend/index.tsx index f98d2e48d..1bfe7493c 100644 --- a/src/pages/app/components/Analysis/components/Trend/index.tsx +++ b/src/pages/app/components/Analysis/components/Trend/index.tsx @@ -4,14 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { useAnalysisTimeFormat } from '@app/components/Analysis/context' import { GRID_WRAPPER_STYLE } from '@app/components/common/grid' -import { KanbanCard } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import { KanbanCard } from '@app/components/common/kanban' import { useXsState } from "@hooks" +import { t } from "@app/locale" +import { periodFormatter } from '@app/util/time' import Flex from "@pages/components/Flex" import { defineComponent } from "vue" -import { useAnalysisTimeFormat } from '../../context' import { initAnalysisTrend } from "./context" import Dimension from "./Dimension" import Filter from "./Filter" diff --git a/src/pages/app/components/Analysis/context.ts b/src/pages/app/components/Analysis/context.ts index 8e993d540..a9bdebcce 100644 --- a/src/pages/app/components/Analysis/context.ts +++ b/src/pages/app/components/Analysis/context.ts @@ -5,41 +5,40 @@ * https://opensource.org/licenses/MIT */ +import { type AppAnalysisQuery } from '@/shared/route' +import { listCateStats, listSiteStats } from "@api/sw/stat" import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import { selectCate, selectSite } from "@service/stat-service" +import { extractHostname } from '@util/pattern' import { ref, watch, type Ref } from "vue" import { useRoute, useRouter } from "vue-router" import type { AnalysisTarget } from "./types" type Context = { target: Ref - timeFormat: Ref - rows: Ref -} - -export type AnalysisQuery = Partial & { - cateId?: string + timeFormat: Ref + rows: Ref } function parseQuery(): AnalysisTarget | undefined { // Process the query param - const query = useRoute().query as unknown as AnalysisQuery + const query = useRoute().query as unknown as AppAnalysisQuery useRouter().replace({ query: {} }) - const { host, type: siteType, cateId } = query + const { host, type: siteType, cateId, url } = query if (cateId) return { type: 'cate', key: parseInt(cateId) } if (host && siteType) return { type: 'site', key: { host, type: siteType } } + if (url) return { type: 'site', key: { host: extractHostname(url).host, type: 'normal' } } return undefined } -async function queryRows(target: AnalysisTarget | undefined): Promise<(timer.stat.CateRow | timer.stat.SiteRow)[]> { +async function queryRows(target: AnalysisTarget | undefined): Promise<(tt4b.stat.CateRow | tt4b.stat.SiteRow)[]> { const { key, type } = target ?? {} if (!key) return [] if (type === 'cate') { - return selectCate({ cateIds: [key], sortKey: 'date' }) + return await listCateStats({ cateIds: [key], sortKey: 'date' }) } else if (type === 'site') { - const { host, type: siteType } = key ?? {} - return selectSite({ host, mergeHost: siteType === 'merged', sortKey: 'date' }) + const { host, type: siteType } = key + return await listSiteStats({ host, mergeHost: siteType === 'merged', sortKey: 'date' }) } else { // Not supported yet return [] @@ -51,7 +50,7 @@ const NAMESPACE = 'siteAnalysis' export const initAnalysis = () => { const target = ref(parseQuery()) - const [cachedFormat, setFormatCache] = useLocalStorage('analysis_timeFormat') + const [cachedFormat, setFormatCache] = useLocalStorage('analysis_timeFormat') const timeFormat = ref(cachedFormat ?? 'default') watch(timeFormat, setFormatCache) diff --git a/src/pages/app/components/Analysis/index.tsx b/src/pages/app/components/Analysis/index.tsx index 9adc71a13..9e18ea393 100644 --- a/src/pages/app/components/Analysis/index.tsx +++ b/src/pages/app/components/Analysis/index.tsx @@ -6,7 +6,7 @@ */ import { ElScrollbar } from "element-plus" import { defineComponent, type StyleValue } from "vue" -import ContentContainer, { FilterContainer } from "../common/ContentContainer" +import ContentContainer, { FilterContainer } from '../common/ContentContainer' import AnalysisFilter from "./components/AnalysisFilter" import Summary from "./components/Summary" import Trend from "./components/Trend" diff --git a/src/pages/app/components/Analysis/types.d.ts b/src/pages/app/components/Analysis/types.d.ts index 9bbe6135e..56fbcd80b 100644 --- a/src/pages/app/components/Analysis/types.d.ts +++ b/src/pages/app/components/Analysis/types.d.ts @@ -1,7 +1,17 @@ export type AnalysisTarget = { type: 'site' - key: timer.site.SiteInfo + key: tt4b.site.SiteInfo } | { type: 'cate' key: number -} \ No newline at end of file +} + +export type DimensionEntry = { + date: string + value: number +} + +export type DimensionData = { + thisPeriod: DimensionEntry[] + previousPeriod: DimensionEntry[] +} diff --git a/src/pages/app/components/Analysis/util.ts b/src/pages/app/components/Analysis/util.ts index 86b1c0662..28ca91b89 100644 --- a/src/pages/app/components/Analysis/util.ts +++ b/src/pages/app/components/Analysis/util.ts @@ -6,11 +6,12 @@ */ import { t } from "@app/locale" +import type { ValueFormatter } from '../common/kanban/types' /** * Transfer host info to label */ -export function labelOfHostInfo(site: timer.site.SiteKey | undefined): string { +export function labelOfHostInfo(site: tt4b.site.SiteKey | undefined): string { if (!site) return '' const { host, type } = site if (!host) return '' @@ -20,35 +21,5 @@ export function labelOfHostInfo(site: timer.site.SiteKey | undefined): string { return `${host}${label}` } -export type RingValue = [ - current?: number, - last?: number, -] - -/** - * Compute ring text - * - * @param ring ring value - * @param formatter formatter - * @returns text or '-' - */ -export function computeRingText(ring: RingValue, formatter?: ValueFormatter): string | undefined { - const [current, last] = ring - if (current === undefined && last === undefined) { - // return undefined if both are undefined - return undefined - } - const delta = (current ?? 0) - (last ?? 0) - let result = formatter ? formatter(delta) : delta?.toString() - delta >= 0 && (result = '+' + result) - return result -} - -export type ValueFormatter = (val: number | undefined) => string export const formatValue = (val: number | undefined, formatter?: ValueFormatter) => formatter ? formatter(val) : val?.toString() || '-' - -export type DimensionEntry = { - date: string - value: number -} \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts b/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts index 8441ab945..46462517f 100644 --- a/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts @@ -4,23 +4,19 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { getWeekStartDay } from "@api/sw/option" import { t } from "@app/locale" -import { getStepColors } from "@app/util/echarts" -import { cvt2LocaleTime } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" -import weekHelper from "@service/components/week-helper" +import { getStepColors } from '@app/util/echarts' +import { cvt2LocaleTime } from '@app/util/time' +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { groupBy, rotate } from "@util/array" import { formatPeriodCommon, getAllDatesBetween, MILL_PER_HOUR, MILL_PER_MINUTE } from "@util/time" -import { - type ComposeOption, - type GridComponentOption, - type HeatmapSeriesOption, - type ScatterSeriesOption, - type TooltipComponentOption, - type VisualMapComponentOption, +import type { + ComposeOption, GridComponentOption, HeatmapSeriesOption, ScatterSeriesOption, TooltipComponentOption, + VisualMapComponentOption, } from "echarts" -import { TopLevelFormatterParams } from "echarts/types/dist/shared" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" export type ChartValue = [ x: number, @@ -53,19 +49,15 @@ function getXAxisLabelMap(data: ChartValue[]): { [x: string]: string } { const result: Record = {} // {[ x:string ]: Set } const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substring(4, 6)))) - let lastMonth: string + let lastMonth: string | undefined Object.entries(xAndMonthMap).forEach(([x, monthSet]) => { - if (monthSet.size != 1) { - return - } + if (monthSet.size != 1) return const currentMonth = Array.from(monthSet)[0] - if (currentMonth === lastMonth) { - return - } + if (!currentMonth || currentMonth === lastMonth) return lastMonth = currentMonth const monthNum = parseInt(currentMonth) const label = allMonthLabel[monthNum - 1] - result[x] = label + label && (result[x] = label) }) return result } @@ -127,8 +119,8 @@ function optionOf(data: ChartValue[], weekDays: string[], dom: HTMLElement): EcO borderWidth: 0, formatter: (params: TopLevelFormatterParams) => { const param = Array.isArray(params) ? params[0] : params - const { data } = param - const { value } = data as any + const { data } = param ?? {} + const { value } = data as any ?? {} const [_1, _2, mills, date] = value return mills ? formatTooltip(mills as number, date) : '' }, @@ -191,7 +183,7 @@ class Wrapper extends EchartsWrapper { data.push([x, y, dailyMills, date]) }) const weekDays = (t(msg => msg.calendar.weekDays)?.split?.('|') || []).reverse() - const weekStart = await weekHelper.getRealWeekStart() + const weekStart = await getWeekStartDay() weekStart && rotate(weekDays, weekStart, true) return optionOf(data, weekDays, this.getDom()) } diff --git a/src/pages/app/components/Dashboard/components/Calendar/index.tsx b/src/pages/app/components/Dashboard/components/Calendar/index.tsx index 7586c4cd8..263e5564f 100644 --- a/src/pages/app/components/Dashboard/components/Calendar/index.tsx +++ b/src/pages/app/components/Dashboard/components/Calendar/index.tsx @@ -6,19 +6,17 @@ */ import { createTabAfterCurrent } from "@api/chrome/tab" -import type { ReportQueryParam } from "@app/components/Report/types" +import { getWeekStartTime } from '@api/sw/option' +import { listSiteStats } from '@api/sw/stat' +import ChartTitle from '@app/components/Dashboard/ChartTitle' import { t } from "@app/locale" -import { REPORT_ROUTE } from "@app/router/constants" -import { useRequest } from "@hooks" -import { useEcharts } from "@hooks/useEcharts" +import { REPORT_ROUTE, type ReportQuery } from '@app/router/constants' +import { useEcharts, useRequest } from "@hooks" import Flex from "@pages/components/Flex" -import weekHelper from "@service/components/week-helper" -import { selectSite } from '@service/stat-service' import { groupBy, sum } from "@util/array" import { getAppPageUrl } from "@util/constant/url" -import { formatTimeYMD, MILL_PER_DAY, MILL_PER_HOUR } from "@util/time" +import { cvtDateRange2Str, formatTimeYMD, MILL_PER_DAY, MILL_PER_HOUR } from "@util/time" import { computed, defineComponent } from "vue" -import ChartTitle from "../../ChartTitle" import Wrapper, { type BizOption, type ChartValue } from "./Wrapper" const titleText = (option: Result | undefined) => { @@ -38,15 +36,11 @@ type Result = BizOption & { yearAgo: Date } const fetchData = async (): Promise => { const endTime = new Date() - const yearAgo = new Date(endTime.getTime() - MILL_PER_DAY * 365) - const [startTime] = await weekHelper.getWeekDate(yearAgo) - const items = await selectSite({ date: [startTime, endTime], sortKey: 'date' }) - const value = groupBy( - items, - i => i.date, - list => sum(list?.map(i => i.focus ?? 0)) - ) - return { value, startTime, endTime, yearAgo } + const yearAgo = endTime.getTime() - MILL_PER_DAY * 365 + const startTime = await getWeekStartTime(yearAgo) + const items = await listSiteStats({ date: cvtDateRange2Str([startTime, endTime]), sortKey: 'date' }) + const value = groupBy(items, i => i.date, list => sum(list.map(i => i.focus))) + return { value, startTime, endTime, yearAgo: new Date(yearAgo) } } /** @@ -64,9 +58,8 @@ function handleClick(value: ChartValue): void { const currentMonth = parseInt(currentDate.substring(4, 6)) - 1 const currentDay = parseInt(currentDate.substring(6, 8)) const currentTs = (new Date(currentYear, currentMonth, currentDay).getTime() + 1000).toString() - const query: ReportQueryParam = { ds: currentTs, de: currentTs } - const url = getAppPageUrl(REPORT_ROUTE, query) + const url = getAppPageUrl(REPORT_ROUTE, { ds: currentTs, de: currentTs } satisfies ReportQuery) createTabAfterCurrent(url) } diff --git a/src/pages/app/components/Dashboard/components/Indicator.tsx b/src/pages/app/components/Dashboard/components/Indicator.tsx index 7cc461d7e..5f42fca1c 100644 --- a/src/pages/app/components/Dashboard/components/Indicator.tsx +++ b/src/pages/app/components/Dashboard/components/Indicator.tsx @@ -5,57 +5,66 @@ * https://opensource.org/licenses/MIT */ -import NumberGrow from "@app/components/common/NumberGrow" +import { listPeriods } from "@api/sw/period" +import { listSiteStats } from "@api/sw/stat" import { tN, type I18nKey } from "@app/locale" -import periodDatabase from "@db/period-database" import { Sunrise } from "@element-plus/icons-vue" import { useRequest, useXsState } from "@hooks" import Flex from "@pages/components/Flex" -import { selectSite } from "@service/stat-service" -import { calcMostPeriodOf2Hours } from "@util/period" -import { getStartOfDay, MILL_PER_DAY, MILL_PER_MINUTE } from "@util/time" +import { groupBy, sum } from '@util/array' +import { getStartOfDay, MILL_PER_DAY, MILL_PER_HOUR, MILL_PER_MINUTE } from "@util/time" import { ElIcon, ElScrollbar } from "element-plus" import { computed, defineComponent, toRef, type VNode } from "vue" +import NumberGrow from "./NumberGrow" type _Value = { installedDays?: number sites: number visits: number browsingTime: number - most2Hour: number + busiestClock: number | undefined } /** * @return days used */ function calculateInstallDays(installTime: Date, now: Date): number { - const deltaMills = getStartOfDay(now).getTime() - getStartOfDay(installTime).getTime() + const deltaMills = getStartOfDay(now) - getStartOfDay(installTime) return Math.round(deltaMills / MILL_PER_DAY) } +function calcBusiestClock(rows: tt4b.period.Row[]): number | undefined { + const map = groupBy(rows, + ({ startTime }) => startTime - getStartOfDay(startTime), + list => sum(list.map(e => e.milliseconds)) + ) + const maxOffsetStr = Object.entries(map).sort((a, b) => b[1] - a[1])[0]?.[0] + if (maxOffsetStr === undefined) return undefined + return Math.floor(Number.parseInt(maxOffsetStr) / MILL_PER_HOUR) +} + async function query(): Promise<_Value> { - const allData = await selectSite() + const allData = await listSiteStats() const hostSet = new Set() let visits = 0 let browsingTime = 0 - allData.forEach(({ siteKey, focus, time }) => { - const { host } = siteKey || {} - host && hostSet.add(host) + allData.forEach(({ siteKey: { host }, focus, time }) => { + hostSet.add(host) visits += time browsingTime += focus }) - const periodInfos: timer.period.Result[] = await periodDatabase.getAll() - const most2Hour = calcMostPeriodOf2Hours(periodInfos) + const periods = await listPeriods({ size: 8 }) + const busiestClock = calcBusiestClock(periods) const result: _Value = { - sites: hostSet?.size || 0, + sites: hostSet.size, visits, browsingTime, - most2Hour + busiestClock, } // 2. if not exist, calculate from all data items - const firstDate = allData.map(a => a.date).filter(d => d?.length === 8).sort()[0] + const firstDate = allData.map(a => a.date).filter(d => d.length === 8).sort()[0] if (firstDate) { const year = parseInt(firstDate.substring(0, 4)) const month = parseInt(firstDate.substring(4, 6)) - 1 @@ -89,10 +98,10 @@ const IndicatorLabel = defineComponent(props => { }, { props: ['path', 'param', 'duration'] }) const computeMost2HourParam = (value: _Value | undefined): { start: number, end: number } => { - const most2HourIndex = value?.most2Hour - const [start, end] = most2HourIndex === undefined || isNaN(most2HourIndex) + const { busiestClock } = value ?? {} + const [start, end] = busiestClock === undefined || isNaN(busiestClock) ? [0, 0] - : [most2HourIndex * 2, most2HourIndex * 2 + 2] + : [busiestClock, busiestClock + 2] return { start, end } } diff --git a/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts b/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts index 02472e477..1bb685ffb 100644 --- a/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts @@ -1,15 +1,11 @@ import { getCompareColor, getDiffColor, tooltipDot } from "@app/util/echarts" -import { cvt2LocaleTime } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { cvt2LocaleTime } from '@app/util/time' +import { EchartsWrapper } from "@hooks" import { formatPeriodCommon } from "@util/time" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, - type LegendComponentOption, - type TooltipComponentOption, +import type { + BarSeriesOption, ComposeOption, GridComponentOption, LegendComponentOption, TooltipComponentOption, } from "echarts" -import { type TopLevelFormatterParams } from "echarts/types/dist/shared" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" type EcOption = ComposeOption< | BarSeriesOption @@ -38,9 +34,9 @@ function optionOf(lastPeriodItems: Row[], thisPeriodItems: Row[], domWidth: numb const [thisColor, lastColor] = params.map(v => v.color) const { date: thisDate, total: thisVal } = thisItem || {} const { date: lastDate, total: lastVal } = lastItem || {} - const lastStr = `${tooltipDot(lastColor as string)} ${cvt2LocaleTime(lastDate)} ${formatPeriodCommon(lastVal)}` - let thisStr = `${tooltipDot(thisColor as string)} ${cvt2LocaleTime(thisDate)} ${formatPeriodCommon(thisVal)}` - if (lastVal) { + const lastStr = `${tooltipDot(lastColor as string)} ${cvt2LocaleTime(lastDate)} ${formatPeriodCommon(lastVal ?? 0)}` + let thisStr = `${tooltipDot(thisColor as string)} ${cvt2LocaleTime(thisDate)} ${formatPeriodCommon(thisVal ?? 0)}` + if (lastVal && thisVal) { const delta = (thisVal - lastVal) / lastVal * 100 let deltaStr = delta.toFixed(1) + '%' if (delta >= 0) deltaStr = '+' + deltaStr @@ -78,7 +74,7 @@ function optionOf(lastPeriodItems: Row[], thisPeriodItems: Row[], domWidth: numb barCategoryGap: `${domWidth < 500 ? 30 : 55}%`, itemStyle: { color: color1 }, data: thisPeriodItems.map((row, idx) => { - const otherIsEmpty = lastPeriodItems[idx].total === 0 + const otherIsEmpty = !lastPeriodItems[idx]?.total return { value: row.total, row, itemStyle: { borderRadius: otherIsEmpty ? 10 : [10, 10, 0, 0] }, @@ -90,7 +86,7 @@ function optionOf(lastPeriodItems: Row[], thisPeriodItems: Row[], domWidth: numb type: 'bar', itemStyle: { color: color2 }, data: lastPeriodItems.map((row, idx) => { - const otherIsEmpty = thisPeriodItems[idx].total === 0 + const otherIsEmpty = !thisPeriodItems[idx]?.total return { value: -row.total, row, itemStyle: { borderRadius: otherIsEmpty ? 10 : [0, 0, 10, 10] }, diff --git a/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx b/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx index e08e61b52..5cc854b16 100644 --- a/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx +++ b/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx @@ -1,12 +1,12 @@ +import { listSiteStats } from "@api/sw/stat" +import ChartTitle from "@app/components/Dashboard/ChartTitle" import { t } from "@app/locale" -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import Flex from "@pages/components/Flex" -import { selectSite } from "@service/stat-service" import { groupBy, sum } from "@util/array" import DateIterator from "@util/date-iterator" -import { MILL_PER_DAY } from "@util/time" +import { cvtDateRange2Str, MILL_PER_DAY } from "@util/time" import { defineComponent } from "vue" -import ChartTitle from "../../ChartTitle" import Wrapper from "./Wrapper" const PERIOD_WIDTH = 30 @@ -17,7 +17,7 @@ type Row = { total: number } -const cvtRow = (rows: timer.stat.Row[], start: Date, end: Date): Row[] => { +const cvtRow = (rows: tt4b.stat.Row[], start: Date, end: Date): Row[] => { const groupByDate = groupBy(rows, r => r.date, l => sum(l.map(e => e.focus ?? 0))) const iterator = new DateIterator(start, end) const result: Row[] = [] @@ -37,9 +37,9 @@ const fetchData = async (): Promise<[thisMonth: Row[], lastMonth: Row[]]> => { // Query with alias // @since 1.1.8 - const lastPeriodItems = await selectSite({ date: [lastPeriodStart, lastPeriodEnd] }) + const lastPeriodItems = await listSiteStats({ date: cvtDateRange2Str([lastPeriodStart, lastPeriodEnd]) }) const lastRows = cvtRow(lastPeriodItems, lastPeriodStart, lastPeriodEnd) - const thisPeriodItems = await selectSite({ date: [thisPeriodStart, thisPeriodEnd] }) + const thisPeriodItems = await listSiteStats({ date: cvtDateRange2Str([thisPeriodStart, thisPeriodEnd]) }) const thisRows = cvtRow(thisPeriodItems, thisPeriodStart, thisPeriodEnd) return [lastRows, thisRows] } diff --git a/src/pages/app/components/common/NumberGrow.tsx b/src/pages/app/components/Dashboard/components/NumberGrow.tsx similarity index 94% rename from src/pages/app/components/common/NumberGrow.tsx rename to src/pages/app/components/Dashboard/components/NumberGrow.tsx index 195ff6051..25f71ee8c 100644 --- a/src/pages/app/components/common/NumberGrow.tsx +++ b/src/pages/app/components/Dashboard/components/NumberGrow.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useCountUp } from '@hooks/useCount' +import { useCountUp } from '@hooks' import { tNum } from '@i18n' import { computed, defineComponent, toRef } from "vue" diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts index 2db0f8d35..a3bb3ccb4 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts @@ -1,18 +1,22 @@ +import { t } from '@app/locale' import { getSeriesPalette, tooltipDot } from '@app/util/echarts' -import { EchartsWrapper } from '@hooks/useEcharts' +import { EchartsWrapper } from '@hooks' import { getPrimaryTextColor } from '@pages/util/style' import { groupBy, toMap } from '@util/array' -import { formatPeriodCommon, MILL_PER_DAY, MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from '@util/time' +import { CATE_NOT_SET_ID } from '@util/site' import { - type ComposeOption, type CustomSeriesOption, type CustomSeriesRenderItem, - type DataZoomComponentOption, type GridComponentOption, type LegendComponentOption, type TooltipComponentOption + formatPeriodCommon, getStartOfDay, MILL_PER_DAY, MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND +} from '@util/time' +import type { + ComposeOption, CustomSeriesOption, CustomSeriesRenderItem, DataZoomComponentOption, GridComponentOption, + LegendComponentOption, TooltipComponentOption, } from 'echarts' import { graphic } from "echarts/core" -import type { Activity, MergeMethod } from './useMerge' +import { formatYAxis } from '../common' export type BizData = { - activities: Activity[] - merge: MergeMethod + activities: tt4b.timeline.Activity[] + merge: tt4b.timeline.MergeMethod dates: string[] } @@ -30,18 +34,12 @@ type LegendInfo = { color: string } -type MyItem = { - // host - name: string - value: [ - yIndex: number, - // seconds - start: number, - // seconds - end: number, - duration: number, - ] -} +type MyValue = [ + yIndex: number, + start: number, // milliseconds, offset of this day + end: number, // milliseconds, offset of this day + duration: number, +] const formatTimeLabel = (val: number) => { let minute = Math.floor(val / MILL_PER_MINUTE) @@ -71,7 +69,7 @@ const formatDuration = (duration: number): string => { const LEGEND_WIDTH = 180 -const collectLegends = (activities: Activity[]): LegendInfo[] => { +const collectLegends = (activities: tt4b.timeline.Activity[], merge: tt4b.timeline.MergeMethod): LegendInfo[] => { const colors = getSeriesPalette() const colorLen = colors.length || 1 @@ -82,15 +80,24 @@ const collectLegends = (activities: Activity[]): LegendInfo[] => { .sort((a, b) => b[1] - a[1]) .map(([key], idx) => ({ name: key, - displayName: keyNameMap[key], - color: colors[idx % colorLen], - })) + displayName: merge === 'cate' && key === String(CATE_NOT_SET_ID) + ? t(msg => msg.shared.cate.notSet) + : keyNameMap[key], + color: colors[idx % colorLen] ?? '#000', + } satisfies LegendInfo)) +} + +type Cartesian2DCoordSys = { + x: number + y: number + width: number + height: number } const renderItem: CustomSeriesRenderItem = (params, api) => { const categoryIndex = api.value(0) - const start = api.coord([api.value(1), categoryIndex]) - const end = api.coord([api.value(2), categoryIndex]) + const [sX = 0, sY = 0] = api.coord([api.value(1), categoryIndex]) + const [eX = 0] = api.coord([api.value(2), categoryIndex]) const size = api.size?.([0, 1]) const height = ((Array.isArray(size) ? size[1] : size) ?? 0) * 0.6 @@ -98,10 +105,10 @@ const renderItem: CustomSeriesRenderItem = (params, api) => { const coordSys = params.coordSys as unknown as Cartesian2DCoordSys var rectShape = graphic.clipRectByRect({ - x: start[0], - y: start[1] - height / 2, - width: end[0] - start[0], - height: height + x: sX, + y: sY - height / 2, + width: eX - sX, + height }, { x: coordSys.x, y: coordSys.y, @@ -137,14 +144,20 @@ const generateSeries = (biz: BizData, legendColors: Record): EcO }, renderItem, selectedMode: true, - data: list.map(({ date, start, duration }) => ({ - value: [dates.indexOf(date), start, start + duration, duration], - })) + data: list.map(({ start, duration }) => { + const date = formatYAxis(start) + const dateIdx = dates.indexOf(date) + const dayStart = getStartOfDay(start) + const startOffset = start - dayStart + const endOffset = start + duration - dayStart + const value: MyValue = [dateIdx, startOffset, endOffset, duration] + return { value } + }) } }) } -const calcDataZoomDefaultRange = (activities: Activity[]): [start: number | undefined, end: number | undefined] => { +const calcDataZoomDefaultRange = (activities: tt4b.timeline.Activity[]): [start: number | undefined, end: number | undefined] => { if (!activities.length) return [undefined, undefined] let min = activities.map(a => a.start).reduce((a, b) => b < a ? b : a) let max = activities.map(({ start, duration }) => start + duration).reduce((a, b) => b > a ? b : a) @@ -166,7 +179,7 @@ class Wrapper extends EchartsWrapper { const gridLeft = Math.min(Math.max(30, domWidth * .05), 60) const primaryTextColor = getPrimaryTextColor() - const legendData = collectLegends(activities) + const legendData = collectLegends(activities, merge) const legendNames = toMap(legendData, e => e.name, e => e.displayName) const legendColor = toMap(legendData, e => e.name, e => e.color) @@ -220,8 +233,8 @@ class Wrapper extends EchartsWrapper { formatter: params => { const color = (params as any)?.color ?? "#000" const param = Array.isArray(params) ? params[0] : params - const { value, seriesName } = param - const [_1, start, _2, duration] = value as MyItem['value'] + const { value, seriesName } = param ?? {} + const [_1, start, _2, duration] = value as MyValue const startStr = formatStart(start) const durStr = formatDuration(duration) return `${tooltipDot(color)} ${seriesName ? tooltipSeriesName(seriesName) : ''}` diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx index 3008520dc..a259ff9db 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx @@ -7,22 +7,19 @@ import ChartTitle from '@app/components/Dashboard/ChartTitle' import { t } from '@app/locale' -import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database' import { Collection, Files, Link } from '@element-plus/icons-vue' -import { useShadow } from '@hooks' -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from '@hooks' import Flex from "@pages/components/Flex" +import IconRadioGroup from '@pages/components/IconRadioGroup' import { type ECElementEvent, type ECharts } from "echarts/core" -import { ElIcon, ElRadioButton, ElRadioGroup } from 'element-plus' -import { computed, defineComponent } from "vue" -import { type JSX } from 'vue/jsx-runtime' -import Wrapper, { EcOption, type BizData } from './Wrapper' -import { useMerge, type MergeMethod } from './useMerge' +import { type Component, computed, defineComponent } from "vue" +import { TIMELINE_DAY_COUNT, useTimelineContext } from '../context' +import Wrapper, { BizData, EcOption } from './Wrapper' -const CHART_CONFIG: Record = { - none: , - domain: , - cate: , +const CHART_CONFIG: Record = { + none: Files, + domain: Link, + cate: Collection, } const extractLegendSelected = (legends: EcOption['legend']): Record => { @@ -55,14 +52,9 @@ const handleClick = (inst: ECharts, ev: ECElementEvent) => { seriesNames2Toggle.forEach(name => inst.dispatchAction({ type: "legendToggleSelect", name })) } -const TimelineChart = defineComponent<{ data: timer.timeline.Tick[] }>(props => { - const [myData] = useShadow(() => props.data) - const { merge, setMerge, activities, dates } = useMerge(myData) - const bizData = computed(() => ({ - activities: activities.value, - merge: merge.value, - dates, - })) +const TimelineChart = defineComponent<{}>(() => { + const { dates, activities, merge, setMerge } = useTimelineContext() + const bizData = computed(() => ({ dates, activities: activities.value, merge: merge.value })) const { elRef } = useEcharts(Wrapper, bizData, { afterInit: ew => { @@ -77,22 +69,19 @@ const TimelineChart = defineComponent<{ data: timer.timeline.Tick[] }>(props => - {t(msg => msg.dashboard.timeline.title, { n: TIMELINE_LIFE_CYCLE })} - - - - {Object.entries(CHART_CONFIG).map(([k, v]) => ( - - {v} - - ))} - + {t(msg => msg.dashboard.timeline.title, { n: TIMELINE_DAY_COUNT })} + setMerge(val as tt4b.timeline.MergeMethod)} + options={Object.entries(CHART_CONFIG).map(([value, icon]) => ({ value, icon }))} + /> - +
) -}, { props: ['data'] }) +}) export default TimelineChart \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts deleted file mode 100644 index 838c8bb51..000000000 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { useCategory } from '@app/context' -import { t } from '@app/locale' -import mergeRuleDatabase from '@db/merge-rule-database' -import siteDatabase from '@db/site-database' -import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database' -import { useState } from '@hooks' -import CustomizedHostMergeRuler from '@service/components/host-merge-ruler' -import { toMap } from '@util/array' -import { CATE_NOT_SET_ID } from '@util/site' -import { formatTime, getAllDatesBetween, getStartOfDay, MILL_PER_DAY } from '@util/time' -import { type Ref, ref, watch } from 'vue' - -export type Activity = { - date: string - // offset of date (mills) - start: number - // mills - duration: number - // series - seriesKey: string - seriesName: string | undefined -} - -type ActivityInner = Omit - -export type MergeMethod = 'cate' | 'domain' | 'none' - -const isMergeMethod = (val: unknown): val is MergeMethod => { - return val === 'none' || val === 'domain' || val === 'cate' -} - -const MONTH_DATE_FORMAT = t(msg => msg.calendar.monthDateFormat) -const formatDate = (date: Date | number) => formatTime(date, MONTH_DATE_FORMAT) - -const calcOffsetOfDay = (ts: number) => { - const startOfDate = getStartOfDay(ts) - return ts - startOfDate.getTime() -} - -const genLatestDates = () => { - const now = new Date() - const start = new Date(now.getTime() - MILL_PER_DAY * (TIMELINE_LIFE_CYCLE - 1)) - return getAllDatesBetween(start, now, formatDate) -} - -async function mergeByDomain(ticks: timer.timeline.Tick[]): Promise { - // 1. merge all - const mergeRules = await mergeRuleDatabase.selectAll() - const merger = new CustomizedHostMergeRuler(mergeRules) - const allHosts = Array.from(new Set(ticks.map(t => t.host))) - const mergedMap = toMap(allHosts, h => h, h => merger.merge(h)) - - // 2. query all the merged sites' names - const allSiteKeys = Array.from(new Set(Object.values(mergedMap))) - .map((mergedHost) => ({ type: 'merged', host: mergedHost } satisfies timer.site.SiteKey)) - const allSites = await siteDatabase.getBatch(allSiteKeys) - const nameMap = toMap(allSites, s => s.host, s => s.alias) - - // 3. convert - return ticks.map(({ start, duration, host }) => { - const seriesKey = mergedMap[host] ?? host - return { - start, duration, - seriesKey, seriesName: nameMap[seriesKey], - } - }) -} - -async function mergeByCate(ticks: timer.timeline.Tick[], cateNameMap: Record): Promise { - // 1. query all the sites' category - const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) - .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) - const allSites = await siteDatabase.getBatch(allSiteKeys) - const siteCateMap = toMap(allSites, s => s.host, s => s.cate) - - // 2. convert - return ticks.map(({ start, duration, host }) => { - const cateId = siteCateMap[host] ?? CATE_NOT_SET_ID - return { - start, duration, - seriesKey: `${cateId}`, - seriesName: cateNameMap[cateId], - } - }) -} - -async function fillSiteName(ticks: timer.timeline.Tick[]): Promise { - // 1. query all the sites' names - const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) - .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) - const allSites = await siteDatabase.getBatch(allSiteKeys) - const nameMap = toMap(allSites, s => s.host, s => s.alias) - - // 2. convert - return ticks.map(({ start, duration, host }) => ({ - start, duration, - seriesKey: host, seriesName: nameMap[host], - })) -} - -async function handleMerge( - ticks: timer.timeline.Tick[], - merge: MergeMethod, - cateNameMap: Record, - dates: Set -): Promise { - let activities: ActivityInner[] = [] - if (merge === 'domain') { - activities = await mergeByDomain(ticks) - } else if (merge === 'cate') { - activities = await mergeByCate(ticks, cateNameMap) - } else { - activities = await fillSiteName(ticks) - } - const result: Activity[] = [] - activities.forEach(act => { - let actStart = act.start - const date = formatDate(actStart) - if (!dates.has(date)) return - - const start = calcOffsetOfDay(act.start) - result.push({ ...act, date, start }) - }) - return result -} - -export const useMerge = (ticks: Ref) => { - const dates = genLatestDates() - const merge = ref('none') - const cate = useCategory() - const setMerge = (val: unknown) => isMergeMethod(val) && (merge.value = val) - - const [activities, setActivities] = useState([]) - - watch([ticks, merge, cate], async () => { - const newVal = await handleMerge(ticks.value, merge.value, cate.nameMap, new Set(dates)) - setActivities(newVal) - }, { immediate: true }) - - return { merge, setMerge, activities, dates } -} \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx index 0aec23a29..2831e44bc 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx @@ -6,37 +6,33 @@ import { groupBy } from '@util/array' import { MILL_PER_HOUR, MILL_PER_MINUTE } from '@util/time' import { ElIcon, ElRate, ElText, ElTooltip } from 'element-plus' import { computed, defineComponent } from 'vue' - -type AnalysisResult = { - busy: number - focus: number -} +import { useTimelineContext } from './context' const MAX_SCORE = 5 -const defaultResult = (): AnalysisResult => ({ +const defaultResult = (): { busy: number, focus: number } => ({ busy: 1, focus: 1, }) -const computeSessionScore = (ticks: timer.timeline.Tick[], hourCount: number) => { +const computeSessionScore = (activities: tt4b.timeline.Activity[], hourCount: number) => { let continuousSessions = 0 - let currentSession: timer.timeline.Tick[] = [] + let currentSession: tt4b.timeline.Activity[] = [] - ticks.sort((a, b) => a.start - b.start).forEach(currentTick => { - if (!currentSession.length) { - currentSession.push(currentTick) + activities.sort((a, b) => a.start - b.start).forEach(current => { + const prev = currentSession[currentSession.length - 1] + if (!prev) { + currentSession.push(current) return } - const prevTick = currentSession[currentSession.length - 1] - const gap = currentTick.start - (prevTick.start + prevTick.duration) + const gap = current.start - (prev.start + prev.duration) if (gap <= MILL_PER_MINUTE * 3) { - currentSession.push(currentTick) + currentSession.push(current) } else { if (currentSession.length > 1) continuousSessions++ - currentSession = [currentTick] + currentSession = [current] } }) @@ -46,31 +42,31 @@ const computeSessionScore = (ticks: timer.timeline.Tick[], hourCount: number) => return Math.min(sessionDensity, MAX_SCORE) } -const analyze = (ticks: timer.timeline.Tick[]): AnalysisResult => { - if (!ticks.length) return defaultResult() +const analyze = (activities: tt4b.timeline.Activity[]): { busy: number, focus: number } => { + if (!activities.length) return defaultResult() - const minTime = ticks.map(t => t.start).sort((a, b) => a - b)[0]! - const maxTime = ticks.map(t => t.start + t.duration).sort((a, b) => b - a)[0]! + const minTime = activities.map(t => t.start).sort((a, b) => a - b)[0]! + const maxTime = activities.map(t => t.start + t.duration).sort((a, b) => b - a)[0]! const totalRange = maxTime - minTime - const totalActiveTime = ticks.reduce((sum, tick) => sum + tick.duration, 0) + const totalActiveTime = activities.reduce((sum, s) => sum + s.duration, 0) - // { hourStart: hosts } - const hourlyData = groupBy(ticks, + // { hourStart: distinct series keys } — same merge dimension as the chart + const hourlyData = groupBy(activities, t => Math.floor(t.start / MILL_PER_HOUR) * MILL_PER_HOUR, - l => new Set(l.map(t => t.host)), + l => new Set(l.map(t => t.seriesKey)), ) - // busyScore = timeDensity * 0.6 + hostCountPerHour * 0.4 + // busyScore = timeDensity * 0.6 + seriesCountPerHour * 0.4 const timeDensity = totalActiveTime / totalRange const timeDensityScore = Math.min(timeDensity / 0.3, MAX_SCORE) - const maxHostCount = Object.values(hourlyData).map(hosts => hosts.size).sort((a, b) => b - a)[0]! - const hostMaxScore = Math.min(maxHostCount / 4, MAX_SCORE) - const busy = timeDensityScore * 0.6 + hostMaxScore * 0.4 + const maxSeriesCount = Object.values(hourlyData).map(keys => keys.size).sort((a, b) => b - a)[0]! + const seriesMaxScore = Math.min(maxSeriesCount / 4, MAX_SCORE) + const busy = timeDensityScore * 0.6 + seriesMaxScore * 0.4 // focusScore = duration * 0.7 + session * 0.3 - const avgDuration = totalActiveTime / ticks.length + const avgDuration = totalActiveTime / activities.length const avgDurationScore = Math.min(avgDuration / (2 * MILL_PER_MINUTE), MAX_SCORE) - const sessionScore = computeSessionScore(ticks, Object.keys(hourlyData).length) + const sessionScore = computeSessionScore(activities, Object.keys(hourlyData).length) const focus = avgDurationScore * 0.7 + sessionScore * 0.3 return { busy, focus } @@ -95,23 +91,24 @@ const Score = defineComponent<{ score: number, label: string, desc: string }>(pr ) }, { props: ['desc', 'label', 'score'] }) -const Summary = defineComponent<{ data: timer.timeline.Tick[] }>(props => { - const ticks = computed(() => analyze(props.data)) +const Summary = defineComponent<{}>(() => { + const { activities } = useTimelineContext() + const scores = computed(() => analyze(activities.value)) return () => ( msg.dashboard.timeline.busyScore)} desc={t(msg => msg.dashboard.timeline.busyScoreDesc)} /> msg.dashboard.timeline.focusScore)} desc={t(msg => msg.dashboard.timeline.focusScoreDesc)} /> ) -}, { props: ['data'] }) +}) export default Summary \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/common.ts b/src/pages/app/components/Dashboard/components/Timeline/common.ts new file mode 100644 index 000000000..5e157153b --- /dev/null +++ b/src/pages/app/components/Dashboard/components/Timeline/common.ts @@ -0,0 +1,5 @@ +import { t } from '@app/locale' +import { formatTime } from '@util/time' + +const MONTH_DATE_FORMAT = t(msg => msg.calendar.monthDateFormat) +export const formatYAxis = (date: Date | number) => formatTime(date, MONTH_DATE_FORMAT) \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/context.ts b/src/pages/app/components/Dashboard/components/Timeline/context.ts new file mode 100644 index 000000000..66f6bd458 --- /dev/null +++ b/src/pages/app/components/Dashboard/components/Timeline/context.ts @@ -0,0 +1,38 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { useProvide, useProvider, useRequest, useState } from '@hooks' +import { getAllDatesBetween, getStartOfDay, MILL_PER_DAY } from '@util/time' +import { type ShallowRef } from 'vue' +import { formatYAxis } from './common' + +/** + * The days shown in the timeline + */ +export const TIMELINE_DAY_COUNT = 3 + +const NAMESPACE = 'dashboard-timeline' + +type ContextValue = { + dates: string[] + activities: ShallowRef + merge: ShallowRef + setMerge: ArgCallback +} + +export const initTimelineContext = () => { + const start = getStartOfDay(Date.now() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) + const dates = getAllDatesBetween(new Date(start), new Date(), formatYAxis) + const [merge, setMerge] = useState('none') + const { data: activities } = useRequest( + () => sendMsg2Runtime('timeline.list', { start, merge: merge.value }), + { + deps: [merge], + defaultValue: [], + } + ) + + useProvide(NAMESPACE, { dates, activities, merge, setMerge }) +} + +export const useTimelineContext = () => useProvider(NAMESPACE, + 'dates', 'activities', 'merge', 'setMerge', +) \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/index.tsx index 346f0f6be..0ec851c7a 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/index.tsx @@ -1,19 +1,18 @@ -import timelineDatabase from '@db/timeline-database' -import { useRequest } from '@hooks' +import DashboardCard from '@app/components/Dashboard/DashboardCard' import { defineComponent } from 'vue' -import DashboardCard from '../../DashboardCard' import TimelineChart from './Chart' import Summary from './Summary' +import { initTimelineContext } from './context' const Timeline = defineComponent<{ height: number }>(({ height }) => { - const { data } = useRequest(() => timelineDatabase.getAll(), { defaultValue: [] }) + initTimelineContext() return () => <> - + - + }, { props: ['height'] }) diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts index a68a549ce..a79609591 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts @@ -1,9 +1,7 @@ import { getStepColors, tooltipDot } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from "@hooks" import { generateSiteLabel } from "@util/site" -import type { BarSeriesOption } from "echarts/charts" -import type { GridComponentOption, TooltipComponentOption } from "echarts/components" -import type { ComposeOption } from "echarts/core" +import type { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" import { BizOption } from "../context" type EcOption = ComposeOption< diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx index 0852af7cc..8d0ea79d0 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx @@ -1,4 +1,4 @@ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { defineComponent } from "vue" import { useTopKValue } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts index 424a9b4b2..bc9587bac 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts @@ -1,21 +1,14 @@ -import { getSeriesPalette, tooltipDot } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { getSeriesPalette, tooltipDot } from '@app/util/echarts' +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { generateSiteLabel } from "@util/site" -import { - type ComposeOption, - type GridComponentOption, - type PieSeriesOption, - type TooltipComponentOption, -} from "echarts" +import type { ComposeOption, GridComponentOption, PieSeriesOption, TooltipComponentOption } from "echarts" import { BizOption } from "../context" type EcOption = ComposeOption< | PieSeriesOption | GridComponentOption - | TooltipComponentOption -> - + | TooltipComponentOption> const tooltipOption = (): EcOption['tooltip'] => ( { show: true, diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx index 0852af7cc..8d0ea79d0 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx @@ -1,4 +1,4 @@ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { defineComponent } from "vue" import { useTopKValue } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts index 1ded87bfb..c3ce50903 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts @@ -1,14 +1,9 @@ import { getSeriesPalette, tooltipDot } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { generateSiteLabel } from "@util/site" -import { - type ComposeOption, - type GridComponentOption, - type PieSeriesOption, - type TooltipComponentOption -} from "echarts" -import { BizOption } from "../context" +import type { ComposeOption, GridComponentOption, PieSeriesOption, TooltipComponentOption } from "echarts" +import type { BizOption } from "../context" type EcOption = ComposeOption< | PieSeriesOption diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx index 0852af7cc..8d0ea79d0 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx @@ -1,4 +1,4 @@ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { defineComponent } from "vue" import { useTopKValue } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx index 5125fe64c..48f95fef7 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx @@ -1,72 +1,36 @@ import { tN } from "@app/locale" -import { css } from '@emotion/css' -import { useXsState } from '@hooks/useMediaSize' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" -import { ElIcon, ElRadioButton, ElRadioGroup, useNamespace } from "element-plus" -import { defineComponent } from "vue" -import type { JSX } from "vue/jsx-runtime" +import IconRadioGroup from '@pages/components/IconRadioGroup' +import { BarChart, HalfPieChart, RoseChart } from '@pages/icons' +import { type Component, defineComponent } from "vue" import { type TopKChartType, useTopKFilter } from "../context" import TitleSelect from "./TitleSelect" -const CHART_CONFIG: { [type in TopKChartType]: JSX.Element | string } = { - pie: ( - - - - - - - ), - bar: ( - - - - - - - - ), - halfPie: ( - - - - ), -} - -const useRadioStyle = () => { - const radioNs = useNamespace('radio') - return css` - & .${radioNs.be('button', 'inner')} { - padding: 3px 5px; - } - ` +const CHART_CONFIG: { [type in TopKChartType]: Component } = { + pie: RoseChart, + bar: BarChart, + halfPie: HalfPieChart, } const Title = defineComponent(() => { const filter = useTopKFilter() const isXs = useXsState() - const radioCls = useRadioStyle() return () => ( - + {tN(msg => msg.dashboard.topK.title, { k: , day: , })} - filter.topKChartType = val as TopKChartType} - > - {Object.entries(CHART_CONFIG).map(([k, v]) => ( - - {v} - - ))} - + options={Object.entries(CHART_CONFIG).map(([value, icon]) => ({ value, icon }))} + /> ) }) diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/common.ts b/src/pages/app/components/Dashboard/components/TopKVisit/common.ts deleted file mode 100644 index 8f7d1e3c8..000000000 --- a/src/pages/app/components/Dashboard/components/TopKVisit/common.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type GridComponentOption } from "echarts/components" - -export const generateGridOption = (): GridComponentOption => { - return { - top: 30, - bottom: 40, - left: 40, - right: 20, - } -} diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts index 90f28d607..7291f59ca 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts @@ -1,7 +1,7 @@ +import { getSiteStatPage } from "@api/sw/stat" import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import { selectSitePage, type SiteQuery } from "@service/stat-service" -import { MILL_PER_DAY } from "@util/time" -import { reactive, toRaw, watch, type Reactive, type Ref } from "vue" +import { cvtDateRange2Str, MILL_PER_DAY } from "@util/time" +import { reactive, type ShallowRef, toRaw, watch } from "vue" export type BizOption = { name: string @@ -20,8 +20,8 @@ export type TopKFilterOption = { } type Context = { - value: Ref - filter: Reactive + value: ShallowRef + filter: TopKFilterOption } const NAMESPACE = 'dashboardTopKVisit' @@ -35,18 +35,17 @@ export const initProvider = () => { const { data: value } = useRequest(async () => { const now = new Date() const startTime: Date = new Date(now.getTime() - MILL_PER_DAY * filter.dayNum) - const query: SiteQuery = { - date: [startTime, now], + const query: tt4b.stat.SiteQuery = { + date: cvtDateRange2Str([startTime, now]), sortKey: "time", sortDirection: 'DESC', mergeDate: true, } const SIZE = filter.topK - const top = (await selectSitePage(query, { num: 1, size: SIZE })).list - const data: BizOption[] = top.map(({ time, siteKey, alias }) => ({ - name: alias ?? siteKey?.host ?? '', - host: siteKey?.host ?? '', - alias, + const { list: top } = await getSiteStatPage({ num: 1, size: SIZE, ...query }) + const data: BizOption[] = top.map(({ time, siteKey: { host }, alias }) => ({ + name: alias ?? host, + host, alias, value: time, })) for (let realSize = top.length; realSize < SIZE; realSize++) { diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx index 1bc35e1bf..cec5ae07a 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx @@ -4,10 +4,10 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { useXsState } from '@hooks/useMediaSize' +import ChartTitle from "@app/components/Dashboard/ChartTitle" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { computed, defineComponent } from "vue" -import ChartTitle from "../../ChartTitle" import BarChart from "./BarChart" import { initProvider } from "./context" import HalfBarChart from "./HalfBarChart" @@ -34,7 +34,7 @@ const _default = defineComponent(() => { - </ChartTitle > + </ChartTitle> <Flex flex={1}> {chart.value} </Flex> diff --git a/src/pages/app/components/Dashboard/index.tsx b/src/pages/app/components/Dashboard/index.tsx index 55673ab9f..456f88648 100644 --- a/src/pages/app/components/Dashboard/index.tsx +++ b/src/pages/app/components/Dashboard/index.tsx @@ -6,15 +6,15 @@ */ import { t } from "@app/locale" -import { MediaSize, useManualRequest, useMediaSize, useRequest, useXsState } from "@hooks" +import { MediaSize, useMediaSize, useRequest, useXsState } from "@hooks" import { isTranslatingLocale, locale } from "@i18n" import Flex from "@pages/components/Flex" -import { recommendRate, saveFlag } from "@service/meta-service" +import { rateClicked, recommendRate } from '@pages/util/rate' import { REVIEW_PAGE } from "@util/constant/url" import { ElRow, ElScrollbar } from "element-plus" -import { computed, defineComponent, FunctionalComponent } from "vue" +import { computed, defineComponent, type FunctionalComponent } from "vue" import { useRouter } from "vue-router" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import Calendar from "./components/Calendar" import Indicator from "./components/Indicator" import MonthOnMonth from "./components/MonthOnMonth" @@ -43,7 +43,10 @@ const _default = defineComponent(() => { const isNotEnOrZhCn = locale !== "en" && locale !== "zh_CN" const showHelp = isTranslatingLocale() || isNotEnOrZhCn const { data: showRate, refresh } = useRequest(recommendRate) - const { refresh: handleRate } = useManualRequest(() => saveFlag("rateOpen"), { onSuccess: refresh }) + const onRateClicked = () => { + rateClicked() + refresh() + } const mediaSize = useMediaSize() const isXs = useXsState() @@ -95,7 +98,7 @@ const _default = defineComponent(() => { <a href={REVIEW_PAGE} target="_blank" - onClick={handleRate} + onClick={onRateClicked} style={{ color: 'inherit' }} > {t(msg => msg.about.text.rate)} diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx index 3d419a42a..65faecd51 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx @@ -6,62 +6,50 @@ */ import { t, tN } from "@app/locale" import { dateFormat as elDateFormat } from "@i18n/element" -import { getDatePickerIconSlots } from "@pages/element-ui/rtl" +import { getDatePickerIconSlots } from '@pages/element-ui/rtl' +import type { ElDatePickerShortcut } from '@pages/element-ui/types' import { formatTime, getBirthday, MILL_PER_DAY } from "@util/time" import { ElDatePicker } from "element-plus" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" -import { defineComponent, type PropType, type StyleValue } from "vue" +import { defineComponent, type StyleValue } from "vue" -const _default = defineComponent({ - emits: { - change: (_date: [Date, Date]) => true - }, - props: { - dateRange: Object as PropType<[Date, Date]> - }, - setup(props, ctx) { - const yesterday = new Date().getTime() - MILL_PER_DAY - const daysBefore = (days: number) => new Date(new Date().getTime() - days * MILL_PER_DAY) +type Props = ModelValue<[Date, Date] | undefined> +const _default = defineComponent<Props>(props => { + const birthday = getBirthday() + const yesterday = Date.now() - MILL_PER_DAY + const daysBefore = (days: number) => new Date(Date.now() - days * MILL_PER_DAY) + const dateFormat = t(msg => msg.calendar.dateFormat) + const shortcuts: ElDatePickerShortcut[] = [{ + text: t(msg => msg.calendar.range.tillYesterday), + value: [birthday, daysBefore(1)], + }, { + text: t(msg => msg.calendar.range.tillDaysAgo, { n: 7 }), + value: [birthday, daysBefore(7)], + }, { + text: t(msg => msg.calendar.range.tillDaysAgo, { n: 30 }), + value: [birthday, daysBefore(30)], + }] - const birthday = getBirthday() - const pickerShortcuts: Shortcut[] = [ - { - text: t(msg => msg.calendar.range.tillYesterday), - value: [birthday, daysBefore(1)], - }, { - text: t(msg => msg.calendar.range.tillDaysAgo, { n: 7 }), - value: [birthday, daysBefore(7)], - }, { - text: t(msg => msg.calendar.range.tillDaysAgo, { n: 30 }), - value: [birthday, daysBefore(30)], - } - ] - const dateFormat = '{y}-{m}-{d}' - const startPlaceholder = formatTime(birthday, dateFormat) - const endPlaceholder = formatTime(yesterday, dateFormat) - - return () => ( - <p> - <a style={{ marginInlineEnd: '10px' }}>1.</a> - {tN(msg => msg.dataManage.filterDate, { - picker: <ElDatePicker - modelValue={props.dateRange} - onUpdate:modelValue={date => ctx.emit("change", date)} - size="small" - style={{ width: "250px" } satisfies StyleValue} - startPlaceholder={startPlaceholder} - endPlaceholder={endPlaceholder} - dateFormat={elDateFormat()} - type="daterange" - disabledDate={(date: Date) => date.getTime() > yesterday} - shortcuts={pickerShortcuts} - rangeSeparator="-" - v-slots={getDatePickerIconSlots()} - /> - })} - </p> - ) - } -}) + return () => ( + <p> + <a style={{ marginInlineEnd: '10px' }}>1.</a> + {tN(msg => msg.dataManage.filterDate, { + picker: <ElDatePicker + modelValue={props.modelValue} + onUpdate:modelValue={props.onChange} + size="small" + style={{ width: "250px" } satisfies StyleValue} + startPlaceholder={formatTime(birthday, dateFormat)} + endPlaceholder={formatTime(yesterday, dateFormat)} + dateFormat={elDateFormat()} + type="daterange" + disabledDate={(date: Date) => date.getTime() > yesterday} + shortcuts={shortcuts} + rangeSeparator="-" + v-slots={getDatePickerIconSlots()} + /> + })} + </p> + ) +}, { props: ['modelValue', 'onChange'] }) export default _default diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx index 185e61a6a..573123f5f 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx @@ -8,50 +8,35 @@ import { tN } from "@app/locale" import { type DataManageMessage } from "@i18n/message/app/data-manage" import { ElInput } from "element-plus" -import { defineComponent, type PropType, type Ref, ref, type StyleValue, watch } from "vue" +import { defineComponent, type StyleValue } from "vue" -const elInput = (ref: Ref<string | undefined>, placeholder: string) => ( +const elInput = (value: string | undefined, onChange: ArgCallback<string | undefined>, placeholder: string) => ( <ElInput placeholder={placeholder} clearable size="small" - modelValue={ref.value} - onInput={val => ref.value = val?.trim()} - onClear={() => ref.value = undefined} + modelValue={value} + onInput={onChange} + onClear={() => onChange(undefined)} style={{ width: '60px' } satisfies StyleValue} /> ) -const _default = defineComponent({ - props: { - translateKey: { - type: String as PropType<keyof DataManageMessage>, - required: true, - }, - defaultValue: { - type: Object as PropType<[string?, string?]>, - required: true, - }, - lineNo: Number, - }, - emits: { - change: (_val: [string?, string?]) => true, - }, - setup(props, ctx) { - const start = ref(props.defaultValue[0]) - const end = ref(props.defaultValue[1]) - watch([start, end], () => ctx.emit("change", [start.value, end.value])) +type Props = ModelValue<[string?, string?]> & { + i18nKey: keyof DataManageMessage + lineNo: number +} - return () => ( - <p> - <a style={{ marginInlineEnd: '10px' }}>{props.lineNo}.</a> - {tN(msg => msg.dataManage[props.translateKey], { - start: elInput(start, '0'), - end: elInput(end, '∞'), - })} - </p> - ) - } -}) +const _default = defineComponent<Props>(props => { + return () => ( + <p> + <a style={{ marginInlineEnd: '10px' }}>{props.lineNo}.</a> + {tN(msg => msg.dataManage[props.i18nKey], { + start: elInput(props.modelValue[0], val => props.onChange?.([val, props.modelValue[1]]), '0'), + end: elInput(props.modelValue[1], val => props.onChange?.([props.modelValue[0], val]), '∞'), + })} + </p> + ) +}, { props: ['modelValue', 'onChange', 'lineNo', 'i18nKey'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx index 8838c0fe3..930fe3927 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx @@ -7,49 +7,35 @@ import { t } from "@app/locale" import { Delete } from "@element-plus/icons-vue" import { useState } from "@hooks" +import Box from '@pages/components/Box' import { ElButton } from "element-plus" import { defineComponent } from "vue" import DateFilter from "./DateFilter" import NumberFilter from "./NumberFilter" -const _default = defineComponent({ - emits: { - delete: (_date: [Date, Date] | undefined, _focus: [string?, string?], _time: [string?, string?]) => true - }, - setup(_, ctx) { - const [date, setDate] = useState<[Date, Date]>() - const [focus, setFocus] = useState<[string?, string?]>(['0', '2']) - const [time, setTime] = useState<[string?, string?]>(['0',]) +type Props = { + onDelete?: (date: [Date, Date] | undefined, focus: [string?, string?], time: [string?, string?]) => void +} - return () => ( - <div style={{ paddingInlineStart: '30px', paddingTop: '40px' }}> - <h3>{t(msg => msg.dataManage.filterItems)}</h3> - <DateFilter dateRange={date.value} onChange={setDate} /> - <NumberFilter - translateKey="filterFocus" - defaultValue={focus.value} - lineNo={2} - onChange={setFocus} - /> - <NumberFilter - translateKey="filterTime" - defaultValue={time.value} - lineNo={3} - onChange={setTime} - /> - <div style={{ paddingTop: '40px' }}> - <ElButton - icon={Delete} - type="danger" - size="small" - onClick={() => ctx.emit("delete", date.value, focus.value, time.value)} - > - {t(msg => msg.button.delete)} - </ElButton> - </div> - </div> - ) - } -}) +const _default = defineComponent<Props>(props => { + const [date, setDate] = useState<[Date, Date]>() + const [focus, setFocus] = useState<[string?, string?]>(['0', '2']) + const [time, setTime] = useState<[string?, string?]>(['0',]) + const handleDelete = () => props.onDelete?.(date.value, focus.value, time.value) + + return () => ( + <div style={{ paddingInlineStart: '30px', paddingTop: '40px' }}> + <h3>{t(msg => msg.dataManage.filterItems)}</h3> + <DateFilter modelValue={date.value} onChange={setDate} /> + <NumberFilter i18nKey="filterFocus" lineNo={2} modelValue={focus.value} onChange={setFocus} /> + <NumberFilter i18nKey="filterTime" lineNo={3} modelValue={time.value} onChange={setTime} /> + <Box marginTop={40}> + <ElButton icon={Delete} type="danger" onClick={handleDelete}> + {t(msg => msg.button.delete)} + </ElButton> + </Box> + </div> + ) +}, { props: ['onDelete'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/DataManage/ClearPanel/index.tsx b/src/pages/app/components/DataManage/ClearPanel/index.tsx index 7e4cf8861..d025f4b35 100644 --- a/src/pages/app/components/DataManage/ClearPanel/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ +import { batchDeleteStats, listGroupStats, listSiteStats } from "@api/sw/stat" import { t } from "@app/locale" -import db, { type StatCondition } from "@db/stat-database" -import { MILL_PER_DAY, MILL_PER_SECOND } from "@util/time" +import { cvtDateRange2Str, getBirthday, MILL_PER_DAY, MILL_PER_SECOND } from "@util/time" import { ElCard, ElMessage, ElMessageBox } from "element-plus" import { defineComponent, type StyleValue } from "vue" import { useDataMemory } from "../context" @@ -20,21 +20,20 @@ type FilterOption = { time: [string?, string?] } -async function generateParamAndSelect(option: FilterOption): Promise<timer.core.Row[]> { - const param = checkParam(option) - if (!param) { - ElMessage.warning(t(msg => msg.dataManage.paramError)) - return [] - } +type ClearFilterRanges = { + focusRange: [number, number] + timeRange: [number, number?] +} +function buildClearStatQuery(option: FilterOption): (tt4b.stat.SiteQuery & tt4b.stat.GroupQuery) | undefined { + const param = checkParam(option) + if (!param) return undefined const { date } = option - let [dateStart, dateEnd] = date || [] - if (dateEnd == null) { - // default end time is the yesterday - dateEnd = new Date(new Date().getTime() - MILL_PER_DAY) - } - param.date = dateStart ? [dateStart, dateEnd] : undefined - return await db.select(param) + let [ + start = getBirthday(), + end = new Date(Date.now() - MILL_PER_DAY), + ] = date ?? [] + return { ...param, date: cvtDateRange2Str([start, end]) } } /** @@ -57,7 +56,7 @@ function assertQueryParam(range: [number, number?], mustInteger?: boolean): bool const str2Num = (str: string | undefined) => str ? parseInt(str) : undefined const seconds2Milliseconds = (a: number) => a * MILL_PER_SECOND -function checkParam(option: FilterOption): StatCondition | undefined { +function checkParam(option: FilterOption): ClearFilterRanges | undefined { const { focus, time } = option let hasError = false const focusRange = str2Range(focus, seconds2Milliseconds) as [number, number] @@ -67,10 +66,7 @@ function checkParam(option: FilterOption): StatCondition | undefined { if (hasError) { return undefined } - const condition: StatCondition = {} - condition.focusRange = focusRange - condition.timeRange = timeRange - return condition + return { focusRange, timeRange } } function str2Range(startAndEnd: [string?, string?], numAmplifier?: (origin: number) => number): [number, number | undefined] { @@ -86,15 +82,23 @@ function str2Range(startAndEnd: [string?, string?], numAmplifier?: (origin: numb const _default = defineComponent(() => { const { refreshMemory } = useDataMemory() async function handleClick(option: FilterOption) { - const result = await generateParamAndSelect(option) + const q = buildClearStatQuery(option) + if (!q) { + ElMessage.warning(t(msg => msg.dataManage.paramError)) + return + } + const siteRows = await listSiteStats(q) + const groupRows = await listGroupStats(q) + + const count = siteRows.length + groupRows.length + if (!count) return - const count = result.length const confirmMsg = t(msg => msg.dataManage.deleteConfirm, { count }) ElMessageBox.confirm(confirmMsg, { cancelButtonText: t(msg => msg.button.cancel), - confirmButtonText: t(msg => msg.button.confirm) + confirmButtonText: t(msg => msg.button.confirm), }).then(async () => { - await db.delete(result) + await batchDeleteStats([...siteRows, ...groupRows]) ElMessage.success(t(msg => msg.operation.successMsg)) refreshMemory?.() }).catch(() => { }) diff --git a/src/pages/app/components/DataManage/DataManageAlert.tsx b/src/pages/app/components/DataManage/DataManageAlert.tsx index 51498ee48..2dab72417 100644 --- a/src/pages/app/components/DataManage/DataManageAlert.tsx +++ b/src/pages/app/components/DataManage/DataManageAlert.tsx @@ -8,15 +8,24 @@ type Props = { } const DataManageAlert = defineComponent<Props>(props => { - const text = computed(() => { - const text = props.text - return typeof text === 'string' ? text : t(text) - }) + const message = computed(() => + typeof props.text === 'string' ? props.text : t(props.text)) return () => ( - <ElAlert type={props.type ?? 'info'} closable={false} center> - {text.value} - </ElAlert> + <div + style={{ + flexShrink: 0, + width: '100%', + minHeight: 52, + display: 'flex', + alignItems: 'center', + boxSizing: 'border-box', + }} + > + <ElAlert type={props.type ?? 'info'} closable={false} center style={{ width: '100%' }}> + {message.value} + </ElAlert> + </div> ) }, { props: ['text', 'type'] }) diff --git a/src/pages/app/components/DataManage/MemoryInfo.tsx b/src/pages/app/components/DataManage/MemoryInfo.tsx index 4022e1dc6..8252c03fc 100644 --- a/src/pages/app/components/DataManage/MemoryInfo.tsx +++ b/src/pages/app/components/DataManage/MemoryInfo.tsx @@ -5,63 +5,80 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { getOption } from '@api/sw/option' +import { t } from '@app/locale' +import { OPTION_ROUTE } from '@app/router/constants' +import { useRequest } from '@hooks' import Flex from "@pages/components/Flex" -import { ElCard, ElProgress } from "element-plus" +import { getColor } from '@pages/util/style' +import { getAppPageUrl } from '@util/constant/url' +import { ElCard, ElLink, ElProgress, ElText } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" import { useDataMemory } from "./context" import DataManageAlert from './DataManageAlert' const byte2Mb = (size: number) => Math.round((size || 0) / 1024.0 / 1024.0 * 1000) / 1000 -function computeColor(percentage: number, total: number): string { - // Danger color - let typeColor = '#F56C6C' - // Primary color - if (percentage < 50) typeColor = '#409EFF' - // Warning color - else if (percentage < 75) typeColor = '#E6A23C' - // Specially, show warning color if not detect the max memory - if (!total) typeColor = '#E6A23C' - return typeColor +const IDB_THRESHOLD_MB = 5 +const IDB_THRESHOLD_PERCENTAGE = 75 + +function computeColor(percentage: number, total: number): string | undefined { + if (!total) { + return getColor('warning') + } else if (percentage < 50) { + return getColor('primary') + } else if (percentage < IDB_THRESHOLD_PERCENTAGE) { + return getColor('warning') + } else { + return getColor('danger') + } } const totalTitle = (totalMb: number) => totalMb ? t(msg => msg.dataManage.totalMemoryAlert, { size: totalMb }) : t(msg => msg.dataManage.totalMemoryAlert1) + const _default = defineComponent(() => { const { memory } = useDataMemory() - + const { data: option } = useRequest(getOption) const usedMb = computed(() => byte2Mb(memory.value?.used)) const totalMb = computed(() => byte2Mb(memory.value?.total)) const percentage = computed(() => memory.value?.total ? Math.round(memory.value?.used * 10000.0 / memory.value.total) / 100 : 0) const color = computed(() => computeColor(percentage.value, memory.value.total)) + const idbTipVisible = computed(() => { + if (option.value?.storage !== 'classic') return false + return totalMb.value ? percentage.value > IDB_THRESHOLD_PERCENTAGE : usedMb.value > IDB_THRESHOLD_MB + }) return () => ( - <ElCard - style={{ width: '100%' } satisfies StyleValue} - bodyStyle={{ height: '100%', boxSizing: 'border-box' }} - > - <Flex column height='100%' align="center"> + <ElCard style={{ width: '100%' } satisfies StyleValue}> + <Flex column width='100%' align="center" gap={16}> <DataManageAlert type={totalMb.value ? "info" : "warning"} text={totalTitle(totalMb.value)} /> - <Flex flex={1} height={0}> + <Flex justify='center' align='center'> <ElProgress strokeWidth={10} percentage={percentage.value} type="circle" color={color.value} - style={{ display: 'flex', marginTop: '30px' } satisfies StyleValue} /> </Flex> - <div style={{ userSelect: 'none' }}> - <h3 style={{ color: color.value }}> + <Flex justify='center' column gap={10}> + <ElText style={{ color: color.value }} size='large'> {t(msg => msg.dataManage.usedMemoryAlert, { size: usedMb.value })} - </h3> - </div> + </ElText> + {idbTipVisible.value && ( + <ElLink + type='primary' underline + href={getAppPageUrl(OPTION_ROUTE, { i: 'tracking' })} + > + {t(msg => msg.dataManage.idbAlert)} + </ElLink> + )} + </Flex> </Flex> </ElCard> ) diff --git a/src/pages/app/components/DataManage/Migration/ImportButton.tsx b/src/pages/app/components/DataManage/Migration/ImportButton.tsx deleted file mode 100644 index 1e0f09a23..000000000 --- a/src/pages/app/components/DataManage/Migration/ImportButton.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { t } from "@app/locale" -import { Upload } from "@element-plus/icons-vue" -import Immigration from "@service/components/immigration" -import { deserialize } from "@util/file" -import { ElButton, ElLoading, ElMessage } from "element-plus" -import { defineComponent, ref } from "vue" -import { useDataMemory } from "../context" - -const immigration: Immigration = new Immigration() - -async function handleFileSelected(fileInput: HTMLInputElement | undefined, callback: () => void) { - const files = fileInput?.files - if (!files?.length) { - return - } - const loading = ElLoading.service({ fullscreen: true }) - const file: File = files[0] - const fileText = await file.text() - const data = deserialize(fileText) - if (!data) { - ElMessage.error(t(msg => msg.dataManage.importError)) - } - await immigration.importData(data) - loading.close() - callback?.() - ElMessage.success(t(msg => msg.operation.successMsg)) -} - -const _default = defineComponent(() => { - const { refreshMemory } = useDataMemory() - const fileInput = ref<HTMLInputElement>() - - return () => ( - <ElButton - size="large" - type="primary" - icon={Upload} - onClick={() => fileInput.value?.click()} - style={{ margin: 0, flex: 1 }} - > - {t(msg => msg.item.operation.importWholeData)} - <input - ref={fileInput} - type="file" - accept=".json" - style={{ display: "none" }} - onChange={() => handleFileSelected(fileInput.value, refreshMemory)} - /> - </ElButton> - ) -}) - -export default _default diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx deleted file mode 100644 index ceb8c4ca0..000000000 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import DialogSop, { type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useManualRequest } from "@hooks" -import Flex from "@pages/components/Flex" -import { processImportedData } from "@service/components/import-processor" -import { ElMessage, ElStep, ElSteps } from "element-plus" -import { defineComponent, ref } from "vue" -import Step1 from "./Step1" -import Step2 from "./Step2" - -type Props = { - onCancel: NoArgCallback - onImport: NoArgCallback -} - -const _default = defineComponent<Props>((props) => { - const step = ref<0 | 1>(0) - const step1 = ref<SopStepInstance<timer.imported.Data>>() - const step2 = ref<SopStepInstance<timer.imported.ConflictResolution>>() - - const { data, refresh: handleNext, loading: parsing } = useManualRequest(() => step1.value!.parseData(), { - defaultValue: { rows: [] }, - onSuccess: () => step.value = 1, - onError: e => ElMessage.error((e as Error)?.message ?? 'Unknown Error') - }) - - const { loading: importing, refresh: doImport } = useManualRequest(async () => { - const resolution = await step2.value?.parseData?.() - if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) - await processImportedData(data.value, resolution) - }, { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onImport?.() - }, - onError: e => ElMessage.warning((e as Error)?.message ?? 'Unknown error'), - }) - - return () => ( - <DialogSop - first={step.value === 0} - last={step.value === 1} - onCancel={props.onCancel} - onBack={() => step.value = 0} - onNext={handleNext} - onFinish={doImport} - nextLoading={parsing.value} - finishLoading={importing.value} - v-slots={{ - steps: () => ( - <ElSteps space={200} finishStatus="success" active={step.value} alignCenter> - <ElStep title={t(msg => msg.dataManage.importOther.step1)} /> - <ElStep title={t(msg => msg.dataManage.importOther.step2)} /> - </ElSteps> - ), - content: () => ( - <Flex width="100%" justify="center"> - {step.value === 0 ? <Step1 ref={step1} /> : <Step2 ref={step2} data={data.value} />} - </Flex> - ), - }} - /> - ) -}, { props: ['onCancel', 'onImport'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx index bdbe556a2..021f48826 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx @@ -5,67 +5,40 @@ * https://opensource.org/licenses/MIT */ -import { type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" +import { useDialogSop } from '@app/components/common/DialogSop/context' +import { t } from '@app/locale' import { Document } from "@element-plus/icons-vue" -import { useState } from "@hooks" import Flex from "@pages/components/Flex" -import { ElButton, ElForm, ElFormItem, ElSelect } from "element-plus" +import { ElButton, ElForm, ElFormItem } from "element-plus" import { defineComponent, ref } from "vue" -import { type OtherExtension, parseFile } from "./processor" +import type { ImportForm, OtherExtension } from './types' -const OTHER_NAMES: { [ext in OtherExtension]: string } = { - webtime_tracker: "Webtime Tracker", - web_activity_time_tracker: "Web Activity Time Tracker", - history_trends_unlimited: "History Trends Unlimited", -} - -const OTHER_FILE_FORMAT: { [ext in OtherExtension]: string } = { +const OTHER_FILE_FORMAT: Record<OtherExtension, string> = { webtime_tracker: '.csv,.json', web_activity_time_tracker: '.csv,.json', history_trends_unlimited: '.tsv', } -const ALL_TYPES: OtherExtension[] = Object.keys(OTHER_NAMES) as OtherExtension[] - -const _default = defineComponent<{}>((_, ctx) => { - const [type, setType] = useState<OtherExtension>('webtime_tracker') - const [selectedFile, setSelectedFile] = useState<File>() +const _default = defineComponent<{}>(() => { + const { form } = useDialogSop<ImportForm>() const fileInput = ref<HTMLInputElement>() - const parseData = async () => { - const file = selectedFile.value - if (!file) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) - - const data = await parseFile(type.value, file) - if (!data?.rows?.length) throw new Error("No rows parsed") - - return data - } - - ctx.expose({ parseData } satisfies SopStepInstance<timer.imported.Data>) - return () => ( <ElForm labelWidth={100} labelPosition="left" style={{ width: '500px' }}> - <ElFormItem label={t(msg => msg.dataManage.importOther.dataSource)} required> - <ElSelect - modelValue={type.value} onChange={setType} - options={ALL_TYPES.map(value => ({ value, label: OTHER_NAMES[value] }))} - /> - </ElFormItem> <ElFormItem label={t(msg => msg.dataManage.importOther.file)} required> <Flex gap={10}> <ElButton icon={Document} onClick={() => fileInput.value?.click?.()}> {t(msg => msg.dataManage.importOther.selectFileBtn)} <input + key={form.ext} ref={fileInput} type="file" - accept={OTHER_FILE_FORMAT[type.value]} + accept={OTHER_FILE_FORMAT[form.ext]} style={{ display: 'none' }} - onChange={() => setSelectedFile(fileInput.value?.files?.[0])} + onChange={() => form.file = fileInput.value?.files?.[0]} /> </ElButton> - {selectedFile.value?.name && <span>{selectedFile.value?.name}</span>} + {<span>{form.file?.name ?? ''}</span>} </Flex> </ElFormItem> </ElForm> diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx index 363c6133b..59d40c9e9 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx @@ -5,28 +5,24 @@ * https://opensource.org/licenses/MIT */ -import { type SopStepInstance } from "@app/components/common/DialogSop" +import { useDialogSop } from '@app/components/common/DialogSop/context' import CompareTable from "@app/components/common/imported/CompareTable" import ResolutionRadio from "@app/components/common/imported/ResolutionRadio" -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { defineComponent } from "vue" +import type { ImportForm } from './types' -const _default = defineComponent<{ data: timer.imported.Data }>((props, ctx) => { - const [resolution, setResolution] = useState<timer.imported.ConflictResolution>() - - ctx.expose({ - parseData: () => resolution.value - } satisfies SopStepInstance<timer.imported.ConflictResolution | undefined>) +const _default = defineComponent<{}>(() => { + const { form } = useDialogSop<ImportForm>() return () => ( <Flex column width="100%" gap={20}> - <CompareTable data={props.data} comparedCol={msg => msg.dataManage.importOther.imported} /> + <CompareTable data={form.data} comparedCol={msg => msg.dataManage.importOther.imported} /> <Flex width="100%" justify="center"> - <ResolutionRadio modelValue={resolution.value} onChange={setResolution} /> + <ResolutionRadio modelValue={form.resolution} onChange={v => form.resolution = v} /> </Flex> </Flex> ) -}, { props: ['data'] }) +}) export default _default diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/detector.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/detector.ts new file mode 100644 index 000000000..7e49e119c --- /dev/null +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/detector.ts @@ -0,0 +1,23 @@ +import { IS_CHROME, IS_EDGE } from '@util/constant/environment' + +const WATT_CHROME_ID = 'hhfnghjdeddcfegfekjeihfmbjenlomm' +const WATT_EDGE_ID = 'eepmlmdenlkkjieghjmedjahpofieogf' +const WATT_RES_URL = '/assets/pomodoro-sounds/1.mp3' +export async function detectWatt(): Promise<boolean> { + if (IS_CHROME) { + return await fetchChrome(WATT_CHROME_ID, WATT_RES_URL) + } else if (IS_EDGE) { + return await fetchChrome(WATT_CHROME_ID, WATT_RES_URL) || await fetchChrome(WATT_EDGE_ID, WATT_RES_URL) + } + return false +} + +async function fetchChrome(id: string, uri: string) { + const url = `chrome-extension://${id}${uri}` + try { + const resp = await fetch(url, { method: 'HEAD' }) + return resp.status === 200 + } catch { + return false + } +} \ No newline at end of file diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx index d34532b59..005b5a23f 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx @@ -5,42 +5,137 @@ * https://opensource.org/licenses/MIT */ +import { importOther } from '@api/sw/immigration' +import DialogSop from '@app/components/common/DialogSop' +import { initDialogSopContext } from '@app/components/common/DialogSop/context' +import { useDataMemory } from '@app/components/DataManage/context' import { t } from "@app/locale" -import { Upload } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { ElButton, ElDialog } from "element-plus" -import { defineComponent } from "vue" -import { useDataMemory } from "../../context" -import Sop from "./Sop" +import { css } from '@emotion/css' +import { useRequest, useState } from '@hooks' +import Flex from '@pages/components/Flex' +import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElText } from "element-plus" +import { computed, defineComponent, type FunctionalComponent, h, toRaw } from "vue" +import { detectWatt } from './detector' +import { parseFile } from './processor' +import Step1 from './Step1' +import Step2 from './Step2' +import type { ImportForm, OtherExtension } from './types' + +type Config = { + name: string + Icon: FunctionalComponent<{}> +} + +const EXTENSION_CONFIGS: Record<OtherExtension, Config> = { + webtime_tracker: { + name: "Webtime Tracker", + Icon: () => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"> + <g><path style="opacity:1" fill="#3896fa" d="M 52.5,-0.5 C 54.1667,-0.5 55.8333,-0.5 57.5,-0.5C 58.8309,8.13683 58.8309,16.8035 57.5,25.5C 53.6019,26.975 49.6019,28.1417 45.5,29C 41.6563,31.5153 37.823,34.0153 34,36.5C 27.7055,32.376 21.5388,28.0426 15.5,23.5C 14.3691,21.9255 14.2025,20.2588 15,18.5C 25.3653,7.73449 37.8653,1.40116 52.5,-0.5 Z" /></g> + <g><path style="opacity:1" fill="#f9645c" d="M 61.5,-0.5 C 63.1667,-0.5 64.8333,-0.5 66.5,-0.5C 96.5,4.83333 114.167,22.5 119.5,52.5C 119.5,57.1667 119.5,61.8333 119.5,66.5C 114.167,96.5 96.5,114.167 66.5,119.5C 62.5,119.5 58.5,119.5 54.5,119.5C 41.2903,118.898 29.957,113.898 20.5,104.5C 26.0177,98.8165 31.351,92.9832 36.5,87C 43.7447,89.8929 51.4114,91.7263 59.5,92.5C 73.6465,92.5144 83.8132,86.181 90,73.5C 97.1507,47.4534 87.6507,31.4534 61.5,25.5C 60.1691,16.8035 60.1691,8.13683 61.5,-0.5 Z" /></g> + <g><path style="opacity:1" fill="#51c15c" d="M -0.5,66.5 C -0.5,61.8333 -0.5,57.1667 -0.5,52.5C 0.666576,42.9739 3.99991,34.1406 9.5,26C 10.9078,25.6848 12.2411,26.0181 13.5,27C 18.5,30.6667 23.5,34.3333 28.5,38C 29.8045,38.804 30.4712,39.9707 30.5,41.5C 24.1818,56.0341 25.5151,69.7007 34.5,82.5C 29.4427,88.737 24.1093,94.9037 18.5,101C 17.5,101.667 16.5,101.667 15.5,101C 6.46176,91.0972 1.12842,79.5972 -0.5,66.5 Z" /></g> + </svg> + ), + }, + web_activity_time_tracker: { + name: "Web Activity Time Tracker", + Icon: () => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"> + <g><path style="opacity:1" fill="#023750" d="M 45.5,-0.5 C 54.8333,-0.5 64.1667,-0.5 73.5,-0.5C 74.7785,2.00241 74.7785,4.50241 73.5,7C 70.1832,7.49834 66.8499,7.66501 63.5,7.5C 63.5,9.83333 63.5,12.1667 63.5,14.5C 74.5659,14.5351 84.2325,18.2017 92.5,25.5C 94.3586,23.8076 96.3586,22.3076 98.5,21C 105.333,19.8333 108.167,22.6667 107,29.5C 105.692,31.6414 104.192,33.6414 102.5,35.5C 120.15,66.7322 114.483,92.8989 85.5,114C 80.285,116.571 74.9516,118.404 69.5,119.5C 62.8333,119.5 56.1667,119.5 49.5,119.5C 26.929,113.761 12.7623,99.4278 7,76.5C 4.65865,61.5335 7.82532,47.8669 16.5,35.5C 14.4346,32.9573 13.1012,30.1239 12.5,27C 13.0621,21.4471 16.0621,19.4471 21.5,21C 23.5852,22.0414 25.2519,23.5414 26.5,25.5C 34.7685,18.2011 44.4352,14.5344 55.5,14.5C 55.5,12.1667 55.5,9.83333 55.5,7.5C 52.1501,7.66501 48.8168,7.49834 45.5,7C 44.2215,4.50241 44.2215,2.00241 45.5,-0.5 Z" /></g> + <g><path style="opacity:1" fill="#eff2f0" d="M 52.5,21.5 C 71.0342,20.1879 85.8675,26.8546 97,41.5C 108.799,63.1963 106.299,83.0297 89.5,101C 64.3901,118.657 41.8901,115.824 22,92.5C 11.2242,73.4867 12.2242,55.1534 25,37.5C 32.8691,29.7679 42.0357,24.4345 52.5,21.5 Z" /></g> + <g><path style="opacity:1" fill="#6aba57" d="M 53.5,29.5 C 54.8333,29.5 56.1667,29.5 57.5,29.5C 57.3354,32.5184 57.502,35.5184 58,38.5C 59,39.8333 60,39.8333 61,38.5C 61.3333,35.5 61.6667,32.5 62,29.5C 82.4616,32.4608 93.9616,44.1274 96.5,64.5C 93.5525,64.2229 90.7191,64.5562 88,65.5C 87.3162,66.7839 87.4829,67.9505 88.5,69C 91.1667,69.3333 93.8333,69.6667 96.5,70C 93.7002,90.1331 82.2002,101.633 62,104.5C 61.502,101.518 61.3354,98.5184 61.5,95.5C 60.1667,95.5 58.8333,95.5 57.5,95.5C 57.6646,98.5184 57.498,101.518 57,104.5C 36.7998,101.633 25.2998,90.1331 22.5,70C 25.1667,69.6667 27.8333,69.3333 30.5,69C 31.5171,67.9505 31.6838,66.7839 31,65.5C 28.2809,64.5562 25.4475,64.2229 22.5,64.5C 24.6824,45.6491 35.0158,33.9824 53.5,29.5 Z" /></g> + <g><path style="opacity:1" fill="#033751" d="M 79.5,41.5 C 84.1225,40.4082 85.9558,42.0748 85,46.5C 76.4048,54.4232 68.5715,62.9232 61.5,72C 55.7247,72.2045 53.5581,69.3712 55,63.5C 64.2013,57.4732 72.3679,50.1399 79.5,41.5 Z" /></g> + </svg> + ), + }, + history_trends_unlimited: { + name: "History Trends Unlimited", + Icon: () => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"> + <g><path style="opacity:1" fill="#e6e000" d="M 59.5,5.5 C 59.5,23.8333 59.5,42.1667 59.5,60.5C 42.4539,65.6783 25.4539,71.0117 8.5,76.5C 1.16551,47.0244 10.8322,24.8577 37.5,10C 44.4758,6.7715 51.8091,5.2715 59.5,5.5 Z" /></g> + <g><path style="opacity:1" fill="#446fa1" d="M 59.5,5.5 C 87.1275,6.95499 104.961,20.955 113,47.5C 115.24,57.3463 114.74,67.013 111.5,76.5C 94.1807,71.1767 76.8474,65.8433 59.5,60.5C 59.5,42.1667 59.5,23.8333 59.5,5.5 Z" /></g> + <g><path style="opacity:1" fill="#a7433d" d="M 59.5,60.5 C 76.8474,65.8433 94.1807,71.1767 111.5,76.5C 105.384,93.7824 93.7178,105.616 76.5,112C 52.7659,118.31 32.9326,112.144 17,93.5C 13.0956,88.3579 10.2623,82.6912 8.5,76.5C 25.4539,71.0117 42.4539,65.6783 59.5,60.5 Z" /></g> + </svg> + ), + }, +} + +const STEP_TITLES = [ + t(msg => msg.dataManage.importOther.step1), + t(msg => msg.dataManage.importOther.step2), +] + +const dropdownCls = css` + width: 100%; + & .el-button-group { + width: 100%; + display: flex; + } + & .el-button-group > .el-button:first-child { + flex: 1; + min-width: 0; + } +` const _default = defineComponent(() => { const { refreshMemory } = useDataMemory() - const [visible, open, close] = useSwitch() - const handleImported = () => { - close() - refreshMemory?.() - } + const [ext, setExt] = useState<OtherExtension>('webtime_tracker') + useRequest(detectWatt, { onSuccess: v => v && setExt('web_activity_time_tracker') }) + const buttonText = computed(() => t(msg => msg.dataManage.restoreFromOther, { ext: EXTENSION_CONFIGS[ext.value].name })) + + const { step, open } = initDialogSopContext<ImportForm>({ + stepCount: 2, + init: () => ({ ext: 'webtime_tracker', data: { rows: [], focus: true, time: true } }), + onNext: async ({ form }) => { + const file = form.file + if (!file) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) + + const data = await parseFile(form.ext, file) + if (!data.rows.length) throw new Error("No rows parsed") + form.data = data + }, + onFinish: async ({ form }) => { + const data = form.data + if (!data) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) + const resolution = form.resolution + if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) + await importOther({ data: toRaw(data), resolution }) + refreshMemory?.() + }, + }) return () => <> - <ElButton + <ElDropdown + splitButton + type='primary' size="large" - type="warning" - icon={Upload} - onClick={open} - style={{ margin: 0, flex: 1, width: '100%', textWrap: 'wrap', lineHeight: '1.4em' }} - > - {t(msg => msg.item.operation.importOtherData)} - </ElButton> - <ElDialog - top="10vh" - modelValue={visible.value} - title={t(msg => msg.item.operation.importOtherData)} - width="80%" - closeOnClickModal={false} - onClose={close} + class={dropdownCls} + style={{ width: '100%' }} + onCommand={(ext: OtherExtension) => setExt(ext)} + onClick={() => open({ ext: ext.value, data: { rows: [], focus: true, time: true } })} + v-slots={{ + dropdown: () => ( + <ElDropdownMenu> + {Object.entries(EXTENSION_CONFIGS).map(([ext, config]) => ( + <ElDropdownItem key={ext} command={ext}> + {config.name} + </ElDropdownItem> + ))} + </ElDropdownMenu> + ), + }} > - <Sop onCancel={close} onImport={handleImported} /> - </ElDialog> + <Flex gap={2} align='center'> + <ElIcon size='1.4em'>{h(EXTENSION_CONFIGS[ext.value].Icon)}</ElIcon> + <ElText truncated style={{ minWidth: 0, color: 'inherit', fontSize: 'inherit', lineHeight: 'inherit' }}>{buttonText.value}</ElText> + </Flex> + </ElDropdown> + <DialogSop title={buttonText.value} stepTitles={STEP_TITLES} width='80%' top="10vh"> + <Flex width="100%" justify="center"> + <Step1 v-show={step.value === 0} /> + <Step2 v-show={step.value === 1} /> + </Flex> + </DialogSop> </> }) diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts index de50e3b63..87846e579 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts @@ -5,29 +5,20 @@ * https://opensource.org/licenses/MIT */ -import { fillExist } from "@service/components/import-processor" -import { AUTHOR_EMAIL } from "@src/package" +import { AUTHOR_EMAIL } from "@/package" +import { sendMsg2Runtime } from '@api/sw/common' import { IS_WINDOWS } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" +import { mergeWith } from '@util/stat' import { formatTimeYMD, MILL_PER_SECOND } from "@util/time" +import type { OtherExtension } from './types' -export type OtherExtension = - | "webtime_tracker" - | "web_activity_time_tracker" - | "history_trends_unlimited" - -const throwError = () => { throw new Error("Failed to parse, please check your file or contact the author via " + AUTHOR_EMAIL) } +function throwError<T>(): T { + throw new Error("Failed to parse, please check your file or contact the author via " + AUTHOR_EMAIL) +} -/** - * Parse the content to rows - * - * @param type extension type - * @param file selected file - * @returns row data - */ -export async function parseFile(ext: OtherExtension, file: File): Promise<timer.imported.Data> { - // const worker = new Worker() - let rows: timer.imported.Row[] = [] +export async function parseFile(ext: OtherExtension, file: File): Promise<tt4b.imported.Data> { + let rows: tt4b.imported.Row[] = [] let focus = false let time = false if (ext === 'web_activity_time_tracker') { @@ -40,21 +31,22 @@ export async function parseFile(ext: OtherExtension, file: File): Promise<timer. rows = await parseHistoryTrendsUnlimited(file) time = true } - await fillExist(rows) + const exists = await sendMsg2Runtime('item.batch', rows) + await mergeWith(rows, exists, (r, exist) => { r.exist = exist }) return { rows, focus, time } } -async function parseWebActivityTimeTracker(file: File): Promise<[timer.imported.Row[], time: boolean]> { +async function parseWebActivityTimeTracker(file: File): Promise<[tt4b.imported.Row[], time: boolean]> { const text = await file.text() if (isCsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line).splice(1) - const rows = lines.map(line => { + const rows: tt4b.imported.Row[] = lines.map(line => { const [host, date, seconds] = line.split(',').map(cell => cell.trim()) - !host || !date || (!seconds && seconds !== '0') && throwError() - const [year, month, day] = date.split('/') - !year || !month || !day && throwError() + if (!host || !date || (!seconds && seconds !== '0')) return throwError() + const [year, month, day] = date?.split('/') ?? [] + if (!year || !month || !day) return throwError() const realDate = `${year}${month.length == 2 ? month : '0' + month}${day.length == 2 ? day : '0' + day}` - return { host, date: realDate, focus: parseInt(seconds) * MILL_PER_SECOND, time: 0 } satisfies timer.imported.Row + return { host, date: realDate, focus: parseInt(seconds) * MILL_PER_SECOND, time: 0 } }) return [rows, false] } else if (isJsonFile(file)) { @@ -81,7 +73,7 @@ type WattJsonItem = { } const parseWattJsonFile = (fileContent: string) => { - const rows: timer.imported.Row[] = [] + const rows: tt4b.imported.Row[] = [] const data = JSON.parse(fileContent) as WattJsonItem[] data.forEach(({ url: host, days }) => { if (!host) throw new Error("Invalid item without url") @@ -121,13 +113,13 @@ type WebtimeTrackerBackup = { const WEBTIME_TRACKER_DATE_REG = /(\d{2})-(\d{2})-\d{2}/ const cvtWebtimeTrackerDate = (date: string): string | undefined => WEBTIME_TRACKER_DATE_REG.test(date) ? date.split('-').join('') : undefined -async function parseWebtimeTracker(file: File): Promise<timer.imported.Row[]> { +async function parseWebtimeTracker(file: File): Promise<tt4b.imported.Row[]> { const text = await file.text() if (isJsonFile(file)) { // JSON file by backup const data = JSON.parse(text) as WebtimeTrackerBackup const domains = data?.content?.domains || {} - const rows: timer.imported.Row[] = Object.entries(domains) + const rows: tt4b.imported.Row[] = Object.entries(domains) .flatMap( ([host, value]) => Object.entries(value?.days || {}) .map(([date, item]) => [host, cvtWebtimeTrackerDate(date), item?.seconds] as [string, string, number]) @@ -137,19 +129,19 @@ async function parseWebtimeTracker(file: File): Promise<timer.imported.Row[]> { host, date, focus: seconds * MILL_PER_SECOND - } as timer.imported.Row)) + } as tt4b.imported.Row)) return rows } else if (isCsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line) - const colHeaders = lines[0].split(',') - const rows: timer.imported.Row[] = [] + const colHeaders = lines[0]?.split(',') ?? [] + const rows: tt4b.imported.Row[] = [] lines.slice(1).forEach(line => { const cells = line.split(',') const host = cells[0] if (!host) return for (let i = 1; i < colHeaders?.length; i++) { - const seconds = Number.parseInt(cells[i]) - const date = cvtWebtimeTrackerDate(colHeaders[i]) + const seconds = Number.parseInt(cells[i] ?? '') + const date = cvtWebtimeTrackerDate(colHeaders[i] ?? '') seconds && date && rows.push({ host, date, focus: seconds * MILL_PER_SECOND, time: 0 }) } }) @@ -161,9 +153,9 @@ async function parseWebtimeTracker(file: File): Promise<timer.imported.Row[]> { function parseHistoryTrendsUnlimitedLine(line: string, data: { [dateAndHost: string]: number }) { const cells = line.split('\t') const url = cells[0] - if (isBrowserUrl(url)) return + if (!url || isBrowserUrl(url)) return const tsMaybe = cells?.[1]?.trim?.() - if (/^U\d{13,}(\.\d*)?$/.test(tsMaybe)) { + if (tsMaybe && /^U\d{13,}(\.\d*)?$/.test(tsMaybe)) { // Backup data let date: string try { @@ -178,16 +170,16 @@ function parseHistoryTrendsUnlimitedLine(line: string, data: { [dateAndHost: str data[key] = (data[key] ?? 0) + 1 } else { // Analyze data - const host = cells[1] - const dateStr = cells[4] - const date = cvtWebtimeTrackerDate(dateStr?.substring(0, 10)) + const host = cells[1] ?? '' + const dateStr = cells[4] ?? '' + const date = cvtWebtimeTrackerDate(dateStr.substring(0, 10)) if (!host || !date) return const key = date + host data[key] = (data[key] ?? 0) + 1 } } -async function parseHistoryTrendsUnlimited(file: File): Promise<timer.imported.Row[]> { +async function parseHistoryTrendsUnlimited(file: File): Promise<tt4b.imported.Row[]> { const text = await file.text() if (isTsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line) @@ -196,7 +188,7 @@ async function parseHistoryTrendsUnlimited(file: File): Promise<timer.imported.R return Object.entries(dailyVisits).map(([dateAndHost, time]) => { const date = dateAndHost.substring(0, 8) const host = dateAndHost.substring(8) - return { date, host, time, focus: 0 } satisfies timer.imported.Row + return { date, host, time, focus: 0 } satisfies tt4b.imported.Row }) } throw new Error("Invalid file format") diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts new file mode 100644 index 000000000..d1abbd52c --- /dev/null +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts @@ -0,0 +1,11 @@ +export type ImportForm = { + ext: OtherExtension + file?: File + data: tt4b.imported.Data + resolution?: tt4b.imported.ConflictResolution +} + +export type OtherExtension = + | "webtime_tracker" + | "web_activity_time_tracker" + | "history_trends_unlimited" diff --git a/src/pages/app/components/DataManage/Migration/index.tsx b/src/pages/app/components/DataManage/Migration/index.tsx index f9ca7c6ad..256340807 100644 --- a/src/pages/app/components/DataManage/Migration/index.tsx +++ b/src/pages/app/components/DataManage/Migration/index.tsx @@ -5,45 +5,81 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { Download } from "@element-plus/icons-vue" +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' +import { Download, Upload } from "@element-plus/icons-vue" +import { useManualRequest } from '@hooks' import Flex from "@pages/components/Flex" -import Immigration from "@service/components/immigration" -import { exportJson } from "@util/file" +import { deserialize, exportJson } from "@util/file" import { formatTime } from "@util/time" -import { ElButton, ElCard } from "element-plus" -import { type FunctionalComponent, type StyleValue } from "vue" +import { ElButton, ElCard, ElMessage } from "element-plus" +import { defineComponent, ref, type StyleValue } from "vue" +import { useDataMemory } from '../context' import DataManageAlert from '../DataManageAlert' -import ImportButton from "./ImportButton" import ImportOtherButton from "./ImportOtherButton" -const immigration: Immigration = new Immigration() - -async function handleExport() { - const data = await immigration.getExportingData() - const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') - exportJson(data, `timer_backup_${timestamp}`) +const BUTTON_STYLE: StyleValue = { + width: '100%', + margin: 0, } -const Migration: FunctionalComponent = () => ( - <ElCard style={{ width: '100%' } satisfies StyleValue}> - <Flex column gap={20} justify="center" height="100%" align="center"> - <DataManageAlert text={msg => msg.dataManage.migrationAlert} /> - <Flex column gap={20} maxWidth={350} flex={1}> - <ElButton - size="large" - type="success" - icon={Download} - onClick={handleExport} - style={{ flex: 1 } satisfies StyleValue} - > - {t(msg => msg.item.operation.exportWholeData)} - </ElButton> - <ImportButton /> - <ImportOtherButton /> +const Migration = defineComponent(() => { + const { refreshMemory } = useDataMemory() + const fileInput = ref<HTMLInputElement>() + + const { refresh: handleExport } = useManualRequest(async () => { + const data = await sendMsg2Runtime('immigration.export') + const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') + exportJson(data, `timer_backup_${timestamp}`) + }, { + loadingOptions: { fullscreen: true }, + onSuccess: () => ElMessage.success(t(msg => msg.operation.successMsg)), + }) + + const { refresh: onFileSelected } = useManualRequest(async () => { + const input = fileInput.value + try { + const file = input?.files?.[0] + if (!file) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) + const fileText = await file.text() + const data = deserialize(fileText) + if (!data) throw new Error(t(msg => msg.dataManage.importError)) + await sendMsg2Runtime('immigration.import', data) + } finally { + if (input) input.value = '' + } + }, { + loadingOptions: { fullscreen: true }, + onSuccess: () => { + refreshMemory() + ElMessage.success(t(msg => msg.operation.successMsg)) + }, + onError: e => ElMessage.error(e instanceof Error ? e.message : String(e)), + }) + + return () => ( + <ElCard style={{ width: '100%' } satisfies StyleValue}> + <Flex column width='100%' align='center' gap={16}> + <DataManageAlert text={msg => msg.dataManage.migrationAlert} /> + <Flex column gap={20} maxWidth={350} width='100%' align='stretch'> + <ImportOtherButton /> + <ElButton size="large" icon={Download} onClick={handleExport} style={BUTTON_STYLE}> + {t(msg => msg.dataManage.exportData)} + </ElButton> + <ElButton size="large" icon={Upload} onClick={() => fileInput.value?.click()} style={BUTTON_STYLE}> + {t(msg => msg.dataManage.restoreData)} + <input + ref={fileInput} + type="file" + accept=".json" + style={{ display: "none" }} + onChange={onFileSelected} + /> + </ElButton> + </Flex> </Flex> - </Flex> - </ElCard> -) + </ElCard> + ) +}) export default Migration diff --git a/src/pages/app/components/DataManage/context.ts b/src/pages/app/components/DataManage/context.ts index ac8877778..dffe0eb29 100644 --- a/src/pages/app/components/DataManage/context.ts +++ b/src/pages/app/components/DataManage/context.ts @@ -1,16 +1,19 @@ -import { getUsedStorage, type MemoryInfo } from "@db/memory-detector" -import { useProvide, useProvider, useRequest } from "@hooks" -import { type Ref } from "vue" +import { sendMsg2Runtime } from '@api/sw/common' +import { useProvide, useProvider, useRequest } from '@hooks' +import { type ShallowRef } from "vue" type Context = { - memory: Ref<MemoryInfo> + memory: ShallowRef<tt4b.common.StorageUsage> refreshMemory: () => void } const NAMESPACE = 'dataManage' export const initDataManage = () => { - const { data: memory, refresh: refreshMemory } = useRequest(getUsedStorage, { defaultValue: { used: 0, total: 1 } }) + const { data: memory, refresh: refreshMemory } = useRequest( + () => sendMsg2Runtime('meta.usedStorage'), + { defaultValue: { used: 0, total: 1 } }, + ) useProvide<Context>(NAMESPACE, { memory, refreshMemory }) } diff --git a/src/pages/app/components/DataManage/index.tsx b/src/pages/app/components/DataManage/index.tsx index a2a457f59..50ca96be0 100644 --- a/src/pages/app/components/DataManage/index.tsx +++ b/src/pages/app/components/DataManage/index.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { MediaSize, useMediaSize } from '@hooks/useMediaSize' +import { MediaSize, useMediaSize } from '@hooks' import Flex from "@pages/components/Flex" import { ElScrollbar } from 'element-plus' import { computed, defineComponent, type StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import ClearPanel from './ClearPanel' import MemoryInfo from "./MemoryInfo" import Migration from "./Migration" @@ -24,17 +24,17 @@ export default defineComponent(() => { <ElScrollbar height="100%" style={{ width: '100%' } satisfies StyleValue}> <ContentContainer> <Flex column gap={22}> - <Flex gap={22} height={ltSm.value ? undefined : 300} column={ltSm.value}> - <Flex height='100%' flex={5}> - <MemoryInfo /> - </Flex> - <Flex height='100%' flex={5}> + <Flex gap={22} align='start' column={ltSm.value}> + <Flex flex={5} width='100%' style={{ minWidth: 0 }}> <Migration /> </Flex> + <Flex flex={5} width='100%' style={{ minWidth: 0 }}> + <MemoryInfo /> + </Flex> </Flex> <ClearPanel /> </Flex> - </ContentContainer > + </ContentContainer> </ElScrollbar> ) }) \ No newline at end of file diff --git a/src/pages/app/components/Habit/components/HabitFilter.tsx b/src/pages/app/components/Habit/HabitFilter.tsx similarity index 77% rename from src/pages/app/components/Habit/components/HabitFilter.tsx rename to src/pages/app/components/Habit/HabitFilter.tsx index dea1e225a..f2d9c4e61 100644 --- a/src/pages/app/components/Habit/components/HabitFilter.tsx +++ b/src/pages/app/components/Habit/HabitFilter.tsx @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import DateRangeFilterItem from "@app/components/common/filter/DateRangeFilterItem" -import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" -import { t } from "@app/locale" +import DateRangeFilterItem from '@app/components/common/filter/DateRangeFilterItem' +import TimeFormatFilterItem from '@app/components/common/filter/TimeFormatFilterItem' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" +import { ElDatePickerShortcut } from '@pages/element-ui/types' import { daysAgo, MILL_PER_DAY } from "@util/time" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { defineComponent } from "vue" import { useHabitFilter } from "./context" @@ -26,7 +26,7 @@ const shortcutProps: ShortCutProp[] = [ [t(msg => msg.calendar.range.lastDays, { n: 365 }), 365], ] -const SHORTCUTS: Shortcut[] = shortcutProps.map(([text, agoOfStart]) => ({ text, value: daysAgo(agoOfStart, 0) })) +const SHORTCUTS: ElDatePickerShortcut[] = shortcutProps.map(([text, agoOfStart]) => ({ text, value: daysAgo(agoOfStart, 0) })) const _default = defineComponent(() => { const filter = useHabitFilter() diff --git a/src/pages/app/components/Habit/components/Period/Average/Wrapper.tsx b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx similarity index 84% rename from src/pages/app/components/Habit/components/Period/Average/Wrapper.tsx rename to src/pages/app/components/Habit/Period/Average/Wrapper.tsx index 42711e9c1..38a79f674 100644 --- a/src/pages/app/components/Habit/components/Period/Average/Wrapper.tsx +++ b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx @@ -6,15 +6,11 @@ */ import { t } from "@app/locale" import { getCompareColor, tooltipDot, tooltipFlexLine, tooltipSpaceLine } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { averageByDay, MINUTE_PER_PERIOD } from "@util/period" import { formatPeriodCommon, MILL_PER_MINUTE } from "@util/time" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, type TooltipComponentOption -} from "echarts" +import type { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" import { type TopLevelFormatterParams } from "echarts/types/dist/shared" import { generateGridOption } from "../common" @@ -25,16 +21,16 @@ type EcOption = ComposeOption< > export type BizOption = { - currRange: timer.period.KeyRange - prevRange: timer.period.KeyRange - curr: timer.period.Row[] - prev: timer.period.Row[] + currRange: tt4b.period.KeyRange + prevRange: tt4b.period.KeyRange + curr: tt4b.period.Row[] + prev: tt4b.period.Row[] periodSize: number } const [CURR_COLOR = '', PREV_COLOR = ''] = getCompareColor() -const cvt2Item = (row: timer.period.Row): number => { +const cvt2Item = (row: tt4b.period.Row): number => { const milliseconds = row.milliseconds return milliseconds } @@ -46,24 +42,24 @@ const formatXAxis = (idx: number, periodSize: number) => { return hour.toString().padStart(2, '0') + ':' + min.toString().padStart(2, '0') } -const key2Str = (key: timer.period.Key) => { +const key2Str = (key: tt4b.period.Key) => { const { month, date } = key return `${month?.toString?.()?.padStart(2, '0')}/${date?.toString?.()?.padStart(2, '0')}` } -const isSameDay = (keyRange: timer.period.KeyRange): boolean => { +const isSameDay = (keyRange: tt4b.period.KeyRange): boolean => { const [start, end] = keyRange || [] return start?.year === end?.year && start?.month === end?.month && start?.date === end?.date } -const range2Str = (keyRange: timer.period.KeyRange) => { +const range2Str = (keyRange: tt4b.period.KeyRange) => { const [start, end] = keyRange return isSameDay(keyRange) ? key2Str(start) : `${key2Str(start)}-${key2Str(end)}` } -const formatValueLine = (mill: number, range: timer.period.KeyRange, color: string): string => { +const formatValueLine = (mill: number, range: tt4b.period.KeyRange, color: string): string => { return tooltipFlexLine( `${tooltipDot(color)} <b>${formatPeriodCommon(mill ?? 0)}</b>`, range2Str(range), @@ -73,9 +69,9 @@ const formatValueLine = (mill: number, range: timer.period.KeyRange, color: stri const formatTooltip = (params: TopLevelFormatterParams, biz: BizOption): string => { const { periodSize, prevRange, currRange } = biz if (!Array.isArray(params)) return '' - const [curr, prev] = params || [] + const [curr, prev] = params ?? [] - const idx = curr.dataIndex + const idx = curr?.dataIndex ?? 0 const start = formatXAxis(idx, periodSize) const end = formatXAxis(idx + 1, periodSize) @@ -87,7 +83,7 @@ const formatTooltip = (params: TopLevelFormatterParams, biz: BizOption): string periodStr, ) - const currLine = formatValueLine(curr.value as number, currRange, CURR_COLOR) + const currLine = formatValueLine(curr?.value as number ?? 0, currRange, CURR_COLOR) const prevLine = formatValueLine(-(prev?.value ?? 0 as number), prevRange, PREV_COLOR) return `${timeLine}${tooltipSpaceLine()}${currLine}${prevLine}` diff --git a/src/pages/app/components/Habit/components/Period/Average/index.tsx b/src/pages/app/components/Habit/Period/Average/index.tsx similarity index 95% rename from src/pages/app/components/Habit/components/Period/Average/index.tsx rename to src/pages/app/components/Habit/Period/Average/index.tsx index b60cbd5ba..9c86cd04f 100644 --- a/src/pages/app/components/Habit/components/Period/Average/index.tsx +++ b/src/pages/app/components/Habit/Period/Average/index.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { computed, defineComponent, type StyleValue } from "vue" import { usePeriodFilter, usePeriodRange, usePeriodValue } from "../context" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Period/Filter.tsx b/src/pages/app/components/Habit/Period/Filter.tsx similarity index 96% rename from src/pages/app/components/Habit/components/Period/Filter.tsx rename to src/pages/app/components/Habit/Period/Filter.tsx index cfb66da81..a5111cb65 100644 --- a/src/pages/app/components/Habit/components/Period/Filter.tsx +++ b/src/pages/app/components/Habit/Period/Filter.tsx @@ -11,8 +11,8 @@ import { type HabitMessage } from '@i18n/message/app/habit' import Flex from '@pages/components/Flex' import { ElRadioButton, ElRadioGroup } from 'element-plus' import { defineComponent } from 'vue' -import { type ChartType } from './common' import { usePeriodFilter } from './context' +import type { ChartType } from './types' // [value, label] type _SizeOption = [number, keyof HabitMessage['period']['sizes']] @@ -46,7 +46,7 @@ const _default = defineComponent(() => { historyName='periodSize' defaultValue={filter.periodSize?.toString?.()} options={allOptions()} - onSelect={val => { + onChange={val => { if (!val) return const newPeriodSize = parseInt(val) if (isNaN(newPeriodSize)) return diff --git a/src/pages/app/components/Habit/components/Period/Stack/Wrapper.ts b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts similarity index 76% rename from src/pages/app/components/Habit/components/Period/Stack/Wrapper.ts rename to src/pages/app/components/Habit/Period/Stack/Wrapper.ts index 597a36e58..0d15c76b5 100644 --- a/src/pages/app/components/Habit/components/Period/Stack/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts @@ -1,14 +1,9 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getLineSeriesPalette } from "@app/util/echarts" -import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { periodFormatter } from '@app/util/time' +import { EchartsWrapper } from "@hooks" import { formatTime } from "@util/time" -import { - type ComposeOption, - type GridComponentOption, - type LineSeriesOption, - type TooltipComponentOption, -} from "echarts" +import type { ComposeOption, GridComponentOption, LineSeriesOption, TooltipComponentOption } from "echarts" import { type TopLevelFormatterParams } from "echarts/types/dist/shared" import { formatXAxisTime, generateGridOption } from "../common" @@ -19,17 +14,17 @@ type EcOption = ComposeOption< > export type BizOption = { - data: timer.period.Row[] - timeFormat: timer.app.TimeFormat + data: tt4b.period.Row[] + timeFormat: tt4b.app.TimeFormat } const [COLOR] = getLineSeriesPalette() -const formatTooltip = (params: TopLevelFormatterParams, timeFormat: timer.app.TimeFormat) => { +const formatTooltip = (params: TopLevelFormatterParams, timeFormat: tt4b.app.TimeFormat) => { const param = Array.isArray(params) ? params[0] : params - const [, total, , end] = param.data as number[] + const [, total, , end] = param?.data as number[] return ` - <div>${formatTime(end, t(msg => msg.calendar.timeFormat))}</div> + <div>${end ? formatTime(end, t(msg => msg.calendar.timeFormat)) : ''}</div> <div><b>${periodFormatter(total, { format: timeFormat })}</b></div> ` } @@ -38,8 +33,8 @@ const generateOption = (biz: BizOption): EcOption => { const { data, timeFormat } = biz || {} let stackVal: number = 0 const seriesData = data.map(row => { - const startTime = row.startTime.getTime() - const endTime = row.endTime.getTime() + const startTime = row.startTime + const endTime = row.endTime const time = (startTime + endTime) / 2 const delta = row.milliseconds ?? 0 stackVal += delta diff --git a/src/pages/app/components/Habit/components/Period/Stack/index.tsx b/src/pages/app/components/Habit/Period/Stack/index.tsx similarity index 88% rename from src/pages/app/components/Habit/components/Period/Stack/index.tsx rename to src/pages/app/components/Habit/Period/Stack/index.tsx index 8afe72cdb..ddf610a2b 100644 --- a/src/pages/app/components/Habit/components/Period/Stack/index.tsx +++ b/src/pages/app/components/Habit/Period/Stack/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useHabitFilter } from "@app/components/Habit/context" +import { useEcharts } from "@hooks" import { computed, defineComponent, type StyleValue } from "vue" -import { useHabitFilter } from "../../context" import { usePeriodValue } from "../context" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Period/Summary.tsx b/src/pages/app/components/Habit/Period/Summary.tsx similarity index 67% rename from src/pages/app/components/Habit/components/Period/Summary.tsx rename to src/pages/app/components/Habit/Period/Summary.tsx index 8acabafb7..449c76b38 100644 --- a/src/pages/app/components/Habit/components/Period/Summary.tsx +++ b/src/pages/app/components/Habit/Period/Summary.tsx @@ -1,8 +1,8 @@ -import { GRID_CELL_STYLE } from "@app/components/common/grid" -import { KanbanIndicatorCell } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" -import { useXsState } from "@hooks" +import { GRID_CELL_STYLE } from '@app/components/common/grid' +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { averageByDay } from "@util/period" import { formatTime } from "@util/time" @@ -21,7 +21,7 @@ type Result = { } } -const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => { +const computeSummary = (rows: tt4b.period.Row[], periodSize: number): Result => { const averaged = averageByDay(rows, periodSize) const favoriteRow = averaged.sort((b, a) => a.milliseconds - b.milliseconds)[0] let favoritePeriod = '-' @@ -31,14 +31,14 @@ const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => favoritePeriod = `${formatTime(start, "{h}:{i}")}-${formatTime(end, "{h}:{i}")}` } - let maxIdle: [timer.period.Row | undefined, timer.period.Row | undefined, number] = [, , 0] + let maxIdle: [tt4b.period.Row | undefined, tt4b.period.Row | undefined, number] = [, , 0] - let idleStart: timer.period.Row | undefined - let idleEnd: timer.period.Row | undefined + let idleStart: tt4b.period.Row | undefined + let idleEnd: tt4b.period.Row | undefined rows.forEach(r => { if (r.milliseconds) { if (!idleStart || !idleEnd) return - const newEmptyTs = idleEnd.endTime.getTime() - idleStart.endTime.getTime() + const newEmptyTs = idleEnd.endTime - idleStart.endTime if (newEmptyTs > maxIdle[2]) { maxIdle = [idleStart, idleEnd, newEmptyTs] } @@ -54,7 +54,7 @@ const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => let idleLength = '-' let idlePeriod = '' if (start && end) { - idleLength = periodFormatter(end.endTime.getTime() - start.startTime.getTime(), { format: 'hour' }) + idleLength = periodFormatter(end.endTime - start.startTime, { format: 'hour' }) const format = t(msg => msg.calendar.simpleTimeFormat) const startTime = formatTime(start.startTime, format) const endTime = formatTime(end.endTime, format) @@ -64,7 +64,7 @@ const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => return { favorite: { period: favoritePeriod, - average: favoriteRow?.milliseconds, + average: favoriteRow?.milliseconds ?? 0, }, longestIdle: { length: idleLength, @@ -77,22 +77,22 @@ const _default = defineComponent(() => { const data = usePeriodValue() const filter = usePeriodFilter() const globalFilter = useHabitFilter() - const summary = computed(() => computeSummary(data.value?.curr, filter.periodSize)) + const summary = computed(() => computeSummary(data.value.curr, filter.periodSize)) const isXs = useXsState() return () => ( <Flex column gap={1} flex={isXs.value ? undefined : 1}> <KanbanIndicatorCell mainName={t(msg => msg.habit.period.busiest)} - mainValue={summary.value?.favorite?.period} + mainValue={summary.value.favorite.period} subTips={msg => msg.habit.common.focusAverage} - subValue={periodFormatter(summary.value?.favorite?.average, { format: globalFilter.timeFormat })} + subValue={periodFormatter(summary.value.favorite.average, { format: globalFilter.timeFormat })} containerStyle={GRID_CELL_STYLE} /> <KanbanIndicatorCell mainName={t(msg => msg.habit.period.idle)} - mainValue={summary.value?.longestIdle?.length} - subTips={() => summary.value?.longestIdle?.period} + mainValue={summary.value.longestIdle.length} + subTips={() => summary.value.longestIdle.period} containerStyle={GRID_CELL_STYLE} /> </Flex> diff --git a/src/pages/app/components/Habit/components/Period/Trend/Wrapper.ts b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts similarity index 70% rename from src/pages/app/components/Habit/components/Period/Trend/Wrapper.ts rename to src/pages/app/components/Habit/Period/Trend/Wrapper.ts index 6746a279b..fa2d96421 100644 --- a/src/pages/app/components/Habit/components/Period/Trend/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts @@ -5,16 +5,11 @@ * https://opensource.org/licenses/MIT */ import { getSeriesPalette } from "@app/util/echarts" -import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { periodFormatter } from '@app/util/time' +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { formatTime } from "@util/time" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, - type TooltipComponentOption, -} from "echarts" +import type { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" import { formatXAxisTime, generateGridOption } from "../common" @@ -25,17 +20,24 @@ type EcOption = ComposeOption< > export type BizOption = { - data: timer.period.Row[] - timeFormat: timer.app.TimeFormat + data: tt4b.period.Row[] + timeFormat: tt4b.app.TimeFormat } -function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: timer.app.TimeFormat): string { +function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: tt4b.app.TimeFormat): string { const format = Array.isArray(params) ? params[0] : params - const value = format.value as number[] - const milliseconds = value?.[1] ?? 0 - const start = formatTime(value?.[2], '{m}-{d} {h}:{i}') - const end = formatTime(value?.[3], '{h}:{i}') + if (!format) return 'NaN' + const { value } = format + if (!Array.isArray(value)) return 'NaN' + const time = value?.[1] ?? 0 + const startTs = value?.[2] + const endTs = value?.[3] + const start = typeof startTs === 'number' ? formatTime(startTs, '{m}-{d} {h}:{i}') : 'NaN' + const end = typeof endTs === 'number' ? formatTime(endTs, '{h}:{i}') : 'NaN' + const milliseconds = time instanceof Date + ? time.getTime() + : (typeof time === 'number' ? time : Number.parseInt(time)) return ` <div>${start}-${end}</div> <div> @@ -48,9 +50,9 @@ function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: timer. type BarItem = Exclude<BarSeriesOption["data"], undefined>[number] -const cvt2Item = (row: timer.period.Row): BarItem => { - const startTime = row.startTime.getTime() - const endTime = row.endTime.getTime() +const cvt2Item = (row: tt4b.period.Row): BarItem => { + const startTime = row.startTime + const endTime = row.endTime const time = (startTime + endTime) / 2 const milliseconds = row.milliseconds return [time, milliseconds, startTime, endTime] diff --git a/src/pages/app/components/Habit/components/Period/Trend/index.tsx b/src/pages/app/components/Habit/Period/Trend/index.tsx similarity index 94% rename from src/pages/app/components/Habit/components/Period/Trend/index.tsx rename to src/pages/app/components/Habit/Period/Trend/index.tsx index 7503c6b12..4d2bfd5c9 100644 --- a/src/pages/app/components/Habit/components/Period/Trend/index.tsx +++ b/src/pages/app/components/Habit/Period/Trend/index.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { computed, defineComponent, type StyleValue } from "vue" import { useHabitFilter } from "../../context" import { usePeriodValue } from "../context" diff --git a/src/pages/app/components/Habit/components/Period/common.ts b/src/pages/app/components/Habit/Period/common.ts similarity index 61% rename from src/pages/app/components/Habit/components/Period/common.ts rename to src/pages/app/components/Habit/Period/common.ts index 9334f5287..3dd58cf2e 100644 --- a/src/pages/app/components/Habit/components/Period/common.ts +++ b/src/pages/app/components/Habit/Period/common.ts @@ -5,25 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { formatTime } from "@util/time" import { type GridComponentOption } from "echarts/components" -export const generateGridOption = (): GridComponentOption => { - return { - top: 30, - bottom: 40, - left: 40, - right: 20, - } -} - -export type ChartType = 'average' | 'trend' | 'stack' - -export type FilterOption = { - periodSize: number - chartType: ChartType -} +export const generateGridOption = () => ({ + top: 30, + bottom: 40, + left: 40, + right: 20, +} satisfies GridComponentOption) const MONTHS = t(msg => msg.calendar.months).split('|') @@ -33,9 +24,10 @@ export const formatXAxisTime = (time: number, idx: number): string => { const isStartOfMonth = dateStr === '01000000' const isStartOfDate = dateStr.endsWith('000000') if (idx === 0 || isStartOfMonth) { - return MONTHS[date.getMonth()] + const monthIdx = date.getMonth() + return MONTHS[monthIdx] ?? `${monthIdx + 1}` } else if (isStartOfDate) { - return date.getDate()?.toString?.()?.padStart(2, '0') + return date.getDate().toString().padStart(2, '0') } else { return formatTime(date, "{h}:{i}") } diff --git a/src/pages/app/components/Habit/components/Period/context.ts b/src/pages/app/components/Habit/Period/context.ts similarity index 77% rename from src/pages/app/components/Habit/components/Period/context.ts rename to src/pages/app/components/Habit/Period/context.ts index 50f7d21c2..91c2eb864 100644 --- a/src/pages/app/components/Habit/components/Period/context.ts +++ b/src/pages/app/components/Habit/Period/context.ts @@ -5,23 +5,22 @@ * https://opensource.org/licenses/MIT */ -import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import { merge } from "@service/components/period-calculator" -import periodService from "@service/period-service" +import { listPeriods } from '@api/sw/period' +import { useLocalStorage, useProvide, useProvider, useRequest } from '@hooks' import { keyOf, MAX_PERIOD_ORDER } from "@util/period" import { getDayLength, MILL_PER_DAY } from "@util/time" import { computed, reactive, toRaw, watch, type Reactive, type Ref } from "vue" import { useHabitFilter } from "../context" -import type { FilterOption } from "./common" +import type { FilterOption } from "./types" type Value = { - curr: timer.period.Row[] - prev: timer.period.Row[] + curr: tt4b.period.Row[] + prev: tt4b.period.Row[] } -export type PeriodRange = { - curr: timer.period.KeyRange - prev: timer.period.KeyRange +type PeriodRange = { + curr: tt4b.period.KeyRange + prev: tt4b.period.KeyRange } type Context = { @@ -41,12 +40,6 @@ const computeRange = (filterDateRange: [Date, Date]): PeriodRange => { } } -const fetchRows = async (range: timer.period.KeyRange, periodSize: number) => { - const results = await periodService.listBetween({ periodRange: range }) - const [start, end] = range || [] - return merge(results, { start, end, periodSize }) -} - const NAMESPACE = 'habitPeriod' export const initProvider = () => { @@ -62,8 +55,8 @@ export const initProvider = () => { const { curr: currRange, prev: prevRange } = periodRange.value || {} const periodSize = filter.periodSize const [curr, prev] = await Promise.all([ - fetchRows(currRange, periodSize), - fetchRows(prevRange, periodSize), + listPeriods({ range: currRange, size: periodSize }), + listPeriods({ range: prevRange, size: periodSize }), ]) return { curr, prev } }, { diff --git a/src/pages/app/components/Habit/components/Period/index.tsx b/src/pages/app/components/Habit/Period/index.tsx similarity index 97% rename from src/pages/app/components/Habit/components/Period/index.tsx rename to src/pages/app/components/Habit/Period/index.tsx index 9a0f78d3e..b977fc567 100644 --- a/src/pages/app/components/Habit/components/Period/index.tsx +++ b/src/pages/app/components/Habit/Period/index.tsx @@ -7,7 +7,7 @@ import { GRID_CELL_STYLE } from "@app/components/common/grid" import { KanbanCard } from "@app/components/common/kanban" -import { useXsState } from "@hooks" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { defineComponent } from "vue" import Average from "./Average" diff --git a/src/pages/app/components/Habit/Period/types.d.ts b/src/pages/app/components/Habit/Period/types.d.ts new file mode 100644 index 000000000..cc0e80f5f --- /dev/null +++ b/src/pages/app/components/Habit/Period/types.d.ts @@ -0,0 +1,6 @@ +export type ChartType = 'average' | 'trend' | 'stack' + +export type FilterOption = { + periodSize: number + chartType: ChartType +} diff --git a/src/pages/app/components/Habit/components/Site/DailyTrend/Wrapper.ts b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts similarity index 90% rename from src/pages/app/components/Habit/components/Site/DailyTrend/Wrapper.ts rename to src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts index 22cc825b0..1084b998d 100644 --- a/src/pages/app/components/Habit/components/Site/DailyTrend/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts @@ -1,21 +1,16 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getLineSeriesPalette, tooltipDot, tooltipFlexLine, tooltipSpaceLine } from "@app/util/echarts" import { cvt2LocaleTime, periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from '@hooks' import { groupBy, sum } from "@util/array" import { getHost } from "@util/stat" import { getAllDatesBetween } from "@util/time" -import { - type ComposeOption, - type GridComponentOption, - type LegendComponentOption, - type LineSeriesOption, - type LinearGradientObject, - type TitleComponentOption, - type TooltipComponentOption, +import type { + ComposeOption, GridComponentOption, LegendComponentOption, LineSeriesOption, LinearGradientObject, + TitleComponentOption, TooltipComponentOption, } from "echarts" -import { type TopLevelFormatterParams, type YAXisOption } from "echarts/types/dist/shared" +import type { TopLevelFormatterParams, YAXisOption } from "echarts/types/dist/shared" import { generateTitleOption } from "../common" type EcOption = ComposeOption< @@ -39,9 +34,9 @@ const LEGEND_COLOR_MAP = { const TITLE = t(msg => msg.habit.site.trend.title) export type BizOption = { - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] dateRange: [Date, Date] - timeFormat: timer.app.TimeFormat + timeFormat: tt4b.app.TimeFormat } const valueYAxis = (): YAXisOption => ({ @@ -52,7 +47,7 @@ const valueYAxis = (): YAXisOption => ({ splitLine: { show: false }, }) -const formatTimeTooltip = (params: TopLevelFormatterParams, format: timer.app.TimeFormat) => { +const formatTimeTooltip = (params: TopLevelFormatterParams, format: tt4b.app.TimeFormat) => { if (!Array.isArray(params)) return '' const date = params?.[0]?.name if (!date) return '' @@ -68,7 +63,7 @@ const formatTimeTooltip = (params: TopLevelFormatterParams, format: timer.app.Ti ? periodFormatter(value as number, { format }) : (value as number) return tooltipFlexLine( - `<b>${tooltipDot(color?.colorStops?.[0]?.color)} ${valueStr}</b>`, + `<b>${tooltipDot(color?.colorStops?.[0]?.color ?? '#000')} ${valueStr}</b>`, seriesName, ) }).join('') diff --git a/src/pages/app/components/Habit/components/Site/DailyTrend/index.tsx b/src/pages/app/components/Habit/Site/DailyTrend/index.tsx similarity index 94% rename from src/pages/app/components/Habit/components/Site/DailyTrend/index.tsx rename to src/pages/app/components/Habit/Site/DailyTrend/index.tsx index 4b08529dd..33da1ad3b 100644 --- a/src/pages/app/components/Habit/components/Site/DailyTrend/index.tsx +++ b/src/pages/app/components/Habit/Site/DailyTrend/index.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" import { useHabitFilter } from "../../context" import { useRows } from "../context" diff --git a/src/pages/app/components/Habit/components/Site/Distribution/Wrapper.ts b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts similarity index 93% rename from src/pages/app/components/Habit/components/Site/Distribution/Wrapper.ts rename to src/pages/app/components/Habit/Site/Distribution/Wrapper.ts index 4e5d471a1..606893114 100644 --- a/src/pages/app/components/Habit/components/Site/Distribution/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts @@ -1,25 +1,16 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getPieBorderColor, getSeriesPalette } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor, getRegularTextColor } from "@pages/util/style" +import { EchartsWrapper } from '@hooks' +import { getPrimaryTextColor, getRegularTextColor } from '@pages/util/style' import { groupBy, sum } from "@util/array" import { getHost } from "@util/stat" import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" -import { - type ComposeOption, - type LegendComponentOption, - type PieSeriesOption, - type TitleComponentOption -} from "echarts" -import { - type GridOption, - type TooltipOption, - type TopLevelFormatterParams -} from "echarts/types/dist/shared" +import type { ComposeOption, LegendComponentOption, PieSeriesOption, TitleComponentOption } from "echarts" +import type { GridOption, TooltipOption, TopLevelFormatterParams } from "echarts/types/dist/shared" import { computeAverageLen, generateTitleOption } from "../common" export type BizOption = { - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] dateRange: [Date, Date] } diff --git a/src/pages/app/components/Habit/components/Site/Distribution/index.tsx b/src/pages/app/components/Habit/Site/Distribution/index.tsx similarity index 87% rename from src/pages/app/components/Habit/components/Site/Distribution/index.tsx rename to src/pages/app/components/Habit/Site/Distribution/index.tsx index 157c65a32..36adeb40a 100644 --- a/src/pages/app/components/Habit/components/Site/Distribution/index.tsx +++ b/src/pages/app/components/Habit/Site/Distribution/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useHabitFilter } from "@app/components/Habit/context" +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" -import { useHabitFilter } from "../../context" import { useDateMergedRows } from "../context" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Site/Summary.tsx b/src/pages/app/components/Habit/Site/Summary.tsx similarity index 89% rename from src/pages/app/components/Habit/components/Site/Summary.tsx rename to src/pages/app/components/Habit/Site/Summary.tsx index a87127589..b30564f62 100644 --- a/src/pages/app/components/Habit/components/Site/Summary.tsx +++ b/src/pages/app/components/Habit/Site/Summary.tsx @@ -1,8 +1,8 @@ -import { GRID_CELL_STYLE } from "@app/components/common/grid" -import { KanbanIndicatorCell } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" -import { useXsState } from "@hooks" +import { GRID_CELL_STYLE } from '@app/components/common/grid' +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { sum } from "@util/array" import { getHost } from "@util/stat" @@ -24,7 +24,7 @@ type Result = { exclusiveToday4Average: boolean } -const computeSummary = (rows: timer.stat.Row[] = [], filter: FilterOption): Result => { +const computeSummary = (rows: tt4b.stat.Row[] = [], filter: FilterOption): Result => { const [averageLen, exclusiveToday4Average, exclusiveDate] = computeAverageLen(filter?.dateRange) const totalFocus = sum(rows.map(r => r.focus)) const totalFocus4Average = exclusiveDate ? sum(rows.filter(r => r.date !== exclusiveDate).map(r => r.focus)) : totalFocus diff --git a/src/pages/app/components/Habit/components/Site/TopK/Wrapper.ts b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts similarity index 77% rename from src/pages/app/components/Habit/components/Site/TopK/Wrapper.ts rename to src/pages/app/components/Habit/Site/TopK/Wrapper.ts index 85d981f7d..62448f342 100644 --- a/src/pages/app/components/Habit/components/Site/TopK/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts @@ -5,18 +5,14 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getStepColors } from "@app/util/echarts" import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from '@hooks' import { generateSiteLabel } from "@util/site" import { identifyTargetKey, isSite } from "@util/stat" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, - type TitleComponentOption, - type TooltipComponentOption, +import type { + BarSeriesOption, ComposeOption, GridComponentOption, TitleComponentOption, TooltipComponentOption, } from "echarts" import { type TopLevelFormatterParams } from "echarts/types/dist/shared" import { type SeriesDataItem, generateTitleOption } from "../common" @@ -29,8 +25,8 @@ type EcOption = ComposeOption< > type BizOption = { - rows: timer.stat.Row[] - timeFormat: timer.app.TimeFormat + rows: tt4b.stat.Row[] + timeFormat: tt4b.app.TimeFormat } const TOP_NUM = 8 @@ -38,10 +34,10 @@ const TOP_NUM = 8 const MARGIN_LEFT_P = 8 const MARGIN_RIGHT_P = 8 -const formatFocusTooltip = (params: TopLevelFormatterParams, format: timer.app.TimeFormat): string => { +const formatFocusTooltip = (params: TopLevelFormatterParams, format: tt4b.app.TimeFormat): string => { const param = Array.isArray(params) ? params[0] : params const { data } = param || {} - const row = (data as any)?.row as timer.stat.Row + const row = (data as any)?.row as tt4b.stat.Row if (!isSite(row)) return '' @@ -54,27 +50,23 @@ const formatFocusTooltip = (params: TopLevelFormatterParams, format: timer.app.T </div> ` } +type MergeRow = + | MakeRequired<tt4b.stat.SiteRow | tt4b.stat.CateRow, 'mergedDates' | 'mergedRows'> + | MakeRequired<tt4b.stat.GroupRow, 'mergedDates' | 'mergedRows'> -function mergeDate(origin: timer.stat.Row[]): timer.stat.Row[] { - const map: Record< - string, - | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> - | MakeRequired<timer.stat.GroupRow, 'mergedDates'> - > = {} +function mergeDate(origin: tt4b.stat.Row[]): tt4b.stat.Row[] { + const map: Record<string, MergeRow> = {} origin.forEach(ele => { - const { date = '', focus, time } = ele + const { date, focus, time } = ele const key = identifyTargetKey(ele) - let exist = map[key] - if (!exist) { - exist = map[key] = { - ...ele, - focus: 0, - time: 0, - mergedRows: [], - mergedDates: [], - composition: { focus: [], time: [], run: [] }, - } - } + const exist: MergeRow = map[key] ?? (map[key] = { + ...ele, + focus: 0, + time: 0, + mergedRows: [], + mergedDates: [], + composition: { focus: [], time: [], run: [] }, + }) exist.focus += focus ?? 0 exist.time += time ?? 0 exist.mergedDates.push(date) @@ -83,7 +75,7 @@ function mergeDate(origin: timer.stat.Row[]): timer.stat.Row[] { return newRows } -async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app.TimeFormat, dom: HTMLElement): Promise<EcOption> { +async function generateOption(rows: tt4b.stat.Row[] = [], timeFormat: tt4b.app.TimeFormat, dom: HTMLElement): Promise<EcOption> { const merged = mergeDate(rows) const topList = merged.sort((a, b) => b.focus - a.focus).splice(0, TOP_NUM).reverse() const max = topList[topList.length - 1]?.focus ?? 0 @@ -154,7 +146,7 @@ async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app padding: [0, 4, 0, 0], formatter: (params: TopLevelFormatterParams) => { const param = Array.isArray(params) ? params[0] : params - const { row } = (param.data || {}) as SeriesDataItem + const { row } = (param?.data ?? {}) as SeriesDataItem if (!isSite(row)) return '' const { siteKey, alias } = row return alias ?? siteKey.host diff --git a/src/pages/app/components/Habit/components/Site/TopK/index.tsx b/src/pages/app/components/Habit/Site/TopK/index.tsx similarity index 86% rename from src/pages/app/components/Habit/components/Site/TopK/index.tsx rename to src/pages/app/components/Habit/Site/TopK/index.tsx index 9fb83b6b1..ec8fa8e2a 100644 --- a/src/pages/app/components/Habit/components/Site/TopK/index.tsx +++ b/src/pages/app/components/Habit/Site/TopK/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useHabitFilter } from "@app/components/Habit/context" +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" -import { useHabitFilter } from "../../context" import { useDateMergedRows } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Site/common.ts b/src/pages/app/components/Habit/Site/common.ts similarity index 94% rename from src/pages/app/components/Habit/components/Site/common.ts rename to src/pages/app/components/Habit/Site/common.ts index 26529f06b..4a63023fc 100644 --- a/src/pages/app/components/Habit/components/Site/common.ts +++ b/src/pages/app/components/Habit/Site/common.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { getRegularTextColor } from "@pages/util/style" +import { getRegularTextColor } from '@pages/util/style' import { formatTimeYMD, getDayLength, isSameDay } from "@util/time" import { type TitleComponentOption } from "echarts/components" @@ -25,7 +25,7 @@ export const generateTitleOption = (text: string): TitleComponentOption => { export type SeriesDataItem = { value: number - row: timer.stat.Row + row: tt4b.stat.Row } /** diff --git a/src/pages/app/components/Habit/Site/context.ts b/src/pages/app/components/Habit/Site/context.ts new file mode 100644 index 000000000..b23117231 --- /dev/null +++ b/src/pages/app/components/Habit/Site/context.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listSiteStats } from "@api/sw/stat" +import { useProvide, useProvider, useRequest } from '@hooks' +import { cvtDateRange2Str, getDayLength } from "@util/time" +import { computed, type ShallowRef } from "vue" +import { useHabitFilter } from "../context" + +type Context = { + rows: ShallowRef<tt4b.stat.Row[]> + dateMergedRows: ShallowRef<tt4b.stat.Row[]> +} + +const NAMESPACE = 'habitSite' + +export const initProvider = () => { + const filter = useHabitFilter() + + const { data: rows } = useRequest(() => listSiteStats({ date: cvtDateRange2Str(filter.dateRange) }), { + deps: [() => filter.dateRange], + defaultValue: [], + }) + + const dateRangeLength = computed(() => getDayLength(filter.dateRange?.[0], filter.dateRange?.[1])) + + const { data: dateMergedRows } = useRequest(() => listSiteStats({ date: cvtDateRange2Str(filter.dateRange), mergeDate: true }), { + deps: [() => filter.dateRange], + defaultValue: [], + }) + useProvide<Context>(NAMESPACE, { rows, dateMergedRows }) + + return dateRangeLength +} + +export const useRows = (): ShallowRef<tt4b.stat.Row[]> => useProvider<Context, 'rows'>(NAMESPACE, "rows").rows + +export const useDateMergedRows = (): ShallowRef<tt4b.stat.Row[]> => useProvider<Context, 'dateMergedRows'>(NAMESPACE, 'dateMergedRows').dateMergedRows \ No newline at end of file diff --git a/src/pages/app/components/Habit/components/Site/index.tsx b/src/pages/app/components/Habit/Site/index.tsx similarity index 91% rename from src/pages/app/components/Habit/components/Site/index.tsx rename to src/pages/app/components/Habit/Site/index.tsx index 4dbaa843a..3fc8ec4c9 100644 --- a/src/pages/app/components/Habit/components/Site/index.tsx +++ b/src/pages/app/components/Habit/Site/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { GRID_CELL_STYLE, GRID_WRAPPER_STYLE } from "@app/components/common/grid" +import { GRID_CELL_STYLE, GRID_WRAPPER_STYLE } from '@app/components/common/grid' import { KanbanCard } from "@app/components/common/kanban" -import { useXsState } from "@hooks" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { computed, defineComponent, type StyleValue } from "vue" import { initProvider } from "./context" @@ -41,7 +41,7 @@ const _default = defineComponent(() => { </Flex> )} </Flex> - </KanbanCard > + </KanbanCard> ) }) diff --git a/src/pages/app/components/Habit/components/Site/context.ts b/src/pages/app/components/Habit/components/Site/context.ts deleted file mode 100644 index 729c5fba1..000000000 --- a/src/pages/app/components/Habit/components/Site/context.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { useProvide, useProvider, useRequest } from "@hooks" -import { selectSite } from "@service/stat-service" -import { mergeDate } from "@service/stat-service/merge/date" -import { getDayLength } from "@util/time" -import { computed, type Ref } from "vue" -import { useHabitFilter } from "../context" - -type Context = { - rows: Ref<timer.stat.Row[]> - dateMergedRows: Ref<timer.stat.Row[]> -} - -const NAMESPACE = 'habitSite' - -export const initProvider = () => { - const filter = useHabitFilter() - - const { data: rows } = useRequest(() => selectSite({ date: filter.dateRange }), { - deps: [() => filter.dateRange], - defaultValue: [], - }) - - const dateRangeLength = computed(() => getDayLength(filter.dateRange?.[0], filter.dateRange?.[1])) - - const dateMergedRows = computed(() => mergeDate(rows.value ?? [])) - useProvide<Context>(NAMESPACE, { rows, dateMergedRows }) - - return dateRangeLength -} - -export const useRows = (): Ref<timer.stat.Row[]> => useProvider<Context, 'rows'>(NAMESPACE, "rows").rows - -export const useDateMergedRows = (): Ref<timer.stat.Row[]> => useProvider<Context, 'dateMergedRows'>(NAMESPACE, 'dateMergedRows').dateMergedRows \ No newline at end of file diff --git a/src/pages/app/components/Habit/components/context.ts b/src/pages/app/components/Habit/context.ts similarity index 88% rename from src/pages/app/components/Habit/components/context.ts rename to src/pages/app/components/Habit/context.ts index b1c10f41e..84db196f1 100644 --- a/src/pages/app/components/Habit/components/context.ts +++ b/src/pages/app/components/Habit/context.ts @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import { useProvide, useProvider } from "@hooks" +import { useProvide, useProvider } from '@hooks' import { daysAgo } from "@util/time" import { reactive, Reactive } from "vue" export type FilterOption = { - timeFormat: timer.app.TimeFormat + timeFormat: tt4b.app.TimeFormat dateRange: [Date, Date] } diff --git a/src/pages/app/components/Habit/index.tsx b/src/pages/app/components/Habit/index.tsx index d77a2e9cf..67ca15e82 100644 --- a/src/pages/app/components/Habit/index.tsx +++ b/src/pages/app/components/Habit/index.tsx @@ -4,20 +4,20 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import ContentContainer, { FilterContainer } from "@app/components/common/ContentContainer" import { ElScrollbar } from "element-plus" import { defineComponent, type StyleValue } from "vue" -import HabitFilter from "./components/HabitFilter" -import Period from "./components/Period" -import Site from "./components/Site" -import { initHabit } from "./components/context" +import ContentContainer, { FilterContainer } from '../common/ContentContainer' +import HabitFilter from "./HabitFilter" +import Period from "./Period" +import Site from "./Site" +import { initHabit } from "./context" const _default = defineComponent(() => { initHabit() return () => ( <ElScrollbar height="100%" style={{ width: '100%' } satisfies StyleValue}> - <ContentContainer > + <ContentContainer> <FilterContainer> <HabitFilter /> </FilterContainer> diff --git a/src/pages/app/components/HelpUs/MemberList.tsx b/src/pages/app/components/HelpUs/MemberList.tsx index 44d2c54f7..15997ab1f 100644 --- a/src/pages/app/components/HelpUs/MemberList.tsx +++ b/src/pages/app/components/HelpUs/MemberList.tsx @@ -6,10 +6,11 @@ */ import { getMembers } from "@api/crowdin" -import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { t } from '@app/locale' +import { useRequest } from '@hooks' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" +import Img from '@pages/components/Img' import { ElDivider } from "element-plus" import { defineComponent } from "vue" @@ -31,10 +32,8 @@ const _default = defineComponent(() => { target="_blank" style={idx === (arr.length - 1) ? { marginInlineEnd: 'auto' } : undefined} > - <img - src={avatarUrl} - alt={username} - title={username} + <Img + src={avatarUrl} alt={username} title={username} style={{ width: '60px', height: '60px', borderRadius: '30px' }} /> </a> diff --git a/src/pages/app/components/HelpUs/ProgressList.tsx b/src/pages/app/components/HelpUs/ProgressList.tsx index ce9eecd9c..7078c4d5d 100644 --- a/src/pages/app/components/HelpUs/ProgressList.tsx +++ b/src/pages/app/components/HelpUs/ProgressList.tsx @@ -6,14 +6,14 @@ */ import { getTranslationStatus, type TranslationStatusInfo } from "@api/crowdin" -import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { t } from '@app/locale' +import { useRequest } from '@hooks' import localeMessages from "@i18n/message/common/locale" import Flex from "@pages/components/Flex" import { ElProgress, type ProgressProps } from "element-plus" import { defineComponent, ref, type StyleValue } from "vue" -type SupportedLocale = timer.Locale | timer.TranslatingLocale +type SupportedLocale = tt4b.Locale | tt4b.TranslatingLocale const localeCrowdMap: { [locale in SupportedLocale]: string } = { en: "en", @@ -108,7 +108,7 @@ const _default = defineComponent(() => { > <Flex width={70} gap={10} fontSize={12} column align="end" color="var(--el-text-color-regular)"> <span>{`${progress}%`}</span> - <span>{localeMessages[locale as timer.Locale]?.name ?? locale}</span> + <span>{localeMessages[locale as tt4b.Locale]?.name ?? locale}</span> </Flex> </ElProgress> ))} diff --git a/src/pages/app/components/HelpUs/index.tsx b/src/pages/app/components/HelpUs/index.tsx index 2f7dfadfa..9cd0f3f35 100644 --- a/src/pages/app/components/HelpUs/index.tsx +++ b/src/pages/app/components/HelpUs/index.tsx @@ -1,12 +1,12 @@ -import { createTabAfterCurrent } from "@api/chrome/tab" -import AlertLines from '@app/components/common/AlertLines' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Pointer } from "@element-plus/icons-vue" import Box from "@pages/components/Box" +import { createTabAfterCurrent } from "@api/chrome/tab" import { CROWDIN_HOMEPAGE } from "@util/constant/url" import { ElButton, ElCard, ElScrollbar } from "element-plus" import type { FunctionalComponent, StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import AlertLines from '../common/AlertLines' +import ContentContainer from '../common/ContentContainer' import MemberList from "./MemberList" import ProgressList from "./ProgressList" diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx deleted file mode 100644 index fde155be6..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { CircleClose, Clock } from "@element-plus/icons-vue" -import { useDebounceFn, useState } from "@hooks" -import { getStyle } from "@pages/util/style" -import { range } from "@util/array" -import { Effect, ElIcon, ElInput, ElPopover, ElScrollbar, ScrollbarInstance, useLocale, useNamespace } from "element-plus" -import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" - -function computeSecond2LimitInfo(time: number): [number, number, number] { - time = time || 0 - const second = time % 60 - const totalMinutes = (time - second) / 60 - const minute = totalMinutes % 60 - const hour = (totalMinutes - minute) / 60 - return [hour, minute, second] -} - -const formatTimeVal = (val: number): string => { - return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' -} - -const TimeSpinner = defineComponent({ - props: { - max: { - type: Number, - required: true, - }, - visible: Boolean, - modelValue: { - type: Number, - required: true, - }, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const ns = useNamespace('time') - const scrollbar = ref<ScrollbarInstance>() - const scrolling = ref(false) - - const debounceChangeValue = useDebounceFn((val: number) => { - scrolling.value = false - ctx.emit('change', val) - }, 200) - - const getScrollbarElement = () => { - const el = scrollbar.value?.$el - return el?.querySelector(`.${ns.namespace.value}-scrollbar__wrap`) as HTMLElement - } - - const adjustSpinner = (value: number) => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.scrollTop = Math.max(0, value * typeItemHeight()) - } - - watch(() => props.modelValue, () => adjustSpinner(props.modelValue)) - watch(() => props.visible, () => props.visible && nextTick(() => adjustSpinner(props.modelValue))) - - const typeItemHeight = (): number => { - const listItem = scrollbar.value?.$el.querySelector('li') as HTMLLinkElement - if (listItem) { - return Number.parseFloat(getStyle(listItem, 'height')) || 0 - } - return 0 - } - - const bindScroll = () => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.addEventListener('scroll', () => { - scrolling.value = true - const scrollTop = getScrollbarElement()?.scrollTop ?? 0 - const scrollbarH = (scrollbar.value?.$el as HTMLUListElement)!.offsetHeight ?? 0 - const itemH = typeItemHeight() - const estimatedIdx = Math.round((scrollTop - (scrollbarH * 0.5 - 10) / itemH + 3) / itemH) - const value = Math.min(estimatedIdx, props.max - 1) - debounceChangeValue(value) - }, { passive: true }) - } - - onMounted(() => { - bindScroll() - adjustSpinner(props.modelValue) - }) - - return () => ( - <ElScrollbar - ref={scrollbar} - class={ns.be('spinner', 'wrapper')} - viewClass={ns.be('spinner', 'list')} - noresize - wrapStyle={{ maxHeight: 'inherit' }} - tag="ul" - > - {range(props.max).map(idx => ( - <li - onClick={() => ctx.emit('change', idx)} - class={[ - ns.be('spinner', 'item'), - ns.is('active', idx === props.modelValue), - ]} - > - {idx.toString().padStart(2, '0')} - </li> - ))} - </ElScrollbar> - ) - }, -}) - -const useTimeInput = (source: () => number) => { - const [initialHour, initialMin, initialSec] = computeSecond2LimitInfo(source?.() ?? 0) - const [hour, setHour] = useState(initialHour) - const [minute, setMinute] = useState(initialMin) - const [second, setSecond] = useState(initialSec) - - const reset = () => { - const [hour, min, sec] = computeSecond2LimitInfo(source?.() ?? 0) - setHour(hour) - setMinute(min) - setSecond(sec) - } - - watch(source, reset) - - const getTotalSecond = () => { - let time = 0 - time += (hour.value ?? 0) * 3600 - time += (minute.value ?? 0) * 60 - time += (second.value ?? 0) - return time - } - - return { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } -} - -/** - * Rewrite - * - * https://github.com/element-plus/element-plus/blob/dev/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue - */ -const TimeInput = defineComponent({ - props: { - modelValue: { - type: Number, - required: true, - }, - hourMax: Number, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const [popoverVisible, setPopoverVisible] = useState(false) - const { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } = useTimeInput(() => props.modelValue) - - const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) - - const ns = useNamespace('time') - const nsDate = useNamespace('date') - const nsInput = useNamespace('input') - - const { t: tEle } = useLocale() - - const transitionName = computed(() => popoverVisible.value ? '' : `${ns.namespace.value}-zoom-in-top`) - - const handleCancel = () => { - reset() - setPopoverVisible(false) - } - - const handleConfirm = () => { - ctx.emit('change', getTotalSecond()) - setPopoverVisible(false) - } - - const handleVisibleChange = (newVal: boolean) => { - setPopoverVisible(newVal) - !newVal && handleCancel() - } - - const handleClear = (ev: MouseEvent) => { - ctx.emit('change', 0) - ev.stopPropagation() - } - - return () => ( - <ElPopover - trigger='click' - effect={Effect.LIGHT} - visible={popoverVisible.value} - transition={`${nsDate.namespace.value}-zoom-in-top`} - popperClass={`${nsDate.namespace.value}-picker__popper`} - onUpdate:visible={handleVisibleChange} - v-slots={{ - reference: () => ( - <ElInput - class={[nsDate.b('editor'), nsDate.bm('editor', 'time')]} - prefixIcon={Clock} - modelValue={inputText.value} - inputStyle={{ cursor: 'pointer', width: '100px' }} - readonly - v-slots={{ - suffix: () => !!props.modelValue && ( - <div onClick={handleClear}> - <ElIcon class={[nsInput.e('icon'), 'clear-icon']}> - <CircleClose /> - </ElIcon> - </div> - ) - }} - /> - ) - }}> - <Transition name={transitionName.value}> - <div class={ns.b('panel')} style={{ width: '100%' }}> - <div class={[ns.be('panel', 'content'), 'has-seconds']}> - <div class={[ns.b('spinner'), 'has-seconds']}> - <TimeSpinner max={props.hourMax ?? 24} modelValue={hour.value} onChange={setHour} visible={popoverVisible.value} /> - <TimeSpinner max={60} modelValue={minute.value} onChange={setMinute} visible={popoverVisible.value} /> - <TimeSpinner max={60} modelValue={second.value} onChange={setSecond} visible={popoverVisible.value} /> - </div> - </div> - <div class={[ns.be('panel', 'footer')]}> - <button - type="button" - class={[ns.be('panel', 'btn'), 'cancel']} - onClick={handleCancel} - > - {tEle('el.datepicker.cancel')} - </button> - <button - type="button" - class={[ns.be('panel', 'btn'), 'confirm']} - onClick={handleConfirm} - > - {tEle('el.datepicker.confirm')} - </button> - </div> - </div> - </Transition> - </ElPopover> - ) - }, -}) - -export default TimeInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/context.ts b/src/pages/app/components/Limit/LimitModify/Sop/context.ts deleted file mode 100644 index 51eb12b71..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/context.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { t } from "@app/locale" -import { useProvide, useProvider } from "@hooks" -import { range } from "@util/array" -import { ElMessage } from "element-plus" -import { type Reactive, reactive, type Ref, ref, toRaw } from "vue" - -type Step = 0 | 1 | 2 - -type SopData = Required<Omit<timer.limit.Rule, 'id'>> - -type Context = { - data: Reactive<SopData> - urlMiss: Ref<boolean> -} - -const createInitial = (): SopData => ({ - name: `RULE-${new String(new Date().getTime() % 10000).padStart(4, '0')}`, - time: 3600, - weekly: 0, - cond: [], - visitTime: 0, - periods: [], - enabled: true, - weekdays: range(7), - count: 0, - weeklyCount: 0, - allowDelay: false, - locked: false, -}) - -type Options = { - onSave?: (data: SopData) => void -} - -const NAMESPACE = 'limit_sop_model' - -export const initSop = ({ onSave }: Options) => { - const step = ref<Step>(0) - const data = reactive<SopData>(createInitial()) - const urlMiss = ref(false) - - const validator: Record<Step, () => Promise<boolean>> = { - 0: async () => { - const nameVal = data.name?.trim?.() - const weekdaysVal = data.weekdays - if (!nameVal) { - ElMessage.error("Name is empty") - return false - } if (!weekdaysVal?.length) { - ElMessage.error("Effective days are empty") - return false - } - return true - }, - 1: async () => { - if (!data.cond?.length) { - ElMessage.error(t(msg => msg.limit.message.noUrl)) - urlMiss.value = true - return false - } - urlMiss.value = false - return true - }, - 2: async () => { - const { time, count, weekly, weeklyCount, visitTime, periods } = data - if (true - && !time && !count - && !weekly && !weeklyCount - && !visitTime && !periods?.length - ) { - ElMessage.error(t(msg => msg.limit.message.noRule)) - return false - } - return true - }, - } - - const reset = (rule?: timer.limit.Rule) => { - const rawRule = rule ? toRaw(rule) : createInitial() - Object.entries(rawRule).forEach(([k, v]) => (data as any)[k] = v) - step.value = 0 - } - - const handleNext = async () => { - const stepVal = step.value - const isValid = await validator[stepVal]?.() - if (isValid) { - stepVal === 2 ? onSave?.(toRaw(data)) : step.value++ - } - } - - useProvide<Context>(NAMESPACE, { data, urlMiss }) - - return { - step, reset, handleNext - } -} - -export const useSopData = () => useProvider<Context, 'data'>(NAMESPACE, 'data').data - -export const useUrlMiss = () => useProvider<Context, 'urlMiss'>(NAMESPACE, 'urlMiss').urlMiss \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/index.tsx b/src/pages/app/components/Limit/LimitModify/Sop/index.tsx deleted file mode 100644 index 6dbd7b5a9..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import DialogSop from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { ElStep, ElSteps } from "element-plus" -import { computed, defineComponent } from "vue" -import { initSop } from "./context" -import Step1 from "./Step1" -import Step2 from "./Step2" -import Step3 from "./Step3" - -export type SopInstance = { - /** - * Reset with rule or initial value - */ - reset: (rule?: timer.limit.Rule) => void -} - -type Props = { - onCancel?: NoArgCallback - onSave?: ArgCallback<MakeOptional<timer.limit.Rule, 'id'>> -} - -const _default = defineComponent<Props>(({ onSave, onCancel }, ctx) => { - const { reset, step, handleNext } = initSop({ onSave }) - const last = computed(() => step.value === 2) - const first = computed(() => step.value === 0) - ctx.expose({ reset } satisfies SopInstance) - - return () => ( - <DialogSop - last={last.value} - first={first.value} - onBack={() => step.value--} - onCancel={onCancel} - onNext={handleNext} - onFinish={handleNext} - v-slots={{ - steps: () => ( - <ElSteps space={200} finishStatus="success" active={step.value} alignCenter> - <ElStep title={t(msg => msg.limit.step.base)} /> - <ElStep title={t(msg => msg.limit.step.url)} /> - <ElStep title={t(msg => msg.limit.step.rule)} /> - </ElSteps> - ), - content: () => <> - <Step1 v-show={step.value === 0} /> - <Step2 v-show={step.value === 1} /> - <Step3 v-show={step.value === 2} /> - </> - }} - /> - ) -}, { props: ['onCancel', 'onSave'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/index.tsx b/src/pages/app/components/Limit/LimitModify/index.tsx deleted file mode 100644 index 2abe7dbe6..000000000 --- a/src/pages/app/components/Limit/LimitModify/index.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { t } from "@app/locale" -import { useSwitch } from "@hooks" -import limitService from "@service/limit-service" -import { ElDialog, ElMessage } from "element-plus" -import { computed, defineComponent, nextTick, ref, toRaw } from "vue" -import { type ModifyInstance, useLimitTable } from "../context" -import Sop, { type SopInstance } from "./Sop" - -type Mode = "create" | "modify" - -const _default = defineComponent((_, ctx) => { - const { refresh } = useLimitTable() - const [visible, open, close] = useSwitch() - const sop = ref<SopInstance>() - const mode = ref<Mode>() - const title = computed(() => mode.value === "create" ? t(msg => msg.button.create) : t(msg => msg.button.modify)) - // Cache - let modifyingItem: timer.limit.Rule | undefined = undefined - - const handleSave = async (rule: MakeOptional<timer.limit.Rule, "id">) => { - if (!rule) return - const { cond, enabled, name, time, weekly, visitTime, periods, weekdays, count, weeklyCount } = rule - let saved: timer.limit.Rule - if (mode.value === 'modify') { - if (!modifyingItem) return - saved = { - ...modifyingItem, - cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, - // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector<number>)), - } satisfies timer.limit.Rule - await limitService.update(saved) - } else { - const toCreate = { - cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, - // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector<number>)), - allowDelay: false, locked: false, - } satisfies MakeOptional<timer.limit.Rule, 'id'> - const id = await limitService.create(toCreate) - saved = { ...toCreate, id } - } - close() - ElMessage.success(t(msg => msg.operation.successMsg)) - sop.value?.reset?.() - refresh?.() - } - - ctx.expose({ - create() { - open() - mode.value = 'create' - modifyingItem = undefined - nextTick(() => sop.value?.reset()) - }, - modify(row: timer.limit.Item) { - open() - mode.value = 'modify' - modifyingItem = { ...row } - nextTick(() => sop.value?.reset?.(toRaw(row))) - }, - } satisfies ModifyInstance) - - return () => ( - <ElDialog - title={title.value} - modelValue={visible.value} - closeOnClickModal={false} - width={800} - onClose={close} - > - <Sop ref={sop} onSave={handleSave} onCancel={close} /> - </ElDialog> - ) -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/RuleContent.tsx b/src/pages/app/components/Limit/LimitTable/RuleContent.tsx deleted file mode 100644 index 0a969f2c2..000000000 --- a/src/pages/app/components/Limit/LimitTable/RuleContent.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { t } from "@app/locale" -import Flex from "@pages/components/Flex" -import { period2Str } from "@util/limit" -import { formatPeriod, MILL_PER_SECOND } from "@util/time" -import { ElTag } from "element-plus" -import { computed, defineComponent, toRef, type PropType } from "vue" - -const TIME_FORMAT = { - dayMsg: '{day}d{hour}h{minute}m{second}s', - hourMsg: '{hour}h{minute}m{second}s', - minuteMsg: '{minute}m{second}s', - secondMsg: '{second}s', -} - -const TimeCountTag = defineComponent({ - props: { - time: Number, - count: Number, - label: String, - }, - setup(props) { - const visible = computed(() => !!props.time || !!props.count) - const content = computed(() => { - const timeContent = props.time ? formatPeriod(props.time * MILL_PER_SECOND, TIME_FORMAT) : '' - const countContent = props.count ? `${props.count} ${t(msg => msg.limit.item.visits)}` : '' - return [timeContent, countContent].filter(str => !!str).join(` ${t(msg => msg.limit.item.or)} `) - }) - - return () => ( - <div v-show={visible.value}> - <ElTag size="small"> - {props.label}: {content.value} - </ElTag> - </div> - ) - }, -}) - -const RuleContent = defineComponent({ - props: { - value: Object as PropType<timer.limit.Item> - }, - setup(props) { - const row = toRef(props, 'value') - - return () => ( - <Flex column gap={4}> - <TimeCountTag - time={row.value?.time} - count={row.value?.count} - label={t(msg => msg.limit.item.daily)} - /> - <TimeCountTag - time={row.value?.weekly} - count={row.value?.weeklyCount} - label={t(msg => msg.limit.item.weekly)} - /> - {!!row.value?.visitTime && ( - <div> - <ElTag size="small" type="danger"> - {t(msg => msg.limit.item.visitTime)}: {formatPeriod(row.value?.visitTime * MILL_PER_SECOND, TIME_FORMAT)} - </ElTag> - </div> - )} - {!!row.value?.periods?.length && <> - <div> - <ElTag size="small" type="info">{t(msg => msg.limit.item.period)}</ElTag> - </div> - <Flex justify="center" gap={4} wrap="wrap"> - {row.value?.periods?.map(p => <ElTag size="small" type="info">{period2Str(p)}</ElTag>)} - </Flex> - </>} - </Flex> - ) - }, -}) - -export default RuleContent \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Waste.tsx b/src/pages/app/components/Limit/LimitTable/Waste.tsx deleted file mode 100644 index 0b1baa8f4..000000000 --- a/src/pages/app/components/Limit/LimitTable/Waste.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" -import Flex from "@pages/components/Flex" -import { meetLimit, meetTimeLimit } from "@util/limit" -import { formatPeriodCommon } from "@util/time" -import { ElTag } from "element-plus" -import { computed, defineComponent } from "vue" - -const Waste = defineComponent({ - props: { - time: Number, - waste: { - type: Number, - required: true, - }, - count: Number, - visit: Number, - delayCount: Number, - allowDelay: Boolean, - }, - setup(props) { - const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') - const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') - - return () => ( - <Flex column gap={5}> - <div> - <TooltipWrapper - trigger="hover" - usePopover={props.allowDelay && !!props.time} - placement="top" - v-slots={{ - content: () => `${t(msg => msg.limit.item.delayCount)}: ${props.delayCount ?? 0}`, - default: () => ( - <ElTag size="small" type={timeType.value}> - {formatPeriodCommon(props.waste)} - </ElTag> - ), - }} - /> - </div> - <div> - <ElTag size="small" type={visitType.value}> - {props.visit ?? 0} {t(msg => msg.limit.item.visits)} - </ElTag> - </div> - </Flex> - ) - }, -}) - -export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx b/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx deleted file mode 100644 index 3d6e7f08f..000000000 --- a/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import { t } from "@app/locale" -import { Delete, Edit } from "@element-plus/icons-vue" -import { locale } from "@i18n" -import { ElButton, ElTableColumn, type RenderRowData } from "element-plus" -import { defineComponent } from "vue" -import { verifyCanModify } from "../../common" -import { useLimitAction, useLimitTable } from "../../context" - -const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { - en: 220, - zh_CN: 200, - ja: 200, - zh_TW: 200, - pt_PT: 250, - uk: 260, - es: 240, - de: 250, - fr: 230, - ru: 240, - ar: 220, - tr: 220, - pl: 220, -} - -const _default = defineComponent<{}>(() => { - const { deleteRow } = useLimitTable() - const { modify } = useLimitAction() - - const handleModify = (row: timer.limit.Item) => verifyCanModify(row) - .then(() => modify(row)) - .catch(() => {/** Do nothing */ }) - - return () => <ElTableColumn - prop="operations" - label={t(msg => msg.button.operation)} - width={LOCALE_WIDTH[locale]} - align="center" - fixed="right" - v-slots={({ row }: RenderRowData<timer.limit.Item>) => <> - <ElButton type="danger" size="small" icon={Delete} onClick={() => deleteRow(row)}> - {t(msg => msg.button.delete)} - </ElButton> - <ElButton type="primary" size="small" icon={Edit} onClick={() => handleModify(row)}> - {t(msg => msg.button.modify)} - </ElButton> - </>} - /> -}) - -export default _default diff --git a/src/pages/app/components/Limit/LimitTable/index.tsx b/src/pages/app/components/Limit/LimitTable/index.tsx deleted file mode 100644 index ea7c6401a..000000000 --- a/src/pages/app/components/Limit/LimitTable/index.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import ColumnHeader from "@app/components/common/ColumnHeader" -import { t } from "@app/locale" -import { useLocalStorage, useRequest, useState } from "@hooks" -import weekHelper from "@service/components/week-helper" -import { isEffective } from "@util/limit" -import { ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort } from "element-plus" -import { defineComponent, watch } from "vue" -import { useLimitTable } from "../context" -import LimitOperationColumn from "./column/LimitOperationColumn" -import RuleContent from "./RuleContent" -import Waste from "./Waste" -import Weekday from "./Weekday" - -export type LimitSortProp = keyof Pick<timer.limit.Item, 'name' | 'weekdays' | 'waste' | 'weeklyWaste'> - -const DEFAULT_SORT_COL = 'waste' - -const sortMethodByNumVal = (key: keyof timer.limit.Item & 'waste' | 'weeklyWaste'): (a: timer.limit.Item, b: timer.limit.Item) => number => { - return ({ [key]: a }: timer.limit.Item, { [key]: b }: timer.limit.Item) => (a ?? 0) - (b ?? 0) -} - -const sortByEffectiveDays = ({ weekdays: a }: timer.limit.Item, { weekdays: b }: timer.limit.Item) => (a?.length ?? 0) - (b?.length ?? 0) - -const _default = defineComponent(() => { - const { data: weekStartName } = useRequest(async () => { - const offset = await weekHelper.getRealWeekStart() - const name = t(msg => msg.calendar.weekDays)?.split('|')?.[offset] - return name || 'NaN' - }) - - const { - list, table, - changeEnabled, changeDelay, changeLocked - } = useLimitTable() - - const [cachedSort, setCachedSort] = useLocalStorage<Sort>( - '__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' } - ) - - const [sort, setSort] = useState(cachedSort) - watch(sort, () => setCachedSort(sort.value)) - - return () => ( - <ElTable - ref={table} - border fit highlightCurrentRow - style={{ width: "100%" }} - height="100%" - data={list.value} - defaultSort={sort.value} - onSort-change={(val: Sort) => setSort({ prop: val?.prop, order: val?.order })} - > - <ElTableColumn type="selection" align="center" fixed="left" /> - <ElTableColumn - prop='name' - label={t(msg => msg.limit.item.name)} - minWidth={120} - align="center" - formatter={({ name }: timer.limit.Item) => name || '-'} - fixed - sortable - sortBy={(row: timer.limit.Item) => row.name} - /> - <ElTableColumn - label={t(msg => msg.limit.item.condition)} - minWidth={180} - align="center" - formatter={({ cond }: timer.limit.Item) => <>{cond?.map?.(c => <span style={{ display: "block" }}>{c}</span>) || ''}</>} - /> - <ElTableColumn - label={t(msg => msg.limit.item.detail)} - minWidth={200} - align="center" - > - {({ row }: RenderRowData<timer.limit.Item>) => <RuleContent value={row} />} - </ElTableColumn> - <ElTableColumn - prop='effectiveDays' - label={t(msg => msg.limit.item.effectiveDay)} - minWidth={170} - align="center" - sortable - sortMethod={sortByEffectiveDays} - > - {({ row: { weekdays } }: RenderRowData<timer.limit.Item>) => <Weekday value={weekdays} />} - </ElTableColumn> - <ElTableColumn - prop={DEFAULT_SORT_COL} - sortable - sortMethod={sortMethodByNumVal('waste')} - label={t(msg => msg.calendar.range.today)} - minWidth={90} - align="center" - > - {({ row }: RenderRowData<timer.limit.Item>) => isEffective(row.weekdays) ? ( - <Waste - waste={row.waste} - time={row.time} - count={row.count} - visit={row.visit} - allowDelay={row.allowDelay} - delayCount={row.delayCount} - /> - ) : ( - <ElTag type="info" size="small"> - {t(msg => msg.limit.item.notEffective)} - </ElTag> - )} - </ElTableColumn> - <ElTableColumn - prop='weeklyWaste' - minWidth={110} - align="center" - sortable - sortMethod={sortMethodByNumVal('weeklyWaste')} - v-slots={{ - header: () => ( - <ColumnHeader - label={t(msg => msg.calendar.range.thisWeek)} - tooltipContent={t(msg => msg.limit.item.weekStartInfo, { weekStart: weekStartName.value })} - /> - ), - default: ({ row: { - weeklyWaste, weekly, - weeklyVisit, weeklyCount, - weeklyDelayCount, allowDelay, - } }: RenderRowData<timer.limit.Item>) => ( - <Waste - time={weekly} - waste={weeklyWaste} - count={weeklyCount} - visit={weeklyVisit} - allowDelay={allowDelay} - delayCount={weeklyDelayCount} - /> - ), - }} - /> - <ElTableColumn label={t(msg => msg.button.configuration)}> - <ElTableColumn - label={t(msg => msg.limit.item.enabled)} - minWidth={80} - align="center" - fixed="right" - > - {({ row }: RenderRowData<timer.limit.Item>) => ( - <ElSwitch size="small" modelValue={row.enabled} onChange={v => changeEnabled(row, !!v)} /> - )} - </ElTableColumn> - <ElTableColumn - label={t(msg => msg.limit.item.delayAllowed)} - minWidth={80} - align="center" - fixed="right" - > - {({ row }: RenderRowData<timer.limit.Item>) => ( - <ElSwitch size="small" modelValue={row.allowDelay} onChange={v => changeDelay(row, !!v)} /> - )} - </ElTableColumn> - <ElTableColumn - label={t(msg => msg.limit.item.locked)} - minWidth={80} - align="center" - fixed="right" - > - {({ row }: RenderRowData<timer.limit.Item>) => ( - <ElSwitch size="small" modelValue={row.locked} onChange={v => changeLocked(row, !!v)} /> - )} - </ElTableColumn> - </ElTableColumn> - <LimitOperationColumn /> - </ElTable> - ) -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/common.ts b/src/pages/app/components/Limit/common.ts index 791e2263c..230d7ef0a 100644 --- a/src/pages/app/components/Limit/common.ts +++ b/src/pages/app/components/Limit/common.ts @@ -1,21 +1,13 @@ -import { judgeVerificationRequired, processVerification } from "@app/util/limit" -import optionHolder from "@service/components/option-holder" +export function cleanCond(origin: string): string +export function cleanCond(origin: undefined): undefined +export function cleanCond(origin: string | undefined): string | undefined { + if (!origin) return undefined -const batchJudge = async (items: timer.limit.Item[]): Promise<boolean> => { - if (!items?.length) return false - for (const item of items) { - if (!item) continue - const needVerify = await judgeVerificationRequired(item) - if (needVerify) return true + const startIdx = origin?.indexOf('//') + const endIdx = origin?.indexOf('?') + let res = origin.substring(startIdx === -1 ? 0 : startIdx + 2, endIdx === -1 ? undefined : endIdx) + while (res.endsWith('/')) { + res = res.substring(0, res.length - 1) } - return false -} - -export const verifyCanModify = async (...items: timer.limit.Item[]) => { - const needVerify = await batchJudge(items) - if (!needVerify) return - - // Open delay for limited rules, so verification is required - const option = await optionHolder.get() - await processVerification(option) + return res || undefined } \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitFilter.tsx b/src/pages/app/components/Limit/components/Filter.tsx similarity index 77% rename from src/pages/app/components/Limit/LimitFilter.tsx rename to src/pages/app/components/Limit/components/Filter.tsx index f70896544..33fc1fdb8 100644 --- a/src/pages/app/components/Limit/LimitFilter.tsx +++ b/src/pages/app/components/Limit/components/Filter.tsx @@ -6,24 +6,25 @@ */ import { createTabAfterCurrent } from "@api/chrome/tab" +import DropdownButton, { type DropdownButtonItem } from '@app/components/common/DropdownButton' import ButtonFilterItem from "@app/components/common/filter/ButtonFilterItem" import InputFilterItem from "@app/components/common/filter/InputFilterItem" -import SwitchFilterItem from "@app/components/common/filter/SwitchFilterItem" -import { t } from "@app/locale" -import { OPTION_ROUTE } from "@app/router/constants" +import SwitchFilterItem from '@app/components/common/filter/SwitchFilterItem' +import { t } from '@app/locale' +import { OPTION_ROUTE } from '@app/router/constants' import { Delete, Open, Operation, Plus, SetUp, TurnOff, WarningFilled } from "@element-plus/icons-vue" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { getAppPageUrl } from "@util/constant/url" import { ElIcon, ElText, ElTooltip } from 'element-plus' -import { defineComponent, ref, Ref, watch } from "vue" -import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" -import { useLimitAction, useLimitBatch, useLimitFilter } from "./context" +import { computed, defineComponent, ref, type Ref, watch } from "vue" +import { useLimitAction, useLimitBatch, useLimitFilter } from "../context" const optionPageUrl = getAppPageUrl(OPTION_ROUTE, { i: 'limit' }) type BatchOpt = 'delete' | 'enable' | 'disable' -const useCreateTip = (empty: Ref<boolean>) => { +const useCreateTip = (empty: Ref<boolean>, isXs: Ref<boolean>) => { const tipVisible = ref(false) let initialized = false watch(empty, emptyVal => { @@ -33,12 +34,14 @@ const useCreateTip = (empty: Ref<boolean>) => { setTimeout(closeTip, 10000) }) const closeTip = () => tipVisible.value = false - return { tipVisible, closeTip } + const finalVisible = computed(() => !isXs.value && tipVisible.value) + return { tipVisible: finalVisible, closeTip } } const _default = defineComponent(() => { const { create, test, empty } = useLimitAction() - const { tipVisible, closeTip } = useCreateTip(empty) + const isXs = useXsState() + const { tipVisible, closeTip } = useCreateTip(empty, isXs) const filter = useLimitFilter() const { batchDelete, batchDisable, batchEnable } = useLimitBatch() @@ -75,20 +78,22 @@ const _default = defineComponent(() => { onSearch={val => filter.url = val} /> <SwitchFilterItem - historyName="onlyEnabled" - label={t(msg => msg.limit.filterDisabled)} - defaultValue={filter.onlyEnabled} - onChange={val => filter.onlyEnabled = val} + v-show={!isXs.value} + historyName="effective" + label={t(msg => msg.limit.onlyEffective)} + defaultValue={filter.effective} + onChange={val => filter.effective = val} /> </Flex> - <Flex gap={10}> - <DropdownButton items={batchItems} /> + <Flex gap={10} align='center'> + <DropdownButton v-show={!isXs.value} items={batchItems} /> <ButtonFilterItem text={msg => msg.limit.button.test} icon={Operation} onClick={test} /> <ButtonFilterItem + v-show={!isXs.value} text={msg => msg.base.option} icon={SetUp} onClick={() => createTabAfterCurrent(optionPageUrl)} diff --git a/src/pages/app/components/Limit/components/List/Card.tsx b/src/pages/app/components/Limit/components/List/Card.tsx new file mode 100644 index 000000000..b83c63240 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/Card.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { useLimitAction } from "@app/components/Limit/context" +import { t } from '@app/locale' +import { Delete, EditPen } from "@element-plus/icons-vue" +import { css } from '@emotion/css' +import Flex from "@pages/components/Flex" +import { ElButton, ElCard, ElDivider, ElTag, type TagProps, useNamespace } from "element-plus" +import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" +import Rule from "./Rule" + +type Props = { + value: tt4b.limit.Item +} + +const CARD_PADDING = 10 + +const useStyle = () => { + const cardNs = useNamespace('card') + const cardCls = css` + .${cardNs.e('body')} { + padding: ${CARD_PADDING}px; + } + ` + return cardCls +} + +const ALL_WEEKDAYS = t(msg => msg.calendar.weekDays).split('|') + +const Divider: FunctionalComponent<{}> = () => { + const marginInline = `${-CARD_PADDING}px` + const width = `calc(100% + ${CARD_PADDING * 2}px)` + return <ElDivider style={{ marginBlock: '6px', marginInline, width } satisfies StyleValue} /> +} + +const EffectiveDays: FunctionalComponent<{ weekdays?: number[] }> = ({ weekdays = [] }) => { + const weekdayNum = weekdays.length + const first = weekdays[0] + let text: string = '' + let type: TagProps['type'] | undefined = undefined + if (!weekdayNum || weekdayNum === 7) { + text = t(msg => msg.calendar.range.everyday) + type = 'success' + } else if (first !== undefined && weekdayNum === 1) { + text = ALL_WEEKDAYS[first] ?? '' + } else if (first !== undefined) { + text = `${ALL_WEEKDAYS[first] ?? ''}...${weekdayNum}` + } + + return <ElTag size="small" type={type}>{text}</ElTag> +} + +const _default = defineComponent<Props>(props => { + const { modify, remove } = useLimitAction() + const clz = useStyle() + + return () => ( + <ElCard class={clz} shadow="hover"> + <Flex column gap={8}> + <Flex justify="space-between" align="center"> + <Flex align="center" gap={8}> + <span style={{ fontWeight: 'bold' }}> + {props.value.name ?? 'Unnamed'} + </span> + <EffectiveDays weekdays={props.value.weekdays} /> + </Flex> + <ElButton + size="small" + type='danger' + text + icon={Delete} + onClick={() => remove(props.value)} + /> + </Flex> + <Divider /> + {/* Sites */} + <Flex gap={2}> + {props.value.cond.map((c, idx) => <ElTag key={idx} type='info'>{c}</ElTag>)} + </Flex> + <Divider /> + {/** Content */} + <Rule value={props.value} /> + <Divider /> + {/* Footer Button */} + <Flex justify='end'> + <ElButton text icon={EditPen} onClick={() => modify(props.value)} size='small'> + {t(msg => msg.button.modify)} + </ElButton> + </Flex> + </Flex> + </ElCard> + ) +}, { props: ['value'] }) + +export default _default diff --git a/src/pages/app/components/Limit/components/List/Rule.tsx b/src/pages/app/components/Limit/components/List/Rule.tsx new file mode 100644 index 000000000..c54f34b3a --- /dev/null +++ b/src/pages/app/components/Limit/components/List/Rule.tsx @@ -0,0 +1,112 @@ +import { useDelayDuration } from '@app/components/Limit/context' +import { t } from '@app/locale' +import Flex from '@pages/components/Flex' +import { isEffective, meetLimit, meetTimeLimit, period2Str } from '@util/limit' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' +import { defineComponent, type FunctionalComponent, toRefs } from 'vue' +import { DAILY_WEEKLY_TAG_TYPE, PERIOD_TAG_TYPE, VISIT_TAG_TYPE } from '../style' + +type Props = { + value: tt4b.limit.Item +} + +const TimeCountPair: FunctionalComponent<{ time?: number, count?: number }> = ({ time, count }) => { + if (!time && !count) return null + return ( + <Flex gap={2} wrap> + {!!time && ( + <ElTag size="small" type={DAILY_WEEKLY_TAG_TYPE}>{formatPeriodCommon(time * MILL_PER_SECOND, true)}</ElTag> + )} + {!!count && ( + <ElTag size="small" type={DAILY_WEEKLY_TAG_TYPE}>{t(msg => msg.shared.limit.visits, { n: count })}</ElTag> + )} + </Flex> + ) +} + +type WastePairProps = { + time: Parameters<typeof meetTimeLimit>[0] + delay: Parameters<typeof meetTimeLimit>[1] + count?: number + visit?: number +} + +const WastePair: FunctionalComponent<WastePairProps> = props => { + const timeType = meetTimeLimit(props.time, props.delay) ? 'danger' : 'info' + const visitType = meetLimit(props.count, props.visit) ? 'danger' : 'info' + + return ( + <Flex gap={2}> + <ElTag size="small" type={timeType}> + {formatPeriodCommon(props.time.wasted)} + </ElTag> + <ElTag size="small" type={visitType}> + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} + </ElTag> + </Flex> + ) +} + +const Rule = defineComponent<Props>(({ value }) => { + const { + time, count, waste, visit, + weekly, weeklyCount, weeklyWaste, weeklyVisit, + visitTime, periods, + weekdays, + allowDelay, delayCount, weeklyDelayCount, + } = toRefs(value) + + const delayDuration = useDelayDuration() + + return () => <> + <ElDescriptions border size='small' column={1} labelWidth={130}> + <ElDescriptionsItem label={t(msg => msg.shared.limit.daily)} v-show={time?.value || count?.value}> + <TimeCountPair time={time?.value} count={count?.value} /> + </ElDescriptionsItem> + <ElDescriptionsItem label={t(msg => msg.shared.limit.weekly)} v-show={weekly?.value || weeklyCount?.value}> + <TimeCountPair time={weekly?.value} count={weeklyCount?.value} /> + </ElDescriptionsItem> + {!!visitTime?.value && ( + <ElDescriptionsItem label={t(msg => msg.limit.item.visitTime)}> + <ElTag size="small" type={VISIT_TAG_TYPE}>{formatPeriodCommon(visitTime.value * MILL_PER_SECOND, true)}</ElTag> + </ElDescriptionsItem> + )} + {!!periods?.value?.length && ( + <ElDescriptionsItem label={t(msg => msg.shared.limit.period)}> + <Flex gap={2} wrap> + {periods.value.map((p, idx) => ( + <ElTag key={idx} size="small" type={PERIOD_TAG_TYPE}>{period2Str(p)}</ElTag> + ))} + </Flex> + </ElDescriptionsItem> + )} + </ElDescriptions> + <ElDescriptions border size='small' column={1} labelWidth={130}> + <ElDescriptionsItem label={t(msg => msg.calendar.range.today)}> + {isEffective(weekdays?.value) ? ( + <WastePair + time={{ wasted: waste.value, maxLimit: (time?.value ?? 0) * MILL_PER_SECOND }} + delay={{ count: delayCount.value, duration: delayDuration.value, allow: !!allowDelay.value }} + count={count?.value ?? 0} + visit={visit.value} + /> + ) : ( + <ElTag type="info" size="small"> + {t(msg => msg.limit.item.notEffective)} + </ElTag> + )} + </ElDescriptionsItem> + <ElDescriptionsItem label={t(msg => msg.calendar.range.thisWeek)}> + <WastePair + time={{ wasted: weeklyWaste.value, maxLimit: (weekly?.value ?? 0) * MILL_PER_SECOND }} + delay={{ count: weeklyDelayCount.value, duration: delayDuration.value, allow: !!allowDelay.value }} + count={weeklyCount?.value ?? 0} + visit={weeklyVisit.value} + /> + </ElDescriptionsItem> + </ElDescriptions> + </> +}, { props: ['value'] }) + +export default Rule \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/List/index.tsx b/src/pages/app/components/Limit/components/List/index.tsx new file mode 100644 index 000000000..3effb2458 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/index.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { useLimitData } from "@app/components/Limit/context" +import Flex from '@pages/components/Flex' +import { ElScrollbar } from 'element-plus' +import { defineComponent } from "vue" +import Card from "./Card" + +const _default = defineComponent(() => { + const { list } = useLimitData() + + return () => ( + <ElScrollbar> + <Flex + column padding={8} gap={15} height="100%" + style={{ overflow: 'auto' }} + > + {list.value.map(row => <Card key={row.id} value={row} />)} + </Flex> + </ElScrollbar> + ) +}) + +export default _default diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx b/src/pages/app/components/Limit/components/Modify/Step1.tsx similarity index 79% rename from src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx rename to src/pages/app/components/Limit/components/Modify/Step1.tsx index c27a9713e..3ad0585c1 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step1.tsx @@ -1,15 +1,18 @@ -import { t } from "@app/locale" +import { useDialogSop } from '@app/components/common/DialogSop/context' +import type { ModifyForm } from '@app/components/Limit/types' +import { t } from '@app/locale' +import { useXsState } from '@hooks' import { ElCol, ElForm, ElFormItem, ElInput, ElRow, ElSelect, ElSwitch } from "element-plus" import { defineComponent } from "vue" -import { useSopData } from "./context" const _default = defineComponent(() => { - const data = useSopData() + const { form: data } = useDialogSop<ModifyForm>() + const isXs = useXsState() return () => ( <ElForm labelWidth={130} labelPosition="left"> <ElRow gutter={30}> - <ElCol span={12}> + <ElCol span={isXs.value ? 24 : 12}> <ElFormItem label={t(msg => msg.limit.item.name)} required> <ElInput modelValue={data.name} onInput={val => data.name = val} @@ -17,7 +20,7 @@ const _default = defineComponent(() => { /> </ElFormItem> </ElCol> - <ElCol span={12}> + <ElCol span={isXs.value ? 24 : 12}> <ElFormItem label={t(msg => msg.limit.item.enabled)} required> <ElSwitch modelValue={data.enabled} onChange={v => data.enabled = !!v} /> </ElFormItem> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx b/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx similarity index 89% rename from src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx rename to src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx index 18f05704f..930615ce5 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx @@ -1,9 +1,9 @@ import { listTabs } from '@api/chrome/tab' +import { listSites } from '@api/sw/site' +import { cleanCond } from '@app/components/Limit/common' import { t } from '@app/locale' import { useDebounceState, useRequest } from '@hooks' import Flex from '@pages/components/Flex' -import { selectAllSites } from '@service/site-service' -import { cleanCond } from '@util/limit' import { extractHostname, isBrowserUrl } from '@util/pattern' import { ElMessage, ElSelectV2, ElText, useNamespace, type SelectV2Instance } from 'element-plus' import { computed, defineComponent, onMounted, ref, type StyleValue } from 'vue' @@ -22,13 +22,13 @@ const fetchAllHosts = async () => { hostSet.add(host) } }) - const sites = await selectAllSites({ types: ['normal', 'virtual'] }) + const sites = await listSites({ types: ['normal', 'virtual'] }) sites.forEach(({ host }) => hostSet.add(host)) return Array.from(hostSet) } const useUrlSelect = ({ onAdd }: Props) => { - const { data: allHosts } = useRequest(fetchAllHosts) + const { data: allHosts } = useRequest(fetchAllHosts, { defaultValue: [] }) const [input, onFilter] = useDebounceState('', 50) const inputUrl = computed(() => { const clean = cleanCond(input.value) @@ -43,9 +43,9 @@ const useUrlSelect = ({ onAdd }: Props) => { if (full) { result.push(full) domain && result.push(domain) - allHosts.value?.forEach(host => host.includes(full) && host !== full && host !== domain && result.push(host)) + allHosts.value.forEach(host => host.includes(full) && host !== full && host !== domain && result.push(host)) } else { - allHosts.value?.forEach(h => result.push(h)) + allHosts.value.forEach(h => result.push(h)) } return result.map(value => ({ value, label: value })) }) diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx b/src/pages/app/components/Limit/components/Modify/Step2/index.tsx similarity index 86% rename from src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx rename to src/pages/app/components/Limit/components/Modify/Step2/index.tsx index 7a47f7f4b..f93c47f4a 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step2/index.tsx @@ -5,23 +5,18 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { useDialogSop } from '@app/components/common/DialogSop/context' +import type { ModifyForm } from '@app/components/Limit/types' +import { t } from '@app/locale' import { Delete, WarnTriangleFilled } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import { EXCLUDING_PREFIX } from '@util/constant/remain-host' -import { - ElDivider, ElIcon, - ElLink, - ElScrollbar, ElText, - type ScrollbarInstance -} from "element-plus" +import { ElDivider, ElIcon, ElLink, ElScrollbar, ElText, type ScrollbarInstance } from "element-plus" import { defineComponent, ref } from "vue" -import { useSopData, useUrlMiss } from "../context" import SiteInput from './SiteInput' const _default = defineComponent(() => { - const data = useSopData() - const urlMiss = useUrlMiss() + const { form: data } = useDialogSop<ModifyForm>() const scrollbar = ref<ScrollbarInstance>() const handleAdd = (url: string) => { @@ -29,7 +24,7 @@ const _default = defineComponent(() => { if (urls.includes(url)) return 'URL added already' urls.unshift(url) - urlMiss.value = false + data.urlMiss = false scrollbar.value?.scrollTo(0) } diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx similarity index 67% rename from src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx rename to src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx index 6c90e74df..742a75548 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { Check, Close, Plus } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch, useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { dateMinute2Idx, period2Str } from "@util/limit" import { MILL_PER_HOUR } from "@util/time" @@ -28,7 +28,7 @@ const range2Period = (range: [Date, Date]): [number, number] => { return [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)] } -const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Period) => { +const insertPeriods = (periods: tt4b.limit.Period[], toInsert: tt4b.limit.Period) => { if (!toInsert || !periods) return let len = periods.length if (!len) { @@ -37,16 +37,17 @@ const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Peri } for (let i = 0; i < len; i++) { const pre = periods[i] + if (!pre) continue const next = periods[i + 1] if (checkImpact(pre, toInsert)) { mergePeriod(pre, toInsert) - if (checkImpact(pre, next)) { + if (next && checkImpact(pre, next)) { mergePeriod(pre, next) periods.splice(i + 1, 1) } return } - if (checkImpact(toInsert, next)) { + if (next && checkImpact(toInsert, next)) { mergePeriod(next, toInsert) return } @@ -56,13 +57,13 @@ const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Peri periods.sort((a, b) => a[0] - b[0]) } -const mergePeriod = (target: timer.limit.Period, toMerge: timer.limit.Period) => { +const mergePeriod = (target: tt4b.limit.Period, toMerge: tt4b.limit.Period) => { if (!target || !toMerge) return target[0] = Math.min(target[0], toMerge[0]) target[1] = Math.max(target[1], toMerge[1]) } -const checkImpact = (p1: timer.limit.Period, p2: timer.limit.Period): boolean => { +const checkImpact = (p1: tt4b.limit.Period, p2: tt4b.limit.Period): boolean => { if (!p1 || !p2) return false const [s1, e1] = p1 const [s2, e2] = p2 @@ -71,6 +72,13 @@ const checkImpact = (p1: timer.limit.Period, p2: timer.limit.Period): boolean => const rangeInitial = (): [Date, Date] => { const now = new Date() + if (now.getHours() >= 23) { + const start = new Date(now) + start.setHours(23, 0, 0, 0) + const end = new Date(now) + end.setHours(23, 59, 0, 0) + return [start, end] + } return [now, new Date(now.getTime() + MILL_PER_HOUR)] } @@ -92,9 +100,10 @@ const usePickerStyle = () => { ` } -const PeriodInput = defineComponent<ModelValue<timer.limit.Period[]>>(props => { +const PeriodInput = defineComponent<ModelValue<tt4b.limit.Period[]>>(props => { const [editing, openEditing, closeEditing] = useSwitch(false) const [editingRange, setEditingRange] = useState(rangeInitial()) + const isXs = useXsState() const handleEdit = () => { openEditing() @@ -103,32 +112,34 @@ const PeriodInput = defineComponent<ModelValue<timer.limit.Period[]>>(props => { const handleSave = () => { const val = range2Period(editingRange.value) - const oldPeriods = props.modelValue?.map(p => ([p?.[0], p?.[1]] satisfies Vector<number>)) || [] + const oldPeriods = props.modelValue.map(p => [p[0], p[1]] satisfies tt4b.limit.Period) insertPeriods(oldPeriods, val) props.onChange?.(oldPeriods) closeEditing() } const handleDelete = (idx: number) => { - const newPeriods = props.modelValue?.filter((_, i) => i !== idx) - ?.map(p => ([p?.[0], p?.[1]] satisfies Vector<number>)) || [] + const newPeriods = props.modelValue.filter((_, i) => i !== idx) + .map(p => [p[0], p[1]] satisfies tt4b.limit.Period) props.onChange?.(newPeriods) } const pickerCls = usePickerStyle() return () => ( - <Flex gap={5}> - {props.modelValue?.map((p, idx) => - <ElTag - size="large" - closable - onClose={() => handleDelete(idx)} - > - {period2Str(p)} - </ElTag> - )} - <div v-show={editing.value}> + <Flex gap={5} column={isXs.value}> + <Flex gap={5} wrap> + {props.modelValue?.map((p, idx) => + <ElTag + size={isXs.value ? 'small' : 'large'} + closable + onClose={() => handleDelete(idx)} + > + {period2Str(p)} + </ElTag> + )} + </Flex> + <Flex v-show={editing.value} wrap={false}> <ElTimePicker class={pickerCls} modelValue={editingRange.value} @@ -148,15 +159,18 @@ const PeriodInput = defineComponent<ModelValue<timer.limit.Period[]>>(props => { onClick={handleSave} style={{ ...BUTTON_STYLE, marginInlineStart: 0 } satisfies StyleValue} /> + </Flex> + <div> + <ElButton + v-show={!editing.value} + icon={Plus} + onClick={handleEdit} + style={BUTTON_STYLE} + size={isXs.value ? 'small' : undefined} + > + {t(msg => msg.button.create)} + </ElButton> </div> - <ElButton - v-show={!editing.value} - icon={Plus} - onClick={handleEdit} - style={BUTTON_STYLE} - > - {t(msg => msg.button.create)} - </ElButton> </Flex> ) }, { props: ['modelValue', 'onChange'] }) diff --git a/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx new file mode 100644 index 000000000..ac7e56715 --- /dev/null +++ b/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx @@ -0,0 +1,247 @@ +import { CircleClose, Clock } from "@element-plus/icons-vue" +import { useDebounceFn, useState, useXsState } from '@hooks' +import { getStyle } from '@pages/util/style' +import { range } from "@util/array" +import { + Effect, ElIcon, ElInput, ElPopover, ElScrollbar, type ScrollbarInstance, useLocale, useNamespace, +} from "element-plus" +import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" + +function computeSecond2LimitInfo(time: number): [number, number, number] { + time = time || 0 + const second = time % 60 + const totalMinutes = (time - second) / 60 + const minute = totalMinutes % 60 + const hour = (totalMinutes - minute) / 60 + return [hour, minute, second] +} + +const formatTimeVal = (val: number): string => { + return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' +} + +type TimeSpinnerProps = { + max: number + modelValue: number + visible: boolean + onChange?: (val: number) => void +} + +const TimeSpinner = defineComponent<TimeSpinnerProps>(props => { + const ns = useNamespace('time') + const scrollbar = ref<ScrollbarInstance>() + const scrolling = ref(false) + + const debounceChangeValue = useDebounceFn((val: number) => { + scrolling.value = false + props.onChange?.(val) + }, 200) + + const getScrollbarElement = () => { + const el = scrollbar.value?.$el + return el?.querySelector(`.${ns.namespace.value}-scrollbar__wrap`) as HTMLElement + } + + const adjustSpinner = (value: number) => { + let scrollbarEl = getScrollbarElement() + if (!scrollbarEl) return + + scrollbarEl.scrollTop = Math.max(0, value * typeItemHeight()) + } + + watch(() => props.modelValue, () => adjustSpinner(props.modelValue)) + watch(() => props.visible, () => props.visible && nextTick(() => adjustSpinner(props.modelValue))) + + const typeItemHeight = (): number => { + const listItem = scrollbar.value?.$el.querySelector('li') as HTMLLinkElement + if (listItem) { + return Number.parseFloat(getStyle(listItem, 'height')) || 0 + } + return 0 + } + + const bindScroll = () => { + let scrollbarEl = getScrollbarElement() + if (!scrollbarEl) return + + scrollbarEl.addEventListener('scroll', () => { + scrolling.value = true + const scrollTop = getScrollbarElement()?.scrollTop ?? 0 + const scrollbarH = (scrollbar.value?.$el as HTMLUListElement)!.offsetHeight ?? 0 + const itemH = typeItemHeight() + const estimatedIdx = Math.round((scrollTop - (scrollbarH * 0.5 - 10) / itemH + 3) / itemH) + const value = Math.min(estimatedIdx, props.max - 1) + debounceChangeValue(value) + }, { passive: true }) + } + + onMounted(() => { + bindScroll() + adjustSpinner(props.modelValue) + }) + + return () => ( + <ElScrollbar + ref={scrollbar} + class={ns.be('spinner', 'wrapper')} + viewClass={ns.be('spinner', 'list')} + noresize + wrapStyle={{ maxHeight: 'inherit' }} + tag="ul" + > + {range(props.max).map(idx => ( + <li + onClick={() => props.onChange?.(idx)} + class={[ + ns.be('spinner', 'item'), + ns.is('active', idx === props.modelValue), + ]} + > + {idx.toString().padStart(2, '0')} + </li> + ))} + </ElScrollbar> + ) +}, { props: ['max', 'modelValue', 'visible', 'onChange'] }) + +const useTimeInput = (source: () => number) => { + const [initialHour, initialMin, initialSec] = computeSecond2LimitInfo(source?.() ?? 0) + const [hour, setHour] = useState(initialHour) + const [minute, setMinute] = useState(initialMin) + const [second, setSecond] = useState(initialSec) + + const reset = () => { + const [hour, min, sec] = computeSecond2LimitInfo(source?.() ?? 0) + setHour(hour) + setMinute(min) + setSecond(sec) + } + + watch(source, reset) + + const getTotalSecond = () => { + let time = 0 + time += (hour.value ?? 0) * 3600 + time += (minute.value ?? 0) * 60 + time += (second.value ?? 0) + return time + } + + return { + hour, minute, second, + setHour, setMinute, setSecond, + reset, getTotalSecond, + } +} + +type TimeInputProps = { + modelValue: number + hourMax?: number + onChange?: (val: number) => void +} + +/** + * Rewrite + */ +const TimeInput = defineComponent<TimeInputProps>(props => { + const [popoverVisible, setPopoverVisible] = useState(false) + const { + hour, minute, second, + setHour, setMinute, setSecond, + reset, getTotalSecond, + } = useTimeInput(() => props.modelValue) + + const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) + + const ns = useNamespace('time') + const nsDate = useNamespace('date') + const nsInput = useNamespace('input') + + const { t: tEle } = useLocale() + + const transitionName = computed(() => popoverVisible.value ? '' : `${ns.namespace.value}-zoom-in-top`) + + const handleCancel = () => { + reset() + setPopoverVisible(false) + } + + const handleConfirm = () => { + props.onChange?.(getTotalSecond()) + setPopoverVisible(false) + } + + const handleVisibleChange = (newVal: boolean) => { + setPopoverVisible(newVal) + !newVal && handleCancel() + } + + const handleClear = (ev: MouseEvent) => { + props.onChange?.(0) + ev.stopPropagation() + } + + const isXs = useXsState() + + return () => ( + <ElPopover + trigger='click' + effect={Effect.LIGHT} + visible={popoverVisible.value} + transition={`${nsDate.namespace.value}-zoom-in-top`} + popperClass={`${nsDate.namespace.value}-picker__popper`} + onUpdate:visible={handleVisibleChange} + v-slots={{ + reference: () => ( + <ElInput + class={[nsDate.b('editor'), nsDate.bm('editor', 'time')]} + prefixIcon={Clock} + modelValue={inputText.value} + inputStyle={{ cursor: 'pointer' }} + style={{ '--el-date-editor-width': '170px' }} + size={isXs.value ? 'small' : undefined} + readonly + v-slots={{ + suffix: () => !!props.modelValue && ( + <div onClick={handleClear}> + <ElIcon class={[nsInput.e('icon'), 'clear-icon']}> + <CircleClose /> + </ElIcon> + </div> + ) + }} + /> + ) + }}> + <Transition name={transitionName.value}> + <div class={ns.b('panel')} style={{ width: '100%' }}> + <div class={[ns.be('panel', 'content'), 'has-seconds']}> + <div class={[ns.b('spinner'), 'has-seconds']}> + <TimeSpinner max={props.hourMax ?? 24} modelValue={hour.value} onChange={setHour} visible={popoverVisible.value} /> + <TimeSpinner max={60} modelValue={minute.value} onChange={setMinute} visible={popoverVisible.value} /> + <TimeSpinner max={60} modelValue={second.value} onChange={setSecond} visible={popoverVisible.value} /> + </div> + </div> + <div class={[ns.be('panel', 'footer')]}> + <button + type="button" + class={[ns.be('panel', 'btn'), 'cancel']} + onClick={handleCancel} + > + {tEle('el.datepicker.cancel')} + </button> + <button + type="button" + class={[ns.be('panel', 'btn'), 'confirm']} + onClick={handleConfirm} + > + {tEle('el.datepicker.confirm')} + </button> + </div> + </div> + </Transition> + </ElPopover> + ) +}, { props: ['hourMax', 'modelValue', 'onChange'] }) + +export default TimeInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx similarity index 50% rename from src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx rename to src/pages/app/components/Limit/components/Modify/Step3/index.tsx index 25acb2b8f..b288cea1c 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx @@ -5,53 +5,58 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { useDialogSop } from '@app/components/common/DialogSop/context' +import type { ModifyForm } from '@app/components/Limit/types' +import { t } from '@app/locale' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElInputNumber } from "element-plus" import { defineComponent } from "vue" -import { useSopData } from "../context" import PeriodInput from "./PeriodInput" import TimeInput from "./TimeInput" const MAX_HOUR_WEEKLY = 7 * 24 const _default = defineComponent(() => { - const data = useSopData() + const { form: data } = useDialogSop<ModifyForm>() + const isXs = useXsState() return () => ( <Flex justify="center"> - <ElForm labelWidth={150} labelPosition="left"> - <ElFormItem label={t(msg => msg.limit.item.daily)}> - <Flex gap={10}> - <TimeInput modelValue={data.time} onChange={v => data.time = v} /> - {t(msg => msg.limit.item.or)} + <ElForm labelWidth={150} labelPosition='left'> + <ElFormItem label={t(msg => msg.shared.limit.daily)}> + <Flex gap={10} column={isXs.value}> + <TimeInput modelValue={data.time ?? 0} onChange={v => data.time = v} /> + {!isXs.value && t(msg => msg.limit.item.or)} <ElInputNumber min={0} step={10} modelValue={data.count} onChange={v => data.count = v ?? 0} - v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} + size={isXs.value ? 'small' : undefined} + v-slots={{ suffix: () => t(msg => msg.shared.limit.visits, { n: '' }).trim() }} /> </Flex> </ElFormItem> - <ElFormItem label={t(msg => msg.limit.item.weekly)}> - <Flex gap={10}> - <TimeInput modelValue={data.weekly} onChange={v => data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> - {t(msg => msg.limit.item.or)} + <ElFormItem label={t(msg => msg.shared.limit.weekly)}> + <Flex gap={10} column={isXs.value}> + <TimeInput modelValue={data.weekly ?? 0} onChange={v => data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> + {!isXs.value && t(msg => msg.limit.item.or)} <ElInputNumber min={0} step={10} modelValue={data.weeklyCount} onChange={v => data.weeklyCount = v ?? 0} - v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} + size={isXs.value ? 'small' : undefined} + v-slots={{ suffix: () => t(msg => msg.shared.limit.visits, { n: '' }).trim() }} /> </Flex> </ElFormItem> <ElFormItem label={t(msg => msg.limit.item.visitTime)}> - <TimeInput modelValue={data.visitTime} onChange={v => data.visitTime = v} /> + <TimeInput modelValue={data.visitTime ?? 0} onChange={v => data.visitTime = v} /> </ElFormItem> - <ElFormItem label={t(msg => msg.limit.item.period)}> - <PeriodInput modelValue={data.periods} onChange={v => data.periods = v} /> + <ElFormItem label={t(msg => msg.shared.limit.period)}> + <PeriodInput modelValue={data.periods ?? []} onChange={v => data.periods = v} /> </ElFormItem> </ElForm> </Flex> diff --git a/src/pages/app/components/Limit/components/Modify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx new file mode 100644 index 000000000..574be120d --- /dev/null +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { addLimit, updateLimits } from '@api/sw/limit' +import DialogSop from '@app/components/common/DialogSop' +import { initDialogSopContext } from '@app/components/common/DialogSop/context' +import { cleanCond } from '@app/components/Limit/common' +import { useLimitData } from "@app/components/Limit/context" +import type { ModifyForm, ModifyInstance } from '@app/components/Limit/types' +import { t } from '@app/locale' +import { range } from '@util/array' +import { computed, defineComponent, ref, toRaw } from "vue" +import Step1 from './Step1' +import Step2 from './Step2' +import Step3 from './Step3' + +type Mode = "create" | "modify" + +const STEP_TITLES = [ + t(msg => msg.limit.step.base), + t(msg => msg.limit.step.url), + t(msg => msg.limit.step.rule), +] + +const createInitial = (url?: string): ModifyForm => ({ + name: `RULE-${new String(new Date().getTime() % 10000).padStart(4, '0')}`, + time: 3600, + weekly: 0, + cond: url ? [cleanCond(url)] : [], + visitTime: 0, + periods: [], + enabled: true, + weekdays: range(7), + count: 0, + weeklyCount: 0, + allowDelay: false, + locked: false, +}) + +const _default = defineComponent((_, ctx) => { + const { refresh } = useLimitData() + const mode = ref<Mode>() + const title = computed(() => mode.value === "create" ? t(msg => msg.button.create) : t(msg => msg.button.modify)) + + const { step, open } = initDialogSopContext<ModifyForm>({ + stepCount: STEP_TITLES.length, + init: createInitial, + onNext: ({ form, current }) => { + if (current === 0) { + const nameVal = form.name?.trim?.() + const weekdaysVal = form.weekdays + if (!nameVal) { + throw new Error("Name is empty") + } if (!weekdaysVal?.length) { + throw new Error("Effective days are empty") + } + } else if (current === 1) { + if (!form.cond?.length) { + form.urlMiss = true + throw new Error(t(msg => msg.limit.message.noUrl)) + } + form.urlMiss = false + } + }, + onFinish: async ({ form }) => { + const { cond, enabled, name, time, count, weekly, weeklyCount, visitTime, periods, weekdays } = toRaw(form) + if (true + && !time && !count + && !weekly && !weeklyCount + && !visitTime && !periods?.length + ) { + throw new Error(t(msg => msg.limit.message.noRule)) + } + let saved: tt4b.limit.Rule + if (mode.value === 'modify') { + if (!modifyingItem) return + saved = { + ...modifyingItem, + cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, + // Object to array + periods: periods?.map(i => [i[0], i[1]] satisfies tt4b.limit.Period), + } satisfies tt4b.limit.Rule + await updateLimits([saved]) + } else { + const toCreate = { + cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, + // Object to array + periods: periods?.map(i => [i[0], i[1]] satisfies tt4b.limit.Period), + allowDelay: false, locked: false, + } satisfies MakeOptional<tt4b.limit.Rule, 'id'> + const id = await addLimit(toCreate) + saved = { ...toCreate, id } + } + refresh?.() + } + }) + // Cache + let modifyingItem: tt4b.limit.Rule | undefined = undefined + + ctx.expose({ + create(url?: string) { + open(createInitial(url)) + mode.value = 'create' + modifyingItem = undefined + }, + modify(row: tt4b.limit.Item) { + open(toRaw(row)) + mode.value = 'modify' + modifyingItem = { ...row } + }, + } satisfies ModifyInstance) + + + return () => ( + <DialogSop title={title.value} stepTitles={STEP_TITLES}> + <Step1 v-show={step.value === 0} /> + <Step2 v-show={step.value === 1} /> + <Step3 v-show={step.value === 2} /> + </DialogSop> + + ) +}) + +export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Table/Rule.tsx b/src/pages/app/components/Limit/components/Table/Rule.tsx new file mode 100644 index 000000000..dfc6683bd --- /dev/null +++ b/src/pages/app/components/Limit/components/Table/Rule.tsx @@ -0,0 +1,69 @@ +import { t } from '@app/locale' +import Flex from "@pages/components/Flex" +import { period2Str } from '@util/limit' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import { ElTag, TagProps } from 'element-plus' +import { defineComponent, type FunctionalComponent, toRef } from "vue" +import { DAILY_WEEKLY_TAG_TYPE, VISIT_TAG_TYPE } from '../style' + +type TimeCountPairProps = { + time?: number + count?: number + label: string + type?: TagProps['type'] +} + +const TimeCountPair: FunctionalComponent<TimeCountPairProps> = ({ time, count, label, type = DAILY_WEEKLY_TAG_TYPE }) => { + const content = [ + time && formatPeriodCommon(time * MILL_PER_SECOND, true), + count && t(msg => msg.shared.limit.visits, { n: count }), + ].filter(Boolean).join(` ${t(msg => msg.limit.item.or)} `) + + if (!content) return null + + return ( + <div> + <ElTag size="small" type={type}>{label}: {content}</ElTag> + </div> + ) +} + +const PeriodTag: FunctionalComponent<{ periods?: tt4b.limit.Period[], }> = ({ periods }) => { + if (!periods?.length) return null + + return <> + <div> + <ElTag size="small" type="info">{t(msg => msg.shared.limit.period)}</ElTag> + </div> + <Flex justify="center" gap={4} wrap="wrap"> + {periods.map((p, idx) => <ElTag key={idx} size="small" type="info">{period2Str(p)}</ElTag>)} + </Flex> + </> +} + +const Rule = defineComponent<{ value: tt4b.limit.Item }>(props => { + const row = toRef(props, 'value') + + return () => ( + <Flex column gap={4}> + <TimeCountPair + time={row.value?.time} + count={row.value?.count} + label={t(msg => msg.shared.limit.daily)} + /> + <TimeCountPair + time={row.value?.weekly} + count={row.value?.weeklyCount} + label={t(msg => msg.shared.limit.weekly)} + /> + <TimeCountPair + time={row.value?.visitTime} + label={t(msg => msg.limit.item.visitTime)} + type={VISIT_TAG_TYPE} + /> + <PeriodTag periods={row.value?.periods} /> + </Flex> + ) +}, { props: ['value'] }) + +export default Rule \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Table/Waste.tsx b/src/pages/app/components/Limit/components/Table/Waste.tsx new file mode 100644 index 000000000..762718945 --- /dev/null +++ b/src/pages/app/components/Limit/components/Table/Waste.tsx @@ -0,0 +1,46 @@ +import { t } from '@app/locale' +import Flex from "@pages/components/Flex" +import TooltipWrapper from '@pages/components/TooltipWrapper' +import { meetLimit, meetTimeLimit } from "@util/limit" +import { formatPeriodCommon } from "@util/time" +import { ElTag } from "element-plus" +import { computed, defineComponent } from "vue" + +type Props = { + time: Parameters<typeof meetTimeLimit>[0] + delay: Parameters<typeof meetTimeLimit>[1] + count?: number + visit?: number +} + +const Waste = defineComponent<Props>(props => { + const timeType = computed(() => meetTimeLimit(props.time, props.delay) ? 'danger' : 'info') + const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') + + return () => ( + <Flex column gap={5}> + <div> + <TooltipWrapper + trigger="hover" + usePopover={props.delay.allow && !!props.time} + placement="top" + v-slots={{ + content: () => `${t(msg => msg.limit.item.delayCount)}: ${props.delay.count}`, + default: () => ( + <ElTag size="small" type={timeType.value}> + {formatPeriodCommon(props.time.wasted)} + </ElTag> + ), + }} + /> + </div> + <div> + <ElTag size="small" type={visitType.value}> + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} + </ElTag> + </div> + </Flex> + ) +}, { props: ['time', 'delay', 'count', 'visit'] }) + +export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Weekday.tsx b/src/pages/app/components/Limit/components/Table/Weekday.tsx similarity index 96% rename from src/pages/app/components/Limit/LimitTable/Weekday.tsx rename to src/pages/app/components/Limit/components/Table/Weekday.tsx index 33a8804a9..35e91f96e 100644 --- a/src/pages/app/components/Limit/LimitTable/Weekday.tsx +++ b/src/pages/app/components/Limit/components/Table/Weekday.tsx @@ -1,5 +1,5 @@ +import { t } from "@app/locale" import Flex from "@pages/components/Flex" -import { t } from "@popup/locale" import { ElTag } from "element-plus" import { computed, defineComponent, toRef, type PropType } from "vue" diff --git a/src/pages/app/components/Limit/components/Table/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx new file mode 100644 index 000000000..1a6fe0870 --- /dev/null +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { getOption, getWeekStartDay } from "@api/sw/option" +import ColumnHeader from "@app/components/common/ColumnHeader" +import { useDelayDuration, useLimitAction, useLimitData } from "@app/components/Limit/context" +import type { LimitInstance } from '@app/components/Limit/types' +import { t } from '@app/locale' +import { Delete, Edit } from '@element-plus/icons-vue' +import { useCached, useRequest } from '@hooks' +import { locale } from '@i18n' +import { isEffective } from "@util/limit" +import { MILL_PER_SECOND } from "@util/time" +import { + ElButton, ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort, type TableInstance, +} from "element-plus" +import { defineComponent, ref } from "vue" +import Rule from "./Rule" +import Waste from "./Waste" +import Weekday from "./Weekday" + +const ACTION_WIDTH: { [locale in tt4b.Locale]: number } = { + en: 220, + zh_CN: 200, + ja: 200, + zh_TW: 200, + pt_PT: 250, + uk: 260, + es: 240, + de: 250, + fr: 230, + ru: 240, + ar: 220, + tr: 220, + pl: 220, + it: 220, +} + +const DEFAULT_SORT_COL = 'waste' + +function createSorter(key: 'waste' | 'weeklyWaste') { + return (a: tt4b.limit.Item, b: tt4b.limit.Item) => a[key] - b[key] +} + +function sortByEffectiveDays(a: tt4b.limit.Item, b: tt4b.limit.Item) { + return (a.weekdays?.length ?? 0) - (b.weekdays?.length ?? 0) +} + +const _default = defineComponent((_, ctx) => { + const { data: weekStartName } = useRequest(async () => { + const offset = await getWeekStartDay() + const name = t(msg => msg.calendar.weekDays)?.split('|')?.[offset] + return name || 'NaN' + }) + + const { list, changeEnabled, changeDelay, changeLocked } = useLimitData() + const { modify, remove } = useLimitAction() + const delayDuration = useDelayDuration() + + const [sort, setSort] = useCached<Sort>('__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' }) + const table = ref<TableInstance>() + + const { data: lockVisible } = useRequest(async () => { + const option = await getOption() + return option.limitLevel !== 'nothing' + }, { defaultValue: false }) + + ctx.expose({ + getSelected: () => table.value?.getSelectionRows?.() ?? [] + } satisfies LimitInstance) + + return () => ( + <ElTable + ref={table} + border fit highlightCurrentRow + style={{ width: "100%" }} + height="100%" + data={list.value} + defaultSort={sort.value} + onSort-change={(val: Sort) => setSort({ prop: val?.prop, order: val?.order })} + > + <ElTableColumn type="selection" align="center" fixed="left" /> + <ElTableColumn + prop='name' + label={t(msg => msg.limit.item.name)} + minWidth={120} + align="center" + formatter={({ name }: tt4b.limit.Item) => name || '-'} + fixed + sortable + sortBy={(row: tt4b.limit.Item) => row.name} + /> + <ElTableColumn + label={t(msg => msg.limit.item.condition)} + minWidth={180} + align="center" + formatter={({ cond }: tt4b.limit.Item) => <>{cond?.map?.(c => <span style={{ display: "block" }}>{c}</span>) || ''}</>} + /> + <ElTableColumn + label={t(msg => msg.limit.item.detail)} + minWidth={200} + align="center" + > + {({ row }: RenderRowData<tt4b.limit.Item>) => <Rule value={row} />} + </ElTableColumn> + <ElTableColumn + prop='effectiveDays' + label={t(msg => msg.limit.item.effectiveDay)} + minWidth={170} + align="center" + sortable + sortMethod={sortByEffectiveDays} + > + {({ row: { weekdays } }: RenderRowData<tt4b.limit.Item>) => <Weekday value={weekdays} />} + </ElTableColumn> + <ElTableColumn + prop={DEFAULT_SORT_COL} + sortable + sortMethod={createSorter('waste')} + label={t(msg => msg.calendar.range.today)} + minWidth={90} + align="center" + > + {({ row }: RenderRowData<tt4b.limit.Item>) => isEffective(row.weekdays) ? ( + <Waste + time={{ wasted: row.waste, maxLimit: (row.time ?? 0) * MILL_PER_SECOND }} + delay={{ count: row.delayCount, duration: delayDuration.value, allow: !!row.allowDelay }} + count={row.count ?? 0} + visit={row.visit ?? 0} + /> + ) : ( + <ElTag type="info" size="small"> + {t(msg => msg.limit.item.notEffective)} + </ElTag> + )} + </ElTableColumn> + <ElTableColumn + prop='weeklyWaste' + minWidth={110} + align="center" + sortable + sortMethod={createSorter('weeklyWaste')} + v-slots={{ + header: () => ( + <ColumnHeader + label={t(msg => msg.calendar.range.thisWeek)} + tooltipContent={t(msg => msg.limit.item.weekStartInfo, { weekStart: weekStartName.value })} + /> + ), + default: ({ row: { + weeklyWaste, weekly, + weeklyVisit, weeklyCount, + weeklyDelayCount, allowDelay, + } }: RenderRowData<tt4b.limit.Item>) => ( + <Waste + time={{ wasted: weeklyWaste, maxLimit: (weekly ?? 0) * MILL_PER_SECOND }} + delay={{ count: weeklyDelayCount, duration: delayDuration.value, allow: !!allowDelay }} + count={weeklyCount ?? 0} + visit={weeklyVisit ?? 0} + /> + ), + }} + /> + <ElTableColumn label={t(msg => msg.button.configuration)}> + <ElTableColumn + label={t(msg => msg.limit.item.enabled)} + minWidth={80} + align="center" + fixed="right" + > + {({ row }: RenderRowData<tt4b.limit.Item>) => ( + <ElSwitch size="small" modelValue={row.enabled} onChange={v => changeEnabled(row, !!v)} /> + )} + </ElTableColumn> + <ElTableColumn + label={t(msg => msg.limit.item.allowDelay)} + minWidth={80} + align="center" + fixed="right" + > + {({ row }: RenderRowData<tt4b.limit.Item>) => ( + <ElSwitch size="small" modelValue={row.allowDelay} onChange={v => changeDelay(row, !!v)} /> + )} + </ElTableColumn> + {lockVisible.value && ( + <ElTableColumn + label={t(msg => msg.limit.item.locked)} + minWidth={80} + align="center" + fixed="right" + > + {({ row }: RenderRowData<tt4b.limit.Item>) => ( + <ElSwitch size="small" modelValue={row.locked} onChange={v => changeLocked(row, !!v)} /> + )} + </ElTableColumn> + )} + </ElTableColumn> + <ElTableColumn + label={t(msg => msg.button.operation)} + width={ACTION_WIDTH[locale]} + align="center" + fixed="right" + v-slots={({ row }: RenderRowData<tt4b.limit.Item>) => <> + <ElButton type="danger" size="small" icon={Delete} onClick={() => remove(row)}> + {t(msg => msg.button.delete)} + </ElButton> + <ElButton type="primary" size="small" icon={Edit} onClick={() => modify(row)}> + {t(msg => msg.button.modify)} + </ElButton> + </>} + /> + </ElTable> + ) +}) + +export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTest.tsx b/src/pages/app/components/Limit/components/Test.tsx similarity index 79% rename from src/pages/app/components/Limit/LimitTest.tsx rename to src/pages/app/components/Limit/components/Test.tsx index 3a6c37c0a..7baddcd2e 100644 --- a/src/pages/app/components/Limit/LimitTest.tsx +++ b/src/pages/app/components/Limit/components/Test.tsx @@ -5,21 +5,21 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { useDebounce, useRequest, useState, useSwitch } from "@hooks" +import { listLimits } from "@api/sw/limit" +import AlertLines, { type AlertLinesProps } from '@app/components/common/AlertLines' +import { t } from '@app/locale' +import { useDebounce, useRequest, useState, useSwitch, useXsState } from '@hooks' import Flex from '@pages/components/Flex' -import limitService from "@service/limit-service" import { ElDialog, ElInput } from "element-plus" import { defineComponent } from "vue" -import AlertLines, { type AlertLinesProps } from '../common/AlertLines' -import { type TestInstance } from "./context" +import type { TestInstance } from '../types' async function fetchResult(url: string | undefined): Promise<AlertLinesProps> { if (!url) { return { type: 'warning', title: msg => msg.limit.message.inputTestUrl } } - const matched = await limitService.select({ url, filterDisabled: true }) - if (!matched?.length) { + const matched = await listLimits({ url, enabled: true }) + if (!matched.length) { return { type: 'info', title: msg => msg.limit.message.noRuleMatched } } else { return { @@ -35,6 +35,7 @@ const _default = defineComponent((_props, ctx) => { const debouncedUrl = useDebounce(url) const [visible, open, close] = useSwitch() const { data: result } = useRequest(() => fetchResult(debouncedUrl.value), { deps: debouncedUrl }) + const isXs = useXsState() ctx.expose({ show: () => { @@ -49,6 +50,7 @@ const _default = defineComponent((_props, ctx) => { modelValue={visible.value} closeOnClickModal={false} onClose={close} + fullscreen={isXs.value} > <Flex gap={18} column> <ElInput diff --git a/src/pages/app/components/Limit/components/index.tsx b/src/pages/app/components/Limit/components/index.tsx new file mode 100644 index 000000000..353a8404e --- /dev/null +++ b/src/pages/app/components/Limit/components/index.tsx @@ -0,0 +1,5 @@ +export { default as Filter } from './Filter' +export { default as List } from './List' +export { default as Modify } from './Modify' +export { default as Table } from './Table' +export { default as Test } from './Test' diff --git a/src/pages/app/components/Limit/components/style.ts b/src/pages/app/components/Limit/components/style.ts new file mode 100644 index 000000000..56001cd08 --- /dev/null +++ b/src/pages/app/components/Limit/components/style.ts @@ -0,0 +1,5 @@ +import { type TagProps } from 'element-plus' + +export const DAILY_WEEKLY_TAG_TYPE = 'primary' satisfies TagProps['type'] +export const VISIT_TAG_TYPE = 'danger' satisfies TagProps['type'] +export const PERIOD_TAG_TYPE = 'info' satisfies TagProps['type'] \ No newline at end of file diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index 3f7ab5638..9c4bb508a 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -1,67 +1,107 @@ -import { t } from "@app/locale" -import { useDocumentVisibility, useManualRequest, useProvide, useProvider, useRequest } from "@hooks" -import limitService from "@service/limit-service" -import { ElMessage, ElMessageBox, type TableInstance } from "element-plus" -import { computed, Reactive, reactive, ref, toRaw, watch, type Ref } from "vue" -import { useRoute, useRouter } from "vue-router" -import { verifyCanModify } from "./common" -import type { LimitFilterOption } from "./types" - -export type ModifyInstance = { - create(): void - modify(row: timer.limit.Item): void -} - -export type TestInstance = { - show(): void -} +import { getOption } from '@/api/sw/option' +import { DEFAULT_LIMIT } from '@/util/constant/option' +import { deleteLimits, listLimits, updateLimits } from "@api/sw/limit" +import { t } from '@app/locale' +import type { LimitQuery } from '@app/router/constants' +import { judgeVerificationRequired, processVerification } from '@app/util/limit' +import { useDocumentVisibility, useManualRequest, useProvide, useProvider, useRequest } from '@hooks' +import { tryParseInteger } from '@util/number' +import { ElMessage, ElMessageBox } from "element-plus" +import { computed, onMounted, reactive, ref, toRaw, watch, type ShallowRef } from "vue" +import { useRoute, useRouter } from 'vue-router' +import type { LimitFilterOption, LimitInstance, ModifyInstance, TestInstance } from "./types" type Context = { - table: Ref<TableInstance | undefined> - filter: Reactive<LimitFilterOption> - list: Ref<timer.limit.Item[]>, refresh: NoArgCallback, - deleteRow: ArgCallback<timer.limit.Item> + filter: LimitFilterOption + list: ShallowRef<tt4b.limit.Item[]> + refresh: NoArgCallback batchDelete: NoArgCallback batchEnable: NoArgCallback batchDisable: NoArgCallback - changeEnabled: (item: timer.limit.Item, val: boolean) => Promise<void> - changeDelay: (item: timer.limit.Item, val: boolean) => Promise<void> - changeLocked: (item: timer.limit.Item, val: boolean) => Promise<void> - modify: (item: timer.limit.Item) => void + changeEnabled: (item: tt4b.limit.Item, val: boolean) => Promise<void> + changeDelay: (item: tt4b.limit.Item, val: boolean) => Promise<void> + changeLocked: (item: tt4b.limit.Item, val: boolean) => Promise<void> + modify: ArgCallback<tt4b.limit.Item> + remove: ArgCallback<tt4b.limit.Item> create: () => void test: () => void - empty: Ref<boolean> + empty: ShallowRef<boolean> + delayDuration: ShallowRef<number> } const NAMESPACE = 'limit' -const initialUrl = () => { - // Init with url parameter - const urlParam = useRoute().query['url'] as string +const initialQuery = () => { + const { url, action, id: idQuery } = useRoute().query as LimitQuery useRouter().replace({ query: {} }) - return urlParam ? decodeURIComponent(urlParam) : '' + const [isNum, idMaybe] = idQuery ? tryParseInteger(idQuery) : [false, undefined] + return { + url: url && decodeURIComponent(url), + action, + id: isNum && !Number.isNaN(idMaybe) && idMaybe ? idMaybe : undefined, + } +} + +const batchJudge = async (items: tt4b.limit.Item[]): Promise<boolean> => { + if (!items?.length) return false + const { limitDelayDuration, limitLevel } = await getOption() + for (const item of items) { + if (!item) continue + let needVerify = await judgeVerificationRequired(item, limitDelayDuration) + // If locked and the level is not strict, verification is also required to modify the rule + if (limitLevel !== 'strict') needVerify ||= item.locked + if (needVerify) return true + } + return false +} + +const verifyCanModify = async (...items: tt4b.limit.Item[]) => { + const needVerify = await batchJudge(items) + if (!needVerify) return + + // Open delay for limited rules, so verification is required + const option = await getOption() + if (!option) return + await processVerification(option) } export const useLimitProvider = () => { - const filter = reactive<LimitFilterOption>({ url: initialUrl(), onlyEnabled: false }) + const { url, action, id } = initialQuery() + const initialUrl = action === 'create' ? undefined : url + + if (action === 'create') { + onMounted(() => setTimeout(() => modifyInst.value?.create(url))) + } + + const filter = reactive<LimitFilterOption>({ url: initialUrl, effective: false }) const { data: list, refresh, loading } = useRequest( - () => limitService.select({ filterDisabled: filter.onlyEnabled, url: filter.url ?? '' }), + () => listLimits({ url: filter.url, effective: filter.effective }), { defaultValue: [], - deps: [() => filter.url, () => filter.onlyEnabled], + deps: [() => filter.url, () => filter.effective], + onSuccess: data => { + if (action !== 'modify') return + const target = data.find(i => i.id === id) + target && setTimeout(() => modifyInst.value?.modify(target)) + } }, ) + const { data: delayDuration } = useRequest( + () => getOption().then(o => o.limitDelayDuration), + { defaultValue: DEFAULT_LIMIT.limitDelayDuration }, + ) + // Query data if the window become visible const docVisible = useDocumentVisibility() watch(docVisible, () => docVisible.value && refresh()) - const { refresh: deleteRow } = useManualRequest(async (row: timer.limit.Item) => { + const { refresh: remove } = useManualRequest(async (row: tt4b.limit.Item) => { await verifyCanModify(row) const message = t(msg => msg.limit.message.deleteConfirm, { name: row.name }) - await ElMessageBox.confirm(message, { type: "warning" }) - await limitService.remove(row) + await ElMessageBox.confirm(message) + await deleteLimits([row.id]) }, { onSuccess() { ElMessage.success(t(msg => msg.operation.successMsg)) @@ -69,10 +109,10 @@ export const useLimitProvider = () => { } }) - const table = ref<TableInstance>() + const inst = ref<LimitInstance>() - const selectedAndThen = (then: (list: timer.limit.Item[]) => void): void => { - const list = table.value?.getSelectionRows?.() + const selectedAndThen = (then: (list: tt4b.limit.Item[]) => void): void => { + const list = inst.value?.getSelected?.() if (!list?.length) { ElMessage.info('No limit rule selected') return @@ -85,47 +125,50 @@ export const useLimitProvider = () => { refresh() } - const handleBatchDelete = (list: timer.limit.Item[]) => verifyCanModify(...list) - .then(() => limitService.remove(...list)) - .then(onBatchSuccess) - .catch(() => { }) + const handleBatchDelete = (list: tt4b.limit.Item[]) => { + const names = list.map(item => item.name ?? item.id).join(', ') + verifyCanModify(...list) + .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: names }), { type: "warning" })) + .then(() => deleteLimits(list.map(item => item.id))) + .then(onBatchSuccess) + .catch(() => { }) + } - const handleBatchEnable = (list: timer.limit.Item[]) => { + const handleBatchEnable = (list: tt4b.limit.Item[]) => { list.forEach(item => item.enabled = true) - limitService.updateEnabled(...list).then(onBatchSuccess).catch(() => { }) + updateLimits(list).then(onBatchSuccess).catch(() => { }) } - const handleBatchDisable = (list: timer.limit.Item[]) => verifyCanModify(...list) + const handleBatchDisable = (list: tt4b.limit.Item[]) => verifyCanModify(...list) .then(() => { list.forEach(item => item.enabled = false) - return limitService.updateEnabled(...list) + return updateLimits(list) }) .then(onBatchSuccess) .catch(() => { }) - const changeEnabled = async (row: timer.limit.Item, newVal: boolean) => { - const enabled = !!newVal + const changeEnabled = async (row: tt4b.limit.Item, newVal: boolean) => { try { - (row.locked || !enabled) && await verifyCanModify(row) - row.enabled = enabled - await limitService.updateEnabled(toRaw(row)) + // Only verify when disabling, ignore lock state + !newVal && await verifyCanModify(row) + row.enabled = newVal + await updateLimits([toRaw(row)]) } catch (e) { - console.warn(e) + console.info(e) } } - const changeDelay = async (row: timer.limit.Item, newVal: boolean) => { - const delayable = !!newVal + const changeDelay = async (row: tt4b.limit.Item, newVal: boolean) => { try { - (row.locked || delayable) && await verifyCanModify(row) - row.allowDelay = delayable - await limitService.updateDelay(toRaw(row)) + (row.locked || newVal) && await verifyCanModify(row) + row.allowDelay = newVal + await updateLimits([toRaw(row)]) } catch (e) { console.warn(e) } } - const changeLocked = async (row: timer.limit.Item, newVal: boolean) => { + const changeLocked = async (row: tt4b.limit.Item, newVal: boolean) => { const locked = !!newVal try { if (locked) { @@ -135,7 +178,7 @@ export const useLimitProvider = () => { await verifyCanModify(row) } row.locked = locked - await limitService.updateLocked(toRaw(row)) + await updateLimits([toRaw(row)]) } catch (e) { console.warn(e) } @@ -143,34 +186,34 @@ export const useLimitProvider = () => { const modifyInst = ref<ModifyInstance>() const testInst = ref<TestInstance>() - const modify = (row: timer.limit.Item) => modifyInst.value?.modify?.(toRaw(row)) + const modify = (row: tt4b.limit.Item) => verifyCanModify(row) + .then(() => modifyInst.value?.modify?.(toRaw(row))) + .catch(() => {/** Do nothing */ }) const create = () => modifyInst.value?.create?.() const test = () => testInst.value?.show?.() - const empty = computed(() => !loading.value && !list.value.length) + const empty = computed(() => !loading.value && !(list.value?.length)) useProvide<Context>(NAMESPACE, { - table, - filter, - list, empty, refresh, - deleteRow, + filter, list, empty, refresh, delayDuration, + remove, modify, create, test, changeEnabled, changeDelay, changeLocked, batchDelete: () => selectedAndThen(handleBatchDelete), batchEnable: () => selectedAndThen(handleBatchEnable), batchDisable: () => selectedAndThen(handleBatchDisable), - changeEnabled, changeDelay, changeLocked, - modify, create, test, }) - return { modifyInst, testInst } + return { modifyInst, testInst, inst } } -export const useLimitFilter = (): Reactive<LimitFilterOption> => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter +export const useLimitFilter = (): LimitFilterOption => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter -export const useLimitTable = () => useProvider<Context, 'list' | 'table' | 'refresh' | 'deleteRow' | 'changeEnabled' | 'changeDelay' | 'changeLocked'>( - NAMESPACE, 'list', 'table', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' +export const useLimitData = () => useProvider<Context, 'list' | 'refresh' | 'changeEnabled' | 'changeDelay' | 'changeLocked'>( + NAMESPACE, 'list', 'refresh', 'changeEnabled', 'changeDelay', 'changeLocked' ) export const useLimitBatch = () => useProvider<Context, 'batchDelete' | 'batchEnable' | 'batchDisable'>( - NAMESPACE, 'batchDelete', 'batchDisable', 'batchEnable' + NAMESPACE, 'batchDelete', 'batchEnable', 'batchDisable' ) -export const useLimitAction = () => useProvider<Context, 'test' | 'modify' | 'create' | 'empty'>(NAMESPACE, 'modify', 'test', 'create', 'empty') \ No newline at end of file +export const useLimitAction = () => useProvider<Context, 'test' | 'remove' | 'modify' | 'create' | 'empty'>(NAMESPACE, 'remove', 'modify', 'test', 'create', 'empty') + +export const useDelayDuration = () => useProvider<Context, 'delayDuration'>(NAMESPACE, 'delayDuration').delayDuration \ No newline at end of file diff --git a/src/pages/app/components/Limit/index.tsx b/src/pages/app/components/Limit/index.tsx index 77877f850..dee76c1ac 100644 --- a/src/pages/app/components/Limit/index.tsx +++ b/src/pages/app/components/Limit/index.tsx @@ -5,24 +5,24 @@ * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { defineComponent } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentCard from '../common/ContentCard' +import ContentContainer from '../common/ContentContainer' +import { Filter, List, Modify, Table, Test } from "./components" import { useLimitProvider } from "./context" -import LimitFilter from "./LimitFilter" -import LimitModify from "./LimitModify" -import LimitTable from "./LimitTable" -import LimitTest from "./LimitTest" const _default = defineComponent(() => { - const { modifyInst, testInst } = useLimitProvider() + const { modifyInst, testInst, inst } = useLimitProvider() + const isXs = useXsState() return () => ( <ContentContainer v-slots={{ - filter: () => <LimitFilter />, - content: () => <> - <LimitTable /> - <LimitModify ref={modifyInst} /> - <LimitTest ref={testInst} /> + filter: () => <Filter />, + default: () => <> + {isXs.value ? <List /> : <ContentCard><Table ref={inst} /></ContentCard>} + <Modify ref={modifyInst} /> + <Test ref={testInst} /> </> }} /> ) diff --git a/src/pages/app/components/Limit/types.d.ts b/src/pages/app/components/Limit/types.d.ts index 9e00296ce..f2c2c8ccc 100644 --- a/src/pages/app/components/Limit/types.d.ts +++ b/src/pages/app/components/Limit/types.d.ts @@ -1,4 +1,21 @@ export type LimitFilterOption = { url: string | undefined - onlyEnabled: boolean + effective: boolean } + +export type ModifyInstance = { + create(url?: string): void + modify(row: tt4b.limit.Item): void +} + +export type TestInstance = { + show(): void +} + +export type LimitInstance = { + getSelected(): tt4b.limit.Item[] +} + +export type ModifyForm = Omit<tt4b.limit.Rule, 'id'> & { + urlMiss?: boolean +} \ No newline at end of file diff --git a/src/pages/app/components/Option/Select.tsx b/src/pages/app/components/Option/Select.tsx index 77f880af5..e3df4eecf 100644 --- a/src/pages/app/components/Option/Select.tsx +++ b/src/pages/app/components/Option/Select.tsx @@ -1,40 +1,22 @@ -import { t } from "@app/locale" -import { ElCard, ElSelect } from "element-plus" -import { defineComponent, h, ref, useSlots, watch } from "vue" -import { useRouter } from "vue-router" -import ContentContainer from "../common/ContentContainer" -import { CATE_LABELS, changeQuery, type OptionCategory, parseQuery } from "./common" - -const IGNORED_CATE: OptionCategory[] = ['limit'] +import { ElCard, ElOption, ElSelect } from "element-plus" +import { defineComponent, h, useSlots } from "vue" +import ContentContainer from '../common/ContentContainer' +import { useCategory } from "./useCategory" const _default = defineComponent(() => { - const tab = ref<OptionCategory>(parseQuery() || 'appearance') - const router = useRouter() - watch(tab, () => changeQuery(tab.value, router)) - + const { category, setCategory, getLabel } = useCategory() const slots = useSlots() return () => ( <ContentContainer v-slots={{ filter: () => ( - <ElSelect - modelValue={tab.value} - onChange={val => tab.value = val} - > - {Object.keys(slots) - .filter(key => !IGNORED_CATE.includes(key as OptionCategory) && key !== 'default') - .map(cate => ( - <ElSelect.Option value={cate} label={t(CATE_LABELS[cate as OptionCategory])} /> - )) - } + <ElSelect modelValue={category.value} onChange={setCategory}> + {Object.keys(slots).map(c => <ElOption value={c} label={getLabel(c)} />)} </ElSelect> ), default: () => { - const slot = slots[tab.value] - return !!slot && <ElCard - class="option-select-card" - style={{ "--el-card-padding": '20px 10px' }} - >{h(slot)}</ElCard> + const slot = slots[category.value] + return !!slot && <ElCard style={{ "--el-card-padding": '20px 10px' }}>{h(slot)}</ElCard> } }} /> ) diff --git a/src/pages/app/components/Option/Tabs.tsx b/src/pages/app/components/Option/Tabs.tsx index 3d60d60be..5335e064f 100644 --- a/src/pages/app/components/Option/Tabs.tsx +++ b/src/pages/app/components/Option/Tabs.tsx @@ -1,82 +1,49 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { Download, Refresh, Upload } from "@element-plus/icons-vue" import { css } from '@emotion/css' import Flex from "@pages/components/Flex" -import { ElLink, ElMessage, ElMessageBox, ElTabPane, ElTabs, ElTooltip, TabPaneName, useNamespace } from "element-plus" -import { defineComponent, h, ref, useSlots } from "vue" -import { useRouter } from "vue-router" -import ContentContainer from "../common/ContentContainer" -import { CATE_LABELS, changeQuery, type OptionCategory, parseQuery } from "./common" +import { ElButton, ElMessage, ElMessageBox, ElTabPane, ElTabs, useNamespace } from "element-plus" +import { defineComponent, h, useSlots } from "vue" +import ContentContainer from '../common/ContentContainer' import { createFileInput, exportSettings, importSettings } from "./export-import" +import { type OptionCategory, useCategory } from './useCategory' -const TOOLBAR_NAME = "toolbar" - -type TooltipProps = { - onReset?: NoArgCallback -} - -const Toolbar = defineComponent<TooltipProps>(props => { - const handleExport = async () => { - try { - await exportSettings() - ElMessage.success(t(msg => msg.option.exportSuccess)) - } catch (error) { - ElMessage.error('Export failed: ' + (error as Error).message) - } +const handleExport = async () => { + try { + await exportSettings() + ElMessage.success(t(msg => msg.option.exportSuccess)) + } catch (error) { + ElMessage.error('Export failed: ' + (error as Error).message) } +} - const handleImport = async () => { - try { - const fileContent = await createFileInput() - // User cancelled, don't show error message - if (!fileContent) return - await importSettings(fileContent) - ElMessageBox({ - message: t(msg => msg.option.importConfirm), - type: "success", - confirmButtonText: t(msg => msg.option.reloadButton), - closeOnPressEscape: false, - closeOnClickModal: false - }).then(() => { - window.location.reload() - }).catch(() => {/* do nothing */ }) - } catch (error) { - ElMessage.error(t(msg => msg.option.importError)) - } +const handleImport = async () => { + try { + const fileContent = await createFileInput() + // User cancelled, don't show error message + if (!fileContent) return + await importSettings(fileContent) + ElMessageBox({ + message: t(msg => msg.option.importConfirm), + type: "success", + confirmButtonText: t(msg => msg.option.reloadButton), + closeOnPressEscape: false, + closeOnClickModal: false + }).then(() => { + window.location.reload() + }).catch(() => {/* do nothing */ }) + } catch (error) { + ElMessage.error(t(msg => msg.option.importError)) } +} - return () => ( - <Flex align="center" gap={10} onClick={ev => ev.stopPropagation()}> - <ElTooltip content={t(msg => msg.option.exportButton)}> - <ElLink icon={Download} underline="never" onClick={handleExport} /> - </ElTooltip> - <ElTooltip content={t(msg => msg.option.importButton)}> - <ElLink icon={Upload} underline="never" onClick={handleImport} /> - </ElTooltip> - <ElLink icon={Refresh} underline="never" onClick={props.onReset}> - {t(msg => msg.option.resetButton)} - </ElLink> - </Flex> - ) -}, { - props: ['onReset'] -}) - -type Props = { onReset: (cate: OptionCategory) => Promise<void> | void } +type Props = { onReset: (category: OptionCategory) => Promise<void> | void } const useStyle = () => { const tabsNs = useNamespace('tabs') return css` & { - .${tabsNs.e('nav')} { - float: none !important; - - #tab-${TOOLBAR_NAME} { - position: absolute; - right: 0px; - } - } .${tabsNs.e('item')} { font-size: 16px; height: 43px; @@ -91,39 +58,34 @@ const useStyle = () => { } const _default = defineComponent<Props>(props => { - const tab = ref(parseQuery() ?? 'appearance') - const router = useRouter() - const handleReset = () => props.onReset?.(tab.value) + const { category, setCategory, getLabel } = useCategory() + const handleReset = () => props.onReset(category.value) + const cls = useStyle() - const handleBeforeLeave = (activeName: TabPaneName): Promise<boolean> => { - if (activeName === TOOLBAR_NAME) { - // do nothing, and never happen - return Promise.reject() - } - const cate = activeName as OptionCategory - tab.value = cate - // Change the query of current route - changeQuery(cate, router) - return Promise.resolve(true) - } - const tabsCls = useStyle() return () => ( <ContentContainer> + <Flex justify='end' gap={5}> + <ElButton icon={Download} onClick={handleExport} type='primary'> + {t(msg => msg.option.exportButton)} + </ElButton> + <ElButton icon={Upload} onClick={handleImport} type='primary'> + {t(msg => msg.option.importButton)} + </ElButton> + <ElButton icon={Refresh} onClick={handleReset} type='danger'> + {t(msg => msg.option.resetButton)} + </ElButton> + </Flex> <ElTabs - modelValue={tab.value} - type="border-card" - beforeLeave={handleBeforeLeave} - class={tabsCls} + modelValue={category.value} + class={cls} + type='border-card' + onTabChange={setCategory} > {Object.entries(useSlots()).filter(([key]) => key !== 'default').map(([key, slot]) => ( - <ElTabPane name={key} label={t(CATE_LABELS[key as OptionCategory])}> + <ElTabPane name={key} label={getLabel(key)}> {!!slot && h(slot)} </ElTabPane> ))} - <ElTabPane - name={TOOLBAR_NAME} - v-slots={{ label: () => <Toolbar onReset={handleReset} /> }} - /> </ElTabs> </ContentContainer> ) diff --git a/src/pages/app/components/Option/components/AccessibilityOption.tsx b/src/pages/app/components/Option/categories/Accessibility.tsx similarity index 53% rename from src/pages/app/components/Option/components/AccessibilityOption.tsx rename to src/pages/app/components/Option/categories/Accessibility.tsx index 9fbd03495..02d4585e1 100644 --- a/src/pages/app/components/Option/components/AccessibilityOption.tsx +++ b/src/pages/app/components/Option/categories/Accessibility.tsx @@ -1,19 +1,19 @@ -import { defaultAccessibility } from "@util/constant/option" +import { DEFAULT_ACCESSIBILITY } from "@util/constant/option" import { ElSwitch } from "element-plus" import { defineComponent } from "vue" -import { type OptionInstance } from "../common" +import { OptionItem } from '../components' import { useOption } from "../useOption" -import OptionItem from "./OptionItem" +import type { CategoryInstance } from './types' -function copy(target: timer.option.AccessibilityOption, source: timer.option.AccessibilityOption) { +function copy(target: tt4b.option.AccessibilityOption, source: Readonly<tt4b.option.AccessibilityOption>) { target.chartDecal = source.chartDecal } const _default = defineComponent((_, ctx) => { - const { option } = useOption({ defaultValue: defaultAccessibility, copy }) + const { option } = useOption({ defaultValue: DEFAULT_ACCESSIBILITY, copy }) ctx.expose({ - reset: () => copy(option, defaultAccessibility()) - } satisfies OptionInstance) + reset: () => copy(option, DEFAULT_ACCESSIBILITY) + } satisfies CategoryInstance) return () => ( <OptionItem label={msg => msg.option.accessibility.chartDecal} defaultValue={false}> <ElSwitch modelValue={option.chartDecal} onChange={val => option.chartDecal = val as boolean} /> diff --git a/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx similarity index 75% rename from src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx rename to src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx index 9622d8449..f9f9d1917 100644 --- a/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx +++ b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx @@ -4,14 +4,17 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { ElSelect, ElTimePicker } from "element-plus" import { computed, defineComponent, StyleValue } from "vue" -const ALL_MODES: timer.option.DarkMode[] = ["default", "on", "off", "timed"] -const labelOfMode = (mode: timer.option.DarkMode) => mode === 'default' - ? t(msg => msg.option.followBrowser) - : t(msg => msg.option.appearance.darkMode.options[mode]) +const ALL_MODES: tt4b.option.DarkMode[] = ["default", "on", "off", "timed"] +const MODE_LABELS: Record<tt4b.option.DarkMode, string> = { + default: t(msg => msg.option.followBrowser), + on: t(msg => msg.option.on), + off: t(msg => msg.option.off), + timed: t(msg => msg.option.appearance.darkMode.timed) +} function computeSecondToDate(secondOfDate: number): Date { const now = new Date() @@ -33,10 +36,10 @@ function computeDateToSecond(date: Date) { } type Props = { - modelValue: timer.option.DarkMode + modelValue: tt4b.option.DarkMode startSecond?: number endSecond?: number - onChange?: (darkMode: timer.option.DarkMode, [startSecond, endSecond]: [number?, number?]) => void + onChange?: (darkMode: tt4b.option.DarkMode, [startSecond, endSecond]: [number?, number?]) => void } const _default = defineComponent<Props>(props => { @@ -48,8 +51,8 @@ const _default = defineComponent<Props>(props => { modelValue={props.modelValue} size="small" style={{ width: "120px" }} - onChange={val => props.onChange?.(val as timer.option.DarkMode, [props.startSecond, props.endSecond])} - options={ALL_MODES.map(value => ({ value, label: labelOfMode(value) }))} + onChange={val => props.onChange?.(val as tt4b.option.DarkMode, [props.startSecond, props.endSecond])} + options={ALL_MODES.map(value => ({ value, label: MODE_LABELS[value] }))} /> {props.modelValue === "timed" && <> <ElTimePicker diff --git a/src/pages/app/components/Option/components/AppearanceOption/index.tsx b/src/pages/app/components/Option/categories/Appearance/index.tsx similarity index 82% rename from src/pages/app/components/Option/components/AppearanceOption/index.tsx rename to src/pages/app/components/Option/categories/Appearance/index.tsx index 16c58c495..2268d5c0d 100644 --- a/src/pages/app/components/Option/components/AppearanceOption/index.tsx +++ b/src/pages/app/components/Option/categories/Appearance/index.tsx @@ -6,35 +6,32 @@ */ import { isSidePanelEnabled, setSidePanelEnabled, SIDE_PANEL_STATE_SUPPORTED_CONTROL } from '@api/chrome/sidePanel' -import { type I18nKey, t, tWith } from "@app/locale" -import { useRequest } from '@hooks/useRequest' +import { OptionItem, OptionLines, OptionTag } from '@app/components/Option/components' +import { useOption } from '@app/components/Option/useOption' +import { type I18nKey, t, tWith } from '@app/locale' +import { useRequest } from '@hooks' import { ALL_LOCALES, localeSameAsBrowser } from "@i18n" import localeMessages from "@i18n/message/common/locale" -import optionService from "@service/option-service" +import { processDarkMode } from '@pages/util/dark-mode' import { IS_ANDROID } from "@util/constant/environment" -import { defaultAppearance } from "@util/constant/option" -import { toggle } from "@util/dark-mode" +import { DEFAULT_APPEARANCE } from "@util/constant/option" import { ElColorPicker, ElMessageBox, ElSelect, ElSlider, ElSwitch, ElTag, type TagProps } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" -import { type OptionInstance } from "../../common" -import { useOption } from "../../useOption" -import OptionItem from '../OptionItem' -import OptionLines from '../OptionLines' -import OptionTag from '../OptionTag' +import type { CategoryInstance } from '../types' import DarkModeInput from "./DarkModeInput" const FOLLOW_BROWSER: I18nKey = msg => msg.option.followBrowser -const SORTED_LOCALES: timer.Locale[] = ALL_LOCALES +const SORTED_LOCALES: tt4b.Locale[] = ALL_LOCALES // Keep the locale same as this browser first position .sort((a, _b) => a === localeSameAsBrowser ? -1 : 0) -const allLocaleOptions = (["default", ...SORTED_LOCALES] satisfies timer.option.LocaleOption[]).map(locale => ({ +const allLocaleOptions = (["default", ...SORTED_LOCALES] satisfies tt4b.option.LocaleOption[]).map(locale => ({ value: locale, label: locale === "default" ? t(FOLLOW_BROWSER) : localeMessages[locale].name })) -function copy(target: timer.option.AppearanceOption, source: timer.option.AppearanceOption) { +function copy(target: tt4b.option.AppearanceOption, source: tt4b.option.AppearanceOption) { target.displayWhitelistMenu = source.displayWhitelistMenu target.displayBadgeText = source.displayBadgeText target.badgeBgColor = source.badgeBgColor @@ -46,14 +43,13 @@ function copy(target: timer.option.AppearanceOption, source: timer.option.Appear target.chartAnimationDuration = source.chartAnimationDuration } -const DEFAULT_ANIMA_DURATION = defaultAppearance().chartAnimationDuration const DEFAULT_SIDE_PANEL_ENABLED = true const _default = defineComponent((_props, ctx) => { - const { option } = useOption<timer.option.AppearanceOption>({ - defaultValue: defaultAppearance, copy, - onChange: async val => optionService.isDarkMode(val).then(toggle) + const { option } = useOption<tt4b.option.AppearanceOption>({ + defaultValue: DEFAULT_APPEARANCE, copy, + onChange: processDarkMode, }) const { data: sidePanelEnabled, refresh: refreshSidePanel } = useRequest(isSidePanelEnabled, { defaultValue: DEFAULT_SIDE_PANEL_ENABLED, @@ -66,15 +62,15 @@ const _default = defineComponent((_props, ctx) => { ctx.expose({ reset() { handleSidePanelChange(DEFAULT_SIDE_PANEL_ENABLED) - copy(option, defaultAppearance()) + copy(option, DEFAULT_APPEARANCE) } - } satisfies OptionInstance) + } satisfies CategoryInstance) - const handleLocaleChange = (newVal: timer.option.LocaleOption) => { + const handleLocaleChange = (newVal: tt4b.option.LocaleOption) => { option.locale = newVal // await maybe not work in Firefox, so calculate the real locale again // GG Firefox - const realLocale: timer.Locale = newVal === "default" + const realLocale: tt4b.Locale = newVal === "default" ? localeSameAsBrowser : newVal ElMessageBox({ @@ -88,7 +84,7 @@ const _default = defineComponent((_props, ctx) => { const animaDurationTagType = computed<TagProps['type']>(() => { const val = option.chartAnimationDuration if (!val) return 'info' - if (val > DEFAULT_ANIMA_DURATION) return 'warning' + if (val > DEFAULT_APPEARANCE.chartAnimationDuration) return 'warning' return 'primary' }) @@ -111,7 +107,7 @@ const _default = defineComponent((_props, ctx) => { modelValue={option.locale} size="small" style={{ width: "120px" }} - onChange={(newVal: timer.option.LocaleOption) => handleLocaleChange(newVal)} + onChange={(newVal: tt4b.option.LocaleOption) => handleLocaleChange(newVal)} filterable options={allLocaleOptions} /> @@ -119,7 +115,7 @@ const _default = defineComponent((_props, ctx) => { {!IS_ANDROID && <> <OptionItem label={msg => msg.option.appearance.displayWhitelist} - defaultValue={t(msg => msg.option.yes)} + defaultValue={DEFAULT_APPEARANCE.displayWhitelistMenu} v-slots={{ whitelist: () => <OptionTag>{t(msg => msg.option.appearance.whitelistItem)}</OptionTag>, contextMenu: () => <OptionTag>{t(msg => msg.option.appearance.contextMenu)}</OptionTag>, @@ -132,7 +128,7 @@ const _default = defineComponent((_props, ctx) => { </OptionItem> <OptionItem label={msg => msg.option.appearance.displayBadgeText} - defaultValue={t(msg => msg.option.yes)} + defaultValue={DEFAULT_APPEARANCE.displayBadgeText} v-slots={{ timeInfo: () => <OptionTag>{t(msg => msg.option.appearance.badgeTextContent)}</OptionTag>, icon: () => <OptionTag>{t(msg => msg.option.appearance.icon)}</OptionTag>, @@ -155,7 +151,7 @@ const _default = defineComponent((_props, ctx) => { </OptionItem> <OptionItem label={msg => msg.option.appearance.printInConsole.label} - defaultValue={t(msg => msg.option.yes)} + defaultValue={DEFAULT_APPEARANCE.printInConsole} v-slots={{ console: () => <OptionTag>{t(msg => msg.option.appearance.printInConsole.console)}</OptionTag>, info: () => <OptionTag>{t(msg => msg.option.appearance.printInConsole.info)}</OptionTag>, @@ -180,7 +176,7 @@ const _default = defineComponent((_props, ctx) => { )} <OptionItem label={msg => msg.option.appearance.animationDuration} - defaultValue={`${DEFAULT_ANIMA_DURATION}ms`} + defaultValue={`${DEFAULT_APPEARANCE.chartAnimationDuration}ms`} > <ElSlider modelValue={option.chartAnimationDuration} @@ -190,7 +186,7 @@ const _default = defineComponent((_props, ctx) => { persistent={false} style={{ width: '250px', display: 'inline-flex', marginInlineStart: '10px' } satisfies StyleValue} formatTooltip={val => `${val}ms`} - onUpdate:modelValue={val => option.chartAnimationDuration = Array.isArray(val) ? val[0] : val} + onUpdate:modelValue={val => option.chartAnimationDuration = Array.isArray(val) ? (val[0] ?? 0) : val} /> <ElTag size="small" diff --git a/src/pages/app/components/Option/components/BackupOption/AutoInput.tsx b/src/pages/app/components/Option/categories/Backup/AutoInput.tsx similarity index 97% rename from src/pages/app/components/Option/components/BackupOption/AutoInput.tsx rename to src/pages/app/components/Option/categories/Backup/AutoInput.tsx index a3f6307c8..0d986e2a8 100644 --- a/src/pages/app/components/Option/components/BackupOption/AutoInput.tsx +++ b/src/pages/app/components/Option/categories/Backup/AutoInput.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { t, tN } from "@app/locale" +import { t, tN } from '@app/locale' import { locale } from "@i18n" import localeMessages from "@i18n/message/common/locale" import { ElInputNumber, ElSwitch } from "element-plus" diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx similarity index 55% rename from src/pages/app/components/Option/components/BackupOption/Clear/Step2.tsx rename to src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx index 9778251d9..0bb43dd7f 100644 --- a/src/pages/app/components/Option/components/BackupOption/Clear/Step2.tsx +++ b/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx @@ -5,20 +5,22 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { useDialogSop } from '@app/components/common/DialogSop/context' +import { t } from '@app/locale' import { ElAlert } from "element-plus" import { defineComponent, toRaw } from "vue" -import { type StatResult } from "./Step1" +import type { ClearForm } from './types' -const _default = defineComponent<{ data?: StatResult }>(props => { +const _default = defineComponent<{}>(() => { + const { form } = useDialogSop<ClearForm>() return () => ( <ElAlert type="success" closable={false}> {t(msg => msg.option.backup.clear.confirmTip, { - ...toRaw(props.data), - clientName: props.data?.client?.name || '' + ...toRaw(form.result), + clientName: form.client?.name ?? '' })} </ElAlert> ) -}, { props: ['data'] }) +}) export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx new file mode 100644 index 000000000..902db37ce --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2023-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { clearBackup, queryBackup } from '@api/sw/backup' +import DialogSop from "@app/components/common/DialogSop" +import { initDialogSopContext } from '@app/components/common/DialogSop/context' +import { t } from '@app/locale' +import { Delete } from "@element-plus/icons-vue" +import { BIRTHDAY, formatTimeYMD } from '@util/time' +import { ElButton } from "element-plus" +import { defineComponent } from "vue" +import ClientTable from '../ClientTable' +import Step2 from './Step2' +import type { ClearForm, StatResult } from './types' + +const STEP_TITLES = [ + t(msg => msg.option.backup.clientTable.selectTip), + t(msg => msg.option.backup.confirmStep), +] + +async function fetchStatResult(client: tt4b.backup.Client): Promise<StatResult> { + const { id: specCid, maxDate, minDate = BIRTHDAY } = client + const start = minDate ?? BIRTHDAY + const end = maxDate ?? formatTimeYMD(Date.now()) + const remoteRows = await queryBackup({ specCid, start, end }) + const siteSet: Set<string> = new Set() + remoteRows?.forEach(row => { + const { host } = row || {} + host && siteSet.add(host) + }) + const rowCount = remoteRows?.length || 0 + const hostCount = siteSet?.size || 0 + return { rowCount, hostCount } +} + +const _default = defineComponent(() => { + const { step, form, open } = initDialogSopContext<ClearForm>({ + stepCount: STEP_TITLES.length, + init: () => ({}), + onNext: async ({ form }) => { + const client = form.client + if (!client) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + form.result = await fetchStatResult(client) + }, + onFinish: async ({ form }) => { + const cid = form.client?.id + if (!cid) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + const errMsg = await clearBackup(cid) + if (errMsg) throw new Error(errMsg) + }, + }) + + + return () => <> + <ElButton type="danger" icon={Delete} onClick={() => open()}> + {t(msg => msg.option.backup.clear.btn)} + </ElButton> + <DialogSop + title={t(msg => msg.option.backup.clear.btn)} + finishButton={{ type: 'danger', text: t(msg => msg.option.backup.clear.btn) }} + stepTitles={STEP_TITLES} + > + {step.value === 0 ? <ClientTable onSelect={c => form.client = c} /> : <Step2 />} + </DialogSop> + </> +}) + +export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Clear/types.ts b/src/pages/app/components/Option/categories/Backup/Clear/types.ts new file mode 100644 index 000000000..32fdcc5a3 --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/Clear/types.ts @@ -0,0 +1,9 @@ +export type ClearForm = { + client?: tt4b.backup.Client + result?: StatResult +} + +export type StatResult = { + rowCount: number + hostCount: number +} diff --git a/src/pages/app/components/Option/components/BackupOption/ClientTable.tsx b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx similarity index 69% rename from src/pages/app/components/Option/components/BackupOption/ClientTable.tsx rename to src/pages/app/components/Option/categories/Backup/ClientTable.tsx index 71959765d..a5544daf3 100644 --- a/src/pages/app/components/Option/components/BackupOption/ClientTable.tsx +++ b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx @@ -5,18 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { cvt2LocaleTime } from "@app/util/time" +import { allBackupClients } from "@api/sw/backup" +import { t } from '@app/locale' +import { cvt2LocaleTime } from '@app/util/time' import { Loading, RefreshRight } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useRequest } from "@hooks" -import processor from "@service/backup/processor" -import { getCid } from "@service/meta-service" +import { useRequest } from '@hooks' import { - ElLink, ElMessage, ElRadio, ElTable, ElTableColumn, ElTag, useNamespace, - type RenderRowData, + ElLink, ElMessage, ElRadio, ElTable, ElTableColumn, ElTag, useNamespace, type RenderRowData, } from "element-plus" -import { defineComponent, ref, StyleValue, toRaw } from "vue" +import { defineComponent, ref, toRaw, type StyleValue } from "vue" const useStyle = () => { const radioNs = useNamespace('radio') @@ -28,29 +26,21 @@ const useStyle = () => { return { radioCls } } -const formatTime = (value: timer.backup.Client): string => { +const formatTime = (value: tt4b.backup.Client): string => { const { minDate, maxDate } = value || {} const min = minDate ? cvt2LocaleTime(minDate) : '' const max = maxDate ? cvt2LocaleTime(maxDate) : '' return `${min} - ${max}` } -const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }>(props => { - const { data: list, loading, refresh } = useRequest(async () => { - const { success, data, errorMsg } = await processor.listClients() || {} - if (!success) { - throw new Error(errorMsg) - } - return data - }, { +const _default = defineComponent<{ onSelect: ArgCallback<tt4b.backup.Client> }>(props => { + const { data: list, loading, refresh } = useRequest(allBackupClients, { defaultValue: [], - onError: e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...') + onError: e => ElMessage.error(e instanceof Error ? e.message : String(e ?? 'Unknown error...')) }) - const { data: localCid } = useRequest(getCid) - const selectedCid = ref<string>() - const handleRowSelect = (row: timer.backup.Client) => { + const handleRowSelect = (row: tt4b.backup.Client) => { selectedCid.value = row.id props.onSelect?.(toRaw(row)) } @@ -63,7 +53,7 @@ const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }> maxHeight="40vh" class="backup-client-table" highlightCurrentRow - onCurrent-change={(row: timer.backup.Client) => handleRowSelect(row)} + onCurrent-change={(row: tt4b.backup.Client) => handleRowSelect(row)} emptyText={loading.value ? 'Loading data ...' : 'Empty data'} > <ElTableColumn @@ -78,7 +68,7 @@ const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }> underline="never" /> ), - default: ({ row }: RenderRowData<timer.backup.Client>) => ( + default: ({ row }: RenderRowData<tt4b.backup.Client>) => ( <ElRadio class={radioCls} value={row.id} @@ -93,17 +83,17 @@ const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }> align="center" headerAlign="center" width={320} - formatter={(client: timer.backup.Client) => client.id || '-'} + formatter={(client: tt4b.backup.Client) => client.id || '-'} /> <ElTableColumn label={t(msg => msg.option.backup.client, { input: '' })} align="center" headerAlign="center" > - {({ row: client }: RenderRowData<timer.backup.Client>) => <> + {({ row: client }: RenderRowData<(tt4b.backup.Client & { current: boolean })>) => <> {client.name || '-'} <ElTag - v-show={localCid.value === client?.id} + v-show={client.current} size="small" type="danger" style={{ height: '20px', marginInline: '6px 0' } satisfies StyleValue} > diff --git a/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx new file mode 100644 index 000000000..106ef0491 --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { useDialogSop } from '@app/components/common/DialogSop/context' +import CompareTable from "@app/components/common/imported/CompareTable" +import ResolutionRadio from "@app/components/common/imported/ResolutionRadio" +import { t } from '@app/locale' +import Flex from "@pages/components/Flex" +import { ElAlert } from "element-plus" +import { defineComponent } from "vue" +import { DownloadForm } from './types' + +const _default = defineComponent<{}>(() => { + const { form } = useDialogSop<DownloadForm>() + + return () => ( + <Flex column width='100%' gap={20} margin='40px 20px 0 20px'> + <ElAlert type="success" closable={false}> + { + t(msg => msg.option.backup.download.confirmTip, { + clientName: form.client?.name, + size: form.data.rows.length, + }) + } + </ElAlert> + <CompareTable data={form.data} comparedCol={msg => msg.option.backup.download.willDownload} /> + <Flex justify="center"> + <ResolutionRadio modelValue={form.resolution} onChange={v => form.resolution = v} /> + </Flex> + </Flex> + ) +}) + +export default _default diff --git a/src/pages/app/components/Option/categories/Backup/Download/index.tsx b/src/pages/app/components/Option/categories/Backup/Download/index.tsx new file mode 100644 index 000000000..ac0d3544c --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/Download/index.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { previewBackup } from '@api/sw/backup' +import { importOther } from '@api/sw/immigration' +import DialogSop from '@app/components/common/DialogSop' +import { initDialogSopContext } from '@app/components/common/DialogSop/context' +import { t } from "@app/locale" +import { Files } from "@element-plus/icons-vue" +import { BIRTHDAY, formatTimeYMD } from '@util/time' +import { ElButton } from "element-plus" +import { defineComponent, toRaw } from "vue" +import ClientTable from '../ClientTable' +import Step2 from './Step2' +import type { DownloadForm } from './types' + +const STEP_TITLES = [ + t(msg => msg.option.backup.clientTable.selectTip), + t(msg => msg.option.backup.confirmStep), +] + +async function fetchData(client: tt4b.backup.Client): Promise<tt4b.imported.Data> { + const { id: specCid, maxDate, minDate } = client + const start = minDate ?? BIRTHDAY + const end = maxDate ?? formatTimeYMD(Date.now()) + const rows = await previewBackup({ specCid, start, end }) + return { rows, focus: true, time: true } +} + +const _default = defineComponent(() => { + const { open, step, form } = initDialogSopContext<DownloadForm>({ + stepCount: STEP_TITLES.length, + init: () => ({ data: { rows: [], focus: true, time: true }, resolution: undefined }), + onNext: async ({ form, target }) => { + if (target === 1) { + const clientVal = form.client + if (!clientVal) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + form.data = await fetchData(clientVal) + } + }, + onFinish: async ({ form }) => { + const resolution = form.resolution + if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) + const data = form.data + if (!data) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + await importOther({ resolution, data: toRaw(data) }) + }, + }) + + return () => <> + <ElButton type="primary" icon={Files} onClick={() => open()}> + {t(msg => msg.option.backup.download.btn)} + </ElButton> + <DialogSop + width='80%' + title={t(msg => msg.option.backup.download.btn)} + stepTitles={STEP_TITLES} + finishButton={{ text: t(msg => msg.option.backup.download.btn) }} + > + <ClientTable v-show={step.value === 0} onSelect={c => form.client = c} /> + <Step2 v-show={step.value === 1} /> + </DialogSop> + </> +}) + +export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Download/types.ts b/src/pages/app/components/Option/categories/Backup/Download/types.ts new file mode 100644 index 000000000..6a60bdf1a --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/Download/types.ts @@ -0,0 +1,5 @@ +export type DownloadForm = { + client?: tt4b.backup.Client + resolution?: tt4b.imported.ConflictResolution + data: tt4b.imported.Data +} \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/Footer.tsx b/src/pages/app/components/Option/categories/Backup/Footer.tsx similarity index 52% rename from src/pages/app/components/Option/components/BackupOption/Footer.tsx rename to src/pages/app/components/Option/categories/Backup/Footer.tsx index 16687e34d..5e94910e2 100644 --- a/src/pages/app/components/Option/components/BackupOption/Footer.tsx +++ b/src/pages/app/components/Option/categories/Backup/Footer.tsx @@ -5,57 +5,54 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { checkAuth, getLastBackUp, syncData } from "@api/sw/backup" +import { t } from '@app/locale' import { Operation, UploadFilled } from "@element-plus/icons-vue" -import { useManualRequest, useRequest, useState } from "@hooks" +import { css } from '@emotion/css' +import { useManualRequest, useRequest } from "@hooks" import Flex from "@pages/components/Flex" -import processor from "@service/backup/processor" -import { getLastBackUp } from "@service/meta-service" import { formatTime } from "@util/time" -import { ElButton, ElDivider, ElLoading, ElMessage, ElText } from "element-plus" +import { ElButton, ElDivider, ElMessage, ElText, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" import Clear from "./Clear" import Download from "./Download" -async function handleTest() { - const loading = ElLoading.service({ text: "Please wait...." }) - try { - const { errorMsg } = await processor.checkAuth() - if (!errorMsg) { - ElMessage.success("Valid!") - } else { - ElMessage.error(errorMsg) +const useStyle = () => { + const buttonNs = useNamespace('button') + return css` + .${buttonNs.b()}+.${buttonNs.b()} { + margin-inline-start: 0px; } - } finally { - loading.close() - } + ` } const TIME_FORMAT = t(msg => msg.calendar.timeFormat) -const _default = defineComponent<{ type: timer.backup.Type }>(props => { - const [lastTime, setLastTime] = useState<number>() +const _default = defineComponent<{ type: tt4b.backup.Type }>(props => { - useRequest(() => getLastBackUp(props.type).then(d => d?.ts), { + const { data: lastTime, refresh: refreshLastTime } = useRequest(() => getLastBackUp(props.type), { deps: () => props.type, - onSuccess: setLastTime, }) - const { refresh: handleBackup } = useManualRequest(() => processor.syncData(), { + const { refresh: handleBackup } = useManualRequest(syncData, { loadingText: "Doing backup....", - onSuccess: ({ success, data, errorMsg }) => { - if (success) { - ElMessage.success('Successfully!') - setLastTime(data ?? Date.now()) - } else { - ElMessage.error(errorMsg ?? 'Unknown error') - } + onSuccess: errorMsg => { + if (errorMsg) return ElMessage.error(errorMsg) + ElMessage.success('Successfully!') + refreshLastTime() }, }) + const { refresh: handleTest } = useManualRequest(checkAuth, { + loadingText: "Please wait....", + onSuccess: err => err ? ElMessage.error(err) : ElMessage.success("Valid!"), + }) + + const footerCls = useStyle() + return () => <> <ElDivider /> - <Flex gap={12}> + <Flex gap={12} wrap class={footerCls}> <ElButton type="primary" icon={Operation} onClick={handleTest}> {t(msg => msg.button.test)} </ElButton> diff --git a/src/pages/app/components/Option/components/BackupOption/index.tsx b/src/pages/app/components/Option/categories/Backup/index.tsx similarity index 64% rename from src/pages/app/components/Option/components/BackupOption/index.tsx rename to src/pages/app/components/Option/categories/Backup/index.tsx index 9e4f0a52a..df0f1333f 100644 --- a/src/pages/app/components/Option/components/BackupOption/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/index.tsx @@ -8,26 +8,24 @@ import { DEFAULT_VAULT as DEFAULT_OBSIDIAN_BUCKET, DEFAULT_ENDPOINT as DEFAULT_OBSIDIAN_ENDPOINT, } from "@api/obsidian" -import { t } from "@app/locale" +import { OptionItem, OptionLines, OptionTooltip } from '@app/components/Option/components' +import { t } from '@app/locale' import { ElInput, ElSelect } from "element-plus" import { computed, defineComponent } from "vue" -import { type OptionInstance } from "../../common" -import OptionItem from "../OptionItem" -import OptionLines from '../OptionLines' -import OptionTooltip from "../OptionTooltip" +import type { CategoryInstance } from '../types' import AutoInput from "./AutoInput" import Footer from "./Footer" -import { useOptionState } from "./state" +import { useBackup } from "./useBackup" -const ALL_TYPES: timer.backup.Type[] = [ +const ALL_TYPES: tt4b.backup.Type[] = [ 'none', 'gist', 'web_dav', 'obsidian_local_rest_api', ] -const TYPE_NAMES: { [t in timer.backup.Type]: string } = { - none: t(msg => msg.option.backup.meta.none.label), +const TYPE_NAMES: Record<tt4b.backup.Type, string> = { + none: t(msg => msg.option.off), gist: 'GitHub Gist', obsidian_local_rest_api: 'Obsidian - Local REST API', web_dav: 'WebDAV' @@ -37,46 +35,42 @@ const LONG_INPUT_WIDTH = 'min(400px, calc(100vw - 80px))' const _default = defineComponent((_, ctx) => { const { - backupType, clientName, reset, - autoBackUp, autoBackUpInterval, - auth, account, password, + option, auth, account, password, reset, ext, setExtField, - } = useOptionState() + } = useBackup() - const isNotNone = computed(() => backupType.value && backupType.value !== 'none') + const isNotNone = computed(() => option.backupType !== 'none') - ctx.expose({ reset } satisfies OptionInstance) + ctx.expose({ reset } satisfies CategoryInstance) return () => <OptionLines> <OptionItem label={msg => msg.option.backup.type} defaultValue={TYPE_NAMES['none']}> <ElSelect - modelValue={backupType.value} + modelValue={option.backupType} size="small" - onChange={(val: timer.backup.Type) => backupType.value = val} + onChange={(val: tt4b.backup.Type) => option.backupType = val} options={ALL_TYPES.map(value => ({ value, label: TYPE_NAMES[value] }))} /> - </OptionItem > - <OptionItem - v-show={isNotNone.value} - label={_ => "{input}"} - defaultValue={t(msg => msg.option.no)} - > - <AutoInput - autoBackup={autoBackUp.value} - interval={autoBackUpInterval.value} - onAutoBackupChange={val => autoBackUp.value = val} - onIntervalChange={val => autoBackUpInterval.value = val} - /> </OptionItem> - {backupType.value === 'gist' && <> + {isNotNone.value && ( + <OptionItem label="{input}" defaultValue={false}> + <AutoInput + autoBackup={option.autoBackUp} + interval={option.autoBackUpInterval} + onAutoBackupChange={val => option.autoBackUp = val} + onIntervalChange={val => val !== undefined && (option.autoBackUpInterval = val)} + /> + </OptionItem> + )} + {option.backupType === 'gist' && ( <OptionItem - key="gist-token" - label={_ => 'Personal Access Token {info} {input}'} + label="Personal Access Token {info} {input}" v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.backup.meta.gist.authInfo)}</OptionTooltip> }} > <ElInput + name='token' modelValue={auth.value} size="small" type="password" @@ -85,10 +79,9 @@ const _default = defineComponent((_, ctx) => { onInput={val => auth.value = val?.trim?.() || ''} /> </OptionItem> - </>} - {backupType.value === 'obsidian_local_rest_api' && <> + )} + {option.backupType === 'obsidian_local_rest_api' && <> <OptionItem - key="obsidian-endpoint" label={msg => msg.option.backup.label.endpoint} v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.backup.meta.obsidian_local_rest_api.endpointInfo)}</OptionTooltip> @@ -102,7 +95,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => setExtField('endpoint', val)} /> </OptionItem> - <OptionItem key="obsidian-vault" label={_ => "Vault Name {input}"}> + <OptionItem label="Vault Name {input}"> <ElInput placeholder={DEFAULT_OBSIDIAN_BUCKET} modelValue={ext.value?.bucket} @@ -111,7 +104,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => setExtField('bucket', val)} /> </OptionItem> - <OptionItem key="obsidian-path" label={msg => msg.option.backup.label.path} required> + <OptionItem label={msg => msg.option.backup.label.path} required> <ElInput modelValue={ext.value?.dirPath} size="small" @@ -119,7 +112,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => setExtField('dirPath', val)} /> </OptionItem> - <OptionItem key="obsidian-auth" label={_ => "Authorization {input}"} required> + <OptionItem label="Authorization {input}" required> <ElInput modelValue={auth.value} size="small" @@ -130,9 +123,8 @@ const _default = defineComponent((_, ctx) => { /> </OptionItem> </>} - {backupType.value === 'web_dav' && <> + {option.backupType === 'web_dav' && <> <OptionItem - key="web-dav-endpoint" label={msg => msg.option.backup.label.endpoint} v-slots={{ info: () => '' }} required @@ -145,7 +137,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => setExtField('endpoint', val)} /> </OptionItem> - <OptionItem key='web-dav-path' label={msg => msg.option.backup.label.path} required> + <OptionItem label={msg => msg.option.backup.label.path} required> <ElInput modelValue={ext.value?.dirPath} placeholder="/for/example" @@ -154,7 +146,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => setExtField('dirPath', val)} /> </OptionItem> - <OptionItem key='web-dav-acc' label={msg => msg.option.backup.label.account} required> + <OptionItem label={msg => msg.option.backup.label.account} required> <ElInput modelValue={account.value} size="small" @@ -162,7 +154,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => account.value = val?.trim?.()} /> </OptionItem> - <OptionItem key='web-dav-psw' label={msg => msg.option.backup.label.password} required> + <OptionItem label={msg => msg.option.backup.label.password} required> <ElInput modelValue={password.value} size="small" @@ -172,15 +164,17 @@ const _default = defineComponent((_, ctx) => { /> </OptionItem> </>} - <OptionItem v-show={isNotNone.value} label={msg => msg.option.backup.client}> - <ElInput - modelValue={clientName.value} - size="small" - style={{ width: "120px" }} - onInput={val => clientName.value = val?.trim?.() || ''} - /> - </OptionItem> - {isNotNone.value && <Footer type={backupType.value} />} + {isNotNone.value && <> + <OptionItem label={msg => msg.option.backup.client}> + <ElInput + modelValue={option.clientName} + size="small" + style={{ width: "120px" }} + onInput={val => option.clientName = val?.trim?.() ?? ''} + /> + </OptionItem> + <Footer type={option.backupType} /> + </>} </OptionLines> }) diff --git a/src/pages/app/components/Option/categories/Backup/useBackup.ts b/src/pages/app/components/Option/categories/Backup/useBackup.ts new file mode 100644 index 000000000..9b2e6e7de --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/useBackup.ts @@ -0,0 +1,79 @@ +import { useOption } from '@app/components/Option/useOption' +import { DEFAULT_BACKUP } from "@util/constant/option" +import { computed } from "vue" + +function copy(target: tt4b.option.BackupOption, source: tt4b.option.BackupOption) { + target.backupType = source.backupType + target.autoBackUp = source.autoBackUp + target.autoBackUpInterval = source.autoBackUpInterval + target.backupExts = source.backupExts + target.backupAuths = source.backupAuths + target.clientName = source.clientName + target.backupLogin = source.backupLogin +} + +export const useBackup = () => { + const { option } = useOption<tt4b.option.BackupOption>({ defaultValue: DEFAULT_BACKUP, copy }) + + const reset = () => { + // Only reset type and auto flag + option.backupType = DEFAULT_BACKUP.backupType + option.autoBackUp = DEFAULT_BACKUP.autoBackUp + } + + const auth = computed({ + get: () => option.backupAuths[option.backupType], + set: val => { + const typeVal = option.backupType + if (!typeVal) return + const newAuths = { + ...option.backupAuths, + [typeVal]: val, + } + option.backupAuths = newAuths + } + }) + + const ext = computed<tt4b.backup.TypeExt | undefined>({ + get: () => option.backupExts?.[option.backupType], + set: val => { + const typeVal = option.backupType + if (!typeVal) return + const newExts = { + ...option.backupExts, + [typeVal]: val, + } + option.backupExts = newExts + }, + }) + + const setExtField = (field: keyof tt4b.backup.TypeExt, val: string) => { + const newVal = { ...(ext.value || {}), [field]: val?.trim?.() } + ext.value = newVal + } + + const setLoginField = (field: keyof tt4b.backup.LoginInfo, val: string) => { + const typeVal = option.backupType + if (!typeVal) return + const newLogin = { + ...option.backupLogin, + [typeVal]: { ...option.backupLogin?.[typeVal], [field]: val } + } + option.backupLogin = newLogin + } + + const account = computed<string | undefined>({ + get: () => option.backupLogin?.[option.backupType]?.acc, + set: val => setLoginField('acc', val ?? '') + }) + + const password = computed<string | undefined>({ + get: () => option.backupLogin?.[option.backupType]?.psw, + set: val => setLoginField('psw', val ?? '') + }) + + return { + option, auth, account, password, reset, + ext, setExtField, + } +} diff --git a/src/pages/app/components/Option/components/LimitOption/index.tsx b/src/pages/app/components/Option/categories/Limit/index.tsx similarity index 64% rename from src/pages/app/components/Option/components/LimitOption/index.tsx rename to src/pages/app/components/Option/categories/Limit/index.tsx index b414d8d7d..f94bab970 100644 --- a/src/pages/app/components/Option/components/LimitOption/index.tsx +++ b/src/pages/app/components/Option/categories/Limit/index.tsx @@ -4,24 +4,24 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { OptionItem, OptionLines } from '@app/components/Option/components' +import { useOption } from "@app/components/Option/useOption" +import { t } from '@app/locale' import { processVerification } from "@app/util/limit" import { Edit } from "@element-plus/icons-vue" import { css } from '@emotion/css' import { locale } from '@i18n' -import { defaultLimit } from "@util/constant/option" +import { DEFAULT_LIMIT } from "@util/constant/option" import { ElButton, ElInput, ElInputNumber, ElMessage, ElMessageBox, ElSelect, ElSwitch, useNamespace } from "element-plus" -import { defineComponent, type StyleValue } from "vue" -import { type OptionInstance } from "../../common" -import { useOption } from "../../useOption" -import OptionItem from "../OptionItem" -import OptionLines from '../OptionLines' +import { defineComponent, FunctionalComponent, type StyleValue } from "vue" +import type { CategoryInstance } from '../types' +import { use2faSetup } from './use2faSetup' import { usePswEdit } from "./usePswEdit" import { useVerify } from "./useVerify" const useLevelSelectStyle = () => { const selectNs = useNamespace('select') - const localWidth: Partial<Record<timer.Locale, number>> = { + const localWidth: Partial<Record<tt4b.Locale, number>> = { en: 330, uk: 330, zh_CN: 210, @@ -38,32 +38,34 @@ const useLevelSelectStyle = () => { return { width, cls } } -const ALL_LEVEL: timer.limit.RestrictionLevel[] = [ +const ALL_LEVEL: tt4b.limit.RestrictionLevel[] = [ 'nothing', 'verification', 'password', + '2fa', 'strict', ] -const ALL_DIFF: timer.limit.VerificationDifficulty[] = [ +const ALL_DIFF: tt4b.limit.VerificationDifficulty[] = [ 'easy', 'hard', 'disgusting', ] -function copy(target: timer.option.LimitOption, source: timer.option.LimitOption) { +function copy(target: tt4b.option.LimitOption, source: Readonly<tt4b.option.LimitOption>) { target.limitPrompt = source.limitPrompt target.limitLevel = source.limitLevel target.limitPassword = source.limitPassword target.limitVerifyDifficulty = source.limitVerifyDifficulty target.limitReminder = source.limitReminder target.limitReminderDuration = source.limitReminderDuration + target.limitDelayDuration = source.limitDelayDuration } -function reset(target: timer.option.LimitOption) { +function reset(target: tt4b.option.LimitOption) { const defaultValue: MakeOptional< - timer.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration' - > = defaultLimit() + tt4b.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration' + > = structuredClone(DEFAULT_LIMIT) // Not to reset limitPassword delete defaultValue.limitPassword // Not to reset difficulty @@ -83,50 +85,74 @@ const confirm4Strict = async (): Promise<void> => { }) } +const TestButton: FunctionalComponent<{ onClick: NoArgCallback }> = props => ( + <ElButton + size="small" + style={{ height: '28px', marginInlineStart: '5px' } satisfies StyleValue} + onClick={props.onClick} + > + {t(msg => msg.button.test)} + </ElButton> +) + const _default = defineComponent((_, ctx) => { - const { option } = useOption<timer.option.LimitOption>({ defaultValue: defaultLimit, copy }) + const { option } = useOption<tt4b.option.LimitOption>({ defaultValue: DEFAULT_LIMIT, copy }) const { verified, verify } = useVerify(option) const { modifyPsw } = usePswEdit({ reset: () => option.limitPassword }) + const { setup2fa } = use2faSetup() ctx.expose({ reset: () => verify().then(() => reset(option)).catch(() => { }) - } satisfies OptionInstance) + } satisfies CategoryInstance) - const handleLevelChange = async (val: timer.limit.RestrictionLevel) => { - try { - await verify() + const handleLevelChange = (val: tt4b.limit.RestrictionLevel) => { + verify().then(async () => { if (val === "strict") { await confirm4Strict() + } else if (val === '2fa') { + await setup2fa() } else if (val === "password") { option.limitPassword = await modifyPsw() } option.limitLevel = val - } catch (e) { - console.warn("Failed to verify", e) - } + }).catch(e => console.warn("Failed to verify", e)) } const handlePswEdit = async () => { - try { - await verify() + verify().then(async () => { option.limitPassword = await modifyPsw() ElMessage.success(t(msg => msg.operation.successMsg)) - } catch (e) { - console.warn("Failed to verify", e) - } + }).catch(e => console.warn("Failed to verify", e)) + } + + const handleDurationChange = (val: number | undefined) => { + if (typeof val !== "number") return + verify() + .then(() => option.limitDelayDuration = val) + .catch(e => console.warn("Failed to verify", e)) } + const handleTest = () => processVerification(option).then(() => ElMessage.success('Valid!')).catch(console.warn) + const levelSelectStyle = useLevelSelectStyle() return () => <OptionLines> + <OptionItem label={msg => msg.option.limit.delayDuration} defaultValue={DEFAULT_LIMIT.limitDelayDuration}> + <ElInputNumber + modelValue={option.limitDelayDuration} + size="small" min={1} max={20} + style={{ width: "80px" } satisfies StyleValue} + onChange={handleDurationChange} + /> + </OptionItem> <OptionItem label={msg => msg.option.limit.reminder} - defaultValue={t(msg => msg.option.no)} + defaultValue={false} v-slots={{ default: () => ( <ElSwitch modelValue={option.limitReminder} - onChange={val => option.limitReminder = val as boolean} + onChange={val => option.limitReminder = !!val} /> ), minInput: () => ( @@ -134,15 +160,15 @@ const _default = defineComponent((_, ctx) => { disabled={!option.limitReminder} modelValue={option.limitReminderDuration} onChange={val => val && (option.limitReminderDuration = val)} - min={1} max={20} - size="small" + min={1} max={20} size="small" + style={{ width: "80px" } satisfies StyleValue} /> ), }} /> <OptionItem label={msg => msg.option.limit.level.label} - defaultValue={t(msg => msg.option.limit.level[defaultLimit().limitLevel])} + defaultValue={t(msg => msg.option.limit.level[DEFAULT_LIMIT.limitLevel])} > <ElSelect modelValue={option.limitLevel} @@ -152,6 +178,7 @@ const _default = defineComponent((_, ctx) => { onChange={handleLevelChange} options={ALL_LEVEL.map(value => ({ value, label: t(msg => msg.option.limit.level[value]) }))} /> + {option.limitLevel === '2fa' && <TestButton onClick={handleTest} />} </OptionItem> <OptionItem v-show={option.limitLevel === "password"} @@ -171,24 +198,18 @@ const _default = defineComponent((_, ctx) => { <OptionItem v-show={option.limitLevel === "verification"} label={msg => msg.option.limit.level.verificationLabel} - defaultValue={t(msg => (msg.option.limit.level as any)[defaultLimit().limitVerifyDifficulty])} + defaultValue={t(msg => (msg.option.limit.level as any)[DEFAULT_LIMIT.limitVerifyDifficulty])} > <ElSelect modelValue={option.limitVerifyDifficulty} size="small" - onChange={(val: timer.limit.VerificationDifficulty) => verify() + onChange={(val: tt4b.limit.VerificationDifficulty) => verify() .then(() => option.limitVerifyDifficulty = val) .catch(console.warn) } options={ALL_DIFF.map(value => ({ value, label: t(msg => msg.option.limit.level.verificationDifficulty[value]) }))} /> - <ElButton - size="small" - style={{ height: '28px', marginInlineStart: '5px' } satisfies StyleValue} - onClick={() => processVerification(option)} - > - {t(msg => msg.button.test)} - </ElButton> + <TestButton onClick={handleTest} /> </OptionItem> <OptionItem label={msg => msg.option.limit.prompt}> <ElInput diff --git a/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx new file mode 100644 index 000000000..b12885a20 --- /dev/null +++ b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx @@ -0,0 +1,81 @@ +import { t } from '@app/locale' +import { CopyDocument } from '@element-plus/icons-vue' +import { useRequest, useState } from '@hooks' +import Flex from '@pages/components/Flex' +import Img from '@pages/components/Img' +import { generateQrDataUrl } from '@pages/util/qrcode' +import { ElButton, ElForm, ElFormItem, ElInput, ElMessage, ElText } from 'element-plus' +import { computed, defineComponent, toRef, watch } from 'vue' + +function extractSecret(otpauth: string): string { + const raw = new URL(otpauth).searchParams.get('secret') + return raw ?? 'Secret is unknown' +} + +async function copy(text: string) { + try { + await navigator.clipboard.writeText(text) + ElMessage.success('Copied') + } catch (e) { + const errMsg = e instanceof Error ? e.message : e ?? 'Unknown error' + ElMessage.error(`Copy failed: ${errMsg}`) + } +} + +export type FormInstance = { + getVerifyCode: () => string +} + +const _default = defineComponent<{ otpauth: string }>((props, ctx) => { + const otpauth = toRef(props, 'otpauth') + + const [code, setCode] = useState('') + watch(otpauth, () => setCode(''), { immediate: true }) + + const { data: qrData } = useRequest( + () => generateQrDataUrl({ text: otpauth.value, size: 200 }), + { deps: otpauth }, + ) + ctx.expose({ + getVerifyCode: () => code.value, + } satisfies FormInstance) + + const secret = computed(() => extractSecret(otpauth.value)) + + return () => ( + <ElForm labelPosition="top"> + <Flex column marginBottom={12} marginTop={12}> + <ElText style={{ lineHeight: 1.5 }}> + {t(msg => msg.option.limit.level.twoFaScanHint)} + </ElText> + </Flex> + <Flex column align="center" marginBottom={12}> + <Img src={qrData.value} size={200} /> + </Flex> + <ElFormItem label='2FA Secret'> + <ElInput + size='small' + modelValue={secret.value} + readonly v-slots={{ + append: () => <ElButton size="small" icon={CopyDocument} onClick={() => copy(secret.value)} />, + }} + /> + <Flex marginTop={6}> + <ElButton size="small" icon={CopyDocument} onClick={() => copy(otpauth.value)}> + {t(msg => msg.option.limit.level.twoFaCopyLink)} + </ElButton> + </Flex> + </ElFormItem> + <ElFormItem required label={t(msg => msg.option.limit.level.twoFaVerifyLabel)}> + <ElInput + size='small' + modelValue={code.value} + onUpdate:modelValue={val => setCode(val.trim())} + maxlength={6} + /> + </ElFormItem> + </ElForm > + ) +}, { props: ['otpauth'] }) + +export default _default diff --git a/src/pages/app/components/Option/categories/Limit/use2faSetup/index.tsx b/src/pages/app/components/Option/categories/Limit/use2faSetup/index.tsx new file mode 100644 index 000000000..794af3486 --- /dev/null +++ b/src/pages/app/components/Option/categories/Limit/use2faSetup/index.tsx @@ -0,0 +1,41 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' +import { ElMessage, ElMessageBox } from 'element-plus' +import { ref } from 'vue' +import Form, { type FormInstance } from './Form' + +export const use2faSetup = () => { + const form = ref<FormInstance>() + + const setup2fa = async () => { + const otpauth = await sendMsg2Runtime('meta.prepare2fa') + + const action = await ElMessageBox({ + title: t(msg => msg.option.limit.level.twoFaTitle), + message: () => <Form ref={form} otpauth={otpauth} />, + confirmButtonText: t(msg => msg.button.confirm), + showCancelButton: true, + closeOnClickModal: false, + showClose: false, + customStyle: { '--el-messagebox-padding-primary': '12px 20px' }, + beforeClose: (act, instance, done) => { + if (act !== 'confirm') return done() + + const code = form.value?.getVerifyCode().replace(/\s/g, '') + if (!code) return ElMessage.error('Verify code is empty') + if (!/^\d{6}$/.test(code)) return ElMessage.error('Invalid verify code') + + sendMsg2Runtime('meta.check2fa', code).then(ok => { + if (!ok) return ElMessage.error("Incorrect code") + instance.action = act + instance.inputValue = code + done() + }).catch(() => ElMessage.error("Failed to check code")) + }, + }) + + if (action !== 'confirm') throw new Error('User cancelled') + } + + return { setup2fa } +} diff --git a/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx similarity index 97% rename from src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx rename to src/pages/app/components/Option/categories/Limit/usePswEdit.tsx index 3cff5f95d..0a3769892 100644 --- a/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx +++ b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx @@ -1,6 +1,6 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { css } from '@emotion/css' -import { useState } from "@hooks" +import { useState } from '@hooks' import { ElForm, ElFormItem, ElInput, ElMessage, ElMessageBox, useNamespace } from "element-plus" type Options = { diff --git a/src/pages/app/components/Option/categories/Limit/useVerify.ts b/src/pages/app/components/Option/categories/Limit/useVerify.ts new file mode 100644 index 000000000..d69992c41 --- /dev/null +++ b/src/pages/app/components/Option/categories/Limit/useVerify.ts @@ -0,0 +1,19 @@ +import { listLimits } from "@api/sw/limit" +import { judgeVerificationRequired, processVerification } from "@app/util/limit/index" +import { ref } from "vue" + +export const useVerify = (option: tt4b.option.LimitOption) => { + const verified = ref(false) + + const verify = async (): Promise<void> => { + if (verified.value) return + const items = await listLimits() + const delayDuration = option.limitDelayDuration + const triggerResults = await Promise.all(items.map(item => judgeVerificationRequired(item, delayDuration))) + const anyTrigger = triggerResults.some(t => t) + if (anyTrigger) await processVerification(option) + verified.value = true + } + + return { verified, verify } +} \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Notification/Footer.tsx b/src/pages/app/components/Option/categories/Notification/Footer.tsx new file mode 100644 index 000000000..3c3746a4f --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/Footer.tsx @@ -0,0 +1,30 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' +import { Operation } from '@element-plus/icons-vue' +import Flex from '@pages/components/Flex' +import { ElButton, ElDivider, ElMessage } from 'element-plus' +import type { FunctionalComponent } from 'vue' + +const Footer: FunctionalComponent<{}> = () => { + const handleTest = async () => { + try { + const errMsg = await sendMsg2Runtime('option.testNotification') + if (errMsg) throw new Error(errMsg) + ElMessage.success('Valid!') + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) ?? 'Unknown error' + ElMessage.error(msg) + } + } + + return <> + <ElDivider /> + <Flex gap={12} wrap> + <ElButton type="primary" icon={Operation} onClick={handleTest}> + {t(msg => msg.button.test)} + </ElButton> + </Flex> + </> +} + +export default Footer \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx new file mode 100644 index 000000000..cc649c48a --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -0,0 +1,127 @@ +import { OptionItem, OptionLines } from '@app/components/Option/components' +import { t } from '@app/locale' +import { QuestionFilled } from '@element-plus/icons-vue' +import { ElIcon, ElInput, ElLink, ElMessage, ElSelect, ElTimePicker, ElTooltip } from 'element-plus' +import { computed, defineComponent, StyleValue } from 'vue' +import type { CategoryInstance } from '../types' +import Footer from './Footer' +import { useNotification } from './useNotification' +import usePermission from './usePermission' + +const CYCLE_LABELS: Record<tt4b.notification.Cycle, string> = { + none: t(msg => msg.option.off), + daily: t(msg => msg.option.notification.cycle.daily), + weekly: t(msg => msg.option.notification.cycle.weekly), +} + +const METHOD_LABELS: Record<tt4b.notification.Method, string> = { + browser: t(msg => msg.option.notification.method.browser), + callback: t(msg => msg.option.notification.method.callback.label), +} + +const ALL_WEEKDAYS = t(msg => msg.calendar.weekDays)?.split('|') || [] + +const PADDING: StyleValue = { paddingInlineStart: '2px' } + +const Notification = defineComponent((_, ctx) => { + const { option, weekday, datetime, reset } = useNotification() + const isNotNone = computed(() => option.notificationCycle !== 'none') + + const { checkRequest } = usePermission() + + const onCycleChange = async (val: tt4b.notification.Cycle) => { + if (val === 'none') { + option.notificationCycle = val + return + } + const result = await checkRequest(option.notificationMethod) + result ? option.notificationCycle = val : ElMessage.info('Denied by user') + } + + const onMethodChange = async (val: tt4b.notification.Method) => { + const result = await checkRequest(val) + result ? option.notificationMethod = val : ElMessage.info('Denied by user') + } + + ctx.expose({ + reset, + } satisfies CategoryInstance) + + return () => ( + <OptionLines> + <OptionItem label={msg => msg.option.notification.cycle.label} defaultValue={msg => msg.option.off}> + <ElSelect + modelValue={option.notificationCycle} + size="small" + style={{ width: "120px" } satisfies StyleValue} + onChange={val => onCycleChange(val as tt4b.notification.Cycle)} + options={Object.entries(CYCLE_LABELS).map(([value, label]) => ({ value: value as tt4b.notification.Cycle, label }))} + /> + {option.notificationCycle === 'weekly' && <> + <ElSelect + modelValue={weekday.value} + size="small" + style={{ minWidth: "70px", width: "70px", ...PADDING } satisfies StyleValue} + onChange={val => weekday.value = val as number} + options={ALL_WEEKDAYS.map((label, idx) => ({ value: idx, label }))} + /> + <span style={PADDING}>-</span> + </>} + {(option.notificationCycle === 'daily' || option.notificationCycle === 'weekly') && ( + <ElTimePicker + modelValue={datetime.value} + size="small" + style={{ width: "80px", ...PADDING } satisfies StyleValue} + onUpdate:modelValue={val => datetime.value = val as Date} + format="HH:mm" + clearable={false} + /> + )} + </OptionItem> + <OptionItem v-show={isNotNone.value} label={msg => msg.option.notification.method.label}> + <ElSelect + modelValue={option.notificationMethod} + size="small" + style={{ width: "150px" } satisfies StyleValue} + onChange={val => onMethodChange(val as tt4b.notification.Method)} + options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value: value as tt4b.notification.Method, label }))} + /> + </OptionItem> + {isNotNone.value && option.notificationMethod === 'callback' && ( + <> + <OptionItem label={msg => msg.option.notification.method.callback.url} required> + <ElInput + modelValue={option.notificationEndpoint || ''} + size="small" + style={{ width: "400px" } satisfies StyleValue} + onInput={val => option.notificationEndpoint = val} + placeholder="https://example.com/notification" + /> + <ElTooltip content='Document' effect='light' placement='top'> + <ElLink + target='_blank' underline={false} + href="https://github.com/sheepzh/time-tracker-4-browser/blob/main/examples/notification/README.md" + > + <ElIcon><QuestionFilled /></ElIcon> + </ElLink> + </ElTooltip> + </OptionItem> + <OptionItem label="Security Token {input}"> + <ElInput + modelValue={option.notificationAuthToken || ''} + size="small" + type="password" + showPassword={true} + style={{ width: "300px" } satisfies StyleValue} + onInput={val => option.notificationAuthToken = val} + placeholder="Optional" + /> + </OptionItem> + </> + )} + {isNotNone.value && <Footer />} + </OptionLines> + ) +}) + +export default Notification diff --git a/src/pages/app/components/Option/categories/Notification/useNotification.ts b/src/pages/app/components/Option/categories/Notification/useNotification.ts new file mode 100644 index 000000000..1f6c4aa4d --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/useNotification.ts @@ -0,0 +1,63 @@ +import { useOption } from '@app/components/Option/useOption' +import { DEFAULT_NOTIFICATION } from '@util/constant/option' +import { computed } from 'vue' + +function copy(target: tt4b.option.NotificationOption, source: Readonly<tt4b.option.NotificationOption>) { + target.notificationCycle = source.notificationCycle + target.notificationOffset = source.notificationOffset + target.notificationMethod = source.notificationMethod + target.notificationEndpoint = source.notificationEndpoint + target.notificationAuthToken = source.notificationAuthToken +} + +const MIN_PER_DAY = 24 * 60 + +export const useNotification = () => { + const { option } = useOption<tt4b.option.NotificationOption>({ defaultValue: DEFAULT_NOTIFICATION, copy }) + + const weekday = computed<number | null>({ + get() { + if (option.notificationCycle !== 'weekly') return null + let offset = option.notificationOffset + offset = Number.isNaN(offset) ? 0 : offset + return Math.floor(offset / MIN_PER_DAY) + }, + set(val) { + if (option.notificationCycle !== 'weekly' || val === null) return + const timeMinutes = option.notificationOffset % MIN_PER_DAY + option.notificationOffset = val * MIN_PER_DAY + timeMinutes + }, + }) + + const datetime = computed<Date | null>({ + get() { + if (option.notificationCycle === 'none') return null + let offset = option.notificationOffset + offset = Number.isNaN(offset) ? 0 : offset + const hours = Math.floor(offset / 60) + const minutes = offset % 60 + const date = new Date() + date.setHours(hours, minutes, 0, 0) + return date + }, + set(val) { + if (val === null || option.notificationCycle === 'none') return + const hours = val.getHours() + const minutes = val.getMinutes() + const dateMinutes = hours * 60 + minutes + + if (option.notificationCycle === 'daily') { + option.notificationOffset = dateMinutes + } else if (option.notificationCycle === 'weekly') { + option.notificationOffset = (weekday.value ?? 0) * MIN_PER_DAY + dateMinutes + } + }, + }) + + const reset = () => { + option.notificationCycle = DEFAULT_NOTIFICATION.notificationCycle + // other fields needn't be reset + } + + return { option, weekday, datetime, reset } +} diff --git a/src/pages/app/components/Option/categories/Notification/usePermission.tsx b/src/pages/app/components/Option/categories/Notification/usePermission.tsx new file mode 100644 index 000000000..d296b9d56 --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/usePermission.tsx @@ -0,0 +1,46 @@ +import { hasPerm, requestPerm } from '@api/chrome/permission' +import { IS_FIREFOX } from '@util/constant/environment' +import { onBeforeMount, reactive } from 'vue' + +const DATA_PERM: browser._manifest.OptionalDataCollectionPermission = 'technicalAndInteraction' +const BASE_PERM: chrome.runtime.ManifestPermission = 'notifications' + +const judgeDataPerm = async () => { + if (!IS_FIREFOX) return true + const perm = await browser?.permissions?.getAll() + return !!perm?.data_collection?.includes?.(DATA_PERM) +} + +const doRequestPerm = async (method: tt4b.notification.Method | undefined): Promise<boolean> => { + if (!method) return true + if (method === 'browser') { + return await requestPerm(BASE_PERM) + } + + if (!IS_FIREFOX) return true + + return await browser.permissions.request({ data_collection: [DATA_PERM] }) +} + +const usePermission = () => { + const granted = reactive<Record<tt4b.notification.Method, boolean>>({ + browser: false, + callback: false, + }) + + onBeforeMount(async () => { + granted.callback = await judgeDataPerm() + granted.browser = await hasPerm(BASE_PERM) + }) + + const checkRequest = async (method: tt4b.notification.Method) => { + // Invalid method to check + if (granted[method]) return true + + return granted[method] = await doRequestPerm(method) + } + + return { checkRequest } +} + +export default usePermission \ No newline at end of file diff --git a/src/pages/app/components/Option/components/TrackingOption.tsx b/src/pages/app/components/Option/categories/Tracking.tsx similarity index 66% rename from src/pages/app/components/Option/components/TrackingOption.tsx rename to src/pages/app/components/Option/categories/Tracking.tsx index c8b01d4c4..7faebc33d 100644 --- a/src/pages/app/components/Option/components/TrackingOption.tsx +++ b/src/pages/app/components/Option/categories/Tracking.tsx @@ -5,52 +5,73 @@ * https://opensource.org/licenses/MIT */ import { hasPerm, requestPerm } from "@api/chrome/permission" -import { isAllowedFileSchemeAccess, sendMsg2Runtime } from "@api/chrome/runtime" -import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { isAllowedFileSchemeAccess } from "@api/chrome/runtime" +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' +import { useManualRequest, useRequest } from '@hooks' import { locale } from "@i18n" import { rotate } from "@util/array" import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" -import { defaultTracking } from "@util/constant/option" +import { DEFAULT_TRACKING } from "@util/constant/option" import { MILL_PER_SECOND } from "@util/time" import { ElMessage, ElMessageBox, ElSelect, ElSwitch, ElTimePicker, ElTooltip } from "element-plus" import { computed, defineComponent } from "vue" -import { type OptionInstance } from "../common" +import { OptionItem, OptionLines, OptionTag, OptionTooltip } from '../components' import { useOption } from "../useOption" -import OptionItem from "./OptionItem" -import OptionLines from './OptionLines' -import OptionTag from './OptionTag' -import OptionTooltip from './OptionTooltip' +import type { CategoryInstance } from './types' -const DEFAULT_VALUE = defaultTracking() +const ALL_STORAGES: Record<tt4b.option.StorageType, string> = { + classic: 'chrome.storage.local', + indexed_db: 'IndexedDB', +} -const weekStartOptionPairs: [[timer.option.WeekStartOption, string]] = [ +const weekStartOptionPairs: [[tt4b.option.WeekStartOption, string]] = [ ['default', t(msg => msg.option.tracking.weekStartAsNormal)] ] const allWeekDays = t(msg => msg.calendar.weekDays) .split('|') - .map((weekDay, idx) => [idx + 1, weekDay] as [timer.option.WeekStartOption, string]) + .map((weekDay, idx) => [idx + 1, weekDay] as [tt4b.option.WeekStartOption, string]) rotate(allWeekDays, locale === 'zh_CN' ? 0 : 1, true) allWeekDays.forEach(weekDayInfo => weekStartOptionPairs.push(weekDayInfo)) -function copy(target: timer.option.TrackingOption, source: timer.option.TrackingOption) { +function copy(target: tt4b.option.TrackingOption, source: Readonly<tt4b.option.TrackingOption>) { target.countLocalFiles = source.countLocalFiles target.countTabGroup = source.countTabGroup target.weekStart = source.weekStart target.autoPauseTracking = source.autoPauseTracking target.autoPauseInterval = source.autoPauseInterval + target.storage = source.storage } const _default = defineComponent((_props, ctx) => { - const { option } = useOption({ defaultValue: defaultTracking, copy }) + const { option } = useOption<tt4b.option.TrackingOption>({ defaultValue: DEFAULT_TRACKING, copy }) const { data: fileAccess } = useRequest(isAllowedFileSchemeAccess) - ctx.expose({ - reset: () => { - const oldInterval = option.autoPauseInterval - copy(option, defaultTracking()) - option.autoPauseInterval = oldInterval + const reset = () => { + // Not to reset these fields + const { + autoPauseInterval: oldInterval, + storage: oldStorage, + } = option + copy(option, DEFAULT_TRACKING) + option.autoPauseInterval = oldInterval + option.storage = oldStorage + } + ctx.expose({ reset } satisfies CategoryInstance) + + const { refresh: changeStorageType, loading: storageMigrating } = useManualRequest( + type => sendMsg2Runtime('option.changeStorage', type), + { + loadingText: 'Data migrating...', + onSuccess: (_, type) => option.storage = type, } - } satisfies OptionInstance) + ) + + const handleChangeStorage = (type: tt4b.option.StorageType) => { + const msg = t(msg => msg.option.tracking.storageConfirm, { type: ALL_STORAGES[type] }) + ElMessageBox.confirm(msg, { type: 'warning' }) + .then(() => changeStorageType(type)) + .catch(() => ElMessage.info('Cancelled by user')) + } const interval = computed<number>({ get: _oldValue => { @@ -78,7 +99,7 @@ const _default = defineComponent((_props, ctx) => { const handleTabGroupChange = async (val: boolean) => { if (val && !await hasPerm("tabGroups")) { try { - const granted = await ElMessageBox.confirm(t(msg => msg.option.tracking.tabGroupsPermGrant), { type: 'primary' }) + const granted = await ElMessageBox.confirm(t(msg => msg.option.permGrantConfirm), { type: 'primary' }) .then(() => requestPerm("tabGroups")) if (!granted) { ElMessage.error("Grant permission failed") @@ -89,14 +110,13 @@ const _default = defineComponent((_props, ctx) => { } } option.countTabGroup = val - val && sendMsg2Runtime("enableTabGroup") } return () => <OptionLines> {!IS_ANDROID && <> <OptionItem label={msg => msg.option.tracking.autoPauseTrack} - defaultValue={DEFAULT_VALUE.autoPauseTracking} + defaultValue={DEFAULT_TRACKING.autoPauseTracking} v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.tracking.noActivityInfo)}</OptionTooltip>, maxTime: () => <ElTimePicker @@ -116,7 +136,7 @@ const _default = defineComponent((_props, ctx) => { /> <OptionItem label={msg => msg.option.tracking.countLocalFiles} - defaultValue={DEFAULT_VALUE.countLocalFiles} + defaultValue={DEFAULT_TRACKING.countLocalFiles} v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.tracking.localFilesInfo)}</OptionTooltip>, localFileTime: () => <OptionTag>{t(msg => msg.option.tracking.localFileTime)}</OptionTag>, @@ -133,7 +153,7 @@ const _default = defineComponent((_props, ctx) => { /> <OptionItem label={msg => msg.option.tracking.countTabGroup} - defaultValue={t(msg => msg.option.no)} + defaultValue={DEFAULT_TRACKING.countTabGroup} v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.tracking.tabGroupInfo)}</OptionTooltip>, default: () => <ElSwitch modelValue={option.countTabGroup} onChange={val => handleTabGroupChange(!!val)} /> @@ -148,10 +168,20 @@ const _default = defineComponent((_props, ctx) => { modelValue={option.weekStart} size="small" style={{ width: '120px' }} - onChange={(val: timer.option.WeekStartOption) => option.weekStart = val} + onChange={(val: tt4b.option.WeekStartOption) => option.weekStart = val} options={weekStartOptionPairs.map(([value, label]) => ({ value, label }))} /> </OptionItem> + <OptionItem label={msg => msg.option.tracking.storage}> + <ElSelect + modelValue={option.storage} + size="small" + loading={storageMigrating.value} + style={{ width: '160px' }} + onChange={val => handleChangeStorage(val)} + options={Object.entries(ALL_STORAGES).map(([value, label]) => ({ value, label }))} + /> + </OptionItem> </OptionLines> }) diff --git a/src/pages/app/components/Option/categories/index.ts b/src/pages/app/components/Option/categories/index.ts new file mode 100644 index 000000000..70a309ed1 --- /dev/null +++ b/src/pages/app/components/Option/categories/index.ts @@ -0,0 +1,8 @@ +export { default as Accessibility } from "./Accessibility" +export { default as Appearance } from "./Appearance" +export { default as Backup } from "./Backup" +export { default as Limit } from "./Limit" +export { default as Notification } from "./Notification" +export { default as Tracking } from "./Tracking" +export * from "./types" + diff --git a/src/pages/app/components/Option/categories/types.ts b/src/pages/app/components/Option/categories/types.ts new file mode 100644 index 000000000..42d15e510 --- /dev/null +++ b/src/pages/app/components/Option/categories/types.ts @@ -0,0 +1,4 @@ + +export interface CategoryInstance { + reset(): Promise<void> | void +} diff --git a/src/pages/app/components/Option/common.tsx b/src/pages/app/components/Option/common.tsx deleted file mode 100644 index 189fed850..000000000 --- a/src/pages/app/components/Option/common.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type I18nKey } from "@app/locale" -import { type Router, useRoute } from "vue-router" - -export const ALL_CATEGORIES = ["appearance", "tracking", 'limit', 'accessibility', 'backup'] as const -export type OptionCategory = typeof ALL_CATEGORIES[number] - -export type OptionInstance = { - reset: () => Promise<void> | void -} - -const PARAM = "i" - -export function parseQuery(): OptionCategory | undefined { - const initialQuery = useRoute().query?.[PARAM] - const queryVal: string | null | undefined = Array.isArray(initialQuery) ? initialQuery[0] : initialQuery - if (!queryVal) return undefined - const cate = queryVal as OptionCategory - return ALL_CATEGORIES.includes(cate) ? cate : undefined -} - -export function changeQuery(cate: OptionCategory, router: Router) { - const query: Record<string, string> = {} - query[PARAM] = cate - router.replace({ query }) -} - -export const CATE_LABELS: Record<OptionCategory, I18nKey> = { - appearance: msg => msg.option.appearance.title, - tracking: msg => msg.option.tracking.title, - limit: msg => msg.menu.limit, - accessibility: msg => msg.option.accessibility.title, - backup: msg => msg.option.backup.title, -} \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx b/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx deleted file mode 100644 index 0cce76b66..000000000 --- a/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import DialogSop, { type SopInstance, type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useManualRequest, useState } from "@hooks" -import processor from "@service/backup/processor" -import { ElMessage, ElStep, ElSteps } from "element-plus" -import { defineComponent, ref } from "vue" -import Step1, { type StatResult } from "./Step1" -import Step2 from "./Step2" - -type Props = { - onCancel: NoArgCallback - onClear: NoArgCallback -} - -const _default = defineComponent<Props>((props, ctx) => { - const [step, setStep] = useState<0 | 1>(0) - const step1 = ref<SopStepInstance<StatResult>>() - - const { data, refresh: handleNext, loading: readingClient } = useManualRequest(() => step1.value?.parseData?.(), { - onSuccess: () => setStep(1), - onError: e => ElMessage.warning((e as Error)?.message || 'Unknown error'), - }) - - const { refresh: handleClear, loading: deleting } = useManualRequest(async () => { - const cid = data.value?.client?.id - if (!cid) throw new Error('Client not selected') - const result = await processor.clear(cid) - if (!result.success) throw new Error(result.errorMsg) - }, { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onClear?.() - }, - onError: e => ElMessage.warning((e as Error)?.message || 'Unknown error'), - }) - - ctx.expose({ init: () => setStep(0) } satisfies SopInstance) - - return () => ( - <DialogSop - first={step.value === 0} - last={step.value === 1} - onCancel={props.onCancel} - onNext={handleNext} - nextLoading={readingClient.value} - onBack={() => step.value = 0} - onFinish={handleClear} - finishBtn={{ text: t(msg => msg.option.backup.clear.btn), type: 'danger' }} - finishLoading={deleting.value} - v-slots={{ - steps: () => ( - <ElSteps space={200} finishStatus="success" active={step.value} alignCenter> - <ElStep title={t(msg => msg.option.backup.clientTable.selectTip)} /> - <ElStep title={t(msg => msg.option.backup.confirmStep)} /> - </ElSteps> - ), - content: () => step.value === 0 ? <Step1 ref={step1} /> : <Step2 data={data.value} /> - }} - /> - ) -}, { props: ['onCancel', 'onClear'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/Step1.tsx b/src/pages/app/components/Option/components/BackupOption/Clear/Step1.tsx deleted file mode 100644 index 1961c7d1d..000000000 --- a/src/pages/app/components/Option/components/BackupOption/Clear/Step1.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useState } from "@hooks" -import processor from "@service/backup/processor" -import { BIRTHDAY, getBirthday, parseTime } from "@util/time" -import { defineComponent } from "vue" -import ClientTable from "../ClientTable" - -export type StatResult = { - rowCount: number - hostCount: number - client: timer.backup.Client -} - -async function fetchStatResult(client: timer.backup.Client): Promise<StatResult> { - const { id: specCid, maxDate, minDate = BIRTHDAY } = client - const start = parseTime(minDate) ?? getBirthday() - const end = parseTime(maxDate) ?? new Date() - const remoteRows: timer.core.Row[] = await processor.query({ specCid, start, end }) - const siteSet: Set<string> = new Set() - remoteRows?.forEach(row => { - const { host } = row || {} - host && siteSet.add(host) - }) - const rowCount = remoteRows?.length || 0 - const hostCount = siteSet?.size || 0 - return { - rowCount, - hostCount, - client, - } -} - -const _default = defineComponent((_, ctx) => { - const [client, setClient] = useState<timer.backup.Client>() - - const parseData = (): Promise<StatResult> => { - const clientVal = client.value - if (!clientVal) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) - return fetchStatResult(clientVal) - } - - ctx.expose({ parseData } satisfies SopStepInstance<StatResult>) - - return () => <ClientTable onSelect={setClient} /> -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/index.tsx b/src/pages/app/components/Option/components/BackupOption/Clear/index.tsx deleted file mode 100644 index e6ce843bc..000000000 --- a/src/pages/app/components/Option/components/BackupOption/Clear/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2023-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type SopInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { Delete } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { ElButton, ElDialog } from "element-plus" -import { defineComponent, ref, type StyleValue } from "vue" -import Sop from "./Sop" - -const _default = defineComponent(() => { - const [dialogVisible, open, close] = useSwitch(false) - const sop = ref<SopInstance>() - - return () => <> - <ElButton - type="danger" - icon={Delete} - onClick={open} - style={{ marginInlineStart: 0 } satisfies StyleValue} - > - {t(msg => msg.option.backup.clear.btn)} - </ElButton> - <ElDialog - title={t(msg => msg.option.backup.clear.btn)} - modelValue={dialogVisible.value} - onOpen={() => sop.value?.init?.()} - onClose={close} - > - <Sop ref={sop} onCancel={close} onClear={close} /> - </ElDialog> - </> -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/Download/Sop.tsx b/src/pages/app/components/Option/components/BackupOption/Download/Sop.tsx deleted file mode 100644 index 6e5ab43d5..000000000 --- a/src/pages/app/components/Option/components/BackupOption/Download/Sop.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import DialogSop, { type SopInstance, type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useManualRequest, useState } from "@hooks" -import processor from "@service/backup/processor" -import { fillExist, processImportedData } from "@service/components/import-processor" -import { getBirthday, parseTime } from "@util/time" -import { ElMessage, ElStep, ElSteps } from "element-plus" -import { defineComponent, ref } from "vue" -import ClientTable from "../ClientTable" -import Step2 from "./Step2" - -async function fetchData(client: timer.backup.Client): Promise<timer.imported.Data> { - const { id: specCid, maxDate, minDate } = client - const start = parseTime(minDate) ?? getBirthday() - const end = parseTime(maxDate) ?? new Date() - const remoteRows = await processor.query({ specCid, start, end }) - const rows: timer.imported.Row[] = remoteRows.map(rr => ({ - date: rr.date, - host: rr.host, - focus: rr.focus, - time: rr.time, - })) - await fillExist(rows) - return { rows, focus: true, time: true } -} - -type Props = { - onCancel: NoArgCallback - onDownload: NoArgCallback -} - -const _default = defineComponent<Props>((props, ctx) => { - const [step, setStep] = useState<0 | 1>(0) - const [client, setClient] = useState<timer.backup.Client>() - const step2 = ref<SopStepInstance<timer.imported.ConflictResolution>>() - - const init = () => { - setStep(0) - setClient() - } - - ctx.expose({ init } satisfies SopInstance) - - const { data, refresh: handleNext, loading: dataFetching } = useManualRequest(() => { - if (step.value !== 0) throw new Error("Data already loaded") - - const clientVal = client.value - if (!clientVal) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) - return fetchData(clientVal) - }, { - defaultValue: { rows: [] }, - onSuccess: () => step.value = 1, - onError: e => ElMessage.error((e as Error)?.message || 'Unknown error...'), - }) - - const { refresh: handleDownload, loading: downloading } = useManualRequest(async () => { - const resolution = await step2.value?.parseData?.() - if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) - - await processImportedData(data.value, resolution) - }, { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onDownload?.() - }, - onError: e => ElMessage.error((e as Error)?.message || 'Unknown error...'), - }) - - return () => ( - <DialogSop - first={step.value === 0} - last={step.value === 1} - onNext={handleNext} - nextLoading={dataFetching.value} - onCancel={props.onCancel} - onBack={init} - onFinish={handleDownload} - finishBtn={t(msg => msg.option.backup.download.btn)} - finishLoading={downloading.value} - v-slots={{ - steps: () => ( - <ElSteps finishStatus="success" active={step.value} alignCenter> - <ElStep title={t(msg => msg.option.backup.clientTable.selectTip)} /> - <ElStep title={t(msg => msg.option.backup.confirmStep)} /> - </ElSteps> - ), - content: () => step.value === 0 - ? <ClientTable onSelect={setClient} /> - : <Step2 - ref={step2} - data={data.value} - clientName={client.value?.name ?? ''} - /> - }} - /> - ) -}, { props: ['onCancel', 'onDownload'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx b/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx deleted file mode 100644 index 03f6fdcc9..000000000 --- a/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { SopStepInstance } from "@app/components/common/DialogSop" -import CompareTable from "@app/components/common/imported/CompareTable" -import ResolutionRadio from "@app/components/common/imported/ResolutionRadio" -import { t } from "@app/locale" -import { useState } from "@hooks" -import Flex from "@pages/components/Flex" -import { ElAlert } from "element-plus" -import { defineComponent } from "vue" - -type Props = { - data: timer.imported.Data - clientName: string -} - -const _default = defineComponent<Props>((props, ctx) => { - const [resolution, setResolution] = useState<timer.imported.ConflictResolution>() - - ctx.expose({ parseData: () => resolution.value } satisfies SopStepInstance<timer.imported.ConflictResolution | undefined>) - - return () => ( - <Flex column width='100%' gap={20} margin='40px 20px 0 20px'> - <ElAlert type="success" closable={false}> - { - t(msg => msg.option.backup.download.confirmTip, { - clientName: props.clientName, - size: props.data?.rows?.length || 0 - }) - } - </ElAlert> - <CompareTable data={props.data} comparedCol={msg => msg.option.backup.download.willDownload} /> - <Flex justify="center"> - <ResolutionRadio modelValue={resolution.value} onChange={setResolution} /> - </Flex> - </Flex> - ) -}, { props: ['data', 'clientName'] }) - -export default _default diff --git a/src/pages/app/components/Option/components/BackupOption/Download/index.tsx b/src/pages/app/components/Option/components/BackupOption/Download/index.tsx deleted file mode 100644 index c32ccf22e..000000000 --- a/src/pages/app/components/Option/components/BackupOption/Download/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { SopInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { Files } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { ElButton, ElDialog } from "element-plus" -import { defineComponent, ref } from "vue" -import Sop from "./Sop" - -const _default = defineComponent(() => { - const [dialogVisible, open, close] = useSwitch() - const sop = ref<SopInstance>() - - return () => <> - <ElButton type="primary" icon={Files} onClick={open}> - {t(msg => msg.option.backup.download.btn)} - </ElButton> - <ElDialog - alignCenter - title={t(msg => msg.option.backup.download.btn)} - width="70%" - modelValue={dialogVisible.value} - onOpen={() => sop.value?.init?.()} - onClose={close} - > - <Sop ref={sop} onCancel={close} onDownload={close} /> - </ElDialog> - </> -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/state.ts b/src/pages/app/components/Option/components/BackupOption/state.ts deleted file mode 100644 index b77e492c2..000000000 --- a/src/pages/app/components/Option/components/BackupOption/state.ts +++ /dev/null @@ -1,118 +0,0 @@ -import optionHolder from "@service/components/option-holder" -import optionService from "@service/option-service" -import { defaultBackup } from "@util/constant/option" -import { computed, onBeforeMount, type Ref, ref, toRaw, watch } from "vue" - -type Result = { - reset: () => void - backupType: Ref<timer.backup.Type> - clientName: Ref<string> - autoBackUp: Ref<boolean> - autoBackUpInterval: Ref<number | undefined> - auth: Ref<string | undefined> - account: Ref<string | undefined> - password: Ref<string | undefined> - ext: Ref<timer.backup.TypeExt | undefined> - setExtField: (field: keyof timer.backup.TypeExt, val: string) => void -} - -export const useOptionState = (): Result => { - const defaultOption = defaultBackup() - const backupType = ref(defaultOption.backupType) - const autoBackUp = ref(defaultOption.autoBackUp) - const autoBackUpInterval = ref(defaultOption.autoBackUpInterval) - const backupExts = ref(defaultOption.backupExts) - const backupAuths = ref(defaultOption.backupAuths) - const clientName = ref(defaultOption.clientName) - const login = ref(defaultOption.backupLogin) - - watch([ - backupType, - autoBackUp, autoBackUpInterval, - backupExts, backupAuths, login, - clientName, - ], () => optionService.setBackupOption({ - backupType: backupType.value, - autoBackUp: autoBackUp.value, - autoBackUpInterval: autoBackUpInterval.value, - backupExts: toRaw(backupExts.value), - backupAuths: toRaw(backupAuths.value), - clientName: clientName.value, - backupLogin: toRaw(login.value), - })) - - onBeforeMount(async () => { - const val = await optionHolder.get() - backupType.value = val?.backupType - autoBackUp.value = val?.autoBackUp - autoBackUpInterval.value = val?.autoBackUpInterval - backupExts.value = val?.backupExts - backupAuths.value = val?.backupAuths - clientName.value = val?.clientName - login.value = val?.backupLogin - }) - - const reset = () => { - // Only reset type and auto flag - backupType.value = defaultOption.backupType - autoBackUp.value = defaultOption.autoBackUp - } - - const auth = computed({ - get: () => backupAuths.value?.[backupType?.value], - set: val => { - const typeVal = backupType.value - if (!typeVal) return - const newAuths = { - ...backupAuths.value || {}, - [typeVal]: val, - } - backupAuths.value = newAuths - } - }) - - const ext = computed<timer.backup.TypeExt | undefined>({ - get: () => backupExts.value?.[backupType.value], - set: val => { - const typeVal = backupType.value - if (!typeVal) return - const newExts = { - ...backupExts.value || {}, - [typeVal]: val, - } - backupExts.value = newExts - }, - }) - - const setExtField = (field: keyof timer.backup.TypeExt, val: string) => { - const newVal = { ...(ext.value || {}), [field]: val?.trim?.() } - ext.value = newVal - } - - const setLoginField = (field: keyof timer.backup.LoginInfo, val: string) => { - const typeVal = backupType.value - if (!typeVal) return - const newLogin = { - ...login.value || {}, - [typeVal]: { ...(login.value?.[typeVal] || {}), [field]: val } - } - login.value = newLogin - } - - const account = computed<string | undefined>({ - get: () => login.value?.[backupType?.value]?.acc, - set: (val: string | undefined) => setLoginField('acc', val ?? '') - }) - - const password = computed<string | undefined>({ - get: () => login.value?.[backupType?.value]?.psw, - set: (val: string | undefined) => setLoginField('psw', val ?? '') - }) - - return { - backupType, clientName, reset, - autoBackUp, autoBackUpInterval, - auth, account, password, - ext, setExtField, - } -} diff --git a/src/pages/app/components/Option/components/OptionItem.tsx b/src/pages/app/components/Option/components/Item.tsx similarity index 86% rename from src/pages/app/components/Option/components/OptionItem.tsx rename to src/pages/app/components/Option/components/Item.tsx index 9a6a1b011..2033f4e7a 100644 --- a/src/pages/app/components/Option/components/OptionItem.tsx +++ b/src/pages/app/components/Option/components/Item.tsx @@ -1,6 +1,6 @@ -import { t, tN, type I18nKey } from "@app/locale" +import { t, tN, type I18nKey } from '@app/locale' import { css } from '@emotion/css' -import { MediaSize, useMediaSize } from '@hooks/useMediaSize' +import { MediaSize, useMediaSize } from '@hooks' import Flex from "@pages/components/Flex" import { colorVariant } from '@pages/util/style' import { ElTag, useNamespace } from "element-plus" @@ -19,7 +19,7 @@ const computedDefValText = (defVal: Props['defaultValue']): string | number | un case 'undefined': return undefined case 'string': case 'number': return defVal - case 'boolean': return t(defVal ? msg => msg.option.yes : msg => msg.option.no) + case 'boolean': return t(msg => msg.option[defVal ? 'yes' : 'no']) default: return t(defVal) } } @@ -27,7 +27,8 @@ const computedDefValText = (defVal: Props['defaultValue']): string | number | un const TAG_STYLE: StyleValue = { height: '20px', marginInlineStart: '4px' } const renderLabel = (label: Props['label'], param: any) => { - return typeof label === 'string' ? label : tN(label, param) + const key = typeof label === 'string' ? () => label : label + return tN(key, param) } export function isOptionItem(component: VNode): boolean { @@ -88,7 +89,7 @@ const useStyle = () => { return { lineCls, smCls } } -const OptionItem = defineComponent<Props>((props, { slots }) => { +const Item = defineComponent<Props>((props, { slots }) => { const defaultText = computed(() => computedDefValText(props.defaultValue)) const mediaSize = useMediaSize() const isSmScreen = computed(() => mediaSize.value <= MediaSize.sm) @@ -100,7 +101,7 @@ const OptionItem = defineComponent<Props>((props, { slots }) => { return ( <Flex class={[lineCls, isSmScreen.value && smCls]} align="center" justify="space-between" gap={10}> <Flex align="center" color='text-primary' gap={4} wrap lineHeight={32}> - {!!props.required && <span style={{ color: colorVariant('danger'), marginInlineEnd: 4 }}>*</span>} + {!!props.required && <span style={{ color: `var(${colorVariant('danger')})`, marginInlineEnd: 4 }}>*</span>} {renderLabel(props.label, param)} </Flex> {defaultText.value && !isSmScreen.value && ( @@ -115,7 +116,7 @@ const OptionItem = defineComponent<Props>((props, { slots }) => { } }, { props: ['label', 'required', 'defaultValue'] }) -const OptionItemAny = OptionItem as any -OptionItemAny[OPTION_ITEM_SYMBOL] = true +const ItemAny = Item as any +ItemAny[OPTION_ITEM_SYMBOL] = true -export default OptionItem \ No newline at end of file +export default Item \ No newline at end of file diff --git a/src/pages/app/components/Option/components/LimitOption/useVerify.ts b/src/pages/app/components/Option/components/LimitOption/useVerify.ts deleted file mode 100644 index cd4e98498..000000000 --- a/src/pages/app/components/Option/components/LimitOption/useVerify.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { judgeVerificationRequired, processVerification } from "@app/util/limit" -import limitService from "@service/limit-service" -import { ref } from "vue" - -export const useVerify = (option: timer.option.LimitOption) => { - const verified = ref(false) - - const verify = async (): Promise<void> => { - if (verified.value) return - const items = await limitService.select() - const triggerResults = await Promise.all((items || []).map(judgeVerificationRequired)) - const anyTrigger = triggerResults.some(t => !!t) - if (anyTrigger) { - await processVerification(option) - } - verified.value = true - } - - return { verified, verify } -} \ No newline at end of file diff --git a/src/pages/app/components/Option/components/OptionLines.tsx b/src/pages/app/components/Option/components/Lines.tsx similarity index 53% rename from src/pages/app/components/Option/components/OptionLines.tsx rename to src/pages/app/components/Option/components/Lines.tsx index 96dbf4337..865ba4b43 100644 --- a/src/pages/app/components/Option/components/OptionLines.tsx +++ b/src/pages/app/components/Option/components/Lines.tsx @@ -1,33 +1,31 @@ import { ElDivider } from 'element-plus' -import { type FunctionalComponent, h, isVNode, type VNode } from "vue" -import { isOptionItem } from './OptionItem' - -function isFragment({ type, children }: VNode): boolean { - return typeof type === 'symbol' && Array.isArray(children) -} +import { type FunctionalComponent, h, isVNode, type VNode, vShow } from "vue" +import { isOptionItem } from './Item' function isComment({ type, children }: VNode): boolean { return typeof type === 'symbol' && (children === null || children === undefined) } function isHidden({ dirs }: VNode): boolean { - return !!dirs?.some(({ dir, value }) => (dir as any)?.name === 'show' && value === false) + return !!dirs?.some(({ dir, value }) => dir === vShow && value === false) } function flattenChildren(original: VNode[]): VNode[] { const flat: VNode[] = [] + const stack: unknown[] = [...original] - for (const child of original) { + let child: unknown | undefined + while ((child = stack.shift()) != undefined) { if (!isVNode(child)) { - flat.push(child) + console.log('Found non-VNode child, ignored:', child) continue } - if (isFragment(child) && child.children) { - const fragmentChildren = Array.isArray(child.children) - ? child.children - : [child.children] - flat.push(...flattenChildren(fragmentChildren as VNode[])) + const { type, children } = child + if (typeof type === 'symbol' && Array.isArray(children)) { + // is Fragment, flatten it by pushing its children to stack + stack.unshift(...children) + continue } else { flat.push(child) } @@ -35,7 +33,7 @@ function flattenChildren(original: VNode[]): VNode[] { return flat } -const OptionLines: FunctionalComponent<{}> = (_, { slots }) => { +const Lines: FunctionalComponent<{}> = (_, { slots }) => { const children: VNode[] = [] let beforeIsItem = false @@ -50,10 +48,9 @@ const OptionLines: FunctionalComponent<{}> = (_, { slots }) => { children.push(child) beforeIsItem = thisIsItem } - - return h('div', {}, children) + return <div>{children}</div> } -OptionLines.displayName = 'OptionLines' +Lines.displayName = 'OptionLines' -export default OptionLines \ No newline at end of file +export default Lines \ No newline at end of file diff --git a/src/pages/app/components/Option/components/OptionTag.tsx b/src/pages/app/components/Option/components/OptionTag.tsx deleted file mode 100644 index ca275a862..000000000 --- a/src/pages/app/components/Option/components/OptionTag.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { h, type FunctionalComponent, type StyleValue } from "vue" - -const OptionTag: FunctionalComponent<{}> = (_, { slots: { default: default_ } }) => ( - <a style={{ color: '#F56C6C', fontSize: 'inherit' } satisfies StyleValue}> - {default_ && h(default_)} - </a> -) -OptionTag.displayName = 'OptionTag' - -export default OptionTag diff --git a/src/pages/app/components/Option/components/OptionTooltip.tsx b/src/pages/app/components/Option/components/OptionTooltip.tsx deleted file mode 100644 index f75fc29e3..000000000 --- a/src/pages/app/components/Option/components/OptionTooltip.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { InfoFilled } from "@element-plus/icons-vue" -import { ElIcon, ElTooltip } from "element-plus" -import { type FunctionalComponent } from "vue" - -const OptionTooltip: FunctionalComponent<{}> = (_, { slots: { default: content } }) => ( - content ? ( - <ElTooltip v-slots={{ content }}> - <ElIcon size={15}><InfoFilled /></ElIcon> - </ElTooltip> - ) : null -) -OptionTooltip.displayName = 'OptionTooltip' - -export default OptionTooltip \ No newline at end of file diff --git a/src/pages/app/components/Option/components/index.tsx b/src/pages/app/components/Option/components/index.tsx new file mode 100644 index 000000000..2b4899687 --- /dev/null +++ b/src/pages/app/components/Option/components/index.tsx @@ -0,0 +1,24 @@ +import { InfoFilled } from '@element-plus/icons-vue' +import { ElIcon, ElTooltip } from 'element-plus' +import { type FunctionalComponent, h, type StyleValue } from 'vue' + +const Tag: FunctionalComponent<{}> = (_, { slots: { default: default_ } }) => ( + <a style={{ color: '#F56C6C', fontSize: 'inherit' } satisfies StyleValue}> + {default_ && h(default_)} + </a> +) +Tag.displayName = 'OptionTag' +export const OptionTag = Tag + +const Tooltip: FunctionalComponent<{}> = (_, { slots: { default: content } }) => ( + content ? ( + <ElTooltip v-slots={{ content }}> + <ElIcon size={15}><InfoFilled /></ElIcon> + </ElTooltip> + ) : null +) +Tooltip.displayName = 'OptionTooltip' +export const OptionTooltip = Tooltip + +export { default as OptionItem } from "./Item" +export { default as OptionLines } from "./Lines" diff --git a/src/pages/app/components/Option/export-import.ts b/src/pages/app/components/Option/export-import.ts index de08301eb..6938f3f42 100644 --- a/src/pages/app/components/Option/export-import.ts +++ b/src/pages/app/components/Option/export-import.ts @@ -4,23 +4,23 @@ * https://opensource.org/licenses/MIT */ -import optionHolder from "@service/components/option-holder" +import { getOption, setOption } from "@api/sw/option" import { deserialize, exportJson } from "@util/file" import { mergeObject } from '@util/lang' -export interface ExportedSettings { +interface ExportedSettings { version: string timestamp: number - settings: timer.option.AllOption + settings: tt4b.option.AllOption } /** * Export all settings to JSON file */ export async function exportSettings(): Promise<void> { - const settings = await optionHolder.get() + const settings = await getOption() const exportData: ExportedSettings = { - version: '1.0', + version: chrome.runtime.getManifest().version, timestamp: Date.now(), settings, } @@ -43,18 +43,18 @@ export async function importSettings(jsonString: string): Promise<void> { const validatedSettings = await validateAndMergeSettings(importData.settings) // Set the imported settings - await optionHolder.set(validatedSettings) + await setOption(validatedSettings as Partial<tt4b.option.AllOption>) } /** * Validate imported settings and merge with defaults to ensure all required fields exist */ -async function validateAndMergeSettings(importedSettings: Partial<timer.option.AllOption>): Promise<timer.option.AllOption> { +async function validateAndMergeSettings(importedSettings: Partial<tt4b.option.AllOption>): Promise<tt4b.option.AllOption> { // Get current user settings as defaults instead of default options - const defaults = await optionHolder.get() + const current = await getOption() // Delete client name delete importedSettings['clientName'] - return mergeObject(defaults, importedSettings) + return mergeObject(current ?? {}, importedSettings) as tt4b.option.AllOption } /** diff --git a/src/pages/app/components/Option/index.tsx b/src/pages/app/components/Option/index.tsx index 4cd2818d1..184c4a44b 100644 --- a/src/pages/app/components/Option/index.tsx +++ b/src/pages/app/components/Option/index.tsx @@ -4,36 +4,34 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { MediaSize, useMediaSize } from "@hooks" +import { MediaSize, useMediaSize } from '@hooks' import { ElScrollbar } from 'element-plus' import { defineComponent, ref, type Ref, type StyleValue } from "vue" -import { type JSX } from "vue/jsx-runtime" -import { type OptionCategory, type OptionInstance } from "./common" -import AccessibilityOption from "./components/AccessibilityOption" -import AppearanceOption from "./components/AppearanceOption" -import BackupOption from './components/BackupOption' -import LimitOption from './components/LimitOption' -import TrackingOption from "./components/TrackingOption" +import type { JSX } from "vue/jsx-runtime" +import { Accessibility, Appearance, Backup, type CategoryInstance, Limit, Notification, Tracking } from './categories' import Select from "./Select" import Tabs from "./Tabs" +import type { OptionCategory } from "./useCategory" const _default = defineComponent(() => { - const paneRefMap: Record<OptionCategory, Ref<OptionInstance | undefined>> = { + const paneRefMap: Record<OptionCategory, Ref<CategoryInstance | undefined>> = { appearance: ref(), tracking: ref(), backup: ref(), limit: ref(), accessibility: ref(), + notification: ref(), } const mediaSize = useMediaSize() const slots: Record<OptionCategory, () => JSX.Element> = { - appearance: () => <AppearanceOption ref={paneRefMap.appearance} />, - tracking: () => <TrackingOption ref={paneRefMap.tracking} />, - limit: () => <LimitOption ref={paneRefMap.limit} />, - accessibility: () => <AccessibilityOption ref={paneRefMap.accessibility} />, - backup: () => <BackupOption ref={paneRefMap.backup} />, + appearance: () => <Appearance ref={paneRefMap.appearance} />, + tracking: () => <Tracking ref={paneRefMap.tracking} />, + limit: () => <Limit ref={paneRefMap.limit} />, + accessibility: () => <Accessibility ref={paneRefMap.accessibility} />, + backup: () => <Backup ref={paneRefMap.backup} />, + notification: () => <Notification ref={paneRefMap.notification} />, } const handleReset = (cate: OptionCategory) => paneRefMap[cate]?.value?.reset?.() diff --git a/src/pages/app/components/Option/useCategory.ts b/src/pages/app/components/Option/useCategory.ts new file mode 100644 index 000000000..641fe8ea7 --- /dev/null +++ b/src/pages/app/components/Option/useCategory.ts @@ -0,0 +1,53 @@ +import { t, type I18nKey } from '@app/locale' +import { createStringUnionGuard } from 'typescript-guard' +import { computed, type ShallowRef } from 'vue' +import { useRoute, useRouter, type LocationQuery } from 'vue-router' + +export type OptionCategory = 'appearance' | 'tracking' | 'limit' | 'accessibility' | 'backup' | 'notification' +const isCategory = createStringUnionGuard<OptionCategory>('appearance', 'tracking', 'limit', 'accessibility', 'backup', 'notification') + +const CATE_LABELS: Record<OptionCategory, I18nKey> = { + appearance: msg => msg.option.appearance.title, + tracking: msg => msg.option.tracking.title, + limit: msg => msg.base.limit, + accessibility: msg => msg.option.accessibility.title, + backup: msg => msg.option.backup.title, + notification: msg => msg.option.notification.title, +} + +const PARAM = "i" + +function parseInit(query: LocationQuery): OptionCategory | undefined { + const initialQuery = query[PARAM] + const queryVal = Array.isArray(initialQuery) ? initialQuery[0] : initialQuery + return isCategory(queryVal) ? queryVal : undefined +} + +export const useCategory = (): { + category: ShallowRef<OptionCategory> + setCategory: (value: unknown) => void + getLabel: (cate: unknown) => string | undefined +} => { + const route = useRoute() + const router = useRouter() + + const category = computed({ + set(value: OptionCategory) { + const oldQuery = route.query + const query: LocationQuery = { + ...oldQuery, + [PARAM]: value, + } + router.replace({ query }) + }, + get: () => parseInit(route.query) ?? 'appearance', + }) + + const setCategory = (value: unknown) => isCategory(value) && (category.value = value) + const getLabel = (cate: unknown) => { + const key = isCategory(cate) ? CATE_LABELS[cate] : undefined + return key ? t(key) : undefined + } + + return { category, setCategory, getLabel } +} \ No newline at end of file diff --git a/src/pages/app/components/Option/useOption.ts b/src/pages/app/components/Option/useOption.ts index 48e6d6404..438337c74 100644 --- a/src/pages/app/components/Option/useOption.ts +++ b/src/pages/app/components/Option/useOption.ts @@ -1,25 +1,31 @@ -import optionHolder from "@service/components/option-holder" -import { onBeforeMount, type Reactive, reactive, toRaw, watch } from "vue" +import { getOption, setOption } from "@api/sw/option" +import { useRequest } from '@hooks' +import { reactive, toRaw, watch } from "vue" type Options<T> = { - defaultValue: () => T - copy: (target: T, source: T) => void + defaultValue: (() => T) | T + copy: (target: T, source: Readonly<T>) => void onChange?: (newVal: T) => void } -export const useOption = <T extends object = Partial<timer.option.AllOption>>(options: Options<T>): { option: Reactive<T> } => { +type MutableKeys<T> = { + -readonly [K in keyof T]: T[K] +} + +export const useOption = <T extends object = Partial<tt4b.option.AllOption>>(options: Options<T>) => { const { defaultValue, copy, onChange } = options - const option = reactive<T>(defaultValue?.()) + const option = reactive<MutableKeys<T>>(typeof defaultValue === 'function' ? defaultValue() : structuredClone(defaultValue)) - onBeforeMount(async () => { - const currentVal = await optionHolder.get() as T + const { loading } = useRequest(async () => { + const currentVal = await getOption() as T copy(option as T, currentVal) - watch(option, async () => { - const newVal = toRaw(option) as T - await optionHolder.set(newVal) - onChange?.(newVal) - }) }) - return { option } + watch(option, async () => { + const newVal = toRaw(option) as T + !loading.value && await setOption(newVal) + onChange?.(newVal) + }) + + return { option, loading } } \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx b/src/pages/app/components/Report/Filter/BatchDelete.tsx similarity index 75% rename from src/pages/app/components/Report/ReportFilter/BatchDelete.tsx rename to src/pages/app/components/Report/Filter/BatchDelete.tsx index bff1b31a7..caad0b7c4 100644 --- a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx +++ b/src/pages/app/components/Report/Filter/BatchDelete.tsx @@ -1,16 +1,17 @@ import { getGroup } from "@api/chrome/tabGroups" -import { type I18nKey, t } from "@app/locale" -import statDatabase from "@db/stat-database" +import { + batchDeleteStats, countGroupStatsByIds, countSiteStatsByHosts, deleteSiteStatByGroup, deleteSiteStatByHost, +} from "@api/sw/stat" +import { type I18nKey, t } from '@app/locale' import { DeleteFilled } from "@element-plus/icons-vue" -import { batchDelete, countGroupByIds, countSiteByHosts } from "@service/stat-service" import { isGroup, isNormalSite, isSite } from "@util/stat" -import { formatTime, getBirthday } from "@util/time" +import { cvtDateRange2Str, DateRange, formatTime, formatTimeYMD, getBirthday } from "@util/time" import { ElButton, ElMessage, ElMessageBox } from "element-plus" import { computed, defineComponent } from "vue" import { useReportComponent, useReportFilter } from "../context" import type { DisplayComponent, ReportFilterOption } from "../types" -async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date?, Date?]): Promise<string> { +async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: DateRange): Promise<string> { const hosts: string[] = [] const groupIds: number[] = [] selected.forEach(row => { @@ -30,8 +31,10 @@ async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: bool let count2Delete = selected.length ?? 0 if (mergeDate) { // All the items - const siteCount = hosts.length ? await countSiteByHosts(hosts, dateRange) : 0 - const groupCount = groupIds.length ? await countGroupByIds(groupIds, dateRange) : 0 + const [start, end] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] + const date: [string?, string?] = [start && formatTimeYMD(start), end && formatTimeYMD(end)] + const siteCount = hosts.length ? await countSiteStatsByHosts(hosts, date) : 0 + const groupCount = groupIds.length ? await countGroupStatsByIds(groupIds, date) : 0 count2Delete = siteCount + groupCount } const i18nParam: Record<string, string | number | undefined> = { @@ -48,7 +51,7 @@ async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: bool } let key: I18nKey | undefined = undefined - let [startDate, endDate] = dateRange + let [startDate, endDate] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] if (!startDate && !endDate) { // Delete all key = msg => msg.report.batchDelete.confirmMsgAll @@ -84,7 +87,7 @@ async function handleBatchDelete(displayComp: DisplayComponent | undefined, filt ElMessageBox({ message: await computeBatchDeleteMsg(selected, mergeDate, dateRange), type: "warning", - confirmButtonText: t(msg => msg.button.okey), + confirmButtonText: t(msg => msg.button.okay), showCancelButton: true, cancelButtonText: t(msg => msg.button.dont), // Cant close this on press ESC @@ -101,17 +104,17 @@ async function handleBatchDelete(displayComp: DisplayComponent | undefined, filt }) } -async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date?, Date?]) { +async function deleteBatch(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: DateRange) { if (mergeDate) { // Delete according to the date range - const [start, end] = dateRange ?? [] + const date = cvtDateRange2Str(dateRange) for (const row of selected) { - isNormalSite(row) && await statDatabase.deleteByUrlBetween(row.siteKey.host, start, end) - isGroup(row) && await statDatabase.deleteByGroupBetween(row.groupKey, start, end) + isNormalSite(row) && await deleteSiteStatByHost(row.siteKey.host, date) + isGroup(row) && await deleteSiteStatByGroup(row.groupKey, date) } } else { // If not merge date, batch delete - await batchDelete(selected) + await batchDeleteStats(selected) } } diff --git a/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx b/src/pages/app/components/Report/Filter/DownloadFile.tsx similarity index 97% rename from src/pages/app/components/Report/ReportFilter/DownloadFile.tsx rename to src/pages/app/components/Report/Filter/DownloadFile.tsx index bf2f6d390..4e6e30ee7 100644 --- a/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx +++ b/src/pages/app/components/Report/Filter/DownloadFile.tsx @@ -6,8 +6,8 @@ */ import { useCategory } from "@app/context" +import { useTabGroups } from "@hooks" import { Download } from "@element-plus/icons-vue" -import { useTabGroups } from "@hooks/useTabGroups" import { ElButton, ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" import { defineComponent } from "vue" import { queryAll } from "../common" diff --git a/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx b/src/pages/app/components/Report/Filter/MergeFilterItem.tsx similarity index 89% rename from src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx rename to src/pages/app/components/Report/Filter/MergeFilterItem.tsx index afe295acb..0171f6989 100644 --- a/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx +++ b/src/pages/app/components/Report/Filter/MergeFilterItem.tsx @@ -1,5 +1,5 @@ import { useCategory } from '@app/context' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Calendar, Collection, Link, Menu } from "@element-plus/icons-vue" import { useSiteMerge } from '@hooks' import Flex from "@pages/components/Flex" @@ -8,7 +8,7 @@ import { computed, defineComponent, StyleValue } from "vue" import { type JSX } from "vue/jsx-runtime" import { useReportFilter } from "../context" -const METHOD_ICONS: Record<timer.stat.MergeMethod, JSX.Element> = { +const METHOD_ICONS: Record<tt4b.stat.MergeMethod, JSX.Element> = { cate: <Collection />, date: <Calendar />, domain: <Link />, @@ -22,13 +22,13 @@ const MergeFilterItem = defineComponent<{}>(() => { onGroupDisabled: () => mergeMethod.value.filter(v => v !== 'group') }) const mergeItems = computed(() => { - const res = ['date', ...siteMergeItems.value] satisfies timer.stat.MergeMethod[] + const res = ['date', ...siteMergeItems.value] satisfies tt4b.stat.MergeMethod[] return cate.enabled ? res : res.filter(m => m !== 'cate') }) const mergeMethod = computed({ get: () => { const { mergeDate, siteMerge } = filter - const res: timer.stat.MergeMethod[] = [] + const res: tt4b.stat.MergeMethod[] = [] mergeDate && (res.push('date')) siteMerge && (res.push(siteMerge)) return res @@ -51,7 +51,7 @@ const MergeFilterItem = defineComponent<{}>(() => { </ElText> <ElCheckboxGroup modelValue={mergeMethod.value} - onChange={val => mergeMethod.value = val as timer.stat.MergeMethod[]} + onChange={val => mergeMethod.value = val as tt4b.stat.MergeMethod[]} > {mergeItems.value.map(method => ( <ElCheckboxButton value={method}> @@ -63,7 +63,7 @@ const MergeFilterItem = defineComponent<{}>(() => { </ElCheckboxButton> ))} </ElCheckboxGroup> - </Flex > + </Flex> ) }, { props: ['hideCate'] }) diff --git a/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx b/src/pages/app/components/Report/Filter/RemoteClient.tsx similarity index 84% rename from src/pages/app/components/Report/ReportFilter/RemoteClient.tsx rename to src/pages/app/components/Report/Filter/RemoteClient.tsx index 62ce8c9c3..2b54268ee 100644 --- a/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx +++ b/src/pages/app/components/Report/Filter/RemoteClient.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { checkAuth } from '@api/sw/backup' +import { t } from '@app/locale' import { UploadFilled } from "@element-plus/icons-vue" -import { useRequest } from "@hooks" -import { canReadRemote } from '@service/stat-service/remote' +import { useRequest } from '@hooks' import { ElButton, ElIcon, ElTooltip } from "element-plus" import { computed, defineComponent } from "vue" import { useReportFilter } from "../context" @@ -17,7 +17,7 @@ import { ICON_BTN_STYLE } from "./common" const _default = defineComponent(() => { const filter = useReportFilter() const content = computed(() => t(msg => msg.report.remoteReading[filter.readRemote ? 'on' : 'off'])) - const { data: visible } = useRequest(() => canReadRemote(), { defaultValue: false }) + const { data: visible } = useRequest(() => checkAuth().then(errMsg => !errMsg), { defaultValue: false }) return () => ( <ElTooltip trigger="hover" placement="bottom-start" effect="dark" content={content.value}> diff --git a/src/pages/app/components/Report/ReportFilter/common.ts b/src/pages/app/components/Report/Filter/common.ts similarity index 100% rename from src/pages/app/components/Report/ReportFilter/common.ts rename to src/pages/app/components/Report/Filter/common.ts diff --git a/src/pages/app/components/Report/ReportFilter/index.tsx b/src/pages/app/components/Report/Filter/index.tsx similarity index 87% rename from src/pages/app/components/Report/ReportFilter/index.tsx rename to src/pages/app/components/Report/Filter/index.tsx index a05db7ac8..dd26b59fa 100644 --- a/src/pages/app/components/Report/ReportFilter/index.tsx +++ b/src/pages/app/components/Report/Filter/index.tsx @@ -9,10 +9,10 @@ import CategoryFilter from "@app/components/common/filter/CategoryFilter" import DateRangeFilterItem from "@app/components/common/filter/DateRangeFilterItem" import InputFilterItem from '@app/components/common/filter/InputFilterItem' import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" -import { t } from "@app/locale" +import { t } from '@app/locale' import Flex from "@pages/components/Flex" +import { ElDatePickerShortcut } from '@pages/element-ui/types' import { daysAgo } from "@util/time" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { defineComponent } from "vue" import { useReportFilter } from "../context" import BatchDelete from "./BatchDelete" @@ -20,11 +20,11 @@ import DownloadFile from "./DownloadFile" import MergeFilterItem from "./MergeFilterItem" import RemoteClient from "./RemoteClient" -const shortcut = (text: string, agoOfStart?: number, agoOfEnd?: number) => ( - { text, value: daysAgo(agoOfStart ?? 0, agoOfEnd ?? 0) } satisfies Shortcut +const shortcut = (text: string, agoOfStart?: number, agoOfEnd?: number): ElDatePickerShortcut => ( + { text, value: daysAgo(agoOfStart ?? 0, agoOfEnd ?? 0) } ) -const dateShortcuts: Shortcut[] = [ +const dateShortcuts: ElDatePickerShortcut[] = [ shortcut(t(msg => msg.calendar.range.today)), shortcut(t(msg => msg.calendar.range.yesterday), 1, 1), shortcut(t(msg => msg.calendar.range.lastDays, { n: 7 }), 7), @@ -47,7 +47,7 @@ const _default = defineComponent<{}>(() => { endPlaceholder={t(msg => msg.calendar.label.endDate)} disabledDate={(date: Date | number) => new Date(date) > new Date()} shortcuts={dateShortcuts} - modelValue={filter.dateRange} + modelValue={(filter.dateRange instanceof Date ? [filter.dateRange] : (filter.dateRange ?? [])) as [Date?, Date?]} onChange={val => filter.dateRange = val} /> <CategoryFilter @@ -68,6 +68,6 @@ const _default = defineComponent<{}>(() => { </Flex> </Flex> ) -}, { props: ['hideCateFilter'] }) +}) export default _default \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportList/Item.tsx b/src/pages/app/components/Report/List/Item.tsx similarity index 90% rename from src/pages/app/components/Report/ReportList/Item.tsx rename to src/pages/app/components/Report/List/Item.tsx index a367e0765..2561035be 100644 --- a/src/pages/app/components/Report/ReportList/Item.tsx +++ b/src/pages/app/components/Report/List/Item.tsx @@ -1,23 +1,24 @@ -import HostAlert from "@app/components/common/HostAlert" -import PopupConfirmButton from "@app/components/common/PopupConfirmButton" -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { cvt2LocaleTime, periodFormatter } from "@app/util/time" + +import HostAlert from '@app/components/common/HostAlert' +import PopupConfirmButton from '@app/components/common/PopupConfirmButton' +import { cvt2LocaleTime, periodFormatter } from '@app/util/time' import { Calendar, Delete, Mouse, QuartzWatch } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useTabGroups } from "@hooks/useTabGroups" +import { useTabGroups } from "@hooks" import Flex from '@pages/components/Flex' +import TooltipWrapper from '@pages/components/TooltipWrapper' import { getComposition, isGroup, isNormalSite, isSite } from "@util/stat" import { Effect, ElCheckbox, ElDivider, ElIcon, ElTag, useNamespace } from "element-plus" import { computed, defineComponent, ref, StyleValue, watch } from "vue" import { computeDeleteConfirmMsg, handleDelete } from "../common" -import CompositionTable from "../CompositionTable" +import CompositionTable from "../components/CompositionTable" +import TooltipSiteList from "../components/TooltipSiteList" import { useReportFilter } from "../context" -import TooltipSiteList from "../ReportTable/columns/TooltipSiteList" type Props = { - value: timer.stat.Row + value: tt4b.stat.Row onSelectedChange: ArgCallback<boolean> - onDelete?: ArgCallback<timer.stat.Row> + onDelete?: ArgCallback<tt4b.stat.Row> } const useContentStyle = () => { @@ -70,7 +71,7 @@ const _default = defineComponent<Props>(props => { trigger="click" usePopover={props.value.siteKey.type === 'merged'} v-slots={{ - content: () => <TooltipSiteList modelValue={mergedRows} />, + content: () => <TooltipSiteList modelValue={mergedRows as tt4b.stat.SiteRow[]} />, }} > <HostAlert @@ -127,7 +128,7 @@ const _default = defineComponent<Props>(props => { </ElTag> </TooltipWrapper> </Flex> - </div > + </div> ) }, { props: ['onDelete', 'onSelectedChange', 'value'] }) diff --git a/src/pages/app/components/Report/ReportList/index.tsx b/src/pages/app/components/Report/List/index.tsx similarity index 89% rename from src/pages/app/components/Report/ReportList/index.tsx rename to src/pages/app/components/Report/List/index.tsx index ceac0f5cf..014f7d618 100644 --- a/src/pages/app/components/Report/ReportList/index.tsx +++ b/src/pages/app/components/Report/List/index.tsx @@ -1,6 +1,6 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { css } from '@emotion/css' -import { useScrollRequest } from "@hooks" +import { useScrollRequest } from '@hooks' import { getHost } from "@util/stat" import { ElCard, useNamespace } from "element-plus" import { defineComponent, ref } from "vue" @@ -46,12 +46,12 @@ const _default = defineComponent<{}>((_, ctx) => { const selected = ref<number[]>([]) ctx.expose({ - getSelected: () => selected.value?.map(idx => data.value?.[idx])?.filter(i => !!i) ?? [], + getSelected: () => selected.value.map(idx => data.value[idx]).filter(i => !!i), refresh: reset, } satisfies DisplayComponent) const handleSelectedChange = (val: boolean, idx: number) => { - const newSelected = selected.value?.filter(v => v !== idx) || [] + const newSelected = selected.value.filter(v => v !== idx) val && newSelected.push(idx) return selected.value = newSelected } @@ -64,7 +64,7 @@ const _default = defineComponent<{}>((_, ctx) => { v-infinite-scroll={loadMoreAsync} infinite-scroll-disabled={end.value || loading.value} > - {data.value?.map((row, idx) => ( + {data.value.map((row, idx) => ( <ElCard> <Item key={`row-${getHost(row)}-${idx}`} diff --git a/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx b/src/pages/app/components/Report/Table/columns/CateColumn.tsx similarity index 64% rename from src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx rename to src/pages/app/components/Report/Table/columns/CateColumn.tsx index fd2b23630..48498b325 100644 --- a/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/CateColumn.tsx @@ -1,28 +1,19 @@ -import CategoryEditable from "@app/components/common/category/CategoryEditable" -import { useCategory } from "@app/context" -import { t } from "@app/locale" +import CategoryEditable from "@app/components/common/Category/Editable" +import TooltipSiteList from "@app/components/Report/components/TooltipSiteList" +import { useCategory } from '@app/context' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import { CATE_NOT_SET_ID, SiteMap } from "@util/site" +import { CATE_NOT_SET_ID } from "@util/site" import { getRelatedCateId, identifyStatKey, isCate, isGroup, isSite } from "@util/stat" import { Effect, ElTableColumn, ElText, ElTooltip, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import TooltipSiteList from "./TooltipSiteList" -const renderMerged = (cateId: number, categories: timer.site.Cate[], merged: timer.stat.SiteRow[]) => { - let cateName: string - let isNotSet = false - const siteMap = new SiteMap<string>() - merged.forEach(row => isSite(row) && siteMap.put(row.siteKey, row.iconUrl)) +const renderMerged = (cateId: number, categories: tt4b.site.Cate[], merged: tt4b.stat.SiteRow[]) => { + const [cateName, isNotSet] = CATE_NOT_SET_ID === cateId + ? [t(msg => msg.shared.cate.notSet), true] + : [categories.find(c => c.id === cateId)?.name, false] - if (cateId === CATE_NOT_SET_ID) { - cateName = t(msg => msg.shared.cate.notSet) - isNotSet = true - } else { - const current = categories?.find(c => c.id === cateId) - if (!current) return null - cateName = current?.name - } - return ( + return cateName ? ( <ElTooltip effect={Effect.LIGHT} offset={10} @@ -37,24 +28,24 @@ const renderMerged = (cateId: number, categories: timer.site.Cate[], merged: tim ), }} /> - ) + ) : null } type Props = { - onChange: (key: timer.site.SiteKey, newCate: number | undefined) => void, + onChange: (key: tt4b.site.SiteKey, newCate: number | undefined) => void, } const CateColumn = defineComponent<Props>(props => { const cate = useCategory() return () => cate.enabled ? ( <ElTableColumn label={t(msg => msg.siteManage.column.cate)} minWidth={140}> - {({ row }: RenderRowData<timer.stat.Row>) => { + {({ row }: RenderRowData<tt4b.stat.Row>) => { if (!row || isGroup(row)) return const { mergedRows } = row const cateId = getRelatedCateId(row) return ( <Flex key={`${identifyStatKey(row)}_${cateId}`} justify="center"> - {isCate(row) && renderMerged(row.cateKey, cate.all, mergedRows ?? [])} + {isCate(row) && renderMerged(row.cateKey, cate.all, (mergedRows ?? []) as tt4b.stat.SiteRow[])} {isSite(row) && ( <CategoryEditable siteKey={row.siteKey} diff --git a/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx b/src/pages/app/components/Report/Table/columns/DateColumn.tsx similarity index 69% rename from src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx rename to src/pages/app/components/Report/Table/columns/DateColumn.tsx index 2b24004c4..3f341d755 100644 --- a/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/DateColumn.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { cvt2LocaleTime } from "@app/util/time" +import type { ReportSort } from '@app/components/Report/types' +import { t } from '@app/locale' +import { cvt2LocaleTime } from '@app/util/time' import { ElTableColumn, RenderRowData } from "element-plus" import { type FunctionalComponent } from "vue" -import type { ReportSort } from "../../types" const DateColumn: FunctionalComponent = () => ( <ElTableColumn @@ -19,7 +19,7 @@ const DateColumn: FunctionalComponent = () => ( align="center" sortable="custom" > - {({ row }: RenderRowData<timer.stat.Row>) => <span>{cvt2LocaleTime(row.date)}</span>} + {({ row }: RenderRowData<tt4b.stat.Row>) => <span>{cvt2LocaleTime(row.date)}</span>} </ElTableColumn> ) diff --git a/src/pages/app/components/Report/ReportTable/columns/GroupColumn.tsx b/src/pages/app/components/Report/Table/columns/GroupColumn.tsx similarity index 82% rename from src/pages/app/components/Report/ReportTable/columns/GroupColumn.tsx rename to src/pages/app/components/Report/Table/columns/GroupColumn.tsx index 149b0fddb..fc30e3664 100644 --- a/src/pages/app/components/Report/ReportTable/columns/GroupColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/GroupColumn.tsx @@ -1,6 +1,6 @@ -import { cvtGroupColor } from "@api/chrome/tabGroups" -import { t } from "@app/locale" -import { useTabGroups } from "@hooks/useTabGroups" +import { t } from '@app/locale' +import { useTabGroups } from "@hooks" +import { cvtGroupColor } from '@pages/util/style' import { isGroup } from "@util/stat" import { ElTableColumn, ElTag, type RenderRowData } from "element-plus" import { defineComponent, StyleValue } from "vue" @@ -13,7 +13,7 @@ const GroupColumn = defineComponent(() => { align="center" label={t(msg => msg.item.group)} width={140} - v-slots={({ row }: RenderRowData<timer.stat.Row>) => { + v-slots={({ row }: RenderRowData<tt4b.stat.Row>) => { if (!isGroup(row)) return const { groupKey } = row const { color, title } = groupMap.value[groupKey] ?? {} diff --git a/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx b/src/pages/app/components/Report/Table/columns/HostColumn.tsx similarity index 64% rename from src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx rename to src/pages/app/components/Report/Table/columns/HostColumn.tsx index 52c09cb99..d31761191 100644 --- a/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/HostColumn.tsx @@ -5,17 +5,17 @@ * https://opensource.org/licenses/MIT */ -import HostAlert from "@app/components/common/HostAlert" -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" +import HostAlert from '@app/components/common/HostAlert' +import TooltipSiteList from '@app/components/Report/components/TooltipSiteList' +import { useReportFilter } from '@app/components/Report/context' +import { ReportSort } from '@app/components/Report/types' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" +import TooltipWrapper from '@pages/components/TooltipWrapper' import { identifySiteKey } from "@util/site" import { isGroup, isSite } from "@util/stat" import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import { useReportFilter } from "../../context" -import type { ReportSort } from "../../types" -import TooltipSiteList from "./TooltipSiteList" const _default = defineComponent(() => { const filter = useReportFilter() @@ -27,7 +27,7 @@ const _default = defineComponent(() => { sortable="custom" align="center" > - {({ row }: RenderRowData<timer.stat.Row>) => ( + {({ row }: RenderRowData<tt4b.stat.Row>) => ( <Flex key={isSite(row) ? identifySiteKey(row.siteKey) : ''} justify="center"> <TooltipWrapper usePopover={filter?.siteMerge === 'domain'} @@ -35,13 +35,17 @@ const _default = defineComponent(() => { offset={10} placement="left" v-slots={{ - content: () => <TooltipSiteList modelValue={isGroup(row) ? undefined : row.mergedRows} />, + content: () => ( + <TooltipSiteList + modelValue={isGroup(row) ? undefined : (row.mergedRows as tt4b.stat.SiteRow[] | undefined)} + /> + ), default: () => isSite(row) ? <HostAlert value={row.siteKey} iconUrl={row.iconUrl} /> : '', }} /> </Flex> )} - </ElTableColumn > + </ElTableColumn> ) }) diff --git a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx b/src/pages/app/components/Report/Table/columns/OperationColumn.tsx similarity index 66% rename from src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx rename to src/pages/app/components/Report/Table/columns/OperationColumn.tsx index a131e7463..2f29ed29b 100644 --- a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/OperationColumn.tsx @@ -4,24 +4,23 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import type { AnalysisQuery } from "@app/components/Analysis/context" -import PopupConfirmButton from "@app/components/common/PopupConfirmButton" -import { t } from "@app/locale" -import { ANALYSIS_ROUTE } from "@app/router/constants" +import { type AppAnalysisQuery } from '@/shared/route' +import { addWhitelist, deleteWhitelist, listWhitelist } from "@api/sw/whitelist" +import PopupConfirmButton from '@app/components/common/PopupConfirmButton' +import { computeDeleteConfirmMsg, handleDelete } from '@app/components/Report/common' +import { useReportFilter } from '@app/components/Report/context' +import { t } from '@app/locale' +import { ANALYSIS_ROUTE } from '@app/router/constants' import { Delete, Open, Plus, Stopwatch } from "@element-plus/icons-vue" -import { useRequest } from "@hooks" -import { useTabGroups } from "@hooks/useTabGroups" +import { useManualRequest, useRequest, useTabGroups } from '@hooks' import { locale } from "@i18n" -import whitelistService from "@service/whitelist/service" import { CATE_NOT_SET_ID } from "@util/site" import { isCate, isGroup, isNormalSite, isSite } from "@util/stat" import { ElButton, ElMessage, ElTableColumn, type RenderRowData } from "element-plus" import { computed, defineComponent } from "vue" import { useRouter } from "vue-router" -import { computeDeleteConfirmMsg, handleDelete } from "../../common" -import { useReportFilter } from "../../context" -const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { +const LOCALE_WIDTH: { [locale in tt4b.Locale]: number } = { en: 330, zh_CN: 290, ja: 360, @@ -35,19 +34,20 @@ const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { ar: 320, tr: 320, pl: 320, + it: 320, } type Props = { - onDelete?: ArgCallback<timer.stat.Row> + onDelete?: ArgCallback<tt4b.stat.Row> } -const analysisVisible = (row: timer.stat.Row) => { +const analysisVisible = (row: tt4b.stat.Row) => { if (isGroup(row)) return false if (isCate(row)) return row.cateKey !== CATE_NOT_SET_ID return true } -const deleteVisible = (row: timer.stat.Row) => { +const deleteVisible = (row: tt4b.stat.Row) => { if (isCate(row)) return false if (isSite(row) && row.siteKey.type === 'merged') return false return true @@ -61,13 +61,16 @@ const _default = defineComponent<Props>(({ onDelete }) => { return !siteMerge || siteMerge === 'group' ? LOCALE_WIDTH[locale] : 110 }) const router = useRouter() - const { - data: whitelist, - refresh: refreshWhitelist, - } = useRequest(() => whitelistService.listAll(), { defaultValue: [] }) + const { data: whitelist, refresh: refreshWhitelist } = useRequest(listWhitelist, { defaultValue: [] }) + const onWhitelistSuccess = () => { + refreshWhitelist() + ElMessage.success(t(msg => msg.operation.successMsg)) + } + const { refresh: onAddWhitelist } = useManualRequest(addWhitelist, { onSuccess: onWhitelistSuccess }) + const { refresh: onRemoveWhitelist } = useManualRequest(deleteWhitelist, { onSuccess: onWhitelistSuccess }) - const jump2Analysis = (row: timer.stat.Row) => { - let query: AnalysisQuery + const jump2Analysis = (row: tt4b.stat.Row) => { + let query: AppAnalysisQuery if (isCate(row)) { query = { cateId: row.cateKey?.toString?.() } } else if (isSite(row)) { @@ -84,7 +87,7 @@ const _default = defineComponent<Props>(({ onDelete }) => { label={t(msg => msg.button.operation)} align="center" > - {({ row }: RenderRowData<timer.stat.Row>) => <> + {({ row }: RenderRowData<tt4b.stat.Row>) => <> {/* Analysis */} {analysisVisible(row) && ( <ElButton @@ -110,33 +113,23 @@ const _default = defineComponent<Props>(({ onDelete }) => { /> )} {/* Add 2 whitelist */} - {isNormalSite(row) && !whitelist.value?.includes(row.siteKey.host) && ( + {isNormalSite(row) && !whitelist.value.includes(row.siteKey.host) && ( <PopupConfirmButton buttonIcon={Plus} buttonType="warning" buttonText={t(msg => msg.item.operation.add2Whitelist)} confirmText={t(msg => msg.whitelist.addConfirmMsg, { url: row.siteKey?.host })} - onConfirm={async () => { - const host = row.siteKey?.host - host && await whitelistService.add(host) - refreshWhitelist() - ElMessage.success(t(msg => msg.operation.successMsg)) - }} + onConfirm={() => onAddWhitelist(row.siteKey.host)} /> )} {/* Remove from whitelist */} - {isNormalSite(row) && whitelist.value?.includes(row.siteKey.host) && ( + {isNormalSite(row) && whitelist.value.includes(row.siteKey.host) && ( <PopupConfirmButton buttonIcon={Open} buttonType="primary" buttonText={t(msg => msg.button.enable)} confirmText={t(msg => msg.whitelist.removeConfirmMsg, { url: row.siteKey?.host })} - onConfirm={async () => { - const host = row.siteKey?.host - host && await whitelistService.remove(host) - refreshWhitelist() - ElMessage.success(t(msg => msg.operation.successMsg)) - }} + onConfirm={() => onRemoveWhitelist(row.siteKey.host)} /> )} </>} diff --git a/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx b/src/pages/app/components/Report/Table/columns/TimeColumn.tsx similarity index 72% rename from src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx rename to src/pages/app/components/Report/Table/columns/TimeColumn.tsx index 6b1fb43a0..5843ec336 100644 --- a/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/TimeColumn.tsx @@ -5,18 +5,18 @@ * https://opensource.org/licenses/MIT */ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import CompositionTable from '@app/components/Report/components/CompositionTable' +import { useReportFilter } from "@app/components/Report/context" +import type { ReportSort } from "@app/components/Report/types" +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' +import TooltipWrapper from '@pages/components/TooltipWrapper' import { getComposition } from "@util/stat" import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import CompositionTable from '../../CompositionTable' -import { useReportFilter } from "../../context" -import type { ReportSort } from "../../types" type Props = { - dimension: timer.core.Dimension & 'focus' | 'run' + dimension: tt4b.core.Dimension & 'focus' | 'run' } const TimeColumn = defineComponent<Props>(props => { @@ -30,7 +30,7 @@ const TimeColumn = defineComponent<Props>(props => { align="center" sortable="custom" > - {({ row }: RenderRowData<timer.stat.Row>) => ( + {({ row }: RenderRowData<tt4b.stat.Row>) => ( <TooltipWrapper usePopover={filter.readRemote} placement="top" @@ -42,7 +42,7 @@ const TimeColumn = defineComponent<Props>(props => { }} /> )} - </ElTableColumn > + </ElTableColumn> ) }, { props: ['dimension'] }) diff --git a/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx b/src/pages/app/components/Report/Table/columns/VisitColumn.tsx similarity index 75% rename from src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx rename to src/pages/app/components/Report/Table/columns/VisitColumn.tsx index a9aabba06..232a52b50 100644 --- a/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/VisitColumn.tsx @@ -5,14 +5,14 @@ * https://opensource.org/licenses/MIT */ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" +import CompositionTable from "@app/components/Report/components/CompositionTable" +import { useReportFilter } from "@app/components/Report/context" +import type { ReportSort } from "@app/components/Report/types" +import { t } from '@app/locale' +import TooltipWrapper from '@pages/components/TooltipWrapper' import { getComposition } from "@util/stat" import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import CompositionTable from "../../CompositionTable" -import { useReportFilter } from "../../context" -import type { ReportSort } from "../../types" const VisitColumn = defineComponent(() => { const filter = useReportFilter() @@ -24,7 +24,7 @@ const VisitColumn = defineComponent(() => { align="center" sortable="custom" > - {({ row }: RenderRowData<timer.stat.Row>) => ( + {({ row }: RenderRowData<tt4b.stat.Row>) => ( <TooltipWrapper usePopover={filter.readRemote} placement="top" diff --git a/src/pages/app/components/Report/ReportTable/index.tsx b/src/pages/app/components/Report/Table/index.tsx similarity index 78% rename from src/pages/app/components/Report/ReportTable/index.tsx rename to src/pages/app/components/Report/Table/index.tsx index 32972008c..03937fcd1 100644 --- a/src/pages/app/components/Report/ReportTable/index.tsx +++ b/src/pages/app/components/Report/Table/index.tsx @@ -4,20 +4,21 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import ContentCard from "@app/components/common/ContentCard" -import Editable from "@app/components/common/Editable" -import Pagination from "@app/components/common/Pagination" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import { changeSiteAlias } from '@api/sw/site' +import { listCateStats, listGroupStats, listSiteStats } from "@api/sw/stat" +import ContentCard from '@app/components/common/ContentCard' +import Editable from '@app/components/common/Editable' +import Pagination from '@app/components/common/Pagination' +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' import { Histogram } from "@element-plus/icons-vue" -import { useDocumentVisibility, useManualRequest, useRequest, useState } from "@hooks" +import { useDocumentVisibility, useManualRequest, useRequest, useState } from '@hooks' import Flex from "@pages/components/Flex" -import { removeAlias, saveAlias } from '@service/site-service' -import { selectCate, selectGroup, selectSite, type SiteQuery } from "@service/stat-service" import { sum } from "@util/array" import { isRtl } from "@util/document" import { siteEqual } from "@util/site" import { getAlias, isSite } from "@util/stat" +import { cvtDateRange2Str } from '@util/time' import { ElLink, ElTable, ElTableColumn, ElText, ElTooltip, type TableInstance } from "element-plus" import { computed, defineComponent, ref, watch } from "vue" import { queryPage } from "../common" @@ -31,16 +32,11 @@ import OperationColumn from "./columns/OperationColumn" import TimeColumn from "./columns/TimeColumn" import VisitColumn from "./columns/VisitColumn" -async function handleAliasChange(key: timer.site.SiteKey, newAlias: string | undefined, data: timer.stat.Row[]) { - newAlias = newAlias?.trim?.() - if (!newAlias) { - await removeAlias(key) - } else { - await saveAlias(key, newAlias) - } - data?.filter(isSite) - ?.filter(item => siteEqual(item.siteKey, key)) - ?.forEach(item => item.alias = newAlias) +async function handleAliasChange(key: tt4b.site.SiteKey, newAlias: string | undefined, data: tt4b.stat.Row[]) { + newAlias = await changeSiteAlias(key, newAlias) + data.filter(isSite) + .filter(item => siteEqual(item.siteKey, key)) + .forEach(item => item.alias = newAlias) } type ColumnVisible = Record<'index' | 'date' | 'site' | 'cate' | 'group', boolean> @@ -58,12 +54,12 @@ const computeVisible = (filter: ReportFilterOption): ColumnVisible => { const _default = defineComponent((_, ctx) => { const rtl = isRtl() - const [page, setPage] = useState<timer.common.PageQuery>({ size: 20, num: 1 }) + const [page, setPage] = useState<tt4b.common.PageQuery>({ size: 20, num: 1 }) const sort = useReportSort() const filter = useReportFilter() const visible = computed(() => computeVisible(filter)) const { data, refresh, loading } = useRequest(() => queryPage(filter, sort.value, page.value), { - loadingTarget: () => table.value?.$el as HTMLDivElement, + loadingTarget: () => table.value?.$el, deps: [() => ({ ...filter }), sort, page], defaultValue: { list: [], total: 0 }, }) @@ -72,30 +68,31 @@ const _default = defineComponent((_, ctx) => { refresh: refreshTotal, loading: totalLoading, } = useManualRequest(async () => { - const { siteMerge, dateRange: date, query, readRemote: inclusiveRemote, cateIds } = filter - let rows: timer.stat.Row[] = [] + const { siteMerge, dateRange, query, readRemote: inclusiveRemote, cateIds } = filter + const date = cvtDateRange2Str(dateRange) + let rows: tt4b.stat.Row[] = [] if (siteMerge === 'group') { - rows = await selectGroup({ date, query }) + rows = await listGroupStats({ date, query }) } else if (siteMerge === 'cate') { - rows = await selectCate({ date, query, cateIds, inclusiveRemote }) + rows = await listCateStats({ date, query, cateIds, inclusiveRemote }) } else { - const param: SiteQuery = { + const param: tt4b.stat.SiteQuery = { date, query, cateIds, inclusiveRemote, mergeHost: siteMerge === 'domain', } - rows = await selectSite(param) + rows = await listSiteStats(param) } const visit = sum(rows.map(e => e.time)) const focus = sum(rows.map(e => e.focus)) return { visit, focus } }, { defaultValue: { visit: 0, focus: 0 } }) - const runColVisible = computed(() => !!data.value?.list?.find(r => r.run)) + const runColVisible = computed(() => data.value.list.some(r => r.run)) // Query data if document become visible const docVisible = useDocumentVisibility() watch(docVisible, () => docVisible.value && refresh()) - const [selection, setSelection] = useState<timer.stat.Row[]>([]) + const [selection, setSelection] = useState<tt4b.stat.Row[]>([]) ctx.expose({ getSelected: () => selection.value, refresh, @@ -114,7 +111,7 @@ const _default = defineComponent((_, ctx) => { <Flex flex={1} height={0}> <ElTable ref={table} - data={data.value?.list} + data={data.value.list} border fit highlightCurrentRow height="100%" defaultSort={sort.value} @@ -129,10 +126,10 @@ const _default = defineComponent((_, ctx) => { label={t(msg => msg.siteManage.column.alias)} minWidth={140} align="center" - v-slots={({ row }: { row: timer.stat.Row }) => ( + v-slots={({ row }: { row: tt4b.stat.Row }) => ( <Editable modelValue={getAlias(row)} - onChange={newAlias => 'siteKey' in row && handleAliasChange(row.siteKey, newAlias, data.value?.list ?? [])} + onChange={newAlias => 'siteKey' in row && handleAliasChange(row.siteKey, newAlias, data.value.list)} /> )} /> @@ -165,7 +162,7 @@ const _default = defineComponent((_, ctx) => { <Pagination disabled={loading.value} defaultValue={page.value} - total={data.value?.total || 0} + total={data.value.total} onChange={setPage} /> </Flex> diff --git a/src/pages/app/components/Report/common.ts b/src/pages/app/components/Report/common.ts index 958f36485..d6319bf0a 100644 --- a/src/pages/app/components/Report/common.ts +++ b/src/pages/app/components/Report/common.ts @@ -1,11 +1,10 @@ -import { t } from "@app/locale" -import statDatabase from "@db/stat-database" import { - selectCate, selectCatePage, selectGroup, selectGroupPage, selectSite, selectSitePage, - type CateQuery, type GroupQuery, type SiteQuery, -} from "@service/stat-service" + deleteSiteStatByGroup, deleteSiteStatByHost, getCateStatPage, getGroupStatPage, getSiteStatPage, listCateStats, + listGroupStats, listSiteStats, +} from "@api/sw/stat" +import { t } from '@app/locale' import { getGroupName, isGroup, isSite } from "@util/stat" -import { formatTime, getBirthday } from "@util/time" +import { cvtDateRange2Str, type DateRange, formatTime, getBirthday } from "@util/time" import type { ReportFilterOption, ReportSort } from "./types" /** @@ -18,8 +17,8 @@ function computeSingleConfirmText(url: string, date: string): string { return t(msg => msg.item.operation.deleteConfirmMsg, { url, date }) } -function computeRangeConfirmText(url: string, dateRange: [Date?, Date?]): string { - let [startDate, endDate] = dateRange +function computeRangeConfirmText(url: string, dateRange: DateRange): string { + let [startDate, endDate] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] if (!startDate && !endDate) { // Delete all return t(msg => msg.item.operation.deleteConfirmMsgAll, { url }) @@ -35,7 +34,7 @@ function computeRangeConfirmText(url: string, dateRange: [Date?, Date?]): string : t(msg => msg.item.operation.deleteConfirmMsgRange, { url, start, end }) } -export function computeDeleteConfirmMsg(row: timer.stat.Row, filterOption: ReportFilterOption, groupMap: Record<number, chrome.tabGroups.TabGroup>): string { +export function computeDeleteConfirmMsg(row: tt4b.stat.Row, filterOption: ReportFilterOption, groupMap: Record<number, chrome.tabGroups.TabGroup>): string { let name: string | undefined if (isGroup(row)) { name = getGroupName(groupMap, row) @@ -50,30 +49,30 @@ export function computeDeleteConfirmMsg(row: timer.stat.Row, filterOption: Repor : computeSingleConfirmText(name, date ?? '') } -export async function handleDelete(row: timer.stat.Row, filterOption: ReportFilterOption) { +export async function handleDelete(row: tt4b.stat.Row, filterOption: ReportFilterOption) { const { date } = row const { mergeDate, dateRange } = filterOption if (!mergeDate) { // Delete one day - isSite(row) && date && await statDatabase.deleteByUrlAndDate(row.siteKey.host, date) - isGroup(row) && date && await statDatabase.deleteByGroupAndDate(row.groupKey, date) + isSite(row) && date && await deleteSiteStatByHost(row.siteKey.host, date) + isGroup(row) && date && await deleteSiteStatByGroup(row.groupKey, date) return } - const start = dateRange?.[0] - const end = dateRange?.[1] + const strRange = cvtDateRange2Str(dateRange) + const [start, end] = strRange ?? [] if (!start && !end) { // Delete all - isSite(row) && await statDatabase.deleteByUrl(row.siteKey.host) - isGroup(row) && await statDatabase.deleteByGroup(row.groupKey) + isSite(row) && await deleteSiteStatByHost(row.siteKey.host) + isGroup(row) && await deleteSiteStatByGroup(row.groupKey) return } // Delete by range - isSite(row) && await statDatabase.deleteByUrlBetween(row.siteKey.host, start, end) - isGroup(row) && await statDatabase.deleteByGroupBetween(row.groupKey, start, end) + isSite(row) && await deleteSiteStatByHost(row.siteKey.host, strRange) + isGroup(row) && await deleteSiteStatByGroup(row.groupKey, strRange) } -const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | undefined => { +const cvtOrderDir = (order: ReportSort['order']): tt4b.common.SortDirection | undefined => { if (order === 'ascending') return 'ASC' else if (order === 'descending') return 'DESC' else return undefined @@ -82,8 +81,8 @@ const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | u const cvt2GroupQuery = ( { query, mergeDate, dateRange: date }: ReportFilterOption, { prop, order }: ReportSort, -): GroupQuery => ({ - date, mergeDate, query, +): tt4b.stat.GroupQuery => ({ + date: cvtDateRange2Str(date), mergeDate, query, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, sortDirection: cvtOrderDir(order), }) @@ -91,8 +90,8 @@ const cvt2GroupQuery = ( const cvt2SiteQuery = ( { dateRange: date, mergeDate, siteMerge, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, { prop, order }: ReportSort, -): SiteQuery => ({ - date, mergeDate, +): tt4b.stat.SiteQuery => ({ + date: cvtDateRange2Str(date), mergeDate, mergeHost: siteMerge === 'domain', query, cateIds, inclusiveRemote, virtual: true, @@ -103,30 +102,30 @@ const cvt2SiteQuery = ( const cvt2CateQuery = ( { dateRange: date, mergeDate, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, { prop, order }: ReportSort, -): CateQuery => ({ - date, mergeDate, query, cateIds, inclusiveRemote, +): tt4b.stat.CateQuery => ({ + date: cvtDateRange2Str(date), mergeDate, query, cateIds, inclusiveRemote, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, sortDirection: cvtOrderDir(order), }) -export const queryPage = (filter: ReportFilterOption, sort: ReportSort, page: timer.common.PageQuery): Promise<timer.common.PageResult<timer.stat.Row>> => { +export const queryPage = async (filter: ReportFilterOption, sort: ReportSort, page: tt4b.common.PageQuery): Promise<tt4b.common.PageResult<tt4b.stat.Row>> => { const { siteMerge } = filter if (siteMerge === 'group') { - return selectGroupPage(cvt2GroupQuery(filter, sort), page) + return await getGroupStatPage({ ...cvt2GroupQuery(filter, sort), ...page }) } else if (siteMerge === 'cate') { - return selectCatePage(cvt2CateQuery(filter, sort), page) + return await getCateStatPage({ ...cvt2CateQuery(filter, sort), ...page }) } else { - return selectSitePage(cvt2SiteQuery(filter, sort), page) + return await getSiteStatPage({ ...cvt2SiteQuery(filter, sort), ...page }) } } -export const queryAll = (filter: ReportFilterOption, sort: ReportSort): Promise<timer.stat.Row[]> => { +export const queryAll = async (filter: ReportFilterOption, sort: ReportSort): Promise<tt4b.stat.Row[]> => { const { siteMerge } = filter if (siteMerge === 'group') { - return selectGroup(cvt2GroupQuery(filter, sort)) + return await listGroupStats(cvt2GroupQuery(filter, sort)) } else if (siteMerge === 'cate') { - return selectCate(cvt2CateQuery(filter, sort)) + return await listCateStats(cvt2CateQuery(filter, sort)) } else { - return selectSite(cvt2SiteQuery(filter, sort)) + return await listSiteStats(cvt2SiteQuery(filter, sort)) } } \ No newline at end of file diff --git a/src/pages/app/components/Report/CompositionTable.tsx b/src/pages/app/components/Report/components/CompositionTable.tsx similarity index 93% rename from src/pages/app/components/Report/CompositionTable.tsx rename to src/pages/app/components/Report/components/CompositionTable.tsx index 0ff130be3..5d0afa0f5 100644 --- a/src/pages/app/components/Report/CompositionTable.tsx +++ b/src/pages/app/components/Report/components/CompositionTable.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { sum } from "@util/array" import { ElTable, ElTableColumn } from "element-plus" import { computed, defineComponent, type PropType } from "vue" @@ -23,7 +23,7 @@ const VALUE = t(msg => msg.report.remoteReading.table.value) const LOCAL_DATA = t(msg => msg.report.remoteReading.table.localData) const PERCENTAGE = t(msg => msg.report.remoteReading.table.percentage) -function computeRows(data: timer.stat.RemoteCompositionVal[]): Row[] { +function computeRows(data: tt4b.stat.RemoteCompositionVal[]): Row[] { const rows: Row[] = data.map(e => typeof e === 'number' ? { name: LOCAL_DATA, value: e || 0 } : { name: e.cname || e.cid, value: e.value } @@ -44,7 +44,7 @@ const formatValue = (value: number, valueFormatter?: ValueFormatter) => { const _default = defineComponent({ props: { data: { - type: Array as PropType<timer.stat.RemoteCompositionVal[]>, + type: Array as PropType<tt4b.stat.RemoteCompositionVal[]>, required: true, }, valueFormatter: Function as PropType<ValueFormatter>, diff --git a/src/pages/app/components/Report/ReportTable/columns/TooltipSiteList.tsx b/src/pages/app/components/Report/components/TooltipSiteList.tsx similarity index 87% rename from src/pages/app/components/Report/ReportTable/columns/TooltipSiteList.tsx rename to src/pages/app/components/Report/components/TooltipSiteList.tsx index 18f78e0fa..f9e7c336e 100644 --- a/src/pages/app/components/Report/ReportTable/columns/TooltipSiteList.tsx +++ b/src/pages/app/components/Report/components/TooltipSiteList.tsx @@ -5,15 +5,15 @@ import { ElScrollbar } from "element-plus" import { computed, defineComponent, StyleValue, toRefs } from "vue" type Props = { - modelValue?: timer.stat.SiteRow[] | false + modelValue: tt4b.stat.SiteRow[] | false | undefined clickDisabled?: boolean } const TooltipSiteList = defineComponent<Props>(props => { const { modelValue, clickDisabled: clickable } = toRefs(props) const iconMap = computed(() => { - const siteMap = new SiteMap<string>() - const rows = modelValue?.value + const siteMap = new SiteMap<string | undefined>() + const rows = modelValue.value if (!rows) return siteMap rows.forEach(({ siteKey, iconUrl }) => siteMap.put(siteKey, iconUrl)) return siteMap diff --git a/src/pages/app/components/Report/context.ts b/src/pages/app/components/Report/context.ts index 92fe56a78..e33e0ffb6 100644 --- a/src/pages/app/components/Report/context.ts +++ b/src/pages/app/components/Report/context.ts @@ -1,37 +1,43 @@ -import { useLocalStorage, useProvide, useProvider } from "@hooks" -import { reactive, type Reactive, ref, type Ref, toRaw, watch } from "vue" +import { useLocalStorage, useProvide, useProvider } from '@hooks' +import { createStringUnionGuard, isString } from 'typescript-guard' +import { reactive, ref, type ShallowRef, toRaw, watch } from "vue" import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" -import type { DisplayComponent, ReportFilterOption, ReportQueryParam, ReportSort } from "./types" +import type { DisplayComponent, ReportFilterOption, ReportSort } from "./types" type Context = { - filter: Reactive<ReportFilterOption> - sort: Ref<ReportSort> - comp: Ref<DisplayComponent | undefined> + filter: ReportFilterOption + sort: ShallowRef<ReportSort> + comp: ShallowRef<DisplayComponent | undefined> } const NAMESPACE = 'report' type QueryPartial = PartialPick<ReportFilterOption, 'query' | 'dateRange' | 'mergeDate' | 'siteMerge'> +const isSortProp = createStringUnionGuard<ReportSort['prop']>('date', 'host', 'focus', 'run', 'time') +const isSiteMerge = createStringUnionGuard<Exclude<ReportFilterOption['siteMerge'], undefined>>( + 'cate', 'domain', 'group', +) + /** * Init the query parameters */ function parseQuery(route: RouteLocation, router: Router): [QueryPartial, ReportSort['prop'] | undefined] { - const routeQuery = route.query as unknown as ReportQueryParam + const routeQuery = route.query const { q, mm, md, ds, de, sc } = routeQuery - const dateStart = ds ? new Date(Number.parseInt(ds)) : undefined - const dateEnd = de ? new Date(Number.parseInt(de)) : undefined + const dateStart = isString(ds) ? new Date(Number.parseInt(ds)) : undefined + const dateEnd = isString(de) ? new Date(Number.parseInt(de)) : undefined // Remove queries router.replace({ query: {} }) const now = new Date() const partial: QueryPartial = { - ...(q && { query: q }), + ...(isString(q) && { query: q }), ...((md === 'true' || md === '1') && { mergeDate: true }), ...((dateStart ?? dateEnd) && { dateRange: [dateStart ?? now, dateEnd ?? now] }), - ...(mm && { siteMerge: mm }) + ...(isSiteMerge(mm) && { siteMerge: mm }) } - return [partial, sc ? sc satisfies ReportSort['prop'] : undefined] + return [partial, isSortProp(sc) ? sc : undefined] } type FilterStorageValue = Omit<ReportFilterOption, 'dateRange' | 'readRemote'> & { @@ -40,7 +46,7 @@ type FilterStorageValue = Omit<ReportFilterOption, 'dateRange' | 'readRemote'> & } const cvtStorage2Filter = (storage: FilterStorageValue | undefined): ReportFilterOption => { - const { query, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage || {} + const { query, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage ?? {} const now = new Date() return { query, @@ -55,11 +61,12 @@ const cvtStorage2Filter = (storage: FilterStorageValue | undefined): ReportFilte const cvtFilter2Storage = (filter: ReportFilterOption): FilterStorageValue => { const { query, dateRange, mergeDate, siteMerge, cateIds, timeFormat } = filter + const [dateStart, dateEnd] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] return { query, mergeDate, siteMerge, - dateStart: dateRange?.[0]?.getTime?.(), - dateEnd: dateRange?.[1]?.getTime?.(), + dateStart: dateStart?.getTime?.(), + dateEnd: dateEnd?.getTime?.(), cateIds, timeFormat, } } @@ -70,14 +77,12 @@ export const initReportContext = () => { const [queryFilter, querySort] = parseQuery(route, router) const [cachedFilter, setCachedFilter] = useLocalStorage<FilterStorageValue>('report_filter') - - const initialFilter: ReportFilterOption = { ...cvtStorage2Filter(cachedFilter), ...queryFilter } - const filter = reactive(initialFilter) + const filter = reactive<ReportFilterOption>({ ...cvtStorage2Filter(cachedFilter), ...queryFilter }) watch(() => filter, v => setCachedFilter(cvtFilter2Storage(toRaw(v))), { deep: true }) const sort = ref<ReportSort>({ order: 'descending', - prop: querySort || 'focus' + prop: querySort ?? 'focus' }) const comp = ref<DisplayComponent>() @@ -88,8 +93,8 @@ export const initReportContext = () => { return context } -export const useReportFilter = (): Reactive<ReportFilterOption> => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter +export const useReportFilter = (): ReportFilterOption => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter -export const useReportSort = (): Ref<ReportSort> => useProvider<Context, 'sort'>(NAMESPACE, 'sort').sort +export const useReportSort = (): ShallowRef<ReportSort> => useProvider<Context, 'sort'>(NAMESPACE, 'sort').sort export const useReportComponent = () => useProvider<Context, 'comp'>(NAMESPACE, 'comp').comp \ No newline at end of file diff --git a/src/pages/app/components/Report/file-export.ts b/src/pages/app/components/Report/file-export.ts index 70d215f10..818cc5e9b 100644 --- a/src/pages/app/components/Report/file-export.ts +++ b/src/pages/app/components/Report/file-export.ts @@ -5,12 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { type I18nKey, t } from "@app/locale" -import { periodFormatter } from "@app/util/time" -import { - exportCsv as exportCsv_, - exportJson as exportJson_, -} from "@util/file" +import { type I18nKey, t } from '@app/locale' +import { periodFormatter } from '@app/util/time' +import { exportCsv as exportCsv_, exportJson as exportJson_ } from "@util/file" import { CATE_NOT_SET_ID } from "@util/site" import { getAlias, getGroupName, getHost, getRelatedCateId, isGroup } from "@util/stat" import { formatTimeYMD } from "@util/time" @@ -30,7 +27,8 @@ type ExportInfo = { * Compute the name of downloaded file */ function computeFileName(filterParam: ReportFilterOption): string { - const { dateRange: [ds, de], siteMerge, mergeDate, timeFormat } = filterParam + const { dateRange, siteMerge, mergeDate, timeFormat } = filterParam + const [ds, de] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] const parts = [ t(msg => msg.report.exportFileName), ds && formatTimeYMD(ds), @@ -52,7 +50,7 @@ const generateJsonData = ({ rows, categories, groupMap }: ExportParam): ExportIn time: row.time })) -const getCateName = (row: timer.stat.Row, categories: timer.site.Cate[]): string | undefined => { +const getCateName = (row: tt4b.stat.Row, categories: tt4b.site.Cate[]): string | undefined => { const cateId = getRelatedCateId(row) let cate: string | undefined = undefined if (cateId === CATE_NOT_SET_ID) { @@ -65,9 +63,9 @@ const getCateName = (row: timer.stat.Row, categories: timer.site.Cate[]): string } export type ExportParam = { - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] filter: ReportFilterOption - categories: timer.site.Cate[] + categories: tt4b.site.Cate[] groupMap: Record<number, chrome.tabGroups.TabGroup> } @@ -88,7 +86,7 @@ type CsvColumn = keyof ExportInfo type CsvColumnConfig = { visible: (mergeDate: boolean, siteMerge: ReportFilterOption['siteMerge']) => boolean i18n: I18nKey - formatter: (row: timer.stat.Row, categories: timer.site.Cate[], groupMap: Record<number, chrome.tabGroups.TabGroup>) => string + formatter: (row: tt4b.stat.Row, categories: tt4b.site.Cate[], groupMap: Record<number, chrome.tabGroups.TabGroup>) => string } const CSV_COLUMN_CONFIGS: Record<CsvColumn, CsvColumnConfig> = { diff --git a/src/pages/app/components/Report/index.tsx b/src/pages/app/components/Report/index.tsx index ead028cb6..0e44aec53 100644 --- a/src/pages/app/components/Report/index.tsx +++ b/src/pages/app/components/Report/index.tsx @@ -5,21 +5,21 @@ * https://opensource.org/licenses/MIT */ -import { useXsState } from "@hooks" +import { useXsState } from '@hooks' import { defineComponent } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import { initReportContext } from "./context" -import ReportFilter from "./ReportFilter" -import ReportList from "./ReportList" -import ReportTable from "./ReportTable" +import Filter from "./Filter" +import List from "./List" +import Table from "./Table" const _default = defineComponent(() => { const { comp } = initReportContext() const isXs = useXsState() return () => <ContentContainer v-slots={{ - filter: () => <ReportFilter />, - default: () => isXs.value ? <ReportList ref={comp} /> : <ReportTable ref={comp} /> + filter: () => <Filter />, + default: () => isXs.value ? <List ref={comp} /> : <Table ref={comp} /> }} /> }) diff --git a/src/pages/app/components/Report/types.d.ts b/src/pages/app/components/Report/types.d.ts index 157be51e4..5e5f5e99a 100644 --- a/src/pages/app/components/Report/types.d.ts +++ b/src/pages/app/components/Report/types.d.ts @@ -1,53 +1,24 @@ +import { DateRange } from '@util/time' import type { Sort } from "element-plus" export type ReportSort = Omit<Sort, 'prop'> & { - prop: timer.core.Dimension | 'host' | 'date' -} - -/** -* The query param of report page -*/ -export type ReportQueryParam = { - /** - * Query - */ - q?: string - /** - * Merge method - */ - mm?: Exclude<timer.stat.MergeMethod, 'date'> - /** - * Merge date - */ - md?: string - /** - * Date start - */ - ds?: string - /** - * Date end - */ - de?: string - /** - * Sorted column - */ - sc?: timer.core.Dimension + prop: tt4b.core.Dimension | 'host' | 'date' } export type ReportFilterOption = { query: string | undefined - dateRange: [Date?, Date?] + dateRange: DateRange mergeDate: boolean - siteMerge?: timer.stat.MergeMethod & ('cate' | 'domain' | 'group') + siteMerge?: Exclude<tt4b.stat.MergeMethod, 'date'> cateIds?: number[] /** * @since 1.1.7 */ - timeFormat: timer.app.TimeFormat + timeFormat: tt4b.app.TimeFormat readRemote?: boolean } export interface DisplayComponent { - getSelected(): timer.stat.Row[] + getSelected(): tt4b.stat.Row[] refresh(): Promise<void> | void } \ No newline at end of file diff --git a/src/pages/app/components/RuleMerge/ItemList.tsx b/src/pages/app/components/RuleMerge/ItemList.tsx index 5c809e895..1581e3db1 100644 --- a/src/pages/app/components/RuleMerge/ItemList.tsx +++ b/src/pages/app/components/RuleMerge/ItemList.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import mergeRuleDatabase from "@db/merge-rule-database" -import { useManualRequest, useRequest } from "@hooks" +import { addMergeRule, deleteMergeRule, listAllMergeRules } from "@api/sw/merge" +import { t } from '@app/locale' +import { useManualRequest, useRequest } from '@hooks' import Flex from "@pages/components/Flex" import { ElMessage, ElMessageBox } from "element-plus" import { defineComponent, ref } from "vue" @@ -15,18 +15,15 @@ import AddButton from './components/AddButton' import Item, { type ItemInstance } from './components/Item' const _default = defineComponent(() => { - const { data: items, refresh } = useRequest(() => mergeRuleDatabase.selectAll()) + const { data: items, refresh } = useRequest(listAllMergeRules, { defaultValue: [] }) const handleSucc = () => { ElMessage.success(t(msg => msg.operation.successMsg)) refresh() } - const { refreshAsync: remove } = useManualRequest( - (origin: string) => mergeRuleDatabase.remove(origin), - { onSuccess: handleSucc }, - ) + const { refreshAsync: remove } = useManualRequest(deleteMergeRule, { onSuccess: handleSucc }) const { refresh: update } = useManualRequest(async (origin: string, merged: string | number) => { - await mergeRuleDatabase.remove(origin) - await mergeRuleDatabase.add({ origin, merged }) + await deleteMergeRule(origin) + await addMergeRule({ origin, merged }) }, { onSuccess: handleSucc }) const itemRefs = ref<ItemInstance[]>([]) @@ -38,7 +35,7 @@ const _default = defineComponent(() => { } async function handleChange(origin: string, merged: string | number, index: number): Promise<void> { - const hasDuplicate = items.value?.find((o, i) => o.origin === origin && i !== index) + const hasDuplicate = items.value.some((o, i) => o.origin === origin && i !== index) if (hasDuplicate) { ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) itemRefs.value?.[index]?.forceEdit?.() @@ -48,12 +45,12 @@ const _default = defineComponent(() => { } const { refresh: add } = useManualRequest( - (rule: timer.merge.Rule) => mergeRuleDatabase.add(rule), + (rule: tt4b.merge.Rule) => addMergeRule(rule), { onSuccess: handleSucc } ) const handleAdd = async (origin: string, merged: string | number): Promise<boolean> => { - const alreadyExist = !!items.value?.find(item => item.origin === origin) + const alreadyExist = items.value.some(item => item.origin === origin) if (alreadyExist) { ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) return false @@ -71,7 +68,7 @@ const _default = defineComponent(() => { return () => ( <Flex gap={10} wrap justify="space-between"> - {items.value?.map((item, idx) => + {items.value.map((item, idx) => <Item ref={() => itemRefs.value[idx]} origin={item.origin} diff --git a/src/pages/app/components/RuleMerge/components/AddButton.tsx b/src/pages/app/components/RuleMerge/components/AddButton.tsx index c45d46f96..26bd67cff 100644 --- a/src/pages/app/components/RuleMerge/components/AddButton.tsx +++ b/src/pages/app/components/RuleMerge/components/AddButton.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch } from '@hooks' +import { t } from '@app/locale' import { ElButton } from "element-plus" import { defineComponent, StyleValue } from "vue" import ItemInput from './ItemInput' diff --git a/src/pages/app/components/RuleMerge/components/Item.tsx b/src/pages/app/components/RuleMerge/components/Item.tsx index f192a8cf0..adad1e988 100644 --- a/src/pages/app/components/RuleMerge/components/Item.tsx +++ b/src/pages/app/components/RuleMerge/components/Item.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ import EditableTag from "@app/components/common/EditableTag" -import { t } from "@app/locale" -import { useShadow, useSwitch } from "@hooks" +import { t } from '@app/locale' +import { useShadow, useSwitch } from '@hooks' import Flex from "@pages/components/Flex" import { LOCAL_HOST_PATTERN } from "@util/constant/remain-host" import { type TagProps } from "element-plus" diff --git a/src/pages/app/components/RuleMerge/components/ItemInput.tsx b/src/pages/app/components/RuleMerge/components/ItemInput.tsx index 7194c9dc3..423cb75ae 100644 --- a/src/pages/app/components/RuleMerge/components/ItemInput.tsx +++ b/src/pages/app/components/RuleMerge/components/ItemInput.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { Check, Close } from "@element-plus/icons-vue" -import { useShadow } from "@hooks" +import { useShadow } from '@hooks' import Box from "@pages/components/Box" import { LOCAL_HOST_PATTERN } from "@util/constant/remain-host" import { tryParseInteger } from "@util/number" diff --git a/src/pages/app/components/RuleMerge/index.tsx b/src/pages/app/components/RuleMerge/index.tsx index 0097a322b..7c4e536b6 100644 --- a/src/pages/app/components/RuleMerge/index.tsx +++ b/src/pages/app/components/RuleMerge/index.tsx @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import AlertLines from '@app/components/common/AlertLines' import Flex from "@pages/components/Flex" import { PSL_HOMEPAGE } from '@util/constant/url' import { ElCard } from "element-plus" import type { FunctionalComponent, StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import AlertLines from '../common/AlertLines' +import ContentContainer from '../common/ContentContainer' import ItemList from "./ItemList" const pslStyle: StyleValue = { diff --git a/src/pages/app/components/SiteManage/Modify/HostSelect.tsx b/src/pages/app/components/SiteManage/Modify/HostSelect.tsx new file mode 100644 index 000000000..3ad646b05 --- /dev/null +++ b/src/pages/app/components/SiteManage/Modify/HostSelect.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { searchSite } from '@api/sw/site' +import { t } from '@app/locale' +import { useManualRequest } from '@hooks' +import { identifySiteKey, parseSiteIdentity } from '@util/site' +import { ElOption, ElSelect, ElTag } from "element-plus" +import { computed, defineComponent } from "vue" + +type Props = ModelValue<tt4b.site.SiteKey | undefined> + +const EXIST_MSG = t(msg => msg.siteManage.msg.existedTag) +const MERGED_MSG = t(msg => msg.siteManage.type.merged?.name)?.toLocaleUpperCase?.() +const VIRTUAL_MSG = t(msg => msg.siteManage.type.virtual?.name)?.toLocaleUpperCase?.() + +/** + * Calculate the label of alias key to display + * + * @returns + * 1. www.google.com + * 2. www.google.com[MERGED] + * 4. www.google.com[EXISTED] + * 5. www.github.com/sheepzh/*[VIRTUAL] + * 5. www.github.com/sheepzh/*[VIRTUAL-EXISTED] + * 3. www.google.com[MERGED-EXISTED] + */ +function labelOf(site: tt4b.site.SiteInfo): string { + let { host: label, type, alias } = site + const suffix: string[] = [] + type === 'merged' && suffix.push(MERGED_MSG) + type === 'virtual' && suffix.push(VIRTUAL_MSG) + alias && suffix.push(EXIST_MSG) + suffix.length && (label += `[${suffix.join('-')}]`) + return label +} + +const _default = defineComponent<Props>(props => { + const value = computed(() => identifySiteKey(props.modelValue)) + const { data: options, loading: searching, refresh: searchOption } = useManualRequest( + searchSite, { defaultValue: [] } + ) + + return () => ( + <ElSelect + style={{ width: '100%' }} + modelValue={value.value} + filterable + remote + loading={searching.value} + remoteMethod={searchOption} + onChange={val => props.onChange?.(parseSiteIdentity(val) ?? { host: '', type: 'normal' })} + > + {options.value.map(row => ( + <ElOption value={identifySiteKey(row)} disabled={!!row.alias} label={labelOf(row)}> + <span>{row.host}</span> + <ElTag v-show={row.type === 'merged'} size="small">{MERGED_MSG}</ElTag> + <ElTag v-show={row.type === 'virtual'} size="small">{VIRTUAL_MSG}</ElTag> + <ElTag v-show={!!row.alias} size="small" type="info">{EXIST_MSG}</ElTag> + </ElOption> + ))} + </ElSelect> + ) +}, { props: ['modelValue', 'onChange'] }) + +export default _default diff --git a/src/pages/app/components/SiteManage/SiteManageModify/index.tsx b/src/pages/app/components/SiteManage/Modify/index.tsx similarity index 74% rename from src/pages/app/components/SiteManage/SiteManageModify/index.tsx rename to src/pages/app/components/SiteManage/Modify/index.tsx index 0e763d249..1949a4026 100644 --- a/src/pages/app/components/SiteManage/SiteManageModify/index.tsx +++ b/src/pages/app/components/SiteManage/Modify/index.tsx @@ -4,15 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import siteDatabase from '@db/site-database' +import { sendMsg2Runtime } from '@api/sw/common' +import CategorySelect from '@app/components/common/Category/Select' +import { t } from '@app/locale' import { Check } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { addSite } from "@service/site-service" +import { useSwitch } from '@hooks' import { supportCategory } from "@util/site" import { ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage, type FormInstance, type FormItemRule } from "element-plus" import { computed, defineComponent, reactive, ref } from "vue" -import CategorySelect from "../../common/category/CategorySelect" import HostSelect from "./HostSelect" export type ModifyInstance = { @@ -23,7 +22,7 @@ type _FormData = { /** * Value of alias key */ - key: timer.site.SiteKey | undefined + key: tt4b.site.SiteKey | undefined alias: string | undefined category: number | undefined } @@ -50,21 +49,6 @@ function validateForm(form: FormInstance | undefined): Promise<boolean> { }) } -async function handleAdd(siteInfo: timer.site.SiteInfo): Promise<boolean> { - const existed = await siteDatabase.exist(siteInfo) - if (existed) { - ElMessage({ - type: 'warning', - message: t(msg => msg.siteManage.msg.hostExistWarn, { host: siteInfo.host }), - showClose: true, - duration: 1600 - }) - } else { - await addSite(siteInfo) - } - return !existed -} - const initData = (): _FormData => ({ key: undefined, alias: undefined, @@ -83,21 +67,20 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { } ctx.expose({ add } satisfies ModifyInstance) - const handleSave = async () => { - const valid: boolean = await validateForm(form.value) + const handleAdd = async () => { + const valid = await validateForm(form.value) if (!valid) return false let { key: siteKey, alias } = formData if (!siteKey) return false alias = alias?.trim() - const siteInfo: timer.site.SiteInfo = { ...siteKey, alias, cate: formData.category } - const saved = await handleAdd(siteInfo) - if (saved) { - close() - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onSave?.() - } + const siteInfo: tt4b.site.SiteInfo = { ...siteKey, alias, cate: formData.category } + const errMsg = await sendMsg2Runtime('site.add', siteInfo) + if (errMsg) return ElMessage.warning(errMsg) + close() + ElMessage.success(t(msg => msg.operation.successMsg)) + props.onSave?.() } return () => ( @@ -109,13 +92,13 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { onClose={close} v-slots={{ footer: () => ( - <ElButton type="primary" icon={Check} onClick={handleSave}> + <ElButton type="primary" icon={Check} onClick={handleAdd}> {t(msg => msg.button.save)} </ElButton> ) }} > - <ElForm model={formData} rules={formRule} ref={form}> + <ElForm model={formData} rules={formRule} ref={form} labelWidth={130}> <ElFormItem prop="key" label={t(msg => msg.item.host)}> <HostSelect modelValue={formData.key} onChange={val => formData.key = val} /> </ElFormItem> @@ -123,7 +106,7 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { <ElInput modelValue={formData.alias} onInput={val => formData.alias = val} - onKeydown={ev => (ev as KeyboardEvent).key === "Enter" && handleSave()} + onKeydown={ev => ev instanceof KeyboardEvent && ev.key === "Enter" && handleAdd()} /> </ElFormItem> {showCate.value && ( diff --git a/src/pages/app/components/SiteManage/SiteManageFilter.tsx b/src/pages/app/components/SiteManage/SiteManageFilter.tsx index 2239de5b5..bd81bfbee 100644 --- a/src/pages/app/components/SiteManage/SiteManageFilter.tsx +++ b/src/pages/app/components/SiteManage/SiteManageFilter.tsx @@ -4,16 +4,16 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import ButtonFilterItem from "@app/components/common/filter/ButtonFilterItem" -import InputFilterItem from "@app/components/common/filter/InputFilterItem" import { useCategory } from "@app/context" -import { t } from "@app/locale" +import { t } from '@app/locale' import { Connection, Delete, Grid, Plus } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import { computed, defineComponent, watch } from "vue" -import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" -import CategoryFilter from "../common/filter/CategoryFilter" -import MultiSelectFilterItem from "../common/filter/MultiSelectFilterItem" +import DropdownButton, { type DropdownButtonItem } from '../common/DropdownButton' +import ButtonFilterItem from "../common/filter/ButtonFilterItem" +import CategoryFilter from '../common/filter/CategoryFilter' +import InputFilterItem from "../common/filter/InputFilterItem" +import MultiSelectFilterItem from '../common/filter/MultiSelectFilterItem' import { ALL_TYPES } from "./common" import { useSiteManageFilter } from './useSiteManage' @@ -69,8 +69,8 @@ const _default = defineComponent<{ <MultiSelectFilterItem placeholder={t(msg => msg.siteManage.column.type)} options={ALL_TYPES.map(type => ({ value: type, label: t(msg => msg.siteManage.type[type].name) }))} - defaultValue={filter.types} - onChange={val => filter.types = val as timer.site.Type[]} + defaultValue={filter.types ?? []} + onChange={val => filter.types = val as tt4b.site.Type[]} /> <CategoryFilter disabled={cateDisabled.value} diff --git a/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx b/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx deleted file mode 100644 index eff4d8c71..000000000 --- a/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import siteDatabase from '@db/site-database' -import { useManualRequest } from "@hooks" -import { listHosts } from "@service/stat-service" -import { MERGED_HOST, ALL_HOSTS as REMAIN_HOSTS } from "@util/constant/remain-host" -import { isValidVirtualHost, judgeVirtualFast } from "@util/pattern" -import { ElOption, ElSelect, ElTag } from "element-plus" -import { computed, defineComponent } from "vue" -import { cvt2OptionValue, cvt2SiteKey, EXIST_MSG, labelOf, MERGED_MSG, VIRTUAL_MSG } from "../common" - -type _OptionInfo = { - siteKey: timer.site.SiteKey - hasAlias: boolean -} - -function cleanQuery(query: string) { - try { - const url = new URL(query) - const { host, pathname } = url - query = host + pathname - } catch { - } - if (query.endsWith('/')) query += '**' - return query -} - -async function handleRemoteSearch(query: string): Promise<_OptionInfo[]> { - query = cleanQuery(query) - if (!query) return [] - const { normal, merged } = await listHosts(query) - const allAlias: timer.site.SiteKey[] = [ - ...normal.map(host => ({ host, type: 'normal' } satisfies timer.site.SiteKey)), - ...merged.map(host => ({ host, type: 'merged' } satisfies timer.site.SiteKey)), - ] - // Add local files - REMAIN_HOSTS.filter(h => h.includes(query)).forEach(remain => allAlias.push({ host: remain, type: 'normal' })) - MERGED_HOST.includes(query) && allAlias.push({ host: MERGED_HOST, type: 'merged' }) - const existedAliasSet = new Set() - const existedKeys = await siteDatabase.existBatch(allAlias) - existedKeys.forEach(key => existedAliasSet.add(cvt2OptionValue(key))) - const existedOptions: _OptionInfo[] = [] - const notExistedOptions: _OptionInfo[] = [] - allAlias.forEach(siteKey => { - const hasAlias = existedAliasSet.has(cvt2OptionValue(siteKey)) - const props: _OptionInfo = { siteKey, hasAlias } - hasAlias ? existedOptions.push(props) : notExistedOptions.push(props) - }) - - const originalOptions = [...notExistedOptions, ...existedOptions] - - const result: _OptionInfo[] = [] - const existIdx = originalOptions.findIndex(o => o.siteKey?.host === query) - if (existIdx === -1) { - // Not exist host, insert site into the first - const isVirtual = judgeVirtualFast(query) - if (isVirtual) { - isValidVirtualHost(query) && result.push({ siteKey: { host: query, type: 'virtual' }, hasAlias: false }) - } else { - result.push({ siteKey: { host: query, type: 'normal' }, hasAlias: false }) - } - result.push(...originalOptions) - } else { - result.push(originalOptions[existIdx]) - originalOptions.forEach((opt, idx) => idx !== existIdx && result.push(opt)) - } - return result -} - -type Props = { - modelValue: timer.site.SiteKey | undefined - onChange?: (_siteKey: timer.site.SiteKey | undefined) => void -} - -const _default = defineComponent<Props>(props => { - const value = computed(() => cvt2OptionValue(props.modelValue)) - const { data: options, loading: searching, refresh: searchOption } = useManualRequest(handleRemoteSearch) - - return () => ( - <ElSelect - style={{ width: '100%' }} - modelValue={value.value} - filterable - remote - loading={searching.value} - remoteMethod={searchOption} - onChange={val => props.onChange?.(cvt2SiteKey(val))} - > - {options.value?.map(({ siteKey, hasAlias }) => ( - <ElOption value={cvt2OptionValue(siteKey)} disabled={hasAlias} label={labelOf(siteKey, hasAlias)}> - <span>{siteKey.host}</span> - <ElTag v-show={siteKey.type === 'merged'} size="small">{MERGED_MSG}</ElTag> - <ElTag v-show={siteKey.type === 'virtual'} size="small">{VIRTUAL_MSG}</ElTag> - <ElTag v-show={hasAlias} size="small" type="info">{EXIST_MSG}</ElTag> - </ElOption> - ))} - </ElSelect> - ) -}, { props: ['modelValue', 'onChange'] }) - -export default _default diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx deleted file mode 100644 index 2369ab7b3..000000000 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import Editable from "@app/components/common/Editable" -import { t } from "@app/locale" -import { MagicStick } from "@element-plus/icons-vue" -import Flex from "@pages/components/Flex" -import { batchGetSites, batchSaveAliasNoRewrite, removeAlias, saveAlias } from "@service/site-service" -import { getSuffix as getPslSuffix } from "@util/psl" -import { identifySiteKey, SiteMap } from "@util/site" -import { ElIcon, ElMessage, ElPopconfirm, ElTableColumn, ElText } from "element-plus" -import { toUnicode as punyCode2Unicode } from "punycode" -import { defineComponent, type StyleValue } from "vue" -import { useSiteManageTable } from '../../useSiteManage' - -function cvt2Alias(part: string): string { - let decoded = part - try { - decoded = punyCode2Unicode(part) - } catch { - } - return decoded.charAt(0).toUpperCase() + decoded.slice(1) -} - -export function genInitialAlias(site: timer.site.SiteInfo): string | undefined { - const { host, alias, type } = site || {} - if (alias) return - if (type !== 'normal') return - let parts = host.split('.') - if (parts.length < 2) return - - const suffix = getPslSuffix(host) - const prefix = host.replace(`.${suffix}`, '').replace(/^www\./, '') - parts = prefix.split('.') - return parts.reverse().map(cvt2Alias).join(' ') -} - -const AliasColumn = defineComponent<{}>(() => { - const { pagination, refresh } = useSiteManageTable() - - const handleChange = async (newAlias: string | undefined, row: timer.site.SiteInfo) => { - newAlias = newAlias?.trim?.() - row.alias = newAlias - if (newAlias) { - await saveAlias(row, newAlias) - } else { - await removeAlias(row) - } - refresh() - } - - const handleBatchGenerate = async () => { - let data = pagination.value?.list - if (!data?.length) { - return ElMessage.info("No data") - } - const toSave = new SiteMap<string>() - const items = await batchGetSites(data) - items.filter(i => !i.alias).forEach(site => { - const newAlias = genInitialAlias(site) - newAlias && toSave.put(site, newAlias) - }) - await batchSaveAliasNoRewrite(toSave) - refresh() - ElMessage.success(t(msg => msg.operation.successMsg)) - } - - return () => ( - <ElTableColumn - minWidth={160} - align="center" - v-slots={{ - header: () => ( - <Flex justify="center" align="center" gap={4}> - <span>{t(msg => msg.siteManage.column.alias)}</span> - <ElPopconfirm - title={t(msg => msg.siteManage.genAliasConfirmMsg)} - width={400} - onConfirm={handleBatchGenerate} - v-slots={{ - reference: () => ( - <Flex height='100%'> - <ElText type="primary" style={{ cursor: 'pointer' } satisfies StyleValue}> - <ElIcon color="primary"> - <MagicStick /> - </ElIcon> - </ElText> - </Flex> - ) - }} - /> - </Flex> - ), - default: ({ row }: { row: timer.site.SiteInfo }) => <Editable - key={`${identifySiteKey(row)}_${row.alias}`} - modelValue={row.alias} - initialValue={genInitialAlias(row)} - onChange={val => handleChange(val, row)} - /> - }} - /> - ) -}) - -export default AliasColumn \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx new file mode 100644 index 000000000..2f2395ce1 --- /dev/null +++ b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { changeSiteAlias, fillInitialAlias, getInitialAlias } from "@api/sw/site" +import Editable from "@app/components/common/Editable" +import { useSiteManageTable } from '@app/components/SiteManage/useSiteManage' +import { t } from '@app/locale' +import { MagicStick } from "@element-plus/icons-vue" +import { useManualRequest } from '@hooks' +import Flex from "@pages/components/Flex" +import { identifySiteKey } from "@util/site" +import { ElIcon, ElMessage, ElPopconfirm, ElTableColumn, ElText } from "element-plus" +import { defineComponent, type StyleValue } from "vue" + +const AliasColumn = defineComponent<{}>(() => { + const { pagination, refresh } = useSiteManageTable() + const { refresh: doChange } = useManualRequest(changeSiteAlias, { onSuccess: refresh }) + + const handleBatchGenerate = async () => { + let { list } = pagination.value + if (!list.length) return ElMessage.info("No data") + await fillInitialAlias(list) + refresh() + ElMessage.success(t(msg => msg.operation.successMsg)) + } + + const genInitialAlias = async ({ host, type, alias }: tt4b.site.SiteInfo) => { + if (alias) return alias + if (type === 'normal') return await getInitialAlias(host) + return undefined + } + + return () => ( + <ElTableColumn + minWidth={160} + align="center" + v-slots={{ + header: () => ( + <Flex justify="center" align="center" gap={4}> + <span>{t(msg => msg.siteManage.column.alias)}</span> + <ElPopconfirm + title={t(msg => msg.siteManage.genAliasConfirmMsg)} + width={400} + onConfirm={handleBatchGenerate} + v-slots={{ + reference: () => ( + <Flex height='100%'> + <ElText type="primary" style={{ cursor: 'pointer' } satisfies StyleValue}> + <ElIcon color="primary"> + <MagicStick /> + </ElIcon> + </ElText> + </Flex> + ) + }} + /> + </Flex> + ), + default: ({ row }: { row: tt4b.site.SiteInfo }) => ( + <Editable + key={`${identifySiteKey(row)}_${row.alias}`} + modelValue={row.alias} + initialValue={() => genInitialAlias(row)} + onChange={val => doChange(row, val)} + /> + ) + }} + /> + ) +}) + +export default AliasColumn \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx similarity index 64% rename from src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx rename to src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx index 4821e56d0..5be59cbcd 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx @@ -4,31 +4,31 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import PopupConfirmButton from "@app/components/common/PopupConfirmButton" -import { t } from "@app/locale" +import { deleteSites } from '@api/sw/site' +import PopupConfirmButton from '@app/components/common/PopupConfirmButton' +import { useSiteManageTable } from '@app/components/SiteManage/useSiteManage' +import { t } from '@app/locale' import { Delete } from "@element-plus/icons-vue" -import { useRequest } from '@hooks' -import { removeSites } from "@service/site-service" +import { useManualRequest } from '@hooks' import { ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import { useSiteManageTable } from '../../useSiteManage' const OperationColumn = defineComponent<{}>(() => { const { refresh } = useSiteManageTable() - const { refresh: handleConfirm } = useRequest(removeSites, { onSuccess: refresh }) + const { refresh: doDelete } = useManualRequest<[tt4b.site.SiteKey], void>(deleteSites, { onSuccess: refresh }) return () => ( <ElTableColumn width={150} label={t(msg => msg.button.operation)} align="center" v-slots={ - ({ row }: RenderRowData<timer.site.SiteInfo>) => ( + ({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( <PopupConfirmButton buttonIcon={Delete} buttonType="danger" buttonText={t(msg => msg.button.delete)} confirmText={t(msg => msg.siteManage.deleteConfirmMsg, { host: row.host })} - onConfirm={() => handleConfirm(row)} + onConfirm={() => doDelete(row)} /> )} /> diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/TypeColumn.tsx b/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx similarity index 75% rename from src/pages/app/components/SiteManage/SiteManageTable/column/TypeColumn.tsx rename to src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx index f853f70fa..9f66a66e8 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/TypeColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx @@ -1,13 +1,13 @@ -import ColumnHeader from "@app/components/common/ColumnHeader" -import { t } from "@app/locale" +import ColumnHeader from '@app/components/common/ColumnHeader' +import { ALL_TYPES } from "@app/components/SiteManage/common" +import { t } from '@app/locale' import { ElTableColumn, ElTag, type RenderRowData, type TagProps } from "element-plus" import { type FunctionalComponent } from "vue" import type { JSX } from "vue/jsx-runtime" -import { ALL_TYPES } from "../../common" -const computeText = ({ type }: timer.site.SiteInfo): string => t(msg => msg.siteManage.type[type].name) +const computeText = ({ type }: tt4b.site.SiteInfo): string => t(msg => msg.siteManage.type[type].name) -function computeType({ type }: timer.site.SiteInfo): TagProps["type"] | undefined { +function computeType({ type }: tt4b.site.SiteInfo): TagProps["type"] | undefined { switch (type) { case 'merged': return 'info' case 'virtual': return 'success' @@ -34,7 +34,7 @@ const TypeColumn: FunctionalComponent = () => ( }} /> ), - default: ({ row }: RenderRowData<timer.site.SiteInfo>) => ( + default: ({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( <ElTag size="small" type={computeType(row)}> {computeText(row)} </ElTag> diff --git a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx b/src/pages/app/components/SiteManage/Table/index.tsx similarity index 69% rename from src/pages/app/components/SiteManage/SiteManageTable/index.tsx rename to src/pages/app/components/SiteManage/Table/index.tsx index 50cf8245a..c8a9933e5 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx +++ b/src/pages/app/components/SiteManage/Table/index.tsx @@ -4,13 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import HostAlert from "@app/components/common/HostAlert" -import { t } from "@app/locale" +import { changeSiteRun, deleteSiteIcon } from '@api/sw/site' +import Category from '@app/components/common/Category' +import HostAlert from '@app/components/common/HostAlert' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import { removeIconUrl, saveSiteRunState } from '@service/site-service' +import Img from '@pages/components/Img' import { ElSwitch, ElTable, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import CategoryEditable from "../../common/category/CategoryEditable" import { useSiteManageTable } from '../useSiteManage' import AliasColumn from "./column/AliasColumn" import OperationColumn from "./column/OperationColumn" @@ -19,21 +20,20 @@ import TypeColumn from "./column/TypeColumn" const _default = defineComponent<{}>(() => { const { setSelected, refresh, pagination } = useSiteManageTable() - const handleIconError = async (row: timer.site.SiteInfo) => { - await removeIconUrl(row) + const handleIconError = async (row: tt4b.site.SiteInfo) => { + await deleteSiteIcon(row) row.iconUrl = undefined } - const handleRunChange = async (val: boolean, row: timer.site.SiteInfo) => { - // Save - await saveSiteRunState(row, val) + const handleRunChange = async (val: boolean, row: tt4b.site.SiteInfo) => { + await changeSiteRun(row, val) row.run = val refresh() } return () => ( <ElTable - data={pagination.value?.list} + data={pagination.value.list} height="100%" highlightCurrentRow border fit onSelection-change={setSelected} @@ -43,7 +43,7 @@ const _default = defineComponent<{}>(() => { label={t(msg => msg.item.host)} minWidth={220} align="center" - v-slots={({ row }: RenderRowData<timer.site.SiteInfo>) => ( + v-slots={({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( <div style={{ margin: 'auto', width: 'fit-content' }}> <HostAlert value={row} /> </div> @@ -54,12 +54,12 @@ const _default = defineComponent<{}>(() => { label={t(msg => msg.siteManage.column.icon)} minWidth={100} align="center" - v-slots={({ row }: RenderRowData<timer.site.SiteInfo>) => { + v-slots={({ row }: RenderRowData<tt4b.site.SiteInfo>) => { const { iconUrl } = row || {} if (!iconUrl) return '' return ( <Flex align="center" justify="center"> - <img width={12} height={12} src={iconUrl} onError={() => handleIconError(row)} /> + <Img size={12} src={iconUrl} onError={() => handleIconError(row)} /> </Flex> ) }} @@ -69,8 +69,8 @@ const _default = defineComponent<{}>(() => { label={t(msg => msg.siteManage.column.cate)} minWidth={140} align="center" - v-slots={({ row }: RenderRowData<timer.site.SiteInfo>) => ( - <CategoryEditable siteKey={row} modelValue={row?.cate} onChange={val => row.cate = val} /> + v-slots={({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( + <Category.Editable siteKey={row} modelValue={row?.cate} onChange={val => row.cate = val} /> )} /> <ElTableColumn @@ -78,7 +78,7 @@ const _default = defineComponent<{}>(() => { width={100} align="center" > - {({ row }: RenderRowData<timer.site.SiteInfo>) => row.type === 'normal' && ( + {({ row }: RenderRowData<tt4b.site.SiteInfo>) => row.type === 'normal' && ( <ElSwitch size="small" modelValue={row.run} diff --git a/src/pages/app/components/SiteManage/common.ts b/src/pages/app/components/SiteManage/common.ts index a3cfed2ca..f9575e095 100644 --- a/src/pages/app/components/SiteManage/common.ts +++ b/src/pages/app/components/SiteManage/common.ts @@ -5,62 +5,4 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" - -const MERGED_FLAG = 'm' -const VIRTUAL_FLAG = 'v' -const NONE_FLAG = '_' - -/** - * site key => option value - */ -export function cvt2OptionValue(siteKey: timer.site.SiteKey | undefined): string { - if (!siteKey) return '' - const { type } = siteKey - let flag = NONE_FLAG - type === 'merged' && (flag = MERGED_FLAG) - type === 'virtual' && (flag = VIRTUAL_FLAG) - return `${flag}${siteKey.host}` -} - -/** - * option value => site key - */ -export function cvt2SiteKey(optionValue: string): timer.site.SiteKey { - const flag = optionValue.substring(0, 1) - const host = optionValue.substring(1) - if (flag === MERGED_FLAG) { - return { host, type: 'merged' } - } else if (flag === VIRTUAL_FLAG) { - return { host, type: 'virtual' } - } else { - return { host, type: 'normal' } - } -} - -export const EXIST_MSG = t(msg => msg.siteManage.msg.existedTag) -export const MERGED_MSG = t(msg => msg.siteManage.type.merged?.name)?.toLocaleUpperCase?.() -export const VIRTUAL_MSG = t(msg => msg.siteManage.type.virtual?.name)?.toLocaleUpperCase?.() - -/** - * Calculate the label of alias key to display - * - * @returns - * 1. www.google.com - * 2. www.google.com[MERGED] - * 4. www.google.com[EXISTED] - * 5. www.github.com/sheepzh/*[VIRTUAL] - * 5. www.github.com/sheepzh/*[VIRTUAL-EXISTED] - * 3. www.google.com[MERGED-EXISTED] - */ -export function labelOf(siteKey: timer.site.SiteKey, exists?: boolean): string { - let { host: label, type } = siteKey || {} - const suffix: string[] = [] - type === 'merged' && suffix.push(MERGED_MSG) - type === 'virtual' && suffix.push(VIRTUAL_MSG) - exists && suffix.push(EXIST_MSG) - suffix.length && (label += `[${suffix.join('-')}]`) - return label -} - -export const ALL_TYPES: timer.site.Type[] = ['normal', 'merged', 'virtual'] \ No newline at end of file +export const ALL_TYPES: tt4b.site.Type[] = ['normal', 'merged', 'virtual'] \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/index.tsx b/src/pages/app/components/SiteManage/index.tsx index aba58bb31..ec1513d15 100644 --- a/src/pages/app/components/SiteManage/index.tsx +++ b/src/pages/app/components/SiteManage/index.tsx @@ -5,20 +5,20 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { changeSitesCate, deleteSites } from "@api/sw/site" +import { t } from '@app/locale' import { Check, Close, WarnTriangleFilled } from "@element-plus/icons-vue" -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch } from '@hooks' import Flex from "@pages/components/Flex" -import { batchSaveSiteCate, removeSites } from "@service/site-service" import { supportCategory } from "@util/site" import { ElButton, ElDialog, ElForm, ElFormItem, ElMessage, ElMessageBox } from "element-plus" import { computed, defineComponent, markRaw, ref, type VNode } from "vue" -import ContentContainer from "../common/ContentContainer" -import Pagination from "../common/Pagination" -import CategorySelect from "../common/category/CategorySelect" -import SiteManageFilter from "./SiteManageFilter" -import Modify, { type ModifyInstance } from './SiteManageModify' -import SiteManageTable from "./SiteManageTable" +import Category from '../common/Category' +import ContentContainer from '../common/ContentContainer' +import Pagination from '../common/Pagination' +import Modify, { type ModifyInstance } from './Modify' +import Filter from "./SiteManageFilter" +import Table from "./Table" import { initSiteManage } from './useSiteManage' export default defineComponent(() => { @@ -29,15 +29,15 @@ export default defineComponent(() => { } = initSiteManage(() => loadingTarget.value?.el as HTMLElement | undefined) const modify = ref<ModifyInstance>() - const cateSupported = computed(() => selected?.value?.filter(supportCategory) || []) + const cateSupported = computed(() => selected.value.filter(supportCategory)) const [showCateChange, openCateChange, closeCateChange] = useSwitch(false) const [batchCate, setBatchCate] = useState<number>() const handleChangeCate = () => { - if (!selected.value?.length) { + if (!selected.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) } - if (!cateSupported.value?.length) { + if (!cateSupported.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSupported)) } setBatchCate() @@ -46,17 +46,16 @@ export default defineComponent(() => { const onCateChangeConfirm = async () => { const cateId = batchCate.value - if (!cateId) { - return ElMessage.warning("Category not selected") - } - await batchSaveSiteCate(cateId, cateSupported.value) + if (!cateId) return ElMessage.warning("Category not selected") + + await changeSitesCate(cateId, ...cateSupported.value) ElMessage.success(t(msg => msg.operation.successMsg)) closeCateChange() refresh() } const handleDisassociate = () => { - if (!selected.value?.length) { + if (!selected.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) } ElMessageBox.confirm( @@ -67,15 +66,15 @@ export default defineComponent(() => { closeOnClickModal: true, } ).then(async () => { - const need2Clear = cateSupported.value?.filter(s => s.cate) - need2Clear?.length && await batchSaveSiteCate(undefined, need2Clear) + const need2Clear = cateSupported.value.filter(s => s.cate) + need2Clear.length && await changeSitesCate(undefined, ...need2Clear) ElMessage.success(t(msg => msg.operation.successMsg)) refresh() }).catch(() => { }) } const handleBatchDelete = () => { - if (!selected.value?.length) { + if (!selected.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) } ElMessageBox.confirm( @@ -87,7 +86,7 @@ export default defineComponent(() => { icon: markRaw(WarnTriangleFilled), } ).then(async () => { - await removeSites(...(selected.value ?? [])) + await deleteSites(...(selected.value ?? [])) ElMessage.success(t(msg => msg.operation.successMsg)) refresh() }).catch(() => { }) @@ -95,7 +94,7 @@ export default defineComponent(() => { return () => <ContentContainer v-slots={{ filter: () => ( - <SiteManageFilter + <Filter onCreate={() => modify.value?.add?.()} onBatchChangeCate={handleChangeCate} onBatchDelete={handleBatchDelete} @@ -105,13 +104,13 @@ export default defineComponent(() => { content: () => <> <Flex ref={loadingTarget} column width="100%" height="100%" gap={23}> <Flex flex={1} height={0}> - <SiteManageTable /> + <Table /> </Flex> <Flex justify="center"> <Pagination disabled={loading.value} defaultValue={page} - total={pagination.value?.total || 0} + total={pagination.value.total} onChange={val => { page.num = val.num, page.size = val.size }} /> </Flex> @@ -126,7 +125,7 @@ export default defineComponent(() => { default: () => <> <ElForm> <ElFormItem label={t(msg => msg.siteManage.cate.name)} required> - <CategorySelect modelValue={batchCate.value} onChange={setBatchCate} /> + <Category.Select modelValue={batchCate.value} onChange={setBatchCate} /> </ElFormItem> </ElForm> </>, diff --git a/src/pages/app/components/SiteManage/useSiteManage.ts b/src/pages/app/components/SiteManage/useSiteManage.ts index f5dfd57ee..4d497dd5d 100644 --- a/src/pages/app/components/SiteManage/useSiteManage.ts +++ b/src/pages/app/components/SiteManage/useSiteManage.ts @@ -1,13 +1,10 @@ -import { - type RequestOption, - useLocalStorage, useProvide, useProvider, useRequest, useState -} from '@hooks' -import { selectSitePage, type SiteQueryParam } from '@service/site-service' -import { type Reactive, reactive, type ShallowRef, watch } from 'vue' +import { getSitePage } from '@api/sw/site' +import { type RequestOption, useLocalStorage, useProvide, useProvider, useRequest, useState } from '@hooks' +import { reactive, type ShallowRef, watch } from 'vue' type FilterOption = { query?: string - types?: timer.site.Type[] + types?: tt4b.site.Type[] cateIds?: number[] } @@ -16,10 +13,10 @@ type CacheValue = { } type Context = { - pagination: ShallowRef<timer.common.PageResult<timer.site.SiteInfo> | undefined> - filter: Reactive<FilterOption> - selected: ShallowRef<timer.site.SiteInfo[]> - setSelected: ArgCallback<timer.site.SiteInfo[]> + pagination: ShallowRef<tt4b.common.PageResult<tt4b.site.SiteInfo>> + filter: FilterOption + selected: ShallowRef<tt4b.site.SiteInfo[]> + setSelected: ArgCallback<tt4b.site.SiteInfo[]> refresh: NoArgCallback } @@ -31,14 +28,17 @@ export const initSiteManage = (loadingTarget: RequestOption<unknown, unknown[]>[ const filter = reactive<FilterOption>({ cateIds: cache?.cateIds }) watch(() => filter.cateIds, cateIds => setCache({ cateIds })) - const page = reactive<timer.common.PageQuery>({ num: 1, size: 20 }) - const [selected, setSelected] = useState<timer.site.SiteInfo[]>([]) + const page = reactive<tt4b.common.PageQuery>({ num: 1, size: 20 }) + const [selected, setSelected] = useState<tt4b.site.SiteInfo[]>([]) const { data: pagination, refresh, loading } = useRequest(() => { const { query: fuzzyQuery, cateIds, types } = filter - const param: SiteQueryParam = { fuzzyQuery, cateIds, types } - return selectSitePage(param, page) - }, { loadingTarget, deps: [() => filter, () => page] }) + return getSitePage({ fuzzyQuery, cateIds, types }, page) + }, { + defaultValue: { list: [], total: 0 }, + loadingTarget, + deps: [() => filter, () => page], + }) useProvide<Context>(NAMESPACE, { pagination, filter, selected, setSelected, refresh }) diff --git a/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx b/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx index 310520863..9a90d1889 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx @@ -4,8 +4,8 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch } from '@hooks' +import { t } from '@app/locale' import { ElButton } from "element-plus" import { defineComponent, StyleValue } from "vue" import WhiteInput from './WhiteInput' diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx index 70ecdbaba..58f94ab07 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx @@ -5,18 +5,18 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { listSites } from '@api/sw/site' +import { t } from '@app/locale' import { Check, CirclePlus, Close } from "@element-plus/icons-vue" -import { useRequest, useShadow } from "@hooks" +import { useRequest, useShadow } from '@hooks' import Box from "@pages/components/Box" import Flex from '@pages/components/Flex' -import { selectAllSites } from '@service/site-service' import { EXCLUDING_PREFIX, isRemainHost } from "@util/constant/remain-host" import { isValidHost, judgeVirtualFast } from "@util/pattern" import { ElButton, ElIcon, ElMessage, ElOption, ElSelect, ElTag } from "element-plus" import { defineComponent, StyleValue } from "vue" -type SearchItem = timer.site.SiteKey & { +type SearchItem = tt4b.site.SiteKey & { exclude?: boolean } @@ -24,18 +24,16 @@ async function remoteSearch(query: string): Promise<SearchItem[]> { let exclude = false if (query?.startsWith(EXCLUDING_PREFIX)) { exclude = true - query = query.slice(1) + query = query.slice(EXCLUDING_PREFIX.length) } if (!query) return [] - let sites: SearchItem[] = await selectAllSites({ fuzzyQuery: query }) + const sites: SearchItem[] = await listSites({ fuzzyQuery: query }) const idx = sites.findIndex(s => s.host === query) - const target = idx >= 0 - // Move found item to the front - ? sites.splice(idx, 1)[0] - // Or create a new one if not found - : { host: query, type: judgeVirtualFast(query) ? 'virtual' : 'normal' } satisfies SearchItem + const target: SearchItem = (idx >= 0 ? sites.splice(idx, 1)[0] : null) + ?? { host: query, type: judgeVirtualFast(query) ? 'virtual' : 'normal' } + const result = [target, ...sites] result.forEach(s => s.exclude = exclude) diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx index 26db89160..3d33ceef4 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import EditableTag, { type EditableTagProps } from "@app/components/common/EditableTag" -import { useShadow, useSwitch } from "@hooks" +import EditableTag, { type EditableTagProps } from '@app/components/common/EditableTag' +import { useShadow, useSwitch } from '@hooks' import { EXCLUDING_PREFIX } from '@util/constant/remain-host' import { judgeVirtualFast } from "@util/pattern" import { computed, defineComponent } from "vue" diff --git a/src/pages/app/components/Whitelist/WhitePanel/index.tsx b/src/pages/app/components/Whitelist/WhitePanel/index.tsx index ac665afbf..5ab5099f1 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/index.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/index.tsx @@ -4,9 +4,9 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { addWhitelist, deleteWhitelist, listWhitelist } from "@api/sw/whitelist" +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import whitelistService from "@service/whitelist/service" import { ElMessage, ElMessageBox } from "element-plus" import { defineComponent, onBeforeMount, reactive } from "vue" import AddButton from './AddButton' @@ -14,17 +14,18 @@ import WhiteItem from './WhiteItem' const _default = defineComponent(() => { const whitelist = reactive<string[]>([]) - onBeforeMount(() => whitelistService.listAll().then(l => whitelist.push(...l))) + onBeforeMount(() => listWhitelist().then(l => whitelist.push(...l))) const handleChanged = async (val: string, index: number): Promise<boolean> => { - const duplicate = whitelist.find((white, i) => white === val && i !== index) + const duplicate = whitelist.some((white, i) => white === val && i !== index) if (duplicate) { ElMessage.warning(t(msg => msg.whitelist.duplicateMsg)) // Reopen return false } - await whitelistService.remove(whitelist[index]) - await whitelistService.add(val) + const exists = whitelist[index] + if (exists) await deleteWhitelist(exists) + await addWhitelist(val) whitelist[index] = val ElMessage.success(t(msg => msg.operation.successMsg)) return true @@ -41,7 +42,7 @@ const _default = defineComponent(() => { const title = t(msg => msg.operation.confirmTitle) return ElMessageBox.confirm(msg, title, { dangerouslyUseHTMLString: true }) .then(async () => { - await whitelistService.add(val) + await addWhitelist(val) whitelist.push(val) ElMessage.success(t(msg => msg.operation.successMsg)) return true @@ -54,7 +55,7 @@ const _default = defineComponent(() => { const confirmTitle = t(msg => msg.operation.confirmTitle) ElMessageBox .confirm(confirmMsg, confirmTitle, { dangerouslyUseHTMLString: true }) - .then(() => whitelistService.remove(whiteItem)) + .then(() => deleteWhitelist(whiteItem)) .then(() => { ElMessage.success(t(msg => msg.operation.successMsg)) const index = whitelist.indexOf(whiteItem) diff --git a/src/pages/app/components/Whitelist/index.tsx b/src/pages/app/components/Whitelist/index.tsx index ba16cd5d0..867070cdc 100644 --- a/src/pages/app/components/Whitelist/index.tsx +++ b/src/pages/app/components/Whitelist/index.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import AlertLines from '@app/components/common/AlertLines' import Flex from "@pages/components/Flex" import { ElCard } from "element-plus" import { type FunctionalComponent } from "vue" -import ContentContainer from "../common/ContentContainer" +import AlertLines from '../common/AlertLines' +import ContentContainer from '../common/ContentContainer' import WhitePanel from "./WhitePanel" const Whitelist: FunctionalComponent = () => ( diff --git a/src/pages/app/components/common/AlertLines.tsx b/src/pages/app/components/common/AlertLines.tsx index 9ab1717d9..4ee9df148 100644 --- a/src/pages/app/components/common/AlertLines.tsx +++ b/src/pages/app/components/common/AlertLines.tsx @@ -13,20 +13,25 @@ export type AlertLinesProps = { type?: AlertProps['type'] } -const AlertLines: FunctionalComponent<AlertLinesProps> = ({ lines, title, type }) => ( - <ElAlert - type={type} - title={typeof title === 'string' ? title : t(title)} - closable={false} - style={STYLE} - > - {lines?.map(l => <li>{ - Array.isArray(l) - ? tN(l[0], l[1]) - : (typeof l === 'string' ? l : t(l)) - }</li>)} - </ElAlert> -) +const AlertLines: FunctionalComponent<AlertLinesProps> = ({ lines, title, type }) => { + return ( + <ElAlert + key={type} + type={type} + title={typeof title === 'string' ? title : t(title)} + closable={false} + style={STYLE} + > + {lines?.map((l, i) => ( + <li key={i}> + {Array.isArray(l) + ? tN(l[0], l[1]) + : (typeof l === 'string' ? l : t(l))} + </li> + ))} + </ElAlert> + ) +} AlertLines.displayName = 'AlertLines' diff --git a/src/pages/app/components/common/category/CategoryEditable.tsx b/src/pages/app/components/common/Category/Editable.tsx similarity index 86% rename from src/pages/app/components/common/category/CategoryEditable.tsx rename to src/pages/app/components/common/Category/Editable.tsx index 6ddcaf686..e1fc31ccd 100644 --- a/src/pages/app/components/common/category/CategoryEditable.tsx +++ b/src/pages/app/components/common/Category/Editable.tsx @@ -1,15 +1,15 @@ +import { changeSitesCate } from '@api/sw/site' import { useCategory } from "@app/context" import { Edit } from "@element-plus/icons-vue" import { useManualRequest, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import { saveSiteCate } from '@service/site-service' import { supportCategory } from "@util/site" import { ElIcon, ElTag } from "element-plus" import { computed, defineComponent, nextTick, ref } from "vue" -import CategorySelect, { CategorySelectInstance } from "./CategorySelect" +import Select, { type Instance } from "./Select" type Props = ModelValue<number | undefined> & { - siteKey: timer.site.SiteKey + siteKey: tt4b.site.SiteKey } const CategoryEditable = defineComponent<Props>(props => { @@ -25,7 +25,7 @@ const CategoryEditable = defineComponent<Props>(props => { const { refresh: doSave } = useManualRequest(async (cateId: number | string | undefined) => { const realCateId = typeof cateId === 'string' ? parseInt(cateId) : cateId - await saveSiteCate(props.siteKey, realCateId) + await changeSitesCate(realCateId, props.siteKey) return realCateId }, { onSuccess(realCateId) { @@ -34,17 +34,18 @@ const CategoryEditable = defineComponent<Props>(props => { }, }) - const handleEditClick = () => { + const handleEditClick = (ev: MouseEvent) => { + ev.stopImmediatePropagation() openEditing() nextTick(() => selectRef.value?.openOptions?.()) } - const selectRef = ref<CategorySelectInstance>() + const selectRef = ref<Instance>() return () => supportCategory(props.siteKey) ? <Flex width="100%" height="100%" justify="center"> {editing.value ? - <CategorySelect + <Select ref={selectRef} size="small" width="100px" @@ -68,7 +69,7 @@ const CategoryEditable = defineComponent<Props>(props => { <Edit /> </ElIcon> </Flex> - </Flex > + </Flex> } </Flex> : false diff --git a/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx b/src/pages/app/components/common/Category/Select/OptionItem.tsx similarity index 86% rename from src/pages/app/components/common/category/CategorySelect/OptionItem.tsx rename to src/pages/app/components/common/Category/Select/OptionItem.tsx index f075c081f..eb8f95916 100644 --- a/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx +++ b/src/pages/app/components/common/Category/Select/OptionItem.tsx @@ -1,40 +1,43 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { listSites } from "@api/sw/site" import { useCategory } from "@app/context" import { t } from "@app/locale" import { Check, Close, Delete, Edit } from "@element-plus/icons-vue" import { useManualRequest, useRequest, useState, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import cateService from "@service/cate-service" -import { selectAllSites } from '@service/site-service' import { stopPropagationAfter } from "@util/document" import { ElButton, ElInput, ElMessage, ElMessageBox } from "element-plus" import { defineComponent, nextTick, ref } from "vue" -const OptionItem = defineComponent<{ value: timer.site.Cate }>(props => { +const OptionItem = defineComponent<{ value: tt4b.site.Cate }>(props => { const cate = useCategory() const [editing, openEditing, closeEditing] = useSwitch(false) const [editingName, setEditingName] = useState(props.value.name) const inputRef = ref<HTMLInputElement>() - const { refresh: saveCate } = useManualRequest((name: string) => cateService.saveName(props.value.id, name), { - onSuccess: () => { - cate.refresh() - closeEditing() - } - }) + const { refresh: saveCate } = useManualRequest( + (name: string) => sendMsg2Runtime('cate.change', { id: props.value.id, name }), + { + onSuccess: () => { + cate.refresh() + closeEditing() + } + }, + ) const onSaveClick = () => { const name = editingName.value?.trim?.() name ? saveCate(name) : ElMessage.warning("Name is blank") } - const { refresh: removeCate } = useManualRequest(() => cateService.remove(props.value.id), { + const { refresh: doRemoveCate } = useManualRequest(() => sendMsg2Runtime('cate.delete', props.value.id), { onSuccess: () => { cate.refresh() ElMessage.success(t(msg => msg.operation.successMsg)) } }) - const { data: relatedSites, loading: queryingSites } = useRequest(() => selectAllSites({ cateIds: props.value?.id })) + const { data: relatedSites, loading: queryingSites } = useRequest(() => listSites({ cateIds: props.value?.id })) const onRemoveClick = (e: MouseEvent) => { e.stopPropagation() @@ -48,7 +51,7 @@ const OptionItem = defineComponent<{ value: timer.site.Cate }>(props => { message: t(msg => msg.siteManage.cate.removeConfirm, { category: props.value?.name }), type: 'warning', closeOnClickModal: false, - }).then(removeCate).catch(() => { }) + }).then(doRemoveCate).catch(() => { }) } const onEditClick = () => { diff --git a/src/pages/app/components/common/category/CategorySelect/SelectFooter.tsx b/src/pages/app/components/common/Category/Select/SelectFooter.tsx similarity index 96% rename from src/pages/app/components/common/category/CategorySelect/SelectFooter.tsx rename to src/pages/app/components/common/Category/Select/SelectFooter.tsx index 035220366..621a1ea97 100644 --- a/src/pages/app/components/common/category/CategorySelect/SelectFooter.tsx +++ b/src/pages/app/components/common/Category/Select/SelectFooter.tsx @@ -1,9 +1,9 @@ +import { sendMsg2Runtime } from '@api/sw/common' import { useCategory } from "@app/context" import { t } from "@app/locale" import { Check, Close, Plus } from "@element-plus/icons-vue" import { useManualRequest, useState, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import cateService from "@service/cate-service" import { stopPropagationAfter } from "@util/document" import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from "element-plus" import { defineComponent, nextTick, ref } from "vue" @@ -14,7 +14,7 @@ const SelectFooter = defineComponent(() => { const [name, setName] = useState<string>() const { refresh: saveCate, loading } = useManualRequest( - (name: string) => cateService.add(name), + (name: string) => sendMsg2Runtime('cate.add', name), { onSuccess() { cate.refresh() diff --git a/src/pages/app/components/common/category/CategorySelect/index.tsx b/src/pages/app/components/common/Category/Select/index.tsx similarity index 75% rename from src/pages/app/components/common/category/CategorySelect/index.tsx rename to src/pages/app/components/common/Category/Select/index.tsx index bc74edab4..47f2476c2 100644 --- a/src/pages/app/components/common/category/CategorySelect/index.tsx +++ b/src/pages/app/components/common/Category/Select/index.tsx @@ -5,7 +5,7 @@ import { defineComponent, ref } from "vue" import OptionItem from "./OptionItem" import SelectFooter from "./SelectFooter" -export type CategorySelectInstance = { +export type Instance = { openOptions: () => void } @@ -18,29 +18,29 @@ type Props = { onChange?: ArgCallback<number | undefined> } -const CategorySelect = defineComponent<Props>((props, ctx) => { +const Select = defineComponent<Props>((props, ctx) => { const cate = useCategory() const selectRef = ref<SelectInstance>() ctx.expose({ openOptions: () => selectRef.value?.selectOption?.() - } satisfies CategorySelectInstance) + } satisfies Instance) return () => ( <ElSelect ref={selectRef} size={props.size} modelValue={props.modelValue} - onChange={val => ctx.emit('change', val)} - onVisible-change={visible => ctx.emit('visibleChange', visible)} + onChange={val => props.onChange?.(val)} + onVisible-change={visible => props.onVisibleChange?.(visible)} style={{ width: props.width || '100%' }} clearable={props.clearable} - onClear={() => ctx.emit('change', undefined)} + onClear={() => props.onChange?.(undefined)} emptyValues={[CATE_NOT_SET_ID, undefined]} v-slots={{ footer: () => <SelectFooter /> }} > {cate.all.map(c => ( - <ElOption value={c?.id} label={c?.name}> + <ElOption value={c.id} label={c.name}> <OptionItem value={c} /> </ElOption> ))} @@ -48,4 +48,4 @@ const CategorySelect = defineComponent<Props>((props, ctx) => { ) }, { props: ['clearable', 'modelValue', 'size', 'width', 'onVisibleChange', 'onChange'] }) -export default CategorySelect +export default Select diff --git a/src/pages/app/components/common/Category/index.ts b/src/pages/app/components/common/Category/index.ts new file mode 100644 index 000000000..bd0b45662 --- /dev/null +++ b/src/pages/app/components/common/Category/index.ts @@ -0,0 +1,9 @@ +import Editable from './Editable' +import Select from './Select' + +const Category = { + Select: Select, + Editable: Editable, +} + +export default Category diff --git a/src/pages/app/components/common/DialogSop.tsx b/src/pages/app/components/common/DialogSop.tsx deleted file mode 100644 index 043a845e0..000000000 --- a/src/pages/app/components/common/DialogSop.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { t } from "@app/locale" -import { Back, Check, Close, Right } from "@element-plus/icons-vue" -import { css } from '@emotion/css' -import Box from "@pages/components/Box" -import Flex from "@pages/components/Flex" -import { type ButtonProps, ElButton, useNamespace } from "element-plus" -import { computed, defineComponent, h, useSlots } from "vue" - -export type SopStepInstance<T> = { parseData: Getter<T> } - -export type SopInstance = { init: NoArgCallback } - -const FLAGS = ['first', 'last', 'nextLoading', 'finishLoading'] as const -const EMITS = ['onBack', 'onNext', 'onCancel', 'onFinish'] as const - -type FinishBtn = { - text?: string - type?: ButtonProps['type'] -} - -type Props = { - [F in typeof FLAGS[number]]?: boolean -} & { - [C in typeof EMITS[number]]?: NoArgCallback -} & { - finishBtn?: FinishBtn['text'] | FinishBtn -} - -const DialogSop = defineComponent<Props>(props => { - const { steps, content } = useSlots() - const stepWrapperCls = css` - & .${useNamespace('step').b()} { - width: 200px; - } - ` - - const finishBtn = computed<FinishBtn>(() => { - const prop = props.finishBtn - if (!prop) return {} - if (typeof prop === 'string') return { text: prop } - return prop - }) - - return () => ( - <Flex column align="center" gap={40} marginTop={25}> - <div class={stepWrapperCls}> - {!!steps && h(steps)} - </div> - <Box padding="0 20px" boxSizing="border-box" width="100%"> - {!!content && h(content)} - </Box> - <Flex> - <Flex> - {props.first ? ( - <ElButton type="info" icon={Close} onClick={props.onCancel}> - {t(msg => msg.button.cancel)} - </ElButton> - ) : ( - <ElButton type="info" icon={Back} onClick={props.onBack}> - {t(msg => msg.button.previous)} - </ElButton> - )}{ - props.last ? ( - <ElButton - icon={Check} - type={finishBtn.value.type ?? 'success'} - onClick={props.onFinish} loading={props.finishLoading} - > - {finishBtn.value.text ?? t(msg => msg.button.save)} - </ElButton> - ) : ( - <ElButton type="primary" icon={Right} onClick={props.onNext} loading={props.nextLoading}> - {t(msg => msg.button.next)} - </ElButton> - ) - } - </Flex> - </Flex> - </Flex> - ) -}, { props: [...FLAGS, ...EMITS, 'finishBtn'] }) - -export default DialogSop \ No newline at end of file diff --git a/src/pages/app/components/common/DialogSop/context.ts b/src/pages/app/components/common/DialogSop/context.ts new file mode 100644 index 000000000..157477cea --- /dev/null +++ b/src/pages/app/components/common/DialogSop/context.ts @@ -0,0 +1,98 @@ +import { t } from '@app/locale' +import { useManualRequest, useProvide, useProvider } from "@hooks" +import { mergeObject } from '@util/lang' +import { ElMessage } from "element-plus" +import { computed, nextTick, reactive, ref, ShallowRef, type Reactive, type Ref } from "vue" + +type DialogSopContext<TForm extends Record<string, unknown>> = { + visible: ShallowRef<boolean> + step: Ref<number> + form: Reactive<TForm> + isFirst: ShallowRef<boolean> + isLast: ShallowRef<boolean> + doNext: NoArgCallback + nextLoading: ShallowRef<boolean> + doPrevious: NoArgCallback + hide: NoArgCallback +} + +const NAMESPACE = "dialogSop" + +type TransmitParam<TForm extends Record<string, unknown>> = { + form: Reactive<TForm> + current: number + target: number +} + +type DialogSopInitOptions<TForm extends Record<string, unknown>> = { + stepCount: number + init: () => TForm + onNext?: (p: TransmitParam<TForm>) => Awaitable<void> + onFinish?: (p: Omit<TransmitParam<TForm>, 'target'>) => Awaitable<void> + onBack?: (p: TransmitParam<TForm>) => Awaitable<void> +} + +export function initDialogSopContext<TForm extends Record<string, unknown>>(options: DialogSopInitOptions<TForm>) { + const { + stepCount, + init, onNext, onFinish, onBack, + } = options + if (!Number.isInteger(stepCount) || stepCount < 1) throw new Error("Invalid step count, must be positive integer") + const lastIdx = stepCount - 1 + + const visible = ref(false) + const step = ref(0) + const isLast = computed(() => step.value === lastIdx) + const isFirst = computed(() => step.value === 0) + const form = reactive<TForm>(init()) + const hide = () => visible.value = false + const open = (val?: TForm) => { + visible.value = true + step.value = 0 + nextTick(() => mergeObject(form as TForm, val ?? init())) + } + + const { loading: nextLoading, refresh: doNext } = useManualRequest(async () => { + const current = step.value + if (isLast.value) { + await onFinish?.({ form, current }) + visible.value = false + ElMessage.success(t(msg => msg.operation.successMsg)) + } else { + const target = current + 1 + await onNext?.({ form, current, target }) + step.value = target + } + }, { + onError: e => ElMessage.error(e instanceof Error ? e.message : String(e)), + }) + + const doPrevious = async () => { + if (isFirst.value) { + hide() + } else { + try { + const current = step.value + const target = current - 1 + await onBack?.({ current, target, form }) + step.value = target + } catch (e) { + ElMessage.warning(e instanceof Error ? e.message : String(e)) + } + } + } + + useProvide<DialogSopContext<TForm>>(NAMESPACE, { + visible, form, isFirst, isLast, + step, doNext, doPrevious, nextLoading, hide + }) + + return { open, step, form } +} + +export const useDialogSop = <TForm extends Record<string, unknown>>() => useProvider< + DialogSopContext<TForm>, + 'form' | 'isLast' | 'isFirst' | 'step' | 'doPrevious' | 'doNext' | 'nextLoading' | 'hide' | 'visible' +>( + NAMESPACE, 'form', 'doPrevious', 'doNext', 'isFirst', 'isLast', 'step', 'nextLoading', 'hide', 'visible' +) \ No newline at end of file diff --git a/src/pages/app/components/common/DialogSop/index.tsx b/src/pages/app/components/common/DialogSop/index.tsx new file mode 100644 index 000000000..7cc34244c --- /dev/null +++ b/src/pages/app/components/common/DialogSop/index.tsx @@ -0,0 +1,80 @@ +import { t } from "@app/locale" +import { Back, Check, Close } from "@element-plus/icons-vue" +import { css } from '@emotion/css' +import { useXsState } from '@hooks' +import Box from "@pages/components/Box" +import Flex from "@pages/components/Flex" +import { type ButtonProps, DialogProps, ElButton, ElDialog, ElDivider, ElStep, ElSteps, ElText, useNamespace } from "element-plus" +import { defineComponent, h, type StyleValue, useSlots } from "vue" +import { useDialogSop } from './context' + +type Props = { + stepTitles: string[] + finishButton?: { + text?: string + type?: ButtonProps['type'] + } +} & Pick<DialogProps, 'width' | 'top' | 'title'> + +const DialogSop = defineComponent<Props>(props => { + const { + step, visible, + isFirst, isLast, nextLoading, + doNext, doPrevious, hide, + } = useDialogSop() + + const isXs = useXsState() + + const stepWrapperCls = css` + & .${useNamespace('step').b()} { + width: 200px; + } + ` + + return () => { + const { default: children } = useSlots() + + return ( + <ElDialog + width={props.width} top={props.top} + fullscreen={isXs.value} appendToBody + title={props.title} + modelValue={visible.value} onClose={hide} + closeOnClickModal + > + <Flex column align="center" gap={40} marginTop={25}> + <div class={stepWrapperCls}> + {isXs.value ? ( + <Flex column align='center'> + <ElText size='large'>{props.stepTitles[step.value]}</ElText> + <ElDivider style={{ marginBlockStart: '5px' } satisfies StyleValue} /> + </Flex> + ) : ( + <ElSteps finishStatus="success" active={step.value} alignCenter> + {props.stepTitles.map(stepTitle => <ElStep title={stepTitle} />)} + </ElSteps> + )} + </div> + <Box padding="0 20px" boxSizing="border-box" width="100%"> + {!!children && h(children)} + </Box> + <Flex> + <ElButton type='info' icon={isFirst.value ? Close : Back} onClick={doPrevious}> + {t(msg => msg.button[isFirst.value ? 'cancel' : 'previous'])} + </ElButton> + <ElButton + icon={Check} + type={isLast.value ? props.finishButton?.type ?? 'success' : 'primary'} + onClick={doNext} + loading={nextLoading.value} + > + {isLast.value ? props.finishButton?.text ?? t(msg => msg.button.save) : t(msg => msg.button.next)} + </ElButton> + </Flex> + </Flex> + </ElDialog> + ) + } +}, { props: ['title', 'stepTitles', 'finishButton', 'width', 'top'] }) + +export default DialogSop diff --git a/src/pages/app/components/common/Editable.tsx b/src/pages/app/components/common/Editable.tsx index 76dcf141d..a3788a246 100644 --- a/src/pages/app/components/common/Editable.tsx +++ b/src/pages/app/components/common/Editable.tsx @@ -13,7 +13,7 @@ import { defineComponent, nextTick, ref, type StyleValue, toRef, useSlots } from type Props = { modelValue: string | undefined - initialValue?: string + initialValue?: string | (() => (Awaitable<string | undefined>)) onChange?: (newVal: string | undefined) => void } @@ -37,10 +37,20 @@ const Editable = defineComponent<Props>(props => { closeEditing() props.onChange?.(inputVal.value?.trim()) } + + const resetWithInitial = async () => { + const initial = props.initialValue + if (typeof initial === 'string') { + setInputVal(initial) + } else if (typeof initial === 'function') { + const initialValue = await initial() + setInputVal(initialValue) + } + } + const handleEdit = () => { openEditing() - const initial = props.initialValue - !input.value && initial && setInputVal(initial) + resetWithInitial() nextTick(() => input.value?.focus?.()) } const { label: labelSlot } = useSlots() diff --git a/src/pages/app/components/common/HostAlert.tsx b/src/pages/app/components/common/HostAlert.tsx index 4f3b9c925..a1c7e2146 100644 --- a/src/pages/app/components/common/HostAlert.tsx +++ b/src/pages/app/components/common/HostAlert.tsx @@ -6,13 +6,14 @@ */ import Flex from "@pages/components/Flex" +import Img from '@pages/components/Img' import { IS_SAFARI } from "@util/constant/environment" import { isRemainHost } from "@util/constant/remain-host" import { ElLink } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" type Props = { - value: timer.site.SiteKey + value: tt4b.site.SiteKey iconUrl?: string clickable?: boolean } @@ -45,14 +46,12 @@ const HostAlert = defineComponent<Props>(props => { > {props.value?.host} </ElLink> - {props.iconUrl && - <Flex align="center"> - <img src={props.iconUrl} width={12} height={12} /> - </Flex> - } + <Flex align="center"> + <Img src={props.iconUrl} size={12} /> + </Flex> </Flex> )} - </div > + </div> }, { props: ['clickable', 'iconUrl', 'value'] }) export default HostAlert \ No newline at end of file diff --git a/src/pages/app/components/common/Pagination.tsx b/src/pages/app/components/common/Pagination.tsx index 762e0dfc8..6c5647129 100644 --- a/src/pages/app/components/common/Pagination.tsx +++ b/src/pages/app/components/common/Pagination.tsx @@ -12,9 +12,9 @@ import { defineComponent } from "vue" type Props = { disabled?: boolean - defaultValue?: timer.common.PageQuery + defaultValue?: tt4b.common.PageQuery total?: number - onChange?: (val: timer.common.PageQuery) => void + onChange?: (val: tt4b.common.PageQuery) => void } const Pagination = defineComponent<Props>(props => { @@ -22,10 +22,10 @@ const Pagination = defineComponent<Props>(props => { <Flex justify="center" align="center"> <ElPagination disabled={props.disabled} - {...getPaginationIconProps() || {}} + {...getPaginationIconProps()} pageSizes={[10, 20, 50]} - defaultCurrentPage={(props.defaultValue as timer.common.PageQuery)?.num} - defaultPageSize={(props.defaultValue as timer.common.PageQuery)?.size} + defaultCurrentPage={(props.defaultValue as tt4b.common.PageQuery)?.num} + defaultPageSize={(props.defaultValue as tt4b.common.PageQuery)?.size} layout="total, sizes, prev, pager, next, jumper" total={props.total} onChange={(currentPage, pageSize) => props.onChange?.({ num: currentPage, size: pageSize })} diff --git a/src/pages/app/components/common/PopupConfirmButton.tsx b/src/pages/app/components/common/PopupConfirmButton.tsx index 4472449df..5a96f479f 100644 --- a/src/pages/app/components/common/PopupConfirmButton.tsx +++ b/src/pages/app/components/common/PopupConfirmButton.tsx @@ -21,7 +21,7 @@ type Props = { const PopupConfirmButton: FunctionalComponent<Props> = props => ( <ElPopconfirm - confirmButtonText={t(msg => msg.button.okey)} + confirmButtonText={t(msg => msg.button.okay)} cancelButtonText={t(msg => msg.button.dont)} title={props.confirmText} width={300} diff --git a/src/pages/app/components/common/filter/ButtonFilterItem.tsx b/src/pages/app/components/common/filter/ButtonFilterItem.tsx index 4e173b2e8..665c63894 100644 --- a/src/pages/app/components/common/filter/ButtonFilterItem.tsx +++ b/src/pages/app/components/common/filter/ButtonFilterItem.tsx @@ -4,6 +4,7 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { type I18nKey, t } from '@app/locale' import { type ButtonProps, ElButton } from "element-plus" import { defineComponent } from "vue" @@ -16,11 +17,21 @@ type Props = { } const ButtonFilterItem = defineComponent<Props>(props => { - return () => ( - <ElButton type={props.type ?? 'primary'} icon={props.icon} onClick={props.onClick}> - {t(props.text)} - </ElButton> - ) + const isXs = useXsState() + return () => isXs.value + ? ( + <ElButton + circle + size='small' + type={props.type ?? 'primary'} + icon={props.icon} + onClick={props.onClick} + /> + ) : ( + <ElButton type={props.type ?? 'primary'} icon={props.icon} onClick={props.onClick}> + {t(props.text)} + </ElButton> + ) }, { props: ['icon', 'onClick', 'text', 'type'] }) export default ButtonFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/CategoryFilter.tsx b/src/pages/app/components/common/filter/CategoryFilter.tsx index 2631e3081..438cfc3ce 100644 --- a/src/pages/app/components/common/filter/CategoryFilter.tsx +++ b/src/pages/app/components/common/filter/CategoryFilter.tsx @@ -13,8 +13,8 @@ const CategoryFilter = defineComponent<Props>(props => { const cate = useCategory() const options = computed(() => [ + ...cate.all.map(c => ({ value: c.id, label: c.name })), { value: CATE_NOT_SET_ID, label: t(msg => msg.shared.cate.notSet) }, - ...cate.all.map(c => ({ value: c.id, label: c.name })) ]) return () => cate.enabled ? ( diff --git a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx index faaaef54f..ecea9924b 100644 --- a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx +++ b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx @@ -5,38 +5,36 @@ * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { t } from "@app/locale" import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue' import { css } from '@emotion/css' -import { useXsState } from '@hooks/useMediaSize' -import { dateFormat } from "@i18n/element" import Flex from '@pages/components/Flex' -import { getDatePickerIconSlots } from "@pages/element-ui/rtl" +import { getDatePickerIconSlots } from '@pages/element-ui/rtl' +import { ElDatePickerShortcut } from '@pages/element-ui/types' +import { dateFormat } from "@i18n/element" import { isRtl } from '@util/document' import { MILL_PER_DAY } from '@util/time' import { type DatePickerProps, ElButton, ElDatePicker, ElText, useNamespace } from "element-plus" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { computed, defineComponent, type FunctionalComponent, type StyleValue, toRaw, toRef } from "vue" -const clearShortcut = (): Shortcut => ({ +const clearShortcut = (): ElDatePickerShortcut => ({ text: t(msg => msg.button.clear), value: [new Date(0), new Date(0)], }) -type Value = [Date?, Date?] - -type Props = ModelValue<Value> & { +type Props = ModelValue<[Date?, Date?]> & { disabledDate?: (date: Date) => boolean startPlaceholder?: string endPlaceholder?: string - shortcuts?: Shortcut[] + shortcuts?: ElDatePickerShortcut[] clearable?: boolean } const ALL_PROPS: (keyof Props)[] = ["clearable", "disabledDate", "endPlaceholder", "modelValue", "onChange", "shortcuts", "startPlaceholder"] const ARROW_BTN_STYLE: StyleValue = { - padding: '8px 1px', + padding: '8px 5px', } const usePopperStyle = () => { @@ -108,12 +106,12 @@ const DefaultRange = defineComponent<Props>(props => { } = useRange(props) const innerVal = computed(() => { - const [start, end] = props.modelValue + const [start, end] = props.modelValue ?? [] return start && end ? [start, end] : undefined }) const handleUpdate = (innerVal: [Date, Date] | undefined) => { - let value: Value = innerVal ?? [undefined, undefined] + let value = innerVal ?? [undefined, undefined] if (innerVal?.[0].getTime() === 0 && innerVal[1].getTime() === 0) { // clear shortcuts value = [undefined, undefined] diff --git a/src/pages/app/components/common/filter/InputFilterItem.tsx b/src/pages/app/components/common/filter/InputFilterItem.tsx index 14d2003af..6ef8cb36e 100644 --- a/src/pages/app/components/common/filter/InputFilterItem.tsx +++ b/src/pages/app/components/common/filter/InputFilterItem.tsx @@ -7,30 +7,19 @@ import { Search } from "@element-plus/icons-vue" import { useState } from "@hooks" +import { Enter } from '@pages/icons' import { ElIcon, ElInput } from "element-plus" -import { computed, defineComponent, ref, toRef, type StyleValue } from "vue" - -const EnterIcon = defineComponent<{ focused?: boolean }>(props => { - return () => ( - <ElIcon color={props.focused ? 'var(--el-color-primary)' : undefined}> - <svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5645" width="200" height="200"> - <path d="M417 1c-48.602 0-88 39.399-88 88v346H89c-48.6 0-88 39.399-88 88v412c0 48.6 39.4 88 88 88h314.69c4.341 0.658 8.786 1 13.31 1h517c24.555 0 46.761-10.057 62.724-26.277C1012.943 981.761 1023 959.555 1023 935V523c0-4.524-0.341-8.968-1-13.31V89c0-48.601-39.398-88-88-88H417z m250.036 645.739V389.131c0-27.134 21.977-49.131 49.087-49.131 27.11 0 49.088 21.997 49.088 49.131V745H453.699l31.657 31.657c19.174 19.173 19.167 50.263-0.012 69.441-19.178 19.179-50.268 19.185-69.441 0.013L266 696.207l149.956-149.955c19.179-19.179 50.269-19.185 69.441-0.013 19.172 19.173 19.167 50.263-0.012 69.441l-31.059 31.059h212.71z" /> - </svg> - </ElIcon> - ) -}, { props: ['focused'] }) +import { computed, defineComponent, ref, type StyleValue } from "vue" type Props = { defaultValue?: string placeholder?: string - enter?: boolean width?: number | string onSearch?: ArgCallback<string> } const InputFilterItem = defineComponent<Props>(props => { const modelValue = ref(props.defaultValue ?? '') - const enter = toRef(props, 'enter', true) const width = computed(() => { const w = props.width @@ -43,21 +32,30 @@ const InputFilterItem = defineComponent<Props>(props => { setFocused(false) props.onSearch?.(modelValue.value) } + + const handleKeydown = (ev: Event | KeyboardEvent) => { + if (ev instanceof KeyboardEvent && ev.key === 'Enter') { + props.onSearch?.(modelValue.value) + } + } + return () => ( <ElInput modelValue={modelValue.value} placeholder={props.placeholder} - clearable={!enter.value} - onClear={() => props.onSearch?.(modelValue.value = '')} onInput={val => modelValue.value = val.trim()} - onKeydown={ev => (ev as KeyboardEvent).key === 'Enter' && enter.value && props.onSearch?.(modelValue.value)} - onBlur={() => handleBlur()} + onKeydown={handleKeydown} + onBlur={handleBlur} onFocus={() => setFocused(true)} style={{ width: width.value } satisfies StyleValue} - suffixIcon={enter.value ? <EnterIcon focused={focused.value} /> : undefined} + suffixIcon={( + <ElIcon color={focused.value ? 'var(--el-color-primary)' : undefined}> + <Enter /> + </ElIcon> + )} prefixIcon={Search} /> ) -}, { props: ['defaultValue', 'enter', 'placeholder', 'width', 'onSearch'] }) +}, { props: ['defaultValue', 'placeholder', 'width', 'onSearch'] }) export default InputFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx b/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx index f943eaa65..57028d6ee 100644 --- a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx +++ b/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx @@ -1,27 +1,16 @@ -import { useCached } from "@hooks" import { ElSelect } from "element-plus" -import { defineComponent, watch } from "vue" -import { useRoute } from "vue-router" -import { SELECT_WRAPPER_STYLE } from "./common" +import { defineComponent } from "vue" +import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, SELECT_WRAPPER_STYLE, useFilterState } from "./common" type Data = string | number -type Props = { - defaultValue?: Data[] - /** - * Whether to save the value in the localStorage with {@param historyName} - */ - historyName?: string +type Props = BaseFilterProps<Data[]> & { placeholder?: string - disabled?: boolean options?: { value: Data, label?: string }[] - onChange?: (val: Data[]) => void } const MultiSelectFilterItem = defineComponent<Props>(props => { - const cacheKey = props.historyName && `__filter_item_multi_select_${useRoute().path}_${props.historyName}` - const { data, setter } = useCached<Data[]>(cacheKey, props.defaultValue) - watch(data, val => props.onChange?.(val ?? [])) + const [data, setter] = useFilterState('multi_select', props) return () => ( <ElSelect @@ -30,13 +19,12 @@ const MultiSelectFilterItem = defineComponent<Props>(props => { multiple clearable collapseTags - disabled={props.disabled} onClear={() => setter([])} placeholder={props.placeholder} style={SELECT_WRAPPER_STYLE} options={props.options} /> ) -}, { props: ['defaultValue', 'historyName', 'placeholder', 'disabled', 'options'] }) +}, { props: [...ALL_BASE_FILTER_PROPS, 'placeholder', 'options'] }) export default MultiSelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SelectFilterItem.tsx b/src/pages/app/components/common/filter/SelectFilterItem.tsx index 25ee0e992..a1d9c1669 100644 --- a/src/pages/app/components/common/filter/SelectFilterItem.tsx +++ b/src/pages/app/components/common/filter/SelectFilterItem.tsx @@ -5,34 +5,26 @@ * https://opensource.org/licenses/MIT */ -import { useCached } from "@hooks" -import { ElSelect } from "element-plus" -import { defineComponent, watch } from "vue" -import { useRoute } from "vue-router" -import { SELECT_WRAPPER_STYLE } from "./common" +import { ElSelect, SelectProps } from "element-plus" +import { defineComponent } from "vue" +import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, SELECT_WRAPPER_STYLE, useFilterState } from "./common" -type Props = { - defaultValue?: string - /** - * Whether to save the value in the localStorage with {@param historyName} - */ - historyName?: string +type Props = BaseFilterProps<string | undefined> & Pick<SelectProps, "placeholder"> & { options: Record<string, string> - onSelect?: (val: string | undefined) => void } const SelectFilterItem = defineComponent<Props>(props => { - const cacheKey = props.historyName && `__filter_item_select_${useRoute().path}_${props.historyName}` - const { data, setter } = useCached(cacheKey, props.defaultValue) - watch(data, val => props.onSelect?.(val)) + const [data, setter] = useFilterState('select', props) + return () => ( <ElSelect + placeholder={props.placeholder} modelValue={data.value} onChange={setter} style={SELECT_WRAPPER_STYLE} options={Object.entries(props.options).map(([value, label]) => ({ label, value }))} /> ) -}, { props: ['defaultValue', 'historyName', 'options', 'onSelect'] }) +}, { props: [...ALL_BASE_FILTER_PROPS, 'options', 'placeholder'] }) export default SelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SwitchFilterItem.tsx b/src/pages/app/components/common/filter/SwitchFilterItem.tsx index b59a7a9c6..f1b46e7bc 100644 --- a/src/pages/app/components/common/filter/SwitchFilterItem.tsx +++ b/src/pages/app/components/common/filter/SwitchFilterItem.tsx @@ -5,33 +5,24 @@ * https://opensource.org/licenses/MIT */ -import { useCached } from "@hooks" import Flex from "@pages/components/Flex" import { ElSwitch, ElText } from "element-plus" -import { defineComponent, watch } from "vue" -import { useRoute } from "vue-router" +import { defineComponent } from "vue" +import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, useFilterState } from './common' -const _default = defineComponent({ - emits: { - change: (_val: boolean) => true - }, - props: { - label: String, - defaultValue: Boolean, - historyName: String, - }, - setup(props, ctx) { - const cacheKey = props.historyName ? `__filter_item_switch_${useRoute().path}_${props.historyName}` : undefined - const { data, setter } = useCached(cacheKey, props.defaultValue) - watch(data, () => ctx.emit("change", !!data.value)) +type Props = BaseFilterProps<boolean> & { + label: string +} - return () => ( - <Flex gap={5} align="center"> - <ElText tag="b" type="info">{props.label}</ElText> - <ElSwitch modelValue={data.value} onChange={val => setter(val as boolean)} /> - </Flex> - ) - } -}) +const _default = defineComponent<Props>(props => { + const [data, setter] = useFilterState('switch', props) + + return () => ( + <Flex gap={5} align="center"> + <ElText tag="b" type="info">{props.label}</ElText> + <ElSwitch modelValue={data.value} onChange={val => setter(val as boolean)} /> + </Flex> + ) +}, { props: [...ALL_BASE_FILTER_PROPS, 'label'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx index c690e142d..83ce00475 100644 --- a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx +++ b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx @@ -9,25 +9,20 @@ import { t } from "@app/locale" import { defineComponent } from "vue" import SelectFilterItem from "./SelectFilterItem" -const TIME_FORMAT_LABELS: { [key in timer.app.TimeFormat]: string } = { +const TIME_FORMAT_LABELS: { [key in tt4b.app.TimeFormat]: string } = { default: t(msg => msg.timeFormat.default), second: t(msg => msg.timeFormat.second), minute: t(msg => msg.timeFormat.minute), hour: t(msg => msg.timeFormat.hour) } -type Props = { - modelValue: timer.app.TimeFormat - onChange: (val: timer.app.TimeFormat) => void -} - -const _default = defineComponent<Props>(props => { +const _default = defineComponent<ModelValue<tt4b.app.TimeFormat>>(props => { return () => ( <SelectFilterItem historyName="timeFormat" defaultValue={props.modelValue} options={TIME_FORMAT_LABELS} - onSelect={val => props.onChange(val as timer.app.TimeFormat)} + onChange={val => val && props.onChange?.(val as tt4b.app.TimeFormat)} /> ) }, { props: ['modelValue', 'onChange'] }) diff --git a/src/pages/app/components/common/filter/common.ts b/src/pages/app/components/common/filter/common.ts index db050fa68..c8bcbf430 100644 --- a/src/pages/app/components/common/filter/common.ts +++ b/src/pages/app/components/common/filter/common.ts @@ -1,5 +1,24 @@ -import { StyleValue } from "vue" +import { useCached, useState } from '@hooks' +import { watch, type StyleValue } from "vue" +import { useRoute } from 'vue-router' export const SELECT_WRAPPER_STYLE: StyleValue = { width: '200px', -} \ No newline at end of file +} + +export type BaseFilterProps<T> = { + defaultValue: T + historyName?: string + onChange?: ArgCallback<T> +} + +export const useFilterState = <T>(item: string, props: BaseFilterProps<T>) => { + const [data, setter] = props.historyName + ? useCached(`__filter_item_${item}_${useRoute().path}_${props.historyName}`, props.defaultValue) + : useState(props.defaultValue) + props.onChange && watch(data, props.onChange, { immediate: true }) + + return [data, setter] as const +} + +export const ALL_BASE_FILTER_PROPS: readonly (keyof BaseFilterProps<unknown>)[] = ['defaultValue', 'historyName', 'onChange'] \ No newline at end of file diff --git a/src/pages/app/components/common/imported/CompareTable.tsx b/src/pages/app/components/common/imported/CompareTable.tsx index d1af02a0a..8c2b90efc 100644 --- a/src/pages/app/components/common/imported/CompareTable.tsx +++ b/src/pages/app/components/common/imported/CompareTable.tsx @@ -5,24 +5,24 @@ * https://opensource.org/licenses/MIT */ -import HostAlert from "@app/components/common/HostAlert" import { type I18nKey, t } from "@app/locale" -import { cvt2LocaleTime, periodFormatter } from "@app/util/time" +import { cvt2LocaleTime, periodFormatter } from '@app/util/time' import { useState } from "@hooks" import Box from "@pages/components/Box" import { type Column, ElAutoResizer, ElTableV2, type SortBy, TableV2SortOrder } from "element-plus" import { computed, defineComponent, toRef } from "vue" +import HostAlert from "../HostAlert" type SortInfo = SortBy & { - key: keyof timer.imported.Row + key: keyof tt4b.imported.Row } -function computeList(sort: SortInfo, originRows: timer.imported.Row[]): timer.imported.Row[] { +function computeList(sort: SortInfo, originRows: tt4b.imported.Row[]): tt4b.imported.Row[] { const { key, order } = sort if (!key) { return originRows } - const comparator = (a: timer.imported.Row, b: timer.imported.Row): number => { + const comparator = (a: tt4b.imported.Row, b: tt4b.imported.Row): number => { const av = a[key] ?? 0, bv = b[key] ?? 0 if (av == bv) return 0 if (order === TableV2SortOrder.DESC) { @@ -39,12 +39,12 @@ const focusCol = (comparedCol: I18nKey): Column[] => [ width: 150, align: 'center', title: t(comparedCol), - cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as timer.imported.Row).focus)}</span>, + cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as tt4b.imported.Row).focus)}</span>, }, { width: 150, align: 'center', title: t(msg => msg.dataManage.importOther.local), - cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as timer.imported.Row).exist?.focus)}</span>, + cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as tt4b.imported.Row).exist?.focus)}</span>, } ] @@ -53,17 +53,17 @@ const timeCol = (comparedCol: I18nKey): Column[] => [ width: 150, align: 'center', title: t(comparedCol), - cellRenderer: ({ rowData }) => <span>{(rowData as timer.imported.Row).time ?? 0}</span>, + cellRenderer: ({ rowData }) => <span>{(rowData as tt4b.imported.Row).time ?? 0}</span>, }, { width: 150, align: 'center', title: t(msg => msg.dataManage.importOther.local), - cellRenderer: ({ rowData }) => <span>{(rowData as timer.imported.Row).exist?.time ?? '-'}</span>, + cellRenderer: ({ rowData }) => <span>{(rowData as tt4b.imported.Row).exist?.time ?? '-'}</span>, } ] type Props = { - data: timer.imported.Data + data: tt4b.imported.Data comparedCol: I18nKey } @@ -78,7 +78,7 @@ const BASE_COLUMNS: Column[] = [ sortable: true, align: 'center', title: t(msg => msg.item.date), - cellRenderer: ({ rowData }) => <span>{cvt2LocaleTime((rowData as timer.imported.Row).date)}</span>, + cellRenderer: ({ rowData }) => <span>{cvt2LocaleTime((rowData as tt4b.imported.Row).date)}</span>, }, { width: 300, dataKey: 'host' satisfies SortInfo['key'], @@ -86,7 +86,7 @@ const BASE_COLUMNS: Column[] = [ align: 'center', title: t(msg => msg.item.host), cellRenderer: ({ rowData }) => { - const { host } = rowData as timer.imported.Row + const { host } = rowData as tt4b.imported.Row return host ? <HostAlert value={{ host, type: 'normal' }} clickable={false} /> : <></> }, } @@ -95,7 +95,7 @@ const BASE_COLUMNS: Column[] = [ const _default = defineComponent<Props>((props) => { const data = toRef(props, 'data') const [sort, setSort] = useState<SortInfo>({ order: TableV2SortOrder.ASC, key: 'date' }) - const list = computed(() => computeList(sort.value, data.value?.rows)) + const list = computed(() => computeList(sort.value, data.value.rows)) const columns = computed(() => { const value = [...BASE_COLUMNS] const { focus, time } = data.value diff --git a/src/pages/app/components/common/imported/ResolutionRadio.tsx b/src/pages/app/components/common/imported/ResolutionRadio.tsx index 7c079072c..61a5d5f05 100644 --- a/src/pages/app/components/common/imported/ResolutionRadio.tsx +++ b/src/pages/app/components/common/imported/ResolutionRadio.tsx @@ -11,9 +11,9 @@ import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElIcon, ElRadio, ElRadioGroup, ElTooltip } from "element-plus" import { defineComponent } from "vue" -export const ALL_RESOLUTIONS: timer.imported.ConflictResolution[] = ['overwrite', 'accumulate'] +const ALL_RESOLUTIONS: tt4b.imported.ConflictResolution[] = ['overwrite', 'accumulate'] -type Value = timer.imported.ConflictResolution | undefined +type Value = tt4b.imported.ConflictResolution | undefined type Props = { modelValue: Value diff --git a/src/pages/app/components/common/kanban/Card.tsx b/src/pages/app/components/common/kanban/Card.tsx index 7b0b70824..77363cfb7 100644 --- a/src/pages/app/components/common/kanban/Card.tsx +++ b/src/pages/app/components/common/kanban/Card.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { I18nKey, t } from '@app/locale' -import { useXsState } from '@hooks/useMediaSize' import { ElCard } from "element-plus" import { defineComponent, h, useSlots, type StyleValue } from "vue" diff --git a/src/pages/app/components/common/kanban/IndicatorCell.tsx b/src/pages/app/components/common/kanban/IndicatorCell.tsx index a1bbd6dbd..0e6feade7 100644 --- a/src/pages/app/components/common/kanban/IndicatorCell.tsx +++ b/src/pages/app/components/common/kanban/IndicatorCell.tsx @@ -5,16 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { computeRingText, type RingValue, type ValueFormatter } from "@app/components/Analysis/util" +import { useXsState } from '@hooks' import { tN, type I18nKey } from "@app/locale" import { BottomRight, InfoFilled, TopRight } from "@element-plus/icons-vue" -import { useXsState } from '@hooks/useMediaSize' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" -import { colorVariant } from '@pages/util/style' +import { colorVariant, getCssVariable } from '@pages/util/style' import { range } from "@util/array" import { ElIcon, ElTooltip } from "element-plus" import { computed, defineComponent, type CSSProperties } from "vue" +import type { RingValue, ValueFormatter } from './types' const SubVal = defineComponent<{ value: string }>(props => { return () => ( @@ -27,7 +27,8 @@ const SubVal = defineComponent<{ value: string }>(props => { const computeComparison = (value: RingValue) => { const [current = 0, last = 0] = value if (current === last) return false - const color = current > last ? colorVariant('danger') : colorVariant('success', 'light', 3) + const colorVar = current > last ? colorVariant('danger') : colorVariant('success', 'light', 3) + const color = getCssVariable(colorVar) const Icon = current > last ? TopRight : BottomRight let count = 0 if (current === 0 || last === 0) { @@ -57,6 +58,26 @@ const ComparisonIcon = defineComponent<{ value: RingValue }>(props => { return () => renderIcons(comp.value) }, { props: ['value'] }) + +/** + * Compute ring text + * + * @param ring ring value + * @param formatter formatter + * @returns text or '-' + */ +function computeRingText(ring: RingValue, formatter?: ValueFormatter): string | undefined { + const [current, last] = ring + if (current === undefined && last === undefined) { + // return undefined if both are undefined + return undefined + } + const delta = (current ?? 0) - (last ?? 0) + let result = formatter ? formatter(delta) : delta?.toString() + delta >= 0 && (result = '+' + result) + return result +} + const RingLine = defineComponent<{ value: RingValue, formatter?: ValueFormatter }>(props => { const text = computed(() => computeRingText(props.value, props.formatter)) return () => text.value ? <> diff --git a/src/pages/app/components/common/kanban/index.ts b/src/pages/app/components/common/kanban/index.ts index 36f80fdc4..fe8d5f4d3 100644 --- a/src/pages/app/components/common/kanban/index.ts +++ b/src/pages/app/components/common/kanban/index.ts @@ -5,8 +5,5 @@ * https://opensource.org/licenses/MIT */ -import Card from "./Card" -import IndicatorCell from "./IndicatorCell" - -export const KanbanIndicatorCell = IndicatorCell -export const KanbanCard = Card \ No newline at end of file +export { default as KanbanCard } from "./Card" +export { default as KanbanIndicatorCell } from "./IndicatorCell" diff --git a/src/pages/app/components/common/kanban/types.d.ts b/src/pages/app/components/common/kanban/types.d.ts new file mode 100644 index 000000000..6b5412c6b --- /dev/null +++ b/src/pages/app/components/common/kanban/types.d.ts @@ -0,0 +1,6 @@ +export type RingValue = [ + current?: number, + last?: number, +] + +export type ValueFormatter = (val: number | undefined) => string \ No newline at end of file diff --git a/src/pages/app/context.ts b/src/pages/app/context.ts index 7782963d4..a724a678a 100644 --- a/src/pages/app/context.ts +++ b/src/pages/app/context.ts @@ -1,28 +1,27 @@ +import { listAllCategories } from "@api/sw/cate" import { MediaSize, useMediaSize, useProvide, useProvider, useRequest } from "@hooks" -import cateService from "@service/cate-service" import { toMap } from '@util/array' import { CATE_NOT_SET_ID } from '@util/site' -import { computed, reactive, watch, type Ref } from "vue" +import { computed, reactive, watch } from "vue" import { t } from './locale' type MenuLayout = 'nav' | 'sidebar' interface CategoryInstance { enabled: boolean - all: timer.site.Cate[] + all: tt4b.site.Cate[] nameMap: Record<number, string> refresh(): void } type AppContextValue = { category: Readonly<CategoryInstance> - layout: Readonly<Ref<MenuLayout>> } const NAMESPACE = '_' export const initAppContext = () => { - const { refresh: refreshCategories } = useRequest(() => cateService.listAll(), { + const { refresh: refreshCategories } = useRequest(listAllCategories, { onSuccess: categories => { category.all = categories const map = toMap(categories, c => c.id, c => c.name) @@ -39,11 +38,9 @@ export const initAppContext = () => { nameMap: {}, refresh: refreshCategories, }) - useProvide<AppContextValue>(NAMESPACE, { category, layout }) + useProvide<AppContextValue>(NAMESPACE, { category }) return { layout } } -export const useCategory = () => useProvider<AppContextValue, "category">(NAMESPACE, "category").category - -export const useLayout = () => useProvider<AppContextValue, "layout">(NAMESPACE, "layout").layout \ No newline at end of file +export const useCategory = () => useProvider<AppContextValue, "category">(NAMESPACE, "category").category \ No newline at end of file diff --git a/src/pages/app/echarts.ts b/src/pages/app/echarts.ts deleted file mode 100644 index adaea40a2..000000000 --- a/src/pages/app/echarts.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BarChart, CustomChart, EffectScatterChart, HeatmapChart, LineChart, PieChart, ScatterChart } from "echarts/charts" -import { - AriaComponent, - DataZoomComponent, - GridComponent, - LegendComponent, - TitleComponent, - TooltipComponent, - VisualMapComponent, -} from "echarts/components" -import { use } from "echarts/core" -import { CanvasRenderer } from "echarts/renderers" - -export const initEcharts = () => { - use([ - CanvasRenderer, - AriaComponent, GridComponent, TooltipComponent, TitleComponent, VisualMapComponent, LegendComponent, DataZoomComponent, - BarChart, PieChart, LineChart, HeatmapChart, ScatterChart, EffectScatterChart, CustomChart, - ]) -} \ No newline at end of file diff --git a/src/pages/app/index.ts b/src/pages/app/index.ts index fa1eac4ab..b53d83526 100644 --- a/src/pages/app/index.ts +++ b/src/pages/app/index.ts @@ -7,34 +7,26 @@ import { listenMediaSizeChange } from "@hooks" import { initLocale } from "@i18n" -import { initElementLocale } from "@i18n/element" -import optionService from "@service/option-service" -import { init as initTheme, toggle } from "@util/dark-mode" -import { createApp, type App } from "vue" -import '../../common/timer' -import { initEcharts } from "./echarts" +import { createElApp } from "@pages/element-ui/app" +import { initDarkTheme } from "@pages/util/dark-mode" +import { initEcharts } from "../util/echarts" import Main from "./Layout" import installRouter from "./router" import { injectAppCss } from './styles/index' async function main() { injectAppCss() - // Init theme with cache first - initTheme() + initDarkTheme() listenMediaSizeChange() - // Calculate the latest mode - optionService.isDarkMode().then(toggle) - await initLocale() + initLocale() initEcharts() - const app: App = createApp(Main) + const app = await createElApp(Main) installRouter(app) const el = document.createElement('div') document.body.append(el) el.id = 'app' app.mount(el) - - await initElementLocale(app) } main() \ No newline at end of file diff --git a/src/pages/app/locale.ts b/src/pages/app/locale.ts index 64a5d7f16..e5dbedb70 100644 --- a/src/pages/app/locale.ts +++ b/src/pages/app/locale.ts @@ -19,7 +19,7 @@ export function t(key: I18nKey, param?: any) { /** * @since 0.8.8 */ -export function tWith(key: I18nKey, specLocale: timer.Locale, param?: any) { +export function tWith(key: I18nKey, specLocale: tt4b.Locale, param?: any) { const props = { key, param } return _t<AppMessage>(messages, props, specLocale) } diff --git a/src/pages/app/router/constants.ts b/src/pages/app/router/constants.ts index ff96e795c..4c3116c23 100644 --- a/src/pages/app/router/constants.ts +++ b/src/pages/app/router/constants.ts @@ -7,23 +7,15 @@ export const DASHBOARD_ROUTE = '/data/dashboard' -export const ANALYSIS_ROUTE = '/data/analysis' - -export const OPTION_ROUTE = '/additional/option' - /** - * Use on the app page and background script - * * @since 0.2.2 */ -export const LIMIT_ROUTE = '/behavior/limit' - -/** - * @since 0.9.1 - */ -export const REPORT_ROUTE = '/data/report' +export { + APP_ANALYSIS_ROUTE as ANALYSIS_ROUTE, APP_LIMIT_ROUTE as LIMIT_ROUTE, APP_OPTION_ROUTE as OPTION_ROUTE, + APP_REPORT_ROUTE as REPORT_ROUTE, type AppLimitQuery as LimitQuery, type AppReportQuery as ReportQuery +} from "@/shared/route" /** * @since 1.8.0 */ -export const MERGE_ROUTE = '/additional/rule-merge' \ No newline at end of file +export const MERGE_ROUTE = '/additional/rule-merge' diff --git a/src/pages/app/router/index.ts b/src/pages/app/router/index.ts index b9d89527c..6366a72f1 100644 --- a/src/pages/app/router/index.ts +++ b/src/pages/app/router/index.ts @@ -5,7 +5,6 @@ * https://opensource.org/licenses/MIT */ -import { increaseApp } from "@service/meta-service" import { type App } from "vue" import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router" import { ANALYSIS_ROUTE, DASHBOARD_ROUTE, LIMIT_ROUTE, MERGE_ROUTE, OPTION_ROUTE, REPORT_ROUTE } from "./constants" @@ -91,17 +90,4 @@ const router = createRouter({ routes, }) -async function handleChange() { - await router.isReady() - const current = router.currentRoute.value.fullPath - current && increaseApp(current) - router.afterEach(async (to, from, failure: Error | void) => { - if (failure || to.fullPath === from.fullPath) return - await increaseApp(to.fullPath) - }) -} - -export default (app: App) => { - app.use(router) - handleChange() -} +export default (app: App) => app.use(router) diff --git a/src/pages/app/styles/index.ts b/src/pages/app/styles/index.ts index b658a4395..0c2075ad2 100644 --- a/src/pages/app/styles/index.ts +++ b/src/pages/app/styles/index.ts @@ -1,7 +1,5 @@ import { injectGlobal } from '@emotion/css' import '@pages/element-ui/dark-theme.css' -import "element-plus/theme-chalk/display.css" -import 'element-plus/theme-chalk/index.css' import { injectEchartsCss } from './echarts' import { injectElementCss } from './element' diff --git a/src/pages/app/util/echarts.ts b/src/pages/app/util/echarts.ts index 090c823d4..d1b08dddf 100644 --- a/src/pages/app/util/echarts.ts +++ b/src/pages/app/util/echarts.ts @@ -2,33 +2,34 @@ import { getCssVariable } from "@pages/util/style" import { range } from "@util/array" import { addVector, multiTuple, subVector } from "@util/tuple" import { type LinearGradientObject } from "echarts" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" -const splitVectors = (vectorRange: Tuple<Vector<number>, 2>, count: number, gradientFactor?: number): Vector<number>[] => { - gradientFactor = gradientFactor ?? 1.3 - const segmentCount = count - 1 +const splitColorVectors = (vectorRange: Tuple<Vector<3>, 2>, count: number, gradientFactor?: number): Vector<3>[] => { + gradientFactor ??= 1.3 const [v1, v2] = vectorRange + if (count === 1) return [v1] + const delta = subVector(v2, v1) - const allVectors = range(segmentCount).map(idx => { - const growth = Math.pow(idx / count, gradientFactor) - return addVector(v1, multiTuple(delta, growth)) + const last = count - 1 + + return range(count).map(i => { + const t = Math.pow(i / last, gradientFactor) + return addVector(v1, multiTuple(delta, t)) }) - allVectors.push(v2) - return allVectors } export const getStepColors = (count: number, gradientFactor?: number): string[] => { const p1 = getCssVariable('--echarts-step-color-1') ?? '' const p2 = getCssVariable('--echarts-step-color-2') ?? '' - if (!p1 || !p2) return [p1, p2].filter(s => !!s) + if (!p1 || !p2) return [p1, p2].filter(Boolean) if (count <= 0) return [] - if (count === 1) return [p1] - if (count === 2) return [p1, p2] const c1 = cvtColor2Vector(p1) const c2 = cvtColor2Vector(p2) - const allVectors = splitVectors([c1, c2], count, gradientFactor) - return allVectors.map(([r, g, b]) => `rgb(${r.toFixed(1)}, ${g.toFixed(1)}, ${b.toFixed(1)})`) + + return splitColorVectors([c1, c2], count, gradientFactor) + .map(v => `rgb(${v[0].toFixed(1)}, ${v[1].toFixed(1)}, ${v[2].toFixed(1)})`) } /** @@ -103,4 +104,14 @@ export const tooltipSpaceLine = (height?: number): string => { export const getPieBorderColor = (): string | undefined => { return getCssVariable('--echarts-pie-border-color') +} + +export function parseValueOfFormatter(params: TopLevelFormatterParams) { + const param = Array.isArray(params) ? params[0] : params + if (!param) return undefined + const { data } = param + if (typeof data === 'object' && data !== null && 'value' in data) { + return data.value + } + return undefined } \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/confession.ts b/src/pages/app/util/limit/generator/confession.ts similarity index 83% rename from src/service/limit-service/verification/generator/confession.ts rename to src/pages/app/util/limit/generator/confession.ts index bc39c5fd9..1715dbb99 100644 --- a/src/service/limit-service/verification/generator/confession.ts +++ b/src/pages/app/util/limit/generator/confession.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { type VerificationContext, type VerificationGenerator, type VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" /** * Generator of confession diff --git a/src/service/limit-service/verification/generator/index.ts b/src/pages/app/util/limit/generator/index.ts similarity index 89% rename from src/service/limit-service/verification/generator/index.ts rename to src/pages/app/util/limit/generator/index.ts index a57bb658d..dc3425ce0 100644 --- a/src/service/limit-service/verification/generator/index.ts +++ b/src/pages/app/util/limit/generator/index.ts @@ -4,7 +4,7 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { type VerificationGenerator } from "../common" +import type { VerificationGenerator } from "../types" import ConfessionGenerator from "./confession" import PiGenerator from "./pi" import UglyGenerator from "./ugly" diff --git a/src/service/limit-service/verification/generator/pi.ts b/src/pages/app/util/limit/generator/pi.ts similarity index 91% rename from src/service/limit-service/verification/generator/pi.ts rename to src/pages/app/util/limit/generator/pi.ts index 5c673d7f4..b7b5a67ae 100644 --- a/src/service/limit-service/verification/generator/pi.ts +++ b/src/pages/app/util/limit/generator/pi.ts @@ -6,7 +6,7 @@ */ import { randomIntBetween } from "@util/number" -import { type VerificationContext, type VerificationGenerator, type VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" const MIN_START_IDX = 10 const MAX_START_IDX = 25 diff --git a/src/service/limit-service/verification/generator/ugly.ts b/src/pages/app/util/limit/generator/ugly.ts similarity index 90% rename from src/service/limit-service/verification/generator/ugly.ts rename to src/pages/app/util/limit/generator/ugly.ts index 15cb8458d..d4e94ef41 100644 --- a/src/service/limit-service/verification/generator/ugly.ts +++ b/src/pages/app/util/limit/generator/ugly.ts @@ -7,9 +7,9 @@ import { range } from "@util/array" import { randomIntBetween } from "@util/number" -import type { VerificationContext, VerificationGenerator, VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" -const BASE = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\`-=[]/.,:\"<>?!@#$%^&*()_+;'".replaceAll(/[01IlLOo]/g, "") +const BASE = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\`-=[]/.,:\"<>?!@#$%^&*()_+;'/i18n".replaceAll(/[01IlLOo]/g, "") const BASE_LEN = BASE.length class UglyGenerator implements VerificationGenerator { diff --git a/src/service/limit-service/verification/generator/uncommon-chinese.ts b/src/pages/app/util/limit/generator/uncommon-chinese.ts similarity index 93% rename from src/service/limit-service/verification/generator/uncommon-chinese.ts rename to src/pages/app/util/limit/generator/uncommon-chinese.ts index dcf329ec7..066353ee7 100644 --- a/src/service/limit-service/verification/generator/uncommon-chinese.ts +++ b/src/pages/app/util/limit/generator/uncommon-chinese.ts @@ -6,7 +6,7 @@ */ import { randomIntBetween } from "@util/number" -import type { VerificationContext, VerificationGenerator, VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" const UNCOMMON_WORDS = '龘靐齉齾爩鱻麤龗灪吁龖厵滟爨癵籱饢驫鲡鹂鸾麣纞虋讟钃骊郁鸜麷鞻韽韾响顟顠饙饙騳騱饐' const LENGTH = UNCOMMON_WORDS.length @@ -21,7 +21,7 @@ class UncommonChinese implements VerificationGenerator { while (answer.length < 3) { const idx = randomIntBetween(0, LENGTH) const ch = UNCOMMON_WORDS[idx] - if (!answer.includes(ch)) { + if (ch && !answer.includes(ch)) { answer += ch } } diff --git a/src/pages/app/util/limit.tsx b/src/pages/app/util/limit/index.tsx similarity index 51% rename from src/pages/app/util/limit.tsx rename to src/pages/app/util/limit/index.tsx index 8e0b2378e..42e51a511 100644 --- a/src/pages/app/util/limit.tsx +++ b/src/pages/app/util/limit/index.tsx @@ -1,25 +1,30 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" -import { t, tN } from "@app/locale" -import { useCountDown } from '@hooks/useCount' -import { I18nResultItem, locale } from "@i18n" +import { sendMsg2Runtime, trySendMsg2Runtime } from '@api/sw/common' +import { css } from '@emotion/css' +import { useCountDown } from "@hooks" +import { type I18nKey, type I18nResultItem, locale, t as t_, tN as tN_ } from "@i18n" +import limitMessages, { type LimitMessage } from "@i18n/message/app/limit" +import buttonMessages from "@i18n/message/common/button" import { getCssVariable } from "@pages/util/style" -import verificationProcessor from "@service/limit-service/verification/processor" -import { dateMinute2Idx, hasLimited, isEnabledAndEffective } from "@util/limit" -import { ElMessage, ElMessageBox, type ElMessageBoxOptions, type InputType, useId } from "element-plus" +import { dateMinute2Idx, hasLimited, isEffective } from "@util/limit" +import { ElMessage, ElMessageBox, type InputType, useId, useNamespace } from "element-plus" import { defineComponent, onMounted, ref, type VNode } from "vue" +import verificationProcessor from './processor' + +const t = (key: I18nKey<LimitMessage>, param?: any) => t_(limitMessages, { key, param }) + +const tN = (key: I18nKey<LimitMessage>, param?: any) => tN_<LimitMessage, VNode>(limitMessages, { key, param }) /** * Judge wether verification is required * * @returns T/F */ -export async function judgeVerificationRequired(item: timer.limit.Item): Promise<boolean> { - if (item.locked) return true - if (!isEnabledAndEffective(item)) return false +export async function judgeVerificationRequired(item: tt4b.limit.Item, delayDuration: number): Promise<boolean> { + if (!item.enabled || !isEffective(item.weekdays)) return false const { visitTime, periods } = item // Daily or weekly - if (hasLimited(item)) return true + if (hasLimited(item, delayDuration)) return true // Period if (periods?.length) { const idx = dateMinute2Idx(new Date()) @@ -28,13 +33,8 @@ export async function judgeVerificationRequired(item: timer.limit.Item): Promise } // Visit if (visitTime) { - let hitVisit = false - try { - hitVisit = !!await sendMsg2Runtime("askHitVisit", item) - } catch (e) { - // If error occurs, regarded as not hitting - // ignored - } + // If error occurs, regarded as not hitting + const hitVisit = await trySendMsg2Runtime('limit.hitVisit', item) ?? false if (hitVisit) return true } return false @@ -81,6 +81,21 @@ const AnswerCanvas = defineComponent(((props: { text: string }) => { ) }), { props: ['text'] }) +// Fix some style missing in limit modal with postcss processor +const INPUT_NS = useNamespace('input') +const MODAL_CLS = css` + & .${INPUT_NS.e('inner')} { + background: 0; + } +` +const MSG_CLS = css` + left: 50%; + transform: translate(-50%); +` +const okBtnTxt = t_(buttonMessages, { key: msg => msg.okay }) + +const errMsg = (message: string) => ElMessage.error({ message, customClass: MSG_CLS }) + /** * NOT TO return Promise.resolve() * @@ -89,17 +104,17 @@ const AnswerCanvas = defineComponent(((props: { text: string }) => { * @returns null if verification not required, * or promise with resolve invoked only if verification code or password correct */ -export function processVerification(option: timer.option.LimitOption, context?: { appendTo: Exclude<ElMessageBoxOptions['appendTo'], string> }): Promise<void> { +export function processVerification(option: tt4b.option.LimitOption): Promise<void> { const { limitLevel, limitPassword, limitVerifyDifficulty } = option - const { appendTo } = context || {} - if (limitLevel === "strict") { + if (limitLevel === 'strict') { return new Promise(() => ElMessageBox({ - appendTo, boxType: 'alert', type: 'warning', title: '', - message: <div>{t(msg => msg.limit.verification.strictTip)}</div>, + message: <div>{t(msg => msg.verification.strictTip)}</div>, }).catch(() => { })) + } else if (limitLevel === '2fa') { + return process2faVerification() } let inputType: InputType | undefined let answerValue: string | undefined @@ -108,22 +123,22 @@ export function processVerification(option: timer.option.LimitOption, context?: let countdown: number | undefined if (limitLevel === 'password' && limitPassword) { answerValue = limitPassword - messageNode = t(msg => msg.limit.verification.pswInputTip) - incorrectMessage = t(msg => msg.limit.verification.incorrectPsw) + messageNode = t(msg => msg.verification.pswInputTip) + incorrectMessage = t(msg => msg.verification.incorrectPsw) inputType = 'password' } else if (limitLevel === 'verification') { const pair = verificationProcessor.generate(limitVerifyDifficulty ?? 'easy', locale) - const { prompt, promptParam, answer, second = 60 } = pair || {} + const { prompt, promptParam, answer, second = 60 } = pair ?? {} countdown = second - answerValue = typeof answer === 'function' ? t(msg => answer(msg.limit.verification)) : answer - incorrectMessage = t(msg => msg.limit.verification.incorrectAnswer) + answerValue = typeof answer === 'function' ? t(msg => (answer as (msg: any) => string)(msg.verification)) : answer + incorrectMessage = t(msg => msg.verification.incorrectAnswer) if (prompt) { const promptTxt = typeof prompt === 'function' - ? t(msg => prompt(msg.limit.verification), { ...promptParam, answer: answerValue }) + ? t(msg => prompt(msg.verification), { ...promptParam, answer: answerValue }) : prompt - messageNode = tN(msg => msg.limit.verification.inputTip, { prompt: <b>{promptTxt}</b>, second }) + messageNode = tN(msg => msg.verification.inputTip, { prompt: <b>{promptTxt}</b>, second }) } else if (answerValue) { - messageNode = tN(msg => msg.limit.verification.inputTip2, { answer: <AnswerCanvas text={answerValue} />, second }) + messageNode = tN(msg => msg.verification.inputTip2, { answer: <AnswerCanvas text={answerValue} />, second }) } } if (!messageNode || !answerValue) return Promise.resolve() @@ -131,13 +146,10 @@ export function processVerification(option: timer.option.LimitOption, context?: const okBtnClz = `limit-confirm-btn-${useId().value}` const btnText = (leftSec: number) => `${okBtnTxt} (${leftSec})` - const okBtnTxt = t(msg => msg.button.okey) const msgData = ElMessageBox({ - appendTo, autofocus: true, boxType: 'prompt', type: 'warning', - title: '', message: <div style={{ userSelect: 'none' }}>{messageNode}</div>, showInput: true, inputType, @@ -146,18 +158,19 @@ export function processVerification(option: timer.option.LimitOption, context?: confirmButtonText: countdown ? btnText(countdown) : okBtnTxt, confirmButtonClass: okBtnClz, buttonSize: "small", + modalClass: MODAL_CLS, }) - let cleanCountdown = countdown ? useCountDown({ + const cleanCountdown = countdown ? useCountDown({ countdown, onComplete: () => { - const btn = (appendTo ?? document).querySelector(`.${okBtnClz}`) + const btn = document.querySelector(`.${okBtnClz}`) if (!btn) return - ElMessage.warning(t(msg => msg.limit.message.timeout)) + ElMessage.warning(t(msg => msg.message.timeout)) btn.remove() }, onTick: (val: number) => { - const btnSpan = (appendTo ?? document).querySelector(`.${okBtnClz} span`) + const btnSpan = document.querySelector(`.${okBtnClz} span`) if (!btnSpan) return btnSpan.textContent = btnText(Math.floor(val / 1000)) }, @@ -166,11 +179,39 @@ export function processVerification(option: timer.option.LimitOption, context?: return new Promise(resolve => { msgData.then(data => { // Double check - const btn = (appendTo ?? document).querySelector(`.${okBtnClz}`) + const btn = document.querySelector(`.${okBtnClz}`) if (!btn) return + if (typeof data === 'string') return const { value } = data if (value === answerValue) return resolve() - ElMessage.error({ appendTo, message: incorrectMessage }) + errMsg(incorrectMessage) }).catch(() => cleanCountdown?.()) }) } + +function process2faVerification(): Promise<void> { + return new Promise(resolve => ElMessageBox({ + autofocus: true, + boxType: 'prompt', + type: 'warning', + message: t(msg => msg.verification.twoFaInputTip), + showInput: true, + showCancelButton: true, + showClose: false, + confirmButtonText: okBtnTxt, + buttonSize: 'small', + modalClass: MODAL_CLS, + beforeClose: (act, instance, done) => { + if (act !== 'confirm') return done() + const code = instance.inputValue.replace(/\s/g, '') + if (!/^\d{6}$/.test(code)) return errMsg('Invalid code format') + sendMsg2Runtime('meta.check2fa', code) + .then(ok => { if (!ok) throw new Error('Incorrect code') }) + .then(() => { + resolve() + done() + }) + .catch(e => errMsg(e instanceof Error ? e.message : e ?? 'Unknown error')) + }, + })) +} diff --git a/src/pages/app/util/limit/processor.ts b/src/pages/app/util/limit/processor.ts new file mode 100644 index 000000000..dcfe49ad7 --- /dev/null +++ b/src/pages/app/util/limit/processor.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { randomIntBetween } from "@util/number" +import { ALL_GENERATORS } from "./generator" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "./types" + +class VerificationProcessor { + generators: VerificationGenerator[] + + constructor() { + this.generators = ALL_GENERATORS + } + + generate(difficulty: tt4b.limit.VerificationDifficulty, locale: tt4b.Locale): VerificationPair | undefined { + const context: VerificationContext = { difficulty, locale } + const supported = this.generators.filter(g => g.supports(context)) + + if (!supported.length) return undefined + + const generator = supported[randomIntBetween(0, supported.length)] + return generator?.generate(context) + } +} + +const verificationProcessor = new VerificationProcessor() + +export default verificationProcessor \ No newline at end of file diff --git a/src/service/limit-service/verification/common.ts b/src/pages/app/util/limit/types.ts similarity index 91% rename from src/service/limit-service/verification/common.ts rename to src/pages/app/util/limit/types.ts index 22cf89669..e60c007fd 100644 --- a/src/service/limit-service/verification/common.ts +++ b/src/pages/app/util/limit/types.ts @@ -4,15 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ - import { type I18nKey } from "@i18n" import { type LimitMessage } from "@i18n/message/app/limit" type LimitVerificationMessage = LimitMessage['verification'] export type VerificationContext = { - difficulty: timer.limit.VerificationDifficulty - locale: timer.Locale + difficulty: tt4b.limit.VerificationDifficulty + locale: tt4b.Locale } export type VerificationPair = { diff --git a/src/pages/app/util/time.ts b/src/pages/app/util/time.ts index 2ae73b399..e50e55b78 100644 --- a/src/pages/app/util/time.ts +++ b/src/pages/app/util/time.ts @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" import { formatPeriodCommon, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" +import { t } from "../locale" /** * Convert {yyyy}{mm}{dd} to locale time @@ -25,11 +25,11 @@ export function cvt2LocaleTime(date: string | undefined): string { } type PeriodFormatOption = { - format?: timer.app.TimeFormat + format?: tt4b.app.TimeFormat hideUnit?: boolean } -const UNIT_MAP: { [unit in Exclude<timer.app.TimeFormat, 'default'>]: string } = { +const UNIT_MAP: { [unit in Exclude<tt4b.app.TimeFormat, 'default'>]: string } = { second: 's', minute: 'm', hour: 'h', @@ -59,4 +59,4 @@ export function periodFormatter(milliseconds: number | undefined | null, option? if (hideUnit) return val let unit = UNIT_MAP[format] return val + unit -} +} \ No newline at end of file diff --git a/src/pages/components/Flex.tsx b/src/pages/components/Flex.tsx index c54d81362..711c256fe 100644 --- a/src/pages/components/Flex.tsx +++ b/src/pages/components/Flex.tsx @@ -16,37 +16,41 @@ type Props = { justify?: CSSProperties['justifyContent'] gap?: string | number columnGap?: string | number + rowGap?: string | number wrap?: CSSProperties['flexWrap'] | boolean href?: string target?: HTMLAnchorElement['target'] } & BaseProps const Flex = defineComponent<Props>(props => { - const { default: defaultSlots } = useSlots() const Comp = props.as ?? 'div' - return () => ( - <Comp - id={props.id} - class={props.class} - onClick={props.onClick} - style={{ - display: props.inline ? 'inline-flex' : 'flex', - flex: props.flex, - flexDirection: props?.column ? 'column' : props.direction, - alignItems: props.align, - justifyContent: props.justify, - flexWrap: cvtFlexWrap(props.wrap), - columnGap: cvtPxScale(props.columnGap), - gap: cvtPxScale(props.gap), - ...cvt2BaseStyle(props), - }} - href={props.href} - target={props.target} - > - {defaultSlots && h(defaultSlots)} - </Comp> - ) -}, { props: [...ALL_BASE_PROPS, 'direction', 'column', 'flex', 'align', 'justify', 'gap', 'columnGap', 'wrap', 'as'] }) + return () => { + const { default: defaultSlots } = useSlots() + return ( + <Comp + id={props.id} + class={props.class} + onClick={props.onClick} + style={{ + display: props.inline ? 'inline-flex' : 'flex', + flex: props.flex, + flexDirection: props?.column ? 'column' : props.direction, + alignItems: props.align, + justifyContent: props.justify, + flexWrap: cvtFlexWrap(props.wrap), + columnGap: cvtPxScale(props.columnGap), + rowGap: cvtPxScale(props.rowGap), + gap: cvtPxScale(props.gap), + ...cvt2BaseStyle(props), + }} + href={props.href} + target={props.target} + > + {defaultSlots && h(defaultSlots)} + </Comp> + ) + } +}, { props: [...ALL_BASE_PROPS, 'direction', 'column', 'flex', 'align', 'justify', 'gap', 'columnGap', 'rowGap', 'wrap', 'as'] }) export default Flex \ No newline at end of file diff --git a/src/pages/components/IconRadioGroup.tsx b/src/pages/components/IconRadioGroup.tsx new file mode 100644 index 000000000..488998bb7 --- /dev/null +++ b/src/pages/components/IconRadioGroup.tsx @@ -0,0 +1,42 @@ +import { css } from '@emotion/css' +import { ElIcon, ElRadioButton, ElRadioGroup, type RadioGroupProps, useNamespace } from 'element-plus' +import { type Component, type FunctionalComponent, h } from 'vue' + +const useRadioStyle = () => { + const radioNs = useNamespace('radio') + return css` + & .${radioNs.be('button', 'inner')} { + padding: 3px 5px; + } + ` +} + +const RADIO_CLS = useRadioStyle() + +type Option = { + value: string + icon: Component +} + +type Props = ModelValue<string> & Pick<RadioGroupProps, 'size'> & { + iconSize?: number + options: Option[] +} + +const IconRadioGroup: FunctionalComponent<Props> = ({ size, modelValue, onChange, options, iconSize = 15 }) => ( + <ElRadioGroup + size={size} + modelValue={modelValue} + onChange={val => onChange?.(val as string)} + > + {options.map(({ value, icon }) => ( + <ElRadioButton value={value} class={RADIO_CLS} > + <ElIcon size={iconSize}>{h(icon)}</ElIcon> + </ElRadioButton> + ))} + </ElRadioGroup> +) + +IconRadioGroup.displayName = 'IconRadioGroup' + +export default IconRadioGroup \ No newline at end of file diff --git a/src/pages/components/Img.tsx b/src/pages/components/Img.tsx new file mode 100644 index 000000000..811509d5d --- /dev/null +++ b/src/pages/components/Img.tsx @@ -0,0 +1,31 @@ +import { useState } from '@hooks' +import { type CSSProperties, defineComponent, toRef } from 'vue' + +type Props = Partial<Pick<HTMLImageElement, 'src' | 'alt' | 'title'>> & { + style?: CSSProperties + onError?: ArgCallback<Event> + size?: number +} + +const Img = defineComponent<Props>(props => { + const src = toRef(props, 'src') + const [imgErr, setImgErr] = useState(false) + const handleError = (event: Event) => { + setImgErr(true) + props?.onError?.(event) + } + + return () => !src.value || imgErr.value ? null : ( + <img + src={src.value} + alt={props.alt} + title={props.title} + onError={handleError} + width={props.size} + height={props.size} + style={props.style} + /> + ) +}, { props: ['src', 'alt', 'size', 'style', 'title', 'onError'] }) + +export default Img \ No newline at end of file diff --git a/src/pages/app/components/common/TooltipWrapper.tsx b/src/pages/components/TooltipWrapper.tsx similarity index 79% rename from src/pages/app/components/common/TooltipWrapper.tsx rename to src/pages/components/TooltipWrapper.tsx index 3d502c141..e4f15b184 100644 --- a/src/pages/app/components/common/TooltipWrapper.tsx +++ b/src/pages/components/TooltipWrapper.tsx @@ -1,7 +1,7 @@ -import { ElTooltip, ElTooltipProps } from "element-plus" +import { ElTooltip, type UseTooltipProps } from "element-plus" import { defineComponent, ref, useSlots } from "vue" -type Props = PartialPick<ElTooltipProps, 'placement' | 'effect' | 'trigger' | 'offset'> & { +type Props = PartialPick<UseTooltipProps, 'placement' | 'effect' | 'trigger' | 'offset'> & { usePopover?: boolean } diff --git a/src/pages/element-ui/app.ts b/src/pages/element-ui/app.ts new file mode 100644 index 000000000..f33fb7fa0 --- /dev/null +++ b/src/pages/element-ui/app.ts @@ -0,0 +1,12 @@ +import { initElementLocale } from "@i18n/element" +import { ElConfigProvider, ElLoadingDirective } from "element-plus" +import { createApp, h, type App, type Component } from "vue" + +export async function createElApp(root: Component): Promise<App> { + const locale = await initElementLocale() + const app = createApp({ + render: () => h(ElConfigProvider, { locale }, () => h(root)), + }) + app.directive("loading", ElLoadingDirective) + return app +} diff --git a/src/pages/element-ui/types.d.ts b/src/pages/element-ui/types.d.ts new file mode 100644 index 000000000..2c7287855 --- /dev/null +++ b/src/pages/element-ui/types.d.ts @@ -0,0 +1,4 @@ +export type ElDatePickerShortcut = { + text: string + value: [Date, Date] +} \ No newline at end of file diff --git a/src/pages/hooks/index.ts b/src/pages/hooks/index.ts index d1a257934..058e62ccc 100644 --- a/src/pages/hooks/index.ts +++ b/src/pages/hooks/index.ts @@ -1,6 +1,9 @@ export * from "./useCached" +export * from "./useCount" export * from "./useDebounce" export * from "./useDocumentVisibility" +export * from "./useEcharts" +export * from "./useElementSize" export * from "./useLocalStorage" export * from "./useManualRequest" export * from "./useMediaSize" @@ -11,6 +14,6 @@ export * from "./useShadow" export * from "./useSiteMerge" export * from "./useState" export * from "./useSwitch" -export * from "./useWindowFocus" +export * from "./useTabGroups" +export * from "./useWindowListener" export * from "./useWindowSize" - diff --git a/src/pages/hooks/useCached.ts b/src/pages/hooks/useCached.ts index 7efbe831d..6c40d2fc7 100644 --- a/src/pages/hooks/useCached.ts +++ b/src/pages/hooks/useCached.ts @@ -5,8 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { onBeforeMount, ref, type Ref, watch } from "vue" -import { useState } from "." +import { ref, type Ref, watch } from "vue" const getInitialValue = <T>(key: string, defaultValue?: T): T | undefined => { if (!key) return defaultValue @@ -28,29 +27,13 @@ const saveCache = <T>(key: string, val: T) => { } } -export function useCached<T>(key: string, defaultValue: T, defaultFirst?: boolean): { data: Ref<T>, setter: (val: T) => void } -export function useCached<T>( - key: string | undefined, - defaultValue?: T, - defaultFirst?: boolean, -): { data: Ref<T | undefined>, setter: (val: T | undefined) => void } - -export function useCached<T>( - key: string | undefined, - defaultValue?: T, - defaultFirst?: boolean, -) { - if (!key) { - const [data, setter] = useState(defaultValue) - return { data, setter } - } - const data: Ref<T | undefined> = ref<T>() +export function useCached<T>(key: string, defaultValue: T): [data: Ref<T>, setter: ArgCallback<T>] +export function useCached<T>(key: string, defaultValue?: T): [data: Ref<T | undefined>, setter: ArgCallback<T | undefined>] +export function useCached<T>(key: string, defaultValue?: T) { + let cachedValue = getInitialValue(key, defaultValue) + let initial = cachedValue ?? defaultValue + const data = initial === undefined ? ref<T>() : ref<T>(initial) const setter = (val: T | undefined) => data.value = val - onBeforeMount(() => { - let cachedValue = getInitialValue(key, defaultValue) - let initial = defaultFirst ? defaultValue || cachedValue : cachedValue - setter(initial) - }) - watch(data, () => saveCache(key, data.value)) - return { data, setter } + watch(data, () => saveCache(key, data.value), { immediate: true }) + return [data, setter] } diff --git a/src/pages/hooks/useEcharts.ts b/src/pages/hooks/useEcharts.ts index 3ebc620d7..aa47a1956 100644 --- a/src/pages/hooks/useEcharts.ts +++ b/src/pages/hooks/useEcharts.ts @@ -5,9 +5,9 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import optionHolder from "@service/components/option-holder" +import { getOption } from "@api/sw/option" import { processAnimation, processAria, processFont, processRtl } from "@util/echarts" -import { type AriaComponentOption, type ComposeOption, SeriesOption, TitleComponentOption } from "echarts" +import type { AriaComponentOption, ComposeOption, SeriesOption, TitleComponentOption } from "echarts" import { type ECharts, init } from "echarts/core" import { ElLoading } from "element-plus" import { type Ref, type WatchSource, isRef, onMounted, ref, watch } from "vue" @@ -30,7 +30,7 @@ export abstract class EchartsWrapper<BizOption, EchartsOption> { * true if need to clear all the before series when setOption */ protected replaceSeries: boolean = false - private lastBizOption: BizOption | undefined + protected lastBizOption: BizOption | undefined /** * Fix the font family * @see https://github.com/sheepzh/time-tracker-4-browser/issues/623 @@ -60,7 +60,7 @@ export abstract class EchartsWrapper<BizOption, EchartsOption> { } protected async postChartOption(option: EchartsOption & BaseEchartsOption) { - const { chartDecal, chartAnimationDuration } = await optionHolder.get() || {} + const { chartDecal, chartAnimationDuration } = await getOption() processAnimation(option, chartAnimationDuration) processAria(option, chartDecal) processRtl(option) @@ -123,6 +123,9 @@ export const useEcharts = <BizOption, EchartsOption, EW extends EchartsWrapper<B afterInit?.(wrapperInstance) !manual && refresh() isRef(fetch) && watch(fetch, refresh) + + // The element reference perhaps change + watch(elRef, () => elRef.value && wrapperInstance.init(elRef.value)) }) deps && watch(deps, refresh) diff --git a/src/pages/hooks/useLocalStorage.ts b/src/pages/hooks/useLocalStorage.ts index 2617886a8..49eb8944a 100644 --- a/src/pages/hooks/useLocalStorage.ts +++ b/src/pages/hooks/useLocalStorage.ts @@ -7,34 +7,27 @@ type StorageValue = | StorageArray | StorageObject -export function useLocalStorage<T>(key: string, defaultValue: T): [T, (val: T | undefined) => void] +export function useLocalStorage<T>(key: string, defaultValue: T): [T, ArgCallback<T>] export function useLocalStorage<T>(key: string): [T | undefined, (val: T | undefined) => void] - -export function useLocalStorage<T = StorageValue>(key: string, defaultVal?: T): [data: T | undefined, setter: (val: T | undefined) => void] { - const value = deserialize(localStorage.getItem(key), defaultVal) ?? defaultVal +export function useLocalStorage<T = StorageValue>(key: string, defaultVal?: T): [data: T | undefined, setter: ArgCallback<T | undefined>] { + const value: T | undefined = deserialize(localStorage.getItem(key)) ?? defaultVal const setter = (val: T | undefined) => { if (val === undefined) { - localStorage?.removeItem(key) + localStorage.removeItem(key) } else { - localStorage?.setItem(key, JSON.stringify(val)) + localStorage.setItem(key, JSON.stringify(val)) } } return [value, setter] } -function deserialize<T>(json: string | null, defaultVal?: T): T | undefined { +function deserialize<T>(json: string | null): T | undefined { if (!json) return undefined try { - const stored = JSON.parse(json) || {} - Object.entries(defaultVal || {}).forEach(([k, v]) => { - if (stored[k] === undefined || stored[k] === null) { - stored[k] = v - } - }) - return stored + return JSON.parse(json) as T } catch { return undefined } diff --git a/src/pages/hooks/useMediaSize.ts b/src/pages/hooks/useMediaSize.ts index d659fa971..b1e378270 100644 --- a/src/pages/hooks/useMediaSize.ts +++ b/src/pages/hooks/useMediaSize.ts @@ -1,5 +1,5 @@ -import { useWindowSize } from "@hooks" import { computed } from "vue" +import { useWindowSize } from "./useWindowSize" export enum MediaSize { xs, @@ -48,5 +48,10 @@ export const listenMediaSizeChange = () => { } processMediaSize() window.addEventListener('resize', processMediaSize) - window.addEventListener('unload', () => window.removeEventListener('resize', processMediaSize)) + window.addEventListener('pagehide', () => window.removeEventListener('resize', processMediaSize)) + window.addEventListener('pageshow', () => { + window.removeEventListener('resize', processMediaSize) + window.addEventListener('resize', processMediaSize) + processMediaSize() + }) } \ No newline at end of file diff --git a/src/pages/hooks/useRequest.ts b/src/pages/hooks/useRequest.ts index fc0b9f429..c36c81ecd 100644 --- a/src/pages/hooks/useRequest.ts +++ b/src/pages/hooks/useRequest.ts @@ -1,4 +1,4 @@ -import { ElLoadingService } from "element-plus" +import { ElLoadingService, type LoadingOptions } from "element-plus" import { onBeforeMount, onMounted, ref, shallowRef, watch, type Ref, type ShallowRef, type WatchSource, @@ -9,9 +9,10 @@ export type RequestOption<T, P extends any[]> = { defaultValue?: T loadingTarget?: string | Ref<HTMLElement | undefined> | Getter<HTMLElement | undefined> loadingText?: string + loadingOptions?: LoadingOptions defaultParam?: P deps?: WatchSource<unknown> | WatchSource<unknown>[] - onSuccess?: (result: T) => void, + onSuccess?: (result: T, ...p: P) => void, onError?: (e: unknown) => void } @@ -30,14 +31,11 @@ const findLoadingEl = async (target: RequestOption<unknown, unknown[]>['loadingT if (typeof target === 'string') { return target } else if (typeof target === 'function') { - const res = await target?.() - if (res instanceof HTMLElement) { - return res - } + const res = await target() + return res instanceof HTMLElement ? res : undefined } else { return target.value } - return undefined } export function useRequest<P extends any[], T>( @@ -48,7 +46,6 @@ export function useRequest<P extends any[], T>( getter: (...p: P) => Awaitable<T | undefined>, option?: RequestOption<T, P>, ): RequestResult<T | undefined, P> - export function useRequest<P extends any[], T>( getter: (...p: P) => Promise<T> | T, option?: RequestOption<T, P>, @@ -56,29 +53,27 @@ export function useRequest<P extends any[], T>( const { manual = false, defaultValue, defaultParam = ([] as any[] as P), - loadingTarget, loadingText, deps, onSuccess, onError, - } = option || {} + } = option ?? {} const data = shallowRef(defaultValue) as ShallowRef<T> const loading = ref(false) const param = ref<P>() const ts = ref<number>(Date.now()) + const createLoading = useLoading(option) + const refreshAsync = async (...p: P) => { loading.value = true - let loadingEl = await findLoadingEl(loadingTarget) - // fallback use document - !loadingEl && loadingText && (loadingEl = document.body) - const loadingInstance = loadingEl ? ElLoadingService({ target: loadingEl, text: loadingText }) : null + const loadingInstance = await createLoading?.() try { param.value = p const value = await getter?.(...p) data.value = value ts.value = Date.now() - onSuccess?.(value) + onSuccess?.(value, ...p) } catch (e) { - console.warn("Errored when requesting", e) + console.log("Errored when requesting", e) onError?.(e) } finally { loading.value = false @@ -88,7 +83,7 @@ export function useRequest<P extends any[], T>( const refresh = (...p: P) => { refreshAsync(...p) } if (!manual) { // If loading target specified, do first query after mounted - const hook = loadingTarget ? onMounted : onBeforeMount + const hook = option?.loadingTarget ? onMounted : onBeforeMount hook(() => refresh(...defaultParam)) } if (deps && (!Array.isArray(deps) || deps?.length)) { @@ -96,4 +91,19 @@ export function useRequest<P extends any[], T>( } const refreshAgain = () => param.value && refresh(...param.value) return { data, ts, refresh, refreshAsync, refreshAgain, loading, param } +} + +const useLoading = <T, P extends any[]>(option?: RequestOption<T, P>) => { + const { loadingTarget, loadingText, loadingOptions } = option ?? {} + + if (loadingOptions) return () => ElLoadingService(loadingOptions) + if (loadingTarget || loadingText) { + return async () => { + let loadingEl = await findLoadingEl(loadingTarget) + // fallback use document + !loadingEl && loadingText && (loadingEl = document.body) + return loadingEl ? ElLoadingService({ target: loadingEl, text: loadingText }) : null + } + } + return null } \ No newline at end of file diff --git a/src/pages/hooks/useSiteMerge.ts b/src/pages/hooks/useSiteMerge.ts index 01576c287..d3b799e0c 100644 --- a/src/pages/hooks/useSiteMerge.ts +++ b/src/pages/hooks/useSiteMerge.ts @@ -1,4 +1,4 @@ -import optionHolder from '@service/components/option-holder' +import { getOption } from '@api/sw/option' import { computed } from 'vue' import { useRequest } from './useRequest' @@ -7,13 +7,13 @@ type Options = { } export const useSiteMerge = ({ onGroupDisabled }: Options) => { - const { data: countTabGroup } = useRequest(() => optionHolder.get().then(o => o.countTabGroup), { + const { data: countTabGroup } = useRequest(() => getOption().then(o => o?.countTabGroup ?? false), { defaultValue: false, onSuccess: v => !v && onGroupDisabled?.() }) const mergeItems = computed(() => { - const res: (Exclude<timer.stat.MergeMethod, 'date'>)[] = ['cate', 'domain'] + const res: (Exclude<tt4b.stat.MergeMethod, 'date'>)[] = ['cate', 'domain'] countTabGroup.value && res.push('group') return res }) diff --git a/src/pages/hooks/useTabGroups.ts b/src/pages/hooks/useTabGroups.ts index 8907f250f..a1a0ffad3 100644 --- a/src/pages/hooks/useTabGroups.ts +++ b/src/pages/hooks/useTabGroups.ts @@ -1,6 +1,6 @@ import { listAllGroups, onChanged, removeChangedHandler } from "@api/chrome/tabGroups" import { computed, onMounted } from "vue" -import { useRequest } from "." +import { useRequest } from "./useRequest" export const useTabGroups = () => { const { data: groups, refresh } = useRequest(listAllGroups, { defaultValue: [] }) diff --git a/src/pages/hooks/useWindowFocus.ts b/src/pages/hooks/useWindowFocus.ts deleted file mode 100644 index bbc77d95a..000000000 --- a/src/pages/hooks/useWindowFocus.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { shallowRef, type ShallowRef } from 'vue' -import { useWindowListener } from './useWindowListener' - -export function useWindowFocus(): ShallowRef<boolean> { - if (typeof window === 'undefined') return shallowRef(false) - - const focused = shallowRef(window.document.hasFocus()) - - const options: AddEventListenerOptions = { passive: true } - - useWindowListener('focus', () => focused.value = true, options) - useWindowListener('blur', () => focused.value = false, options) - - return focused -} \ No newline at end of file diff --git a/src/pages/icons.tsx b/src/pages/icons.tsx new file mode 100644 index 000000000..b92c719ec --- /dev/null +++ b/src/pages/icons.tsx @@ -0,0 +1,58 @@ +import type { FunctionalComponent } from 'vue' + +type Icon = FunctionalComponent<{}> + +export const Coffee: Icon = () => ( + <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <path d="M247.68 933.12a66.88 66.88 0 0 0 64 58.88h399.68a66.88 66.88 0 0 0 64-58.88L864 320H160zM512 480a128 128 0 1 1-128 128 128 128 0 0 1 128-128zM923.2 160a64 64 0 0 1-56.64-32l-32-58.88A65.92 65.92 0 0 0 777.6 32H246.72a64 64 0 0 0-57.28 35.52L160 126.4A68.16 68.16 0 0 1 100.8 160 36.48 36.48 0 0 0 64 192v32c0 17.6 23.68 32 41.28 32h813.44C936.32 256 960 241.6 960 224V192a36.48 36.48 0 0 0-36.8-32z" /> + </svg> +) + +export const GitHub: Icon = () => ( + <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'> + <path d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z' /> + </svg> +) + +export const Heart: Icon = () => ( + <svg viewBox="0 0 1024 1024"> + <path d="M1000 248Q976.992 192 933.984 148.992 849.984 64 732.992 64q-64 0-121.504 28T512 171.008q-42.016-51.008-99.488-79.008T291.008 64Q174.016 64 90.016 150.016 47.008 193.024 24 249.024-0.992 308.032 0 371.04q0.992 68.992 28.992 130.496t79.008 104.512q4.992 4 8.992 8 14.016 12 112.992 102.016 208 191.008 256.992 235.008 11.008 8.992 24.992 8.992t24.992-8.992q32.992-30.016 180.992-164.992 158.016-144 196-179.008 52-43.008 80.992-104.992t28.992-132q0-64-24-122.016z" /> + </svg> +) + +export const RoseChart: Icon = () => ( + <svg viewBox="0 0 1024 1024"> + <path d="M512 256a256 256 0 1 0 256 256 256 256 0 0 0-256-256z m0 384a128 128 0 1 1 128-128 128 128 0 0 1-128 128z" /> + <path d="M768 320l-153.6 115.84A128 128 0 0 1 640 512a128 128 0 0 1-37.44 90.56l135.68 135.68A320 320 0 0 0 768 320z" /> + <path d="M602.56 602.56a128 128 0 0 1-181.12 0l-181.12 181.12a384 384 0 0 0 544 0z" /> + <path d="M384 512a128 128 0 0 1 128-128V64a448 448 0 0 0-316.8 764.8l226.24-226.24A128 128 0 0 1 384 512z" /> + </svg> +) + +export const Trend: Icon = () => ( + <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> + <path d="M99.760075 885.579417c-16.231683 0-29.42824-13.196557-29.42824-29.42824L70.331835 148.761092c0-16.238846 13.196557-29.441543 29.42824-29.441543 16.237823 0 29.440519 13.20372 29.440519 29.441543l0 677.955706 807.702746 0c16.23066 0 29.427216 13.202697 29.427216 29.434379s-13.196557 29.42824-29.427216 29.42824L99.760075 885.579417 99.760075 885.579417zM195.092303 726.586286c-6.382361 0-12.472056-2.029216-17.60803-5.867638-12.993942-9.730619-15.657608-28.214599-5.932106-41.208541l193.054901-257.921257c5.619998-7.498788 14.214746-11.799744 23.594371-11.799744 5.867638 0 11.544941 1.729387 16.413831 5.012154l173.612083 116.910687 305.917388-243.700371c5.266957-4.196579 11.597129-6.41613 18.313088-6.41613 9.025561 0 17.418718 4.040013 23.038716 11.095709 4.901637 6.141884 7.107885 13.829983 6.233981 21.648043-0.88209 7.806803-4.758374 14.809287-10.906397 19.717064l-322.80194 257.149685c-5.175883 4.13825-11.689227 6.40999-18.339694 6.40999-5.879917 0-11.565407-1.729387-16.439414-5.012154l-168.280658-113.320928L218.687698 714.77938C213.074863 722.285331 204.471928 726.586286 195.092303 726.586286L195.092303 726.586286z" /> + </svg> +) + +export const BarChart: Icon = () => ( + <svg viewBox="0 0 1024 1024"> + <g transform="matrix(-1 0 0 -1 1024 1024)"> + <g transform="matrix(0 1 -1 0 1024 -0)"> + <path d="M213.312 213.312v597.376h597.376V213.312H213.312z m0-85.312h597.376A85.312 85.312 0 0 1 896 213.312v597.376A85.376 85.376 0 0 1 810.688 896H213.312A85.376 85.376 0 0 1 128 810.688V213.312A85.312 85.312 0 0 1 213.312 128z m128 170.688h85.376a42.688 42.688 0 0 1 42.624 42.624v341.376a42.688 42.688 0 0 1-42.624 42.624H341.312a42.688 42.688 0 0 1-42.624-42.624V341.312a42.688 42.688 0 0 1 42.624-42.624z m256 0h85.376a42.688 42.688 0 0 1 42.624 42.624V512a42.688 42.688 0 0 1-42.624 42.688H597.312A42.688 42.688 0 0 1 554.688 512V341.312a42.688 42.688 0 0 1 42.624-42.624z" /> + </g> + </g> + </svg> +) + +export const HalfPieChart: Icon = () => ( + <svg viewBox="0 0 1365 1024"> + <path d="M1365.055916 682.737408A682.525185 682.525185 0 1 0 91.549237 1024h1181.962989A679.005914 679.005914 0 0 0 1365.055916 682.737408z" /> + </svg> +) + +export const Enter: Icon = () => ( + <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <path d="M417 1c-48.602 0-88 39.399-88 88v346H89c-48.6 0-88 39.399-88 88v412c0 48.6 39.4 88 88 88h314.69c4.341 0.658 8.786 1 13.31 1h517c24.555 0 46.761-10.057 62.724-26.277C1012.943 981.761 1023 959.555 1023 935V523c0-4.524-0.341-8.968-1-13.31V89c0-48.601-39.398-88-88-88H417z m250.036 645.739V389.131c0-27.134 21.977-49.131 49.087-49.131 27.11 0 49.088 21.997 49.088 49.131V745H453.699l31.657 31.657c19.174 19.173 19.167 50.263-0.012 69.441-19.178 19.179-50.268 19.185-69.441 0.013L266 696.207l149.956-149.955c19.179-19.179 50.269-19.185 69.441-0.013 19.172 19.173 19.167 50.263-0.012 69.441l-31.059 31.059h212.71z" /> + </svg> +) \ No newline at end of file diff --git a/src/pages/popup/common.tsx b/src/pages/popup/common.tsx index 9b7265a7c..760855891 100644 --- a/src/pages/popup/common.tsx +++ b/src/pages/popup/common.tsx @@ -1,22 +1,20 @@ -import type { ReportQueryParam } from "@app/components/Report/types" -import { REPORT_ROUTE } from "@app/router/constants" -import weekHelper from "@service/components/week-helper" -import { selectCate, selectGroup, selectSite } from '@service/stat-service' +import { getWeekStartTime } from "@api/sw/option" +import { listCateStats, listGroupStats, listSiteStats } from '@api/sw/stat' +import { REPORT_ROUTE, type ReportQuery } from "@app/router/constants" +import type { PopupDuration, PopupMenu, PopupQuery } from "@popup/types" import { isRemainHost } from "@util/constant/remain-host" import { getAppPageUrl } from "@util/constant/url" import { isSite } from "@util/stat" -import { getMonthTime, MILL_PER_DAY } from "@util/time" -import { type PopupDuration, type PopupQuery } from "./context" +import { cvtDateRange2Str, getMonthTime, MILL_PER_DAY, type DateRange } from "@util/time" +import { createOptionalGuard, createStringUnionGuard } from 'typescript-guard' -type DateRange = Date | [Date, Date] | undefined - -type DateRangeCalculator = (now: Date, num?: number) => Awaitable<DateRange> +type DateRangeCalculator = (now: Date, num?: number) => Awaitable<[Date, Date] | Date | undefined> const DATE_RANGE_CALCULATORS: { [duration in PopupDuration]: DateRangeCalculator } = { today: now => now, yesterday: now => new Date(now.getTime() - MILL_PER_DAY), thisWeek: async now => { - const [start] = await weekHelper.getWeekDate(now) + const start = await getWeekStartTime(now) return [start, now] }, thisMonth: now => [getMonthTime(now)[0], now], @@ -24,27 +22,28 @@ const DATE_RANGE_CALCULATORS: { [duration in PopupDuration]: DateRangeCalculator allTime: () => undefined, } -export const queryRows = async (param: PopupQuery): Promise<[rows: timer.stat.Row[], date: DateRange]> => { +export const queryRows = async (param: PopupQuery): Promise<[rows: tt4b.stat.Row[], date: [Date, Date] | Date | undefined]> => { const { duration, durationNum, mergeMethod, dimension: sortKey } = param - const date = await DATE_RANGE_CALCULATORS[duration]?.(new Date(), durationNum) - const sortDirection: timer.common.SortDirection = 'DESC' - let rows: timer.stat.Row[] + const dateRange = await DATE_RANGE_CALCULATORS[duration]?.(new Date(), durationNum) + const date = cvtDateRange2Str(dateRange) + const sortDirection: tt4b.common.SortDirection = 'DESC' + let rows: tt4b.stat.Row[] if (mergeMethod === 'cate') { - rows = await selectCate({ date, mergeDate: true, sortKey, sortDirection }) + rows = await listCateStats({ date, mergeDate: true, sortKey, sortDirection }) } else if (mergeMethod === 'group') { - rows = await selectGroup({ date, mergeDate: true, sortKey, sortDirection }) + rows = await listGroupStats({ date, mergeDate: true, sortKey, sortDirection }) } else { - rows = await selectSite({ + rows = await listSiteStats({ date, mergeDate: true, mergeHost: mergeMethod === 'domain', sortKey, sortDirection, }) } - return [rows, date] + return [rows, dateRange] } -function buildReportQuery(siteType: timer.site.Type, date: Date | [Date, Date?] | undefined, type: timer.core.Dimension): ReportQueryParam { - const query: ReportQueryParam = {} +function buildReportQuery(siteType: tt4b.site.Type, date: DateRange | undefined, type: tt4b.core.Dimension): ReportQuery { + const query: ReportQuery = {} // Merge host siteType === 'merged' && (query.mm = 'domain') // Date @@ -66,7 +65,11 @@ function buildReportQuery(siteType: timer.site.Type, date: Date | [Date, Date?] return query } -export function calJumpUrl(row: timer.stat.Row | undefined, date: Date | [Date, Date?] | undefined, type: timer.core.Dimension): string | undefined { +export function calJumpUrl( + row: tt4b.stat.Row | undefined, + date: DateRange | undefined, + type: tt4b.core.Dimension, +): string | undefined { if (!row) return if (isSite(row)) { const { siteKey: { host, type: siteType } } = row @@ -80,3 +83,5 @@ export function calJumpUrl(row: timer.stat.Row | undefined, date: Date | [Date, return getAppPageUrl(REPORT_ROUTE, query) } } + +export const isMenu = createOptionalGuard(createStringUnionGuard<PopupMenu>('percentage', 'ranking', 'limit')) \ No newline at end of file diff --git a/src/pages/popup/components/Footer/DataToolbar.tsx b/src/pages/popup/components/Footer/DataToolbar.tsx new file mode 100644 index 000000000..6492a7a74 --- /dev/null +++ b/src/pages/popup/components/Footer/DataToolbar.tsx @@ -0,0 +1,51 @@ +import { useSiteMerge } from '@hooks' +import Flex from '@pages/components/Flex' +import { useQuery } from '@popup/context' +import { t } from '@popup/locale' +import { ALL_DIMENSIONS } from '@util/stat' +import { ElSelect, ElText } from 'element-plus' +import { defineComponent } from 'vue' +import DurationSelect from './DurationSelect' + +const DataToolbar = defineComponent(() => { + const query = useQuery() + + const { mergeItems } = useSiteMerge({ + onGroupDisabled: () => query.mergeMethod === 'group' && (query.mergeMethod = undefined) + }) + + return () => ( + <Flex gap={8}> + <Flex gap={4}> + <ElText>{t(msg => msg.shared.merge.mergeBy)}</ElText> + <ElSelect + modelValue={query.mergeMethod} + onChange={v => query.mergeMethod = v ?? undefined} + placeholder={t(msg => msg.shared.merge.mergeMethod.notMerge)} + popperOptions={{ placement: 'top' }} + style={{ width: '90px' }} + options={[ + { value: '', label: t(msg => msg.shared.merge.mergeMethod.notMerge) }, + ...mergeItems.value.map(value => ({ value, label: t(msg => msg.shared.merge.mergeMethod[value]) })), + ]} + /> + </Flex> + <DurationSelect + modelValue={[query.duration, query.durationNum]} + onChange={([duration, durationNum]) => { + query.duration = duration + query.durationNum = durationNum + }} + /> + <ElSelect + modelValue={query.dimension} + onChange={v => query.dimension = v} + popperOptions={{ placement: 'top' }} + style={{ width: '120px' }} + options={ALL_DIMENSIONS.map(value => ({ value, label: t(msg => msg.item[value]) }))} + /> + </Flex> + ) +}) + +export default DataToolbar \ No newline at end of file diff --git a/src/pages/popup/components/Footer/DurationSelect.tsx b/src/pages/popup/components/Footer/DurationSelect.tsx index 10d3abcea..853c6c356 100644 --- a/src/pages/popup/components/Footer/DurationSelect.tsx +++ b/src/pages/popup/components/Footer/DurationSelect.tsx @@ -1,16 +1,14 @@ import { css } from '@emotion/css' import { t } from "@i18n" import calendarMessages from "@i18n/message/common/calendar" -import { type PopupDuration } from '@popup/context' +import { type PopupDuration } from '@popup/types' import { type CascaderNode, type CascaderOption, ElCascader, useNamespace } from "element-plus" import { computed, defineComponent } from "vue" -export const rangeLabel = (duration: PopupDuration, n?: string | number): string => { - return t(calendarMessages, { - key: msg => msg.range[duration], - param: n ? { n } : undefined, - }) -} +const rangeLabel = (duration: PopupDuration, n?: string | number): string => t(calendarMessages, { + key: msg => msg.range[duration], + param: n ? { n } : undefined, +}) const BUILTIN_DAY_NUM = [7, 30, 90, 180, 365] @@ -18,33 +16,28 @@ const cvt2Opt = (value: PopupDuration, n?: string | number): CascaderOption => ( value, label: rangeLabel(value, n), }) -const options = (reverse?: boolean): CascaderOption[] => { - const result: CascaderOption[] = [ - ...(['today', 'yesterday', 'thisWeek', 'thisMonth'] satisfies PopupDuration[]).map(cvt2Opt), - { - ...cvt2Opt('lastDays', 'X'), - children: [ - ...BUILTIN_DAY_NUM.map(value => ({ - value, - label: rangeLabel('lastDays', value), - })), - ], - }, - cvt2Opt('allTime'), - ] - return reverse ? result.reverse() : result -} +const options = (): CascaderOption[] => [ + cvt2Opt('allTime'), + { + ...cvt2Opt('lastDays', 'X'), + children: [ + ...BUILTIN_DAY_NUM.map(value => ({ + value, + label: rangeLabel('lastDays', value), + })), + ], + }, + ...(['thisMonth', 'thisWeek', 'yesterday', 'today'] satisfies PopupDuration[]).map(cvt2Opt), +] type DurationValue = [PopupDuration, number?] -type Props = ModelValue<DurationValue> & { - reverse?: boolean -} +type Props = ModelValue<DurationValue> const DurationSelect = defineComponent<Props>(props => { const casVal = computed(() => { - const [type, num] = props.modelValue || [] - return type === 'lastDays' ? num || 30 : type || 'today' + const [type, num] = props.modelValue + return type === 'lastDays' ? num ?? 30 : type }) const cascaderNs = useNamespace('cascader') @@ -62,7 +55,7 @@ const DurationSelect = defineComponent<Props>(props => { <ElCascader modelValue={casVal.value} onChange={val => props.onChange?.(val as [PopupDuration, number?])} - options={options(props.reverse)} + options={options()} show-all-levels={false} style={{ width: '130px' }} popperClass={popoverCls} @@ -71,8 +64,8 @@ const DurationSelect = defineComponent<Props>(props => { const { label, value, level } = param?.node as CascaderNode || {} return level === 2 ? value : label }} - </ElCascader > + </ElCascader> ) -}, { props: ['modelValue', 'onChange', 'reverse'] }) +}, { props: ['modelValue', 'onChange'] }) export default DurationSelect \ No newline at end of file diff --git a/src/pages/popup/components/Footer/LimitToolbar.tsx b/src/pages/popup/components/Footer/LimitToolbar.tsx new file mode 100644 index 000000000..653796af5 --- /dev/null +++ b/src/pages/popup/components/Footer/LimitToolbar.tsx @@ -0,0 +1,66 @@ +import { APP_LIMIT_ROUTE, AppLimitQuery } from '@/shared/route' +import { createTab } from '@api/chrome/tab' +import { Edit, Plus } from '@element-plus/icons-vue' +import Flex from '@pages/components/Flex' +import { useLimitSummary } from '@popup/context' +import { t } from '@popup/locale' +import { getAppPageUrl } from '@util/constant/url' +import { isBrowserUrl } from '@util/pattern' +import { ElButton, ElSelect } from 'element-plus' +import { computed, defineComponent, type StyleValue } from 'vue' + +const findHost = (url: string) => { + try { + return new URL(url).host + } catch { + return url + } +} + +const LimitToolbar = defineComponent(() => { + const { summary, selected, loading } = useLimitSummary() + const items = computed(() => summary.value?.items || []) + + const handleNew = async () => { + let url = summary.value?.url + const query: AppLimitQuery = { action: 'create' } + if (url && !isBrowserUrl(url)) { + const host = findHost(url) + query.url = encodeURIComponent(host) + } + await createTab(getAppPageUrl(APP_LIMIT_ROUTE, query)) + } + + const handleEdit = async () => { + if (!selected.value) return + const query: AppLimitQuery = { action: 'modify', id: String(selected.value) } + await createTab(getAppPageUrl(APP_LIMIT_ROUTE, query)) + } + + return () => ( + <Flex gap={8} justify='end'> + {!!items.value.length && ( + <Flex gap={4}> + <ElSelect + modelValue={selected.value} + onChange={val => typeof val === 'number' && (selected.value = val)} + options={items.value.map(i => ({ value: i.id, label: i.name }))} + style={{ width: '140px' } satisfies StyleValue} + /> + <ElButton + icon={Edit} + onClick={handleEdit} + style={{ width: '40px' } satisfies StyleValue} + /> + </Flex> + )} + {!loading.value && !items.value.length && ( + <ElButton type='primary' icon={Plus} onClick={handleNew}> + {t(msg => msg.content.limit.newOne)} + </ElButton> + )} + </Flex> + ) +}) + +export default LimitToolbar \ No newline at end of file diff --git a/src/pages/popup/components/Footer/Menu.tsx b/src/pages/popup/components/Footer/Menu.tsx index c37e83c22..6cee6d51c 100644 --- a/src/pages/popup/components/Footer/Menu.tsx +++ b/src/pages/popup/components/Footer/Menu.tsx @@ -1,23 +1,42 @@ -import { t } from "@popup/locale" -import { POPUP_ROUTES } from "@popup/router" -import { ElRadioButton, ElRadioGroup } from "element-plus" -import { computed, defineComponent } from "vue" -import { useRoute, useRouter } from "vue-router" +import { Histogram, PieChart, Timer } from '@element-plus/icons-vue' +import { useMenu } from '@popup/context' +import { t } from '@popup/locale' +import type { PopupMenu } from '@popup/types' +import { ElIcon, ElRadioButton, ElRadioGroup, ElTooltip } from "element-plus" +import { type Component, defineComponent, h } from "vue" -const Menu = defineComponent(() => { - const route = useRoute() +type MenuItem = { + icon: Component + route: PopupMenu + label: string +} + +const createItems = (): MenuItem[] => [ + { + route: 'percentage', + label: t(msg => msg.footer.route.percentage), + icon: PieChart, + }, { + route: 'ranking', + label: t(msg => msg.footer.route.ranking), + icon: Histogram, + }, { + route: 'limit', + label: t(msg => msg.base.limit), + icon: Timer, + } +] as const - const current = computed(() => route.path?.substring?.(1)) +const Menu = defineComponent(() => { + const { menu, setMenu } = useMenu() - const router = useRouter() return () => ( - <ElRadioGroup - modelValue={current.value} - onChange={val => router.push('/' + val)} - > - {POPUP_ROUTES.map(route => ( + <ElRadioGroup modelValue={menu.value} onChange={v => setMenu(v as PopupMenu)}> + {createItems().map(({ route, label, icon }) => ( <ElRadioButton value={route}> - {t(msg => msg.footer.route[route])} + <ElTooltip content={label}> + <ElIcon>{h(icon)}</ElIcon> + </ElTooltip> </ElRadioButton> ))} </ElRadioGroup> diff --git a/src/pages/popup/components/Footer/index.tsx b/src/pages/popup/components/Footer/index.tsx index ae137e65f..36c643b81 100644 --- a/src/pages/popup/components/Footer/index.tsx +++ b/src/pages/popup/components/Footer/index.tsx @@ -1,56 +1,20 @@ -import { useSiteMerge } from '@hooks/useSiteMerge' import Flex from "@pages/components/Flex" -import DurationSelect from "@popup/components/Footer/DurationSelect" -import { useQuery } from "@popup/context" -import { t } from "@popup/locale" -import { ALL_DIMENSIONS } from "@util/stat" -import { ElSelect, ElText } from "element-plus" -import { defineComponent } from "vue" +import { useMenu } from "@popup/context" +import { defineComponent, Transition } from "vue" +import DataToolbar from './DataToolbar' +import LimitToolbar from './LimitToolbar' import Menu from "./Menu" const Footer = defineComponent(() => { - const query = useQuery() - const { mergeItems } = useSiteMerge({ - onGroupDisabled: () => query.mergeMethod === 'group' && (query.mergeMethod = undefined) - }) + const { menu } = useMenu() return () => ( - <Flex justify="space-between" width="100%"> - <Flex> - <Menu /> - </Flex> - <Flex gap={8}> - <Flex gap={4}> - <ElText>{t(msg => msg.shared.merge.mergeBy)}</ElText> - <ElSelect - modelValue={query.mergeMethod} - onChange={v => query.mergeMethod = v ?? undefined} - placeholder={t(msg => msg.shared.merge.mergeMethod.notMerge)} - popperOptions={{ placement: 'top' }} - style={{ width: '90px' }} - options={[ - { value: '', label: t(msg => msg.shared.merge.mergeMethod.notMerge) }, - ...mergeItems.value.map(value => ({ value, label: t(msg => msg.shared.merge.mergeMethod[value]) })), - ]} - /> - </Flex> - <DurationSelect - reverse - modelValue={[query.duration, query.durationNum]} - onChange={([duration, durationNum]) => { - query.duration = duration - query.durationNum = durationNum - }} - /> - <ElSelect - modelValue={query.dimension} - onChange={v => query.dimension = v} - popperOptions={{ placement: 'top' }} - style={{ width: '120px' }} - options={ALL_DIMENSIONS.map(value => ({ value, label: t(msg => msg.item[value]) }))} - /> - </Flex> - </Flex > + <Flex justify="space-between" marginBottom={2} marginInline={1}> + <Menu /> + <Transition name="el-fade-in" mode="out-in"> + {menu.value === 'limit' ? <LimitToolbar /> : <DataToolbar />} + </Transition> + </Flex> ) }) diff --git a/src/pages/popup/components/Header/Coffee.tsx b/src/pages/popup/components/Header/Coffee.tsx deleted file mode 100644 index eade24775..000000000 --- a/src/pages/popup/components/Header/Coffee.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { locale } from '@i18n' -import { CoffeeIcon } from '@pages/util/icon' -import { BUY_ME_A_COFFEE_PAGE } from '@util/constant/url' -import { ElLink, ElTooltip } from 'element-plus' -import { defineComponent, onMounted, onUnmounted, ref } from 'vue' - -const useCoffee = () => { - const coffeeTip = ref<string>() - - const resetTip = () => { - const now = new Date() - const hours = now.getHours() - let newVal = undefined - if (hours == 8) { - newVal = 'Buy me a coffee for a vibrant morning!' - } else if (hours == 13) { - newVal = 'Buy me a coffee for a pleasant afternoon!' - } - newVal !== coffeeTip.value && (coffeeTip.value = newVal) - } - const timer = setInterval(resetTip, 1000) - - onMounted(resetTip) - onUnmounted(() => clearInterval(timer)) - - return coffeeTip -} - -const Coffee = defineComponent(() => { - const tip = useCoffee() - return () => tip.value && locale !== 'zh_CN' ? - <ElTooltip - content={tip.value} offset={3} - placement='bottom' effect='light' - > - <ElLink - type='info' icon={CoffeeIcon} underline='never' - href={BUY_ME_A_COFFEE_PAGE} target='_blank' - /> - </ElTooltip > - : null -}) - -export default Coffee \ No newline at end of file diff --git a/src/pages/popup/components/Header/DarkSwitch.tsx b/src/pages/popup/components/Header/DarkSwitch.tsx index ba9a26733..b0491a580 100644 --- a/src/pages/popup/components/Header/DarkSwitch.tsx +++ b/src/pages/popup/components/Header/DarkSwitch.tsx @@ -1,5 +1,5 @@ -import Flex from "@pages/components/Flex" import { usePopupContext } from "@popup/context" +import Flex from "@pages/components/Flex" import { ElIcon } from "element-plus" import { defineComponent } from "vue" diff --git a/src/pages/popup/components/Header/Donation.tsx b/src/pages/popup/components/Header/Donation.tsx new file mode 100644 index 000000000..796b481be --- /dev/null +++ b/src/pages/popup/components/Header/Donation.tsx @@ -0,0 +1,25 @@ +import { createTab } from '@api/chrome/tab' +import { locale } from '@i18n' +import Flex from '@pages/components/Flex' +import { Coffee } from '@pages/icons' +import { BUY_ME_A_COFFEE_PAGE, DONATION_PAGE } from '@util/constant/url' +import { ElIcon, ElTooltip } from 'element-plus' +import { FunctionalComponent } from 'vue' + +const Donation: FunctionalComponent<{}> = () => { + const [content, url] = locale === 'zh_CN' + ? ['请他喝杯咖啡~', DONATION_PAGE] + : ['Buy me a coffee', BUY_ME_A_COFFEE_PAGE] + + return ( + <ElTooltip content={content} placement="bottom"> + <Flex onClick={() => createTab(url)} cursor='pointer'> + <ElIcon size="large" color="var(--el-text-color-primary)"> + <Coffee /> + </ElIcon> + </Flex> + </ElTooltip> + ) +} + +export default Donation \ No newline at end of file diff --git a/src/pages/popup/components/Header/Github.tsx b/src/pages/popup/components/Header/Github.tsx deleted file mode 100644 index 819bf31e3..000000000 --- a/src/pages/popup/components/Header/Github.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { createTab } from "@api/chrome/tab" -import Flex from "@pages/components/Flex" -import { SOURCE_CODE_PAGE } from "@util/constant/url" -import { ElIcon } from "element-plus" -import { defineComponent } from "vue" - -const Github = defineComponent(() => { - const handleClick = () => createTab({ url: SOURCE_CODE_PAGE }) - - return () => ( - <Flex onClick={handleClick}> - <ElIcon - size="large" - color="var(--el-text-color-primary)" - style={{ cursor: 'pointer' }} - > - <svg viewBox="0 0 24 24"> - <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /> - </svg> - </ElIcon> - </Flex> - ) -}) - -export default Github \ No newline at end of file diff --git a/src/pages/popup/components/Header/LangSelect.tsx b/src/pages/popup/components/Header/LangSelect.tsx index 8521f5c26..38642f5cf 100644 --- a/src/pages/popup/components/Header/LangSelect.tsx +++ b/src/pages/popup/components/Header/LangSelect.tsx @@ -1,20 +1,19 @@ import { createTab } from "@api/chrome/tab" -import { useManualRequest } from "@hooks/useManualRequest" -import { useRequest } from "@hooks/useRequest" +import { sendMsg2Runtime } from '@api/sw/common' +import { getOption } from "@api/sw/option" +import { useManualRequest, useRequest } from "@hooks" import { ALL_LOCALES, handleLocaleOption, localeSameAsBrowser, t } from "@i18n" import optionMessages from "@i18n/message/app/option" import localeMessages from "@i18n/message/common/locale" import Flex from "@pages/components/Flex" import { usePopupContext } from "@popup/context" -import { t as tPopup } from "@popup/locale" -import optionHolder from "@service/components/option-holder" -import optionService from "@service/option-service" +import { t as tPopup } from '@popup/locale' import { CROWDIN_HOMEPAGE } from "@util/constant/url" import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElText } from "element-plus" import { defineComponent, type StyleValue } from "vue" // Keep the locale same as this browser first position -const SORTED_LOCALES: timer.Locale[] = ALL_LOCALES.sort((a, _b) => a === localeSameAsBrowser ? -1 : 0) +const SORTED_LOCALES: tt4b.Locale[] = ALL_LOCALES.sort((a, _b) => a === localeSameAsBrowser ? -1 : 0) const SELECTED_STYLES: StyleValue = { color: 'var(--el-color-primary)', @@ -23,15 +22,15 @@ const SELECTED_STYLES: StyleValue = { const LangSelect = defineComponent(() => { const { data: current } = useRequest(async () => { - const option = await optionHolder.get() + const option = await getOption() return option?.locale }) const { reload: reloadPopup } = usePopupContext() const { refresh: saveLocale } = useManualRequest( - async opt => { - await optionService.setLocale(opt) + async (opt: tt4b.option.LocaleOption) => { + await sendMsg2Runtime('option.set', { locale: opt }) handleLocaleOption(opt) }, { onSuccess: reloadPopup }, @@ -79,7 +78,7 @@ const LangSelect = defineComponent(() => { onClick={() => createTab(CROWDIN_HOMEPAGE)} divided > - {tPopup(msg => msg.menu.helpUs)} + {tPopup(msg => msg.base.helpUs)} </ElDropdownItem> </ElDropdownMenu> ) diff --git a/src/pages/popup/components/Header/Logo.tsx b/src/pages/popup/components/Header/Logo.tsx index f158a30a1..56e46377b 100644 --- a/src/pages/popup/components/Header/Logo.tsx +++ b/src/pages/popup/components/Header/Logo.tsx @@ -1,13 +1,15 @@ +import packageInfo from "@/package" +import { getIconUrl } from '@api/chrome/runtime' import Flex from "@pages/components/Flex" -import { t } from "@popup/locale" -import packageInfo from "@src/package" +import Img from '@pages/components/Img' +import { t } from '@popup/locale' import { ElText } from "element-plus" import type { FunctionalComponent } from "vue" const Logo: FunctionalComponent = () => ( <Flex height={30} gap={10}> <Flex gap={10} height="100%" align="center"> - <img src="/static/images/icon.png" style={{ height: '100%' }} /> + <Img src={getIconUrl()} style={{ height: '100%' }} /> <ElText size="large" tag="b" style={{ color: 'var(--el-text-color-primary)' }}> {t(msg => msg.meta.name)} </ElText> diff --git a/src/pages/popup/components/Header/MoreInfo.tsx b/src/pages/popup/components/Header/MoreInfo.tsx new file mode 100644 index 000000000..8828036ab --- /dev/null +++ b/src/pages/popup/components/Header/MoreInfo.tsx @@ -0,0 +1,69 @@ +import { createTab } from "@api/chrome/tab" +import { Collection, MoreFilled } from '@element-plus/icons-vue' +import Flex from '@pages/components/Flex' +import { GitHub, Heart } from '@pages/icons' +import { rateClicked } from '@pages/util/rate' +import { getColor, type ColorVariant } from '@pages/util/style' +import { t } from '@popup/locale' +import { BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, DONATION_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE } from "@util/constant/url" +import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" +import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" +import { type JSX } from 'vue/jsx-runtime' + +type Command = 'rate' | 'coffee' | 'donation' | 'github' | 'changelog' + +type ItemLinkProps = { + icon: JSX.Element + text: string + iconColor?: ColorVariant +} +const ItemLink: FunctionalComponent<ItemLinkProps> = ({ icon, text, iconColor }) => ( + <Flex gap={2} align='center'> + <ElIcon color={iconColor ? getColor(iconColor) : undefined}>{icon}</ElIcon> + {text} + </Flex> +) + +const MoreInfo = defineComponent<{}>(() => { + const handleCmd = async (cmd: Command) => { + if (cmd === 'rate') { + rateClicked() + createTab(REVIEW_PAGE) + } else if (cmd === 'coffee') { + createTab(BUY_ME_A_COFFEE_PAGE) + } else if (cmd === 'github') { + createTab(SOURCE_CODE_PAGE) + } else if (cmd === 'changelog') { + createTab(CHANGE_LOG_PAGE) + } else if (cmd === 'donation') { + createTab(DONATION_PAGE) + } + } + + return () => ( + <ElDropdown + size='small' + trigger='click' + onCommand={handleCmd} + style={{ cursor: 'pointer' } satisfies StyleValue} + v-slots={{ + default: () => <ElIcon><MoreFilled /></ElIcon>, + dropdown: () => ( + <ElDropdownMenu> + <ElDropdownItem command={'github' satisfies Command}> + <ItemLink icon={<GitHub />} text={t(msg => msg.base.sourceCode)} /> + </ElDropdownItem> + <ElDropdownItem command={'changelog' satisfies Command}> + <ItemLink icon={<Collection />} text={t(msg => msg.base.changeLog)} /> + </ElDropdownItem> + <ElDropdownItem command={'rate' satisfies Command} divided> + <ItemLink icon={<Heart />} text={t(msg => msg.header.rating)} iconColor="danger" /> + </ElDropdownItem> + </ElDropdownMenu> + ) + }}> + </ElDropdown> + ) +}) + +export default MoreInfo \ No newline at end of file diff --git a/src/pages/popup/components/Header/Option.tsx b/src/pages/popup/components/Header/Option.tsx index 2ebc77f9b..53d97f53f 100644 --- a/src/pages/popup/components/Header/Option.tsx +++ b/src/pages/popup/components/Header/Option.tsx @@ -1,12 +1,12 @@ +import { APP_LIMIT_ROUTE } from '@/shared/route' import { css } from '@emotion/css' -import { useSwitch } from '@hooks/useSwitch' +import { useSwitch } from '@hooks' import Flex from '@pages/components/Flex' -import { useOption } from '@popup/context' +import { useMenu, useOption } from '@popup/context' import { t, tN } from '@popup/locale' -import { ROUTE_PERCENTAGE } from '@popup/router' -import { ElCheckbox, ElIcon, ElInputNumber, ElPopover, ElText, useNamespace } from "element-plus" +import { getAppPageUrl } from '@util/constant/url' +import { ElCheckbox, ElIcon, ElInputNumber, ElLink, ElPopover, ElText, useNamespace } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" -import { useRoute } from 'vue-router' const reference = () => ( <ElIcon size="large" style={{ cursor: 'pointer' } satisfies StyleValue}> @@ -18,8 +18,8 @@ const reference = () => ( const Option = defineComponent(() => { const option = useOption() - const route = useRoute() - const isPercentage = computed(() => !!route.path?.endsWith(ROUTE_PERCENTAGE)) + const { menu } = useMenu() + const isPercentage = computed(() => menu.value === 'percentage') const toggleName = () => { option.showName = !option.showName @@ -47,7 +47,15 @@ const Option = defineComponent(() => { } ` - return () => ( + return () => menu.value === 'limit' ? ( + <ElLink + href={getAppPageUrl(APP_LIMIT_ROUTE)} + target='_blank' + style={{ '--el-link-text-color': 'unset', '--el-link-hover-text-color': 'unset' }} + > + {reference()} + </ElLink> + ) : ( <ElPopover visible={visible.value} placement='auto-end' diff --git a/src/pages/popup/components/Header/RateUs.tsx b/src/pages/popup/components/Header/RateUs.tsx deleted file mode 100644 index d79c22101..000000000 --- a/src/pages/popup/components/Header/RateUs.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { createTab } from "@api/chrome/tab" -import { useRequest } from "@hooks/useRequest" -import { t } from "@popup/locale" -import { recommendRate, saveFlag } from "@service/meta-service" -import { REVIEW_PAGE } from "@util/constant/url" -import { ElLink } from "element-plus" -import { defineComponent } from "vue" - -const HeartIcon = ( - <svg viewBox="0 0 1024 1024"> - <path d="M1000 248Q976.992 192 933.984 148.992 849.984 64 732.992 64q-64 0-121.504 28T512 171.008q-42.016-51.008-99.488-79.008T291.008 64Q174.016 64 90.016 150.016 47.008 193.024 24 249.024-0.992 308.032 0 371.04q0.992 68.992 28.992 130.496t79.008 104.512q4.992 4 8.992 8 14.016 12 112.992 102.016 208 191.008 256.992 235.008 11.008 8.992 24.992 8.992t24.992-8.992q32.992-30.016 180.992-164.992 158.016-144 196-179.008 52-43.008 80.992-104.992t28.992-132q0-64-24-122.016z" /> - </svg > -) - -const RateUs = defineComponent<{}>(() => { - const { data: rateVisible } = useRequest(recommendRate) - - const handleRateClick = async () => { - await saveFlag("rateOpen") - createTab(REVIEW_PAGE) - } - - return () => ( - <ElLink - v-show={rateVisible.value} - type="danger" - underline="never" - onClick={handleRateClick} - style={{ gap: '3px' }} - icon={HeartIcon} - > - {t(msg => msg.header.rate)} - </ElLink> - ) -}) - -export default RateUs \ No newline at end of file diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index c5235105e..49cc14f46 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -1,18 +1,17 @@ -import { createTab, listTabs, updateTab } from "@api/chrome/tab" +import { createTab, listTabs, updateTab } from '@api/chrome/tab' import { View } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import LangSelect from "@popup/components/Header/LangSelect" -import { t } from "@popup/locale" +import { t } from '@popup/locale' import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" -import { FunctionalComponent } from "vue" -import Coffee from './Coffee' +import type { FunctionalComponent } from "vue" import DarkSwitch from "./DarkSwitch" -import Github from "./Github" +import Donation from './Donation' +import LangSelect from "./LangSelect" import Logo from "./Logo" +import MoreInfo from './MoreInfo' import Option from "./Option" -import RateUs from './RateUs' const openAppPage = async () => { const appPageUrl = getAppPageUrl() @@ -31,24 +30,19 @@ const openAppPage = async () => { await createTab(appPageUrl) } -const Header: FunctionalComponent = () => ( +const Header: FunctionalComponent<{}> = () => ( <Flex justify="space-between" padding='0 10px' color='text-primary'> - <Flex gap={4}> - <Logo /> - <Coffee /> - </Flex> + <Logo /> <Flex gap={10}> - <Flex gap={10}> - <RateUs /> - <ElLink underline="never" onClick={openAppPage} icon={View} style={{ gap: '3px' }}> - {t(msg => msg.base.allFunction)} - </ElLink> - </Flex> + <ElLink underline="never" onClick={openAppPage} icon={View} style={{ gap: '3px' }}> + {t(msg => msg.base.allFunction)} + </ElLink> <Flex align="center" gap={8} fontSize={30}> <LangSelect /> <DarkSwitch /> <Option /> - <Github /> + <Donation /> + <MoreInfo /> </Flex> </Flex> </Flex> diff --git a/src/pages/popup/components/Limit/Content/Chart.tsx b/src/pages/popup/components/Limit/Content/Chart.tsx new file mode 100644 index 000000000..3d562d580 --- /dev/null +++ b/src/pages/popup/components/Limit/Content/Chart.tsx @@ -0,0 +1,10 @@ +import { useEcharts } from '@hooks' +import { defineComponent, toRef } from 'vue' +import Wrapper from './Wrapper' + +const Chart = defineComponent<{ item: tt4b.limit.Item }>(props => { + const { elRef } = useEcharts(Wrapper, toRef(props, 'item')) + return () => <div ref={elRef} style={{ width: '100%', flex: 1 }} /> +}, { props: ['item'] }) + +export default Chart \ No newline at end of file diff --git a/src/pages/popup/components/Limit/Content/Wrapper.ts b/src/pages/popup/components/Limit/Content/Wrapper.ts new file mode 100644 index 000000000..a515e2426 --- /dev/null +++ b/src/pages/popup/components/Limit/Content/Wrapper.ts @@ -0,0 +1,324 @@ +import { EchartsWrapper } from '@hooks' +import { getColor, getInfoColor, getRegularTextColor, getSecondaryTextColor } from '@pages/util/style' +import { t } from '@popup/locale' +import { clamp } from '@util/number' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import type { ComposeOption, GaugeSeriesOption, LegendComponentOption, PieSeriesOption, TitleComponentOption, TooltipComponentOption } from 'echarts' + +type EcOption = ComposeOption< + | LegendComponentOption + | TitleComponentOption + | GaugeSeriesOption + | PieSeriesOption + | TooltipComponentOption +> + +const MINUTES_PER_DAY = 24 * 60 +const GAUGE_BG_COLOR = 'rgba(0, 0, 0, 0.12)' + +const timeGaugeColor = () => getColor('primary') ?? '#409eff' +const visitGaugeColor = () => getColor('success') ?? '#67c23a' +const clockPointerColor = () => getColor('warning') ?? '#e6a23c' +const blockedPeriodColor = () => getColor('danger') ?? '#f56c6c' + +type LimitDimension = 'time' | 'visit' + +type ChartPart = { + titles: TitleComponentOption[] + series: (PieSeriesOption | GaugeSeriesOption)[] +} + +const createTitle = (text: string, left: string): TitleComponentOption => { + const textPrimary = getRegularTextColor() + return { + text, + left, + top: '13%', + textAlign: 'center', + width: 100, + textStyle: { color: textPrimary, fontSize: 16, fontWeight: 'bold' }, + } +} + +const createInfo = (text: string, left: string): TitleComponentOption => { + const textSecondary = getSecondaryTextColor() + return { + text, + left, + bottom: '20%', + textAlign: 'center', + subtext: '', + padding: 0, + itemGap: 0, + textStyle: { color: textSecondary, fontSize: 14, overflow: 'break', width: 140 }, + } +} + +type GaugeOptions = { + name: LimitDimension + center: string + usage: { + used: number + limit: number + } + color: string +} + +const createGauge = (options: GaugeOptions): GaugeSeriesOption => { + const { name, center, usage: { used, limit }, color } = options + + const percent = limit ? clamp((used * 100) / limit, 0, 100) : 40 + const percentText = `${percent.toFixed(1)}%` + const usedText = name === 'time' + ? formatPeriodCommon(used, true) + : t(msg => msg.shared.limit.visits, { n: used }) + const progressText = name === 'time' + ? `${usedText}/${formatPeriodCommon(limit, true)}` + : t(msg => msg.shared.limit.visits, { n: `${used}/${limit}` }) + + return { + name, + type: 'gauge', + radius: '45%', + center: [center, '50%'], + startAngle: 210, + endAngle: -30, + min: 0, + max: 100, + splitLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + pointer: { show: false }, + detail: { + show: true, + offsetCenter: [0, 0], + fontSize: 13, + color: getSecondaryTextColor(), + width: 100, + overflow: 'break', + formatter: () => limit ? percentText : usedText, + }, + tooltip: { + formatter: () => `${percentText}<br/>${progressText}`, + }, + silent: false, + axisLine: { lineStyle: { width: 8, color: [[1, GAUGE_BG_COLOR]] } }, + progress: { show: limit > 0, width: 8, itemStyle: { color, borderCap: 'round' } }, + data: [{ value: percent }], + } +} + +class Wrapper extends EchartsWrapper<tt4b.limit.Item, EcOption> { + private dimension: LimitDimension = 'time' + protected replaceSeries = true + + init(container: HTMLDivElement): void { + super.init(container) + this.instance?.on('legendselectchanged', params => { + const name = typeof params === 'object' && params && 'name' in params + ? String((params as { name?: string }).name ?? '') + : '' + const next = name === 'visit' ? 'visit' : 'time' + this.switchDimension(next) + }) + } + + async render(biz: tt4b.limit.Item) { + const firstRender = !this.lastBizOption + if (firstRender) { + const hasTimeLimit = !!biz.time || !!biz.weekly + const hasVisitLimit = !!biz.count || !!biz.weeklyCount + if (!hasTimeLimit && hasVisitLimit) this.dimension = 'visit' + } + await super.render(biz) + } + + private switchDimension(next: LimitDimension) { + if (this.dimension === next) return + this.dimension = next + this.lastBizOption && void this.render(this.lastBizOption) + } + + private builtLimitPart(time: [number, number | undefined], visit: [number, number | undefined], leftPos: string): ChartPart { + const noLimitText = t(msg => msg.content.limit.noLimit) + + const [timeUsed, timeLimit] = time + const timeMax = timeLimit ? timeLimit * MILL_PER_SECOND : 0 + const timeLabel = timeMax + ? t(msg => msg.content.limit.remain, { remaining: formatPeriodCommon(timeMax - timeUsed, true) }) + : noLimitText + const timeOpts: GaugeOptions = { + name: 'time', center: leftPos, + usage: { limit: timeMax, used: timeUsed }, + color: timeGaugeColor(), + } + + const [visitUsed, visitLimit] = visit + const visitLabel = visitLimit + ? t(msg => msg.shared.limit.visits, { n: `${visitUsed}/${visitLimit}` }) + : noLimitText + const visitOpts: GaugeOptions = { + name: 'visit', center: leftPos, + usage: { limit: visitLimit ?? 0, used: visitUsed ?? 0 }, + color: visitGaugeColor(), + } + + return { + titles: [ + createInfo(this.dimension === 'visit' ? visitLabel : timeLabel, leftPos), + ], + series: [createGauge(timeOpts), createGauge(visitOpts)], + } + } + + private buildDailyPart(biz: tt4b.limit.Item, leftPos: string): ChartPart { + const { time, waste, visit, count } = biz + const basePart = this.builtLimitPart([waste, time], [visit, count], leftPos) + basePart.titles.push(createTitle(t(msg => msg.shared.limit.daily), leftPos)) + return basePart + } + + private buildWeeklyPart(biz: tt4b.limit.Item, rightPos: string): ChartPart { + const { weekly = 0, weeklyWaste, weeklyVisit: weeklyVisitCount, weeklyCount = 0 } = biz + const basePart = this.builtLimitPart([weeklyWaste, weekly], [weeklyVisitCount, weeklyCount], rightPos) + basePart.titles.push(createTitle(t(msg => msg.shared.limit.weekly), rightPos)) + return basePart + } + + private buildPeriodPart(periods: tt4b.limit.Period[] | undefined, leftPos: string): ChartPart { + if (!periods?.length) return { titles: [], series: [] } + const now = new Date() + const nowMinutes = now.getHours() * 60 + now.getMinutes() + + const blocked = [...periods] + .map(([s, e]) => ({ start: clamp(s, 0, MINUTES_PER_DAY), end: clamp(e, 0, MINUTES_PER_DAY) })) + .filter(({ start, end }) => start < end) + .sort((a, b) => a.start - b.start) + + const fmt = (m: number) => + `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}` + + // Gauge axisLine color segments (cumulative ratios 0→1) + const gaugeColors: [number, string][] = [] + let last = 0 + for (const { start, end } of blocked) { + if (start > last) gaugeColors.push([start / MINUTES_PER_DAY, GAUGE_BG_COLOR]) + gaugeColors.push([end / MINUTES_PER_DAY, blockedPeriodColor()]) + last = end + } + if (last < MINUTES_PER_DAY) gaugeColors.push([1, GAUGE_BG_COLOR]) + + // Transparent pie as tooltip event layer (invisible, same ring area) + type PieItem = { value: number; name: string; itemStyle: { color: string } } + const firstStart = blocked[0]?.start ?? 0 + const lastEnd = blocked[blocked.length - 1]?.end ?? 0 + const startAngle = 90 - (lastEnd / MINUTES_PER_DAY) * 360 + const pieData: PieItem[] = [ + { value: MINUTES_PER_DAY - lastEnd + firstStart, name: '', itemStyle: { color: 'rgba(0,0,0,0)' } }, + ] + for (let i = 0; i < blocked.length; i++) { + const item = blocked[i] + if (!item) continue + const { start, end } = item + pieData.push({ value: end - start, name: `${fmt(start)} - ${fmt(end)}`, itemStyle: { color: 'rgba(0,0,0,0)' } }) + const nextStart = blocked[i + 1]?.start + if (nextStart !== undefined && nextStart > end) { + pieData.push({ value: nextStart - end, name: '', itemStyle: { color: 'rgba(0,0,0,0)' } }) + } + } + + const hitPeriod = blocked.find(({ start, end }) => nowMinutes >= start && nowMinutes < end) + const infoText = hitPeriod + ? `${fmt(hitPeriod.start)} - ${fmt(hitPeriod.end)}` + : t(msg => msg.content.limit.notHit) + + return { + titles: [ + createTitle(t(msg => msg.shared.limit.period), leftPos), + createInfo(infoText, leftPos), + ], + series: [ + { + type: 'gauge', + center: [leftPos, '50%'], + radius: '45%', + startAngle: 90, + endAngle: -270, + min: 0, + max: MINUTES_PER_DAY, + splitNumber: 4, + axisTick: { + splitNumber: 6, + length: 3, + lineStyle: { color: getSecondaryTextColor(), width: 1 }, + }, + axisLabel: { + distance: 10, + fontSize: 11, + color: getSecondaryTextColor(), + formatter: (val: number) => val === MINUTES_PER_DAY ? '' : String(val / 60), + }, + axisLine: { lineStyle: { width: 8, color: gaugeColors } }, + pointer: { + length: '50%', + width: 2, + itemStyle: { color: clockPointerColor() }, + }, + detail: { show: false }, + data: [{ value: nowMinutes }], + silent: true, + } as GaugeSeriesOption, + { + type: 'pie', + center: [leftPos, '50%'], + radius: ['37%', '45%'], + startAngle, + label: { show: false }, + emphasis: { scale: false }, + itemStyle: { borderWidth: 0 }, + tooltip: { formatter: ({ name }) => name }, + data: pieData.filter(d => d.value > 0), + } as PieSeriesOption, + ], + } + } + + protected generateOption(biz: tt4b.limit.Item): Awaitable<EcOption> { + const hasPeriods = !!biz.periods?.length + const leftPos = hasPeriods ? '17.5%' : '32%' + const centerPos = hasPeriods ? '50%' : undefined + const rightPos = hasPeriods ? '82.5%' : '68%' + + const period = this.buildPeriodPart(biz.periods, leftPos) + const daily = this.buildDailyPart(biz, centerPos ?? leftPos) + const weekly = this.buildWeeklyPart(biz, rightPos) + + const inactiveColor = getInfoColor() + + return { + title: [...period.titles, ...daily.titles, ...weekly.titles], + tooltip: { show: true }, + legend: { + orient: 'horizontal', + bottom: '2%', + left: 'center', + icon: 'roundRect', + itemWidth: 20, + itemHeight: 12, + itemGap: 16, + selectedMode: 'single', + inactiveColor, + formatter: () => '', + tooltip: { show: false }, + selected: { time: this.dimension === 'time', visit: this.dimension === 'visit' }, + data: [ + { name: 'time' satisfies LimitDimension, itemStyle: { color: timeGaugeColor() } }, + { name: 'visit' satisfies LimitDimension, itemStyle: { color: visitGaugeColor() } }, + ], + }, + series: [...period.series, ...daily.series, ...weekly.series], + } + } +} + +export default Wrapper \ No newline at end of file diff --git a/src/pages/popup/components/Limit/Content/index.tsx b/src/pages/popup/components/Limit/Content/index.tsx new file mode 100644 index 000000000..3667d82e0 --- /dev/null +++ b/src/pages/popup/components/Limit/Content/index.tsx @@ -0,0 +1,24 @@ +import Flex from '@pages/components/Flex' +import { useLimitSummary } from '@popup/context' +import { t } from '@popup/locale' +import { ElResult } from 'element-plus' +import { computed, defineComponent, type FunctionalComponent } from 'vue' +import Chart from './Chart' + +const Empty: FunctionalComponent<{}> = () => ( + <Flex column align="center" justify="center" height='100%' gap={20}> + <ElResult + icon='info' + title={t(msg => msg.content.limit.noData)} + /> + </Flex> +) + +const Content = defineComponent<{}>(() => { + const { summary, selected } = useLimitSummary() + const item = computed(() => summary.value?.items.find(i => i.id === selected.value)) + + return () => item.value ? <Chart item={item.value} /> : <Empty /> +}) + +export default Content \ No newline at end of file diff --git a/src/pages/popup/components/Limit/Summary.tsx b/src/pages/popup/components/Limit/Summary.tsx new file mode 100644 index 000000000..9c3abad3c --- /dev/null +++ b/src/pages/popup/components/Limit/Summary.tsx @@ -0,0 +1,54 @@ +import Flex from '@pages/components/Flex' +import Img from '@pages/components/Img' +import { useLimitSummary } from '@popup/context' +import { ElText } from 'element-plus' +import { computed, defineComponent, StyleValue } from 'vue' + +const TITLE_SIZE = 24 + +const Summary = defineComponent<{}>(() => { + const { summary } = useLimitSummary() + const site = computed(() => { + const site = summary.value?.site + if (!site) return '' + const { alias, host } = site + return alias ? `${alias} (${host})` : host + }) + + const withoutSearch = computed(() => { + const original = summary.value?.url + if (!original) return undefined + try { + const u = new URL(original) + return u.origin + u.pathname + } catch { + return original + } + }) + + return () => ( + <Flex + column justify='center' align='center' gap={10} + style={{ marginTop: '20px', marginBottom: '20px' }} + > + <Flex align='center' justify='center' gap={12}> + <Img src={summary.value?.site.iconUrl} size={TITLE_SIZE} /> + <ElText style={{ fontSize: `${TITLE_SIZE}px` } satisfies StyleValue} > + {site.value} + </ElText> + </Flex> + <ElText + size='large' + style={{ + wordBreak: 'break-all', + textAlign: 'center', + lineHeight: '1.4em', + } satisfies StyleValue} + > + {withoutSearch.value} + </ElText> + </Flex> + ) +}) + +export default Summary \ No newline at end of file diff --git a/src/pages/popup/components/Limit/index.tsx b/src/pages/popup/components/Limit/index.tsx new file mode 100644 index 000000000..7949e4f2b --- /dev/null +++ b/src/pages/popup/components/Limit/index.tsx @@ -0,0 +1,18 @@ +import Flex from '@pages/components/Flex' +import { ElCard } from 'element-plus' +import type { FunctionalComponent, StyleValue } from 'vue' +import Content from './Content' +import Summary from './Summary' + +const Limit: FunctionalComponent<{}> = () => ( + <ElCard shadow='never' style={{ width: '100%' } satisfies StyleValue}> + <Flex column width='100%' height='100%'> + <Summary /> + <Content /> + </Flex> + </ElCard> +) + +Limit.displayName = 'Limit' + +export default Limit diff --git a/src/pages/popup/components/Percentage/Cate/Wrapper.ts b/src/pages/popup/components/Percentage/Cate/Wrapper.ts index 05cc3cecf..989ca941c 100644 --- a/src/pages/popup/components/Percentage/Cate/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Cate/Wrapper.ts @@ -1,21 +1,17 @@ -import { EchartsWrapper } from "@hooks/useEcharts" +import { listAllCategories } from "@api/sw/cate" +import { EchartsWrapper } from '@hooks' import { getInfoColor, getPrimaryTextColor } from "@pages/util/style" -import { t } from "@popup/locale" -import cateService from "@service/cate-service" -import { mergeDate } from "@service/stat-service/merge/date" +import { t } from '@popup/locale' import { toMap } from "@util/array" import { CATE_NOT_SET_ID } from "@util/site" import { isCate } from "@util/stat" -import { type PieSeriesOption } from "echarts/charts" +import type { + ComposeOption, LegendComponentOption, PieSeriesOption, TitleComponentOption, ToolboxComponentOption, + TooltipComponentOption, +} from "echarts" +import type { ECElementEvent } from "echarts/core" import { - type LegendComponentOption, - type TitleComponentOption, - type ToolboxComponentOption, - type TooltipComponentOption, -} from "echarts/components" -import { type ComposeOption, type ECElementEvent } from "echarts/core" -import { - adaptDonutSeries, formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolboxOption, + adaptDonutSeries, formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolbox, handleClick, isOther, type PieSeriesItemOption, } from "../chart" import { type PercentageResult } from "../query" @@ -87,14 +83,14 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti const { rows, query, donutChart } = result const { dimension } = query - const selected: timer.stat.Row | undefined = this.selectedCache + const selected: tt4b.stat.Row | undefined = this.selectedCache ? rows.filter(isCate).filter(r => r.cateKey === this.selectedCache)[0] : undefined const textColor = getPrimaryTextColor() const inactiveColor = getInfoColor() - const cates = await cateService.listAll() + const cates = await listAllCategories() const cateNameMap = toMap(cates, c => c.id, c => c.name) cateNameMap[CATE_NOT_SET_ID] = t(msg => msg.shared.cate.notSet) @@ -107,7 +103,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti textStyle: { color: textColor }, pageTextStyle: { color: textColor }, inactiveColor, - data: rows.filter(isCate).map(({ cateKey }) => ({ name: cateNameMap[cateKey] ?? `${cateKey}`, cateKey })), + data: rows.filter(isCate).map(({ cateKey }) => ({ name: String(cateNameMap[cateKey] ?? cateKey), cateKey })), } const series: PieSeriesOption[] = [{ @@ -119,7 +115,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti data: rows.filter(isCate).map(row => ({ value: row[dimension], row, selected: row.cateKey === selected?.cateKey, - name: cateNameMap[row.cateKey ?? ''], + name: String(cateNameMap[row.cateKey ?? ''] ?? ''), } satisfies PieSeriesItemOption)), emphasis: { itemStyle: { @@ -139,8 +135,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti ...adaptDonutSeries(donutChart, selected ? '30%' : '55%', selected ? 0.3 : undefined), }] if (selected) { - let mergedRows = (selected?.mergedRows || []).sort((a, b) => (b[dimension] ?? 0) - (a[dimension] ?? 0)) - mergedRows = mergeDate(mergedRows) + let mergedRows = (selected.mergedRows ?? []).sort((a, b) => b[dimension] - a[dimension]) const siteSeries = generateSiteSeriesOption(mergedRows, result, { center: ['60%', '58%'], @@ -163,7 +158,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti series.push(siteSeries) } - const titleSuffix = this.selectedCache && this.selectedCache !== CATE_NOT_SET_ID ? cateNameMap[this.selectedCache] : undefined + const titleSuffix: string | undefined = this.selectedCache && this.selectedCache !== CATE_NOT_SET_ID ? cateNameMap[this.selectedCache] : undefined const option: EcOption = { title: generateTitleOption(result, titleSuffix), legend, @@ -172,7 +167,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti formatter: (params: any) => formatTooltip(result, params), }, series, - toolbox: generateToolboxOption(), + toolbox: generateToolbox(() => this.instance), } return option } diff --git a/src/pages/popup/components/Percentage/Cate/index.tsx b/src/pages/popup/components/Percentage/Cate/index.tsx index cf3e31880..b03a78dc5 100644 --- a/src/pages/popup/components/Percentage/Cate/index.tsx +++ b/src/pages/popup/components/Percentage/Cate/index.tsx @@ -1,5 +1,5 @@ -import { useEcharts } from "@hooks/useEcharts" import { usePopupContext } from "@popup/context" +import { useEcharts } from '@hooks' import { defineComponent, toRef } from "vue" import { type PercentageResult } from "../query" import Wrapper from "./Wrapper" diff --git a/src/pages/popup/components/Percentage/Site/Wrapper.ts b/src/pages/popup/components/Percentage/Site/Wrapper.ts index c7676efed..7a20a246c 100644 --- a/src/pages/popup/components/Percentage/Site/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Site/Wrapper.ts @@ -1,16 +1,13 @@ -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from '@hooks' import { getInfoColor, getPrimaryTextColor } from "@pages/util/style" -import { type PieSeriesOption } from "echarts/charts" -import { - type LegendComponentOption, - type TitleComponentOption, - type ToolboxComponentOption, - type TooltipComponentOption, -} from "echarts/components" -import { type ComposeOption, type ECElementEvent } from "echarts/core" +import type { + ComposeOption, LegendComponentOption, PieSeriesOption, TitleComponentOption, ToolboxComponentOption, + TooltipComponentOption, +} from "echarts" +import type { ECElementEvent } from "echarts/core" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" import { - formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolboxOption, handleClick, + formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolbox, handleClick, type PieSeriesItemOption, } from "../chart" import { type PercentageResult } from "../query" @@ -26,7 +23,7 @@ type EcOption = ComposeOption< const maxWidth = 750 function calcPositionOfTooltip(container: HTMLElement, point: (number | string)[]) { - let p: number | string = point[0] + const p = point[0] ?? 0 const pN: number = typeof p === 'number' ? p : Number.parseFloat(p) const tooltip = container.children.item(1) as HTMLDivElement let tooltipWidth = 0 @@ -58,7 +55,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti }) } - protected generateOption(result: PercentageResult): EcOption | Promise<EcOption> { + protected generateOption(result: PercentageResult): Awaitable<EcOption> { this.resultCache = result if (!result) return {} @@ -92,7 +89,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti inactiveColor, }, series, - toolbox: generateToolboxOption(), + toolbox: generateToolbox(() => this.instance), } return options } diff --git a/src/pages/popup/components/Percentage/Site/index.tsx b/src/pages/popup/components/Percentage/Site/index.tsx index 565cd9f41..5db032974 100644 --- a/src/pages/popup/components/Percentage/Site/index.tsx +++ b/src/pages/popup/components/Percentage/Site/index.tsx @@ -1,5 +1,5 @@ -import { useEcharts } from "@hooks/useEcharts" import { usePopupContext } from "@popup/context" +import { useEcharts } from '@hooks' import { defineComponent, toRef } from "vue" import { type PercentageResult } from "../query" import Wrapper from "./Wrapper" diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index c0db8ffd0..d845451d5 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -1,16 +1,20 @@ +import { getIconUrl, getRuntimeName } from "@api/chrome/runtime" import { createTab } from "@api/chrome/tab" +import { generateQrCanvas } from "@pages/util/qrcode" import { getCssVariable, getInfoColor, getPrimaryTextColor, getSecondaryTextColor } from "@pages/util/style" import { calJumpUrl } from "@popup/common" -import { t } from "@popup/locale" +import { t } from '@popup/locale' import { sum, toMap } from "@util/array" import { IS_SAFARI } from "@util/constant/environment" +import { INSTALL_PAGE } from "@util/constant/url" import { isRtl } from "@util/document" import { generateSiteLabel } from "@util/site" import { getGroupName, isGroup, isSite } from "@util/stat" import { formatPeriodCommon, formatTime, parseTime } from "@util/time" -import { type PieSeriesOption } from "echarts/charts" -import { type TitleComponentOption, type ToolboxComponentOption } from "echarts/components" -import { type CallbackDataParams, type TopLevelFormatterParams } from "echarts/types/dist/shared" +import type { PieSeriesOption, TitleComponentOption, ToolboxComponentOption } from "echarts" +import type { ECharts } from "echarts/core" +import type { CallbackDataParams, TopLevelFormatterParams } from "echarts/types/dist/shared" +import { ElMessage } from "element-plus" import { type PercentageResult } from "./query" function combineDate(start: Date, end: Date, format: string): string { @@ -23,10 +27,7 @@ function combineDate(start: Date, end: Date, format: string): string { const sy = start.getFullYear() const ey = end.getFullYear() - if (sy !== ey) { - // Different years - return normalStr - } + if (sy !== ey) return normalStr // The same years const execRes = /({d}|{m})[^{}]*({d}|{m})/.exec(format) @@ -47,19 +48,19 @@ function formatDateStr(date: Date | [Date, Date?] | undefined, dataDate: [string const format = t(msg => msg.calendar.dateFormat) if (!date) { - date = dataDate?.map(parseTime) as [Date, Date] + const start = parseTime(dataDate[0]) + const end = parseTime(dataDate[1]) + date = start ? [start, end] : undefined } if (!date) return '' - if (!Array.isArray(date)) { - // Single day - return formatTime(date, format) - } + // Single day + if (!Array.isArray(date)) return formatTime(date, format) const [start, end] = date return end ? combineDate(start, end, format) : formatTime(start, format) } -function formatTotalStr(rows: timer.stat.Row[], type: timer.core.Dimension | undefined): string { +function formatTotalStr(rows: tt4b.stat.Row[], type: tt4b.core.Dimension | undefined): string { if (type === 'focus') { const total = sum(rows.map(r => r?.focus ?? 0)) const totalTime = formatPeriodCommon(total) @@ -85,7 +86,7 @@ function calculateSubTitleText(result: PercentageResult): string { // Don't show averages for single-day durations (today/yesterday) const isSingleDay = duration === 'today' || duration === 'yesterday' - if (dateLength && dateLength > 0 && !isSingleDay) { // Changed: removed dimension check + if (dateLength && dateLength > 0 && !isSingleDay) { if (dimension === 'focus') { // Average time per day const total = sum(rows.map(r => r?.focus ?? 0)) @@ -115,40 +116,168 @@ export function generateTitleOption(result: PercentageResult, suffix?: string): } } -export function generateToolboxOption(): ToolboxComponentOption { +function snapshotChart(chart: ECharts, backgroundColor: string, connectedBackgroundColor: string): string { + const pixelRatio = 7 + const isSvg = chart.getZr().painter.getType() === 'svg' + return chart.getConnectedDataURL({ + type: isSvg ? 'svg' : 'png', + pixelRatio, + backgroundColor, + connectedBackgroundColor, + excludeComponents: ['toolbox'], + }) +} + +async function getIconDataUrl(): Promise<string> { + const res = await fetch(getIconUrl()) + if (!res.ok) throw new Error(`Failed to fetch extension icon: ${res.status}`) + const blob = await res.blob() + const type = blob.type || 'image/png' + const bytes = new Uint8Array(await blob.arrayBuffer()) + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!) + return `data:${type};base64,${btoa(binary)}` +} + +async function saveWithWatermark(instance: ECharts) { + const bgColor = getCssVariable('--el-card-bg-color', '.el-card')?.trim() ?? '#fff' + const footerBgColor = getCssVariable('--el-fill-color-light')?.trim() ?? bgColor + const textColor = getPrimaryTextColor()?.trim() ?? '#000' + const subTextColor = getSecondaryTextColor()?.trim() ?? '#888' + + const chartDataUrl = snapshotChart(instance, bgColor, footerBgColor) + + const chartImg = await new Promise<HTMLImageElement>((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = reject + img.src = chartDataUrl + }) + + const w = chartImg.width + const footerHeight = Math.round(w * 0.14) + const padding = Math.round(footerHeight * 0.15) + const qrSize = footerHeight - padding * 2 + const nameFontSize = Math.round(footerHeight * 0.28) + const subFontSize = Math.round(footerHeight * 0.18) + + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = chartImg.height + footerHeight + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('2D canvas context unavailable') + + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(chartImg, 0, 0) + + ctx.fillStyle = footerBgColor + ctx.fillRect(0, chartImg.height, w, footerHeight) + + const footerTop = chartImg.height + + const qrCanvas = generateQrCanvas({ text: INSTALL_PAGE, size: qrSize }) + const qrX = w - padding - qrCanvas.width + const qrY = footerTop + Math.round((footerHeight - qrCanvas.height) / 2) + ctx.drawImage(qrCanvas, qrX, qrY) + + const iconSize = Math.round(footerHeight * 0.5) + const iconY = footerTop + Math.round((footerHeight - iconSize) / 2) + const iconSrc = await getIconDataUrl() + const iconImg = await new Promise<HTMLImageElement>((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = reject + img.src = iconSrc + }) + ctx.drawImage(iconImg, padding, iconY, iconSize, iconSize) + + const textX = padding + iconSize + Math.round(footerHeight * 0.1) + ctx.textBaseline = 'middle' + ctx.fillStyle = textColor + ctx.font = `bold ${nameFontSize}px sans-serif` + ctx.fillText(getRuntimeName(), textX, footerTop + Math.round(footerHeight * 0.35)) + + ctx.font = `${subFontSize}px sans-serif` + ctx.fillStyle = subTextColor + const tipText = t(msg => msg.content.percentage.installTip) + ctx.fillText(tipText, textX, footerTop + Math.round(footerHeight * 0.68)) + + const link = document.createElement('a') + link.hidden = true + link.download = 'Time_Tracker_Percentage.png' + link.href = canvas.toDataURL('image/png') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +const MY_SAVE_ICON = ` + path://M812.333229 702.996063c-46.845072 0-89.466408 18.430848-119.288544 51.132804 + L319.564027 536.79845a103.801512 103.801512 0 0 0 0-51.132804l373.480658-215.858509 + c29.822136 32.63796 72.443472 52.540716 119.288544 52.540716 90.87432-1.407912 + 159.094057-69.563652 160.501969-160.437972C971.491282 71.03556 903.271546 1.407912 + 812.333229 0c-90.87432 1.407912-160.437973 71.03556-161.90988 161.909881 0 9.91938 + 1.471908 19.83876 2.87982 29.822136L282.638335 404.774702A160.629961 160.629961 0 0 0 + 161.941879 350.698081C71.067558 352.169989 1.43991 420.453722 0.031998 511.328042 + c1.407912 90.87432 71.03556 159.030061 161.909881 160.501969 46.845072 0 90.87432 + -21.310668 120.696456-54.012625l370.664834 214.450597c-1.407912 9.983376-2.815824 + 19.83876-2.815824 31.230048 1.407912 90.87432 71.03556 159.094057 161.90988 160.501969 + 90.87432-1.407912 159.030061-69.563652 160.437973-160.501969-1.407912-90.87432 + -69.563652-159.030061-160.501969-160.437972z +`.replace(/\s+/g, ' ').trim() + +export function generateToolbox(getInstance: () => ECharts | undefined): ToolboxComponentOption { + const toolboxIconColor = getPrimaryTextColor()?.trim() ?? '#5c6b7a' + return { show: true, top: 5, right: 5, + iconStyle: { + color: toolboxIconColor, + borderColor: toolboxIconColor, + borderWidth: 0, + }, + emphasis: { + iconStyle: { + color: toolboxIconColor, + borderColor: toolboxIconColor, + borderWidth: 0, + }, + }, feature: { - saveAsImage: { + mySave: { show: true, - title: t(msg => msg.content.percentage.saveAsImageTitle), - // file name - name: 'Time_Tracker_Percentage', - excludeComponents: ['toolbox'], - pixelRatio: 7, - backgroundColor: getCssVariable('--el-card-bg-color', '.el-card'), + title: t(msg => msg.content.percentage.shareTitle), + icon: MY_SAVE_ICON, + onclick: () => { + const inst = getInstance() + if (!inst) return + inst && void saveWithWatermark(inst).catch(err => { + console.info(err) + ElMessage.error('Could not save the image.') + }) + }, }, - } + }, } } -type OtherRow = Record<Exclude<timer.core.Dimension, 'run'>, number> & { +type OtherRow = Record<Exclude<tt4b.core.Dimension, 'run'>, number> & { other: true count: number } -type ChartRow = timer.stat.Row | OtherRow +type ChartRow = tt4b.stat.Row | OtherRow export const isOther = (row: ChartRow): row is OtherRow => 'other' in row -function cvt2ChartRows(rows: timer.stat.Row[], dimension: Exclude<timer.core.Dimension, 'run'>, itemCount: number): ChartRow[] { - rows = rows.filter(item => !!item[dimension]).sort((a, b) => (b[dimension] ?? 0) - (a[dimension] ?? 0)) +function cvt2ChartRows(rows: tt4b.stat.Row[], dimension: Exclude<tt4b.core.Dimension, 'run'>, itemCount: number): ChartRow[] { + const sorted = rows.filter(item => !!item[dimension]).sort((a, b) => (b[dimension] ?? 0) - (a[dimension] ?? 0)) const popupRows: ChartRow[] = [] const other: OtherRow = { focus: 0, time: 0, count: 0, other: true } - for (let i = 0; i < rows.length; i++) { - const row = rows[i] + sorted.forEach((row, i) => { if (i < itemCount) { popupRows.push(row) } else { @@ -156,8 +285,8 @@ function cvt2ChartRows(rows: timer.stat.Row[], dimension: Exclude<timer.core.Dim other.time += row.time other.count++ } - } - other.count && popupRows.push(other) + }) + if (other.count) popupRows.push(other) return popupRows } @@ -186,17 +315,9 @@ const legend2LabelStyle = (legend: string): string => { return code.join('') } -type CallbackFormat = { - name: string - value: number - data: PieSeriesItemOption - percent: number -} - function formatLabel(params: CallbackDataParams, groupMap: Record<number, chrome.tabGroups.TabGroup>): string { - const format = (Array.isArray(params) ? params[0] : params) as CallbackFormat - const { name, data } = format || {} - const { row } = (data as PieSeriesItemOption) || {} + const { name, data } = params + const { row } = data as PieSeriesItemOption ?? {} if (!row) return 'NaN' if (isOther(row)) { @@ -220,7 +341,11 @@ type CustomOption = Pick< 'center' | 'radius' | 'selectedMode' | 'minShowLabelAngle' > -export function generateSiteSeriesOption(rows: timer.stat.Row[], result: PercentageResult, customOption: CustomOption): PieSeriesOption { +export function generateSiteSeriesOption( + rows: tt4b.stat.Row[], + result: PercentageResult, + customOption: CustomOption, +): PieSeriesOption { const { displaySiteName, query: { dimension }, itemCount, groups, donutChart } = result const groupMap = toMap(groups, g => g.id) @@ -231,12 +356,14 @@ export function generateSiteSeriesOption(rows: timer.stat.Row[], result: Percent if (isOther(row)) { item.itemStyle = { color: getInfoColor() } item.name = t(msg => msg.content.percentage.otherLabel, { count: row.count }) - } else if (isSite(row)) { - const { siteKey, alias, iconUrl } = row - const { host } = siteKey || {} + } else if (!isOther(row) && isSite(row as tt4b.stat.StatKey)) { + const { siteKey, alias, iconUrl } = row as tt4b.stat.SiteRow + const { host, type } = siteKey ?? {} const name = item.name = (displaySiteName ? (alias ?? host) : host) ?? '' const richValue: PieLabelRichValueOption = { ...BASE_LABEL_RICH_VALUE } - iconUrl && (richValue.backgroundColor = { image: iconUrl }) + if (type === 'normal' && iconUrl && !IS_SAFARI) { + richValue.backgroundColor = { image: iconUrl } + } iconRich[legend2LabelStyle(name)] = richValue } else if (isGroup(row)) { item.name = getGroupName(groupMap, row) @@ -301,7 +428,6 @@ function calcRealRadius( if (!radius) return radius if (Array.isArray(radius)) return radius if (typeof radius === 'number') return [radius * donutRadiusRatio, radius] - // String try { const percent = parseFloat(radius.replace('%', '')) const inner = percent * donutRadiusRatio @@ -311,7 +437,7 @@ function calcRealRadius( } } -function calculateAverageText(type: timer.core.Dimension, averageValue: number): string | undefined { +function calculateAverageText(type: tt4b.core.Dimension, averageValue: number): string | undefined { if (type === 'focus') { return t(msg => msg.content.percentage.averageTime, { value: formatPeriodCommon(parseInt(averageValue.toFixed(0))) }) } else if (type === 'time') { @@ -328,7 +454,7 @@ export function formatTooltip({ query, dateLength }: PercentageResult, params: T const { name, value, percent, data } = format ?? {} const { row } = data as PieSeriesItemOption const { dimension, duration } = query - const itemValue = typeof value === 'number' ? value as number : 0 + const itemValue = typeof value === 'number' ? value : 0 let valueLine = dimension === 'time' ? itemValue : formatPeriodCommon(itemValue) // Display percent only when query focus time dimension === 'focus' && (valueLine += ` (${percent}%)`) @@ -342,7 +468,7 @@ export function formatTooltip({ query, dateLength }: PercentageResult, params: T // Don't show averages for single-day durations (today/yesterday) const isSingleDay = duration === 'today' || duration === 'yesterday' - if (dateLength && dateLength > 1 && !isSingleDay) { // Changed: simplified condition + if (dateLength && dateLength > 1 && !isSingleDay) { averageLine = calculateAverageText(dimension, itemValue / dateLength) } } @@ -352,7 +478,7 @@ export function formatTooltip({ query, dateLength }: PercentageResult, params: T /** * Handle click */ -export function handleClick(data: PieSeriesItemOption, date: PercentageResult['date'], type: timer.core.Dimension): void { +export function handleClick(data: PieSeriesItemOption, date: PercentageResult['date'], type: tt4b.core.Dimension): void { const { row } = data if (isOther(row)) { return diff --git a/src/pages/popup/components/Percentage/index.tsx b/src/pages/popup/components/Percentage/index.tsx index d7b0fa614..c2c8edd3a 100644 --- a/src/pages/popup/components/Percentage/index.tsx +++ b/src/pages/popup/components/Percentage/index.tsx @@ -1,17 +1,11 @@ -import { useRequest } from "@hooks/useRequest" +import { useRequest } from "@hooks" import { useOption, useQuery } from "@popup/context" -import { PieChart } from "echarts/charts" -import { AriaComponent, LegendComponent, TitleComponent, ToolboxComponent, TooltipComponent } from "echarts/components" -import { use } from "echarts/core" -import { CanvasRenderer } from "echarts/renderers" import { ElCard } from "element-plus" import { defineComponent } from "vue" import Cate from "./Cate" import { doQuery } from "./query" import Site from "./Site" -use([CanvasRenderer, PieChart, AriaComponent, LegendComponent, TitleComponent, TooltipComponent, ToolboxComponent]) - const Percentage = defineComponent(() => { const query = useQuery() const option = useOption() diff --git a/src/pages/popup/components/Percentage/query.ts b/src/pages/popup/components/Percentage/query.ts index 2b1a05649..e7ad43f48 100644 --- a/src/pages/popup/components/Percentage/query.ts +++ b/src/pages/popup/components/Percentage/query.ts @@ -1,12 +1,12 @@ import { listAllGroups } from "@api/chrome/tabGroups" import { queryRows, } from "@popup/common" -import type { PopupOption, PopupQuery } from "@popup/context" -import { t } from "@popup/locale" -import { getDayLength } from "@util/time" +import { t } from '@popup/locale' +import type { PopupOption, PopupQuery } from "@popup/types" +import { getBirthday, getDayLength } from "@util/time" export type PercentageResult = { query: PopupQuery - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] // Actually date range according to duration date: Date | [Date, Date?] | undefined displaySiteName: boolean @@ -18,7 +18,7 @@ export type PercentageResult = { donutChart: boolean } -const findAllDates = (row: timer.stat.Row): Set<string> => { +const findAllDates = (row: tt4b.stat.Row): Set<string> => { const set = new Set<string>() const { date, mergedDates } = row date && set.add(date) @@ -30,7 +30,7 @@ const findAllDates = (row: timer.stat.Row): Set<string> => { return set } -const findDateRange = (rows: timer.stat.Row[]): [string, string] | undefined => { +const findDateRange = (rows: tt4b.stat.Row[]): [string, string] | undefined => { const set = new Set<string>() rows?.forEach(row => { const dates = findAllDates(row) @@ -59,7 +59,7 @@ export const doQuery = async (query: PopupQuery, option: PopupOption): Promise<P dates.forEach(d => allDatesSet.add(d)) }) const dateLength = allDatesSet.size > 0 ? allDatesSet.size - : (Array.isArray(date) ? getDayLength(date[0], date[1] ?? new Date()) : 1) + : (Array.isArray(date) ? getDayLength(date[0] ?? getBirthday(), date[1] ?? new Date()) : 1) return { query, rows, diff --git a/src/pages/popup/components/Ranking/Item.tsx b/src/pages/popup/components/Ranking/Item.tsx index 3b9f35c08..d4c4e7a87 100644 --- a/src/pages/popup/components/Ranking/Item.tsx +++ b/src/pages/popup/components/Ranking/Item.tsx @@ -1,15 +1,15 @@ import { createTab } from "@api/chrome/tab" -import { cvtGroupColor } from "@api/chrome/tabGroups" -import TooltipWrapper from "@app/components/common/TooltipWrapper" import { Mouse, Timer } from "@element-plus/icons-vue" -import { useTabGroups } from "@hooks/useTabGroups" +import { useTabGroups } from "@hooks" import Flex from "@pages/components/Flex" +import TooltipWrapper from '@pages/components/TooltipWrapper' +import { cvtGroupColor } from '@pages/util/style' import { calJumpUrl } from "@popup/common" import { useCateNameMap, useQuery } from "@popup/context" -import { t } from "@popup/locale" +import { t } from '@popup/locale' import { isRemainHost } from "@util/constant/remain-host" import { getGroupName, getIconUrl, isCate, isGroup, isNormalSite, isSite } from "@util/stat" -import { formatPeriodCommon } from "@util/time" +import { DateRange, formatPeriodCommon } from "@util/time" import { ElAvatar, ElCard, ElIcon, ElLink, ElProgress, ElTag, ElText } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" @@ -65,7 +65,7 @@ const Title = defineComponent<TitleProps>(props => { ) }, { props: ['value', 'date', 'displaySiteName'] }) -const renderAvatarText = (row: timer.stat.Row, cateNameMap: Record<number, string>, groupMap: Record<number, chrome.tabGroups.TabGroup>) => { +const renderAvatarText = (row: tt4b.stat.Row, cateNameMap: Record<number, string>, groupMap: Record<number, chrome.tabGroups.TabGroup>) => { let name: string | undefined = undefined if (isGroup(row)) { name = getGroupName(groupMap, row) @@ -79,10 +79,10 @@ const renderAvatarText = (row: timer.stat.Row, cateNameMap: Record<number, strin } type ItemProps = { - value: timer.stat.Row + value: tt4b.stat.Row max?: number total?: number - date?: Date | [start: Date, end?: Date] + date?: DateRange displaySiteName?: boolean onJump?: NoArgCallback } @@ -171,7 +171,7 @@ const Item = defineComponent<ItemProps>(props => { </Flex> </Flex> </Flex> - </ElCard > + </ElCard> ) }, { props: ['date', 'displaySiteName', 'max', 'total', 'value', 'onJump'] }) diff --git a/src/pages/popup/components/Ranking/index.tsx b/src/pages/popup/components/Ranking/index.tsx index 6d728aa85..81d734b4e 100644 --- a/src/pages/popup/components/Ranking/index.tsx +++ b/src/pages/popup/components/Ranking/index.tsx @@ -1,5 +1,5 @@ -import { useRequest } from "@hooks/useRequest" import { useOption, useQuery } from "@popup/context" +import { useRequest } from "@hooks" import { ElCol, ElRow, ElScrollbar } from "element-plus" import { defineComponent } from "vue" import Item from "./Item" diff --git a/src/pages/popup/components/Ranking/query.tsx b/src/pages/popup/components/Ranking/query.tsx index a644429b7..3a43ca1f0 100644 --- a/src/pages/popup/components/Ranking/query.tsx +++ b/src/pages/popup/components/Ranking/query.tsx @@ -1,13 +1,14 @@ import { queryRows } from "@popup/common" -import type { PopupOption, PopupQuery } from "@popup/context" +import type { PopupOption, PopupQuery } from "@popup/types" import { sum } from "@util/array" +import { type DateRange } from '@util/time' -export type RankingResult = { - rows: timer.stat.Row[] +type RankingResult = { + rows: tt4b.stat.Row[] max: number total: number displaySiteName: boolean - date: Date | [Date, Date?] | undefined + date: DateRange } export const doQuery = async (query: PopupQuery, option: PopupOption): Promise<RankingResult> => { diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index 70cd3decd..4df4cc3c7 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -1,64 +1,93 @@ +import { listAllCategories } from '@api/sw/cate' +import { getLimitSummary } from '@api/sw/limit' +import { getOption, setOption } from "@api/sw/option" import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import cateService from "@service/cate-service" -import optionService from "@service/option-service" +import { isDarkMode, processDarkMode } from "@pages/util/dark-mode" import { toMap } from "@util/array" -import { isDarkMode, toggle } from "@util/dark-mode" import { CATE_NOT_SET_ID } from "@util/site" -import { reactive, type Reactive, ref, type Ref, toRaw, watch } from "vue" +import { computed, reactive, Ref, ref, type ShallowRef, toRaw, watch } from "vue" +import { useRoute, useRouter } from 'vue-router' +import { isMenu } from './common' import { t } from "./locale" - -export type PopupDuration = - | "today" | "yesterday" | "thisWeek" | "thisMonth" - | "lastDays" - | "allTime" - -export type PopupQuery = { - mergeMethod: Exclude<timer.stat.MergeMethod, 'date'> | undefined - duration: PopupDuration - durationNum?: number - dimension: Exclude<timer.core.Dimension, 'run'> -} - -export type PopupOption = { - showName: boolean - topN: number - donutChart: boolean -} +import type { PopupMenu, PopupOption, PopupQuery } from './types' type PopupContextValue = { reload: () => void - darkMode: Ref<boolean> - setDarkMode: (val: boolean) => void - query: Reactive<PopupQuery> - option: Reactive<PopupOption> - cateNameMap: Ref<Record<number, string>> + darkMode: ShallowRef<boolean> + setDarkMode: ArgCallback<boolean> + query: PopupQuery + option: PopupOption + cateNameMap: ShallowRef<Record<number, string>> + menu: ShallowRef<PopupMenu | undefined> + setMenu: ArgCallback<PopupMenu> + limitSummary: ShallowRef<tt4b.limit.Summary | undefined> + limitSummaryLoading: ShallowRef<boolean> + selectedLimit: Ref<number | undefined> +} + +const initMenu = () => { + const [stored, setStored] = useLocalStorage<PopupMenu>('popup_menu', 'percentage') + const route = useRoute() + const router = useRouter() + const myRoute = computed(() => { + const menuMaybe = route.path.substring(1) + return isMenu(menuMaybe) ? menuMaybe : stored + }) + const setMyRoute = (val: PopupMenu) => { + setStored(val) + router.push('/' + val) + } + return [myRoute, setMyRoute] as const } const NAMESPACE = '_' -export const initPopupContext = (): Ref<number> => { +export const initPopupContext = (): ShallowRef<number> => { const appKey = ref(Date.now()) const reload = () => appKey.value = Date.now() - const { data: darkMode, refresh: refreshDarkMode } = useRequest(() => optionService.isDarkMode(), { defaultValue: isDarkMode() }) + const { data: darkMode, refresh: refreshDarkMode } = useRequest(async () => { + const option = await getOption() + return processDarkMode(option) + }, { defaultValue: isDarkMode() }) const setDarkMode = async (val: boolean) => { - const option: timer.option.DarkMode = val ? 'on' : 'off' - await optionService.setDarkMode(option) - toggle(val) + await setOption({ darkMode: val ? 'on' : 'off' }) refreshDarkMode() } const { data: cateNameMap } = useRequest(async () => { - const categories = await cateService.listAll() - const result = toMap(categories ?? [], c => c.id, c => c.name) + const categories = await listAllCategories() + const result = toMap(categories, c => c.id, c => c.name) result[CATE_NOT_SET_ID] = t(msg => msg.shared.cate.notSet) return result }, { defaultValue: {} }) const query = initQuery() const option = initOption() - useProvide<PopupContextValue>(NAMESPACE, { reload, darkMode, setDarkMode, query, option, cateNameMap }) + + const [menu, setMenu] = initMenu() + + const { data: limitSummary, loading: limitSummaryLoading } = useRequest( + () => menu.value === 'limit' ? getLimitSummary() : Promise.resolve(undefined), + { + deps: menu, + onSuccess(newVal) { + const newItems = newVal?.items ?? [] + if (!newItems.some(i => i.id === selectedLimit.value)) { + selectedLimit.value = newItems[0]?.id + } + } + }, + ) + const selectedLimit = ref<number>() + + useProvide<PopupContextValue>(NAMESPACE, { + reload, darkMode, setDarkMode, query, option, + cateNameMap, + menu, setMenu, + limitSummary, limitSummaryLoading, selectedLimit, + }) return appKey } @@ -97,4 +126,13 @@ export const useQuery = () => useProvider<PopupContextValue, 'query'>(NAMESPACE, export const useOption = () => useProvider<PopupContextValue, 'option'>(NAMESPACE, 'option').option -export const useCateNameMap = () => useProvider<PopupContextValue, 'cateNameMap'>(NAMESPACE, 'cateNameMap')?.cateNameMap \ No newline at end of file +export const useCateNameMap = () => useProvider<PopupContextValue, 'cateNameMap'>(NAMESPACE, 'cateNameMap')?.cateNameMap + +export const useMenu = () => useProvider<PopupContextValue, 'menu' | 'setMenu'>(NAMESPACE, 'menu', 'setMenu') + +export const useLimitSummary = () => { + const { limitSummary: summary, limitSummaryLoading: loading, selectedLimit: selected } = useProvider<PopupContextValue, 'limitSummary' | 'limitSummaryLoading' | 'selectedLimit'>( + NAMESPACE, 'limitSummary', 'limitSummaryLoading', 'selectedLimit' + ) + return { summary, loading, selected } +} \ No newline at end of file diff --git a/src/pages/popup/index.ts b/src/pages/popup/index.ts index 819a66a7d..d0cd73333 100644 --- a/src/pages/popup/index.ts +++ b/src/pages/popup/index.ts @@ -5,14 +5,13 @@ * https://opensource.org/licenses/MIT */ +import { getOption } from "@api/sw/option" import { initLocale } from "@i18n" -import { increasePopup } from "@service/meta-service" -import optionService from "@service/option-service" -import { toggle } from "@util/dark-mode" -import "element-plus/theme-chalk/index.css" +import { processDarkMode } from '@pages/util/dark-mode' +import { initEcharts } from "@pages/util/echarts" +import type { FrameRequest, FrameResponse } from "@popup/types" import { createApp } from "vue" import Main from "./Main" -import { type FrameRequest, type FrameResponse } from "./message" import initRouter from "./router" import { injectGlobalCss } from "./style" @@ -36,11 +35,11 @@ function send2ParentWindow(data: any): Promise<void> { } async function main() { - await initLocale() + initLocale() + initEcharts() injectGlobalCss() - const isDarkMode = await optionService.isDarkMode() - toggle(isDarkMode) + getOption().then(processDarkMode) await send2ParentWindow('themeInitialized') const el = document.createElement('div') @@ -50,8 +49,6 @@ async function main() { const app = createApp(Main) initRouter(app) app.mount(el) - - increasePopup() } main() diff --git a/src/pages/popup/locale.ts b/src/pages/popup/locale.ts index f44286ccb..bc161614e 100644 --- a/src/pages/popup/locale.ts +++ b/src/pages/popup/locale.ts @@ -9,7 +9,7 @@ import { type I18nKey as _I18nKey, t as _t, tN as _tN } from "@i18n" import messages, { type PopupMessage } from "@i18n/message/popup" import type { VNode } from 'vue' -export type I18nKey = _I18nKey<PopupMessage> +type I18nKey = _I18nKey<PopupMessage> export const t = (key: I18nKey, param?: any) => _t<PopupMessage>(messages, { key, param }) diff --git a/src/pages/popup/message.ts b/src/pages/popup/message.ts deleted file mode 100644 index 5c340dade..000000000 --- a/src/pages/popup/message.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type FrameRequest = { - stamp: number - data: any -} - -export type FrameResponse = { - stamp: number -} \ No newline at end of file diff --git a/src/pages/popup/router.ts b/src/pages/popup/router.ts index b4f8bbf68..649a27c7a 100644 --- a/src/pages/popup/router.ts +++ b/src/pages/popup/router.ts @@ -1,31 +1,34 @@ +import { useLocalStorage } from '@hooks' import { type App } from "vue" -import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router" +import { createRouter, createWebHashHistory, RouteRecordRedirect, type RouteRecordSingleView } from "vue-router" +import type { PopupMenu } from './types' -export const ROUTE_PERCENTAGE = 'percentage' -const ROUTE_RANKING = 'ranking' +type Path = `/${PopupMenu}` +type MyRoute = + | (Omit<RouteRecordSingleView, 'path'> & { path: Path }) + | (Omit<RouteRecordRedirect, 'redirect'> & { redirect: Path }) -export const POPUP_ROUTES = [ROUTE_PERCENTAGE, ROUTE_RANKING] -export type PopupRoute = typeof POPUP_ROUTES[number] - -const routes: RouteRecordRaw[] = [ +const createRoutes = (stored: PopupMenu | undefined): MyRoute[] => [ { path: '/', - redirect: '/' + ROUTE_PERCENTAGE, + redirect: stored ? `/${stored}` : '/percentage', }, { - path: '/' + ROUTE_PERCENTAGE, + path: '/percentage', component: () => import('./components/Percentage'), }, { - path: '/' + ROUTE_RANKING, + path: '/ranking', component: () => import('./components/Ranking'), - } + }, { + path: '/limit', + component: () => import('./components/Limit'), + }, ] export default (app: App) => { - const router = createRouter({ - history: createWebHashHistory(), - routes, - }) - + const [stored] = useLocalStorage<PopupMenu>('popup_menu') + const routes = createRoutes(stored) + const history = createWebHashHistory() + const router = createRouter({ routes, history }) app.use(router) } \ No newline at end of file diff --git a/src/pages/popup/skeleton.ts b/src/pages/popup/skeleton.ts index f1338e105..4ea302989 100644 --- a/src/pages/popup/skeleton.ts +++ b/src/pages/popup/skeleton.ts @@ -1,5 +1,5 @@ -import { init as initTheme } from "@util/dark-mode" -import { type FrameRequest, type FrameResponse } from "./message" +import { initDarkTheme } from "@pages/util/dark-mode" +import type { FrameRequest, FrameResponse } from "@popup/types" import { injectSkeletonCss } from './style/skeleton' function injectFrame() { @@ -23,7 +23,7 @@ function injectFrame() { */ async function main() { // Calculate the latest mode - initTheme() + initDarkTheme() injectSkeletonCss() // Resize after init theme document.body.style.width = '766px' diff --git a/src/pages/popup/types.d.ts b/src/pages/popup/types.d.ts new file mode 100644 index 000000000..96a0c0b95 --- /dev/null +++ b/src/pages/popup/types.d.ts @@ -0,0 +1,28 @@ +export type FrameRequest = { + stamp: number + data: any +} + +export type FrameResponse = { + stamp: number +} + +export type PopupDuration = + | "today" | "yesterday" | "thisWeek" | "thisMonth" + | "lastDays" + | "allTime" + +export type PopupQuery = { + mergeMethod: Exclude<tt4b.stat.MergeMethod, 'date'> | undefined + duration: PopupDuration + durationNum?: number + dimension: Exclude<tt4b.core.Dimension, 'run'> +} + +export type PopupOption = { + showName: boolean + topN: number + donutChart: boolean +} + +export type PopupMenu = 'percentage' | 'ranking' | 'limit' \ No newline at end of file diff --git a/src/pages/side/Layout.tsx b/src/pages/side/Layout.tsx index f31ec8d28..f122c95ce 100644 --- a/src/pages/side/Layout.tsx +++ b/src/pages/side/Layout.tsx @@ -1,9 +1,9 @@ +import { listSiteStats } from "@api/sw/stat" import { useRequest } from "@hooks" import Flex from '@pages/components/Flex' -import { selectSite } from "@service/stat-service" -import { formatTime } from "@util/time" +import { formatTime, formatTimeYMD } from "@util/time" import { ElText } from "element-plus" -import { defineComponent, ref, type StyleValue } from "vue" +import { defineComponent, ref } from "vue" import RowList from "./components/RowList" import Search from "./components/Search" import { t } from "./locale" @@ -12,12 +12,12 @@ const _default = defineComponent<{}>(() => { const date = ref(new Date()) const query = ref('') - const { data, refresh, loading } = useRequest(() => selectSite({ - date: date.value ?? new Date(), + const { data, refresh, loading } = useRequest(() => listSiteStats({ + date: formatTimeYMD(date.value), query: query.value, sortKey: 'focus', sortDirection: 'DESC', - })) + }), { defaultValue: [] }) return () => <Flex column height='100%'> <Search @@ -39,11 +39,7 @@ const _default = defineComponent<{}>(() => { @{formatTime(date.value, t(msg => msg.calendar.dateFormat))} </ElText> </Flex> - <RowList - loading={loading.value} - data={data.value ?? []} - style={{ flex: 1, overflow: "auto" } satisfies StyleValue} - /> + <RowList loading={loading.value} data={data.value} /> </Flex> }) diff --git a/src/pages/side/components/RowList/Item.tsx b/src/pages/side/components/RowList/Item.tsx index cf9063af1..1aea3e6be 100644 --- a/src/pages/side/components/RowList/Item.tsx +++ b/src/pages/side/components/RowList/Item.tsx @@ -1,13 +1,12 @@ import { createTab } from "@api/chrome/tab" -import { useShadow } from "@hooks" import Flex from "@pages/components/Flex" import { isRemainHost } from "@util/constant/remain-host" import { getAlias, getHost, isSite } from "@util/stat" import { formatPeriodCommon } from "@util/time" import { ElAvatar, ElCard, ElLink, ElProgress, ElTag, ElText, ElTooltip } from "element-plus" -import { computed, defineComponent, type PropType } from "vue" +import { computed, defineComponent, toRef } from "vue" -const renderTitle = (siteName: string | undefined, host: string | undefined, handleJump: () => void) => { +const renderTitle = (siteName: string | undefined, host: string | undefined, handleJump: NoArgCallback) => { const text = siteName ?? host ?? '' const tooltip = siteName ? host : null const textNode = <ElLink onClick={handleJump}>{text}</ElLink> @@ -15,93 +14,90 @@ const renderTitle = (siteName: string | undefined, host: string | undefined, han return ( <ElTooltip content={tooltip} placement="top" offset={4}> {textNode} - </ElTooltip > + </ElTooltip> ) } -const renderAvatarText = (row: timer.stat.Row) => { +const renderAvatarText = (row: tt4b.stat.Row) => { const alias = getAlias(row) if (alias) return alias.substring(0, 1)?.toUpperCase?.() return getHost(row)?.substring?.(0, 1)?.toUpperCase?.() } -const _default = defineComponent({ - props: { - value: { - type: Object as PropType<timer.stat.Row>, - required: true, - }, - max: Number, - total: Number, - }, - setup(props) { - const [iconUrl] = useShadow(() => 'iconUrl' in props.value ? props.value?.iconUrl : undefined) - const [host] = useShadow(() => getHost(props.value)) - const [siteName] = useShadow(() => getAlias(props.value)) - const clickable = computed(() => host?.value && !isRemainHost(host.value)) - const [rate] = useShadow(() => { - if (!props.max) return 0 - return (props.value?.focus ?? 0) / props.max * 100 - }, 0) - const [percentage] = useShadow(() => { - if (!props.total) return '0 %' - const val = (props.value?.focus ?? 0) / props.total * 100 - return val.toFixed(2) + ' %' - }) - const handleJump = () => clickable.value && createTab("https://" + host.value) - return () => ( - <ElCard - shadow="hover" - style={{ padding: '0 10px', height: '70px', boxSizing: 'border-box' }} - bodyStyle={{ padding: '5px', display: 'flex', height: 'calc(100% - 10px)' }} +type Props = { + value: tt4b.stat.Row + max?: number + total?: number +} + +const _default = defineComponent<Props>(props => { + const row = toRef(props, 'value') + const iconUrl = computed(() => 'iconUrl' in row.value ? row.value.iconUrl : undefined) + const host = computed(() => getHost(row.value)) + const siteName = computed(() => getAlias(row.value)) + const clickable = computed(() => host.value && !isRemainHost(host.value)) + const rate = computed(() => { + if (!props.max) return 0 + return (row.value?.focus ?? 0) / props.max * 100 + }) + const percentage = computed(() => { + if (!props.total) return '0 %' + const val = (row.value?.focus ?? 0) / props.total * 100 + return val.toFixed(2) + ' %' + }) + const handleJump = () => clickable.value && createTab("https://" + host.value) + return () => ( + <ElCard + shadow="hover" + style={{ padding: '0 10px', height: '70px', boxSizing: 'border-box' }} + bodyStyle={{ padding: '5px', display: 'flex', height: 'calc(100% - 10px)' }} + > + <Flex + width={50} + align="center" + justify="space-around" + boxSizing="content-box" + cursor={clickable.value ? 'pointer' : undefined} + onClick={handleJump} > - <Flex - width={50} - align="center" - justify="space-around" - boxSizing="content-box" - cursor={clickable.value ? 'pointer' : undefined} - onClick={handleJump} + <ElAvatar + src={iconUrl.value} + shape="square" + fit="fill" + style={{ + backgroundColor: isSite(props.value) && props.value.iconUrl ? "transparent" : null, + padding: '2px', + userSelect: 'none', + fontSize: '22px', + }} > - <ElAvatar - src={iconUrl.value} - shape="square" - fit="fill" - style={{ - backgroundColor: isSite(props.value) && props.value.iconUrl ? "transparent" : null, - padding: '2px', - userSelect: 'none', - fontSize: '22px', - }} + {renderAvatarText(props.value)} + </ElAvatar> + </Flex> + <Flex + column flex={1} + style={{ marginInlineStart: '10px', paddingInlineEnd: '10px' }} + > + <Flex align="center" justify="space-between" height={24}> + {renderTitle(siteName.value, host.value, handleJump)} + <ElTag + size="small" + style={{ fontSize: '10px', padding: '0 3px', height: '16px' }} > - {renderAvatarText(props.value)} - </ElAvatar> + {percentage.value} + </ElTag> </Flex> - <Flex - column flex={1} - style={{ marginInlineStart: '10px', paddingInlineEnd: '10px' }} - > - <Flex align="center" justify="space-between" height={24}> - {renderTitle(siteName.value, host.value, handleJump)} - <ElTag - size="small" - style={{ fontSize: '10px', padding: '0 3px', height: '16px' }} - > - {percentage.value} - </ElTag> - </Flex> - <Flex column justify="space-around" flex={1}> - <Flex justify="end" width="100%"> - <ElText size="small"> - {formatPeriodCommon(props.value?.focus ?? 0)} - </ElText> - </Flex> - <ElProgress percentage={rate.value} showText={false} /> + <Flex column justify="space-around" flex={1}> + <Flex justify="end" width="100%"> + <ElText size="small"> + {formatPeriodCommon(props.value?.focus ?? 0)} + </ElText> </Flex> + <ElProgress percentage={rate.value} showText={false} /> </Flex> - </ElCard > - ) - } -}) + </Flex> + </ElCard> + ) +}, { props: ['value', 'max', 'total'] }) export default _default \ No newline at end of file diff --git a/src/pages/side/components/RowList/index.tsx b/src/pages/side/components/RowList/index.tsx index 26b7d9518..f0a409277 100644 --- a/src/pages/side/components/RowList/index.tsx +++ b/src/pages/side/components/RowList/index.tsx @@ -6,15 +6,15 @@ import { computed, type CSSProperties, defineComponent, ref, toRef, watch } from import Item from "./Item" type Props = { - data: timer.stat.Row[] + data: tt4b.stat.Row[] loading?: boolean style?: CSSProperties } const RowList = defineComponent<Props>(props => { const data = toRef(props, 'data') - const maxFocus = computed(() => data.value.map(r => r.focus).reduce((a, b) => a > b ? a : b, 0) ?? 0) - const totalFocus = computed(() => sum(data.value.map(i => i?.focus ?? 0))) + const maxFocus = computed(() => Math.max(0, ...data.value.map(r => r.focus))) + const totalFocus = computed(() => sum(data.value.map(i => i.focus))) const scrollbar = ref<ScrollbarInstance>() watch(data, () => scrollbar.value?.setScrollTop(0)) @@ -29,11 +29,11 @@ const RowList = defineComponent<Props>(props => { } ` return () => ( - <Flex flex={1} style={props.style}> + <Flex flex={1} style={{ overflow: 'hidden' }}> <ElScrollbar v-loading={props.loading} height="100%" ref={scrollbar} style={{ width: '100%' }}> <Flex column gap={8}> - {!data.value?.length && !props.loading && <ElEmpty class={emptyCls} />} - {data.value?.map(item => <Item value={item} max={maxFocus.value} total={totalFocus.value} />)} + {!data.value.length && !props.loading && <ElEmpty class={emptyCls} />} + {data.value.map(item => <Item value={item} max={maxFocus.value} total={totalFocus.value} />)} </Flex> </ElScrollbar> </Flex> diff --git a/src/pages/side/components/Search/index.tsx b/src/pages/side/components/Search/index.tsx index a55447ad2..d80780590 100644 --- a/src/pages/side/components/Search/index.tsx +++ b/src/pages/side/components/Search/index.tsx @@ -1,9 +1,9 @@ +import { useState } from "@hooks" +import { t } from '@side/locale' import { Search } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { getDatePickerIconSlots } from '@pages/element-ui/rtl' -import { t } from "@side/locale" import { type DateCell, ElDatePicker, ElInput, useNamespace } from "element-plus" import { defineComponent, h } from "vue" import Cell from './Cell' diff --git a/src/pages/side/components/Search/useDatePicker.ts b/src/pages/side/components/Search/useDatePicker.ts index 0e2551429..a7d7747cf 100644 --- a/src/pages/side/components/Search/useDatePicker.ts +++ b/src/pages/side/components/Search/useDatePicker.ts @@ -1,7 +1,6 @@ -import { useRequest } from '@hooks/useRequest' -import { useState } from '@hooks/useState' -import { selectSite } from '@service/stat-service' -import { getMonthTime, MILL_PER_WEEK } from '@util/time' +import { listSiteStats } from '@api/sw/stat' +import { useRequest, useState } from '@hooks' +import { formatTimeYMD, getMonthTime, MILL_PER_WEEK } from '@util/time' import { watch } from 'vue' export const useDatePicker = (options: { onChange: ArgCallback<Date> }) => { @@ -12,10 +11,10 @@ export const useDatePicker = (options: { onChange: ArgCallback<Date> }) => { const { data: dataDates, refresh: refreshDates } = useRequest(async (dateInMonth: Date) => { const [ms, me] = getMonthTime(dateInMonth) - const start = new Date(ms.getTime() - ms.getDay() * MILL_PER_WEEK) - const end = new Date(me.getTime() + (6 - me.getDay()) * MILL_PER_WEEK) + const start = formatTimeYMD(new Date(ms.getTime() - ms.getDay() * MILL_PER_WEEK)) + const end = formatTimeYMD(new Date(me.getTime() + (6 - me.getDay()) * MILL_PER_WEEK)) - const stats = await selectSite({ date: [start, end] }) + const stats = await listSiteStats({ date: [start, end] }) const dateSet = new Set<string>() stats.forEach(({ date }) => date && dateSet.add(date)) return Array.from(dateSet) diff --git a/src/pages/side/index.ts b/src/pages/side/index.ts index 7237a62a9..e78096549 100644 --- a/src/pages/side/index.ts +++ b/src/pages/side/index.ts @@ -5,26 +5,16 @@ * https://opensource.org/licenses/MIT */ import { initLocale } from "@i18n" -import { initElementLocale } from "@i18n/element" -import optionService from "@service/option-service" -import { injectGlobalCss } from '@side/style' -import { init as initTheme, toggle } from "@util/dark-mode" -import { ElLoadingDirective } from 'element-plus' -import 'element-plus/theme-chalk/index.css' -import { type App, createApp } from "vue" -import '../../common/timer' +import { createElApp } from "@pages/element-ui/app" +import { initDarkTheme } from "@pages/util/dark-mode" import Main from "./Layout" +import { injectGlobalCss } from './style' async function main() { - // Init theme with cache first - initTheme() + initDarkTheme() injectGlobalCss() - // Calculate the latest mode - optionService.isDarkMode().then(toggle) - await initLocale() - const app: App = createApp(Main) - await initElementLocale(app) - app.directive("loading", ElLoadingDirective) + initLocale() + const app = await createElApp(Main) const el = document.createElement('div') document.body.append(el) diff --git a/src/pages/side/locale.ts b/src/pages/side/locale.ts index ebf117f66..77c45c81a 100644 --- a/src/pages/side/locale.ts +++ b/src/pages/side/locale.ts @@ -8,7 +8,7 @@ import { type I18nKey as _I18nKey, t as _t } from "@i18n" import messages, { type SideMessage } from "@i18n/message/side" -export type I18nKey = _I18nKey<SideMessage> +type I18nKey = _I18nKey<SideMessage> export const t = (key: I18nKey, param?: any) => { const props = { key, param } diff --git a/src/pages/util/dark-mode.ts b/src/pages/util/dark-mode.ts new file mode 100644 index 000000000..ccbb8b55f --- /dev/null +++ b/src/pages/util/dark-mode.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { getOption } from '@api/sw/option' + +const THEME_ATTR = "data-theme" +const DARK_VAL = "dark" +const STORAGE_KEY = "isDark" +const STORAGE_FLAG = "1" + +function toggle0(isDarkMode: boolean, el?: Element) { + el ??= document.getElementsByTagName("html")?.[0] + el?.setAttribute(THEME_ATTR, isDarkMode ? DARK_VAL : "") + localStorage.setItem(STORAGE_KEY, isDarkMode ? STORAGE_FLAG : '') +} + +/** + * Init from local storage + */ +export function initDarkTheme(el?: Element) { + const val = isDarkMode() + toggle0(val, el) + // Calculate the latest mode + getOption().then(processDarkMode) +} + +function calcDarkMode(option: tt4b.option.AppearanceOption): boolean { + const { darkMode, darkModeTimeStart: start, darkModeTimeEnd: end } = option + if (darkMode === "default") { + if (typeof window === 'undefined') return false + return !!window.matchMedia('(prefers-color-scheme: dark)')?.matches + } else if (darkMode === "on") { + return true + } else if (darkMode === "off") { + return false + } else if (darkMode === "timed") { + if (start === undefined || end === undefined) { + return false + } + const now = new Date() + const currentSecs = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() + if (start > end) { + // Mostly + return start <= currentSecs || currentSecs <= end + } else if (start < end) { + return start <= currentSecs && currentSecs <= end + } else { + return currentSecs === start + } + } + return false +} + +export function processDarkMode(option: tt4b.option.AppearanceOption): boolean { + const val = calcDarkMode(option) + toggle0(val) + return val +} + +export function isDarkMode() { + return localStorage.getItem(STORAGE_KEY) === STORAGE_FLAG +} \ No newline at end of file diff --git a/src/pages/util/echarts.ts b/src/pages/util/echarts.ts new file mode 100644 index 000000000..6d5125271 --- /dev/null +++ b/src/pages/util/echarts.ts @@ -0,0 +1,18 @@ +import { + BarChart, CustomChart, EffectScatterChart, GaugeChart, LineChart, PieChart, ScatterChart, +} from "echarts/charts" +import { + AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TitleComponent, ToolboxComponent, + TooltipComponent, VisualMapComponent, +} from "echarts/components" +import { use } from "echarts/core" +import { CanvasRenderer } from "echarts/renderers" + +export const initEcharts = () => { + use([ + CanvasRenderer, + ToolboxComponent, AriaComponent, GridComponent, TooltipComponent, TitleComponent, VisualMapComponent, LegendComponent, + DataZoomComponent, + BarChart, PieChart, LineChart, ScatterChart, EffectScatterChart, CustomChart, GaugeChart, + ]) +} \ No newline at end of file diff --git a/src/pages/util/icon.tsx b/src/pages/util/icon.tsx deleted file mode 100644 index 510b14363..000000000 --- a/src/pages/util/icon.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export const CoffeeIcon = ( - <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> - <path d="M742.954667 682.666667H554.666667a42.666667 42.666667 0 0 1 0-85.333334h195.413333l14.208-170.666666H259.712l29.013333 348.416A85.333333 85.333333 0 0 0 373.76 853.333333h276.48a85.333333 85.333333 0 0 0 85.034667-78.250666l7.68-92.416z m67.2-341.333334H853.333333V256h-122.965333a119.850667 119.850667 0 0 1-107.178667-66.261333 34.517333 34.517333 0 0 0-30.890666-19.072h-160.597334a34.517333 34.517333 0 0 0-30.890666 19.072A119.850667 119.850667 0 0 1 293.632 256H170.666667v85.333333H810.154667z m39.765333 85.333334l-29.610667 355.498666A170.666667 170.666667 0 0 1 650.24 938.666667H373.76a170.666667 170.666667 0 0 1-170.069333-156.501334L174.08 426.666667H170.666667a85.333333 85.333333 0 0 1-85.333334-85.333334V256a85.333333 85.333333 0 0 1 85.333334-85.333333h122.965333a34.517333 34.517333 0 0 0 30.890667-19.072A119.850667 119.850667 0 0 1 431.701333 85.333333h160.597334c45.397333 0 86.912 25.642667 107.178666 66.261334a34.517333 34.517333 0 0 0 30.890667 19.072H853.333333a85.333333 85.333333 0 0 1 85.333334 85.333333v85.333333a85.333333 85.333333 0 0 1-85.333334 85.333334h-3.413333z"></path> - </svg> -) \ No newline at end of file diff --git a/src/pages/util/qrcode.ts b/src/pages/util/qrcode.ts new file mode 100644 index 000000000..4c5492fdf --- /dev/null +++ b/src/pages/util/qrcode.ts @@ -0,0 +1,43 @@ +import qrcode from 'qrcode-generator' + +type GenerateQrCanvasOption = { + text: string + size: number + margin?: number + errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H' +} + +export function generateQrCanvas(option: GenerateQrCanvasOption): HTMLCanvasElement { + const { text, size, margin = 1, errorCorrectionLevel = 'M' } = option + const qr = qrcode(0, errorCorrectionLevel) + qr.addData(text) + qr.make() + + const moduleCount = qr.getModuleCount() + const totalModules = moduleCount + margin * 2 + const scale = Math.max(1, Math.floor(size / totalModules)) + const canvasSize = totalModules * scale + + const canvas = document.createElement('canvas') + canvas.width = canvasSize + canvas.height = canvasSize + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Failed to create QR canvas context') + + ctx.fillStyle = '#fff' + ctx.fillRect(0, 0, canvasSize, canvasSize) + ctx.fillStyle = '#000' + + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (!qr.isDark(row, col)) continue + ctx.fillRect((col + margin) * scale, (row + margin) * scale, scale, scale) + } + } + + return canvas +} + +export function generateQrDataUrl(option: GenerateQrCanvasOption): string { + return generateQrCanvas(option).toDataURL('image/png') +} diff --git a/src/pages/util/rate.ts b/src/pages/util/rate.ts new file mode 100644 index 000000000..ec3f519cc --- /dev/null +++ b/src/pages/util/rate.ts @@ -0,0 +1,20 @@ +import { trySendMsg2Runtime } from '@api/sw/common' +import { REVIEW_PAGE } from '@util/constant/url' +import { getDayLength } from '@util/time' + +const INSTALL_DAY_MIN_LIMIT = 14 + +const FLAG = 'rateOpen' + +export async function recommendRate(): Promise<boolean> { + if (!REVIEW_PAGE) return false + const installTime = await trySendMsg2Runtime('meta.installTs') + if (!installTime) return false + const installedDays = getDayLength(new Date(installTime), new Date()) + if (installedDays < INSTALL_DAY_MIN_LIMIT) return false + return !localStorage.getItem(FLAG) +} + +export function rateClicked() { + localStorage.setItem(FLAG, '1') +} \ No newline at end of file diff --git a/src/pages/util/style.ts b/src/pages/util/style.ts index 2ba711016..f53312a61 100644 --- a/src/pages/util/style.ts +++ b/src/pages/util/style.ts @@ -8,6 +8,8 @@ import { camelize, type CSSProperties } from "vue" type Variant = "primary" | "success" | "warning" | "info" | "danger" +export type ColorVariant = Variant + type TextVariant = "primary" | "regular" | "secondary" type ColorUsage = 'fill' @@ -25,7 +27,7 @@ export const colorVariant = (variant: Variant, effect?: 'dark' | 'light', level? export const colorUsage = (usage: ColorUsage) => `--el-${usage}-color` -export const textColor = (variant: TextVariant) => `--el-text-color-${variant}` +const textColor = (variant: TextVariant) => `--el-text-color-${variant}` export const getStyle = ( element: HTMLElement, @@ -67,5 +69,24 @@ export function getSecondaryTextColor(): string | undefined { } export function getInfoColor(): string | undefined { - return getCssVariable(colorVariant('info')) + return getColor('info') +} + +export function getColor(variant: Variant): string | undefined { + return getCssVariable(colorVariant(variant)) +} + +export const cvtGroupColor = (color?: `${chrome.tabGroups.Color}`): string => { + switch (color) { + case 'grey': return '#5F6369' + case 'blue': return '#1974E8' + case 'yellow': return '#F9AB03' + case 'red': return '#DA3025' + case 'green': return '#198139' + case 'pink': return '#D01984' + case 'purple': return '#A143F5' + case 'cyan': return '#027B84' + case 'orange': return '#FA913E' + case undefined: return '#000' + } } diff --git a/src/service/backup/processor.ts b/src/service/backup/processor.ts deleted file mode 100644 index 91113599f..000000000 --- a/src/service/backup/processor.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * 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 optionHolder from "@service/components/option-holder" -import itemService from "@service/item-service" -import { getCid, updateBackUpTime, updateCid } from "@service/meta-service" -import { formatTimeYMD, getBirthday } from "@util/time" -import GistCoordinator from "./gist/coordinator" -import ObsidianCoordinator from "./obsidian/coordinator" -import WebDAVCoordinator from "./web-dav/coordinator" - -export type AuthCheckResult = { - option: timer.option.BackupOption - auth: timer.backup.Auth - ext: timer.backup.TypeExt - type: timer.backup.Type - coordinator: timer.backup.Coordinator<unknown> - errorMsg?: string -} - -class CoordinatorContextWrapper<Cache> implements timer.backup.CoordinatorContext<Cache> { - auth: timer.backup.Auth - ext?: timer.backup.TypeExt - cache: Cache = {} as unknown as Cache - type: timer.backup.Type - cid: string - - constructor(cid: string, auth: timer.backup.Auth, ext: timer.backup.TypeExt, type: timer.backup.Type) { - this.cid = cid - this.auth = auth - this.ext = ext - this.type = type - } - - async init(): Promise<timer.backup.CoordinatorContext<Cache>> { - this.cache = await syncDb.getCache(this.type) as Cache - return this - } - - handleCacheChanged(): Promise<void> { - return syncDb.updateCache(this.type, this.cache) - } -} - -/** - * Declare type of NavigatorUAData - */ -type NavigatorUAData = { - brands?: { - brand?: string - version?: string - }[] - platform?: string -} - -type Result<T> = { - success: boolean - errorMsg?: string - data?: T -} - -function error<T>(msg: string): Result<T> { - return { success: false, errorMsg: msg, } -} - -function success<T>(data?: T): Result<T> { - return { success: true, data } -} - -function generateCid() { - const uaData = (navigator as any)?.userAgentData as NavigatorUAData - let prefix = 'unknown' - if (uaData) { - const brand = uaData.brands - ?.map(e => e.brand) - ?.filter(brand => brand && brand !== "Chromium" && !brand.includes("Not")) - ?.[0] - ?.replace(' ', '-') - const platform = uaData.platform - brand && platform && (prefix = `${platform.toLowerCase()}-${brand.toLowerCase()}`) - } - return prefix + '-' + new Date().getTime() -} - -/** - * Get client id or generate it lazily - */ -async function lazyGetCid(): Promise<string> { - let cid = await getCid() - if (!cid) { - cid = generateCid() - await updateCid(cid) - } - return cid -} - -async function syncFull( - context: timer.backup.CoordinatorContext<unknown>, - coordinator: timer.backup.Coordinator<unknown>, - client: timer.backup.Client -): Promise<timer.backup.Snapshot> { - // 1. select rows - let start = getBirthday() - let end = new Date() - const rows = await itemService.selectItems({ date: [start, end] }) - const allDates = rows.map(r => r.date).sort((a, b) => a == b ? 0 : a > b ? 1 : -1) - client.maxDate = allDates[allDates.length - 1] - client.minDate = allDates[0] - // 2. upload - await coordinator.upload(context, rows) - return { - ts: end.getTime(), - date: formatTimeYMD(end), - } -} - -function filterClient(c: timer.backup.Client, excludeLocal: boolean, localClientId: string, start?: string, end?: string) { - // 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: timer.option.BackupOption): timer.backup.Auth { - const type = option?.backupType || 'none' - const token = option?.backupAuths?.[type] - const login = option.backupLogin?.[type] - return { token, login } -} - -export type RemoteQueryParam = { - start: Date - end: Date - specCid?: string - excludeLocal?: boolean -} - -class Processor { - coordinators: { - [type in timer.backup.Type]: timer.backup.Coordinator<unknown> - } - - constructor() { - this.coordinators = { - none: null as unknown as timer.backup.Coordinator<never>, - gist: new GistCoordinator(), - obsidian_local_rest_api: new ObsidianCoordinator(), - web_dav: new WebDAVCoordinator(), - } - } - - async syncData(): Promise<Result<number>> { - const { option, auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return error(errorMsg) - - const cid = await lazyGetCid() - const context: timer.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(cid, auth, ext, type).init() - const client: timer.backup.Client = { - id: cid, - name: option.clientName, - minDate: undefined, - maxDate: undefined - } - try { - let snapshot: timer.backup.Snapshot = await syncFull(context, coordinator, client) - await syncDb.updateSnapshot(type, snapshot) - const clients: timer.backup.Client[] = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) || [] - clients.push(client) - await coordinator.updateClients(context, clients) - // Update time - const now = Date.now() - updateBackUpTime(type, now) - return success(now) - } catch (e) { - console.error("Error to sync data", e) - const msg = (e as Error)?.message ?? e?.toString?.() - return error(msg) - } - } - - async listClients(): Promise<Result<timer.backup.Client[]>> { - const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return error(errorMsg) - const cid = await lazyGetCid() - const context: timer.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(cid, auth, ext, type).init() - const clients = await coordinator.listAllClients(context) - return success(clients) - } - - async checkAuth(): Promise<AuthCheckResult> { - const option = await optionHolder.get() - const { backupType: type, backupExts } = option - const ext = backupExts?.[type] ?? {} - const auth = prepareAuth(option) - - const coordinator: timer.backup.Coordinator<unknown> = 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: RemoteQueryParam): Promise<timer.backup.Row[]> { - const { type, coordinator, auth, ext, errorMsg } = await this.checkAuth() - if (errorMsg || !coordinator) { - return [] - } - - const { start = getBirthday(), end, specCid, excludeLocal } = param - let localCid = await lazyGetCid() - // 1. init context - const context: timer.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(localCid, auth, ext, type).init() - // 2. query all clients, and filter them - let startStr = start ? formatTimeYMD(start) : undefined - let endStr = end ? formatTimeYMD(end) : undefined - const allClients = (await coordinator.listAllClients(context)) - .filter(c => filterClient(c, !!excludeLocal, localCid, startStr, endStr)) - .filter(c => !specCid || c.id === specCid) - // 3. iterate clients - const result: timer.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<Result<void>> { - const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return error(errorMsg) - let localCid = await lazyGetCid() - const context: timer.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(localCid, auth, ext, type).init() - // 1. Find the client - const allClients = await coordinator.listAllClients(context) - const client = allClients?.filter(c => c?.id === cid)?.[0] - if (!client) { - return success() - } - // 2. clear - await coordinator.clear(context, client) - // 3. remove client - const newClients = allClients.filter(c => c?.id !== cid) - await coordinator.updateClients(context, newClients) - - return success() - } -} - -export default new Processor() \ No newline at end of file diff --git a/src/service/cate-service.ts b/src/service/cate-service.ts deleted file mode 100644 index 569509e58..000000000 --- a/src/service/cate-service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import siteCateDatabase from "@db/site-cate-database" - -class CateService { - - listAll(): Promise<timer.site.Cate[]> { - return siteCateDatabase.listAll() - } - - add(name: string): Promise<timer.site.Cate> { - return siteCateDatabase.add(name) - } - - saveName(id: number, name: string): Promise<void> { - return siteCateDatabase.update(id, name) - } - - remove(id: number): Promise<void> { - return siteCateDatabase.delete(id) - } -} - -export default new CateService() \ No newline at end of file diff --git a/src/service/components/immigration.ts b/src/service/components/immigration.ts deleted file mode 100644 index cc74ad70e..000000000 --- a/src/service/components/immigration.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import BaseDatabase from "@db/common/base-database" -import StoragePromise from "@db/common/storage-promise" -import limitDatabase from "@db/limit-database" -import mergeRuleDatabase from "@db/merge-rule-database" -import periodDatabase from "@db/period-database" -import siteCateDatabase from "@db/site-cate-database" -import statDatabase from "@db/stat-database" -import whitelistDatabase from "@db/whitelist-database" -import packageInfo from "@src/package" - -type MetaInfo = { - version: string - ts: number -} - -export type BackupData = { - __meta__: MetaInfo -} & any - -function initDatabase(): BaseDatabase[] { - const result: BaseDatabase[] = [ - statDatabase, - periodDatabase, - limitDatabase, - mergeRuleDatabase, - whitelistDatabase, - siteCateDatabase, - ] - - return result -} - -/** - * Data is citizens - * - * @since 0.2.5 - */ -class Immigration { - private storage: StoragePromise - private databaseArray: BaseDatabase[] - - constructor() { - const localStorage = chrome.storage.local - this.storage = new StoragePromise(localStorage) - this.databaseArray = initDatabase() - } - - async getExportingData(): Promise<BackupData> { - const data = await this.storage.get() as BackupData - const meta: MetaInfo = { version: packageInfo.version, ts: Date.now() } - data.__meta__ = meta - return data - } - - async importData(data: any): Promise<void> { - for (const db of this.databaseArray) await db.importData(data) - } -} - -export default Immigration \ No newline at end of file diff --git a/src/service/components/import-processor.ts b/src/service/components/import-processor.ts deleted file mode 100644 index e76d49546..000000000 --- a/src/service/components/import-processor.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import statDatabase from "@db/stat-database" -import { isNotZeroResult } from "@util/stat" - -/** - * Process imported data from other extensions of remote - * - * @since 1.9.2 - */ -export async function processImportedData(data: timer.imported.Data, resolution: timer.imported.ConflictResolution): Promise<void> { - if (resolution === 'overwrite') { - return processOverwrite(data) - } else { - return processAcc(data) - } -} - -async function processOverwrite(data: timer.imported.Data): Promise<void> { - const { rows, focus, time } = data - await Promise.all(rows.map(async row => { - const { host, date } = row - const exist = await statDatabase.get(host, date) - focus && (exist.focus = row.focus || 0) - time && (exist.time = row.time || 0) - await statDatabase.forceUpdate({ host, date, ...exist }) - })) -} - -async function processAcc(data: timer.imported.Data): Promise<void> { - const { rows } = data - await Promise.all(rows.map(async row => { - const { host, date, focus = 0, time = 0 } = row - await statDatabase.accumulate(host, date, { focus, time }) - })) -} - -export async function fillExist(rows: timer.imported.Row[]): Promise<void> { - await Promise.all(rows.map(async row => { - const { host, date } = row - const exist = await statDatabase.get(host, date) - isNotZeroResult(exist) && (row.exist = exist) - })) -} \ No newline at end of file diff --git a/src/service/components/option-holder.ts b/src/service/components/option-holder.ts deleted file mode 100644 index b5e542d8c..000000000 --- a/src/service/components/option-holder.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { onPermRemoved } from "@api/chrome/permission" -import db from "@db/option-database" -import { type DefaultOption, defaultOption } from "@util/constant/option" - -type ChangeListener = (option: timer.option.AllOption) => void - -class OptionHolder { - private option: DefaultOption | undefined - private listeners: ChangeListener[] = [] - - constructor() { - db.addOptionChangeListener(async () => { - const option = await this.reset() - this.listeners.forEach(listener => listener?.(option)) - }) - onPermRemoved(perm => { - perm.permissions?.includes('tabGroups') && this.set({ countTabGroup: false }) - }) - } - - listenPermChange() { - onPermRemoved(perm => { - perm.permissions?.includes('tabGroups') && this.set({ countTabGroup: false }) - }) - } - - private async reset(): Promise<DefaultOption> { - const exist: Partial<timer.option.AllOption> = await db.getOption() - const result = defaultOption() - Object.entries(exist).forEach(([key, val]) => (result as any)[key] = val) - this.option = result - return result - } - - async get(): Promise<DefaultOption> { - return this.option ?? await this.reset() - } - - async set(option: Partial<timer.option.AllOption>): Promise<void> { - const exist: Partial<timer.option.AllOption> = await db.getOption() - const toSet = defaultOption() - Object.entries(exist).forEach(([key, val]) => (toSet as any)[key] = val) - Object.entries(option).forEach(([key, val]) => (toSet as any)[key] = val) - await db.setOption(toSet) - } - - addChangeListener(listener: ChangeListener) { - listener && this.listeners.push(listener) - } -} - -export default new OptionHolder() \ No newline at end of file diff --git a/src/service/components/virtual-site-holder.ts b/src/service/components/virtual-site-holder.ts deleted file mode 100644 index 46a3f7d14..000000000 --- a/src/service/components/virtual-site-holder.ts +++ /dev/null @@ -1,45 +0,0 @@ -import siteDatabase from "@db/site-database" -import { compileAntPattern } from '@util/pattern' - -/** - * The singleton implementation of virtual sites holder - * - * @since 1.6.0 - */ -class VirtualSiteHolder { - hostSiteRegMap: Record<string, RegExp> = {} - - constructor() { - siteDatabase.select().then(sitesInfos => sitesInfos - .filter(s => s.type === 'virtual') - .forEach(site => this.updateRegularExp(site)) - ) - siteDatabase.addChangeListener(oldAndNew => oldAndNew.forEach(([oldVal, newVal]) => { - if (!newVal) { - // deleted - oldVal?.host && delete this.hostSiteRegMap[oldVal.host] - } else { - this.updateRegularExp(newVal) - } - })) - } - - private updateRegularExp(siteInfo: timer.site.SiteInfo) { - const { host } = siteInfo - this.hostSiteRegMap[host] = compileAntPattern(host) - } - - /** - * Find the virtual sites which matches the target url - * - * @param url - * @returns virtual sites - */ - findMatched(url: string): string[] { - return Object.entries(this.hostSiteRegMap) - .filter(([_, reg]) => reg.test(url)) - .map(([k]) => k) - } -} - -export default new VirtualSiteHolder() \ No newline at end of file diff --git a/src/service/components/week-helper.ts b/src/service/components/week-helper.ts deleted file mode 100644 index e42567566..000000000 --- a/src/service/components/week-helper.ts +++ /dev/null @@ -1,77 +0,0 @@ -import optionDatabase from "@db/option-database" -import { locale } from "@i18n" -import { formatTimeYMD, getWeekDay, MILL_PER_DAY } from "@util/time" - -function getRealWeekStart(weekStart: timer.option.WeekStartOption | undefined, locale: timer.Locale): number { - weekStart = weekStart ?? 'default' - if (weekStart === 'default') { - return locale === 'zh_CN' ? 0 : 6 - } else { - return weekStart - 1 - } -} - -/** - * Get the start time and end time of this week - * @param now the specific time - * @param weekStart 0-6 - * @returns [startTime, endTime] - * - * @since 0.6.0 - */ -function getWeekTime(now: Date, weekStart: number): [Date, Date] { - // Returns 0 - 6 means Monday to Sunday - const weekDayNow = getWeekDay(now) - let start: Date | undefined = undefined - if (weekDayNow === weekStart) { - start = now - } else if (weekDayNow < weekStart) { - const millDelta = (weekDayNow + 7 - weekStart) * MILL_PER_DAY - start = new Date(now.getTime() - millDelta) - } else { - const millDelta = (weekDayNow - weekStart) * MILL_PER_DAY - start = new Date(now.getTime() - millDelta) - } - return [start, now] -} - -class WeekHelper { - private weekStart: timer.option.WeekStartOption | undefined - private initialized: boolean = false - - private async init(): Promise<void> { - const option = await optionDatabase.getOption() - this.weekStart = option?.weekStart - optionDatabase.addOptionChangeListener(val => this.weekStart = val?.weekStart) - this.initialized = true - } - - async getWeekDateRange(now: Date): Promise<[startDate: string, endDateOrToday: string]> { - const [start, end] = await this.getWeekDate(now) - return [formatTimeYMD(start), formatTimeYMD(end)] - } - - async getWeekDate(now: Date | number): Promise<[start: Date, end: Date]> { - const weekStart = await this.getRealWeekStart() - return getWeekTime(typeof now === 'number' ? new Date(now) : now, weekStart) - } - - private async getWeekStartOpt(): Promise<timer.option.WeekStartOption | undefined> { - if (!this.initialized) { - await this.init() - } - return this.weekStart - } - - /** - * Week start - * - * @returns 0-6 - */ - async getRealWeekStart(): Promise<number> { - const weekStart = await this.getWeekStartOpt() - return getRealWeekStart(weekStart, locale) - } -} - -export default new WeekHelper() \ No newline at end of file diff --git a/src/service/limit-service/index.ts b/src/service/limit-service/index.ts deleted file mode 100644 index 09d81b4ce..000000000 --- a/src/service/limit-service/index.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { listTabs, sendMsg2Tab } from "@api/chrome/tab" -import db from "@db/limit-database" -import optionHolder from "@service/components/option-holder" -import weekHelper from "@service/components/week-helper" -import whitelistHolder from "@service/whitelist/holder" -import { sum } from "@util/array" -import { calcTimeState, hasLimited, isEnabledAndEffective, matches } from "@util/limit" -import { formatTimeYMD, MILL_PER_MINUTE } from "@util/time" - -export type QueryParam = { - filterDisabled: boolean - id?: number - url?: string -} - -async function select(cond?: QueryParam): Promise<timer.limit.Item[]> { - const { filterDisabled, url, id } = cond || {} - const now = new Date() - const today = formatTimeYMD(now) - const [startDate, endDate] = await weekHelper.getWeekDateRange(now) - - return (await db.all()) - .filter(item => !filterDisabled || item.enabled) - .filter(item => !id || id === item?.id) - // If use url, then test it - .filter(item => !url || matches(item?.cond, url)) - .map(({ records, ...others }) => { - const todayRec = records[today] - const thisWeekRec = Object.entries(records) - .filter(([k]) => k >= startDate && k <= endDate) - .map(([, v]) => v) - const weeklyWaste = sum(thisWeekRec.map(r => r.mill ?? 0)) - const weeklyDelayCount = sum(thisWeekRec.map(r => r.delay ?? 0)) - const weeklyVisit = sum(thisWeekRec.map(r => r.visit ?? 0)) - return { - ...others, - waste: todayRec?.mill ?? 0, - visit: todayRec?.visit ?? 0, - delayCount: todayRec?.delay ?? 0, - weeklyWaste, - weeklyDelayCount, - weeklyVisit, - } satisfies timer.limit.Item - }) -} - -async function selectEffective(url?: string) { - const enabledItems: timer.limit.Item[] = await select({ filterDisabled: true, url }) - return enabledItems?.filter(isEnabledAndEffective) || [] -} - -/** - * Fired if the item is removed or disabled - * - * @param item - */ -async function noticeLimitChanged() { - const effectiveItems = await selectEffective() - const tabs = await listTabs() - tabs.forEach(({ id, url }) => { - if (!id || !url) return - const limitedItems = effectiveItems.filter(item => matches(item?.cond, url)) - sendMsg2Tab(id, 'limitChanged', limitedItems).catch(err => console.warn(err.message)) - }) -} - -async function updateEnabled(...items: timer.limit.Rule[]): Promise<void> { - if (!items?.length) return - for (const item of items) { - await db.updateEnabled(item.id, !!item.enabled) - } - await noticeLimitChanged() -} - -async function updateLocked(...items: timer.limit.Rule[]): Promise<void> { - if (!items?.length) return - for (const item of items) { - await db.updateLocked(item.id, item.locked) - } -} - -async function updateDelay(...items: timer.limit.Rule[]) { - if (!items?.length) return - for (const item of items) { - await db.updateDelay(item.id, !!item.allowDelay) - } - await noticeLimitChanged() -} - -async function remove(...items: timer.limit.Rule[]): Promise<void> { - if (!items?.length) return - for (const item of items) { - await db.remove(item.id) - } - await noticeLimitChanged() -} - -async function getLimited(url: string): Promise<timer.limit.Item[]> { - const list: timer.limit.Item[] = await getRelated(url) - return list.filter(item => hasLimited(item)) -} - -async function getRelated(url: string): Promise<timer.limit.Item[]> { - const effectiveItems = await selectEffective() - return effectiveItems.filter(item => matches(item?.cond, url)) -} - -type IncreaseResult = { - limited?: timer.limit.Item[] - reminder?: timer.limit.ReminderInfo -} - -/** - * Add time - * - * @param url url - * @param focusTime time, milliseconds - * @returns the rules is limit cause of this operation - */ -async function addFocusTime(host: string, url: string, focusTime: number): Promise<IncreaseResult> { - if (whitelistHolder.contains(host, url)) return {} - - const allEffective = await selectEffective(url) - - const toUpdate: { [cond: string]: number } = {} - const limited: timer.limit.Item[] = [] - const needReminder: timer.limit.Item[] = [] - - const { limitReminder, limitReminderDuration = 0 } = await optionHolder.get() - const durationMill = limitReminder ? limitReminderDuration * MILL_PER_MINUTE : 0 - allEffective.forEach(item => { - const [met, reminder] = addFocusForEach(item, focusTime, durationMill) - met && limited.push(item) - reminder && needReminder.push(item) - toUpdate[item.id] = item.waste - }) - const result: IncreaseResult = { limited } - if (needReminder?.length) { - result.reminder = { - items: needReminder, - duration: limitReminderDuration, - } - } - await db.updateWaste(formatTimeYMD(new Date()), toUpdate) - return result -} - -function addFocusForEach(item: timer.limit.Item, focusTime: number, durationMill: number): [met: boolean, reminder: boolean] { - const before = calcTimeState(item, durationMill) - item.waste += focusTime - // Fast increase - item.weeklyWaste += focusTime - const after = calcTimeState(item, durationMill) - const met = (before.daily !== 'LIMITED' && after.daily === 'LIMITED') || (before.weekly !== 'LIMITED' && after.weekly === 'LIMITED') - const reminder = (before.daily === 'NORMAL' && after.daily === 'REMINDER') || (before.weekly === 'NORMAL' && after.weekly === 'REMINDER') - return [met, reminder] -} - -/** - * Increase visit count - * @returns the rules is limited - */ -async function incVisit(host: string, url: string): Promise<timer.limit.Item[]> { - if (whitelistHolder.contains(host, url)) return [] - - const allEnabled: timer.limit.Item[] = await select({ filterDisabled: true, url }) - const result: timer.limit.Item[] = [] - await db.increaseVisit(formatTimeYMD(new Date()), allEnabled.map(item => item.id)) - allEnabled.forEach(item => { - // Fast increase - item.visit++ - item.weeklyVisit++ - - hasLimited(item) && result.push(item) - }) - return result -} - -/** - * @returns Rules to wake - */ -async function moreMinutes(url: string): Promise<timer.limit.Item[]> { - const rules = (await select({ url: url, filterDisabled: true })) - .filter(item => hasLimited(item) && item.allowDelay) - rules.forEach(rule => { - rule.delayCount = (rule.delayCount ?? 0) + 1 - // Fast increase - rule.weeklyDelayCount = (rule.weeklyDelayCount ?? 0) + 1 - }) - - const date = formatTimeYMD(new Date()) - await db.updateDelayCount(date, rules) - return rules.filter(r => !hasLimited(r)) -} - -async function update(...rules: timer.limit.Rule[]) { - if (!rules?.length) return - for (const rule of rules) { - await db.save(rule, true) - } - await noticeLimitChanged() -} - -async function create(rule: MakeOptional<timer.limit.Rule, 'id'>): Promise<number> { - const id = await db.save(rule, false) - await noticeLimitChanged() - return id -} - -class LimitService { - moreMinutes = moreMinutes - getLimited = getLimited - getRelated = getRelated - updateEnabled = updateEnabled - updateDelay = updateDelay - updateLocked = updateLocked - select = select - remove = remove - update = update - create = create - broadcastRules = noticeLimitChanged - addFocusTime = addFocusTime - incVisit = incVisit -} - -export default new LimitService() diff --git a/src/service/limit-service/verification/processor.ts b/src/service/limit-service/verification/processor.ts deleted file mode 100644 index d2561e726..000000000 --- a/src/service/limit-service/verification/processor.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type VerificationContext, type VerificationGenerator, type VerificationPair } from "./common" -import { ALL_GENERATORS } from "./generator" - -class VerificationProcessor { - generators: VerificationGenerator[] - - constructor() { - this.generators = ALL_GENERATORS - } - - generate(difficulty: timer.limit.VerificationDifficulty, locale: timer.Locale): VerificationPair | null { - const context: VerificationContext = { difficulty, locale } - const supported = this.generators.filter(g => g.supports(context)) - const len = supported?.length - if (!len) { - return null - } - let generator = supported[0] - if (len > 1) { - const idx = Math.floor(Math.random() * supported.length) - generator = supported[idx] - } - return generator.generate(context) - } -} - -export default new VerificationProcessor() \ No newline at end of file diff --git a/src/service/meta-service.ts b/src/service/meta-service.ts deleted file mode 100644 index 00da4fafa..000000000 --- a/src/service/meta-service.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import db from "@db/meta-database" -import { REVIEW_PAGE } from "@util/constant/url" -import { getDayLength } from "@util/time" - -async function getInstallTime() { - const meta = await db.getMeta() - return meta.installTime ? new Date(meta.installTime) : undefined -} - -export async function updateInstallTime(installTime: Date) { - const meta = await db.getMeta() - if (meta.installTime) { - // Must not rewrite - return - } - meta.installTime = installTime.getTime() - await db.update(meta) -} - -export async function increaseApp(routePath: string) { - const meta = await db.getMeta() - const appCounter = meta.appCounter || {} - appCounter[routePath] = (appCounter[routePath] || 0) + 1 - meta.appCounter = appCounter - await db.update(meta) -} - -export async function increasePopup() { - const meta = await db.getMeta() - const popupCounter = meta.popupCounter || {} - popupCounter._total = (popupCounter._total || 0) + 1 - meta.popupCounter = popupCounter - await db.update(meta) -} - -/** - * @since 1.2.0 - */ -export async function getCid(): Promise<string | undefined> { - const meta = await db.getMeta() - return meta.cid -} - -/** - * @since 1.2.0 - */ -export async function updateCid(newCid: string) { - const meta = await db.getMeta() - if (meta.cid) { - return - } - meta.cid = newCid - await db.update(meta) -} - -/** - * @since 1.4.7 - */ -export async function updateBackUpTime(type: timer.backup.Type, time: number) { - const meta = await db.getMeta() - if (!meta.backup) { - meta.backup = {} - } - meta.backup[type] = { ts: time } - await db.update(meta) -} - -/** - * @since 1.4.7 - */ -export async function getLastBackUp(type: timer.backup.Type): Promise<{ ts: number, msg?: string } | undefined> { - const meta = await db.getMeta() - return meta.backup?.[type] -} - -/** - * @since 2.2.0 - */ -export async function saveFlag(flag: timer.ExtensionMetaFlag) { - if (!flag) return - const meta = await db.getMeta() - if (!meta.flag) meta.flag = {} - meta.flag[flag] = true - await db.update(meta) -} - -async function getFlag(flag: timer.ExtensionMetaFlag) { - if (!flag) return false - const meta = await db.getMeta() - return !!meta.flag?.[flag] -} - -const INSTALL_DAY_MIN_LIMIT = 14 - -export async function recommendRate(): Promise<boolean> { - if (!REVIEW_PAGE) return false - const installTime = await getInstallTime() - if (!installTime) return false - const installedDays = getDayLength(installTime, new Date()) - if (installedDays < INSTALL_DAY_MIN_LIMIT) return false - const rateOpen = await getFlag("rateOpen") - return !rateOpen -} diff --git a/src/service/option-service.ts b/src/service/option-service.ts deleted file mode 100644 index 87595220d..000000000 --- a/src/service/option-service.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import optionHolder from "./components/option-holder" - -async function setLocale(locale: timer.option.LocaleOption): Promise<void> { - const exist: Partial<timer.option.AllOption> = await optionHolder.get() - exist.locale = locale - await optionHolder.set(exist) -} - -async function setBackupOption(option: Partial<timer.option.BackupOption>): Promise<void> { - // Rewrite auths - const existOption = await optionHolder.get() - const existAuths = existOption.backupAuths || {} - const existExts = existOption.backupExts || {} - Object.entries(option.backupAuths || {}).forEach(([key, auth]) => existAuths[key as timer.backup.Type] = auth) - Object.entries(option.backupExts || {}).forEach(([key, ext]) => { - if (!ext) return - const type = key as timer.backup.Type - const existExt = existExts[type] || {} - Object.entries(ext).forEach(([extKey, val]) => existExt[extKey as keyof timer.backup.TypeExt] = val) - existExts[type] = existExt - }) - option.backupAuths = existAuths - option.backupExts = existExts - await optionHolder.set(option) -} - -async function isDarkMode(targetVal?: timer.option.AppearanceOption): Promise<boolean> { - const option = targetVal || await optionHolder.get() - const darkMode = option.darkMode - if (darkMode === "default") { - if (typeof window === 'undefined') return false - return !!window.matchMedia('(prefers-color-scheme: dark)')?.matches - } else if (darkMode === "on") { - return true - } else if (darkMode === "off") { - return false - } else if (darkMode === "timed") { - const start = option.darkModeTimeStart - const end = option.darkModeTimeEnd - if (start === undefined || end === undefined) { - return false - } - const now = new Date() - const currentSecs = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() - if (start > end) { - // Mostly - return start <= currentSecs || currentSecs <= end - } else if (start < end) { - return start <= currentSecs && currentSecs <= end - } else { - return currentSecs === start - } - } - return false -} - -async function setDarkMode(mode: timer.option.DarkMode, period?: [number, number]): Promise<void> { - const exist: timer.option.AllOption = await optionHolder.get() - exist.darkMode = mode - if (mode === 'timed') { - const [start, end] = period || [] - exist.darkModeTimeStart = start - exist.darkModeTimeEnd = end - } - await optionHolder.set(exist) -} - -class OptionService { - /** - * @since 1.2.0 - */ - setBackupOption = setBackupOption - /** - * @since 3.0.0 - */ - setLocale = setLocale - /** - * @since 1.1.0 - */ - isDarkMode = isDarkMode - /** - * @since 3.0.0 - */ - setDarkMode = setDarkMode -} - -export default new OptionService() diff --git a/src/service/period-service.ts b/src/service/period-service.ts deleted file mode 100644 index b18395a22..000000000 --- a/src/service/period-service.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import periodDatabase from "@db/period-database" -import { after, compare, getDateString } from "@util/period" - -export type PeriodQueryParam = { - /** - * Required - */ - periodRange: timer.period.KeyRange -} - -function dateStrBetween(startDate: timer.period.Key, endDate: timer.period.Key): string[] { - const result: string[] = [] - while (compare(startDate, endDate) <= 0) { - result.push(getDateString(startDate)) - startDate = after(startDate, 1) - } - return result -} - -async function listBetween(param: PeriodQueryParam): Promise<timer.period.Result[]> { - const [start, end] = param.periodRange - const allDates = dateStrBetween(start, end) - return periodDatabase.getBatch(allDates) -} - -async function batchDeleteBetween(param: PeriodQueryParam): Promise<void> { - const [start, end] = param.periodRange - if (!start || !end) return - const allDates = dateStrBetween(start, end) - await periodDatabase.batchDelete(allDates) -} - -class PeriodService { - listBetween = listBetween - batchDeleteBetween = batchDeleteBetween -} - -export default new PeriodService() diff --git a/src/service/site-service.ts b/src/service/site-service.ts deleted file mode 100644 index 3113a9b80..000000000 --- a/src/service/site-service.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { listTabs, sendMsg2Tab } from "@api/chrome/tab" -import siteDatabase, { type SiteCondition } from "@db/site-database" -import { toMap } from "@util/array" -import { identifySiteKey, SiteMap, supportCategory } from "@util/site" -import { slicePageResult } from "./components/page-info" - -export type SiteQueryParam = SiteCondition - -export async function removeAlias(key: timer.site.SiteKey) { - const exist = await siteDatabase.get(key) - if (!exist) return - delete exist.alias - await siteDatabase.save(exist) -} - -export async function saveAlias(key: timer.site.SiteKey, alias: string, noRewrite?: boolean) { - const exist = await siteDatabase.get(key) - let toUpdate: timer.site.SiteInfo - if (exist) { - // Can't overwrite if alias is already existed - if (exist.alias && noRewrite) return - toUpdate = exist - toUpdate.alias = alias - } else { - toUpdate = { ...key, alias } - } - await siteDatabase.save(toUpdate) -} - -export async function batchSaveAliasNoRewrite(siteMap: SiteMap<string>): Promise<void> { - if (!siteMap?.count?.()) return - const allSites = await siteDatabase.getBatch(siteMap.keys()) - const existMap = new SiteMap<timer.site.SiteInfo>() - allSites.forEach(exist => existMap.put(exist, exist)) - - const toSave: timer.site.SiteInfo[] = [] - siteMap.forEach((k, alias) => { - const exist = existMap.get(k) - if (exist?.alias || !alias) return - toSave.push({ ...exist || k, alias }) - }) - await siteDatabase.save(...toSave) -} - -export async function removeIconUrl(key: timer.site.SiteKey) { - const exist = await siteDatabase.get(key) - if (!exist) return - delete exist.iconUrl - await siteDatabase.save(exist) -} - -export async function saveIconUrl(key: timer.site.SiteKey, iconUrl: string) { - const exist = await siteDatabase.get(key) - let toUpdate: timer.site.SiteInfo - if (exist) { - toUpdate = { ...exist } - toUpdate.iconUrl = iconUrl - } else { - toUpdate = { ...key, iconUrl } - } - await siteDatabase.save(toUpdate) -} - -export async function saveSiteRunState(key: timer.site.SiteKey, run: boolean) { - const exist = await siteDatabase.get(key) - if (!exist) return - exist.run = run - await siteDatabase.save(exist) - // send msg to tabs - const tabs = await listTabs() - for (const { id } of tabs) { - try { - id && await sendMsg2Tab(id, 'siteRunChange') - } catch { } - } -} - -export async function addSite(siteInfo: timer.site.SiteInfo): Promise<void> { - if (await siteDatabase.exist(siteInfo)) { - return - } - if (!supportCategory(siteInfo)) siteInfo.cate = undefined - await siteDatabase.save(siteInfo) -} - -export async function selectSitePage(param?: SiteQueryParam, page?: timer.common.PageQuery): Promise<timer.common.PageResult<timer.site.SiteInfo>> { - const origin: timer.site.SiteInfo[] = await siteDatabase.select(param) - const result: timer.common.PageResult<timer.site.SiteInfo> = slicePageResult(origin, page) - return result -} - -export function selectAllSites(param?: SiteQueryParam): Promise<timer.site.SiteInfo[]> { - return siteDatabase.select(param) -} - -export function batchGetSites(keys: timer.site.SiteKey[]): Promise<timer.site.SiteInfo[]> { - return siteDatabase.getBatch(keys) -} - -export function removeSites(...sites: timer.site.SiteKey[]): Promise<void> { - return siteDatabase.remove(...sites) -} - -export async function saveSiteCate(key: timer.site.SiteKey, cateId: number | undefined): Promise<void> { - if (!supportCategory(key)) return - - const exist = await siteDatabase.get(key) - await siteDatabase.save({ ...exist || key, cate: cateId }) -} - -export async function batchSaveSiteCate(cateId: number | undefined, keys: timer.site.SiteKey[]): Promise<void> { - keys = keys?.filter(supportCategory) - if (!keys?.length) return - - const allSites = await siteDatabase.getBatch(keys) - const siteMap = toMap(allSites, identifySiteKey) - - const toSave = keys.map(k => { - const s = siteMap[identifySiteKey(k)] - return ({ ...s || k, cate: cateId }) - }) - await siteDatabase.save(...toSave) -} - -/** -* @since 0.9.0 -*/ -export async function getSite(siteKey: timer.site.SiteKey): Promise<timer.site.SiteInfo> { - const info = await siteDatabase.get(siteKey) - return info ?? siteKey -} diff --git a/src/service/stat-service/index.ts b/src/service/stat-service/index.ts deleted file mode 100644 index 239b0f468..000000000 --- a/src/service/stat-service/index.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * 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 mergeRuleDatabase from "@db/merge-rule-database" -import cateDatabase from "@db/site-cate-database" -import siteDatabase from "@db/site-database" -import statDatabase, { type StatCondition } from "@db/stat-database" -import { toMap } from "@util/array" -import { judgeVirtualFast } from "@util/pattern" -import { CATE_NOT_SET_ID, distinctSites, SiteMap } from "@util/site" -import { isGroup, isNormalSite, isSite } from "@util/stat" -import { log } from "../../common/logger" -import CustomizedHostMergeRuler from "../components/host-merge-ruler" -import { 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: timer.stat.SiteRow[], container: timer.site.SiteKey[]) { - rows.forEach(row => { - const { mergedRows } = row - container.push(row.siteKey) - mergedRows?.length && extractAllSiteKeys(mergedRows, container) - }) -} - -function fillRowWithSiteInfo(row: timer.stat.SiteRow, siteMap: SiteMap<timer.site.SiteInfo>): 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?: timer.common.SortDirection): number { - if (a === b) return 0 - const val = a > b ? 1 : -1 - return direction === 'DESC' ? -val : val -} - -export type SiteQuery = Pick<StatCondition, 'date' | 'focusRange' | 'timeRange' | 'virtual'> - & timer.common.SortBy<'date' | 'host' | timer.core.Dimension> - & { - query?: string - host?: string - mergeDate?: boolean - mergeHost?: boolean - /** - * Inclusive remote data - * - * If true the date range MUST NOT be unlimited - * - * @since 1.2.0 - */ - inclusiveRemote?: boolean - /** - * Categories - * - * @since 3.0.0 - */ - cateIds?: number[] - ignoreSite?: boolean - } - -export type CateQuery = Pick<StatCondition, 'date'> - & timer.common.SortBy<'date' | 'focus' | 'time'> - & { - query?: string - mergeDate?: boolean - inclusiveRemote?: boolean - cateIds?: number[] - } - -export type GroupQuery = Pick<StatCondition, 'date'> - & timer.common.SortBy<'date' | 'title' | 'focus' | 'time'> - & { - query?: string - mergeDate?: boolean - } - -export type CountQuery = Pick<StatCondition, 'date'> & { - keys: timer.stat.TargetKey -} - -function filterByCateId(itemCateId: number | undefined, cateIds: number[] | undefined): boolean { - if (!cateIds?.length) return true - return cateIds.includes(itemCateId ?? CATE_NOT_SET_ID) -} - -/** - * Query hosts - * - * @param fuzzyQuery the part of host - * @since 0.0.8 - */ -export async function listHosts(fuzzyQuery?: string): Promise<Record<timer.site.Type, string[]>> { - const rows = await statDatabase.select() - const allHosts: Set<string> = new Set(rows.map(row => row.host).filter(h => !!h)) - // Generate ruler - const mergeRuleItems: timer.merge.Rule[] = await mergeRuleDatabase.selectAll() - const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) - - const normalSet: Set<string> = new Set() - const mergedSet: Set<string> = new Set() - const virtualSet: Set<string> = new Set() - - const allHostArr = Array.from(allHosts) - - allHostArr.forEach(host => { - if (judgeVirtualFast(host)) { - virtualSet.add(host) - return - } - normalSet.add(host) - const mergedHost = mergeRuler.merge(host) - mergedSet.add(mergedHost) - }) - - let normal = Array.from(normalSet) - let merged = Array.from(mergedSet) - let virtual = Array.from(virtualSet) - if (fuzzyQuery) { - normal = normal.filter(host => host?.includes(fuzzyQuery)) - merged = merged.filter(host => host?.includes(fuzzyQuery)) - virtual = virtual.filter(host => host?.includes(fuzzyQuery)) - } - - return { normal, merged, virtual } -} - -export async function countSiteByHosts(hosts: string[], dateRange: StatCondition['date']): Promise<number> { - log("service: countSiteByHosts: {hosts}, {dateRange}", hosts, dateRange) - const rows = await statDatabase.select({ keys: hosts, date: dateRange }) - const result = rows.length - log("service: countSiteByHosts: {result}", result) - return result -} - -export async function selectSite(param?: SiteQuery): Promise<timer.stat.SiteRow[]> { - log("service: select:{param}", param) - 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: timer.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?: SiteQuery, - page?: timer.common.PageQuery, -): Promise<timer.common.PageResult<timer.stat.SiteRow>> { - log("selectByPage:{param},{page}", param, page) - const rows = await selectSite(param) - let result = slicePageResult(rows, page) - log("result of selectByPage:{param}, {page}, {result}", param, page, result) - return result -} - -export async function selectCate(param?: CateQuery): Promise<timer.stat.CateRow[]> { - 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) - 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 by date - 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?: CateQuery, page?: timer.common.PageQuery): Promise<timer.common.PageResult<timer.stat.CateRow>> { - const rows = await selectCate(query) - return slicePageResult(rows, page) -} - -async function fillSite(rows: timer.stat.SiteRow[]): Promise<true> { - let keys: timer.site.SiteKey[] = [] - extractAllSiteKeys(rows, keys) - keys = distinctSites(keys) - - const siteInfos = await siteDatabase.getBatch(keys) - const siteInfoMap = new SiteMap<timer.site.SiteInfo>() - siteInfos.forEach(siteInfo => siteInfoMap.put(siteInfo, siteInfo)) - - rows.forEach(item => fillRowWithSiteInfo(item, siteInfoMap)) - return true -} - -export async function selectGroup(param?: GroupQuery): Promise<timer.stat.GroupRow[]> { - const { - date, query, mergeDate: needMergeDate, - sortKey, sortDirection, - } = param ?? {} - const list = await statDatabase.selectGroup({ date }) - const groups = await listAllGroups() - const groupMap = toMap(groups, g => g.id) - let rows: timer.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?: GroupQuery, - page?: timer.common.PageQuery, -) { - const rows = await selectGroup(param) - return slicePageResult(rows, page) -} - -export async function countGroupByIds(groupIds: number[], dateRange: StatCondition["date"]): Promise<number> { - log("service: countGroupByIds: {groupIds}, {dateRange}", groupIds, dateRange) - const keys = groupIds.map(gid => `${gid}`) - const rows = await statDatabase.selectGroup({ keys, date: dateRange }) - const result = rows.length - log("service: countGroupByIds: {result}", result) - return result -} - -export async function batchDelete(targets: timer.stat.Row[]) { - if (!targets?.length) return - const siteKeys: timer.core.RowKey[] = [] - const groupKeys: [groupId: number, date: string][] = [] - targets.forEach(row => { - const { date } = row - if (!date) return - isNormalSite(row) && siteKeys.push({ host: row.siteKey.host, date }) - isGroup(row) && groupKeys.push([row.groupKey, date]) - }) - await statDatabase.delete(siteKeys) - await statDatabase.deleteGroup(groupKeys) -} - -export async function selectGroupByPage(param?: GroupQuery, page?: timer.common.PageQuery): Promise<timer.common.PageResult<timer.stat.Row>> { - const rows = await selectGroup(param) - return slicePageResult(rows, page) -} \ No newline at end of file diff --git a/src/service/stat-service/merge/date.ts b/src/service/stat-service/merge/date.ts deleted file mode 100644 index 9c4e01088..000000000 --- a/src/service/stat-service/merge/date.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { identifyTargetKey, isCate, isGroup, isNormalSite, isSite } from "@util/stat" -import { mergeResult } from "./common" - -export function mergeDate<T extends timer.stat.Row>(origin: T[]): T[] { - const map: Record< - string, - | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> - | MakeRequired<timer.stat.GroupRow, 'mergedDates'> - > = {} - origin.forEach(ele => { - const { date } = ele - const key = identifyTargetKey(ele) - let exist = map[key] - if (!exist) { - exist = map[key] = { - ...ele, - focus: 0, - time: 0, - mergedRows: [], - mergedDates: [], - composition: { focus: [], time: [], run: [] }, - } satisfies timer.stat.Row - } - mergeResult(exist, ele) - isSite(ele) && isSite(exist) && exist.mergedRows.push(...ele.mergedRows ?? []) - isCate(ele) && isCate(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/whitelist/holder.ts b/src/service/whitelist/holder.ts deleted file mode 100644 index 3661fbff1..000000000 --- a/src/service/whitelist/holder.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import whitelistDatabase from "@db/whitelist-database" -import WhitelistProcessor from './processor' - -/** - * The singleton implementation of whitelist holder - */ -class WhitelistHolder { - private processor = new WhitelistProcessor() - private postHandlers: NoArgCallback[] - - constructor() { - whitelistDatabase.selectAll().then(list => this.processor.setWhitelist(list)) - whitelistDatabase.addChangeListener(whitelist => { - this.processor.setWhitelist(whitelist) - this.postHandlers.forEach(handler => handler()) - }) - this.postHandlers = [] - } - - addPostHandler(handler: () => void) { - this.postHandlers.push(handler) - } - - contains(host: string, url: string): boolean { - return this.processor.contains(host, url) - } -} - -export default new WhitelistHolder() \ No newline at end of file diff --git a/src/service/whitelist/service.ts b/src/service/whitelist/service.ts deleted file mode 100644 index b9d267378..000000000 --- a/src/service/whitelist/service.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import whitelistDatabase from "@db/whitelist-database" -import { log } from '@src/common/logger' - -/** - * Service of whitelist - * - * @since 0.0.5 - */ -class WhitelistService { - - add(white: string): Promise<void> { - log('add to whitelist: ' + white) - // Just add to the white list, not to delete records since v0.1.1 - return whitelistDatabase.add(white) - } - - listAll(): Promise<string[]> { - return whitelistDatabase.selectAll() - } - - remove(white: string): Promise<void> { - log('remove whitelist: ' + white) - return whitelistDatabase.remove(white) - } -} - -export default new WhitelistService() \ No newline at end of file diff --git a/src/shared/route.ts b/src/shared/route.ts new file mode 100644 index 000000000..2c48c3261 --- /dev/null +++ b/src/shared/route.ts @@ -0,0 +1,44 @@ +export const APP_LIMIT_ROUTE = '/behavior/limit' +export type AppLimitQuery = { + action?: 'create' | 'modify' + url?: string + id?: string +} + +export const APP_ANALYSIS_ROUTE = '/data/analysis' +export type AppAnalysisQuery = Partial<tt4b.site.SiteKey> & { + cateId?: string + url?: string +} + +export const APP_OPTION_ROUTE = '/additional/option' +export const APP_REPORT_ROUTE = '/data/report' +/** + * The query param of report page + */ +export type AppReportQuery = { + /** + * Query + */ + q?: string + /** + * Merge method + */ + mm?: Exclude<tt4b.stat.MergeMethod, 'date'> + /** + * Merge date + */ + md?: string + /** + * Date start + */ + ds?: string + /** + * Date end + */ + de?: string + /** + * Sorted column + */ + sc?: tt4b.core.Dimension +} \ No newline at end of file diff --git a/src/util/array.ts b/src/util/array.ts index 21686c068..1f0688eee 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -5,6 +5,18 @@ * https://opensource.org/licenses/MIT */ +export function groupBy<T, R>( + arr: T[], + keyFunc: (e: T, idx: number) => string | undefined | null, + downstream: (grouped: T[], key: string) => R +): Record<string, R> + +export function groupBy<T, R>( + arr: T[], + keyFunc: (e: T, idx: number) => number, + downstream: (grouped: T[], key: string) => R +): Record<number, R> + /** * Group by * @@ -107,15 +119,6 @@ export function sum(arr: number[]): number { return arr?.reduce?.((a, b) => (a ?? 0) + (b ?? 0), 0) ?? 0 } -/** - * @since 2.1.0 - * @returns null if arr is empty or null - */ -export function average(arr: number[]): number | null { - if (!arr?.length) return null - return sum(arr) / arr.length -} - export function allMatch<T>(arr: T[], predicate: (t: T) => boolean): boolean { return !arr?.filter?.(e => !predicate?.(e))?.length } @@ -131,22 +134,3 @@ export function range(len: number): number[] { } return arr } - -export function containsAny<T>(arr1: T[], arr2: T[]): boolean { - if (!arr1?.length || !arr2?.length) return false - - return !!arr1.find(e => arr2.includes(e)) -} - -export function joinAny<T = any>(arr: T[], separator: T): T[] { - if (!arr.length) return [separator] - - return arr.reduce<T[]>( - (arr, e) => { - arr.length && arr.push(separator) - arr.push(e) - return arr - }, - [], - ) -} \ No newline at end of file diff --git a/src/util/base64.ts b/src/util/base64.ts deleted file mode 100644 index ab65f3f51..000000000 --- a/src/util/base64.ts +++ /dev/null @@ -1,23 +0,0 @@ -const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' - -export function encode(str: string): string { - const bytes = new TextEncoder().encode(str) - let result = '' - - for (let i = 0; i < bytes.length; i += 3) { - const byte1 = bytes[i] - const byte2 = bytes[i + 1] || 0 - const byte3 = bytes[i + 2] || 0 - - const char1 = byte1 >> 2 - const char2 = ((byte1 & 0x03) << 4) | (byte2 >> 4) - const char3 = ((byte2 & 0x0F) << 2) | (byte3 >> 6) - const char4 = byte3 & 0x3F - - result += BASE64_CHARS[char1] + BASE64_CHARS[char2] + - (i + 1 < bytes.length ? BASE64_CHARS[char3] : '=') + - (i + 2 < bytes.length ? BASE64_CHARS[char4] : '=') - } - - return result -} \ No newline at end of file diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index dc51fe667..a1bb64688 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -5,79 +5,66 @@ * https://opensource.org/licenses/MIT */ -type AppearanceRequired = MakeRequired<timer.option.AppearanceOption, 'darkModeTimeStart' | 'darkModeTimeEnd'> +export const DEFAULT_APPEARANCE: tt4b.option.AppearanceRequired = { + displayWhitelistMenu: false, + // Change false to true @since 0.8.4 + displayBadgeText: true, + locale: "default", + printInConsole: true, + darkMode: "default", + // 6 PM - 6 AM + // 18*60*60 + darkModeTimeStart: 64800, + // 6*60*60 + darkModeTimeEnd: 21600, + // 1s + chartAnimationDuration: 1000, +} as const -export function defaultAppearance(): AppearanceRequired { - return { - displayWhitelistMenu: false, - // Change false to true @since 0.8.4 - displayBadgeText: true, - locale: "default", - printInConsole: true, - darkMode: "default", - // 6 PM - 6 AM - // 18*60*60 - darkModeTimeStart: 64800, - // 6*60*60 - darkModeTimeEnd: 21600, - // 1s - chartAnimationDuration: 1000, - } -} +export const DEFAULT_TRACKING: tt4b.option.TrackingRequired = { + autoPauseTracking: false, + // 10 minutes + autoPauseInterval: 600, + countLocalFiles: false, + countTabGroup: true, + weekStart: 'default', + storage: 'classic', +} as const -type TrackingRequired = MakeRequired<timer.option.TrackingOption, 'weekStart'> +export const DEFAULT_LIMIT: tt4b.option.LimitRequired = { + limitDelayDuration: 5, + limitLevel: 'nothing', + limitPassword: '', + limitVerifyDifficulty: 'easy', + limitReminder: false, + limitReminderDuration: 5, +} as const -export function defaultTracking(): TrackingRequired { - return { - autoPauseTracking: false, - // 10 minutes - autoPauseInterval: 600, - countLocalFiles: false, - countTabGroup: false, - weekStart: 'default', - } -} +export const DEFAULT_BACKUP: tt4b.option.BackupOption = { + backupType: 'none', + clientName: 'unknown', + backupAuths: {}, + backupLogin: {}, + backupExts: {}, + autoBackUp: false, + autoBackUpInterval: 30, +} as const -type LimitRequired = MakeRequired<timer.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration'> +export const DEFAULT_ACCESSIBILITY: tt4b.option.AccessibilityOption = { + chartDecal: false +} as const -export function defaultLimit(): LimitRequired { - return { - limitLevel: 'nothing', - limitPassword: '', - limitVerifyDifficulty: 'easy', - limitReminder: false, - limitReminderDuration: 5, - } -} +export const DEFAULT_NOTIFICATION: tt4b.option.NotificationOption = { + notificationCycle: 'none', + notificationMethod: 'browser', + notificationOffset: 0, +} as const -export function defaultBackup(): timer.option.BackupOption { - return { - backupType: 'none', - clientName: 'unknown', - backupAuths: {}, - backupLogin: {}, - backupExts: {}, - autoBackUp: false, - autoBackUpInterval: 30, - } -} - -export function defaultAccessibility(): timer.option.AccessibilityOption { - return { - chartDecal: false, - } -} - -export type DefaultOption = - & AppearanceRequired & TrackingRequired & LimitRequired - & timer.option.BackupOption & timer.option.AccessibilityOption - -export function defaultOption(): DefaultOption { - return { - ...defaultAppearance(), - ...defaultTracking(), - ...defaultBackup(), - ...defaultLimit(), - ...defaultAccessibility(), - } -} \ No newline at end of file +export const defaultOption = () => structuredClone({ + ...DEFAULT_APPEARANCE, + ...DEFAULT_TRACKING, + ...DEFAULT_BACKUP, + ...DEFAULT_LIMIT, + ...DEFAULT_ACCESSIBILITY, + ...DEFAULT_NOTIFICATION, +}) satisfies tt4b.option.DefaultOption \ No newline at end of file diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 9aaecb4b5..bd8083612 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -12,6 +12,7 @@ import { BROWSER_MAJOR_VERSION, BROWSER_NAME } from "./environment" export const FIREFOX_HOMEPAGE = 'https://addons.mozilla.org/firefox/addon/besttimetracker' export const CHROME_HOMEPAGE = 'https://chromewebstore.google.com/detail/time-tracker/dkdhhcbjijekmneelocdllcldcpmekmm' export const EDGE_HOMEPAGE = 'https://microsoftedge.microsoft.com/addons/detail/timer-the-web-time-is-e/fepjgblalcnepokjblgbgmapmlkgfahc' +export const INSTALL_PAGE = 'https://www.wfhg.cc/en/install' /** * @since 0.4.0 @@ -28,13 +29,6 @@ export const CHANGE_LOG_PAGE = 'https://github.com/sheepzh/time-tracker-4-browse */ export const GITHUB_ISSUE_ADD = 'https://github.com/sheepzh/time-tracker-4-browser/issues/new/choose' -/** - * Feedback powered by www.wjx.cn - * - * @since 0.1.6 - */ -export const ZH_FEEDBACK_PAGE = 'https://www.wjx.cn/vj/YFWwHUy.aspx' - /** * Feedback powered by support.qq.com * @@ -49,7 +43,7 @@ export const LICENSE_PAGE = 'https://github.com/sheepzh/time-tracker-4-browser/b /** * @since 0.9.6 */ -export const FEEDBACK_QUESTIONNAIRE: Record<timer.RequiredLocale, string> & Partial<Record<timer.OptionalLocale, string>> = { +export const FEEDBACK_QUESTIONNAIRE: Record<tt4b.RequiredLocale, string> & Partial<Record<tt4b.OptionalLocale, string>> = { zh_CN: TU_CAO_PAGE, zh_TW: 'https://docs.google.com/forms/d/e/1FAIpQLSdfvG6ExLj331YOLZIKO3x98k3kMxpkkLW1RgFuRGmUnZCGRQ/viewform?usp=sf_link', en: 'https://docs.google.com/forms/d/e/1FAIpQLSdNq4gnSY7uxYkyqOPqyYF3Bqlc3ZnWCLDi5DI5xGjPeVCNiw/viewform?usp=sf_link', @@ -59,7 +53,7 @@ const UNINSTALL_QUESTIONNAIRE_EN = 'https://docs.google.com/forms/d/e/1FAIpQLSfl /** * @since 0.9.6 */ -export const UNINSTALL_QUESTIONNAIRE: { [locale in timer.RequiredLocale]: string } & { [locale in timer.OptionalLocale]?: string } = { +export const UNINSTALL_QUESTIONNAIRE: { [locale in tt4b.RequiredLocale]: string } & { [locale in tt4b.OptionalLocale]?: string } = { zh_CN: 'https://www.wjx.cn/vj/YDgY9Yz.aspx', zh_TW: 'https://docs.google.com/forms/d/e/1FAIpQLSdK93q-548dK-2naoS3DaArdc7tEGoUY9JQvaXP5Kpov8h6-A/viewform?usp=sf_link', ja: 'https://docs.google.com/forms/d/e/1FAIpQLSdsB3onZuleNf6j7KJJLbcote647WV6yeUr-9m7Db5QXakfpg/viewform?usp=sf_link', @@ -78,14 +72,14 @@ export function getAppPageUrl(route?: string, query?: any): string { } export const HOMEPAGE = "https://www.wfhg.cc" -const HOMEPAGE_LOCALES: timer.Locale[] = [ +const HOMEPAGE_LOCALES: tt4b.Locale[] = [ "zh_CN", "zh_TW", "en", "ja", "de", "ru", "fr", "es", "pt_PT", ] export function getHomepageWithLocale(): string { - const homepageLocale: timer.Locale = HOMEPAGE_LOCALES.includes(locale) ? locale : "en" + const homepageLocale: tt4b.Locale = HOMEPAGE_LOCALES.includes(locale) ? locale : "en" return `${HOMEPAGE}/${homepageLocale}/` } @@ -115,25 +109,25 @@ export const CROWDIN_PROJECT_ID = 516822 */ export const CROWDIN_HOMEPAGE = 'https://crowdin.com/project/timer-chrome-edge-firefox' -const webstorePages: Partial<Record<typeof BROWSER_NAME, [storePage: string, reviewPage: string]>> = { - firefox: [FIREFOX_HOMEPAGE, FIREFOX_HOMEPAGE + "/reviews"], - chrome: [CHROME_HOMEPAGE, CHROME_HOMEPAGE + "/reviews"], - edge: [EDGE_HOMEPAGE, EDGE_HOMEPAGE], +const REVIEW_PAGES: Partial<Record<typeof BROWSER_NAME, string>> = { + firefox: FIREFOX_HOMEPAGE + "/reviews", + chrome: CHROME_HOMEPAGE + "/reviews", + edge: EDGE_HOMEPAGE, } -const [webstorePage, reviewPage] = webstorePages[BROWSER_NAME] ?? [HOMEPAGE, CHROME_HOMEPAGE + "/reviews"] - -/** - * @since 0.0.5 - */ -export const WEBSTORE_PAGE = webstorePage - /** * @since 2.2.4 */ -export const REVIEW_PAGE = reviewPage +export const REVIEW_PAGE = REVIEW_PAGES[BROWSER_NAME] ?? CHROME_HOMEPAGE + "/reviews" /** * @since 3.7.13 */ export const BUY_ME_A_COFFEE_PAGE = 'https://buymeacoffee.com/sheepysheep' + +/** + * Only available for simplified chinese users + * + * @since 4.0.0 + */ +export const DONATION_PAGE = 'https://github.com/sheepzh/sheepzh?tab=contributing-ov-file' \ No newline at end of file diff --git a/src/util/dark-mode.ts b/src/util/dark-mode.ts deleted file mode 100644 index 3f694f446..000000000 --- a/src/util/dark-mode.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2022-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -/** - * Dark mode - * - * @since 1.1.0 - */ - -const THEME_ATTR = "data-theme" -const DARK_VAL = "dark" -const STORAGE_KEY = "isDark" -const STORAGE_FLAG = "1" - -function toggle0(isDarkMode: boolean, el?: Element) { - el = el || document.getElementsByTagName("html")?.[0] - el.setAttribute(THEME_ATTR, isDarkMode ? DARK_VAL : "") -} - -/** - * Init from local storage - */ -export function init(el?: Element) { - const val = isDarkMode() - toggle0(val, el) - return val -} - -export function toggle(isDarkMode: boolean, el?: Element) { - toggle0(isDarkMode, el) - localStorage.setItem(STORAGE_KEY, isDarkMode ? STORAGE_FLAG : '') -} - -export function isDarkMode() { - return localStorage.getItem(STORAGE_KEY) === STORAGE_FLAG -} \ No newline at end of file diff --git a/src/util/document.ts b/src/util/document.ts index 35afd48a4..7ec75f374 100644 --- a/src/util/document.ts +++ b/src/util/document.ts @@ -4,7 +4,7 @@ export const setDir = (direction: 'ltr' | 'rtl') => { htmlEl?.setAttribute('dir', direction) } -export const setLocale = (locale: timer.Locale) => { +export const setLocale = (locale: tt4b.Locale) => { if (isNotExtensionPage()) return const htmlEl = document.getElementsByTagName("html")?.[0] htmlEl?.setAttribute('data-locale', locale) diff --git a/src/util/echarts.ts b/src/util/echarts.ts index 1b678ac75..110d01bf5 100644 --- a/src/util/echarts.ts +++ b/src/util/echarts.ts @@ -1,18 +1,10 @@ import type { - AriaComponentOption, - BarSeriesOption, - ComposeOption, - GridComponentOption, - LegendComponentOption, - LineSeriesOption, - PieSeriesOption, - ScatterSeriesOption, - TitleComponentOption, - ToolboxComponentOption, - VisualMapComponentOption, + AriaComponentOption, BarSeriesOption, ComposeOption, GaugeSeriesOption, GridComponentOption, LegendComponentOption, LineSeriesOption, + PieSeriesOption, ScatterSeriesOption, TitleComponentOption, ToolboxComponentOption, VisualMapComponentOption, } from "echarts" import type { AxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes.js' import { isRtl } from "./document" +import { isTuple } from './tuple' export const processAria = (option: ComposeOption<AriaComponentOption>, chartDecal: boolean) => { if (!option) return @@ -65,7 +57,7 @@ const generateAriaOption = (chartDecal: boolean): AriaComponentOption => { } } -type SupportedSeriesOption = PieSeriesOption | LineSeriesOption | BarSeriesOption | ScatterSeriesOption +type SupportedSeriesOption = PieSeriesOption | LineSeriesOption | BarSeriesOption | ScatterSeriesOption | GaugeSeriesOption type GlobalEcOption = ComposeOption< | TitleComponentOption | GridComponentOption | LegendComponentOption | ToolboxComponentOption | VisualMapComponentOption @@ -86,6 +78,7 @@ export const processFont = (toProcess: unknown, elFont: string) => { } }) processArrayLike(series, s => { + if (!('label' in s)) return s.label = { ...s.label, fontFamily: s.label?.fontFamily ?? elFont, @@ -127,6 +120,9 @@ export const processAnimation = (toProcess: unknown, duration: number) => { if (duration > 0) { s.animation = true s.animationDuration = duration + s.animationDurationUpdate = duration + s.animationEasing ??= 'cubicInOut' + s.animationEasingUpdate ??= 'cubicInOut' } else { s.animation = false } @@ -236,7 +232,7 @@ const swapPosition = (option: { left?: string | number, right?: string | number } const swapBorderRadius = (option: number[]) => { - if (option?.length !== 4) return + if (!isTuple(option, 4)) return let t = option[0] option[0] = option[1] diff --git a/src/util/encode.ts b/src/util/encode.ts new file mode 100644 index 000000000..f43fb5f70 --- /dev/null +++ b/src/util/encode.ts @@ -0,0 +1,89 @@ +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + +export function encodeBase64(str: string | Uint8Array<ArrayBuffer>): string { + const bytes = typeof str === 'string' ? new TextEncoder().encode(str) : str + let result = '' + + for (let i = 0; i < bytes.length; i += 3) { + const byte1 = bytes[i] ?? 0 + const byte2 = bytes[i + 1] ?? 0 + const byte3 = bytes[i + 2] ?? 0 + + const char1 = byte1 >> 2 + const char2 = ((byte1 & 0x03) << 4) | (byte2 >> 4) + const char3 = ((byte2 & 0x0F) << 2) | (byte3 >> 6) + const char4 = byte3 & 0x3F + + result += BASE64_CHARS.charAt(char1) + BASE64_CHARS.charAt(char2) + + (i + 1 < bytes.length ? BASE64_CHARS.charAt(char3) : '=') + + (i + 2 < bytes.length ? BASE64_CHARS.charAt(char4) : '=') + } + + return result +} + +export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> { + const chars = BASE64_CHARS + const len = base64.length + + let validLen = len + while (validLen > 0 && base64[validLen - 1] === '=') validLen-- + + const bytesLength = Math.floor(validLen * 3 / 4) + const bytes = new Uint8Array(bytesLength) + let byteIdx = 0 + let buffer = 0 + let bitsCollected = 0 + + for (let i = 0; i < len; i++) { + const c = base64[i] + if (c === '=' || !c) break + const idx = chars.indexOf(c) + if (idx === -1) throw new Error('Invalid Base64 character') + + buffer = (buffer << 6) | idx + bitsCollected += 6 + + if (bitsCollected >= 8) { + bitsCollected -= 8 + bytes[byteIdx++] = (buffer >> bitsCollected) & 0xff + } + } + return bytes +} + +export function encodeBase32(buffer: Uint8Array<ArrayBuffer>) { + let result = '' + let bits = 0 + let value = 0 + + for (const byte of buffer) { + value = (value << 8) | byte + bits += 8 + while (bits >= 5) { + bits -= 5 + result += BASE32_CHARS[(value >>> bits) & 0x1f] + } + } + + if (bits > 0) { + result += BASE32_CHARS[(value << (5 - bits)) & 0x1f] + } + + return result +} + +export function decodeBase32(base32: string): Uint8Array<ArrayBuffer> { + const str = base32.toUpperCase().replace(/=+$/, '') + const bytes = new Uint8Array(Math.floor(str.length * 5 / 8)) + let buf = 0, bits = 0, idx = 0 + for (const c of str) { + const v = BASE32_CHARS.indexOf(c) + if (v === -1) continue + buf = (buf << 5) | v + bits += 5 + if (bits >= 8) { bits -= 8; bytes[idx++] = (buf >> bits) & 0xff } + } + return bytes +} \ No newline at end of file diff --git a/src/util/fifo-cache.ts b/src/util/fifo-cache.ts index 44052f2e2..2a8756c02 100644 --- a/src/util/fifo-cache.ts +++ b/src/util/fifo-cache.ts @@ -41,6 +41,10 @@ class FIFOCache<T> { } } + get(origin: string): T | undefined { + return this.map[origin] + } + async getOrSupply(key: string, supplier: () => PromiseLike<T>): Promise<T> { const exist = this.map[key] if (exist) { diff --git a/src/util/guard.ts b/src/util/guard.ts new file mode 100644 index 000000000..d1d57cbb2 --- /dev/null +++ b/src/util/guard.ts @@ -0,0 +1,7 @@ +import { createOptionalGuard, isInt } from 'typescript-guard' + +export const isOptionalInt = createOptionalGuard(isInt) + +export const isRecord = (unk: unknown): unk is Record<string, unknown> => typeof unk === 'object' && unk !== null + +export const isVector2 = (unk: unknown): unk is Vector<2> => Array.isArray(unk) && unk.length === 2 && unk.every(isInt) \ No newline at end of file diff --git a/src/util/lang.ts b/src/util/lang.ts index 96656669a..76cd6a2e6 100644 --- a/src/util/lang.ts +++ b/src/util/lang.ts @@ -1,36 +1,12 @@ -/** - * @since 2.1.7 - */ -export const deepCopy = <T = any | null | undefined>(obj: T): T => { - if (!obj) return obj - if (typeof obj !== 'object') return obj +export const mergeObject = <T extends Record<string, any>>(defaults: T, newVal: Partial<T> | undefined): T => { + if (newVal === undefined) return defaults - let deep: Record<string, any> = {} - Object.entries(obj).forEach(([k, v]) => { - if (typeof v !== "object" || v === null) { - deep[k] = v - } else if (Array.isArray(v)) { - deep[k] = v.map(e => deepCopy(e)) - } else if (v instanceof Set) { - deep[k] = new Set(v) - } else if (v instanceof Map) { - deep[k] = new Map(v) - } else if (v instanceof Date) { - deep[k] = new Date(v.getTime()) - } else { - // Ignored type - deep[k] = deepCopy(v) - } - }) - return deep as T -} - -export const mergeObject = <T extends Record<string, any>>(defaults: T, newVal: Partial<T>): T => { Object.entries(newVal).forEach(([k, v]) => { - if (typeof v === 'object' && !!v && !Array.isArray(v)) { + if (typeof v === 'object' && !!v && !Array.isArray(v) && typeof defaults[k] === 'object' && !!defaults[k]) { (defaults as any)[k] = mergeObject(defaults[k], v as Record<string, any>) + } else { + (defaults as any)[k] = v } - (defaults as any)[k] = v }) return defaults } \ No newline at end of file diff --git a/src/util/limit.ts b/src/util/limit.ts index 67ff57b46..726938a4e 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -1,22 +1,15 @@ import { EXCLUDING_PREFIX } from './constant/remain-host' import { getWeekDay, MILL_PER_MINUTE, MILL_PER_SECOND } from "./time" -export const DELAY_MILL = 5 * MILL_PER_MINUTE - -export const cleanCond = (origin: string | undefined): string | undefined => { - if (!origin) return undefined - - const startIdx = origin?.indexOf('//') - const endIdx = origin?.indexOf('?') - let res = origin.substring(startIdx === -1 ? 0 : startIdx + 2, endIdx === -1 ? undefined : endIdx) - while (res.endsWith('/')) { - res = res.substring(0, res.length - 1) - } - return res || undefined -} +const GLOBSTAR_TOKEN = `__GLOBSTAR${Math.random().toString(36).slice(2, 6)}__` const matchUrl = (cond: string, url: string): boolean => { - return new RegExp(`^.*//${cond.split('*').join('.*')}`).test(url) + const pattern = cond + .replace(/\*\*/g, GLOBSTAR_TOKEN) + .split('*') + .join('.*') + .replaceAll(GLOBSTAR_TOKEN, '.+') + return new RegExp(`^.*//${pattern}`).test(url) } /** @@ -24,10 +17,11 @@ const matchUrl = (cond: string, url: string): boolean => { * @param cond * @param url */ -export function matches(cond: timer.limit.Item['cond'], url: string): boolean { +export function matches(cond: tt4b.limit.Item['cond'], url: string): boolean { let hit = false for (let i = cond.length - 1; i >= 0; i--) { const rule = cond[i] + if (rule === undefined) continue if (rule.startsWith(EXCLUDING_PREFIX)) { if (matchUrl(rule.slice(1), url)) return false } else { @@ -42,10 +36,11 @@ export function matches(cond: timer.limit.Item['cond'], url: string): boolean { * @param cond * @param url */ -export function matchCond(cond: timer.limit.Item['cond'], url: string): string[] { +export function matchCond(cond: tt4b.limit.Item['cond'], url: string): string[] { const matchedNormalRules: string[] = [] for (let i = cond.length - 1; i >= 0; i--) { const rule = cond[i] + if (rule === undefined) continue if (rule.startsWith(EXCLUDING_PREFIX)) { // Immediately return an empty array if an exclusion rule is hit if (matchUrl(rule.slice(1), url)) return [] @@ -60,78 +55,38 @@ export const meetLimit = (limit: number | undefined, value: number | undefined): return !!limit && !!value && value > limit } -export const meetTimeLimit = ( - limitSec: number | undefined, wastedMill: number | undefined, - allowDelay: boolean | undefined, delayCount: number | undefined -): boolean => { - let realLimit = (limitSec ?? 0) * MILL_PER_SECOND - allowDelay && realLimit && (realLimit += DELAY_MILL * (delayCount ?? 0)) - return meetLimit(realLimit, wastedMill) -} - -type TimeLimitState = 'NORMAL' | 'REMINDER' | 'LIMITED' - -type LimitStateResult = { - daily: TimeLimitState - weekly: TimeLimitState -} - -export function calcTimeState(item: timer.limit.Item, reminderMills: number): LimitStateResult { - let res: LimitStateResult = { - daily: "NORMAL", - weekly: "NORMAL", - } - const { - time, waste, delayCount, - weekly, weeklyWaste, weeklyDelayCount, - allowDelay, - } = item || {} - // 1. daily states - if (meetTimeLimit(time, waste, allowDelay, delayCount)) { - res.daily = 'LIMITED' - } else if (reminderMills && meetTimeLimit(time, waste + reminderMills, allowDelay, delayCount)) { - res.daily = 'REMINDER' - } - - // 2. weekly states - if (meetTimeLimit(weekly, weeklyWaste, allowDelay, weeklyDelayCount)) { - res.weekly = 'LIMITED' - } else if (reminderMills && meetTimeLimit(weekly, weeklyWaste + reminderMills, allowDelay, weeklyDelayCount)) { - res.weekly = 'REMINDER' - } - - return res -} +type LimitInfo = { wasted: number, maxLimit: number | undefined } +type DelayInfo = { count: number, duration: number, allow: boolean } -export function hasLimited(item: timer.limit.Item): boolean { - return hasDailyLimited(item) || hasWeeklyLimited(item) +export const meetTimeLimit = (limit: LimitInfo, delay: DelayInfo) => { + const { wasted, maxLimit = 0 } = limit + const { count, duration, allow } = delay + const realLimit = allow ? maxLimit + duration * MILL_PER_MINUTE * (count ?? 0) : maxLimit + return meetLimit(realLimit, wasted) } -export function hasDailyLimited(item: timer.limit.Item): boolean { - const { time, count = 0, waste = 0, visit = 0, delayCount = 0, allowDelay = false } = item || {} - const timeMeet = meetTimeLimit(time, waste, allowDelay, delayCount) - const countMeet = meetLimit(count, visit) - return timeMeet || countMeet +export function hasDailyLimited(item: tt4b.limit.Item, delayDuration: number): boolean { + const { time, count, waste, visit, delayCount, allowDelay } = item + const delay = { count: delayCount, duration: delayDuration, allow: !!allowDelay } + const limit = { wasted: waste, maxLimit: (time ?? 0) * MILL_PER_SECOND } + return meetTimeLimit(limit, delay) || meetLimit(count, visit) } -export function hasWeeklyLimited(item: timer.limit.Item): boolean { - const { weekly = 0, weeklyCount = 0, weeklyWaste = 0, weeklyVisit = 0, weeklyDelayCount = 0, allowDelay = false } = item || {} - const timeMeet = meetTimeLimit(weekly, weeklyWaste, allowDelay, weeklyDelayCount) - const countMeet = meetLimit(weeklyCount, weeklyVisit) - return timeMeet || countMeet +export function hasWeeklyLimited(item: tt4b.limit.Item, delayDuration: number): boolean { + const { weekly, weeklyCount, weeklyWaste, weeklyVisit, weeklyDelayCount, allowDelay } = item + const delay = { count: weeklyDelayCount, duration: delayDuration, allow: !!allowDelay } + const limit = { wasted: weeklyWaste, maxLimit: (weekly ?? 0) * MILL_PER_SECOND } + return meetTimeLimit(limit, delay) || meetLimit(weeklyCount, weeklyVisit) } -export function isEnabledAndEffective(rule: timer.limit.Rule): boolean { - return !!rule?.enabled && isEffective(rule.weekdays) +export function hasLimited(item: tt4b.limit.Item, delayDuration: number): boolean { + return hasDailyLimited(item, delayDuration) || hasWeeklyLimited(item, delayDuration) } -export function isEffective(weekdays: timer.limit.Rule['weekdays']): boolean { +export function isEffective(weekdays: tt4b.limit.Rule['weekdays'], weekday?: number): boolean { const weekdayLen = weekdays?.length - if (!weekdayLen || weekdayLen <= 0 || weekdayLen >= 7) { - return true - } - const weekday = getWeekDay(new Date()) - return weekdays.includes(weekday) + if (!weekdayLen || weekdayLen <= 0 || weekdayLen >= 7) return true + return weekdays.includes(weekday ?? getWeekDay(new Date())) } const idx2Str = (time: number | undefined): string => { @@ -151,7 +106,7 @@ export const dateMinute2Idx = (date: Date): number => { return hour * 60 + min } -export const period2Str = (p: timer.limit.Period | undefined): string => { - const [start, end] = p || [] +export const period2Str = (p: tt4b.limit.Period | undefined): string => { + const [start, end] = p ?? [] return `${idx2Str(start)}-${idx2Str(end)}` } diff --git a/src/util/merge.ts b/src/util/merge.ts deleted file mode 100644 index 1a05783c0..000000000 --- a/src/util/merge.ts +++ /dev/null @@ -1,20 +0,0 @@ -type Method = timer.stat.MergeMethod - -export const ALL_MERGE_METHODS: Method[] = ['date', 'domain', 'cate', 'group'] - -function judgeAdded(target: Method, newVal: Method[], oldVal: Method[]): boolean { - return newVal?.includes?.(target) && !oldVal?.includes?.(target) -} - -export function processNewMethod(oldVal: Method[] | undefined, newVal: Method[]): Method[] { - oldVal = oldVal || [] - if (judgeAdded('cate', newVal, oldVal)) { - // Add cate, so remove domain - return newVal.filter?.(v => v !== 'domain') - } - if (judgeAdded('domain', newVal, oldVal)) { - // Add domain, so remove cate - return newVal.filter?.(v => v !== 'cate') - } - return newVal -} \ No newline at end of file diff --git a/src/util/number.ts b/src/util/number.ts index 262eec576..8d89ece39 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -5,22 +5,14 @@ * https://opensource.org/licenses/MIT */ -/** - * @since 0.6.0 - * @returns T/F - */ -export function isInteger(str: string): boolean { - return tryParseInteger(str)[0] -} - /** * @since 0.6.0 * @returns [true, intValue] if str is an integer, or [false, str] */ -export function tryParseInteger(str: string): [boolean, number | string] { +export function tryParseInteger(str: string): [true, number] | [false, string] { const num: number = Number.parseInt(str) const isInteger: boolean = !isNaN(num) && num.toString().length === str.length - return [isInteger, isInteger ? num : str] + return isInteger ? [true, num] : [false, str] } /** @@ -28,4 +20,6 @@ export function tryParseInteger(str: string): [boolean, number | string] { */ export function randomIntBetween(lowerInclusive: number, upperExclusive: number): number { return Math.floor(Math.random() * (upperExclusive - lowerInclusive)) + lowerInclusive -} \ No newline at end of file +} + +export const clamp = (v: number, min: number, max: number): number => Math.min(max, Math.max(min, v)) \ No newline at end of file diff --git a/src/util/pattern.ts b/src/util/pattern.ts index 22f39aa0b..d8f0e1c23 100644 --- a/src/util/pattern.ts +++ b/src/util/pattern.ts @@ -47,10 +47,6 @@ export function isIpAndPort(host: string) { return reg.test(host) } -export function isLocalhost(host: string) { - return host?.startsWith?.('localhost') -} - /** * Test whether the host is a valid host * @@ -95,8 +91,8 @@ export function isValidVirtualHost(host: string) { const segments = host.split('/') // Can't be normal host if (segments.length === 1) return false - if (!isValidHost(segments[0])) return false - return true + const [seg] = segments + return seg && isValidHost(seg) } /** @@ -109,7 +105,7 @@ export function judgeVirtualFast(host: string): boolean { return host?.includes('/') || host?.includes('*') } -export type HostInfo = { +type HostInfo = { /** * Including port */ @@ -123,21 +119,21 @@ export function extractHostname(url: string): HostInfo { return { host: fileHost, protocol: 'file' } } - let host: string - let protocol: string + let host: string | undefined + let protocol: string | undefined const indexOfDoubleSlashes = url.indexOf("//") if (indexOfDoubleSlashes > -1) { const splits = url.split('/') host = splits[2] protocol = splits[0] - protocol = protocol.substring(0, protocol.length - 1) + protocol = protocol?.substring(0, protocol.length - 1) } else { host = url.split('/')[0] protocol = '' } - host = host.split('?')[0] + host = host?.split('?')[0] - return { host, protocol } + return { host: host ?? '', protocol: protocol ?? '' } } const FILE_PREFIX = "file://" @@ -205,4 +201,4 @@ export function compileAntPattern(antPattern: string): RegExp { } return new RegExp("^(.+://)?" + patternStr + "/?([\\?#].*)?$") -} \ No newline at end of file +} diff --git a/src/util/period.ts b/src/util/period.ts index 920000c44..47b246182 100644 --- a/src/util/period.ts +++ b/src/util/period.ts @@ -5,15 +5,14 @@ * https://opensource.org/licenses/MIT */ -import { groupBy } from "./array" import { MILL_PER_DAY, MILL_PER_MINUTE } from "./time" export const MINUTE_PER_PERIOD = 15 -export const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD +const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD export const MAX_PERIOD_ORDER = PERIOD_PER_DATE - 1 -export const MILL_PER_PERIOD = MINUTE_PER_PERIOD * MILL_PER_MINUTE +const MILL_PER_PERIOD = MINUTE_PER_PERIOD * MILL_PER_MINUTE -export function keyOf(time: Date | number, order?: number): timer.period.Key { +export function keyOf(time: Date | number, order?: number): tt4b.period.Key { time = time instanceof Date ? time : new Date(time) const year = time.getFullYear() const month = time.getMonth() + 1 @@ -24,13 +23,7 @@ export function keyOf(time: Date | number, order?: number): timer.period.Key { return { year, month, date, order } } -export function copyKeyWith(old: timer.period.Key, newOrder: number): timer.period.Key { - const { year, month, date } = old - return { year, month, date, order: newOrder } -} - -export function indexOf(key: timer.period.Key): number { - if (!key) return 0 +export function indexOf(key: tt4b.period.Key): number { const { year, month, date, order } = key return (year << 18) | (month << 14) @@ -38,90 +31,34 @@ export function indexOf(key: timer.period.Key): number { | order } -export function compare(a: timer.period.Key, b: timer.period.Key): number { +export function compare(a: tt4b.period.Key, b: tt4b.period.Key): number { return indexOf(a) - indexOf(b) } -export function keyBefore(key: timer.period.Key, orderCount: number): timer.period.Key { - let order = key.order - let decomposition = 0 - - while (order < orderCount) { - decomposition++ - order += PERIOD_PER_DATE - } - order = order - orderCount - if (decomposition) { - const newDate = new Date(startOfKey(key).getTime() - MILL_PER_DAY * decomposition) - return keyOf(newDate, order) - } else { - return copyKeyWith(key, order) - } -} - -export function after(key: timer.period.Key, orderCount: number): timer.period.Key { +export function after(key: tt4b.period.Key, orderCount: number): tt4b.period.Key { const date = new Date(key.year, key.month - 1, key.date, 0, (key.order + orderCount) * MINUTE_PER_PERIOD, 1) return keyOf(date) } -export function startOfKey(key: timer.period.Key): Date { +export function startOfKey(key: tt4b.period.Key): Date { return new Date(key.year, key.month - 1, key.date, 0, MINUTE_PER_PERIOD * key.order) } -export function lastKeyOfLastDate(key: timer.period.Key): timer.period.Key { - return keyBefore(key, key.order + 1) -} - -export function getDateString(key: timer.period.Key) { +export function getDateString(key: tt4b.period.Key) { return `${key.year}${key.month < 10 ? '0' : ''}${key.month}${key.date < 10 ? '0' : ''}${key.date}` } -export function rowOf(endKey: timer.period.Key, duration?: number, milliseconds?: number): timer.period.Row { +export function rowOf(endKey: tt4b.period.Key, duration?: number, milliseconds?: number): tt4b.period.Row { duration = duration || 1 milliseconds = milliseconds || 0 const date = getDateString(endKey) const endStart = startOfKey(endKey) - const endTime = new Date(endStart.getTime() + MILL_PER_PERIOD) - const startTime = duration === 1 ? endStart : new Date(endStart.getTime() - (duration - 1) * MILL_PER_PERIOD) + const endTime = endStart.getTime() + MILL_PER_PERIOD + const startTime = duration === 1 ? endStart.getTime() : endStart.getTime() - (duration - 1) * MILL_PER_PERIOD return { startTime, endTime, milliseconds, date } } -export function startOrderOfRow(row: timer.period.Row): number { - return (row.startTime.getHours() * 60 + row.startTime.getMinutes()) / MINUTE_PER_PERIOD -} - -/** - * @param rows period rows - * @returns [0, 12) - */ -export function calcMostPeriodOf2Hours(rows: timer.period.Result[]): number { - const periodCount = rows?.length ?? 0 - // Order [0, 95] - const averageTimePerPeriod: { [order: number]: number } = groupBy(rows, - p => p.order, - (grouped: timer.period.Result[]) => { - const periodMills = grouped.map(p => p.milliseconds) - if (!periodCount) { - return 0 - } - return Math.floor(periodMills.reduce((a, b) => a + b, 0) / periodCount) - } - ) - // Merged per 2 hours - const averageTimePer2Hours: { [idx: number]: number } = groupBy(Object.entries(averageTimePerPeriod), - ([order]) => Math.floor(parseInt(order) / 8), - averages => averages.map(a => a[1]).reduce((a, b) => a + b, 0) - ) - // The two most frequent online hours - const most2Hour: number = parseInt( - Object.entries(averageTimePer2Hours) - .sort((a, b) => a[1] - b[1]) - .reverse()[0]?.[0] - ) - return most2Hour -} - -function generateOrderMap(data: timer.period.Row[], periodSize: number): Map<number, number> { +function generateOrderMap(data: tt4b.period.Row[], periodSize: number): Map<number, number> { const map: Map<number, number> = new Map() data.forEach(item => { const key = Math.floor(startOrderOfRow(item) / periodSize) @@ -131,8 +68,13 @@ function generateOrderMap(data: timer.period.Row[], periodSize: number): Map<num return map } -function cvt2AverageResult(map: Map<number, number>, periodSize: number, dateNum: number): timer.period.Row[] { - const result: timer.period.Row[] = [] +function startOrderOfRow(row: tt4b.period.Row): number { + const d = new Date(row.startTime) + return (d.getHours() * 60 + d.getMinutes()) / MINUTE_PER_PERIOD +} + +function cvt2AverageResult(map: Map<number, number>, periodSize: number, dateNum: number): tt4b.period.Row[] { + const result: tt4b.period.Row[] = [] let period = keyOf(new Date(), 0) for (let i = 0; i < PERIOD_PER_DATE / periodSize; i++) { const key = period.order / periodSize @@ -144,11 +86,12 @@ function cvt2AverageResult(map: Map<number, number>, periodSize: number, dateNum return result } -export function averageByDay(data: timer.period.Row[], periodSize: number): timer.period.Row[] { +export function averageByDay(data: tt4b.period.Row[], periodSize: number): tt4b.period.Row[] { if (!data?.length) return [] const rangeStart = data[0]?.startTime const rangeEnd = data[data.length - 1]?.endTime - const dateNum = (rangeEnd.getTime() - rangeStart.getTime()) / MILL_PER_DAY + if (!rangeStart || !rangeEnd) return [] + const dateNum = (rangeEnd - rangeStart) / MILL_PER_DAY const map = generateOrderMap(data, periodSize) return cvt2AverageResult(map, periodSize, dateNum) } diff --git a/src/util/site.ts b/src/util/site.ts index a188d5aa4..ac7cb7e78 100644 --- a/src/util/site.ts +++ b/src/util/site.ts @@ -54,12 +54,12 @@ export function generateSiteLabel(host: string, name?: string): string { * * @since 3.0.0 */ -export function supportCategory(siteKey: timer.site.SiteKey | undefined): boolean { +export function supportCategory(siteKey: tt4b.site.SiteKey | undefined): boolean { const { type } = siteKey || {} return type === 'normal' } -export function siteEqual(a: timer.site.SiteKey | undefined, b: timer.site.SiteKey | undefined) { +export function siteEqual(a: tt4b.site.SiteKey | undefined, b: tt4b.site.SiteKey | undefined) { if (!a && !b) return true if (a === b) return true return a?.host === b?.host && a?.type === b?.type @@ -72,39 +72,40 @@ export const CATE_NOT_SET_ID = -1 type SiteIdentityPrefix = 'n' | 'm' | 'v' -const TYPE_PREFIX_MAP: { [type in timer.site.Type]: SiteIdentityPrefix } = { +const TYPE_PREFIX_MAP: { [type in tt4b.site.Type]: SiteIdentityPrefix } = { normal: "n", merged: "m", virtual: "v", } -const PREFIX_TYPE_MAP: { [prefix in SiteIdentityPrefix]: timer.site.Type } = { +const PREFIX_TYPE_MAP: { [prefix in SiteIdentityPrefix]: tt4b.site.Type } = { n: 'normal', m: 'merged', v: 'virtual', } -export function identifySiteKey(site: timer.site.SiteKey | undefined): string { +export function identifySiteKey(site: tt4b.site.SiteKey | undefined): string { if (!site) return '' const { host, type } = site || {} return (TYPE_PREFIX_MAP[type] ?? ' ') + (host || '') } -export function parseSiteKeyFromIdentity(keyIdentity: string): timer.site.SiteKey | undefined { - const type = PREFIX_TYPE_MAP[keyIdentity?.charAt?.(0) as SiteIdentityPrefix] +export function parseSiteIdentity(identity: string | undefined): tt4b.site.SiteKey | undefined { + if (!identity) return + const type = PREFIX_TYPE_MAP[identity.charAt(0) as SiteIdentityPrefix] if (!type) return - const host = keyIdentity?.substring(1)?.trim?.() + const host = identity.substring(1).trim() if (!host) return return { type, host } } -function cloneSiteKey(origin: timer.site.SiteKey | undefined): timer.site.SiteKey | undefined { +function cloneSiteKey(origin: tt4b.site.SiteKey | undefined): tt4b.site.SiteKey | undefined { if (!origin) return return { host: origin.host, type: origin.type } } -export function distinctSites(list: timer.site.SiteKey[]): timer.site.SiteKey[] { - const map: Record<string, timer.site.SiteKey> = {} +export function distinctSites(list: tt4b.site.SiteKey[]): tt4b.site.SiteKey[] { + const map: Record<string, tt4b.site.SiteKey> = {} list?.forEach(ele => { const key = identifySiteKey(ele) if (map[key]) return @@ -115,23 +116,29 @@ export function distinctSites(list: timer.site.SiteKey[]): timer.site.SiteKey[] } export class SiteMap<T> { - private innerMap: Record<string, [timer.site.SiteKey, T | undefined]> + private innerMap: Record<string, [tt4b.site.SiteKey, T]> constructor() { this.innerMap = {} } - public put(site: timer.site.SiteKey, t: T | undefined): void { + static identify<T extends tt4b.site.SiteKey>(data: T[]): SiteMap<T> { + const map = new SiteMap<T>() + data.forEach(item => map.put(item, item)) + return map + } + + public put(site: tt4b.site.SiteKey, t: T): void { const key = identifySiteKey(site) this.innerMap[key] = [site, t] } - public get(site: timer.site.SiteKey): T | undefined { + public get(site: tt4b.site.SiteKey): T | null { const key = identifySiteKey(site) - return this.innerMap[key]?.[1] + return this.innerMap[key]?.[1] ?? null } - public map<R>(mapper: (key: timer.site.SiteKey, value: T | undefined) => R): R[] { + public map<R>(mapper: (key: tt4b.site.SiteKey, value: T) => R): R[] { return Object.values(this.innerMap).map(([site, val]) => mapper?.(site, val)) } @@ -139,11 +146,11 @@ export class SiteMap<T> { return Object.keys(this.innerMap).length } - public keys(): timer.site.SiteKey[] { + public keys(): tt4b.site.SiteKey[] { return Object.values(this.innerMap).map(v => v[0]) } - public forEach(func: (k: timer.site.SiteKey, v: T | undefined, idx: number) => void) { + public forEach(func: (k: tt4b.site.SiteKey, v: T, idx: number) => void) { if (!func) return Object.values(this.innerMap).forEach(([k, v], idx) => func(k, v, idx)) } diff --git a/src/util/stat.ts b/src/util/stat.ts index 7904d2616..1fa609773 100644 --- a/src/util/stat.ts +++ b/src/util/stat.ts @@ -1,3 +1,4 @@ +import { toMap } from './array' import { identifySiteKey } from "./site" /** @@ -6,17 +7,17 @@ import { identifySiteKey } from "./site" * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -export function isNotZeroResult(target: timer.core.Result): boolean { +export function isNotZeroResult(target: tt4b.core.Result): boolean { return !!target.focus || !!target.time } -export function resultOf(focus: number, time: number): timer.core.Result { +export function resultOf(focus: number, time: number): tt4b.core.Result { return { focus, time } } -export const ALL_DIMENSIONS: timer.core.Dimension[] = ['focus', 'time'] +export const ALL_DIMENSIONS: tt4b.core.Dimension[] = ['focus', 'time'] -export function identifyTargetKey(targetKey: timer.stat.TargetKey): string { +export function identifyTargetKey(targetKey: tt4b.stat.TargetKey): string { if ('cateKey' in targetKey) { return `cate_${targetKey.cateKey}` } else if ('siteKey' in targetKey) { @@ -26,56 +27,63 @@ export function identifyTargetKey(targetKey: timer.stat.TargetKey): string { } } -export function identifyStatKey(rowKey: timer.stat.StatKey) { +export function identifyStatKey(rowKey: tt4b.stat.StatKey) { const { date } = rowKey || {} return [identifyTargetKey(rowKey), date ?? ''].join('_') } -export const isNormalSite = (row: timer.stat.Row): row is timer.stat.SiteRow => { +export const isNormalSite = (row: tt4b.stat.Row): row is tt4b.stat.SiteRow => { return 'siteKey' in row && row.siteKey.type === 'normal' } -export const isMergedSite = (row: timer.stat.Row): row is timer.stat.SiteRow => { - return 'siteKey' in row && row.siteKey.type === 'merged' -} - -export const isGroup = (row: timer.stat.Row): row is timer.stat.GroupRow => { +export const isGroup = (row: tt4b.stat.StatKey): row is tt4b.stat.GroupRow => { return 'groupKey' in row } -export const isSite = (row: timer.stat.Row): row is timer.stat.SiteRow => { +export const isSite = (row: tt4b.stat.StatKey): row is tt4b.stat.SiteRow => { return 'siteKey' in row } -export const isCate = (row: timer.stat.Row): row is timer.stat.CateRow => { +export const isCate = (row: tt4b.stat.StatKey): row is tt4b.stat.CateRow => { return 'cateKey' in row } -export const getHost = (row: timer.stat.Row): string | undefined => { +export const getHost = (row: tt4b.stat.Row): string | undefined => { return 'siteKey' in row ? row.siteKey.host : undefined } -export const getAlias = (row: timer.stat.Row): string | undefined => { +export const getAlias = (row: tt4b.stat.Row): string | undefined => { return 'alias' in row ? row.alias : undefined } -export const getIconUrl = (row: timer.stat.Row): string | undefined => { +export const getIconUrl = (row: tt4b.stat.Row): string | undefined => { return 'iconUrl' in row ? row.iconUrl : undefined } -export const getRelatedCateId = (row: timer.stat.Row): number | undefined => { +export const getRelatedCateId = (row: tt4b.stat.Row): number | undefined => { if ('cateId' in row) return row.cateId if ('cateKey' in row) return row.cateKey return undefined } -export const getComposition = (row: timer.stat.Row, dimension: timer.core.Dimension): timer.stat.RemoteCompositionVal[] => { +export const getComposition = (row: tt4b.stat.Row, dimension: tt4b.core.Dimension): tt4b.stat.RemoteCompositionVal[] => { return 'composition' in row ? row.composition?.[dimension] ?? [] : [] } -export const getGroupName = (groupMap: Record<string, chrome.tabGroups.TabGroup>, row: timer.stat.GroupRow): string => { +export const getGroupName = (groupMap: Record<string, chrome.tabGroups.TabGroup>, row: tt4b.stat.GroupRow): string => { const { groupKey } = row const title = groupMap[groupKey]?.title return title ?? `ID: ${groupKey}` +} + +export const mergeWith = async <T extends tt4b.core.RowKey,>( + original: T[], exist: tt4b.core.Row[], + mergeFunc: (o: T, e: tt4b.core.Row | undefined) => Awaitable<void>, +) => { + const keyOf = ({ date, host }: tt4b.core.RowKey) => `${date}${host}` + const existMap = toMap(exist, keyOf) + for (const o of original) { + await mergeFunc(o, existMap[keyOf(o)]) + } } \ No newline at end of file diff --git a/src/util/time.ts b/src/util/time.ts index 2782444c5..13656e862 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -18,26 +18,13 @@ import { isRtl } from "./document" * * Parse the time to string */ -export function formatTime(time: Date | string | number, cFormat?: string) { - const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}' +export function formatTime(time: Date | number, format?: string) { + format ??= '{y}-{m}-{d} {h}:{i}:{s}' let date: Date if (time instanceof Date) { date = time } else { - if ((typeof time === 'string')) { - if ((/^[0-9]+$/.test(time))) { - // support "1548221490638" - time = parseInt(time) - } else { - // support safari - time = time.replace(new RegExp(/-/gm), '/') - } - } - - if ((typeof time === 'number') && (time.toString().length === 10)) { - time = time * 1000 - } - date = new Date(time) + date = new Date(time.toString().length === 10 ? time * 1000 : time) } const formatObj: Record<string, number> = { y: date.getFullYear(), @@ -50,6 +37,7 @@ export function formatTime(time: Date | string | number, cFormat?: string) { } const timeStr = format.replace(/{([ymdhisa])+}/g, (_result, key) => { const value = formatObj[key] + if (value === undefined) return key // Note: getDay() returns 0 on Sunday if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] } return value.toString().padStart(2, '0') @@ -57,14 +45,16 @@ export function formatTime(time: Date | string | number, cFormat?: string) { return timeStr } -export function formatTimeYMD(time: Date | string | number) { +export function formatTimeYMD(time: Date | number): string { return formatTime(time, '{y}{m}{d}') } +type PeriodMsgFormat = Record<'dayMsg' | 'hourMsg' | 'minuteMsg' | 'secondMsg', string> + /** * Format milliseconds for display */ -export function formatPeriod(milliseconds: number, message: Record<'dayMsg' | 'hourMsg' | 'minuteMsg' | 'secondMsg', string>): string { +function formatPeriod(milliseconds: number, message: PeriodMsgFormat): string { const prefix = milliseconds < 0 ? '-' : '' milliseconds = Math.abs(milliseconds) const { dayMsg, hourMsg, minuteMsg, secondMsg } = message @@ -92,29 +82,45 @@ export function formatPeriod(milliseconds: number, message: Record<'dayMsg' | 'h return prefix + result } +const PERIOD_RTL: PeriodMsgFormat = { + dayMsg: 's{second} m{minute} h{hour} d{day}', + hourMsg: 's{second} m{minute} h{hour}', + minuteMsg: 's{second} m{minute}', + secondMsg: 's{second}', +} +const PERIOD_RTL_SIMPLIFIED: PeriodMsgFormat = { + dayMsg: 's{second}m{minute}h{hour}d{day}', + hourMsg: 's{second}m{minute}h{hour}', + minuteMsg: 's{second}m{minute}', + secondMsg: 's{second}', +} +const PERIOD_LTR: PeriodMsgFormat = { + dayMsg: '{day}d {hour}h {minute}m {second}s', + hourMsg: '{hour}h {minute}m {second}s', + minuteMsg: '{minute}m {second}s', + secondMsg: '{second}s', +} +const PERIOD_LTR_SIMPLIFIED: PeriodMsgFormat = { + dayMsg: '{day}d{hour}h{minute}m{second}s', + hourMsg: '{hour}h{minute}m{second}s', + minuteMsg: '{minute}m{second}s', + secondMsg: '{second}s', +} + /** * e.g. * - * 100h0m0s - * 20h10m59s - * 20h0m1s - * 10m20s + * 2d 10h 0m 0s + * 20h 0m 1s + * 10m 20s * 30s * * @return (xx+h)(xx+m)xx+s */ -export function formatPeriodCommon(milliseconds: number): string { - const defaultMessage = isRtl() ? { - dayMsg: 's{second} m{minute} h{hour} d{day}', - hourMsg: 's{second} m{minute} h{hour}', - minuteMsg: 's{second} m{minute}', - secondMsg: 's{second}', - } : { - dayMsg: '{day}d {hour}h {minute}m {second}s', - hourMsg: '{hour}h {minute}m {second}s', - minuteMsg: '{minute}m {second}s', - secondMsg: '{second}s', - } +export function formatPeriodCommon(milliseconds: number, simplified?: boolean): string { + const defaultMessage = isRtl() + ? (simplified ? PERIOD_RTL_SIMPLIFIED : PERIOD_RTL) + : (simplified ? PERIOD_LTR_SIMPLIFIED : PERIOD_LTR) return formatPeriod(milliseconds, defaultMessage) } @@ -175,13 +181,13 @@ export function getMonthTime(target: Date): [Date, Date] { * Get the start time of this day * * @param target the specific time - * @returns the start of this day + * @returns the start of this day, in milliseconds * @since 1.0.0 */ -export function getStartOfDay(target: Date | number) { +export function getStartOfDay(target: Date | number): number { const date = new Date(target) date.setHours(0, 0, 0, 0) - return date + return date.getTime() } /** @@ -198,6 +204,7 @@ export function getBirthday(): Date { date.setHours(0, 0, 0, 0) return date } + export const BIRTHDAY = '20220303' /** @@ -252,3 +259,16 @@ export function parseTime(dateStr: string | undefined): Date | undefined { result.setDate(date) return result } + +export type DateRange = Date | [Date?, Date?] | undefined + +export const cvtDateRange2Str = (range: DateRange): [string?, string?] | undefined => { + if (range === undefined) return undefined + if (range instanceof Date) { + // The same day + const date = formatTimeYMD(range) + return [date, date] + } + const [start, end] = range + return [start && formatTimeYMD(start), end && formatTimeYMD(end)] +} \ No newline at end of file diff --git a/src/util/tuple.ts b/src/util/tuple.ts index 7d114d701..a6d190b76 100644 --- a/src/util/tuple.ts +++ b/src/util/tuple.ts @@ -1,22 +1,19 @@ -import { range } from "./array" -const isTuple = (arg: unknown): arg is Tuple<any, never> => { - if (Array.isArray(arg)) return true - if (!(arg as Object).hasOwnProperty?.("get")) return false - const predicate = arg as Tuple<any, never> - const len = predicate?.length - return typeof len === 'number' && !isNaN(len) && isFinite(len) && len >= 0 && Number.isInteger(len) +export function isTuple<T, N extends number>(arr: T[], len: N): arr is Tuple<T, N> { + return arr.length === len } /** * Add tuple */ export const addVector = <L extends number>(a: Vector<L>, toAdd: Vector<L> | number): Vector<L> => { - const l: L = a.length ?? 0 as L - if (isTuple(toAdd)) { - return range(l).map(idx => (a?.[idx] ?? 0) + (toAdd?.[idx] ?? 0)) as unknown as Vector<L> + const arr = a as number[] + + if (typeof toAdd === 'number') { + return arr.map(v => v + toAdd) as Vector<L> } else { - return a?.map(v => (v ?? 0) + ((toAdd as number) ?? 0)) as unknown as Vector<L> + const b = toAdd as number[] + return arr.map((v, i) => v + (b[i] ?? 0)) as Vector<L> } } @@ -24,11 +21,13 @@ export const addVector = <L extends number>(a: Vector<L>, toAdd: Vector<L> | num * Subtract tuple */ export const subVector = <L extends number>(a: Vector<L>, toSub: Vector<L> | number): Vector<L> => { - const l: L = a.length ?? 0 as L - if (isTuple(toSub)) { - return range(l).map(idx => (a?.[idx] ?? 0) - (toSub?.[idx] ?? 0)) as unknown as Vector<L> + const arr = a as number[] + + if (typeof toSub === 'number') { + return arr.map(v => v - toSub) as Vector<L> } else { - return a?.map(v => (v ?? 0) + ((toSub as number) ?? 0)) as unknown as Vector<L> + const b = toSub as number[] + return arr.map((v, i) => v - (b[i] ?? 0)) as Vector<L> } } @@ -36,12 +35,6 @@ export const subVector = <L extends number>(a: Vector<L>, toSub: Vector<L> | num * Multiple tuple */ export const multiTuple = <L extends number>(a: Vector<L>, multiFactor: number): Vector<L> => { - return a?.map(v => (v ?? 0) * (multiFactor ?? 0)) as unknown as Vector<L> -} - -/** - * Divide tuple - */ -export const divideTuple = <L extends number>(a: Vector<L>, divideFactor: number): Vector<L> => { - return a?.map(v => (v ?? 0) / (divideFactor ?? 0)) as unknown as Vector<L> + const arr = a as number[] + return arr.map(v => v * multiFactor) as Vector<L> } diff --git a/test-e2e/backup/common.ts b/test-e2e/backup/common.ts new file mode 100644 index 000000000..5a64e932f --- /dev/null +++ b/test-e2e/backup/common.ts @@ -0,0 +1,206 @@ +import type { ElementHandle, Page } from 'puppeteer' +import { type LaunchContext } from '../common/base' +import { waitForMessage, waitForSuccMessage } from '../common/message' +import { sleep } from '../common/util' + +const typeNames: Record<tt4b.backup.Type, string> = { + gist: 'gist', + web_dav: 'dav', + obsidian_local_rest_api: 'obsidian', + none: 'none', +} + +const OPTION_TAB_ID = 'pane-backup' + +async function waitClientReady(page: Page): Promise<ElementHandle<Element> | null> { + await page.waitForFunction(() => { + const emptyBody = document.querySelector('.el-dialog .el-table__empty-block') + const rows = document.querySelectorAll('.el-dialog .el-table .el-table__row') + return !!emptyBody || !!rows.length + }, { timeout: 1000 }) + + const nextBtn = await page.waitForSelector( + '.el-overlay:not([style*="display: none"]) .el-dialog .el-button.el-button--primary:not([disabled])', + { timeout: 1000 }, + ) + return nextBtn +} + +async function findCurrentClientRow(page: Page): Promise<ElementHandle<Element> | null> { + const table = await page.waitForSelector( + '.el-overlay:not([style*="display: none"]) .el-dialog .el-table', + { timeout: 2000 }, + ) + const rows = await table!.$$('.el-table__row') + + for (const row of rows) { + const currVisible = await row.evaluate(el => { + const tag = el.querySelector('.el-table__cell .el-tag') + if (!tag) return false + const text = tag.textContent + if (!text.toLowerCase().includes('current')) return false + const style = window.getComputedStyle(tag) + return style && style.display !== 'none' + }) + if (currVisible) return row + } + return null +} + +async function selectCurrentClient(page: Page): Promise<void> { + const currRow = await findCurrentClientRow(page) + expect(currRow).toBeTruthy() + await currRow!.click() + await sleep(.1) +} + +export class BackupOptionWrapper { + private _page: Page | undefined + + constructor(private context: LaunchContext) { } + + private async page() { + if (this._page) { + await this._page.bringToFront() + return this._page + } + this._page = await this.context.openAppPage('/additional/option?i=backup') + return this._page + } + + async $(selector: string) { + const page = await this.page() + return await page.$(`#${OPTION_TAB_ID} ${selector}`) + } + + async changeType(type: tt4b.backup.Type) { + const page = await this.page() + const pane = await page.$(`#${OPTION_TAB_ID} .el-select`) + await pane?.click() + await sleep(.1) + + const backupOptions = await page.$$('.el-popper[data-popper-reference-hidden="false"] .el-select-dropdown__item') + + const labelPart = typeNames[type] + for (const option of backupOptions) { + const text = await option.evaluate(el => el.textContent) + if (text?.toLowerCase().includes(labelPart)) { + await option.click() + break + } + } + await sleep(.1) + + return page + } + + async assertTestInvalid() { + const page = await this.clickButton('test') + // The same as mock server + await waitForMessage(page, 'Unauthorized') + } + + async assertTestValid() { + const page = await this.clickButton('test') + await waitForMessage(page, 'Valid!') + } + + async assertBackupSuccess() { + const page = await this.clickButton('backup') + + // Wait for the success message + await waitForSuccMessage(page) + + const lastSyncTime = await this.findLastSyncTime() + expect(lastSyncTime).toBeTruthy() + // Less than 2 seconds + expect(Date.now() - lastSyncTime!.getTime()).toBeLessThan(2 * 1000) + } + + private async clickButton(textPart: string): Promise<Page> { + const page = await this.page() + const buttons = await page.$$(`#${OPTION_TAB_ID} .el-button`) + for (const button of buttons) { + const text = await button.evaluate(el => el.textContent) + if (text?.toLowerCase().includes(textPart.toLowerCase())) { + await button.click() + return page + } + } + throw new Error(`Button with text "${textPart}" not found`) + } + + private async findLastSyncTime() { + const page = await this.page() + const texts = await page.$$(`#${OPTION_TAB_ID} .el-text`) + for (const textEl of texts) { + const text = await textEl.evaluate(el => el.textContent) + const matchRes = /(?<M>\d{2})\/(?<d>\d{2})\/(?<y>\d{4}) (?<h>\d{2}):(?<m>\d{2}):(?<s>\d{2})/.exec(text ?? '') + if (!matchRes) continue + const groups = matchRes.groups + if (!groups) continue + const { M, d, y, h, m, s } = groups + if (!y || !M || !d || !h || !m || !s) continue + return new Date( + Number.parseInt(y), Number.parseInt(M) - 1, Number.parseInt(d), + Number.parseInt(h), Number.parseInt(m), Number.parseInt(s), + ) + } + return undefined + } + + async downloadCurrentWithAcc() { + // Open dialog + const page = await this.clickButton('download') + + const nextBtn = await waitClientReady(page) + + // Select current client row + await selectCurrentClient(page) + + // Next step + await nextBtn!.click() + + // Wait for download enabled + const downloadBtn = await page.waitForSelector('.el-dialog .el-button.el-button--success:not([disabled])', { timeout: 1000 }) + + // Choose the solution + const solutionRadio = await page.waitForSelector('.el-dialog .el-radio-group > label:nth-child(2)', { timeout: 1000 }) + await solutionRadio!.click() + + // Do downloading + await downloadBtn!.click() + + await waitForSuccMessage(page) + } + + async clearData() { + const page = await this.clickButton('clear') + + const nextBtn = await waitClientReady(page) + + // Select current client row + await selectCurrentClient(page) + + // Next step + await nextBtn!.click() + + // Wait for clear enabled + const clearBtn = await page.waitForSelector('.el-dialog .el-button.el-button--danger:not([disabled])', { timeout: 1000 }) + + await clearBtn!.click() + + await waitForSuccMessage(page) + } + + async assertCantDownloadCurr() { + // Open dialog + const page = await this.clickButton('download') + + await waitClientReady(page) + + // Select current client row + const currRow = await findCurrentClientRow(page) + expect(currRow).toBeFalsy() + } +} \ No newline at end of file diff --git a/test-e2e/backup/gist.test.ts b/test-e2e/backup/gist.test.ts new file mode 100644 index 000000000..43c1741f9 --- /dev/null +++ b/test-e2e/backup/gist.test.ts @@ -0,0 +1,73 @@ +import { launchBrowser, type LaunchContext } from '../common/base' +import { readRecordsOfFirstPage } from '../common/record' +import { MOCK_URL, sleep } from '../common/util' +import { BackupOptionWrapper } from './common' + +const GIST_MOCK_ORIGIN = 'http://127.0.0.1:12347' +const GIST_MOCK_TOKEN = 'github_gist_mock_token' + +let context: LaunchContext + +const describeOptional = process.env.GITHUB_ACTIONS ? describe.skip : describe + +describeOptional('Backup with gist', () => { + beforeEach(async () => { + context = await launchBrowser({ bgProxies: [{ host: 'api.github.com', target: GIST_MOCK_ORIGIN }] }) + }) + + afterEach(() => context.close()) + + test('create and update gist', async () => { + // Fill in gist parameters + const option = new BackupOptionWrapper(context) + await option.changeType('gist') + + const tokenInput = await option.$('input[name="token"]') + expect(tokenInput).toBeTruthy() + + // Assert test invalid with invalid token + await tokenInput!.type('foobar' + Date.now()) + await sleep(.5) + await option.assertTestInvalid() + + // Assert token is valid + await tokenInput!.focus() + await tokenInput!.evaluate(el => { + if (!(el instanceof HTMLInputElement)) return + el.value = '' + el.dispatchEvent(new Event('input', { bubbles: true })) + }) + await tokenInput!.type(GIST_MOCK_TOKEN) + await sleep(.5) + await option.assertTestValid() + + + // Visit site + const sitePage = await context.newPageAndWaitCsInjected(MOCK_URL) + await sleep(2) + await sitePage.close() + let originalRecords = await readRecordsOfFirstPage(context) + expect(originalRecords.length).toEqual(1) + const original = originalRecords[0] + + // Upload the data to gist + await option.assertBackupSuccess() + + // Check download content + await sleep(1) + await option.downloadCurrentWithAcc() + + const twiceRecords = await readRecordsOfFirstPage(context) + expect(twiceRecords.length).toEqual(1) + const after = twiceRecords[0] + expect(after?.url).toEqual(original?.url) + expect(after?.visit).toEqual('2') + + // Clear data + await option.clearData() + + // Assert can't download current + await sleep(1) + await option.assertCantDownloadCurr() + }, 50000) +}) \ No newline at end of file diff --git a/test-e2e/common/base.ts b/test-e2e/common/base.ts index eb5e75e3d..04ac99e12 100644 --- a/test-e2e/common/base.ts +++ b/test-e2e/common/base.ts @@ -1,9 +1,46 @@ -import { type Browser, launch, type Page } from "puppeteer" +import { type Browser, type CDPSession, launch, type Page, type Target } from "puppeteer" import { E2E_OUTPUT_PATH } from "../../rspack/constant" import { removeAllWhitelist } from './whitelist' const USE_HEADLESS_PUPPETEER = !!process.env['USE_HEADLESS_PUPPETEER'] +type HostProxy = { + host: string + target: string +} + +async function setupBgProxies(serviceWorker: Target, proxies: HostProxy[]): Promise<CDPSession | undefined> { + let session: CDPSession | undefined + try { + session = await serviceWorker.createCDPSession() + await session.send('Fetch.enable', { + patterns: proxies.map(p => ({ urlPattern: `*://${p.host}/*`, requestStage: 'Request' as const })), + }) + } catch { + // Target may have been closed before the session was ready + return + } + session.on('Fetch.requestPaused', async event => { + const { requestId, request: { url: originUrl } } = event + try { + const url = new URL(originUrl) + const proxy = proxies.find(p => url.hostname === p.host || url.host === p.host) + if (!proxy) { + await session.send('Fetch.continueRequest', { requestId }) + return + } + const targetUrl = new URL(proxy.target) + url.protocol = targetUrl.protocol + url.hostname = targetUrl.hostname + url.port = targetUrl.port + await session.send('Fetch.continueRequest', { requestId, url: String(url) }) + } catch { + await session.send('Fetch.continueRequest', { requestId }) + } + }) + return session +} + export interface LaunchContext { browser: Browser extensionId: string @@ -18,16 +55,15 @@ export interface LaunchContext { } class LaunchContextWrapper implements LaunchContext { - browser: Browser - extensionId: string - constructor(browser: Browser, extensionId: string) { - this.browser = browser - this.extensionId = extensionId - } + constructor( + readonly browser: Browser, readonly extensionId: string, + private cdpSession: CDPSession | undefined + ) { } - close(): Promise<void> { - return this.browser.close() + async close(): Promise<void> { + this.cdpSession?.detach().catch(() => { }) + await this.browser.close() } async openAppPage(route: string): Promise<Page> { @@ -39,9 +75,7 @@ class LaunchContextWrapper implements LaunchContext { async newPage(url?: string): Promise<Page> { const page = await this.browser.newPage() - if (url) { - await page.goto(url, { waitUntil: 'domcontentloaded' }) - } + url && await page.goto(url, { waitUntil: 'domcontentloaded' }) return page } @@ -53,45 +87,42 @@ class LaunchContextWrapper implements LaunchContext { } } -export async function launchBrowser(dirPath?: string): Promise<LaunchContext> { - dirPath = dirPath ?? E2E_OUTPUT_PATH +type BrowserOptions = { + dirPath?: string + bgProxies?: HostProxy[] +} + +export async function launchBrowser(options?: BrowserOptions): Promise<LaunchContext> { + const { dirPath = E2E_OUTPUT_PATH, bgProxies } = options ?? {} const args = [ `--disable-extensions-except=${dirPath}`, `--load-extension=${dirPath}`, '--start-maximized', '--no-sandbox', ] + // GitHub-hosted runners use a small /dev/shm; Chrome can crash or hang without this flag. + if (process.env['GITHUB_ACTIONS'] === 'true') { + args.push('--disable-gpu', '--disable-dev-shm-usage') + } // Test with large screen USE_HEADLESS_PUPPETEER && args.push('--window-size=1880,1000') const browser = await launch({ defaultViewport: null, headless: USE_HEADLESS_PUPPETEER, + enableExtensions: true, args, }) - const serviceWorker = await browser.waitForTarget(target => target.type() === 'service_worker') - const url = serviceWorker.url() - let extensionId: string | undefined = url.split('/')[2] - if (!extensionId) { - throw new Error('Failed to detect extension id') - } + const sw = await browser.waitForTarget(target => target.type() === 'service_worker') + const url = sw.url() + let extensionId = url.split('/')[2] + if (!extensionId) throw new Error('Failed to detect extension id') - const context = new LaunchContextWrapper(browser, extensionId) + const cdpSession = bgProxies?.length ? await setupBgProxies(sw, bgProxies) : undefined + const context = new LaunchContextWrapper(browser, extensionId, cdpSession) // remove whitelist added by service_worker await removeAllWhitelist(context) return context -} - -export function sleep(seconds: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, seconds * 1000)) -} - -export const MOCK_HOST = "127.0.0.1:12345" - -export const MOCK_URL = "http://" + MOCK_HOST - -export const MOCK_HOST_2 = "127.0.0.1:12346" - -export const MOCK_URL_2 = "http://" + MOCK_HOST_2 +} \ No newline at end of file diff --git a/test-e2e/common/message.ts b/test-e2e/common/message.ts new file mode 100644 index 000000000..6d8e1a1ac --- /dev/null +++ b/test-e2e/common/message.ts @@ -0,0 +1,16 @@ +import { type Page } from 'puppeteer' + +export async function waitForMessage(page: Page, msg: string): Promise<void> { + await page.waitForFunction( + (msg: string) => { + const messages = document.querySelectorAll('.el-message') + return Array.from(messages).some(el => el.textContent === msg) + }, + { timeout: 5000 }, + msg, + ) +} + +export async function waitForSuccMessage(page: Page) { + return waitForMessage(page, 'Successfully!') +} \ No newline at end of file diff --git a/test-e2e/common/record.ts b/test-e2e/common/record.ts index 5fbd7469d..e78319e2b 100644 --- a/test-e2e/common/record.ts +++ b/test-e2e/common/record.ts @@ -1,4 +1,4 @@ -import { LaunchContext } from "./base" +import type { LaunchContext } from "./base" type RecordRow = { date: string @@ -14,18 +14,18 @@ function readRecords(): RecordRow[] { const rows = document.querySelectorAll('.el-table .el-table__body-wrapper table tbody tr') return Array.from(rows).map(row => { const cells = row.querySelectorAll('td') - const date = cells[1].textContent ?? '' - const url = cells[2].textContent ?? '' - const name = cells[3].textContent ?? '' - const category = cells[4].textContent ?? '' - const time = cells[5].textContent ?? '' + const date = cells[1]?.textContent ?? '' + const url = cells[2]?.textContent ?? '' + const name = cells[3]?.textContent ?? '' + const category = cells[4]?.textContent ?? '' + const time = cells[5]?.textContent ?? '' let runTime: string | undefined = undefined, visit = '' if (cells?.length === 9) { // Including run time - runTime = cells[6].textContent ?? undefined - visit = cells[7].textContent ?? '' + runTime = cells[6]?.textContent ?? undefined + visit = cells[7]?.textContent ?? '' } else { - visit = cells[6].textContent ?? '' + visit = cells[6]?.textContent ?? '' } return { date, url, name, category, time, runTime, visit } }) diff --git a/test-e2e/common/util.ts b/test-e2e/common/util.ts new file mode 100644 index 000000000..0911dcf0e --- /dev/null +++ b/test-e2e/common/util.ts @@ -0,0 +1,11 @@ +export function sleep(seconds: number): Promise<void> { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)) +} + +export const MOCK_HOST = "127.0.0.1:12345" + +export const MOCK_URL = "http://" + MOCK_HOST + +const MOCK_HOST_2 = "127.0.0.1:12346" + +export const MOCK_URL_2 = "http://" + MOCK_HOST_2 diff --git a/test-e2e/common/whitelist-skip.test.ts b/test-e2e/common/whitelist-skip.test.ts new file mode 100644 index 000000000..32852fadf --- /dev/null +++ b/test-e2e/common/whitelist-skip.test.ts @@ -0,0 +1,8 @@ +import { launchBrowser } from './base' +import { createWhitelist } from './whitelist' + +// Run to test the function, but skip it in normal test runs +test.skip('create whitelist', async () => { + const context = await launchBrowser() + await createWhitelist(context, 'example.com') +}) \ No newline at end of file diff --git a/test-e2e/common/whitelist.ts b/test-e2e/common/whitelist.ts index 357e7afc5..a8e6f9d88 100644 --- a/test-e2e/common/whitelist.ts +++ b/test-e2e/common/whitelist.ts @@ -1,4 +1,5 @@ -import { type LaunchContext, sleep } from "./base" +import { type LaunchContext } from "./base" +import { sleep } from './util' export async function createWhitelist(context: LaunchContext, white: string) { const whitePage = await context.openAppPage('/additional/whitelist') @@ -11,8 +12,10 @@ export async function createWhitelist(context: LaunchContext, white: string) { await input?.focus() await whitePage.keyboard.type(white) await sleep(.4) - const selectItem = await whitePage.waitForSelector('.el-popper .el-select-dropdown li:nth-child(1)') - await selectItem?.click() + await whitePage.keyboard.press('ArrowDown') + await sleep(.2) + await whitePage.keyboard.press('Enter') + await whitePage.click('.el-button:nth-child(3)') const checkBtn = await whitePage.waitForSelector('.el-overlay.is-message-box .el-button.el-button--primary') await checkBtn?.click() diff --git a/test-e2e/example/index.html b/test-e2e/example/index.html deleted file mode 100644 index b1dc95fad..000000000 --- a/test-e2e/example/index.html +++ /dev/null @@ -1,6 +0,0 @@ -<!DOCTYPE html> -<html> - <body> - <div>Time Tracker test page</div> - </body> -</html> \ No newline at end of file diff --git a/test-e2e/install.test.ts b/test-e2e/install.test.ts deleted file mode 100644 index bb774d355..000000000 --- a/test-e2e/install.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { join } from "path" -import { launchBrowser, type LaunchContext } from "./common/base" - -let context: LaunchContext - -describe('After installed', () => { - beforeEach(async () => { - const path = join(__dirname, '..', 'dist_prod') - context = await launchBrowser(path) - }) - - afterEach(async () => context.close()) - - test('Open the official page', async () => { - const { browser } = context - await browser.waitForTarget(target => target.url().includes('wfhg.cc')) - }, 5000) -}) - diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index 18836b199..bf242d03d 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -1,7 +1,23 @@ -import { ElementHandle, type Page } from "puppeteer" -import { sleep } from "../common/base" +import type { ElementHandle, Frame, Page } from "puppeteer" +import { sleep } from "../common/util" -export async function createLimitRule(rule: timer.limit.Rule, page: Page) { +export async function waitForLimitFrame(page: Page, timeout = 5000): Promise<Frame> { + return page.waitForFrame(f => f.url().includes('limit.html'), { timeout }) +} + +export async function isLimitModalVisible(page: Page): Promise<boolean> { + await page.waitForSelector('extension-time-tracker-overlay', { timeout: 3000 }) + return await page.evaluate(async () => { + const overlay = document.querySelector('extension-time-tracker-overlay') + if (!overlay) return false + const iframe = overlay.shadowRoot?.firstElementChild + return iframe instanceof HTMLIFrameElement + && iframe.style.visibility !== 'hidden' + && iframe.style.display !== 'none' + }) +} + +export async function createLimitRule(rule: tt4b.limit.Rule, page: Page) { const createButton = await page.$('.el-card:first-child .el-button:last-child') await createButton!.click() // 1 Fill the name @@ -28,13 +44,13 @@ export async function createLimitRule(rule: timer.limit.Rule, page: Page) { // 3. Fill the rule await sleep(.1) const { time, weekly, visitTime, count, weeklyCount } = rule || {} - const timeInputs = await page.$$('.el-dialog .el-date-editor input') - await fillTimeLimit(time, timeInputs[0], page) - await fillTimeLimit(weekly, timeInputs[1], page) - await fillTimeLimit(visitTime, timeInputs[2], page) - const visitInputs = await page.$$('.el-dialog .el-input-number input') - await fillVisitLimit(count!, visitInputs[0], page) - await fillVisitLimit(weeklyCount!, visitInputs[1], page) + const [fstTime, secTime, trdTime] = await page.$$('.el-dialog .el-date-editor input') + fstTime && await fillTimeLimit(time, fstTime, page) + secTime && await fillTimeLimit(weekly, secTime, page) + trdTime && await fillTimeLimit(visitTime, trdTime, page) + const [fstVisit, secVisit] = await page.$$('.el-dialog .el-input-number input') + fstVisit && await fillVisitLimit(count!, fstVisit, page) + secVisit && await fillVisitLimit(weeklyCount!, secVisit, page) // 4. Save await sleep(.3) @@ -72,8 +88,15 @@ export async function fillTimeLimit(value: number | undefined, input: ElementHan await sleep(.2) } -export async function fillVisitLimit(value: number, input: ElementHandle<HTMLInputElement>, page: Page) { +async function fillVisitLimit(value: number, input: ElementHandle<HTMLInputElement>, page: Page) { await input.focus() await page.keyboard.press('Delete') await page.keyboard.type(`${value ?? 0}`) +} + +export async function clickDelay(testPage: Page) { + const limitFrame = await waitForLimitFrame(testPage) + const moreBtn = await limitFrame.waitForSelector('.el-button--primary') + await moreBtn!.click() + await sleep(.8) } \ No newline at end of file diff --git a/test-e2e/limit/daily-time.test.ts b/test-e2e/limit/daily-time.test.ts index a7d6513ff..695af8be3 100644 --- a/test-e2e/limit/daily-time.test.ts +++ b/test-e2e/limit/daily-time.test.ts @@ -1,17 +1,18 @@ -import { launchBrowser, type LaunchContext, MOCK_URL, sleep } from "../common/base" -import { createLimitRule, fillTimeLimit } from "./common" +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { createLimitRule, fillTimeLimit, isLimitModalVisible, waitForLimitFrame } from "./common" let context: LaunchContext describe('Daily time limit', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) test('basic', async () => { const limitTime = 2 const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: timer.limit.Rule = { + const demoRule: tt4b.limit.Rule = { id: 1, name: 'TEST DAILY LIMIT', cond: [MOCK_URL], time: limitTime, @@ -41,9 +42,13 @@ describe('Daily time limit', () => { await sleep(2.1) // 4. Limited - const { name, time } = await testPage.evaluate(async () => { - const shadow = document.querySelector('extension-time-tracker-overlay') - const descEl = shadow?.shadowRoot?.querySelector('#app .el-descriptions:not([style*="display: none"])') + const limitFrame = await waitForLimitFrame(testPage) + await limitFrame.waitForFunction(() => { + const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') + return td?.textContent && td.textContent !== '-' + }, { timeout: 5000 }) + const { name, time } = await limitFrame.evaluate(() => { + const descEl = document.querySelector('#app .el-descriptions:not([style*="display: none"])') const trs = descEl?.querySelectorAll('tr') const name = trs?.[0]?.querySelector('td:nth-child(2)')?.textContent const timeStr = trs?.[3]?.querySelector('td:nth-child(2) .el-tag--danger')?.textContent @@ -79,11 +84,7 @@ describe('Daily time limit', () => { // 7. Modal disappear await testPage.bringToFront() await sleep(.5) - const modalExist = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - return !!shadow.shadowRoot!.querySelector('body:not([style*="display: none"])') - }) + const modalExist = await isLimitModalVisible(testPage) expect(modalExist).toBeFalsy() }, 60000) }) \ No newline at end of file diff --git a/test-e2e/limit/daily-visit.test.ts b/test-e2e/limit/daily-visit.test.ts index f7a33b1e1..3298c0298 100644 --- a/test-e2e/limit/daily-visit.test.ts +++ b/test-e2e/limit/daily-visit.test.ts @@ -1,16 +1,17 @@ -import { launchBrowser, type LaunchContext, MOCK_URL, sleep } from "../common/base" -import { createLimitRule } from "./common" +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { createLimitRule, isLimitModalVisible, waitForLimitFrame } from "./common" let context: LaunchContext describe('Daily time limit', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) test("Daily visit limit", async () => { const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: timer.limit.Rule = { + const demoRule: tt4b.limit.Rule = { id: 1, name: 'TEST DAILY LIMIT', cond: [MOCK_URL], time: 0, count: 1, @@ -36,13 +37,16 @@ describe('Daily time limit', () => { // Waiting for limit message handling await sleep(2) - const { name, count } = await testPage.evaluate(async () => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return {} - const descEl = shadow!.shadowRoot!.querySelector('#app .el-descriptions:not([style*="display: none"])') - const trs = descEl!.querySelectorAll('tr') - const name = trs[0].querySelector('td:nth-child(2)')!.textContent - const count = trs[3].querySelector('td:nth-child(2) .el-tag--danger')!.textContent + const limitFrame = await waitForLimitFrame(testPage) + await limitFrame.waitForFunction(() => { + const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') + return td?.textContent && td.textContent !== '-' + }, { timeout: 5000 }) + const { name, count } = await limitFrame.evaluate(() => { + const descEl = document.querySelector('#app .el-descriptions:not([style*="display: none"])') + const trs = descEl?.querySelectorAll('tr') + const name = trs?.[0]?.querySelector('td:nth-child(2)')?.textContent + const count = trs?.[3]?.querySelector('td:nth-child(2) .el-tag--danger')?.textContent return { name, count } }) @@ -68,11 +72,7 @@ describe('Daily time limit', () => { // 5. The modal disappear await testPage.bringToFront() await sleep(.5) - const modalExist = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - return !!shadow!.shadowRoot!.querySelector('body:not([style*="display: none"])') - }) + const modalExist = await isLimitModalVisible(testPage) expect(modalExist).toBeFalsy() }, 60000) }) \ No newline at end of file diff --git a/test-e2e/limit/delay-duration.test.ts b/test-e2e/limit/delay-duration.test.ts new file mode 100644 index 000000000..c7822d4e3 --- /dev/null +++ b/test-e2e/limit/delay-duration.test.ts @@ -0,0 +1,93 @@ +import { formatTimeYMD, MILL_PER_SECOND } from '@util/time' +import type { Page } from 'puppeteer' +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { clickDelay, createLimitRule, isLimitModalVisible } from './common' + +async function setDelayDuration(page: Page, value: number) { + const delayInput = await page.waitForSelector('.el-input-number input') + await delayInput!.click({ count: 3 }) + await page.keyboard.press('Backspace') + await page.keyboard.type(`${value}`) + await page.keyboard.press('Enter') + await sleep(.2) +} + +async function findRuleId(page: Page): Promise<number> { + return page.evaluate( + ruleName => new Promise<number>((resolve, reject) => { + chrome.runtime.sendMessage({ code: 'limit.list', data: undefined }, (res: { + code?: string + data?: tt4b.limit.Item[] + msg?: string + }) => { + if (res?.code !== 'success') return reject(new Error(res?.msg ?? 'limit.list failed')) + const id = res.data?.find(r => r.name === ruleName)?.id + typeof id === 'number' ? resolve(id) : reject(new Error('rule id not found')) + }) + }), + DEMO_RULE.name, + ) +} + +async function setTodayWaste(page: Page, ruleId: number, mill: number) { + const today = formatTimeYMD(new Date()) + await page.evaluate( + async (id, m, date) => { + const key = '__timer__LIMIT' + const bag = await chrome.storage.local.get(key) + const items = (bag[key] ?? {}) as Record<string, { r?: Record<string, { m?: number; c?: number; d?: number }> }> + const row = items[String(id)] + if (!row) throw new Error('limit row missing in storage') + const prev = row.r?.[date] ?? { m: 0, c: 0 } + row.r = row.r ?? {} + row.r[date] = { ...prev, m } + await chrome.storage.local.set({ [key]: items }) + }, + ruleId, mill, today, + ) +} + +const DEMO_RULE = { + id: 1, + name: 'DELAY DURATION', + cond: [MOCK_URL], + time: 1, + enabled: true, + allowDelay: true, + locked: false, +} as const satisfies tt4b.limit.Rule + +describe('Limit delay duration', () => { + let context: LaunchContext + + beforeEach(async () => { context = await launchBrowser() }) + + // afterEach(() => context.close()) + + test('Delay with customized duration', async () => { + const optionPage = await context.openAppPage('/additional/option?i=limit') + await setDelayDuration(optionPage, 1) + + const limitPage = await context.openAppPage('/behavior/limit') + await createLimitRule(DEMO_RULE, limitPage) + + const ruleId = await findRuleId(limitPage) + // 61 seconds, more than 1 minute delay + await setTodayWaste(limitPage, ruleId, 61 * MILL_PER_SECOND) + + const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) + await sleep(1) + + expect(await isLimitModalVisible(testPage)).toBeTruthy() + + await clickDelay(testPage) + + // Not disappear if only delay once (1 minute delay) + expect(await isLimitModalVisible(testPage)).toBeTruthy() + + // Disappear if delay twice (2 minutes delay) + await clickDelay(testPage) + expect(await isLimitModalVisible(testPage)).toBeFalsy() + }, 45000) +}) diff --git a/test-e2e/limit/visit-limit.test.ts b/test-e2e/limit/visit-limit.test.ts index 8585cce86..8871f9336 100644 --- a/test-e2e/limit/visit-limit.test.ts +++ b/test-e2e/limit/visit-limit.test.ts @@ -1,16 +1,17 @@ -import { launchBrowser, LaunchContext, MOCK_URL, sleep } from '../common/base' -import { createLimitRule } from './common' +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { createLimitRule, isLimitModalVisible, waitForLimitFrame } from './common' describe('Time limit per visit', () => { let context: LaunchContext - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) - test("More 5 minutes", async () => { + test("Delay", async () => { const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: timer.limit.Rule = { + const demoRule: tt4b.limit.Rule = { id: 1, name: 'TEST DAILY LIMIT', cond: [MOCK_URL], visitTime: 1, @@ -25,23 +26,15 @@ describe('Time limit per visit', () => { await sleep(2) // 3. Modal exist and then click more 5 minutes - const clicked = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - const button = shadow.shadowRoot?.querySelector<HTMLButtonElement>('.el-button--primary') - button?.click() - return !!button - }) - expect(clicked).toBeTruthy() + const limitFrame = await waitForLimitFrame(testPage) + const button = await limitFrame.waitForSelector('.el-button--primary') + expect(button).toBeTruthy() + await button!.click() // 4. Modal disappear await sleep(.5) - const modalExist = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - return !!shadow.shadowRoot!.querySelector('body:not([style*="display: none"])') - }) + const modalExist = await isLimitModalVisible(testPage) expect(modalExist).toBeFalsy() - }, 10000) + }, 1000000000) }) \ No newline at end of file diff --git a/test-e2e/rstest.config.mts b/test-e2e/rstest.config.mts new file mode 100644 index 000000000..d7ebd599c --- /dev/null +++ b/test-e2e/rstest.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core' + +export default defineConfig({ + include: ['test-e2e/**/*.test.ts'], + testEnvironment: 'node', + globals: true, + isolate: false, + hookTimeout: 120_000, + testTimeout: 60_000, + pool: { + maxWorkers: 1, + }, +}) diff --git a/test-e2e/tracker/base.test.ts b/test-e2e/tracker/base.test.ts index aeb4fccf8..2be47352e 100644 --- a/test-e2e/tracker/base.test.ts +++ b/test-e2e/tracker/base.test.ts @@ -1,11 +1,12 @@ -import { launchBrowser, type LaunchContext, MOCK_HOST, MOCK_URL, MOCK_URL_2, sleep } from "../common/base" +import { launchBrowser, type LaunchContext } from '../common/base' import { readRecordsOfFirstPage } from "../common/record" +import { MOCK_HOST, MOCK_URL, MOCK_URL_2, sleep } from '../common/util' import { createWhitelist } from "../common/whitelist" let context: LaunchContext describe('Tracking', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) @@ -15,11 +16,11 @@ describe('Tracking', () => { let records = await readRecordsOfFirstPage(context) expect(records.length).toEqual(1) - const { visit: visitStr, time: timeStr } = records[0] + const { visit: visitStr, time: timeStr } = records[0] ?? {} // 1 visit expect(visitStr).toEqual("1") // >= 2 s - const time = parseInt(timeStr.replace('s', '').trim()) + const time = timeStr ? parseInt(timeStr.replace('s', '').trim()) : NaN expect(time >= 2) // Another page @@ -38,11 +39,11 @@ describe('Tracking', () => { let records = await readRecordsOfFirstPage(context) expect(records.length).toEqual(1) - const { visit: visitStr, time: timeStr } = records[0] + const { visit: visitStr, time: timeStr } = records[0] ?? {} // 1 visit expect(visitStr).toEqual("1") // >= 2 s - const time = parseInt(timeStr.replace('s', '').trim()) + const time = timeStr ? parseInt(timeStr.replace('s', '').trim()) : NaN expect(time >= 2) await createWhitelist(context, MOCK_HOST) @@ -51,7 +52,7 @@ describe('Tracking', () => { await sleep(2) records = await readRecordsOfFirstPage(context) expect(records.length).toEqual(1) - expect(records[0].time).toEqual(timeStr) - expect(records[0].visit).toEqual("1") + expect(records[0]?.time).toEqual(timeStr) + expect(records[0]?.visit).toEqual("1") }, 60000) }) \ No newline at end of file diff --git a/test-e2e/tracker/run-time.test.ts b/test-e2e/tracker/run-time.test.ts index 238791bf9..1f219cac1 100644 --- a/test-e2e/tracker/run-time.test.ts +++ b/test-e2e/tracker/run-time.test.ts @@ -1,5 +1,6 @@ -import { launchBrowser, MOCK_HOST, MOCK_URL, sleep, type LaunchContext } from "../common/base" +import { launchBrowser, type LaunchContext } from '../common/base' import { parseTime2Sec, readRecordsOfFirstPage } from "../common/record" +import { MOCK_HOST, MOCK_URL, sleep } from '../common/util' import { createWhitelist } from "../common/whitelist" let context: LaunchContext @@ -17,7 +18,7 @@ async function clickRunTimeChange(siteHost: string): Promise<void> { } describe('Run time tracking', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) @@ -26,8 +27,8 @@ describe('Run time tracking', () => { await sleep(1.1) let records = await readRecordsOfFirstPage(context) let record = records[0] - expect(parseTime2Sec(record.time)).toBeGreaterThanOrEqual(1) - expect(record.runTime).toBeFalsy() + expect(parseTime2Sec(record?.time)).toBeGreaterThanOrEqual(1) + expect(record?.runTime).toBeFalsy() // 1. Enable run time tracking const enableTs = Date.now() @@ -48,7 +49,7 @@ describe('Run time tracking', () => { await sleep(1) records = await readRecordsOfFirstPage(context) - const runTime2 = parseTime2Sec(records[0].runTime) + const runTime2 = parseTime2Sec(records[0]?.runTime) expect(runTime2).toBeGreaterThanOrEqual(runTime1 + 1) expect(runTime2).toBeLessThan((Date.now() - enableTs) / 1000) @@ -58,7 +59,7 @@ describe('Run time tracking', () => { await emptyPage.bringToFront() await sleep(4) records = await readRecordsOfFirstPage(context) - const runTime3 = parseTime2Sec(records[0].runTime) + const runTime3 = parseTime2Sec(records[0]?.runTime) expect(runTime3).toBeLessThanOrEqual(Math.round((disableTs - enableTs) / 1000)) }, 60000) @@ -71,7 +72,7 @@ describe('Run time tracking', () => { await sleep(4) let records = await readRecordsOfFirstPage(context) - const runTime = parseTime2Sec(records[0].runTime) + const runTime = parseTime2Sec(records[0]?.runTime) expect(runTime).toBeTruthy() expect(runTime).toBeLessThanOrEqual((Date.now() - enableTs) / 1000 + 1) @@ -82,7 +83,7 @@ describe('Run time tracking', () => { await sleep(2) records = await readRecordsOfFirstPage(context) - const runTime1 = parseTime2Sec(records[0].runTime) + const runTime1 = parseTime2Sec(records[0]?.runTime) expect(runTime1).toBeLessThan((disableTs - enableTs) / 1000 + 1) }, 60000) }) diff --git a/test/__mock__/runtime.ts b/test/__mock__/runtime.ts new file mode 100644 index 000000000..81db493de --- /dev/null +++ b/test/__mock__/runtime.ts @@ -0,0 +1,7 @@ +export const mockRuntime = () => { + global.chrome = { + runtime: { + id: 'mock_runtime_id', + } satisfies Pick<typeof chrome.runtime, 'id'> + } as unknown as typeof global.chrome +} \ No newline at end of file diff --git a/test/__mock__/storage.ts b/test/__mock__/storage.ts index 0a1bfc37c..b715e9432 100644 --- a/test/__mock__/storage.ts +++ b/test/__mock__/storage.ts @@ -1,4 +1,4 @@ -import StoragePromise from "@db/common/storage-promise" +import { rstest } from '@rstest/core' let store: Record<string, any> = {} @@ -28,7 +28,7 @@ function resolveKey(key: string | Object | string[] | null) { } const sync = { - get: jest.fn((...args) => { + get: rstest.fn((...args) => { let id: string | string[] | Object let cb: (result: {}) => void let result: {} = {} @@ -42,18 +42,18 @@ const sync = { } cb?.(result) }), - getBytesInUse: jest.fn(cb => cb && cb(0)), - set: jest.fn((payload, cb) => { + getBytesInUse: rstest.fn(cb => cb && cb(0)), + set: rstest.fn((payload, cb) => { Object.keys(payload).forEach((key) => (store[key] = payload[key])) cb?.() }), - remove: jest.fn((id, cb) => { + remove: rstest.fn((id, cb) => { const idType = typeof id const keys: string[] = idType === 'string' ? [id] : (Array.isArray(id) ? id : Object.keys(id)) keys.forEach((key: string) => delete store[key]) cb?.() }), - clear: jest.fn(cb => { + clear: rstest.fn(cb => { store = {} cb?.() }) @@ -64,9 +64,9 @@ const local = { ...sync, QUOTA_BYTES: 5 * 1024 * 1024 } as chrome.storage.LocalS const managed = sync const onChanged = { - addListener: jest.fn(), - removeListener: jest.fn(), - hasListener: jest.fn() + addListener: rstest.fn(), + removeListener: rstest.fn(), + hasListener: rstest.fn() } as unknown as typeof chrome.storage.onChanged export const mockStorage = () => { @@ -80,6 +80,4 @@ export const mockStorage = () => { }, } } as unknown as typeof global.chrome -} - -export const localPromise = new StoragePromise(local) \ No newline at end of file +} \ No newline at end of file diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts index 8346fef0f..cb2d32d4c 100644 --- a/test/background/backup/gist/compressor.test.ts +++ b/test/background/backup/gist/compressor.test.ts @@ -1,7 +1,7 @@ -import { divide2Buckets, GistData, gistData2Rows } from "@service/backup/gist/compressor" +import { divide2Buckets, type GistData, gistData2Rows } from "@service/backup/gist/compressor" test('divide 1', () => { - const rows: timer.core.Row[] = [{ + const rows: tt4b.core.Row[] = [{ host: 'www.baidu.com', date: '20220801', focus: 0, @@ -15,7 +15,7 @@ test('divide 1', () => { }] const divided = divide2Buckets(rows) expect(divided.length).toEqual(1) - const [bucket, gistData] = divided[0] + const [bucket, gistData] = divided[0] ?? [] expect(bucket).toEqual('202208') const expectData: GistData = { "01": { @@ -39,11 +39,11 @@ test('gistData2Rows', () => { rows.sort((a, b) => a.date > b.date ? 1 : -1) const row0 = rows[0] const row1 = rows[1] - expect(row0.date).toEqual('20220901') - expect(row0.time).toEqual(0) - expect(row0.focus).toEqual(1) + expect(row0?.date).toEqual('20220901') + expect(row0?.time).toEqual(0) + expect(row0?.focus).toEqual(1) - expect(row1.date).toEqual('20220908') - expect(row1.time).toEqual(1) - expect(row1.focus).toEqual(1) + expect(row1?.date).toEqual('20220908') + expect(row1?.time).toEqual(1) + expect(row1?.focus).toEqual(1) }) \ No newline at end of file diff --git a/test/database/limit-database.test.ts b/test/background/database/limit-database.test.ts similarity index 52% rename from test/database/limit-database.test.ts rename to test/background/database/limit-database.test.ts index 49d3244c3..9c4a651b3 100644 --- a/test/database/limit-database.test.ts +++ b/test/background/database/limit-database.test.ts @@ -1,13 +1,14 @@ import db from "@db/limit-database" import { formatTimeYMD } from "@util/time" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from "../../__mock__/storage" +import { mockLegacyData } from './migratable' describe('limit-database', () => { beforeAll(() => mockStorage()) beforeEach(async () => chrome.storage.local.clear()) - test('test1', async () => { - const toAdd: timer.limit.Rule = { + test('save, all, remove', async () => { + const toAdd: tt4b.limit.Rule = { id: 1, name: "foobar", cond: ['123'], @@ -16,42 +17,28 @@ describe('limit-database', () => { allowDelay: false, locked: false, } - const id = await db.save(toAdd) - let all: timer.limit.Rule[] = await db.all() + const id = await db.add(toAdd) + let all: tt4b.limit.Rule[] = await db.all() expect(all.length).toEqual(1) - let saved = all[0] - expect(saved.cond).toEqual(toAdd.cond) - expect(saved.time).toEqual(toAdd.time) - expect(saved.name).toEqual(toAdd.name) - expect(saved.enabled).toEqual(toAdd.enabled) - expect(saved.allowDelay).toEqual(toAdd.allowDelay) - const toRewrite = { - id, - name: 'hahah', - cond: ['123'], - time: 21, - enabled: true, - allowDelay: false, - locked: false, - } - // Not rewrite - await db.save(toRewrite) - all = await db.all() - saved = all[0] - expect(saved.cond).toEqual(toAdd.cond) - expect(saved.time).toEqual(toAdd.time) - expect(saved.name).toEqual(toAdd.name) - expect(saved.enabled).toEqual(toAdd.enabled) - expect(saved.allowDelay).toEqual(toAdd.allowDelay) + let { cond, time, name, enabled, allowDelay } = all[0] ?? {} + expect(cond).toEqual(toAdd.cond) + expect(time).toEqual(toAdd.time) + expect(name).toEqual(toAdd.name) + expect(enabled).toEqual(toAdd.enabled) + expect(allowDelay).toEqual(toAdd.allowDelay) - await db.remove(id) + await db.batchRemove([id + 1]) // Not exist, no error throws + all = await db.all() + expect(all.length).toEqual(1) - expect((await db.all()).length).toEqual(0) + await db.batchRemove([id]) + all = await db.all() + expect(all.length).toEqual(0) }) test("update waste", async () => { const date = formatTimeYMD(new Date()) - const id1 = await db.save({ + const id1 = await db.add({ name: "foobar", cond: ["a.*.com"], time: 21, @@ -59,7 +46,7 @@ describe('limit-database', () => { allowDelay: false, locked: false, }) - await db.save({ + await db.add({ name: "foobar", cond: ["*.b.com"], time: 20, @@ -75,11 +62,11 @@ describe('limit-database', () => { const all = await db.all() const used = all.find(a => a.cond?.includes("a.*.com")) expect(used?.records?.[date]).toBeTruthy() - expect(used?.records?.[date].mill).toEqual(10) + expect(used?.records?.[date]?.mill).toEqual(10) }) test("import data", async () => { - const cond1: MakeOptional<timer.limit.Rule, 'id'> = { + const cond1: MakeOptional<tt4b.limit.Rule, 'id'> = { name: 'foobar1', cond: ["cond1"], time: 20, @@ -87,7 +74,7 @@ describe('limit-database', () => { enabled: true, locked: false, } - const cond2: MakeOptional<timer.limit.Rule, 'id'> = { + const cond2: MakeOptional<tt4b.limit.Rule, 'id'> = { name: 'foobar2', cond: ["cond2"], time: 20, @@ -95,15 +82,15 @@ describe('limit-database', () => { enabled: false, locked: false, } - await db.save(cond1) - await db.save(cond2) + await db.add(cond1) + await db.add(cond2) const data2Import = await db.storage.get() // clear chrome.storage.local.clear() expect(await db.all()).toEqual([]) - await db.importData(data2Import) + await db.importData(mockLegacyData(data2Import)) const imported = await db.all() const cond2After = imported.find(a => a.cond?.includes("cond2")) @@ -115,29 +102,10 @@ describe('limit-database', () => { test("import data2", async () => { const importData: Record<string, any> = {} // Invalid data, no error throws - await db.importData(importData) + await db.importData(mockLegacyData(importData)) // Valid data importData["__timer__LIMIT"] = {} - await db.importData(importData) + await db.importData(mockLegacyData(importData)) expect(await db.all()).toEqual([]) }) - - test("update delay", async () => { - const data: MakeOptional<timer.limit.Rule, 'id'> = { - name: 'foobar', - cond: ["cond1"], - time: 20, - allowDelay: false, - enabled: true, - locked: false, - } - const id = await db.save(data) - await db.updateDelay(id, true) - await db.updateDelay(Number.MAX_VALUE, true) - const all = await db.all() - expect(all.length).toEqual(1) - const item = all[0] - expect(item.allowDelay).toBeTruthy() - expect(item.cond).toEqual(["cond1"]) - }) }) \ No newline at end of file diff --git a/test/database/merge-rule-database.test.ts b/test/background/database/merge-rule-database.test.ts similarity index 76% rename from test/database/merge-rule-database.test.ts rename to test/background/database/merge-rule-database.test.ts index 318e7a8f1..009a6fd10 100644 --- a/test/database/merge-rule-database.test.ts +++ b/test/background/database/merge-rule-database.test.ts @@ -1,17 +1,18 @@ import db from "@db/merge-rule-database" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from "../../__mock__/storage" +import { mockLegacyData } from './migratable' -function of(origin: string, merged?: string | number): timer.merge.Rule { +function of(origin: string, merged?: string | number): tt4b.merge.Rule { return { origin, merged: merged || '' } } -describe('merge-rule-database.test', () => { +describe('merge-rule-database', () => { beforeAll(mockStorage) beforeEach(async () => chrome.storage.local.clear()) - test('1', async () => { - let toAdd: timer.merge.Rule[] = [of('4', 2)] + test('add, selectAll, remove', async () => { + let toAdd: tt4b.merge.Rule[] = [of('4', 2)] await db.add(...toAdd) expect((await db.selectAll())).toEqual(expect.arrayContaining(toAdd)) toAdd = [ @@ -40,8 +41,8 @@ describe('merge-rule-database.test', () => { await chrome.storage.local.clear() expect(await db.selectAll()).toEqual([]) - await db.importData(data2Import) - const imported: timer.merge.Rule[] = await db.selectAll() + await db.importData(mockLegacyData(data2Import)) + const imported: tt4b.merge.Rule[] = await db.selectAll() expect(imported).toEqual([ { origin: "www.baidu.com", merged: 2 }, { origin: "www.google.com", merged: "google.com" } diff --git a/test/background/database/migratable.ts b/test/background/database/migratable.ts new file mode 100644 index 000000000..0f944250f --- /dev/null +++ b/test/background/database/migratable.ts @@ -0,0 +1,10 @@ +export function mockLegacyData(data: Record<string, unknown>): tt4b.backup.ExportData { + const withMeta: tt4b.backup.ExportData = { + ...data, + __meta__: { + version: "3.8.15", + ts: Date.now(), + }, + } + return withMeta +} \ No newline at end of file diff --git a/test/background/database/period-database.test.ts b/test/background/database/period-database.test.ts new file mode 100644 index 000000000..e4edff40d --- /dev/null +++ b/test/background/database/period-database.test.ts @@ -0,0 +1,53 @@ +import db from "@db/period-database" +import { keyOf } from '@util/period' +import { formatTimeYMD } from "@util/time" +import { mockStorage } from "../../__mock__/storage" + +function resultOf(date: Date, orderNum: number, milliseconds: number): tt4b.period.Result { + return { ...keyOf(date, orderNum), milliseconds } +} + +describe('period-database', () => { + beforeAll(mockStorage) + + beforeEach(async () => chrome.storage.local.clear()) + + test('get empty, accumulate and get by date', async () => { + const date = new Date(2021, 5, 7) + const dateStr = formatTimeYMD(date) + const yesterday = new Date(2021, 5, 6) + + expect((await db.get(dateStr))).toEqual({}) + + const toAdd: tt4b.period.Result[] = [ + resultOf(date, 0, 56999), + resultOf(date, 1, 2), + resultOf(yesterday, 95, 2) + ] + await db.accumulate(toAdd) + await db.accumulate([ + resultOf(date, 1, 20) + ]) + const data = await db.get(dateStr) + expect(data).toEqual({ 0: 56999, 1: 22 }) + const yesterdayStr = formatTimeYMD(yesterday) + const yesterdayData = await db.get(yesterdayStr) + expect(yesterdayData).toEqual({ 95: 2 }) + }) + + test('getBatch', async () => { + const date = new Date(2021, 5, 7) + const yesterday = new Date(2021, 5, 6) + const toAdd: tt4b.period.Result[] = [ + resultOf(date, 0, 56999), + resultOf(date, 1, 2), + resultOf(yesterday, 95, 2) + ] + await db.accumulate(toAdd) + + let list = await db.getBatch(['20210607', '20210606']) + expect(list.length).toEqual(3) + let all = await db.getAll() + expect(all).toEqual(toAdd) + }) +}) diff --git a/test/database/stat-database.test.ts b/test/background/database/stat-database/classic.test.ts similarity index 54% rename from test/database/stat-database.test.ts rename to test/background/database/stat-database/classic.test.ts index 20006923c..3c47b92e9 100644 --- a/test/database/stat-database.test.ts +++ b/test/background/database/stat-database/classic.test.ts @@ -1,38 +1,43 @@ -import db, { type StatCondition } from "@db/stat-database" +import type { StatCondition } from '@db/stat-database' +import { ClassicStatDatabase, parseImportData } from "@db/stat-database/classic" import { resultOf } from "@util/stat" import { formatTimeYMD, MILL_PER_DAY } from "@util/time" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from '../../../__mock__/storage' -const now = new Date() -const nowStr = formatTimeYMD(now) -const yesterday = new Date(now.getTime() - MILL_PER_DAY) -const beforeYesterday = new Date(now.getTime() - MILL_PER_DAY * 2) +let db: ClassicStatDatabase +const nowTs = Date.now() +const now = formatTimeYMD(nowTs) +const yesterday = formatTimeYMD(new Date(nowTs - MILL_PER_DAY)) +const beforeYesterday = formatTimeYMD(new Date(nowTs - MILL_PER_DAY * 2)) const baidu = 'www.baidu.com' const google = 'www.google.com.hk' -describe('stat-database', () => { - beforeAll(mockStorage) +describe('stat-database/classic', () => { + beforeAll(() => { + mockStorage() + db = new ClassicStatDatabase(chrome.storage.local) + }) beforeEach(async () => chrome.storage.local.clear()) - test('1', async () => { - await db.accumulate(baidu, nowStr, resultOf(100, 0)) - const data: timer.core.Result = await db.get(baidu, now) - expect(data).toEqual(resultOf(100, 0)) + test('accumulate and get single host result', async () => { + await db.accumulate(baidu, now, resultOf(100, 0)) + const data: tt4b.core.Result = await db.get(baidu, now) + expect(data).toMatchObject(resultOf(100, 0)) }) - test('2', async () => { - await db.accumulate(baidu, nowStr, resultOf(200, 0)) - await db.accumulate(baidu, nowStr, resultOf(200, 0)) + test('accumulate merges focus and time', async () => { + await db.accumulate(baidu, now, resultOf(200, 0)) + await db.accumulate(baidu, now, resultOf(200, 0)) let data = await db.get(baidu, now) - expect(data).toEqual(resultOf(400, 0)) - await db.accumulate(baidu, nowStr, resultOf(0, 1)) + expect(data).toEqual({ host: baidu, date: now, focus: 400, time: 0 } satisfies tt4b.core.Row) + await db.accumulate(baidu, now, resultOf(0, 1)) data = await db.get(baidu, now) - expect(data).toEqual(resultOf(400, 1)) + expect(data).toEqual({ host: baidu, date: now, focus: 400, time: 1 } satisfies tt4b.core.Row) }) - test('3', async () => { - await db.accumulateBatch( + test('batchAccumulate and select with condition/date range', async () => { + await db.batchAccumulate( { [google]: resultOf(11, 0), [baidu]: resultOf(1, 0) @@ -40,7 +45,7 @@ describe('stat-database', () => { ) expect((await db.select()).length).toEqual(2) - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(12, 1), [baidu]: resultOf(2, 1) @@ -48,7 +53,7 @@ describe('stat-database', () => { ) expect((await db.select()).length).toEqual(4) - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(13, 2), [baidu]: resultOf(3, 2) @@ -62,9 +67,9 @@ describe('stat-database', () => { // By date range cond = { date: [now, now] } - const expectedResult: timer.core.Row[] = [ - { date: nowStr, focus: 11, host: google, time: 0 }, - { date: nowStr, focus: 1, host: baidu, time: 0 }, + const expectedResult: tt4b.core.Row[] = [ + { date: now, focus: 11, host: google, time: 0 }, + { date: now, focus: 1, host: baidu, time: 0 }, ] expect(await db.select(cond)).toEqual(expectedResult) // Only use start @@ -91,22 +96,22 @@ describe('stat-database', () => { expect((await db.select(cond)).length).toEqual(2) }) - test('5', async () => { - await db.accumulate(baidu, nowStr, resultOf(10, 0)) - await db.accumulate(baidu, formatTimeYMD(yesterday), resultOf(12, 0)) + test('accumulate and delete by key/date', async () => { + await db.accumulate(baidu, now, resultOf(10, 0)) + await db.accumulate(baidu, yesterday, resultOf(12, 0)) expect((await db.select()).length).toEqual(2) // Delete yesterday's data - await db.deleteByUrlAndDate(baidu, yesterday) + await db.delete({ host: baidu, date: yesterday }) expect((await db.select()).length).toEqual(1) // Delete yesterday's data again, nothing changed - await db.deleteByUrlAndDate(baidu, yesterday) + await db.delete({ host: baidu, date: yesterday }) expect((await db.get(baidu, now)).focus).toEqual(10) // Add one again, and another - await db.accumulate(baidu, formatTimeYMD(beforeYesterday), resultOf(1, 1)) - await db.accumulate(google, nowStr, resultOf(0, 0)) + await db.accumulate(baidu, beforeYesterday, resultOf(1, 1)) + await db.accumulate(google, now, resultOf(0, 0)) expect((await db.select()).length).toEqual(3) // Delete all the baidu - await db.deleteByUrl(baidu) + await db.deleteByHost(baidu) const cond: StatCondition = { keys: baidu } // Nothing of baidu remained expect((await db.select(cond)).length).toEqual(0) @@ -115,52 +120,50 @@ describe('stat-database', () => { const list = await db.select(cond) expect(list.length).toEqual(1) // Add one item of baidu again again - await db.accumulate(baidu, nowStr, resultOf(1, 1)) + await db.accumulate(baidu, now, resultOf(1, 1)) // But delete google - await db.delete(list) + await db.delete(...list) // Then only one item of baidu expect((await db.select()).length).toEqual(1) }) - test('6', async () => { - await db.accumulateBatch({}, now) + test('batchAccumulate empty returns zero and get returns zero result', async () => { + await db.batchAccumulate({}, now) expect((await db.select()).length).toEqual(0) - // Return zero instance const result = await db.get(baidu, now) expect([result.focus, result.time]).toEqual([0, 0]) }) - test('7', async () => { + test('delete by key and deleteByHost date range', async () => { const foo = resultOf(1, 1) - await db.accumulate(baidu, nowStr, foo) - await db.accumulate(baidu, formatTimeYMD(yesterday), foo) - await db.accumulate(baidu, formatTimeYMD(beforeYesterday), foo) - await db.deleteByUrlBetween(baidu, now, now) + await db.accumulate(baidu, now, foo) + await db.accumulate(baidu, yesterday, foo) + await db.accumulate(baidu, beforeYesterday, foo) + await db.delete({ host: baidu, date: now }) expect((await db.select()).length).toEqual(2) - await db.deleteByUrlBetween(baidu, now, beforeYesterday) // Invalid + await db.deleteByHost(baidu, [now, beforeYesterday]) // Invalid expect((await db.select()).length).toEqual(2) }) - test("importData", async () => { + test("parseImportData", async () => { const foo = resultOf(1, 1) - await db.accumulate(baidu, nowStr, foo) + await db.accumulate(baidu, now, foo) const data2Import = await db.storage.get() chrome.storage.local.clear() data2Import.foo = "bar" - await db.importData(data2Import) - const data = await db.select({}) + const data = parseImportData(data2Import) expect(data.length).toEqual(1) const item = data[0] - expect(item.date).toEqual(nowStr) - expect(item.host).toEqual(baidu) - expect(item.focus).toEqual(1) - expect(item.time).toEqual(1) + expect(item?.date).toEqual(now) + expect(item?.host).toEqual(baidu) + expect(item?.focus).toEqual(1) + expect(item?.time).toEqual(1) }) - test("importData2", async () => { - await db.importData({ + test("parseImportData2", async () => { + const data = parseImportData({ // Valid "20210910github.com": { focus: 1, @@ -177,16 +180,6 @@ describe('stat-database', () => { // Ignored with zero info "20210914github.com": {} }) - const imported = await db.select() - expect(imported.length).toEqual(2) - }) - - test("importData3", async () => { - await db.importData([]) - expect(await db.select()).toEqual([]) - await db.importData({ foo: "bar" }) - expect(await db.select()).toEqual([]) - await db.importData(false) - expect(await db.select()).toEqual([]) + expect(data.length).toEqual(2) }) }) \ No newline at end of file diff --git a/test/background/database/stat-database/idb.test.ts b/test/background/database/stat-database/idb.test.ts new file mode 100644 index 000000000..8ed585dc6 --- /dev/null +++ b/test/background/database/stat-database/idb.test.ts @@ -0,0 +1,226 @@ +import { zeroResult, zeroRow } from '@db/stat-database/common' +import { IDBStatDatabase } from '@db/stat-database/idb' +import 'fake-indexeddb/auto' +import { mockRuntime } from '../../../__mock__/runtime' + +const GOOGLE = 'www.google.com' +const GITHUB = 'www.github.com' +const GITHUB_VIRTUAL = 'www.github.com/sheepzh/**' +const GROUP_1 = 1 +const GROUP_2 = 2 +const MAYBE_GROUP_1 = '1' + +let db: IDBStatDatabase + +describe('stat-database/idb', () => { + beforeAll(async () => { + mockRuntime() + db = new IDBStatDatabase() + await db.upgrade() + }) + + beforeEach(() => db.clear()) + + test('accumulate', async () => { + await db.accumulate(GITHUB, '20240601', { focus: 10, time: 20 }) + await db.accumulate(GOOGLE, new Date(2025, 10, 1), { focus: 1, time: 0 }) + await db.accumulateGroup(GROUP_1, '20240601', { focus: 5, time: 10 }) + + // Hosts + const github = await db.get(GITHUB, '20240601') + expect(github).toEqual({ host: GITHUB, date: '20240601', focus: 10, time: 20 } satisfies tt4b.core.Row) + + const google = await db.get(GOOGLE, new Date(2025, 10, 1)) + expect(google).toEqual({ host: GOOGLE, date: '20251101', focus: 1, time: 0 } satisfies tt4b.core.Row) + + // Date not exist + const notExist = await db.get(GOOGLE, '20240601') + expect(notExist).toEqual(zeroRow(GOOGLE, '20240601')) + + // list + const list = await db.select() + expect(list).toEqual([ + { host: GITHUB, date: '20240601', focus: 10, time: 20 }, + { host: GOOGLE, date: '20251101', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + + // Groups + const byGroupId = await db.get(`${GROUP_1}`, '20240601') + expect(byGroupId).toMatchObject(zeroResult()) + + const groups = await db.selectGroup({ date: '20240601' }) + expect(groups).toEqual([{ host: `${GROUP_1}`, date: '20240601', focus: 5, time: 10 } satisfies tt4b.core.Row]) + }) + + test('batchAccumulate', async () => { + // Noise data + await db.accumulateGroup(GROUP_1, '20240602', { focus: 5, time: 10 }) + + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [MAYBE_GROUP_1]: { focus: 5, time: 10 }, + }, '20240602') + + expect(await db.get(GITHUB, '20240602')) + .toEqual({ host: GITHUB, date: '20240602', focus: 10, time: 20 } satisfies tt4b.core.Row) + expect(await db.get(GOOGLE, '20240602')) + .toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 0 } satisfies tt4b.core.Row) + expect(await db.get(MAYBE_GROUP_1, '20240602')) + .toEqual({ host: MAYBE_GROUP_1, date: '20240602', focus: 5, time: 10 } satisfies tt4b.core.Row) + + await db.batchAccumulate({ [GOOGLE]: { focus: 0, time: 1 } }, '20240602') + + expect(await db.get(GOOGLE, '20240602')).toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 1 } satisfies tt4b.core.Row) + }) + + test('multiple indexes', async () => { + // Insert noise data + await db.accumulateGroup(GROUP_1, '20240603', { focus: 30, time: 20 }) + + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [GITHUB_VIRTUAL]: { focus: 5, time: 10 }, + }, '20240603') + + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + }, '20240602') + + await db.accumulateGroup(GROUP_1, '20240603', { focus: 5, time: 10 }) + await db.accumulateGroup(GROUP_1, '20240602', { focus: 1, time: 1 }) + + // Query by date index + expect(await db.select({ date: [, '20240602'] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ date: '20240603' })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ date: ['20240602', '20240603'] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + // Same as above, but reversed order + expect(await db.select({ date: ['20240603', '20240602'] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + // Including virtual + expect(await db.select({ date: '20240603', virtual: true })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + + // Query by host + expect(await db.select({ keys: GOOGLE })).toEqual([ + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ keys: GITHUB })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ keys: GITHUB_VIRTUAL })).toEqual([] satisfies tt4b.core.Row[]) + expect(await db.select({ keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies tt4b.core.Row[]) + + // Query by date and host index + expect(await db.select({ date: '20240603', keys: GOOGLE })).toEqual([ + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ date: '20240603', keys: GITHUB_VIRTUAL })).toEqual([] satisfies tt4b.core.Row[]) + expect(await db.select({ date: '20240603', keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies tt4b.core.Row[]) + + // Query by time index + expect(await db.select({ timeRange: [10, 20] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ timeRange: [10, 20], virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ timeRange: [10, 20], date: '20240603', virtual: true })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies tt4b.core.Row[]) + + // Query by focus index + expect(await db.select({ focusRange: [5, 10] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ focusRange: [5, 10], virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies tt4b.core.Row[]) + expect(await db.select({ focusRange: [5, 10], date: '20240603', virtual: true })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies tt4b.core.Row[]) + }) + + test('delete', async () => { + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [GITHUB_VIRTUAL]: { focus: 5, time: 10 }, + [MAYBE_GROUP_1]: { focus: 222, time: 222 }, + }, '20240603') + + expect((await db.select({ virtual: true })).length).toEqual(4) + + await db.delete({ host: GOOGLE, date: '20240603' }) + expect(await db.get(GOOGLE, '20240603')).toEqual(zeroRow(GOOGLE, '20240603')) + + await db.deleteByHost(GITHUB) + expect(await db.get(GITHUB, '20240603')).toEqual(zeroRow(GITHUB, '20240603')) + + await db.deleteByGroup(GROUP_1) + expect(await db.get(MAYBE_GROUP_1, '20240603')) + .toEqual({ host: MAYBE_GROUP_1, date: '20240603', focus: 222, time: 222 } satisfies tt4b.core.Row) + + await db.delete({ host: MAYBE_GROUP_1, date: '20240603' }, { host: GITHUB_VIRTUAL, date: '20240603' }) + expect(await db.select({ virtual: true })).toEqual([] satisfies tt4b.core.Row[]) + }) + + test('multiple select groups', async () => { + // Insert noise data + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [GITHUB_VIRTUAL]: { focus: 5, time: 10 }, + [MAYBE_GROUP_1]: { focus: 222, time: 222 }, + }, '20240603') + + await db.accumulateGroup(GROUP_1, '20240602', { focus: 1, time: 1 }) + await db.accumulateGroup(GROUP_2, '20240602', { focus: 2, time: 2 }) + await db.accumulateGroup(GROUP_1, '20240603', { focus: 3, time: 3 }) + await db.accumulateGroup(GROUP_2, '20240603', { focus: 4, time: 4 }) + + expect(await db.selectGroup({ date: '20240603' })).toEqual([ + { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, + { date: '20240603', host: `${GROUP_2}`, focus: 4, time: 4 }, + ] satisfies tt4b.core.Row[]) + + expect(await db.selectGroup({ date: ['20240602', '20240603'], keys: GROUP_1.toString() })).toEqual([ + { date: '20240602', host: `${GROUP_1}`, focus: 1, time: 1 }, + { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, + ] satisfies tt4b.core.Row[]) + }) +}) \ No newline at end of file diff --git a/test/database/whitelist-database.test.ts b/test/background/database/whitelist-database.test.ts similarity index 85% rename from test/database/whitelist-database.test.ts rename to test/background/database/whitelist-database.test.ts index 13aeb280f..68d2ea1c9 100644 --- a/test/database/whitelist-database.test.ts +++ b/test/background/database/whitelist-database.test.ts @@ -1,12 +1,12 @@ import db from "@db/whitelist-database" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from "../../__mock__/storage" describe('timer-database', () => { beforeAll(mockStorage) beforeEach(async () => chrome.storage.local.clear()) - test('1', async () => { + test('add, selectAll, exist, remove work correctly', async () => { await db.add('www.baidu.com') await db.add('google.com') const list = await db.selectAll() diff --git a/test/service/components/host-merge-ruler.test.ts b/test/background/service/components/host-merge-ruler.test.ts similarity index 100% rename from test/service/components/host-merge-ruler.test.ts rename to test/background/service/components/host-merge-ruler.test.ts diff --git a/test/service/components/period-calculator.test.ts b/test/background/service/components/period-calculator.test.ts similarity index 57% rename from test/service/components/period-calculator.test.ts rename to test/background/service/components/period-calculator.test.ts index be9a6eeea..267c20cb6 100644 --- a/test/service/components/period-calculator.test.ts +++ b/test/background/service/components/period-calculator.test.ts @@ -1,8 +1,10 @@ -import { calculate, getMaxDivisiblePeriod, merge } from "@service/components/period-calculator" -import { keyOf, PERIOD_PER_DATE } from "@util/period" +import { calculate, merge } from "@service/components/period-calculator" +import { keyOf, MINUTE_PER_PERIOD } from '@util/period' import { MILL_PER_DAY } from "@util/time" -function resultOf(date: Date | number, orderNum: number, milliseconds: number): timer.period.Result { +const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD + +function resultOf(date: Date | number, orderNum: number, milliseconds: number): tt4b.period.Result { return { ...keyOf(date, orderNum), milliseconds } } @@ -25,13 +27,13 @@ test('', () => { let result = calculate(ts1, 877) expect(result.length).toEqual(1) let current = result[0] - let realCurrent: timer.period.Result = resultOf(base, 1 + 13 * 4, 877) + let realCurrent: tt4b.period.Result = resultOf(base, 1 + 13 * 4, 877) expect(current).toEqual(realCurrent) result = calculate(ts1, (7 * 60 + 56) * 1000 + 877) expect(result.length).toEqual(2) const last = result[0] - const realLast: timer.period.Result = resultOf(base, 13 * 4, 1) + const realLast: tt4b.period.Result = resultOf(base, 13 * 4, 1) expect(last).toEqual(realLast) current = result[1] realCurrent.milliseconds = (7 * 60 + 56) * 1000 + 876 @@ -52,52 +54,30 @@ test('', () => { expect(result.length).toEqual(2) const last = result[0] - const realLast: timer.period.Result = resultOf(yesterday, 95, 3000 - 2876) + const realLast = resultOf(yesterday, 95, 3000 - 2876) expect(last).toEqual(realLast) const current = result[1] - const realCurrent: timer.period.Result = resultOf(base, 0, 2876) + const realCurrent = resultOf(base, 0, 2876) expect(current).toEqual(realCurrent) }) -test('', () => { - const dateArr = [2020, 6, 6] - - const key = keyOf(new Date(2020, 5, 6)) - key.order = 4 - let result = getMaxDivisiblePeriod(key, 1) - expect([result.year, result.month, result.date, result.order]).toEqual([...dateArr, 4]) - - key.order = 3 - result = getMaxDivisiblePeriod(key, 1) - expect([result.year, result.month, result.date, result.order]).toEqual([...dateArr, 3]) - - key.order = 0 - result = getMaxDivisiblePeriod(key, 2) - expect([result.year, result.month, result.date, result.order]).toEqual([2020, 6, 5, 95]) -}) - -test('merge', () => { - // 20200501 - const start = new Date(2020, 4, 1) - // 20200531 - const end = new Date(2020, 4, 31) - +test.skip('merge', () => { const d20200506 = new Date(2020, 4, 6) - const toMerge: timer.period.Result[] = [ + const toMerge: tt4b.period.Result[] = [ resultOf(d20200506, 19, 20), resultOf(d20200506, 18, 20), resultOf(d20200506, 20, 20), resultOf(new Date(2020, 4, 20), 20, 20), ] - let result = merge(toMerge, { periodSize: 1, start: keyOf(start, 0), end: keyOf(end, 3) }) + let result = merge(toMerge, 1) expect(result.length).toEqual(30 * PERIOD_PER_DATE + 4) expect(result.filter(p => p.date === '20200506' && p.milliseconds > 0).length).toEqual(3) let milliseconds = result.filter(p => p.date === '20200506').map(p => p.milliseconds).reduce((a, b) => a + b, 0) expect(milliseconds).toEqual(60) - result = merge(toMerge, { periodSize: 4, start: keyOf(new Date(2020, 4, 11), 0), end: keyOf(end, 3) }) + result = merge(toMerge, 4) expect(result.length).toEqual(20 * PERIOD_PER_DATE / 4 + 4 / 4) - result = merge(toMerge, { periodSize: 2, start: keyOf(start, 0), end: keyOf(end, 3) }) + result = merge(toMerge, 2) expect(result.length).toEqual(30 * PERIOD_PER_DATE / 2 + 4 / 2) }) \ No newline at end of file diff --git a/test/background/service/limit-service.test.ts b/test/background/service/limit-service.test.ts new file mode 100644 index 000000000..d7cb49a0c --- /dev/null +++ b/test/background/service/limit-service.test.ts @@ -0,0 +1,67 @@ +import { mockStorage } from "../../__mock__/storage" + +let calcTimeState: (item: tt4b.limit.Item, reminderMills: number, delayDuration: number) => { + daily: 'NORMAL' | 'REMINDER' | 'LIMITED' + weekly: 'NORMAL' | 'REMINDER' | 'LIMITED' +} + +beforeAll(async () => { + mockStorage() + Object.assign(global.chrome as object, { + runtime: { + id: "test", + getManifest: () => ({ manifest_version: 3 }), + }, + }) + const mod = await import("@/background/service/limit-service") + calcTimeState = mod.calcTimeState +}) + +describe("background/limit-service", () => { + test("calcTimeState", () => { + const item: tt4b.limit.Item = { + id: 1, + name: "foobar", + cond: [], + time: 10, + weekly: 10, + waste: 0, + visit: 0, + delayCount: 0, + weeklyWaste: 0, + weeklyVisit: 0, + weeklyDelayCount: 0, + enabled: true, + allowDelay: false, + locked: false, + } + const duration = 1000 + + type LimitState = "NORMAL" | "REMINDER" | "LIMITED" + + const assert = (daily: LimitState, weekly: LimitState) => { + const res = calcTimeState(item, duration, 5) + expect(res?.daily).toBe(daily) + expect(res?.weekly).toBe(weekly) + } + + item.waste = 9000 + assert("NORMAL", "NORMAL") + + item.waste = 9001 + assert("REMINDER", "NORMAL") + + item.waste = 10001 + assert("LIMITED", "NORMAL") + + item.allowDelay = true + item.delayCount = 1 + + item.weeklyWaste = 9000 + assert("NORMAL", "NORMAL") + item.weeklyWaste = 9001 + assert("NORMAL", "REMINDER") + item.weeklyWaste = 10001 + assert("NORMAL", "LIMITED") + }) +}) diff --git a/test/background/service/whitelist/processor.test.ts b/test/background/service/whitelist/processor.test.ts new file mode 100644 index 000000000..9cd20b307 --- /dev/null +++ b/test/background/service/whitelist/processor.test.ts @@ -0,0 +1,107 @@ +import WhitelistProcessor from '@service/whitelist/processor' + +describe('whitelist-holder', () => { + let processor: WhitelistProcessor + + beforeEach(() => { processor = new WhitelistProcessor() }) + + const verify = (whitelist: string[], cases: Array<[string, string, boolean]>) => { + processor.setWhitelist(whitelist) + cases.forEach(([host, url, expected]) => expect(processor.contains(host, url)).toBe(expected)) + } + + test('setWhitelist basic', () => { + processor.setWhitelist([]) + expect(processor.contains("github.com", "https://github.com/")).toBeFalsy() + + processor.setWhitelist(['', 'github.com', '']) + expect(processor.contains("github.com", "https://github.com/")).toBeTruthy() + + processor.setWhitelist(['google.com']) + expect(processor.contains("github.com", "https://github.com/")).toBeFalsy() + expect(processor.contains("google.com", "https://google.com/")).toBeTruthy() + }) + + test('normal hosts', () => { + verify(["www.google.com", "github.com"], [ + ["github.com", "", true], + ["github.com", "https://unrelated.com/", true], // URL ignored + ["www.google.com", "http://www.google.com/search", true], + ["www.github.com", "https://www.github.com/", false], + ["google.com", "https://google.com/", false], + ]) + }) + + test('virtual hosts with wildcards', () => { + verify(["github.com/*", "*.google.com/**", "*.example.com/path/*"], [ + // Single wildcard: matches one level + ["", "https://github.com/sheepzh", true], + ["", "https://github.com", false], + ["", "https://github.com/sheepzh/timer", false], + // Host wildcard + ["", "http://map.google.com/search", true], + ["", "http://foo.bar.google.com/path", true], + ["", "https://google.com/", false], + // Complex pattern + ["", "https://sub.example.com/path/to", true], + ["", "https://sub.example.com/path/to/nested", false], + ]) + + verify(["gitlab.com/**"], [ + // Double wildcard: matches any depth + ["", "https://gitlab.com/", true], + ["", "https://gitlab.com/group/project/issues", true], + ]) + }) + + test('exclude patterns', () => { + verify( + ["github.com", "*.google.com/**", "+github.com/login", "+www.google.com/**"], + [ + ["github.com", "https://github.com/", true], + ["github.com", "https://github.com/login", false], + ["", "http://map.google.com/search", true], + ["", "https://www.google.com/search", false], + ] + ) + + verify(["example.com/**", "+example.com/admin/**", "+example.com/login"], [ + ["", "https://example.com/page", true], + ["", "https://example.com/login", false], + ["", "https://example.com/admin/dashboard", false], + ]) + }) + + test('mixed patterns', () => { + verify(["example.com", "test.com/**", "github.com", "*.example.com/**"], [ + // Normal host: only checks host param + ["example.com", "https://other.com/", true], + ["other.com", "https://example.com/", false], + // Virtual host: checks URL + ["", "https://test.com/path", true], + ["", "https://other.com/path", false], + // Empty host param: only virtual patterns work + ["", "https://github.com/", false], + ["", "https://sub.example.com/path", true], + ]) + }) + + test('edge cases', () => { + // Trailing slashes, special chars, duplicates, long list + verify(["github.com/**", "example.com/path-with-dash/**"], [ + ["", "https://github.com/", true], + ["", "https://github.com", true], + ["", "https://example.com/path-with-dash/page", true], + ["", "https://example.com/path_with_dash/page", false], + ]) + + processor.setWhitelist(['github.com', 'github.com']) + expect(processor.contains("github.com", "")).toBeTruthy() + + const longList = Array.from({ length: 100 }, (_, i) => `site${i}.com`) + processor.setWhitelist(longList) + expect(processor.contains("site0.com", "")).toBeTruthy() + expect(processor.contains("site99.com", "")).toBeTruthy() + expect(processor.contains("site100.com", "")).toBeFalsy() + }) +}) \ No newline at end of file diff --git a/test/common/logger.test.ts b/test/common/logger.test.ts deleted file mode 100644 index 3c7da6182..000000000 --- a/test/common/logger.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { closeLog, log, openLog } from "@src/common/logger" - -test('test open log', () => { - global.console.log = jest.fn() - openLog() - log("foobar") - expect(console.log).toHaveBeenCalledWith("foobar") -}) - -test('test close log', () => { - global.console.log = jest.fn() - closeLog() - log("foobar") - expect(console.log).toHaveBeenCalledTimes(0) -}) \ No newline at end of file diff --git a/test/database/period-database.test.ts b/test/database/period-database.test.ts deleted file mode 100644 index 8fcffd308..000000000 --- a/test/database/period-database.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import db from "@db/period-database" -import { keyOf, MILL_PER_PERIOD } from "@util/period" -import { formatTimeYMD } from "@util/time" -import { mockStorage } from "../__mock__/storage" - -function resultOf(date: Date, orderNum: number, milliseconds: number): timer.period.Result { - return { ...keyOf(date, orderNum), milliseconds } -} - -describe('period-database', () => { - beforeAll(mockStorage) - - beforeEach(async () => chrome.storage.local.clear()) - - test('1', async () => { - const date = new Date(2021, 5, 7) - const dateStr = formatTimeYMD(date) - const yesterday = new Date(2021, 5, 6) - - expect((await db.get(dateStr))).toEqual({}) - - const toAdd: timer.period.Result[] = [ - resultOf(date, 0, 56999), - resultOf(date, 1, 2), - resultOf(yesterday, 95, 2) - ] - await db.accumulate(toAdd) - await db.accumulate([ - resultOf(date, 1, 20) - ]) - const data = await db.get(dateStr) - expect(data).toEqual({ 0: 56999, 1: 22 }) - const yesterdayStr = formatTimeYMD(yesterday) - const yesterdayData = await db.get(yesterdayStr) - expect(yesterdayData).toEqual({ 95: 2 }) - }) - - test('getBatch', async () => { - const date = new Date(2021, 5, 7) - const yesterday = new Date(2021, 5, 6) - const toAdd: timer.period.Result[] = [ - resultOf(date, 0, 56999), - resultOf(date, 1, 2), - resultOf(yesterday, 95, 2) - ] - await db.accumulate(toAdd) - - let list = await db.getBatch(['20210607', '20210606']) - expect(list.length).toEqual(3) - let all = await db.getAll() - expect(all).toEqual(toAdd) - }) - - test("importData", async () => { - const date = new Date(2021, 5, 7) - const yesterday = new Date(2021, 5, 6) - const toAdd: timer.period.Result[] = [ - resultOf(date, 0, 56999), - resultOf(date, 1, 2), - resultOf(yesterday, 95, 2) - ] - await db.accumulate(toAdd) - - const data2Import = await db.storage.get() - chrome.storage.local.clear() - expect(await db.getAll()).toEqual([]) - data2Import.foo = "bar" - db.importData(data2Import) - - const imported = await db.getAll() - expect(imported.length).toEqual(3) - }) - - // Invalid data - test("importData2", async () => { - await db.importData(undefined) - expect(await db.getAll()).toEqual([]) - await db.importData({ foo: "bar" }) - expect(await db.getAll()).toEqual([]) - await db.importData([]) - expect(await db.getAll()).toEqual([]) - await db.importData(1) - expect(await db.getAll()).toEqual([]) - await db.importData({ - __timer__PERIOD20210607: { - "-1": 100, - foo: "bar", - 96: 1000, - 85: "???", - 3: undefined, - 4: "", - } - }) - expect(await db.getAll()).toEqual([]) - }) - - test("importData3", async () => { - await db.importData({ - __timer__PERIOD20210607: { - 0: MILL_PER_PERIOD + 1, - 1: 100, - 2: "100", - } - }) - const imported: timer.period.Result[] = await db.getAll() - expect(imported.length).toEqual(3) - const orderMillMap: Record<string, number> = {} - imported.forEach(({ milliseconds, order }) => orderMillMap[order] = milliseconds) - expect(orderMillMap).toEqual({ 0: MILL_PER_PERIOD, 1: 100, 2: 100 }) - }) -}) diff --git a/test/rstest.config.mts b/test/rstest.config.mts new file mode 100644 index 000000000..eaf9b53a3 --- /dev/null +++ b/test/rstest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from '@rstest/core' + +export default defineConfig({ + include: ['test/**/*.test.ts'], + testEnvironment: 'jsdom', + globals: true, +}) diff --git a/test/service/whitelist/processor.test.ts b/test/service/whitelist/processor.test.ts deleted file mode 100644 index 4a5de79c6..000000000 --- a/test/service/whitelist/processor.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import WhitelistProcessor from '@service/whitelist/processor' - -describe('whitelist-holder', () => { - let processor: WhitelistProcessor - - beforeEach(() => processor = new WhitelistProcessor()) - - test('normal', () => { - processor.setWhitelist([ - "www.google.com", - "github.com", - ]) - expect(processor.contains("github.com", "")).toBeTruthy() - expect(processor.contains("www.github.com", "")).toBeFalsy() - expect(processor.contains("www.google.com", "http://www.google.com/search")).toBeTruthy() - }) - - test('wildcards', () => { - processor.setWhitelist([ - "www.github.com", - "*.google.com/**", - "+www.google.com/**", - ]) - expect(processor.contains("google.com", "https://google.com/")).toBeFalsy() - expect(processor.contains("", "http://map.google.com/search")).toBeTruthy() - - // virtual sites only use url - expect(processor.contains("www.google.com", "https://foo.bar.google.com/")).toBeTruthy() - // hit "+www.google.com/**" - expect(processor.contains("www.google.com", "https://www.google.com/")).toBeFalsy() - }) -}) \ No newline at end of file diff --git a/test/util/array.test.ts b/test/util/array.test.ts index 1f7e04f47..28f69589a 100644 --- a/test/util/array.test.ts +++ b/test/util/array.test.ts @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import { allMatch, anyMatch, average, groupBy, rotate, sum, toMap } from "@util/array" +import { allMatch, anyMatch, groupBy, rotate, sum, toMap } from "@util/array" describe("util/array", () => { test('group by', () => { - const arr = [ + const arr: [number, number][] = [ [1, 2], [1, 3], [2, 3], @@ -43,16 +43,10 @@ describe("util/array", () => { test("sum", () => { let arr: number[] = [1, 2, 3, 4] - expect(10).toEqual(sum(arr)) + expect(sum(arr)).toEqual(10) arr = [] - expect(0).toEqual(sum(arr)) - }) - - test("average", () => { - expect(average([10, 1])).toEqual(11 / 2) - expect(average([])).toBeNull() - expect(average([0])).toEqual(0) + expect(sum(arr)).toEqual(0) }) test("allMatch", () => { diff --git a/test/util/chrome/compile.test.ts b/test/util/chrome/compile.test.ts index cce0939b3..c5b825645 100644 --- a/test/util/chrome/compile.test.ts +++ b/test/util/chrome/compile.test.ts @@ -1,8 +1,10 @@ -import compile from "@i18n/chrome/compile" +import compile from "../../../src/i18n/chrome/compile" -test('1', () => { - const messages = { app: '123', foo: { bar: '234' } } - const chromeMessages = compile(messages) - expect(chromeMessages.app.message).toEqual('123') - expect(chromeMessages.foo_bar.message).toEqual('234') +describe('i18n/chrome/compile', () => { + test('flattens nested messages to chrome i18n format', () => { + const messages = { app: '123', foo: { bar: '234' } } + const chromeMessages = compile(messages) + expect(chromeMessages.app.message).toEqual('123') + expect(chromeMessages.foo_bar.message).toEqual('234') + }) }) \ No newline at end of file diff --git a/test/util/limit.test.ts b/test/util/limit.test.ts index dda39391e..209ab07cd 100644 --- a/test/util/limit.test.ts +++ b/test/util/limit.test.ts @@ -1,21 +1,22 @@ +import { rstest } from '@rstest/core' import { - calcTimeState, cleanCond, dateMinute2Idx, hasLimited, hasWeeklyLimited, isEffective, isEnabledAndEffective, - matchCond, matches, meetLimit, meetTimeLimit, period2Str + dateMinute2Idx, hasLimited, hasWeeklyLimited, isEffective, matchCond, matches, meetLimit, meetTimeLimit, + period2Str, } from "@util/limit" describe('util/limit', () => { - test('cleanCond', () => { - expect(cleanCond('https://github.com?a=2')).toEqual('github.com') - expect(cleanCond('https://github.com/?a=2')).toEqual('github.com') - expect(cleanCond('*://github.com/?a=2')).toEqual('github.com') - expect(cleanCond('www.github.com/sheepzh/?a=2')).toEqual('www.github.com/sheepzh') - expect(cleanCond('https://')).toBeUndefined() - }) - test('matches', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', '+github.com/sheepzh/time-tracker-4-browser', '+www.bilibili.com/cheese', '*.bilibili.com*'] + const cond = [ + 'www.baidu.com', '+www.baidu.com/**', + '*.google.com', + 'github.com/sheepzh', + '+github.com/sheepzh/time-tracker-4-browser', + '+www.bilibili.com/cheese', + '*.bilibili.com*', + ] expect(matches(cond, 'https://www.baidu.com')).toBe(true) + expect(matches(cond, 'http://www.baidu.com/')).toBe(true) expect(matches(cond, 'http://hk.google.com')).toBe(true) expect(matches(cond, 'http://github.com/sheepzh/poetry')).toBe(true) expect(matches(cond, 'http://github.com/sheepzh/time-tracker-4-browser')).toBe(false) @@ -46,12 +47,14 @@ describe('util/limit', () => { }) test('meetTimeLimit', () => { - expect(meetTimeLimit(undefined, undefined, undefined, undefined)).toBe(false) + const delay5 = { duration: 5, allow: false as boolean, count: 0 } + + expect(meetTimeLimit({ wasted: 0, maxLimit: 0 }, { ...delay5, allow: false })).toBe(false) - expect(meetTimeLimit(1, 1001, undefined, undefined)).toBe(true) - expect(meetTimeLimit(1, 1001, true, undefined)).toBe(true) - expect(meetTimeLimit(1, 1001, true, 1)).toBe(false) - expect(meetTimeLimit(1, (1 + 60 * 5) * 1000 + 1, true, 1)).toBe(true) + expect(meetTimeLimit({ wasted: 1001, maxLimit: 1 }, { ...delay5, allow: false })).toBe(true) + expect(meetTimeLimit({ wasted: 1001, maxLimit: 1 }, { duration: 5, allow: true, count: 0 })).toBe(true) + expect(meetTimeLimit({ wasted: 1001, maxLimit: 1 }, { duration: 5, allow: true, count: 1 })).toBe(false) + expect(meetTimeLimit({ wasted: (1 + 60 * 5) * 1000 + 1, maxLimit: 1 }, { duration: 5, allow: true, count: 1 })).toBe(true) }) test('period2Str', () => { @@ -71,41 +74,20 @@ describe('util/limit', () => { expect(isEffective(undefined)).toBe(true) expect(isEffective([])).toBe(true) - Object.defineProperty(global, 'performance', { writable: true }) - jest.useFakeTimers({ doNotFake: ['performance'] }) + rstest.useFakeTimers({}) const monday = new Date() monday.setFullYear(2025) monday.setMonth(0) monday.setDate(20) - jest.setSystemTime(monday) + rstest.setSystemTime(monday) expect(isEffective([1, 2])).toBe(false) expect(isEffective([0, 1, 2])).toBe(true) - }) - - test('isEffectiveAndEnabled', () => { - Object.defineProperty(global, 'performance', { writable: true }) - jest.useFakeTimers({ doNotFake: ['performance'] }) - const monday = new Date() - monday.setFullYear(2025) - monday.setMonth(0) - monday.setDate(20) - jest.setSystemTime(monday) - - const rule = (weekdays: number[], enabled: boolean): timer.limit.Rule => ({ - id: 1, name: 'foobar', - cond: [], - time: 0, weekdays, - enabled, allowDelay: false, locked: false, - }) - - expect(isEnabledAndEffective(rule([0, 1, 2], true))).toBe(true) - expect(isEnabledAndEffective(rule([0, 1, 2], false))).toBe(false) - expect(isEnabledAndEffective(rule([1, 2], true))).toBe(false) + rstest.useRealTimers() }) test('hasWeeklyLimited', () => { - const item: timer.limit.Item = { + const item: tt4b.limit.Item = { id: 1, name: 'foobar', cond: [], @@ -121,71 +103,24 @@ describe('util/limit', () => { locked: false, } - expect(hasWeeklyLimited(item)).toBe(false) + expect(hasWeeklyLimited(item, 5)).toBe(false) item.weekly = 299 - expect(hasWeeklyLimited(item)).toBe(false) + expect(hasWeeklyLimited(item, 5)).toBe(false) item.weeklyWaste = 299 * 1000 + 1 - expect(hasWeeklyLimited(item)).toBe(true) + expect(hasWeeklyLimited(item, 5)).toBe(true) item.weeklyDelayCount = 1 - expect(hasWeeklyLimited(item)).toBe(true) - - item.allowDelay = true - expect(hasWeeklyLimited(item)).toBe(false) - }) - - test('calcTimeState', () => { - const item: timer.limit.Item = { - id: 1, - name: 'foobar', - cond: [], - time: 10, - weekly: 10, - waste: 0, - visit: 0, - delayCount: 0, - weeklyWaste: 0, - weeklyVisit: 0, - weeklyDelayCount: 0, - enabled: true, - allowDelay: false, - locked: false, - } - const duration = 1000 - - type LimitState = 'NORMAL' | 'REMINDER' | 'LIMITED' - - const assert = (daily: LimitState, weekly: LimitState) => { - const res = calcTimeState(item, duration) - expect(res?.daily).toBe(daily) - expect(res?.weekly).toBe(weekly) - } - - item.waste = 9000 - assert('NORMAL', 'NORMAL') - - item.waste = 9001 - assert('REMINDER', 'NORMAL') - - item.waste = 10001 - assert('LIMITED', 'NORMAL') + expect(hasWeeklyLimited(item, 5)).toBe(true) item.allowDelay = true - item.delayCount = 1 - - item.weeklyWaste = 9000 - assert('NORMAL', 'NORMAL') - item.weeklyWaste = 9001 - assert('NORMAL', 'REMINDER') - item.weeklyWaste = 10001 - assert('NORMAL', 'LIMITED') + expect(hasWeeklyLimited(item, 5)).toBe(false) }) test('hasLimit', () => { - const assert = (setup: (item: timer.limit.Item) => void, limited: boolean) => { - const item: timer.limit.Item = { + const assert = (setup: (item: tt4b.limit.Item) => void, limited: boolean) => { + const item: tt4b.limit.Item = { id: 1, name: 'foobar', cond: [], @@ -202,7 +137,7 @@ describe('util/limit', () => { locked: false, } setup(item) - expect(hasLimited(item)).toBe(limited) + expect(hasLimited(item, 5)).toBe(limited) } assert(item => item.waste = 1000, false) diff --git a/test/util/period.test.ts b/test/util/period.test.ts index 9785de8ec..0652bbf2f 100644 --- a/test/util/period.test.ts +++ b/test/util/period.test.ts @@ -1,8 +1,10 @@ -import { copyKeyWith, keyOf, compare } from "@util/period" +// import { compare, copyKeyWith, keyOf } from "@util/period" + +import { compare, keyOf } from '@util/period' test('test1', () => { const key1 = keyOf(new Date(), 0) - const key2 = copyKeyWith(key1, 1) + const key2 = keyOf(new Date(), 1) expect(compare(key1, key2) < 0).toBeTruthy() expect(compare(key1, key1) === 0).toBeTruthy() @@ -12,4 +14,4 @@ test('test2', () => { const a = keyOf(new Date(2020, 4, 20), 15) const b = keyOf(new Date(2020, 4, 20), 19) expect(compare(a, b) < 0).toBeTruthy() -}) \ No newline at end of file +}) diff --git a/test/util/time.test.ts b/test/util/time.test.ts index 40785c103..696a985b9 100644 --- a/test/util/time.test.ts +++ b/test/util/time.test.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { daysAgo, formatPeriod, formatPeriodCommon, formatTime, getMonthTime, getStartOfDay, isSameDay } from "@util/time" +import { daysAgo, formatPeriodCommon, formatTime, getMonthTime, getStartOfDay, isSameDay } from "../../src/util/time" test('time', () => { const dateStr = '2020/05/01 00:00:01' @@ -14,30 +14,14 @@ test('time', () => { const result = '20200501 000001五' // default format - expect(formatTime(dateStr)).toEqual('2020-05-01 00:00:01') - - expect(formatTime(dateStr, format)).toEqual(result) - expect(formatTime(date, format)).toEqual(result) // use seconds expect(formatTime(Math.floor(date / 1000), format)).toEqual(result) - // use string - expect(formatTime(date.toString(), format)).toEqual(result) - expect(formatTime(Math.floor(date / 1000).toString(), format)).toEqual(result) - expect(formatTime(new Date(date), format)).toEqual(result) }) test('format', () => { - const msg = { - dayMsg: '{day}天{hour}时{minute}分{second}秒', - hourMsg: '{hour}时{minute}分{second}秒', - minuteMsg: '{minute}分{second}秒', - secondMsg: '{second}秒' - } - expect(formatPeriod(86400 * 1000, msg)).toEqual('1天0时0分0秒') - expect(formatPeriod(3666 * 1000, msg)).toEqual('1时1分6秒') expect(formatPeriodCommon(86400 * 1000)).toEqual('1d 0h 0m 0s') expect(formatPeriodCommon(3666 * 1000)).toEqual('1h 1m 6s') expect(formatPeriodCommon(1)).toEqual('0s') @@ -83,10 +67,5 @@ test("get start of day", () => { const now = new Date(2022, 4, 2) now.setHours(11, 30, 29, 999) const start = getStartOfDay(now) - expect(start.getMonth()).toEqual(4) - expect(start.getDate()).toEqual(2) - expect(start.getHours()).toEqual(0) - expect(start.getMinutes()).toEqual(0) - expect(start.getSeconds()).toEqual(0) - expect(start.getMilliseconds()).toEqual(0) + expect(start).toEqual(new Date(2022, 4, 2).getTime()) }) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e743153aa..f2323fc2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,13 @@ "module": "esnext", "target": "esnext", "lib": [ - "ES2023" + "ESNext", + "DOM" + ], + "types": [ + "chrome", + "firefox-webext-browser", + "@rstest/core/globals" ], "jsx": "preserve", "jsxFactory": "h", @@ -13,9 +19,13 @@ "sourceMap": true, "strict": true, "resolveJsonModule": true, - "importHelpers": true, - "skipLibCheck": true, "moduleResolution": "bundler", + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, "paths": { "@api/*": [ "./src/api/*" @@ -32,9 +42,6 @@ "@side/*": [ "./src/pages/side/*" ], - "@hooks/*": [ - "./src/pages/hooks/*" - ], "@hooks": [ "./src/pages/hooks/index" ], @@ -42,31 +49,25 @@ "./src/pages/*" ], "@db/*": [ - "./src/database/*" + "./src/background/database/*" ], "@service/*": [ - "./src/service/*" + "./src/background/service/*" ], "@util/*": [ "./src/util/*" ], - "@i18n/*": [ - "./src/i18n/*" - ], "@i18n": [ "./src/i18n/index" ], - "@src/*": [ - "./src/*" + "@i18n/*": [ + "./src/i18n/*" ], - "*": [ - "./types/*" + "@/*": [ + "./src/*" ] } }, - "lib": [ - "dom" - ], "exclude": [ "node_modules", "dist" diff --git a/types/chrome.d.ts b/types/chrome.d.ts index b1f5c7dec..215a1a055 100644 --- a/types/chrome.d.ts +++ b/types/chrome.d.ts @@ -15,42 +15,5 @@ declare type ChromeAlarm = chrome.alarms.Alarm // chrome.runtime declare type ChromeOnInstalledReason = `${chrome.runtime.OnInstalledReason}` declare type ChromeMessageSender = chrome.runtime.MessageSender -declare type ChromeMessageHandler<T = any, R = any> = (req: timer.mq.Request<T>, sender: ChromeMessageSender) => Promise<timer.mq.Response<R>> - -declare namespace chrome { - namespace runtime { - type ManifestFirefox = Pick< - ManifestV2, - | 'name' | 'description' | 'version' | 'manifest_version' - | 'icons' | 'background' | 'content_scripts' | 'permissions' | 'optional_permissions' | 'browser_action' - | 'default_locale' | 'homepage_url' | 'key' - > & { - // "author" must be string for Firefox - author?: string - browser_specific_settings?: { - gecko?: { - id?: string - data_collection_permissions?: { - required?: DataCollectionPermission[] - optional?: DataCollectionPermission[] - } - } - } - // see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/sidebar_action - sidebar_action?: Pick<ManifestAction, 'default_icon' | 'default_title'> & { - default_panel?: string - open_at_install?: boolean - } - } - type DataCollectionPermission = 'none' - } -} - -/** - * Firefox-specific APIs - */ -declare namespace browser { - namespace sidebarAction { - export function open(): Promise<void> - } -} \ No newline at end of file +declare type ChromeMessageHandler = (req: tt4b.mq.Request<tt4b.mq.ReqCode>, sender: ChromeMessageSender) => Promise<tt4b.mq.Response<tt4b.mq.ReqCode>> +declare type ChromeTabMessageHandler = (req: tt4b.tab.Request<tt4b.tab.ReqCode>, sender: ChromeMessageSender) => Promise<tt4b.tab.Response<tt4b.tab.ReqCode>> \ No newline at end of file diff --git a/types/common.d.ts b/types/common.d.ts index 340a7b306..804190fc5 100644 --- a/types/common.d.ts +++ b/types/common.d.ts @@ -26,12 +26,9 @@ type RequiredPick<T, K extends keyof T> = Required<Pick<T, K>> * @param E element * @param L length of tuple */ -declare type Tuple<E, L extends number, Arr = [E, ...Array<E>]> = - Pick<Arr, Exclude<keyof Arr, 'splice' | 'push' | 'pop' | 'shift' | 'unshift'>> - & { - readonly length: L - [I: number]: E - } +declare type Tuple<E, L extends number, _Acc extends E[] = []> = + & (_Acc['length'] extends L ? _Acc : Tuple<E, L, [..._Acc, E]>) + & { readonly length: L } /** * Vector @@ -44,6 +41,8 @@ declare type CompareFn<T> = (a: T, b: T) => number declare type Awaitable<T> = T | Promise<T> +declare type Arrayable<T> = T | T[] + declare type Getter<T> = () => T | Promise<T> declare type NoArgCallback = () => void diff --git a/types/qrcode-generator.d.ts b/types/qrcode-generator.d.ts new file mode 100644 index 000000000..ecd36a506 --- /dev/null +++ b/types/qrcode-generator.d.ts @@ -0,0 +1,15 @@ +declare module 'qrcode-generator' { + type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H' + + type QrCode = { + addData(data: string): void + make(): void + getModuleCount(): number + isDark(row: number, col: number): boolean + } + + type QrCodeGenerator = (typeNumber: number, errorCorrectionLevel: ErrorCorrectionLevel) => QrCode + + const qrcode: QrCodeGenerator + export default qrcode +} diff --git a/types/timer/app.d.ts b/types/timer/app.d.ts deleted file mode 100644 index f7b85c721..000000000 --- a/types/timer/app.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -declare namespace timer.app { - /** - * @since 1.1.7 - */ - type TimeFormat = - | "default" - | "second" - | "minute" - | "hour" -} \ No newline at end of file diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts deleted file mode 100644 index 28ea41ce7..000000000 --- a/types/timer/backup.d.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @since 1.2.0 - */ -declare namespace timer.backup { - - type Client = { - id: string - name: string - minDate?: string - maxDate?: string - } - - type LoginInfo = { - acc?: string - psw?: string - } - - type Auth = { - token?: string - login?: LoginInfo - } - - interface CoordinatorContext<Cache> { - cid: string - auth?: Auth - login?: LoginInfo - ext?: TypeExt - cache: Cache - handleCacheChanged: () => Promise<void> - } - - /** - * timer.backup.Coordinator of data synchronizer - */ - interface Coordinator<Cache> { - /** - * Register for client - */ - updateClients(context: timer.backup.CoordinatorContext<Cache>, clients: Client[]): Promise<void> - /** - * List all clients - */ - listAllClients(context: timer.backup.CoordinatorContext<Cache>): Promise<Client[]> - /** - * Download fragmented data from cloud - * - * @param targetCid The client id, default value is the local one in context - */ - download(context: timer.backup.CoordinatorContext<Cache>, dateStart: Date, dateEnd: Date, targetCid?: string): Promise<timer.core.Row[]> - /** - * Upload fragmented data to cloud - * @param rows - */ - upload(context: timer.backup.CoordinatorContext<Cache>, rows: timer.core.Row[]): Promise<void> - /** - * Test auth - * - * @returns errorMsg or null/undefined - */ - testAuth(auth: Auth, ext: timer.backup.TypeExt): Promise<string | undefined> - /** - * Clear data - */ - clear(context: timer.backup.CoordinatorContext<Cache>, client: timer.backup.Client): Promise<void> - } - - type Type = - | 'none' - | 'gist' - // Sync into Obsidian via its plugin Local REST API - // @since 1.9.4 - | 'obsidian_local_rest_api' - // @since 2.4.5 - | 'web_dav' - - type AuthType = - | 'token' - | 'password' - - type TypeExt = { - /** - * The vault of obsidian - * - * @since 2.4.4 - */ - bucket?: string - endpoint?: string - dirPath?: string - } - - /** - * Snapshot of last backup - */ - type Snapshot = { - /** - * Timestamp - */ - ts: number - /** - * The date of the ts - */ - date: string - } - - /** - * Snapshot cache - */ - type SnapshotCache = Partial<{ - [type in Type]: Snapshot - }> - - type MetaCache = Partial<Record<Type, unknown>> - - type RowExtend = { - /** - * The id of client where the remote data is stored - */ - cid?: string - /** - * The name of client where the remote data is stored - */ - cname?: string - } - - type Row = core.Row & RowExtend -} \ No newline at end of file diff --git a/types/timer/common.d.ts b/types/timer/common.d.ts deleted file mode 100644 index 8f593ca8a..000000000 --- a/types/timer/common.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare namespace timer.common { - type PageQuery = { - num?: number - size?: number - } - type PageResult<T> = { - list: T[] - total: number - } - type SortDirection = 'ASC' | 'DESC' - type SortBy<T extends string> = { - sortKey?: T - sortDirection?: SortDirection - } -} \ No newline at end of file diff --git a/types/timer/core.d.ts b/types/timer/core.d.ts deleted file mode 100644 index f2dadc765..000000000 --- a/types/timer/core.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -declare namespace timer.core { - type Event = { - start: number - end: number - url: string - ignoreTabCheck: boolean - /** - * Used for run time tracking - */ - host?: string - } - - /** - * The dimension to statistics - */ - type Dimension = - // Focus time - | 'focus' - // Visit count - | 'time' - // Run time - | 'run' - - /** - * The stat result of host - * - * @since 0.0.1 - */ - type Result = MakeOptional<{ [item in Dimension]: number }, 'run'> - - /** - * The unique key of each data row - */ - type RowKey = { - host: string - date: string - } - - type Row = RowKey & Result -} \ No newline at end of file diff --git a/types/timer/echarts-extend.d.ts b/types/timer/echarts-extend.d.ts deleted file mode 100644 index f606a2138..000000000 --- a/types/timer/echarts-extend.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -interface Cartesian2DCoordSys { - type: 'cartesian2d' - x: number - y: number - width: number - height: number -} \ No newline at end of file diff --git a/types/timer/imported.d.ts b/types/timer/imported.d.ts deleted file mode 100644 index bdeb81bf8..000000000 --- a/types/timer/imported.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @since 1.9.2 - */ -declare namespace timer.imported { - type ConflictResolution = 'overwrite' | 'accumulate' - - type Row = Required<timer.core.RowKey> & timer.core.Result & { - exist?: timer.core.Result - } - - type Data = { - // Whether there is data for this dimension - [dimension in timer.core.Dimension]?: boolean - } & { - rows: Row[] - } -} \ No newline at end of file diff --git a/types/timer/index.d.ts b/types/timer/index.d.ts deleted file mode 100644 index e8b803e4a..000000000 --- a/types/timer/index.d.ts +++ /dev/null @@ -1,77 +0,0 @@ -declare namespace timer { - type RequiredLocale = 'en' - type OptionalLocale = - | 'zh_CN' - | 'ja' - // @since 0.9.0 - | 'zh_TW' - // @since 1.8.2 - | 'pt_PT' - // @since 2.1.0 - | 'uk' - // @since 2.1.4 - | 'es' - // @since 2.2.7 - | 'de' - // @since 2.3.6 - | 'fr' - // @since 2.4.6 - | 'ru' - // @since 2.5.0 - | 'ar' - // @since 3.7.3 - | 'tr' - // @since 3.7.3 - | 'pl' - - /** - * @since 0.8.0 - */ - type Locale = RequiredLocale | OptionalLocale - - /** - * Translating locales - * - * @since 1.4.0 - */ - type TranslatingLocale = - | 'ko' - | 'pl' - | 'it' - | 'sv' - | 'fi' - | 'da' - | 'hr' - | 'id' - | 'cs' - | 'ro' - | 'nl' - | 'vi' - | 'sk' - | 'mn' - | 'hi' - - type ExtensionMetaFlag = "rateOpen" - - type ExtensionMeta = { - installTime?: number - appCounter?: { [routePath: string]: number } - popupCounter?: { - _total?: number - } - /** - * The id of this client - * - * @since 1.2.0 - */ - cid?: string - backup?: { - [key in timer.backup.Type]?: { - ts: number - msg?: string - } - } - // Flags - flag?: Partial<Record<ExtensionMetaFlag, boolean>> - } -} \ No newline at end of file diff --git a/types/timer/limit.d.ts b/types/timer/limit.d.ts deleted file mode 100644 index 5a227860d..000000000 --- a/types/timer/limit.d.ts +++ /dev/null @@ -1,139 +0,0 @@ -declare namespace timer.limit { - /** - * Restricted periods - * [0, 1] means from 00:00 to 00:01 - * [0, 120] means from 00:00 to 02:00 - * @since 2.0.0 - */ - type Period = Vector<2> - /** - * Limit rule in runtime - * - * @since 0.8.4 - */ - type Item = Rule & { - /** - * Waste today, milliseconds - */ - waste: number - /** - * Visit count today - * - * @since 3.1.0 - */ - visit: number - /** - * Number of delays today - */ - delayCount: number - /** - * Waste this week, milliseconds - */ - weeklyWaste: number - /** - * Visit count this week - * - * @since 3.1.0 - */ - weeklyVisit: number - /** - * Delay count of this week - */ - weeklyDelayCount: number - } - type Rule = { - /** - * Id - */ - id: number - /** - * Name - */ - name: string - /** - * Condition, can be regular expression with star signs - */ - cond: string[] - /** - * Time limit per day, seconds - */ - time?: number - /** - * Visit count per day - * - * @since 3.1.0 - */ - count?: number - /** - * Time limit per week, seconds - * - * @since 2.4.1 - */ - weekly?: number - /** - * Visit count per week - * - * @since 3.1.0 - */ - weeklyCount?: number - /** - * Time limit per visit, seconds - * - * @since 2.0.0 - */ - visitTime?: number - enabled: boolean - /** - * Locked - * - * @since 3.4.0 - */ - locked: boolean - /** - * @since 2.3.4 - */ - weekdays?: number[] - /** - * Allow to delay 5 minutes if time over - */ - allowDelay: boolean - periods?: Period[] - } - /** - * @since 1.9.0 - */ - type RestrictionLevel = - // No additional action required to lock - | 'nothing' - // Password required to lock or modify restricted rule - | 'password' - // Verification code input required to lock or modify restricted rule - | 'verification' - // Not allowed to unlock manually - | 'strict' - /** - * @since 1.9.0 - */ - type VerificationDifficulty = - // Easy - | 'easy' - // Need some operations - | 'hard' - // Disgusting - | 'disgusting' - - type ReasonType = - | "DAILY" - | "WEEKLY" - | "VISIT" - | "PERIOD" - - /** - * @since 3.1.0 - */ - type ReminderInfo = { - items: timer.limit.Item[] - // Minutes - duration: number - } -} diff --git a/types/timer/merge.d.ts b/types/timer/merge.d.ts deleted file mode 100644 index 2e800e6c2..000000000 --- a/types/timer/merge.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare namespace timer.merge { - type Rule = { - /** - * Origin host, can be regular expression with star signs - */ - origin: string - /** - * The merge result - * - * + Empty string means equals to the origin host - * + Number means the count of kept dots, must be natural number (int & >=0) - */ - merged: string | number - } -} diff --git a/types/timer/mq.d.ts b/types/timer/mq.d.ts deleted file mode 100644 index d83d410af..000000000 --- a/types/timer/mq.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** -* Message queue -*/ -declare namespace timer.mq { - type ReqCode = - | 'openLimitPage' - | 'limitTimeMeet' - // @since 0.9.0 - | 'limitWaking' - // @since 1.2.3 - | 'limitChanged' - // @since 3.1.0 - | 'limitReminder' - // @since 2.0.0 - | 'askVisitTime' - | 'askHitVisit' - // @since 3.2.0 - | 'siteRunChange' - // @since 3.5.0 - | "enableTabGroup" - // @since 3.7.3 - | "syncAudible" - // Request by content script - // @since 1.3.0 - | "cs.isInWhitelist" - | "cs.incVisitCount" - | "cs.printTodayInfo" - | "cs.getTodayInfo" - | "cs.moreMinutes" - | "cs.getLimitedRules" - | "cs.getRelatedRules" - | "cs.trackTime" - | "cs.trackRunTime" - | "cs.onInjected" - | "cs.openAnalysis" - | "cs.openLimit" - // @since 2.5.5 - | "cs.idleChange" - // @since 3.2.0 - | "cs.getRunSites" - // @since 3.6.1 - | "cs.timelineEv" - // @since 3.7.3 - | "cs.getAudible" - - type ResCode = "success" | "fail" | "ignore" - - /** - * @since 0.2.2 - */ - type Request<T = any> = { - code: ReqCode - data?: T - } - /** - * @since 0.8.4 - */ - type Response<T = any> = { - code: ResCode, - msg?: string - data?: T - } - /** - * @since 1.3.0 - */ - type Handler<Req, Res> = (data: Req, sender: chrome.runtime.MessageSender) => Promise<Res> | Res - /** - * @since 0.8.4 - */ - type Callback<T = any> = (result?: Response<T>) => void -} \ No newline at end of file diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts deleted file mode 100644 index 089f4f639..000000000 --- a/types/timer/option.d.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * The options - * - * @since 0.3.0 - */ -declare namespace timer.option { - /** - * @since 1.2.5 - */ - type WeekStartOption = - | 'default' - | number // Weekday, From 1 to 7 - - type DarkMode = - // Follow the OS, @since 1.3.3 - | "default" - // Always on - | "on" - // Always off - | "off" - // Timed on - | "timed" - - type AppearanceOption = { - /** - * Whether to display the whitelist button in the context menu - * - * @since 0.3.2 - */ - displayWhitelistMenu: boolean - /** - * Whether to display the badge text of focus time - * - * @since 0.3.3 - */ - displayBadgeText: boolean - /** - * The background color of badge text - * - * @since 2.3.0 - */ - badgeBgColor?: string - /** - * The language of this extension - * - * @since 0.8.0 - */ - locale: LocaleOption - /** - * Whether to print the info in the console - * - * @since 0.8.6 - */ - printInConsole: boolean - /** - * The state of dark mode - * - * @since 1.1.0 - */ - darkMode: DarkMode - /** - * The range of seconds to turn on dark mode. Required if {@param darkMode} is 'timed' - * - * @since 1.1.0 - */ - darkModeTimeStart?: number - darkModeTimeEnd?: number - /** - * The animation of charts - * - * @since 3.2.2 - */ - chartAnimationDuration: number - } - - type TrackingOption = { - /** - * Whether to pause tracking if no activity detected - * - * @since 2.5.4 - */ - autoPauseTracking: boolean - /** - * Check interval of auto pausing, seconds - * - * @since 2.5.4 - */ - autoPauseInterval: number - /** - * Whether to count the local files - * @since 0.7.0 - */ - countLocalFiles: boolean - /** - * Whether to count the tile of tab group - */ - countTabGroup: boolean - /** - * The start of one week - * @since 2.4.1 - */ - weekStart?: WeekStartOption - } - - type LimitOption = { - /** - * Motto displayed when restricted - */ - limitPrompt?: string - /** - * restriction level - */ - limitLevel: limit.RestrictionLevel - /** - * The password to unlock - */ - limitPassword?: string - /** - * The difficulty of verification - */ - limitVerifyDifficulty?: limit.VerificationDifficulty - /** - * Whether to reminder before time will meet - * - * @since 3.1.0 - */ - limitReminder: boolean - /** - * Minutes - * - * @since 3.1.0 - */ - limitReminderDuration?: number - } - - /** - * The options of backup - * - * @since 1.2.0 - */ - type BackupOption = { - /** - * The type 2 backup - */ - backupType: backup.Type - /** - * The auth of types, maybe ak/sk or static token - */ - backupAuths: { [type in backup.Type]?: string } - /** - * Login info of types - */ - backupLogin: { [type in backup.Type]?: backup.LoginInfo } - /** - * The extended information of types, including url, file path, and so on - */ - backupExts?: { - [type in backup.Type]?: backup.TypeExt - } - /** - * The name of this client - */ - clientName: string - /** - * Whether to auto-backup data - */ - autoBackUp: boolean - /** - * Interval to auto-backup data, minutes - */ - autoBackUpInterval: number - } - - type AccessibilityOption = { - /** - * Show decals for charts - */ - chartDecal: boolean - } - - type AllOption = - & AppearanceOption - & TrackingOption - & LimitOption - & AccessibilityOption - & BackupOption - /** - * @since 0.8.0 - */ - type LocaleOption = Locale | "default" -} diff --git a/types/timer/period.d.ts b/types/timer/period.d.ts deleted file mode 100644 index 9cd8b8293..000000000 --- a/types/timer/period.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -declare namespace timer.period { - type Key = { - year: number - month: number - date: number - /** - * 0~95 - * ps. 95 = 60 / 15 * 24 - 1 - */ - order: number - } - type KeyRange = [Key, Key] - type Result = Key & { - /** - * 1~900000 - * ps. 900000 = 15min * 60s/min * 1000ms/s - */ - milliseconds: number - } - type Row = { - /** - * {yyyy}{mm}{dd} - */ - date: string - startTime: Date - endTime: Date - /** - * 1 - 60000 - * ps. 60000 = 60s * 1000ms/s - */ - milliseconds: number - } -} \ No newline at end of file diff --git a/types/timer/site.d.ts b/types/timer/site.d.ts deleted file mode 100644 index 39a7decdf..000000000 --- a/types/timer/site.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -declare namespace timer.site { - type SiteKey = { - host: string - type: timer.site.Type - } - type SiteInfo = SiteKey & { - alias?: string - iconUrl?: string - /** - * Category ID - * - * @since 3.0.0 - */ - cate?: number - /** - * Whether to count the running time - * - * @since 3.2.0 - */ - run?: boolean - } - type Type = 'normal' | 'merged' | 'virtual' - /** - * Site tag - * - * @since 3.0.0 - */ - type Cate = { - id: number - name: string - } -} \ No newline at end of file diff --git a/types/timer/stat.d.ts b/types/timer/stat.d.ts deleted file mode 100644 index e0ef2ca30..000000000 --- a/types/timer/stat.d.ts +++ /dev/null @@ -1,95 +0,0 @@ -declare namespace timer.stat { - type SiteTarget = { - siteKey: timer.site.SiteKey - } - type CateTarget = { - cateKey: number - } - type GroupTarget = { - groupKey: number - } - type TargetKey = SiteTarget | CateTarget | GroupTarget - type DateKey = { date?: string } - type StatKey = TargetKey & DateKey - - type SiteMergeExtend = { - /** - * The merged domains - * Can't be empty if merged - * - * @since 0.1.5 - */ - mergedRows?: Omit<timer.stat.SiteRow, 'mergedRows'>[] - } - - type DateMergeExtend = { - /** - * The merged dates - * - * @since 2.4.7 - */ - mergedDates?: string[] - } - - type RemoteExtend = { - /** - * The composition of data when querying remote - */ - composition?: RemoteComposition - } - - interface SiteRow extends SiteTarget, DateKey, core.Result, backup.RowExtend, SiteMergeExtend, DateMergeExtend, RemoteExtend { - /** - * Icon url - */ - iconUrl?: string - /** - * The alias name of this Site, always is the title of its homepage by detected - */ - alias?: string - /** - * @since 3.0.0 - */ - cateId?: number - } - - interface CateRow extends CateTarget, DateKey, core.Result, backup.RowExtend, SiteMergeExtend, DateMergeExtend, RemoteExtend { - cateName: string | undefined - } - - interface GroupRow extends GroupTarget, DateKey, DateMergeExtend, core.Result { - color: `${chrome.tabGroups.Color}` | undefined - title: string | undefined - } - - /** - * Row of each statistics result - */ - type Row = SiteRow | CateRow | GroupRow - - type RemoteCompositionVal = - // Means local data - number | { - /** - * Client's id - */ - cid: string - /** - * Client's name - */ - cname?: string - value: number - } - - /** - * @since 1.4.7 - */ - type RemoteComposition = { - [item in core.Dimension]: RemoteCompositionVal[] - } - - /** - * @since 3.0.0 - */ - type MergeMethod = 'cate' | 'date' | 'domain' | 'group' -} \ No newline at end of file diff --git a/types/timer/timeline.d.ts b/types/timer/timeline.d.ts deleted file mode 100644 index e2a0a10de..000000000 --- a/types/timer/timeline.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare namespace timer.timeline { - type Event = { - start: number - end: number - url: string - } - - type Tick = { - start: number - duration: number - host: string - } -} \ No newline at end of file diff --git a/types/tt4b.d.ts b/types/tt4b.d.ts new file mode 100644 index 000000000..6a15e5d59 --- /dev/null +++ b/types/tt4b.d.ts @@ -0,0 +1,1213 @@ +declare namespace tt4b { + type RequiredLocale = 'en' + type OptionalLocale = + | 'zh_CN' + | 'ja' + // @since 0.9.0 + | 'zh_TW' + // @since 1.8.2 + | 'pt_PT' + // @since 2.1.0 + | 'uk' + // @since 2.1.4 + | 'es' + // @since 2.2.7 + | 'de' + // @since 2.3.6 + | 'fr' + // @since 2.4.6 + | 'ru' + // @since 2.5.0 + | 'ar' + // @since 3.7.3 + | 'tr' + // @since 3.7.3 + | 'pl' + // @since 4.1.2 + | 'it' + + /** + * @since 0.8.0 + */ + type Locale = RequiredLocale | OptionalLocale + + /** + * Translating locales + * + * @since 1.4.0 + */ + type TranslatingLocale = + | 'ko' + | 'pl' + | 'it' + | 'sv' + | 'fi' + | 'da' + | 'hr' + | 'id' + | 'cs' + | 'ro' + | 'nl' + | 'vi' + | 'sk' + | 'mn' + | 'hi' + + type ExtensionMeta = { + installTime?: number + /** + * The id of this client + * + * @since 1.2.0 + */ + cid?: string + backup?: { + [key in tt4b.backup.Type]?: { + ts: number + msg?: string + } + } + notification?: { + [key in tt4b.notification.Method]?: { + ts: number + endDate: string + msg?: string + } + } + /** + * Two-factor auth + */ + twoFa?: tt4b.TwoFactorAuth + } + + type TwoFactorAuth = { + secret: string + iv: string + salt: string + } + + namespace app { + /** + * @since 1.1.7 + */ + type TimeFormat = + | "default" + | "second" + | "minute" + | "hour" + } + + namespace common { + type Result<T> = { + success: true + data: T + } | { + success: false + errorMsg: string + } + + type PageQuery = { + num?: number + size?: number + } + + type PageResult<T> = { + list: T[] + total: number + } + + type SortDirection = 'ASC' | 'DESC' + type SortBy<T extends string> = { + sortKey?: T + sortDirection?: SortDirection + } + + /** + * chrome.storage.local usage (mq memory.getUsedStorage). + */ + type StorageUsage = { + used: number + total: number + } + } + + namespace notification { + type Method = 'browser' | 'callback' + type Cycle = 'none' | 'daily' | 'weekly' + } + + namespace core { + type Event = { + start: number + end: number + ignoreTabCheck: boolean + /** + * Used for run time tracking + */ + host?: string + } + + /** + * The dimension to statistics + */ + type Dimension = + // Focus time + | 'focus' + // Visit count + | 'time' + // Run time + | 'run' + + /** + * The stat result of host + * + * @since 0.0.1 + */ + type Result = MakeOptional<{ [item in Dimension]: number }, 'run'> + + /** + * The unique key of each data row + */ + type RowKey = { + host: string + date: string + } + + type Row = RowKey & Result + } + + namespace site { + type SiteKey = { + host: string + type: tt4b.site.Type + } + type SiteInfo = SiteKey & { + alias?: string + iconUrl?: string + /** + * Category ID + * + * @since 3.0.0 + */ + cate?: number + /** + * Whether to count the running time + * + * @since 3.2.0 + */ + run?: boolean + } + type Type = 'normal' | 'merged' | 'virtual' + /** + * Site tag + * + * @since 3.0.0 + */ + type Cate = { + id: number + name: string + } + + type Query = { + fuzzyQuery?: string + cateIds?: Arrayable<number> + types?: Arrayable<tt4b.site.Type> + } + + type PageQuery = Query & common.PageQuery + + type ChangeCateParam = { + // Undefined means uncategorized + cateId: number | undefined + keys: SiteKey[] + } + + type ChangeAliasParam = { + key: SiteKey + // Undefined means delete alias + alias: string | undefined + } + } + + namespace merge { + type Rule = { + /** + * Origin host, can be regular expression with star signs + */ + origin: string + /** + * The merge result + * + * + Empty string means equals to the origin host + * + Number means the count of kept dots, must be natural number (int & >=0) + */ + merged: string | number + } + } + + namespace limit { + /** + * Restricted periods + * [0, 1] means from 00:00 to 00:01 + * [0, 120] means from 00:00 to 02:00 + * @since 2.0.0 + */ + type Period = Vector<2> + /** + * Limit rule in runtime + * + * @since 0.8.4 + */ + type Item = Rule & { + /** + * Waste today, milliseconds + */ + waste: number + /** + * Visit count today + * + * @since 3.1.0 + */ + visit: number + /** + * Number of delays today + */ + delayCount: number + /** + * Waste this week, milliseconds + */ + weeklyWaste: number + /** + * Visit count this week + * + * @since 3.1.0 + */ + weeklyVisit: number + /** + * Delay count of this week + */ + weeklyDelayCount: number + } + type Rule = { + /** + * Id + */ + id: number + /** + * Name + */ + name: string + /** + * Condition, can be regular expression with star signs + */ + cond: string[] + /** + * Time limit per day, seconds + */ + time?: number + /** + * Visit count per day + * + * @since 3.1.0 + */ + count?: number + /** + * Time limit per week, seconds + * + * @since 2.4.1 + */ + weekly?: number + /** + * Visit count per week + * + * @since 3.1.0 + */ + weeklyCount?: number + /** + * Time limit per visit, seconds + * + * @since 2.0.0 + */ + visitTime?: number + enabled: boolean + /** + * Locked + * + * @since 3.4.0 + */ + locked: boolean + /** + * @since 2.3.4 + */ + weekdays?: number[] + /** + * Allow to delay 5 minutes if time over + */ + allowDelay: boolean + periods?: Period[] + } + /** + * @since 1.9.0 + */ + type RestrictionLevel = + // No additional action required to lock + | 'nothing' + // Password required to lock or modify restricted rule + | 'password' + // Verification code input required to lock or modify restricted rule + | 'verification' + // Not allowed to unlock manually + | 'strict' + // Unlock with 2FA code + | '2fa' + /** + * @since 1.9.0 + */ + type VerificationDifficulty = + // Easy + | 'easy' + // Need some operations + | 'hard' + // Disgusting + | 'disgusting' + + type ReasonType = + | "DAILY" + | "WEEKLY" + | "VISIT" + | "PERIOD" + + /** + * @since 3.1.0 + */ + type ReminderInfo = { + items: tt4b.limit.Item[] + // Minutes + duration: number + } + + type Query = { + id?: number + url?: string + // Only enabled rules + enabled?: boolean + // Only effective rules (should be enabled and meet effective conditions) + effective?: boolean + // Only effective and limited rules + limited?: boolean + } + + type Summary = { + url: string + site: site.SiteInfo + items: tt4b.limit.Item[] + } + } + + namespace period { + type Key = { + year: number + month: number + date: number + /** + * 0~95 + * ps. 95 = 60 / 15 * 24 - 1 + */ + order: number + } + type KeyRange = [Key, Key] + + type Result = Key & { + /** + * 1~900000 + * ps. 900000 = 15min * 60s/min * 1000ms/s + */ + milliseconds: number + } + + type Row = { + /** + * {yyyy}{mm}{dd} + */ + date: string + /** Unix timestamp (ms) of row start */ + startTime: number + /** Unix timestamp (ms) of row end */ + endTime: number + /** + * 1 - 60000 + * ps. 60000 = 60s * 1000ms/s + */ + milliseconds: number + } + + type Query = { + range?: KeyRange + size?: number + } + } + + namespace timeline { + type Event = { + start: number + end: number + url: string + } + + type Tick = { + start: number + duration: number + host: string + } + + type MergeMethod = 'cate' | 'domain' | 'none' + + type Activity = { + start: number + duration: number + seriesKey: string + seriesName: string | undefined + } + + type Query = { + host?: string + start?: number + merge: MergeMethod + } + } + + /** + * @since 1.2.0 + */ + namespace backup { + type Client = { + id: string + name: string + minDate?: string + maxDate?: string + } + + type LoginInfo = { + acc?: string + psw?: string + } + + type Auth = { + token?: string + login?: LoginInfo + } + + interface CoordinatorContext<Cache> { + cid: string + auth?: Auth + login?: LoginInfo + ext?: TypeExt + cache: Cache + handleCacheChanged: () => Promise<void> + } + + /** + * tt4b.backup.Coordinator of data synchronizer + */ + interface Coordinator<Cache> { + /** + * Register for client + */ + updateClients(context: tt4b.backup.CoordinatorContext<Cache>, clients: Client[]): Promise<void> + /** + * List all clients + */ + listAllClients(context: tt4b.backup.CoordinatorContext<Cache>): Promise<Client[]> + /** + * Download fragmented data from cloud + * + * @param targetCid The client id, default value is the local one in context + */ + download(context: tt4b.backup.CoordinatorContext<Cache>, start: string, end: string, targetCid?: string): Promise<tt4b.core.Row[]> + /** + * Upload fragmented data to cloud + * @param rows + */ + upload(context: tt4b.backup.CoordinatorContext<Cache>, rows: tt4b.core.Row[]): Promise<void> + /** + * Test auth + * + * @returns errorMsg or null/undefined + */ + testAuth(auth: Auth, ext: tt4b.backup.TypeExt): Promise<string | undefined> + /** + * Clear data + */ + clear(context: tt4b.backup.CoordinatorContext<Cache>, client: tt4b.backup.Client): Promise<void> + } + + type Type = + | 'none' + | 'gist' + // Sync into Obsidian via its plugin Local REST API + // @since 1.9.4 + | 'obsidian_local_rest_api' + // @since 2 .4.5 + | 'web_dav' + + type AuthType = + | 'token' + | 'password' + + type TypeExt = { + /** + * The vault of obsidian + * + * @since 2.4.4 + */ + bucket?: string + endpoint?: string + dirPath?: string + } + + type MetaCache = Partial<Record<Type, unknown>> + + type RowExtend = { + /** + * The id of client where the remote data is stored + */ + cid?: string + /** + * The name of client where the remote data is stored + */ + cname?: string + } + + type RemoteQuery = { + start: string + end: string + specCid?: string + excludeLocal?: boolean + } + + type Row = core.Row & RowExtend + + /** + * The data format for export and import + */ + type ExportMeta = { + version: string + ts: number + } + + type ExportData = { + __meta__: ExportMeta + __stat__?: tt4b.core.Row[] + __limit__?: tt4b.limit.Rule[] + __merge__?: tt4b.merge.Rule[] + __whitelist__?: string[] + __cate__?: tt4b.site.Cate[] + } + } + + /** + * @since 1.9.2 + */ + namespace imported { + type ConflictResolution = 'overwrite' | 'accumulate' + + type Row = Required<tt4b.core.RowKey> & tt4b.core.Result & { + exist?: tt4b.core.Result + } + + type Data = { + // Whether there is data for this dimension + [dimension in tt4b.core.Dimension]?: boolean + } & { + rows: Row[] + } + + type ProcessQuery = { + data: Data + resolution: ConflictResolution + } + } + + namespace stat { + type SiteTarget = { + siteKey: tt4b.site.SiteKey + } + type CateTarget = { + cateKey: number + } + type GroupTarget = { + groupKey: number + } + type TargetKey = SiteTarget | CateTarget | GroupTarget + type DateKey = { date: string } + type StatKey = TargetKey & DateKey + + type DateMergeExtend = { + /** + * The merged dates + * + * @since 2.4.7 + */ + mergedDates?: string[] + } + + type RemoteExtend = { + /** + * The composition of data when querying remote + */ + composition?: RemoteComposition + } + + /** StatCondition.date fields only (mq / stat-database queries). */ + type _BaseQuery = { + date?: string | [string?, string?] + mergeDate?: boolean + } + + type SiteQuery = + & _BaseQuery + & { + focusRange?: Vector<2> + timeRange?: [number, number?] + virtual?: boolean + } + & tt4b.common.SortBy<'date' | 'host' | tt4b.core.Dimension> + & { + query?: string + host?: string | string[] + mergeHost?: boolean + inclusiveRemote?: boolean + cateIds?: number[] + ignoreSite?: boolean + } + + type SiteDeleteQuery = ({ + host: string + } | { + groupId: number + }) & { + date?: [start?: string, end?: string] | string + } + + type SitePageQuery = SiteQuery & tt4b.common.PageQuery + + type SiteRowFlat = SiteTarget & + DateKey & + core.Result & + backup.RowExtend & + DateMergeExtend & + RemoteExtend & { + /** + * Icon url + */ + iconUrl?: string + /** + * The alias name of this Site, always is the title of its homepage by detected + */ + alias?: string + /** + * @since 3.0.0 + */ + cateId?: number + } + + type SiteMergeExtend = { + /** + * The merged domains + * Can't be empty if merged + * + * @since 0.1.5 + */ + mergedRows?: SiteRowFlat[] + } + + type SiteRow = SiteRowFlat & SiteMergeExtend + + type CateQuery = _BaseQuery + & tt4b.common.SortBy<'date' | 'focus' | 'time'> + & { + query?: string + inclusiveRemote?: boolean + cateIds?: number[] + } + + type CatePageQuery = CateQuery & tt4b.common.PageQuery + + type CateRowFlat = CateTarget & + DateKey & + core.Result & + backup.RowExtend & + DateMergeExtend & + RemoteExtend & { + cateName: string | undefined + } + + type CateMergeExtend = { + mergedRows?: SiteRowFlat[] + } + + type CateRow = CateRowFlat & CateMergeExtend + + type GroupRowFlat = GroupTarget & + DateKey & + DateMergeExtend & + core.Result & { + color: `${chrome.tabGroups.Color}` | undefined + title: string | undefined + } + + type GroupQuery = _BaseQuery + & { + focusRange?: Vector<2> + timeRange?: [number, number?] + } + & tt4b.common.SortBy<'date' | 'title' | 'focus' | 'time'> + & { + query?: string + groupIds?: number[] + } + + type GroupPageQuery = GroupQuery & tt4b.common.PageQuery + + type GroupMergeExtend = { + mergedRows?: GroupRowFlat[] + } + + type GroupRow = GroupRowFlat & GroupMergeExtend + + /** + * Row of each statistics result + */ + type Row = SiteRow | CateRow | GroupRow + + type RemoteCompositionVal = + // Means local data + number | { + /** + * Client's id + */ + cid: string + /** + * Client's name + */ + cname?: string + value: number + } + + /** + * @since 1.4.7 + */ + type RemoteComposition = { + [item in core.Dimension]: RemoteCompositionVal[] + } + + /** + * @since 3.0.0 + */ + type MergeMethod = 'cate' | 'date' | 'domain' | 'group' + } + + /** + * The options + * + * @since 0.3.0 + */ + namespace option { + /** + * @since 1.2.5 + */ + type WeekStartOption = + | 'default' + | number // Weekday, From 1 to 7 + + type DarkMode = + // Follow the OS, @since 1.3.3 + | "default" + // Always on + | "on" + // Always off + | "off" + // Timed on + | "timed" + + type AppearanceOption = { + /** + * Whether to display the whitelist button in the context menu + * + * @since 0.3.2 + */ + displayWhitelistMenu: boolean + /** + * Whether to display the badge text of focus time + * + * @since 0.3.3 + */ + displayBadgeText: boolean + /** + * The background color of badge text + * + * @since 2.3.0 + */ + badgeBgColor?: string + /** + * The language of this extension + * + * @since 0.8.0 + */ + locale: LocaleOption + /** + * Whether to print the info in the console + * + * @since 0.8.6 + */ + printInConsole: boolean + /** + * The state of dark mode + * + * @since 1.1.0 + */ + darkMode: DarkMode + /** + * The range of seconds to turn on dark mode. Required if {@param darkMode} is 'timed' + * + * @since 1.1.0 + */ + darkModeTimeStart?: number + darkModeTimeEnd?: number + /** + * The animation of charts + * + * @since 3.2.2 + */ + chartAnimationDuration: number + } + + type AppearanceRequired = MakeRequired<tt4b.option.AppearanceOption, 'darkModeTimeStart' | 'darkModeTimeEnd'> + + type TrackingOption = { + /** + * Whether to pause tracking if no activity detected + * + * @since 2.5.4 + */ + autoPauseTracking: boolean + /** + * Check interval of auto pausing, seconds + * + * @since 2.5.4 + */ + autoPauseInterval: number + /** + * Whether to count the local files + * @since 0.7.0 + */ + countLocalFiles: boolean + /** + * Whether to count the tile of tab group + */ + countTabGroup: boolean + /** + * The start of one week + * @since 2.4.1 + */ + weekStart?: WeekStartOption + /** + * Where to store the tracking data + * + * @since 4.0.0 + */ + storage: StorageType + } + + type TrackingRequired = MakeRequired<tt4b.option.TrackingOption, 'weekStart'> + + type LimitOption = { + /** + * Delay duration, minutes + */ + limitDelayDuration: number + /** + * Motto displayed when restricted + */ + limitPrompt?: string + /** + * restriction level + */ + limitLevel: limit.RestrictionLevel + /** + * The password to unlock + */ + limitPassword?: string + /** + * The difficulty of verification + */ + limitVerifyDifficulty?: limit.VerificationDifficulty + /** + * Whether to reminder before time will meet + * + * @since 3.1.0 + */ + limitReminder: boolean + /** + * Minutes + * + * @since 3.1.0 + */ + limitReminderDuration?: number + } + + type LimitRequired = MakeRequired<tt4b.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration'> + + /** + * The options of backup + * + * @since 1.2.0 + */ + type BackupOption = { + /** + * The type 2 backup + */ + backupType: backup.Type + /** + * The auth of types, maybe ak/sk or static token + */ + backupAuths: { [type in backup.Type]?: string } + /** + * Login info of types + */ + backupLogin: { [type in backup.Type]?: backup.LoginInfo } + /** + * The extended information of types, including url, file path, and so on + */ + backupExts?: { + [type in backup.Type]?: backup.TypeExt + } + /** + * The name of this client + */ + clientName: string + /** + * Whether to auto-backup data + */ + autoBackUp: boolean + /** + * Interval to auto-backup data, minutes + */ + autoBackUpInterval: number + } + + type AccessibilityOption = { + /** + * Show decals for charts + */ + chartDecal: boolean + } + + type NotificationOption = { + /** + * Notification cycle: none, daily, or weekly + */ + notificationCycle: tt4b.notification.Cycle + /** + * Offset time in minutes relative to the start of the cycle + */ + notificationOffset: number + /** + * Notification method: browser or callback + */ + notificationMethod: tt4b.notification.Method + /** + * HTTP callback endpoint URL + */ + notificationEndpoint?: string + /** + * Auth token for HTTP callback (optional) + */ + notificationAuthToken?: string + } + + export type DefaultOption = + & AppearanceRequired & TrackingRequired & LimitRequired + & tt4b.option.BackupOption & tt4b.option.AccessibilityOption + & tt4b.option.NotificationOption + + type AllOption = + & AppearanceOption + & TrackingOption + & LimitOption + & AccessibilityOption + & BackupOption + & NotificationOption + + /** + * @since 0.8.0 + */ + type LocaleOption = Locale | "default" + + /** + * @since 4.0.0 + */ + type StorageType = 'classic' | 'indexed_db' + } + + namespace mq { + type _TransmitValue = + | undefined | string | number | boolean | void + | { readonly [key: string]: _TransmitValue } + | readonly _TransmitValue[] + + type _HandlerIO<Input extends _TransmitValue = undefined, Output extends _TransmitValue = undefined> = [Input, Output] + type _MakeRegistry<Codes extends string, Param extends _TransmitValue = undefined, Result extends _TransmitValue = undefined> = Record<Codes, _HandlerIO<Param, Result>> + + type _MqReqData<R, K extends keyof R> = R[K] extends [infer In, unknown] ? In : never + type _MqResData<R, K extends keyof R> = R[K] extends [unknown, infer Out] ? Out : never + type _MqRequest<R, K extends keyof R = keyof R> = { code: K; data: _MqReqData<R, K> } + type _MqSuccess<R, K extends keyof R> = + _MqResData<R, K> extends undefined ? { code: "success"; data?: undefined } : { code: "success"; data: _MqResData<R, K> } + type _MqResponse<R, K extends keyof R = keyof R> = + | { code: "fail"; msg: string } + | { code: "ignore" } + | (K extends keyof R ? _MqSuccess<R, K> : never) + type _MqHandler<R, C extends keyof R> = _MqResData<R, C> extends undefined + ? (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<void | undefined> + : (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<_MqResData<R, C>> + + type _HandlerRegistry = + // Track event + & _MakeRegistry<'track.time' | 'track.runTime', core.Event> + // Content script events + & _MakeRegistry<'cs.injected'> + & _MakeRegistry<'cs.idleChanged', boolean> + // Content script API + & _MakeRegistry<'cs.getAudible', undefined, boolean> + // Statistics + & _MakeRegistry<'stat.today', string, core.Result | undefined> + & _MakeRegistry<'stat.sites', stat.SiteQuery | undefined, stat.SiteRow[]> + & _MakeRegistry<'stat.sitePage', stat.SitePageQuery | undefined, common.PageResult<stat.SiteRow>> + & _MakeRegistry<'stat.deleteSite', stat.SiteDeleteQuery> + & _MakeRegistry<'stat.countSite', stat.SiteQuery | undefined, number> + & _MakeRegistry<'stat.cates', stat.CateQuery | undefined, stat.CateRow[]> + & _MakeRegistry<'stat.catePage', stat.CatePageQuery | undefined, common.PageResult<stat.CateRow>> + & _MakeRegistry<'stat.groups', stat.GroupQuery | undefined, stat.GroupRow[]> + & _MakeRegistry<'stat.groupPage', stat.GroupPageQuery | undefined, common.PageResult<stat.GroupRow>> + & _MakeRegistry<'stat.countGroup', stat.GroupQuery | undefined, number> + & _MakeRegistry<'stat.batchDelete', stat.StatKey[]> + // Items + & _MakeRegistry<'item.batch', core.RowKey[], core.Row[]> + // Category + & _MakeRegistry<'cate.all', undefined, site.Cate[]> + & _MakeRegistry<'cate.add', string, site.Cate> + & _MakeRegistry<'cate.change', site.Cate> + & _MakeRegistry<'cate.delete', number> + // Option + & _MakeRegistry<'option.get', undefined, option.DefaultOption> + & _MakeRegistry<'option.set', Partial<option.AllOption>> + & _MakeRegistry<'option.changeStorage', option.StorageType> + & _MakeRegistry<'option.testNotification', undefined, string | undefined> + & _MakeRegistry<'option.weekStartDay', undefined, number> + & _MakeRegistry<'option.weekStartTime', number, number> + // Meta + & _MakeRegistry<'meta.installTs', undefined, number> + & _MakeRegistry<'meta.usedStorage', undefined, common.StorageUsage> + & _MakeRegistry<'meta.check2fa', string, boolean> + & _MakeRegistry<'meta.prepare2fa', undefined, string> + // Site + & _MakeRegistry<'site.runEnabled', string, boolean> + & _MakeRegistry<'site.list', site.Query | undefined, site.SiteInfo[]> + & _MakeRegistry<'site.page', site.PageQuery | undefined, common.PageResult<site.SiteInfo>> + & _MakeRegistry<'site.add', site.SiteInfo, string | undefined> + & _MakeRegistry<'site.delete', site.SiteKey[]> + & _MakeRegistry<'site.changeCate', site.ChangeCateParam> + & _MakeRegistry<'site.deleteIcon', site.SiteKey> + & _MakeRegistry<'site.changeAlias', site.ChangeAliasParam> + & _MakeRegistry<'site.fillAlias', site.SiteKey[]> + & _MakeRegistry<'site.initialAlias', string, string | undefined> + & _MakeRegistry<'site.changeRun', { key: site.SiteKey; enabled: boolean }> + & _MakeRegistry<'site.search', string | undefined, site.SiteInfo[]> + // Time Limit + & _MakeRegistry<'limit.list', limit.Query | undefined, limit.Item[]> + & _MakeRegistry<'limit.delete', number[]> + & _MakeRegistry<'limit.update', limit.Rule[]> + & _MakeRegistry<'limit.add', Omit<limit.Rule, 'id'>, number> + & _MakeRegistry<'limit.hitVisit', limit.Item, boolean> + & _MakeRegistry<'limit.delay', string> + & _MakeRegistry<'limit.summary', undefined, limit.Summary | undefined> + // Merge + & _MakeRegistry<'merge.all', undefined, merge.Rule[]> + & _MakeRegistry<'merge.delete', string> + & _MakeRegistry<'merge.add', merge.Rule> + // Whitelist + & _MakeRegistry<'whitelist.all', undefined, string[]> + & _MakeRegistry<'whitelist.add' | 'whitelist.delete', string> + & _MakeRegistry<'whitelist.contain', { host: string; url: string }, boolean> + // Backup + & _MakeRegistry<'backup.sync' | 'backup.checkAuth', undefined, string | undefined> + & _MakeRegistry<'backup.clear', string, string | undefined> + & _MakeRegistry<'backup.query', backup.RemoteQuery, backup.Row[]> + & _MakeRegistry<'backup.lastTs', backup.Type, number | undefined> + & _MakeRegistry<'backup.clients', undefined, (backup.Client & { current: boolean })[]> + // Period + & _MakeRegistry<'period.list', period.Query, period.Row[]> + // Timeline + & _MakeRegistry<'timeline.list', timeline.Query, timeline.Activity[]> + & _MakeRegistry<'timeline.tick', timeline.Event> + & _MakeRegistry<'backup.preview', backup.RemoteQuery, imported.Row[]> + & _MakeRegistry<'immigration.importOther', imported.ProcessQuery> + & _MakeRegistry<'immigration.import', any> + & _MakeRegistry<'immigration.export', undefined, backup.ExportData> + + type ReqCode = keyof _HandlerRegistry + + type ReqData<T extends ReqCode> = _MqReqData<_HandlerRegistry, T> + + /** + * @since 0.2.2 + */ + type Request<T extends ReqCode = ReqCode> = _MqRequest<_HandlerRegistry, T> + + type ResData<T extends ReqCode> = _MqResData<_HandlerRegistry, T> + + /** + * When ResData is undefined, success may omit data. + * @since 0.8.4 + */ + type Response<T extends ReqCode = ReqCode> = _MqResponse<_HandlerRegistry, T> + + /** + * @since 1.3.0 + */ + type Handler<C extends ReqCode> = _MqHandler<_HandlerRegistry, C> + /** + * @since 0.8.4 + */ + type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void + } + + /** + * Background → content script via chrome.tabs.sendMessage (see sendMsg2Tab). + */ + namespace tab { + type _HandlerRegistry = + & mq._MakeRegistry<'siteRunChange'> + & mq._MakeRegistry<'syncAudible', boolean> + & mq._MakeRegistry<'limitTimeMeet', limit.Item[]> + & mq._MakeRegistry<'limitChanged'> + & mq._MakeRegistry<'limitReminder', limit.ReminderInfo> + & mq._MakeRegistry<'askVisitHit', number, boolean> + + type ReqCode = keyof _HandlerRegistry + + type ReqData<T extends ReqCode> = mq._MqReqData<_HandlerRegistry, T> + + type ResData<T extends ReqCode> = mq._MqResData<_HandlerRegistry, T> + + type Request<T extends ReqCode = ReqCode> = mq._MqRequest<_HandlerRegistry, T> + + type Response<T extends ReqCode = ReqCode> = mq._MqResponse<_HandlerRegistry, T> + + /** + * @since 0.8.4 + */ + type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void + } +}