From 39bfdfbba394e5d7faf4880538e85ca105304847 Mon Sep 17 00:00:00 2001 From: 0HugoHu <0@hugohu.top> Date: Sat, 6 Sep 2025 18:30:34 -0700 Subject: [PATCH 1/9] docs: update repository information and author details --- .vscode/settings.json | 14 +- CHANGELOG.md | 2 +- CONTRIBUTING.md | 183 ------------------ README-zh.md | 77 -------- README.md | 30 +-- doc/for-fire-fox.md | 2 +- doc/safari-install.md | 6 +- doc/similar-comparison.md | 25 --- jest.config.ts => jest.config.js | 12 +- package.json | 10 +- src/pages/app/components/SiteManage/common.ts | 4 +- src/util/constant/url.ts | 12 +- src/util/pattern.ts | 12 +- test/util/limit.test.ts | 10 +- test/util/pattern.test.ts | 2 +- 15 files changed, 44 insertions(+), 357 deletions(-) delete mode 100644 CONTRIBUTING.md delete mode 100644 README-zh.md delete mode 100644 doc/similar-comparison.md rename jest.config.ts => jest.config.js (74%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 02e9869d3..71f1b6c42 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "editor.codeActionsOnSave": { "source.fixAll": "always", "source.organizeImports": "always", - "source.removeUnusedImports": "always", + "source.removeUnusedImports": "always" }, "editor.foldingImportsByDefault": true, "editor.trimAutoWhitespace": true, @@ -18,6 +18,7 @@ "typescript.format.insertSpaceBeforeFunctionParenthesis": false, "files.eol": "\n", "cSpell.words": [ + "0HugoHu", "Arrayable", "Auths", "Cascader", @@ -32,7 +33,8 @@ "emsp", "ensp", "filemanager", - "Hengyang", + "Hugo", + "HugoHu", "Kanban", "MKCOL", "newtab", @@ -46,7 +48,6 @@ "rspack", "rtlcss", "selectchanged", - "sheepzh", "subpages", "Treemap", "Vnode", @@ -54,9 +55,7 @@ "webcomponents", "webstore", "webtime", - "wfhg", "zcvf", - "Zhang", "zrender" ], "cSpell.ignorePaths": [ @@ -66,7 +65,6 @@ ".git/objects", ".vscode", ".vscode-insiders", - // Ignore i18n resources - "src/i18n/message/**/*.json", - ], + "src/i18n/message/**/*.json" + ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f909654a9..665071d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ It is worth mentioning that the release time of each change refers to the time w ## [3.5.6] - 2025-08-01 -- Fixed the scrollbar style conflict with [huaban.com](https://github.com/sheepzh/time-tracker-4-browser/issues/524) +- Fixed the scrollbar style conflict with [huaban.com](https://github.com/0HugoHu/time-tracker-4-browser/issues/524) - Added some French and Arabic translations - Upgraded Echarts to v6 with a more modern style diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 4e152cfc8..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,183 +0,0 @@ -# Contributing Guide - -## 1. Prerequisites - -The technology stack used is: - -- [rspack](https://rspack.dev) + [TypeScript](https://github.com/microsoft/TypeScript) -- [Vue3 (Composition API + JSX)]() -- [sass](https://github.com/sass/sass) -- [Element Plus](https://element-plus.gitee.io/) -- [Echarts](https://github.com/apache/echarts) - -And [Chrome Extension Development Documentation](https://developer.chrome.com/docs/webstore/). Currently, the manifest version used by Chrome and Edge is v3, and Firefox uses v2. Please pay attention to compatibility. - -Some free open source tools are also integrated: - -- Testing tool [jest](https://jestjs.io/docs/getting-started) -- End-to-end integration testing [puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/puppeteer) -- I18N tool [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox) - -## 2. Steps - -1. Fork your repository -2. Install dependencies - -```shell -npm install -``` - -3. Create your own branch -4. Code & run - -First execute the command - -```shell -npm run dev -# or if you want to run in Firefox -npm run dev:firefox -``` - -Two folders will be output in the project root directory, `dist_dev` and `dist_dev_firefox` - -Then import different targets according to the test browser: - -- Import `dist_dev` into Chrome and Edge -- Import the `manifest.json` file in `dist_dev_firefox` for Firefox - -5. Run unit tests - -```shell -npm run test -``` - -6. Run end-to-end tests - -First, you need to compile twice: integration compilation and production compilation - -```shell -npm run dev:e2e -npm run build -``` - -Then execute the test - -```shell -npm run test-e2e -``` - -If you need to start puppeteer in headless mode, you can set the following environment variables - -```bash -export USE_HEADLESS_PUPPETEER=true -``` - -7. Submit a PR - -Please PR to the main branch of this repository. - -## 3. Application architecture design - -> todo - -## 4. Directory Structure - -```plain -project -│ -└───doc # Documents -│ -└───public # Assets -| -└───src -| | manifest.ts # manifest.json for Chrome and Edge -| | manifest-firefox.ts # manifest.json for Firefox -| | -| └───api # API -| | -| └───pages # UI -| | | -| | └───app # Background page -| | | -| | └───popup # Popup page -| | | | -| | | └───skeleton.ts # Skeleton of the popup page -| | | | -| | | └───index.ts # Popup page entrance -| | | -| | └───side # Side page -| | | -| | └───[Other dirs] # Shared code -| | -| └───background # Backend service, responsible for coordinating data interaction, Service Worker -| | -| └───content-script # Script injected into user pages -| | -| └───service # Service Layer -| | -| └───database # Data Access Layer -| | -| └───i18n # Translations -| | -| └───util # Utils -| | -| └───common # Shared -| -└───test # Unit tests -| └───__mock__ -| | -| └───storage.ts # mock chrome.storage -| -└───test-e2e # End-to-end tests -| -└───types # Declarations -| -└───rspack # rspack config - -``` - -## 5. Code format - -Please use the code formatting tools that come with VSCode. Please **disable Prettier Eslint** and other formatting tools - -- Use single quotes whenever possible -- Keep the code as concise as possible while being grammatically correct. -- No semicolon at the end of the line -- Please use LF (\n). In Windows, you need to execute the following command to turn off the warning: - -``` -git config core.autocrlf false -``` - -## 6. How to use i18n - -Except for certain professional terms, the text of the user interface can be in English. For the rest, please use i18n to inject text. See the code directory `src/i18n` - -### How to add entries - -1. Add new fields in the definition file `xxx.ts` -2. Then add the corresponding text of this field in English (en) and Simplified Chinese (zh_CN) in the corresponding resource file `xxx-resource.json` -3. Call `t(msg=>msg...)` in the code to get the text content - -### How to integrate with Crowdin - -Crowdin is a collaborative translation platform that allows native speakers to help translate multilingual content. The project's integration with Crowdin is divided into two steps - -1. Upload English text and other language text in code - -``` -# Upload original English text -ts-node ./script/crowdin/sync-source.ts -# Upload texts in other languages ​​in local code (excluding Simplified Chinese) -ts-node ./script/crowdin/sync-translation.ts -``` - -Because the above two scripts rely on the Crowdin access secret in the environment variable, I integrated them into Github's [Action](https://github.com/sheepzh/timer/actions/workflows/crowdin-sync.yml) - -2. Export translations from Crowdin - -``` -ts-node ./script/crowdin/export-translation.ts -``` - -You can also directly execute [Action](https://github.com/sheepzh/timer/actions/workflows/crowdin-export.yml). diff --git a/README-zh.md b/README-zh.md deleted file mode 100644 index 498162e6b..000000000 --- a/README-zh.md +++ /dev/null @@ -1,77 +0,0 @@ -# 网费很贵 - -[![codecov](https://codecov.io/gh/sheepzh/timer/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/timer) -[![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) -[![](https://img.shields.io/github/v/release/sheepzh/timer)](https://github.com/sheepzh/timer/releases) - -\[ 简体中文 | [English](./README.md) \] - -网费很贵是一款用于上网时间统计的浏览器插件,使用 rspack,TypeScript 和 Element-plus 进行开发。你可以在 Firefox,Chrome 和 Edge 中安装并使用它。 - -- 统计用户在不同网站上的浏览时间,和访问次数 -- 统计用户阅读本地文件的时间 -- 网站白名单,过滤不需要统计的网站 -- 支持多个域名合并统计,用户可以自定义合并规则 -- 统计用户在不同时段的上网习惯 -- 限制每天浏览指定网站的时间 -- 支持数据导出导入 -- 支持将数据备份到 Gist,以及远程查询已备份的多端数据 -- 支持夜间模式 - -## 下载地址 - -[![Chrome](https://img.shields.io/chrome-web-store/v/dkdhhcbjijekmneelocdllcldcpmekmm?color=orange&label=Google%20Chrome)](https://chrome.google.com/webstore/detail/%E7%BD%91%E8%B4%B9%E5%BE%88%E8%B4%B5-%E4%B8%8A%E7%BD%91%E6%97%B6%E9%97%B4%E7%BB%9F%E8%AE%A1/dkdhhcbjijekmneelocdllcldcpmekmm?hl=zh-CN) -[![](https://img.shields.io/chrome-web-store/rating/dkdhhcbjijekmneelocdllcldcpmekmm?color=orange&label=rating)](https://chrome.google.com/webstore/detail/%E7%BD%91%E8%B4%B9%E5%BE%88%E8%B4%B5-%E4%B8%8A%E7%BD%91%E6%97%B6%E9%97%B4%E7%BB%9F%E8%AE%A1/dkdhhcbjijekmneelocdllcldcpmekmm?hl=zh-CN) -[![Chrome Web Store](https://img.shields.io/chrome-web-store/users/dkdhhcbjijekmneelocdllcldcpmekmm?color=orange)](https://chrome.google.com/webstore/detail/%E7%BD%91%E8%B4%B9%E5%BE%88%E8%B4%B5-%E4%B8%8A%E7%BD%91%E6%97%B6%E9%97%B4%E7%BB%9F%E8%AE%A1/dkdhhcbjijekmneelocdllcldcpmekmm?hl=zh-CN) - -[![Edge](https://img.shields.io/badge/dynamic/json?label=Microsoft%20Edge&prefix=v&query=%24.version&url=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fgetproductdetailsbycrxid%2Ffepjgblalcnepokjblgbgmapmlkgfahc)](https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc) -[![](https://img.shields.io/badge/dynamic/json?label=rating&suffix=/5&query=%24.averageRating&url=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fgetproductdetailsbycrxid%2Ffepjgblalcnepokjblgbgmapmlkgfahc)](https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc) -[![](https://img.shields.io/badge/dynamic/json?label=users&query=%24.activeInstallCount&url=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fgetproductdetailsbycrxid%2Ffepjgblalcnepokjblgbgmapmlkgfahc)](https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc) - -[![Firefox](https://img.shields.io/amo/v/2690100?color=green&label=Mozilla%20Firefox)](https://addons.mozilla.org/zh-CN/firefox/addon/web%E6%99%82%E9%96%93%E7%B5%B1%E8%A8%88/) -[![](https://img.shields.io/amo/rating/2690100?color=green)](https://addons.mozilla.org/en-US/firefox/addon/2690100) -[![Mozilla Add-on](https://img.shields.io/amo/users/2690100?color=green)](https://addons.mozilla.org/en-US/firefox/addon/2690100) - -![User Count](./script/output/user_count.svg) - -## 截图 - -> 弹窗页展示今日数据 - -
- -
- -> 所有功能 - -
- -
- -## 贡献指南 - -如果你想参与到该项目的开源建设,可以考虑以下几种方式 - -#### 提交 Issue - -如果您有一些好的想法,或者 bug 反馈,可以新建一条 [issue](https://github.com/sheepzh/timer/issues) 。作者会在第一时间进行回复。 - -#### 参与开发 - -如果你知道如何开发浏览器扩展,并且熟悉该项目的技术栈 ( TypeScript + vue3 + ElementPlus ),也可以贡献代码 - -参见[开发指南](./CONTRIBUTING.md)。 - -> 当然也欢迎新手同学使用该项目作为学习项目自己练手 - -#### 完善翻译 - -除了简体中文外,该扩展另外的本地化语言都依赖机翻。所以也非常欢迎您在 [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox) 里提交翻译建议。 - -#### 好评鼓励 - -至于最简单粗暴的贡献方式,当然是在 [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/web%E6%99%82%E9%96%93%E7%B5%B1%E8%A8%88/) / [Chrome](https://chrome.google.com/webstore/detail/%E7%BD%91%E8%B4%B9%E5%BE%88%E8%B4%B5-%E4%B8%8A%E7%BD%91%E6%97%B6%E9%97%B4%E7%BB%9F%E8%AE%A1/dkdhhcbjijekmneelocdllcldcpmekmm) / [Edge](https://microsoftedge.microsoft.com/addons/detail/timer-the-web-time-is-e/fepjgblalcnepokjblgbgmapmlkgfahc) 好评三连啦 XXD - -## 致谢 - -Timer - Count your browsing time and visits on every sites | Product Hunt diff --git a/README.md b/README.md index da81bed88..03bf9489f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Time Tracker for Browser -[![codecov](https://codecov.io/gh/sheepzh/timer/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/timer) +[![codecov](https://codecov.io/gh/0HugoHu/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/0HugoHu/time-tracker-4-browser) [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) -[![](https://img.shields.io/github/v/release/sheepzh/timer)](https://github.com/sheepzh/timer/releases) +[![](https://img.shields.io/github/v/release/0HugoHu/time-tracker-4-browser)](https://github.com/0HugoHu/time-tracker-4-browser/releases) [![Crowdin](https://badges.crowdin.net/timer-chrome-edge-firefox/localized.svg)](https://crowdin.com/project/timer-chrome-edge-firefox) -\[ 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. @@ -19,7 +18,7 @@ Time Tracker is a browser extension to track the time you spent on all websites. [How to install manually for Safari](./doc/safari-install.md) -![User Count](https://gist.githubusercontent.com/sheepzh/6aaf4c22f909db73b533491167da129b/raw/user_count.svg) +![User Count](https://gist.githubusercontent.com/0HugoHu/6aaf4c22f909db73b533491167da129b/raw/user_count.svg) ## Screenshots @@ -48,29 +47,6 @@ Time Tracker is a browser extension to track the time you spent on all websites.

Page Blocking

-## Contribution - -There are some things you can do to contribute to this software. - -#### 1. Submit issues - -You can [submit one issue](https://github.com/sheepzh/timer/issues) to us if you have some suggestions, feature requests, or feedback of bugs. And we will reply it as soon as possible. - -#### 2. Participate in development - -If you know how to develop browser extensions and are familiar with the project's technology stack (TypeScript + Vue3 + Element Plus + Echarts), you can also contribute code - -See the [Development Guide](./CONTRIBUTING.md) - -#### 3. Perfect translation - -In addition to Simplified Chinese, the other localized languages of this software all rely on machine translation. You can also submit translation suggestions on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox). - -#### 4. Rate 5 stars - -[Firefox](https://addons.mozilla.org/firefox/addon/besttimetracker) / [Chrome](https://chrome.google.com/webstore/detail/%E7%BD%91%E8%B4%B9%E5%BE%88%E8%B4%B5-%E4%B8%8A%E7%BD%91%E6%97%B6%E9%97%B4%E7%BB%9F%E8%AE%A1/dkdhhcbjijekmneelocdllcldcpmekmm) / [Edge](https://microsoftedge.microsoft.com/addons/detail/timer-the-web-time-is-e/fepjgblalcnepokjblgbgmapmlkgfahc) - -It's simple and much helpful! ## Thanks diff --git a/doc/for-fire-fox.md b/doc/for-fire-fox.md index 9f1ce08f4..087747d8c 100644 --- a/doc/for-fire-fox.md +++ b/doc/for-fire-fox.md @@ -11,4 +11,4 @@ The output directory is `dist_prod_firefox` and you can find a zip file named `t Also you could visit the sourcecode on GitHub -[sheepzh/timer](https://github.com/sheepzh/timer) +[0HugoHu/time-tracker-4-browser](https://github.com/0HugoHu/time-tracker-4-browser) diff --git a/doc/safari-install.md b/doc/safari-install.md index a3c8c9b79..613c4b6b0 100644 --- a/doc/safari-install.md +++ b/doc/safari-install.md @@ -6,8 +6,8 @@ So please install it **manually**, GG Safari. ## 0. Download this repository ```shell -git clone https://github.com/sheepzh/timer.git -cd timer +git clone https://github.com/0HugoHu/time-tracker-4-browser.git +cd time-tracker-4-browser ``` ## 1. Install tools @@ -32,7 +32,7 @@ npm run build:safari Then there will be one folder called **Timer**. -Also, you can download the archived file from [the release page](https://github.com/sheepzh/timer/releases), and unzip it to gain this folder. +Also, you can download the archived file from [the release page](https://github.com/0HugoHu/time-tracker-4-browser/releases), and unzip it to gain this folder. 2. Convert js bundles to Xcode project diff --git a/doc/similar-comparison.md b/doc/similar-comparison.md deleted file mode 100644 index c0b7df02a..000000000 --- a/doc/similar-comparison.md +++ /dev/null @@ -1,25 +0,0 @@ - -|Features|Webtime Tracker|Web Activity Time Tracker|Time Tracker|TimeYourWeb Time Tracker| -|----|----|----|----|----| -|Chrome|[👤 60k+
⭐ 4.79/5](https://chrome.google.com/webstore/detail/webtime-tracker/ppaojnbmmaigjmlpjaldnkgnklhicppk)|[👤 20k+
⭐ 4.71/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 2k+
⭐ 4.97/5](https://chrome.google.com/webstore/detail/time-tracker/dkdhhcbjijekmneelocdllcldcpmekmm)|[👤 13k+
⭐ 4.38/5](https://chrome.google.com/webstore/detail/timeyourweb-time-tracker/kfmlkgchpffnaphmlmjnimonlldbcpnh) -|Edge|❌|[👤 4k+
⭐ 4.50/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 3k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc)|[👤 400+
⭐ Not rated](https://microsoftedge.microsoft.com/addons/detail/insight-track-and-optim/kkcmfbejfopnopfcmcjgfkpalecbleio)|[👤 1k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timeyourweb-time-tracker/jodkkcjbahdphacgjhacggclncbaffoe) -|Firefox|[👤 90+
⭐ Not rated](https://addons.mozilla.org/en-US/firefox/addon/webtime-tracker-2/)|❌|[👤 100+
⭐ 5.00/5](https://addons.mozilla.org/en-US/firefox/addon/besttimetracker/)|❌ -|Track by domain|✅|✅|✅|✅| -|Track any URL|❌|❌|✅|❌ -|Track local files|❌|✅|❌|✅| -|Whitelist|❌|✅|✅|✅ -|Data chart screenshot|✅|❌|✅|❌ -|Daily browsing restrictions|❌|✅|✅|❌ -|Daily notifications|❌|✅|❌|❌|❌ -|Pause switch|❌|❌|❌|✅ -|Access calendar|❌|✅|✅|❌ -|Site analysis|❌|❌|✅|❌ -|Active moments per day|❌|❌|✅|✅ -|Beginner's guide|✅|❌|✅|❌ -|Dark mode|❌|❌|✅|❌ -|Export data|✅|✅|✅|❌| -|Sync data across browsers|❌|❌|✅|❌ -|Free|✅|✅|✅|✅| -|Open Source|❌|[GitHub](https://github.com/Stigmatoz/web-activity-time-tracker)|[GitHub](https://github.com/sheepzh/timer)|❌ -|Languages|English|Deutsch, English, русский|English, português (Portugal), 中文 (简体), 中文 (繁體), 日本語|English -|Memory usage with 5 tabs open on Chrome, MacOS
|21+M|19+M|21+M|20+M diff --git a/jest.config.ts b/jest.config.js similarity index 74% rename from jest.config.ts rename to jest.config.js index 4b4b1b320..1b048ddf4 100644 --- a/jest.config.ts +++ b/jest.config.js @@ -1,12 +1,11 @@ -import { type Config } from "@jest/types" -import { compilerOptions } from './tsconfig.json' +const { compilerOptions } = require('./tsconfig.json') const { paths } = compilerOptions const aliasPattern = /^(@.*)\/\*$/ const sourcePattern = /^(.*)\/\*$/ -const moduleNameMapper: { [key: string]: string } = {} +const moduleNameMapper = {} Object.entries(paths).forEach(([alias, sourceArr]) => { const aliasMatch = alias.match(aliasPattern) @@ -23,14 +22,13 @@ Object.entries(paths).forEach(([alias, sourceArr]) => { const prefix = aliasMatch[1] const pattern = `^${prefix}/(.*)$` const source = sourceMath[1] - const sourcePath = `/${source}/$1` - moduleNameMapper[pattern] = sourcePath + moduleNameMapper[pattern] = `/${source}/$1` }) console.log("The moduleNameMapper parsed from tsconfig.json: ") console.log(moduleNameMapper) -const config: Config.InitialOptions = { +const config = { moduleNameMapper, roots: [ "/test", @@ -43,4 +41,4 @@ const config: Config.InitialOptions = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], } -export default config \ No newline at end of file +module.exports = config \ No newline at end of file diff --git a/package.json b/package.json index 4cf1daaa0..da0c91a30 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "timer", "version": "3.6.2", "description": "Time tracker for browser", - "homepage": "https://www.wfhg.cc", + "homepage": "https://hugohu.site", "scripts": { "pure-install": "npm install --include=optional --ignore-scripts", "dev": "rspack --config=rspack/rspack.dev.ts --watch", @@ -20,12 +20,12 @@ "prepare": "husky" }, "author": { - "name": "zhy", - "email": "returnzhy1996@outlook.com", - "url": "https://www.github.com/sheepzh" + "name": "Hugo", + "email": "hugo@hugohu.site", + "url": "https://github.com/0HugoHu" }, "repository": { - "url": "https://github.com/sheepzh/timer" + "url": "https://github.com/0HugoHu/time-tracker-4-browser" }, "license": "MIT", "devDependencies": { diff --git a/src/pages/app/components/SiteManage/common.ts b/src/pages/app/components/SiteManage/common.ts index a3cfed2ca..95a0e96e0 100644 --- a/src/pages/app/components/SiteManage/common.ts +++ b/src/pages/app/components/SiteManage/common.ts @@ -49,8 +49,8 @@ export const VIRTUAL_MSG = t(msg => msg.siteManage.type.virtual?.name)?.toLocale * 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] + * 5. www.github.com/0HugoHu/*[VIRTUAL] + * 5. www.github.com/0HugoHu/*[VIRTUAL-EXISTED] * 3. www.google.com[MERGED-EXISTED] */ export function labelOf(siteKey: timer.site.SiteKey, exists?: boolean): string { diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 6ad6393d2..41fa5016f 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -16,17 +16,17 @@ export const EDGE_HOMEPAGE = 'https://microsoftedge.microsoft.com/addons/detail/ /** * @since 0.4.0 */ -export const SOURCE_CODE_PAGE = 'https://github.com/sheepzh/timer' +export const SOURCE_CODE_PAGE = 'https://github.com/0HugoHu/time-tracker-4-browser' /** * @since 1.9.4 */ -export const CHANGE_LOG_PAGE = 'https://github.com/sheepzh/timer/blob/main/CHANGELOG.md' +export const CHANGE_LOG_PAGE = 'https://github.com/0HugoHu/time-tracker-4-browser/blob/main/CHANGELOG.md' /** * @since 0.0.6 */ -export const GITHUB_ISSUE_ADD = 'https://github.com/sheepzh/timer/issues/new/choose' +export const GITHUB_ISSUE_ADD = 'https://github.com/0HugoHu/time-tracker-4-browser/issues/new/choose' /** * Feedback powered by www.wjx.cn @@ -42,9 +42,9 @@ export const ZH_FEEDBACK_PAGE = 'https://www.wjx.cn/vj/YFWwHUy.aspx' */ export const TU_CAO_PAGE = `https://support.qq.com/products/402895?os=${BROWSER_NAME}&osVersion=${BROWSER_MAJOR_VERSION}&clientVersion=${getVersion()}` -export const PRIVACY_PAGE = 'https://www.wfhg.cc/en/privacy.html' +export const PRIVACY_PAGE = 'https://hugohu.site/en/privacy.html' -export const LICENSE_PAGE = 'https://github.com/sheepzh/timer/blob/main/LICENSE' +export const LICENSE_PAGE = 'https://github.com/0HugoHu/time-tracker-4-browser/blob/main/LICENSE' /** * @since 0.9.6 @@ -92,7 +92,7 @@ export function getAppPageUrl(route?: string, query?: any): string { return url } -export const HOMEPAGE = "https://www.wfhg.cc" +export const HOMEPAGE = "https://hugohu.site" const HOMEPAGE_LOCALES: timer.Locale[] = ["zh_CN", "zh_TW", "en"] export function getHomepageWithLocale(): string { diff --git a/src/util/pattern.ts b/src/util/pattern.ts index f74f3c86c..1e260cfdb 100644 --- a/src/util/pattern.ts +++ b/src/util/pattern.ts @@ -77,12 +77,12 @@ export function isValidHost(host: string) { * * github.com/ = false * github.com = false - * github.com/sheepzh = true + * github.com/0HugoHu = true * github.com/* = true * github.com/** = true - * github.com/sheepzh/ = false - * github.com/sheepzh? = false - * github.com/sheepzh?a=1 = false + * github.com/0HugoHu/ = false + * github.com/0HugoHu? = false + * github.com/0HugoHu?a=1 = false * http://github.com/123 = false * * @since 1.6.0 @@ -95,8 +95,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 + return isValidHost(segments[0]); + } /** diff --git a/test/util/limit.test.ts b/test/util/limit.test.ts index 2dabc6f0d..e5332e82f 100644 --- a/test/util/limit.test.ts +++ b/test/util/limit.test.ts @@ -8,23 +8,23 @@ describe('util/limit', () => { 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('www.github.com/0HugoHu/?a=2')).toEqual('www.github.com/0HugoHu') expect(cleanCond('https://')).toBeUndefined() }) test('matches', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh'] + const cond = ['www.baidu.com', '*.google.com', 'github.com/0HugoHu'] expect(matches(cond, 'https://www.baidu.com')).toBe(true) expect(matches(cond, 'http://hk.google.com')).toBe(true) - expect(matches(cond, 'http://github.com/sheepzh/timer')).toBe(true) + expect(matches(cond, 'http://github.com/0HugoHu/time-tracker-4-browser')).toBe(true) expect(matches(cond, 'http://github.com')).toBe(false) }) test('matchCond', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', 'github.com'] + const cond = ['www.baidu.com', '*.google.com', 'github.com/0HugoHu', 'github.com'] expect(matchCond(cond, 'http://www.baidu.com')).toEqual(['www.baidu.com']) - expect(matchCond(cond, 'https://github.com/sheepzh/time-tracker-for-browser')).toEqual(['github.com/sheepzh', 'github.com']) + expect(matchCond(cond, 'https://github.com/0HugoHu/time-tracker-4-browser')).toEqual(['github.com/0HugoHu', 'github.com']) expect(matchCond(cond, 'https://www.github.com')).toEqual([]) }) diff --git a/test/util/pattern.test.ts b/test/util/pattern.test.ts index 607f4df41..3be3f4fbd 100644 --- a/test/util/pattern.test.ts +++ b/test/util/pattern.test.ts @@ -88,7 +88,7 @@ test("valid virtual host", () => { expect(isValidVirtualHost("http://github.com")).toBeFalsy() expect(isValidVirtualHost("github.com/")).toBeFalsy() - expect(isValidVirtualHost("github.com/sheepzh")).toBeTruthy() + expect(isValidVirtualHost("github.com/0HugoHu")).toBeTruthy() expect(isValidVirtualHost("github.com/**")).toBeTruthy() expect(isValidVirtualHost("github.com/*")).toBeTruthy() expect(isValidVirtualHost("github.com/*/timer")).toBeTruthy() From 33eca9eea86b61f1a60e862563b9a66c75866e96 Mon Sep 17 00:00:00 2001 From: 0HugoHu <0@hugohu.top> Date: Sat, 6 Sep 2025 18:35:56 -0700 Subject: [PATCH 2/9] docs: update readme.md --- README.md | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 03bf9489f..8a69c38f0 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,10 @@ # Time Tracker for Browser -[![codecov](https://codecov.io/gh/0HugoHu/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/0HugoHu/time-tracker-4-browser) -[![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) -[![](https://img.shields.io/github/v/release/0HugoHu/time-tracker-4-browser)](https://github.com/0HugoHu/time-tracker-4-browser/releases) -[![Crowdin](https://badges.crowdin.net/timer-chrome-edge-firefox/localized.svg)](https://crowdin.com/project/timer-chrome-edge-firefox) - +**Project forked from [time-tracker-4-browser](https://github.com/sheepzh/time-tracker-4-browser), and this repo maintains an individual feature branch.** +--- 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. -## Download - -| Released | Version | Rating | User Count | -| ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [Chrome Web Store](https://chromewebstore.google.com/detail/time-tracker-for-browser/dkdhhcbjijekmneelocdllcldcpmekmm?hl=en) | ![](https://img.shields.io/chrome-web-store/v/dkdhhcbjijekmneelocdllcldcpmekmm?color=orange&label=latest) | ![](https://img.shields.io/chrome-web-store/rating/dkdhhcbjijekmneelocdllcldcpmekmm?color=orange&label=rating) | ![](https://img.shields.io/chrome-web-store/users/dkdhhcbjijekmneelocdllcldcpmekmm?color=orange) | -| [Microsoft Edge Addons](https://microsoftedge.microsoft.com/addons/detail/time-tracker-web-habit-/fepjgblalcnepokjblgbgmapmlkgfahc) | ![](https://img.shields.io/badge/dynamic/json?label=latest&prefix=v&query=%24.version&url=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fgetproductdetailsbycrxid%2Ffepjgblalcnepokjblgbgmapmlkgfahc) | ![](https://img.shields.io/badge/dynamic/json?label=rating&suffix=/5&query=%24.averageRating&url=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fgetproductdetailsbycrxid%2Ffepjgblalcnepokjblgbgmapmlkgfahc) | ![](https://img.shields.io/badge/dynamic/json?label=users&query=%24.activeInstallCount&url=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fgetproductdetailsbycrxid%2Ffepjgblalcnepokjblgbgmapmlkgfahc) | -| [Firefox Browser Addons](https://addons.mozilla.org/en-US/firefox/addon/besttimetracker/) | ![](https://img.shields.io/amo/v/2690100?color=green&label=latest) | ![](https://img.shields.io/amo/rating/2690100?color=green) | ![Mozilla Add-on](https://img.shields.io/amo/users/2690100?color=green) | - -[How to install manually for Safari](./doc/safari-install.md) - -![User Count](https://gist.githubusercontent.com/0HugoHu/6aaf4c22f909db73b533491167da129b/raw/user_count.svg) - ## Screenshots
@@ -47,7 +32,3 @@ Time Tracker is a browser extension to track the time you spent on all websites.

Page Blocking

- -## Thanks - -Timer (relaunch) - Timer is one browser extension to stat site visits and time. | Product Hunt From cb2a783557a90b3326926590fcd81575d5cf90b7 Mon Sep 17 00:00:00 2001 From: 0HugoHu <0@hugohu.top> Date: Thu, 11 Sep 2025 01:30:10 -0700 Subject: [PATCH 3/9] feat: support aws-based background sync --- .gitignore | 1 + AWS_DEPLOYMENT_GUIDE.md | 254 ++++++++ CHANGELOG.md | 479 -------------- README.md | 4 +- cdk/README.md | 56 ++ cdk/bin/app.ts | 16 + cdk/cdk.json | 61 ++ cdk/lambda-layers/shared/nodejs/package.json | 15 + cdk/lambda-layers/shared/nodejs/utils.js | 92 +++ cdk/lambda/archive/archive.js | 237 +++++++ cdk/lambda/notify/notify.js | 143 +++++ cdk/lambda/sync/package.json | 7 + cdk/lambda/sync/sync.js | 595 ++++++++++++++++++ cdk/lambda/websocket/websocket.js | 155 +++++ cdk/lib/web-time-tracker-stack.ts | 366 +++++++++++ cdk/package.json | 27 + cdk/tsconfig.json | 30 + src/api/aws.ts | 190 ++++++ src/api/http.ts | 14 + src/background/index.ts | 2 + src/common/timer.ts | 4 +- src/i18n/message/app/option-resource.json | 70 ++- src/i18n/message/common/meta-resource.json | 4 +- .../Option/components/BackupOption/index.tsx | 67 +- src/service/backup/aws/coordinator.ts | 265 ++++++++ src/service/backup/processor.ts | 2 + src/service/sync/background-sync-service.ts | 431 +++++++++++++ src/service/sync/hybrid-sync-manager.ts | 388 ++++++++++++ src/service/sync/sync-integration.ts | 189 ++++++ types/timer/backup.d.ts | 11 + types/timer/core.d.ts | 11 + 31 files changed, 3699 insertions(+), 487 deletions(-) create mode 100644 AWS_DEPLOYMENT_GUIDE.md delete mode 100644 CHANGELOG.md create mode 100644 cdk/README.md create mode 100644 cdk/bin/app.ts create mode 100644 cdk/cdk.json create mode 100644 cdk/lambda-layers/shared/nodejs/package.json create mode 100644 cdk/lambda-layers/shared/nodejs/utils.js create mode 100644 cdk/lambda/archive/archive.js create mode 100644 cdk/lambda/notify/notify.js create mode 100644 cdk/lambda/sync/package.json create mode 100644 cdk/lambda/sync/sync.js create mode 100644 cdk/lambda/websocket/websocket.js create mode 100644 cdk/lib/web-time-tracker-stack.ts create mode 100644 cdk/package.json create mode 100644 cdk/tsconfig.json create mode 100644 src/api/aws.ts create mode 100644 src/service/backup/aws/coordinator.ts create mode 100644 src/service/sync/background-sync-service.ts create mode 100644 src/service/sync/hybrid-sync-manager.ts create mode 100644 src/service/sync/sync-integration.ts diff --git a/.gitignore b/.gitignore index 4277de201..700bd8dba 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ aaa user-chart.svg test.log +/cdk/cdk.out/ diff --git a/AWS_DEPLOYMENT_GUIDE.md b/AWS_DEPLOYMENT_GUIDE.md new file mode 100644 index 000000000..f0c7ec767 --- /dev/null +++ b/AWS_DEPLOYMENT_GUIDE.md @@ -0,0 +1,254 @@ +# AWS Real-time Sync Deployment Guide + +## Overview + +This guide helps you deploy the AWS real-time sync infrastructure for the Time Tracker browser extension. The solution provides: + +- **Real-time sync** via WebSocket connections +- **Conflict resolution** for multi-device usage +- **Hot/Cold data architecture** for cost optimization +- **Automatic fallback** from WebSocket to polling +- **Background sync** with offline queue support + +## Prerequisites + +1. **AWS Account** with appropriate permissions +2. **Node.js 20+** installed locally +3. **AWS CDK** installed globally: `npm install -g aws-cdk` +4. **AWS CLI** configured with credentials + +## Architecture + +``` +Browser Extensions → API Gateway → Lambda Functions + ↓ + DynamoDB (Hot Data) ← → S3 (Cold Storage) + ↓ + WebSocket API ← EventBridge → Notifications +``` + +**Data Lifecycle:** +- **Hot data** (last 7 days): DynamoDB for immediate access +- **Warm data** (7-30 days): DynamoDB + S3 daily files +- **Cold data** (>30 days): S3 monthly files only + +## Step 1: Deploy Infrastructure + +### 1.1 Install CDK Dependencies + +```bash +cd cdk +npm install +``` + +### 1.2 Bootstrap CDK (first time only) + +```bash +cdk bootstrap +``` + +### 1.3 Deploy the Stack + +```bash +# Build Lambda layers +cd lambda-layers/shared/nodejs +npm install +cd ../../.. + +# Deploy infrastructure +cdk deploy +``` + +### 1.4 Note the Outputs + +Save these values from the deployment output: +- `ApiEndpoint`: REST API URL +- `WebSocketEndpoint`: WebSocket API URL +- `ApiKeyId`: API Key ID for authentication +- `DataBucket`: S3 bucket name +- `HotDataTable`: DynamoDB table name + +### 1.5 Get API Key Value + +```bash +aws apigateway get-api-key --api-key --include-value +``` + +## Step 2: Configure Extension + +### 2.1 Open Extension Options + +1. Go to `chrome://extensions/` +2. Find "Time Tracker" extension +3. Click "Options" +4. Navigate to "Backup" tab + +### 2.2 Select AWS Sync + +1. Change backup type to "AWS Real-time Sync" +2. Fill in the configuration: + +| Field | Value | Example | +|-------|-------|---------| +| **API Key** | Value from Step 1.5 | `AbCdEf123456789` | +| **API Endpoint** | From deployment output | `https://abc123.execute-api.us-east-1.amazonaws.com/prod` | +| **WebSocket Endpoint** | From deployment output | `wss://def456.execute-api.us-east-1.amazonaws.com/prod` | +| **AWS Region** | Your deployment region | `us-east-1` | + +3. Set **Client Name** to identify this device (e.g., "Work Laptop", "Home PC") +4. Enable **Auto Backup** for real-time sync + +### 2.3 Test Connection + +Click the test button to verify configuration. You should see: +- ✅ Connection successful +- ✅ WebSocket connected (if available) +- 📊 Sync status indicator + +## Step 3: Verify Setup + +### 3.1 Check Sync Status + +In the extension options, you should see: +- **Hybrid Sync**: Connected via WebSocket or Polling fallback +- **Background Service**: Active with pending count +- **Last Sync**: Recent timestamp + +### 3.2 Test Multi-Device Sync + +1. Install extension on another device +2. Configure with same AWS credentials +3. Use different client name +4. Browse websites on one device +5. Verify data appears on other device within seconds + +### 3.3 Monitor AWS Resources + +Check AWS Console for: +- **DynamoDB**: Records appearing in `TimeTrackerHotData` +- **S3**: Files in `time-tracker-data--` bucket +- **CloudWatch**: Lambda function logs and metrics + +## Configuration Options + +### Sweet Spot Thresholds (Optimized) + +| Setting | Value | Reason | +|---------|--------|--------| +| **Hot Data TTL** | 7 days | Balance between speed and cost | +| **Archive Schedule** | Daily at 2 AM UTC | Off-peak processing | +| **Batch Size** | 50 rows | Optimal Lambda payload size | +| **Sync Interval** | 15 seconds | Real-time feel without overload | +| **Max Queue Size** | 1000 rows | Handle offline scenarios | +| **Retry Limit** | 3 attempts | Balance reliability vs. resource usage | +| **WebSocket Heartbeat** | 30 seconds | Keep connections alive | +| **Polling Fallback** | 30 seconds | When WebSocket unavailable | + +### Cost Optimization + +**Estimated Monthly Costs (heavy user - 1000 entries/day):** +- DynamoDB: $1-2 (read/write operations) +- S3: $0.50 (storage costs) +- Lambda: $0.50 (processing) +- API Gateway: $1 (API calls) +- **Total: ~$3-4/month per user** + +**For lighter usage (100 entries/day): ~$0.50-1/month per user** + +## Troubleshooting + +### Common Issues + +**1. "Connection failed" error** +- Verify API key is correct +- Check API endpoint URL format +- Ensure AWS region matches deployment + +**2. "WebSocket connection timeout"** +- Corporate firewall may block WebSockets +- System will automatically fallback to polling +- Check WebSocket endpoint URL + +**3. "No data syncing"** +- Verify auto backup is enabled +- Check background service status +- Look for errors in browser console + +**4. High AWS costs** +- Review DynamoDB usage patterns +- Adjust archive schedule if needed +- Consider reducing sync frequency + +### Debug Commands + +```bash +# Check DynamoDB data +aws dynamodb scan --table-name TimeTrackerHotData --max-items 10 + +# List S3 objects +aws s3 ls s3://time-tracker-data--/clients/ --recursive + +# View Lambda logs +aws logs describe-log-groups --log-group-name-prefix "/aws/lambda/TimeTracker" +``` + +### Browser Console Debugging + +Open browser DevTools and check for: +```javascript +// Check sync status +console.log('Sync Status:', backgroundSyncService.getStats()) + +// Force sync +await backgroundSyncService.forceSync() + +// Check WebSocket connection +console.log('WebSocket Status:', hybridSyncManager.getStatus()) +``` + +## Security Considerations + +1. **API Keys**: Store securely, rotate regularly +2. **HTTPS Only**: All endpoints use SSL/TLS +3. **CORS**: Restricted to extension origins only +4. **DynamoDB**: Uses least-privilege IAM roles +5. **S3**: Private buckets with encryption at rest + +## Maintenance + +### Regular Tasks + +1. **Monitor costs** via AWS Cost Explorer +2. **Check logs** for errors or unusual patterns +3. **Rotate API keys** quarterly +4. **Update Lambda layers** when dependencies change +5. **Review S3 lifecycle policies** for old data + +### Updates + +To update the infrastructure: +```bash +cd cdk +cdk diff # Preview changes +cdk deploy # Apply changes +``` + +### Cleanup + +To remove all AWS resources: +```bash +cd cdk +cdk destroy +``` + +**⚠️ Warning**: This will delete all sync data permanently! + +## Support + +For issues with: +- **Extension functionality**: Check browser extension logs +- **AWS infrastructure**: Review CloudWatch logs and AWS documentation +- **Cost optimization**: Use AWS Cost Explorer and Trusted Advisor +- **Performance**: Monitor CloudWatch metrics and DynamoDB insights + +The hybrid sync system is designed to be resilient and automatically handle most common scenarios like network issues, browser closures, and temporary AWS service interruptions. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 665071d85..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,479 +0,0 @@ -# Changelog - -All notable changes to Time Tracker will be documented in this file. - -It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Edge to moderate packages, while only 1-2 days for Chrome and Firefox. - -## [3.6.2] - 2025-08-26 - -- Fixed some bugs of timeline - -## [3.6.1] - 2025-08-24 - -- Supported tracking timeline (#538) - -## [3.6.0] - 2025-08-21 - -- Fixed a bug of backup - -## [3.5.9] - 2025-08-15 - -- Fixed some bugs of time limit - -## [3.5.8] - 2025-08-11 [For Firefox] - -- Quickly fixed an error for Firefox - -## [3.5.7] - 2025-08-11 - -- Fixed some bugs -- Added moving buttons of date range picker - -## [3.5.6] - 2025-08-01 - -- Fixed the scrollbar style conflict with [huaban.com](https://github.com/0HugoHu/time-tracker-4-browser/issues/524) -- Added some French and Arabic translations -- Upgraded Echarts to v6 with a more modern style - -## [3.5.5] - 2025-07-26 - -- Still that bug = = -- Added some translations, thanks to [zakno52](https://github.com/zakno52) -- Fixed some styling errors in RTL layout - -## [3.5.4] - 2025-07-23 - -- Fixed a bug again - -## [3.5.3] - 2025-07-14 - -- I had to update something, actually fixed some bugs - -## [3.5.2] - 2025-06-24 - -- Supported exporting and importing the options - -## [3.5.1] - 2025-06-17 - -- Fixed some bugs - -## [3.5.0] - 2025-06-17 - -- Supported to track the time of tab groups (#481) - -## [3.4.7] - 2025-06-03 - -- Fixed the error of menu - -## [3.4.6] - 2025-05-30 - -- Optimized the performance when previewing remoting data -- Beautified tables' style - -## [3.4.5] - 2025-05-17 - -- Optimized Portuguese translation - -## [3.4.4] - 2025-05-15 - -- Removed default filter option of the popup page -- Add total stat of record table - -## [3.4.3] - 2025-05-08 - -- Optimized UI of top-k on the dashboard page (#466) -- Limited the maximum date range for habit analysis - -## [3.4.2] - 2025-04-30 - -- Fixed the error of i18n - -## [3.4.1] - 2025-04-28 - -- Fixed the bug with mdwiki - -## [3.4.0] - 2025-04-20 - -- Supported to lock the limit rule -- Optimized the translations of Traditional Chinese -- Simplified URL configuration for time limit rules - -## [3.3.4] - 2025-03-26 - -- Fixed some bugs - -## [3.3.3] - 2025-03-22 - -- Fixed can't modify the name of site - -## [3.3.2] - 2025-03-20 - -- Fixed Reddit not blocked correctly - -## [3.3.1] - 2025-03-16 - -- Modified the English name of the extension -- Fixed some bugs on Kiwi browser - -## [3.3.0] - 2025-03-13 - -- Supported Firefox for Android -- Speeded up the content scripts -- Supported batch generation of site aliases -- Optimized the performance of the analysis page - -## [3.2.5] - 2025-03-08 - -- Fixed some bugs -- Add days in the default time format -- Optimized the site analysis -- Optimized some performance issues - -## [3.2.4] - 2025-02-27 - -- Fixed one bug again - -## [3.2.3] - 2025-02-26 - -- Fixed some bugs - -## [3.2.2] - 2025-02-25 - -- Fixed the badge error with multiple monitors open -- Supported setting the initial animation duration of charts - -## [3.2.1] - 2025-02-20 - -- Fixed some bugs -- Optimized the pie chart of categories on the popup page - -## [3.2.0] - 2025-02-16 - -- Supported batch operations of limit rules -- Supported run time tracking -- Added a second confirmation when setting the unlock password - -## [3.1.2] - 2025-02-11 - -- Fixed some bugs - -## [3.1.1] - 2025-02-07 [For Firefox] - -- Supported sidebar for Firefox - -## [3.1.0] - 2025-01-25 - -- Supported daily and weekly visit limit -- Supported filtering records and sites for categories-unset -- Supported analysis of category -- Supported reminder before limit time up -- Fixed some bugs - -## [3.0.6] - 2025-01-21 - -- Fixed a bug of limit effective days - -## [3.0.5] - 2025-01-17 - -- Fixed some bugs - -## [3.0.4] - 2025-01-10 - -- Fixed many bugs on a boring Friday - -## [3.0.3] - 2025-01-08 - -- Supported sorting queries of limit rules -- Fixed some bugs - -## [3.0.2] - 2025-01-06 [For Firefox] - -- Fixed i18n error for Firefox - -## [3.0.1] - 2025-01-05 [For Firefox] - -- Fixed some style errors for Firefox - -## [3.0.0] - 2025-01-05 - -- Supported set category of sites - -## [2.5.8] - 2024-12-27 - -- Optimized the speed to open popup page -- Fixed some bugs - -## [2.5.7] - 2024-12-09 - -- Fixed some bugs -- Supported pattern for whitelist - -## [2.5.6] - 2024-12-02 - -- Fixed bugs of badge - -## [2.5.5] - 2024-11-29 - -- Supported option to stop tracking if no activity detected - -## [2.5.4] - 2024-11-25 - -- Optimized the performance of popup page - -## [2.5.3] - 2024-11-23 - -- Refactored the popup page - -## [2.5.2] - 2024-11-03 - -- Fixed a bug of Firefox v132 - -## [2.5.1] - 2024-11-01 - -- Fixed some bugs - -## [2.5.0] - 2024-10-19 - -- Supported RTL and Arabic -- Fixed some bugs -- Optimized the performance of popup page - -## [2.4.7] - 2024-09-28 - -- Fixed some style bugs -- Supported all time's data on popup page - -## [2.4.6] - 2024-09-22 - -- Fixed some bugs -- Supported Russian - -## [2.4.5] - 2024-09-08 - -- Supported WebDAV to backup data (#314) - -## [2.4.4] - 2024-09-01 - -- Fixed some bugs about Obsidian - -## [2.4.3] - 2024-08-27 - -- Adapted to kiwi browser -- Fixed the file type not detected on Windows (#310) - -## [2.4.2] - 2024-08-19 - -- Increased the difficulty of hard mode -- Fixed some bugs - -## [2.4.1] - 2024-08-12 - -- Supported weekly limit - -## [2.4.0] - 2024-08-08 - -- Refactored charts on background pages -- Fixed some styling errors - -## [2.3.7] - 2024-07-28 - -- Supported recording delay count after time blocking - -## [2.3.6] - 2024-06-29 - -- Optimized UI -- Supported French - -## [2.3.5] - 2024-06-23 - -- Fixed bugs for Edge -- Optimized UI of charts - -## [2.3.4] - 2024-06-17 - -- Supported effective days of limit rule -- Optimized UI of site management (#291) - -## [2.3.3] - 2024-06-05 - -- Fixed some bugs -- Improved Ukrainian translation -- Optimized time tracking of local files - -## [2.3.2] - 2024-05-14 - -- Fixed bugs of limit modal - -## [2.3.1] - 2024-05-11 - -- Fixed a bug of limit creation - -## [2.3.0] - 2024-05-08 - -- Beautified the restricted page (#270) -- Supported changing the background color of badge (#279) - -## [2.2.9] - 2024-04-11 - -- Supported adding virtual sites into the whitelist (#274) - -## [2.2.8] - 2024-04-10 - -- Fixed some UI bugs -- Fixed bugs in Firefox caused by proxy data - -## [2.2.7] - 2024-04-05 - -- Supported Germany -- Supported configuring multiple URLs for time limits (#273) - -## [2.2.6] - 2024-03-30 - -- Optimized UI of charts and side panel - -## [2.2.5] - 2024-03-27 - -- Supported side panel (#271) -- Prevented users from editing or deleting mask layer via console (#270) - -## [2.2.4] - 2024-03-25 - -- Count local files by default -- Improved PSL performance -- Added rate link on the popup page - -## [2.2.3] - 2024-03-21 - -- Optimized the about page -- Fixed some bugs of chart -- Added Arabic for translation - -## [2.2.2] - 2024-03-18 - -- Added zh_TW translations -- Fixed indicator cells for analysis - -## [2.2.1] - 2024-03-17 - -- Fixed some UI bugs - -## [2.2.0] - 2024-03-16 - -- Optimized all charts -- Supported calendar charts for site analysis -- Removed unnecessary menu items -- Added about page - -## [2.1.10] - 2024-03-14 - -- Fixed bugs for auto syncing (#265) - -## [2.1.8] - 2024-03-07 - -- Fixed collection error during hibernation - -## [2.1.7] - 2024-03-06 [For Firefox] - -- Fixed some bugs for backup option (#263) - -## [2.1.6] - 2024-03-04 - -- Removed unnecessary permissions: clipboard and webNavigation - -## [2.1.5] - 2024-02-26 - -- Fixed some bugs - -## [2.1.4] - 2024-02-20 - -- Supported Spanish - -## [2.1.3] - 2024-02-17 - -- Moved guide to official website (#253) - -## [2.1.2] - 2024-01-25 - -- Optimized verification of limit (#258) -- Supported strict mode for limit (#244) - -## [2.1.1] - 2024-01-15 - -- Fixed some bugs of bing.com in Firefox -- Optimized verification for 5 more minutes - -## [2.1.0] - 2024-01-02 - -- Supported Ukrainian -- Added several charts of habits -- Optimized badge text in screen-split mode -- Fixed some UI bugs - -## [v2.0.2] - 2023-12-12 - -- Fixed error of habits (#245) - -## [v2.0.1] - 2023-12-04 - -- Supported new file format for History Trends Unlimited - -## [v2.0.0] - 2023-11-13 - -- Supported restricted period for limit rules -- Supported limiting the time of each visit -- Refactored the UI for configuring limit rules - -## [v1.9.7] - 2023-10-17 [For Chrome] - -- Fixed crash on Qihu-360X Browser. - -## [v1.9.6] - 2023-10-16 - -- Supported syncing data from [History Trends Unlimited](https://chrome.google.com/webstore/detail/history-trends-unlimited/pnmchffiealhkdloeffcdnbgdnedheme). (#238) - -## [v1.9.5] - 2023-09-05 - -- Fixed an issue where the restriction did not take effect in full screen mode (#234). -- Fixed the style error when setting restriction rules. (#233) - -## [v1.9.4] - 2023-08-08 - -- Supported syncing data with [Local REST API for Obsidian](https://github.com/coddingtonbear/obsidian-local-rest-api). -- Display change log on the guide page and the context menu. - -## [v1.9.3] - 2023-08-02 - -- Supported downloading and clearing synced data. - -## [v1.9.2] - 2023-07-31 [For Firefox] - -- Fixed the bug caused crash in Firefox. - -## [v1.9.1] - 2023-07-25 - -- Supported importing data exported from Webtime Tracker. -- Supported importing data exported from Web Activity Time Tracker. - -## [v1.9.0] - 2023-06-27 - -- Supported password and verification to unlock daily limit. -- Fixed known bugs. - -## [v1.8.2] - 2023-05-20 - -- Added contributor list of translation. -- Added Portuguese localization. -- Fixed known bugs. - -## [v1.8.1] - 2023-04-12 - -- Renamed from Timer to Time Tracker. - -## [v1.8.0] - 2023-04-04 - -- Refactored the guide page. -- Fixed the background color of sidebar. - -## [previous] - -Sorry! These previous logs are lost. diff --git a/README.md b/README.md index 8a69c38f0..d9c605223 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Time Tracker for Browser +# Web Time Tracker **Project forked from [time-tracker-4-browser](https://github.com/sheepzh/time-tracker-4-browser), and this repo maintains an individual feature branch.** --- -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. +Web 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. ## Screenshots diff --git a/cdk/README.md b/cdk/README.md new file mode 100644 index 000000000..31c00e5f8 --- /dev/null +++ b/cdk/README.md @@ -0,0 +1,56 @@ +# Time Tracker CDK Infrastructure + +This directory contains the AWS CDK infrastructure code for the Time Tracker real-time sync system. + +## Quick Start + +1. Install dependencies: +```bash +npm install +``` + +2. Build Lambda layers: +```bash +cd lambda-layers/shared/nodejs +npm install +cd ../../.. +``` + +3. Deploy: +```bash +cdk deploy +``` + +## Architecture + +- **API Gateway**: REST API for sync operations +- **WebSocket API**: Real-time notifications +- **Lambda Functions**: Business logic +- **DynamoDB**: Hot data storage (7 days) +- **S3**: Cold data archival (>7 days) +- **EventBridge**: Event-driven notifications + +## Lambda Functions + +- `sync.js`: Handle upload/download operations +- `websocket.js`: Manage WebSocket connections +- `notify.js`: Send real-time notifications +- `archive.js`: Move old data from DynamoDB to S3 + +## CDK Commands + +- `cdk deploy`: Deploy infrastructure +- `cdk diff`: Preview changes +- `cdk synth`: Generate CloudFormation templates +- `cdk destroy`: Remove all resources + +## Configuration + +The stack creates: +- DynamoDB table with 7-day TTL +- S3 bucket with lifecycle policies +- Lambda functions with appropriate IAM roles +- API Gateway with usage plans and API keys +- WebSocket API for real-time connections + +See `../AWS_DEPLOYMENT_GUIDE.md` for complete setup instructions. \ No newline at end of file diff --git a/cdk/bin/app.ts b/cdk/bin/app.ts new file mode 100644 index 000000000..b6c234868 --- /dev/null +++ b/cdk/bin/app.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import { WebTimeTrackerStack } from '../lib/web-time-tracker-stack'; + +const app = new cdk.App(); + +const env = { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION || 'us-east-1', +}; + +new WebTimeTrackerStack(app, 'WebTimeTrackerStack', { + env, + description: 'Web Time Tracker Real-time Sync Infrastructure', +}); \ No newline at end of file diff --git a/cdk/cdk.json b/cdk/cdk.json new file mode 100644 index 000000000..3f388f5ab --- /dev/null +++ b/cdk/cdk.json @@ -0,0 +1,61 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": false, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableLoggingForLambdaFunctionState": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableLogging": true, + "@aws-cdk/aws-lambda:recognizeVersionProps": true, + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, + "@aws-cdk/core:stackRelativeExports": true + } +} \ No newline at end of file diff --git a/cdk/lambda-layers/shared/nodejs/package.json b/cdk/lambda-layers/shared/nodejs/package.json new file mode 100644 index 000000000..28b8b30ae --- /dev/null +++ b/cdk/lambda-layers/shared/nodejs/package.json @@ -0,0 +1,15 @@ +{ + "name": "time-tracker-shared-layer", + "version": "1.0.0", + "description": "Shared dependencies for Time Tracker Lambda functions", + "dependencies": { + "aws-sdk": "^2.1550.0", + "@aws-sdk/client-dynamodb": "^3.490.0", + "@aws-sdk/client-s3": "^3.490.0", + "@aws-sdk/client-eventbridge": "^3.490.0", + "@aws-sdk/client-apigatewaymanagementapi": "^3.490.0", + "@aws-sdk/lib-dynamodb": "^3.490.0", + "@aws-sdk/util-dynamodb": "^3.490.0", + "zlib": "^1.0.5" + } +} \ No newline at end of file diff --git a/cdk/lambda-layers/shared/nodejs/utils.js b/cdk/lambda-layers/shared/nodejs/utils.js new file mode 100644 index 000000000..a54839708 --- /dev/null +++ b/cdk/lambda-layers/shared/nodejs/utils.js @@ -0,0 +1,92 @@ +const zlib = require('zlib'); + +/** + * Compress data using gzip + */ +function compress(data) { + const jsonString = typeof data === 'string' ? data : JSON.stringify(data); + return zlib.gzipSync(jsonString).toString('base64'); +} + +/** + * Decompress gzipped data + */ +function decompress(compressedData) { + const buffer = Buffer.from(compressedData, 'base64'); + const decompressed = zlib.gunzipSync(buffer).toString(); + try { + return JSON.parse(decompressed); + } catch (e) { + return decompressed; + } +} + +/** + * Generate TTL timestamp (7 days from now) + */ +function generateTTL(days = 7) { + return Math.floor(Date.now() / 1000) + (days * 24 * 60 * 60); +} + +/** + * Format date as YYYYMMDD + */ +function formatDate(date) { + const d = date instanceof Date ? date : new Date(date); + return d.toISOString().slice(0, 10).replace(/-/g, ''); +} + +/** + * Check if date is within hot data range (last 7 days) + */ +function isHotData(dateStr) { + const date = new Date(dateStr.slice(0, 4) + '-' + dateStr.slice(4, 6) + '-' + dateStr.slice(6, 8)); + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + return date >= sevenDaysAgo; +} + +/** + * Generate partition key for DynamoDB + */ +function generatePK(clientId, host, date) { + return `${clientId}#${host}#${date}`; +} + +/** + * Parse partition key + */ +function parsePK(pk) { + const parts = pk.split('#'); + return { + clientId: parts[0], + host: parts[1], + date: parts[2] + }; +} + +/** + * Generate S3 key for monthly data + */ +function generateMonthlyS3Key(clientId, yearMonth) { + return `clients/${clientId}/monthly/${yearMonth}.json.gz`; +} + +/** + * Generate S3 key for daily data + */ +function generateDailyS3Key(clientId, date) { + return `clients/${clientId}/daily/${date}.json.gz`; +} + +module.exports = { + compress, + decompress, + generateTTL, + formatDate, + isHotData, + generatePK, + parsePK, + generateMonthlyS3Key, + generateDailyS3Key +}; \ No newline at end of file diff --git a/cdk/lambda/archive/archive.js b/cdk/lambda/archive/archive.js new file mode 100644 index 000000000..9ee5f5fa2 --- /dev/null +++ b/cdk/lambda/archive/archive.js @@ -0,0 +1,237 @@ +const { DynamoDBDocumentClient, ScanCommand, DeleteCommand, BatchWriteCommand } = require('@aws-sdk/lib-dynamodb'); +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); +const { compress, decompress, formatDate, generateMonthlyS3Key } = require('/opt/nodejs/utils'); + +const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const s3Client = new S3Client({}); + +const HOT_DATA_TABLE = process.env.HOT_DATA_TABLE; +const DATA_BUCKET = process.env.DATA_BUCKET; + +/** + * Archive Lambda handler - moves old data from DynamoDB to S3 + * Triggered daily by EventBridge + */ +exports.handler = async (event) => { + console.log('Archive event:', JSON.stringify(event, null, 2)); + + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 7); // Archive data older than 7 days + const cutoffTimestamp = cutoffDate.getTime(); + const cutoffDateStr = formatDate(cutoffDate); + + console.log(`Archiving data older than ${cutoffDateStr} (${cutoffTimestamp})`); + + // Scan for old data + const oldItems = await scanOldData(cutoffTimestamp); + console.log(`Found ${oldItems.length} items to archive`); + + if (oldItems.length === 0) { + return { message: 'No data to archive' }; + } + + // Group by client and month + const groupedData = groupByClientAndMonth(oldItems); + + // Archive each group + let archivedCount = 0; + let deletedCount = 0; + + for (const [clientId, monthGroups] of Object.entries(groupedData)) { + for (const [yearMonth, items] of Object.entries(monthGroups)) { + try { + await archiveMonthData(clientId, yearMonth, items); + + // Delete from DynamoDB + await deleteArchivedItems(items); + + archivedCount += items.length; + deletedCount += items.length; + + console.log(`Archived and deleted ${items.length} items for ${clientId}/${yearMonth}`); + } catch (error) { + console.error(`Error archiving ${clientId}/${yearMonth}:`, error); + } + } + } + + return { + message: `Archive completed: ${archivedCount} items archived, ${deletedCount} items deleted from hot storage`, + archivedCount, + deletedCount + }; + } catch (error) { + console.error('Archive handler error:', error); + throw error; + } +}; + +/** + * Scan for old data in DynamoDB + */ +async function scanOldData(cutoffTimestamp) { + const items = []; + let lastEvaluatedKey; + + do { + const response = await dynamoClient.send(new ScanCommand({ + TableName: HOT_DATA_TABLE, + FilterExpression: 'SK = :sk AND lastModified < :cutoff', + ExpressionAttributeValues: { + ':sk': 'data', + ':cutoff': cutoffTimestamp + }, + ExclusiveStartKey: lastEvaluatedKey + })); + + items.push(...response.Items); + lastEvaluatedKey = response.LastEvaluatedKey; + } while (lastEvaluatedKey); + + return items; +} + +/** + * Group items by client and year-month + */ +function groupByClientAndMonth(items) { + const grouped = {}; + + items.forEach(item => { + const { clientId, date } = item; + const yearMonth = date.substring(0, 6); + + if (!grouped[clientId]) { + grouped[clientId] = {}; + } + if (!grouped[clientId][yearMonth]) { + grouped[clientId][yearMonth] = []; + } + + grouped[clientId][yearMonth].push(item); + }); + + return grouped; +} + +/** + * Archive month data to S3 + */ +async function archiveMonthData(clientId, yearMonth, items) { + const s3Key = generateMonthlyS3Key(clientId, yearMonth); + + try { + // Try to get existing data + let existingData = {}; + + try { + const existing = await s3Client.send(new GetObjectCommand({ + Bucket: DATA_BUCKET, + Key: s3Key + })); + + const compressed = await streamToString(existing.Body); + existingData = decompress(compressed); + } catch (error) { + if (error.name !== 'NoSuchKey') { + console.warn(`Warning reading existing data for ${s3Key}:`, error); + } + } + + // Merge with new data + items.forEach(item => { + const recordKey = `${item.date}_${item.host}`; + existingData[recordKey] = { + host: item.host, + date: item.date, + focus: item.focus, + time: item.time, + lastModified: item.lastModified, + sessionId: item.sessionId, + archivedAt: Date.now() + }; + }); + + // Compress and save to S3 + const compressed = compress(existingData); + await s3Client.send(new PutObjectCommand({ + Bucket: DATA_BUCKET, + Key: s3Key, + Body: compressed, + ContentType: 'application/gzip', + ContentEncoding: 'gzip', + Metadata: { + clientId, + yearMonth, + recordCount: Object.keys(existingData).length.toString(), + lastArchived: new Date().toISOString() + } + })); + + console.log(`Successfully archived ${items.length} items to ${s3Key}`); + } catch (error) { + console.error(`Error archiving to ${s3Key}:`, error); + throw error; + } +} + + + +/** + * Delete archived items from DynamoDB + */ +async function deleteArchivedItems(items) { + // DynamoDB batch delete supports max 25 items + const batches = []; + for (let i = 0; i < items.length; i += 25) { + batches.push(items.slice(i, i + 25)); + } + + for (const batch of batches) { + const deleteRequests = batch.map(item => ({ + DeleteRequest: { + Key: { + PK: item.PK, + SK: item.SK + } + } + })); + + try { + await dynamoClient.send(new BatchWriteCommand({ + RequestItems: { + [HOT_DATA_TABLE]: deleteRequests + } + })); + } catch (error) { + console.error('Error deleting batch:', error); + // Try individual deletes for this batch + for (const item of batch) { + try { + await dynamoClient.send(new DeleteCommand({ + TableName: HOT_DATA_TABLE, + Key: { + PK: item.PK, + SK: item.SK + } + })); + } catch (deleteError) { + console.error(`Error deleting item ${item.PK}:`, deleteError); + } + } + } + } +} + +/** + * Convert stream to string + */ +async function streamToString(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString(); +} \ No newline at end of file diff --git a/cdk/lambda/notify/notify.js b/cdk/lambda/notify/notify.js new file mode 100644 index 000000000..e6e37b1b9 --- /dev/null +++ b/cdk/lambda/notify/notify.js @@ -0,0 +1,143 @@ +const { DynamoDBDocumentClient, ScanCommand } = require('@aws-sdk/lib-dynamodb'); +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi'); + +const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const CONNECTIONS_TABLE = process.env.CONNECTIONS_TABLE; + +/** + * Notification Lambda handler - triggered by EventBridge + */ +exports.handler = async (event) => { + console.log('Notify event:', JSON.stringify(event, null, 2)); + + try { + const { source, detail } = event; + + if (source !== 'web-time-tracker') { + console.log('Ignoring non-web-time-tracker event'); + return; + } + + const { clientIds, type, ...notificationData } = detail; + + // Get all active connections for the affected clients + const connections = await getActiveConnections(clientIds); + + if (connections.length === 0) { + console.log('No active connections to notify'); + return; + } + + // Send notifications to all connections + const results = await Promise.allSettled( + connections.map(conn => sendNotification(conn, { + type, + ...notificationData, + timestamp: new Date().toISOString() + })) + ); + + const successful = results.filter(r => r.status === 'fulfilled').length; + const failed = results.filter(r => r.status === 'rejected').length; + + console.log(`Notifications sent: ${successful} successful, ${failed} failed`); + + // Clean up stale connections + const staleConnections = results + .map((result, index) => ({ result, connection: connections[index] })) + .filter(({ result }) => result.status === 'rejected' && + result.reason?.name === 'GoneException') + .map(({ connection }) => connection); + + if (staleConnections.length > 0) { + await cleanupStaleConnections(staleConnections); + } + + } catch (error) { + console.error('Notify handler error:', error); + } +}; + +/** + * Get active connections for specified clients + */ +async function getActiveConnections(clientIds) { + const connections = []; + + try { + let lastEvaluatedKey; + do { + const response = await dynamoClient.send(new ScanCommand({ + TableName: CONNECTIONS_TABLE, + FilterExpression: 'clientId IN (' + clientIds.map((_, i) => `:client${i}`).join(',') + ')', + ExpressionAttributeValues: clientIds.reduce((acc, clientId, i) => { + acc[`:client${i}`] = clientId; + return acc; + }, {}), + ExclusiveStartKey: lastEvaluatedKey + })); + + connections.push(...response.Items); + lastEvaluatedKey = response.LastEvaluatedKey; + } while (lastEvaluatedKey); + + return connections; + } catch (error) { + console.error('Error getting active connections:', error); + return []; + } +} + +/** + * Send notification to a specific connection + */ +async function sendNotification(connection, notification) { + const { connectionId } = connection; + + // Extract API Gateway endpoint from environment or connection info + // In practice, you'd store this when the connection is established + const endpoint = process.env.WEBSOCKET_ENDPOINT || + `${connectionId.split('/')[0]}.execute-api.${process.env.AWS_REGION}.amazonaws.com/prod`; + + const apiGatewayClient = new ApiGatewayManagementApiClient({ + endpoint: `https://${endpoint}` + }); + + try { + await apiGatewayClient.send(new PostToConnectionCommand({ + ConnectionId: connectionId, + Data: JSON.stringify({ + event: 'sync-update', + data: notification + }) + })); + + console.log(`Notification sent to ${connectionId}`); + } catch (error) { + if (error.name === 'GoneException') { + console.log(`Connection ${connectionId} is stale`); + } else { + console.error(`Failed to send to ${connectionId}:`, error); + } + throw error; + } +} + +/** + * Clean up stale connections + */ +async function cleanupStaleConnections(staleConnections) { + try { + await Promise.all(staleConnections.map(conn => + dynamoClient.send(new DeleteCommand({ + TableName: CONNECTIONS_TABLE, + Key: { connectionId: conn.connectionId } + })) + )); + + console.log(`Cleaned up ${staleConnections.length} stale connections`); + } catch (error) { + console.error('Error cleaning up stale connections:', error); + } +} \ No newline at end of file diff --git a/cdk/lambda/sync/package.json b/cdk/lambda/sync/package.json new file mode 100644 index 000000000..34a76b531 --- /dev/null +++ b/cdk/lambda/sync/package.json @@ -0,0 +1,7 @@ +{ + "name": "sync-lambda", + "version": "1.0.0", + "description": "Sync Lambda function for Web Time Tracker", + "main": "sync.js", + "dependencies": {} +} \ No newline at end of file diff --git a/cdk/lambda/sync/sync.js b/cdk/lambda/sync/sync.js new file mode 100644 index 000000000..0ce912911 --- /dev/null +++ b/cdk/lambda/sync/sync.js @@ -0,0 +1,595 @@ +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { DynamoDBDocumentClient, PutCommand, QueryCommand, UpdateCommand, GetCommand } = require('@aws-sdk/lib-dynamodb'); +const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); +const { EventBridgeClient, PutEventsCommand } = require('@aws-sdk/client-eventbridge'); +const { + compress, + decompress, + generateTTL, + formatDate, + isHotData, + generatePK, + generateMonthlyS3Key, + generateDailyS3Key +} = require('/opt/nodejs/utils'); + +const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const s3Client = new S3Client({}); +const eventBridgeClient = new EventBridgeClient({}); + +const HOT_DATA_TABLE = process.env.HOT_DATA_TABLE; +const DATA_BUCKET = process.env.DATA_BUCKET; +const EVENT_BUS_NAME = process.env.EVENT_BUS_NAME; + +/** + * Main handler for sync operations + */ +exports.handler = async (event) => { + console.log('Received event:', JSON.stringify(event, null, 2)); + + const httpMethod = event.httpMethod || event.requestContext?.http?.method; + const path = event.path || event.rawPath; + + try { + let response; + + if (httpMethod === 'POST' && path.includes('/sync')) { + response = await handleUpload(event); + } else if (httpMethod === 'GET' && path.includes('/sync')) { + response = await handleListClients(event); + } else if (httpMethod === 'GET' && path.includes('/data')) { + response = await handleDownload(event); + } else if (httpMethod === 'PUT' && path.includes('/data')) { + response = await handleUpdate(event); + } else { + response = { + statusCode: 404, + body: JSON.stringify({ error: 'Not found' }) + }; + } + + return { + ...response, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Client-Id', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' + } + }; + } catch (error) { + console.error('Handler error:', error); + return { + statusCode: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + }, + body: JSON.stringify({ error: error.message }) + }; + } +}; + +/** + * Handle data upload (real-time sync) + */ +async function handleUpload(event) { + const body = JSON.parse(event.body); + const clientId = event.headers['X-Client-Id'] || body.clientId; + + if (!clientId) { + return { + statusCode: 400, + body: JSON.stringify({ error: 'Client ID required' }) + }; + } + + const { rows, batchId } = body; + + if (!rows || !Array.isArray(rows)) { + return { + statusCode: 400, + body: JSON.stringify({ error: 'Rows array required' }) + }; + } + + const results = []; + const affectedClients = new Set([clientId]); + + // Process each row + for (const row of rows) { + try { + const result = await processRow(clientId, row, batchId); + results.push(result); + + // Track affected clients for notifications + if (result.conflicts) { + result.conflicts.forEach(c => affectedClients.add(c.clientId)); + } + } catch (error) { + console.error(`Error processing row:`, error); + results.push({ + error: error.message, + row + }); + } + } + + // Send notification event for real-time sync + if (results.some(r => r.success)) { + await sendUpdateNotification(Array.from(affectedClients), { + type: 'data-updated', + clientId, + batchId, + updatedRows: results.filter(r => r.success).length + }); + } + + return { + statusCode: 200, + body: JSON.stringify({ + success: true, + processed: results.length, + successful: results.filter(r => r.success).length, + failed: results.filter(r => r.error).length, + results + }) + }; +} + +/** + * Process individual row with conflict resolution + */ +async function processRow(clientId, row, batchId) { + const { host, date, focus, time, sessionId, lastModified } = row; + const pk = generatePK(clientId, host, date); + const now = Date.now(); + + try { + // Check for existing data + const existing = await dynamoClient.send(new GetCommand({ + TableName: HOT_DATA_TABLE, + Key: { PK: pk, SK: 'data' } + })); + + let finalData; + let conflicts = []; + + if (existing.Item) { + // Conflict resolution + const conflictResult = await resolveConflict(existing.Item, { + clientId, + host, + date, + focus: focus || 0, + time: time || 0, + sessionId, + lastModified: lastModified || now, + batchId + }); + + finalData = conflictResult.resolved; + conflicts = conflictResult.conflicts; + } else { + // New record + finalData = { + PK: pk, + SK: 'data', + clientId, + host, + date, + focus: focus || 0, + time: time || 0, + sessionId, + lastModified: lastModified || now, + batchId, + version: 1, + ttl: generateTTL(7) + }; + } + + // Save to DynamoDB + await dynamoClient.send(new PutCommand({ + TableName: HOT_DATA_TABLE, + Item: finalData, + ConditionExpression: 'attribute_not_exists(PK) OR version < :newVersion', + ExpressionAttributeValues: { + ':newVersion': finalData.version + } + })); + + // Archive to S3 if not hot data + if (!isHotData(date)) { + await archiveToS3(clientId, finalData); + } + + return { + success: true, + pk, + version: finalData.version, + conflicts + }; + } catch (error) { + if (error.name === 'ConditionalCheckFailedException') { + // Version conflict - retry with latest data + console.warn('Version conflict, retrying...', pk); + return await processRow(clientId, row, batchId); + } + throw error; + } +} + +/** + * Resolve conflicts between existing and new data + */ +async function resolveConflict(existing, incoming) { + const conflicts = []; + + // Check if it's the same session + if (existing.sessionId === incoming.sessionId) { + // Same session - accumulate + return { + resolved: { + ...existing, + focus: existing.focus + incoming.focus, + time: existing.time + incoming.time, + lastModified: Math.max(existing.lastModified, incoming.lastModified), + version: existing.version + 1, + ttl: generateTTL(7) + }, + conflicts + }; + } + + // Different sessions - check timestamp and apply strategy + if (incoming.lastModified > existing.lastModified) { + // Newer data wins, but track conflict + conflicts.push({ + type: 'session_conflict', + clientId: existing.clientId, + sessionId: existing.sessionId, + overwritten: { + focus: existing.focus, + time: existing.time, + lastModified: existing.lastModified + } + }); + + // Strategy: Take maximum values (conservative approach) + return { + resolved: { + ...existing, + clientId: incoming.clientId, // Latest client wins + sessionId: incoming.sessionId, + focus: Math.max(existing.focus, incoming.focus), + time: Math.max(existing.time, incoming.time), + lastModified: incoming.lastModified, + version: existing.version + 1, + ttl: generateTTL(7), + conflictResolution: 'max_values' + }, + conflicts + }; + } else { + // Existing data is newer, reject update but track attempt + conflicts.push({ + type: 'timestamp_conflict', + clientId: incoming.clientId, + sessionId: incoming.sessionId, + rejected: true, + reason: 'older_timestamp' + }); + + return { + resolved: existing, + conflicts + }; + } +} + +/** + * Archive data to S3 + */ +async function archiveToS3(clientId, data) { + const { date } = data; + const yearMonth = date.substring(0, 6); + + try { + // Try to get existing monthly data + let monthlyData = {}; + const monthlyKey = generateMonthlyS3Key(clientId, yearMonth); + + try { + const existing = await s3Client.send(new GetObjectCommand({ + Bucket: DATA_BUCKET, + Key: monthlyKey + })); + + const compressed = await streamToString(existing.Body); + monthlyData = decompress(compressed); + } catch (error) { + if (error.name !== 'NoSuchKey') { + console.warn('Error reading existing S3 data:', error); + } + } + + // Add/update the record + const recordKey = `${date}_${data.host}`; + monthlyData[recordKey] = { + host: data.host, + date: data.date, + focus: data.focus, + time: data.time, + lastModified: data.lastModified, + sessionId: data.sessionId + }; + + // Compress and save + const compressed = compress(monthlyData); + await s3Client.send(new PutObjectCommand({ + Bucket: DATA_BUCKET, + Key: monthlyKey, + Body: compressed, + ContentType: 'application/gzip', + ContentEncoding: 'gzip', + Metadata: { + clientId, + yearMonth, + recordCount: Object.keys(monthlyData).length.toString() + } + })); + + console.log(`Archived data to S3: ${monthlyKey}`); + } catch (error) { + console.error('S3 archiving error:', error); + // Don't fail the main operation for archiving errors + } +} + +/** + * Handle client list request + */ +async function handleListClients(event) { + try { + // Query all unique clients from hot data + const clients = new Map(); + + // Scan the ClientIndex GSI to get all clients + const response = await dynamoClient.send(new QueryCommand({ + TableName: HOT_DATA_TABLE, + IndexName: 'ClientIndex', + KeyConditionExpression: 'clientId = :clientId', + ExpressionAttributeValues: { + ':clientId': 'all' // This won't work - need to fix this approach + } + })); + + // TODO: Implement proper client listing logic + // For now, return mock data + return { + statusCode: 200, + body: JSON.stringify({ + clients: [] + }) + }; + } catch (error) { + console.error('List clients error:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: error.message }) + }; + } +} + +/** + * Handle data download request + */ +async function handleDownload(event) { + const { queryStringParameters } = event; + const clientId = event.headers['X-Client-Id']; + const startDate = queryStringParameters?.startDate; + const endDate = queryStringParameters?.endDate; + const targetClientId = queryStringParameters?.clientId; + + if (!clientId) { + return { + statusCode: 400, + body: JSON.stringify({ error: 'Client ID required' }) + }; + } + + try { + const data = await downloadData(targetClientId || clientId, startDate, endDate); + + return { + statusCode: 200, + body: JSON.stringify({ + success: true, + data, + count: data.length + }) + }; + } catch (error) { + console.error('Download error:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: error.message }) + }; + } +} + +/** + * Download data for date range + */ +async function downloadData(clientId, startDate, endDate) { + const result = []; + const start = startDate ? new Date(startDate) : new Date('2020-01-01'); + const end = endDate ? new Date(endDate) : new Date(); + + // Download from hot data (DynamoDB) + const hotData = await downloadHotData(clientId, start, end); + result.push(...hotData); + + // Download from cold data (S3) if needed + if (start < new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)) { + const coldData = await downloadColdData(clientId, start, end); + result.push(...coldData); + } + + // Deduplicate and sort + const uniqueData = deduplicateRows(result); + return uniqueData.sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Download hot data from DynamoDB + */ +async function downloadHotData(clientId, startDate, endDate) { + const result = []; + const startDateStr = formatDate(startDate); + const endDateStr = formatDate(endDate); + + // Query by date range using DateIndex + let lastEvaluatedKey; + do { + const response = await dynamoClient.send(new QueryCommand({ + TableName: HOT_DATA_TABLE, + IndexName: 'ClientIndex', + KeyConditionExpression: 'clientId = :clientId AND lastModified BETWEEN :start AND :end', + ExpressionAttributeValues: { + ':clientId': clientId, + ':start': startDate.getTime(), + ':end': endDate.getTime() + }, + ExclusiveStartKey: lastEvaluatedKey + })); + + result.push(...response.Items.filter(item => + item.SK === 'data' && + item.date >= startDateStr && + item.date <= endDateStr + )); + + lastEvaluatedKey = response.LastEvaluatedKey; + } while (lastEvaluatedKey); + + return result.map(item => ({ + host: item.host, + date: item.date, + focus: item.focus, + time: item.time + })); +} + +/** + * Download cold data from S3 + */ +async function downloadColdData(clientId, startDate, endDate) { + const result = []; + + // Generate list of months to check + const months = []; + const current = new Date(startDate.getFullYear(), startDate.getMonth(), 1); + const endMonth = new Date(endDate.getFullYear(), endDate.getMonth(), 1); + + while (current <= endMonth) { + months.push(formatDate(current).substring(0, 6)); + current.setMonth(current.getMonth() + 1); + } + + // Download each month's data + for (const yearMonth of months) { + try { + const monthlyKey = generateMonthlyS3Key(clientId, yearMonth); + const response = await s3Client.send(new GetObjectCommand({ + Bucket: DATA_BUCKET, + Key: monthlyKey + })); + + const compressed = await streamToString(response.Body); + const monthlyData = decompress(compressed); + + // Filter by date range + Object.values(monthlyData).forEach(record => { + const recordDate = new Date(record.date.slice(0, 4) + '-' + record.date.slice(4, 6) + '-' + record.date.slice(6, 8)); + if (recordDate >= startDate && recordDate <= endDate) { + result.push({ + host: record.host, + date: record.date, + focus: record.focus, + time: record.time + }); + } + }); + } catch (error) { + if (error.name !== 'NoSuchKey') { + console.warn(`Error downloading ${yearMonth}:`, error); + } + } + } + + return result; +} + +/** + * Handle data update request + */ +async function handleUpdate(event) { + // This would handle batch updates, similar to handleUpload + // Implementation depends on specific requirements + return { + statusCode: 501, + body: JSON.stringify({ error: 'Not implemented yet' }) + }; +} + +/** + * Send update notification via EventBridge + */ +async function sendUpdateNotification(clientIds, updateData) { + try { + await eventBridgeClient.send(new PutEventsCommand({ + Entries: [{ + Source: 'web-time-tracker', + DetailType: 'Data Updated', + Detail: JSON.stringify({ + clientIds, + ...updateData, + timestamp: new Date().toISOString() + }), + EventBusName: EVENT_BUS_NAME + }] + })); + } catch (error) { + console.error('Failed to send notification:', error); + } +} + +/** + * Deduplicate rows by host+date, keeping latest + */ +function deduplicateRows(rows) { + const map = new Map(); + + rows.forEach(row => { + const key = `${row.host}#${row.date}`; + const existing = map.get(key); + + if (!existing || row.lastModified > existing.lastModified) { + map.set(key, row); + } + }); + + return Array.from(map.values()); +} + +/** + * Convert stream to string + */ +async function streamToString(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString(); +} \ No newline at end of file diff --git a/cdk/lambda/websocket/websocket.js b/cdk/lambda/websocket/websocket.js new file mode 100644 index 000000000..e10e96767 --- /dev/null +++ b/cdk/lambda/websocket/websocket.js @@ -0,0 +1,155 @@ +const { DynamoDBDocumentClient, PutCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb'); +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { generateTTL } = require('/opt/nodejs/utils'); + +const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const CONNECTIONS_TABLE = process.env.CONNECTIONS_TABLE; + +/** + * WebSocket Lambda handler + */ +exports.handler = async (event) => { + console.log('WebSocket event:', JSON.stringify(event, null, 2)); + + const { eventType, connectionId } = event.requestContext || {}; + const routeKey = event.requestContext?.routeKey; + + try { + switch (eventType || routeKey) { + case 'CONNECT': + case '$connect': + return await handleConnect(connectionId, event); + case 'DISCONNECT': + case '$disconnect': + return await handleDisconnect(connectionId); + case 'MESSAGE': + case '$default': + return await handleMessage(connectionId, event); + default: + console.log('Unknown event type:', eventType || routeKey); + return { statusCode: 200 }; + } + } catch (error) { + console.error('WebSocket handler error:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: error.message }) + }; + } +}; + +/** + * Handle WebSocket connection + */ +async function handleConnect(connectionId, event) { + const { headers, queryStringParameters } = event; + const clientId = queryStringParameters?.clientId || headers?.['x-client-id']; + + if (!clientId) { + console.error('No client ID provided for connection'); + return { + statusCode: 400, + body: 'Client ID required' + }; + } + + try { + // Store connection info + await dynamoClient.send(new PutCommand({ + TableName: CONNECTIONS_TABLE, + Item: { + connectionId, + clientId, + connectedAt: Date.now(), + ttl: generateTTL(1) // 1 day TTL for connections + } + })); + + console.log(`Client ${clientId} connected with connection ${connectionId}`); + + return { + statusCode: 200, + body: JSON.stringify({ message: 'Connected successfully' }) + }; + } catch (error) { + console.error('Connection error:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Failed to connect' }) + }; + } +} + +/** + * Handle WebSocket disconnection + */ +async function handleDisconnect(connectionId) { + try { + await dynamoClient.send(new DeleteCommand({ + TableName: CONNECTIONS_TABLE, + Key: { connectionId } + })); + + console.log(`Connection ${connectionId} disconnected`); + + return { + statusCode: 200, + body: 'Disconnected' + }; + } catch (error) { + console.error('Disconnection error:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Failed to disconnect' }) + }; + } +} + +/** + * Handle WebSocket messages + */ +async function handleMessage(connectionId, event) { + try { + const body = JSON.parse(event.body || '{}'); + const { action, data } = body; + + console.log(`Received message from ${connectionId}:`, { action, data }); + + switch (action) { + case 'ping': + return { + statusCode: 200, + body: JSON.stringify({ action: 'pong', timestamp: Date.now() }) + }; + case 'subscribe': + // Handle subscription to specific data updates + return await handleSubscribe(connectionId, data); + default: + return { + statusCode: 200, + body: JSON.stringify({ error: 'Unknown action' }) + }; + } + } catch (error) { + console.error('Message handling error:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: error.message }) + }; + } +} + +/** + * Handle subscription requests + */ +async function handleSubscribe(connectionId, data) { + // In a real implementation, you might store subscription preferences + // For now, just acknowledge the subscription + return { + statusCode: 200, + body: JSON.stringify({ + action: 'subscribed', + subscriptions: data?.topics || ['data-updates'] + }) + }; +} \ No newline at end of file diff --git a/cdk/lib/web-time-tracker-stack.ts b/cdk/lib/web-time-tracker-stack.ts new file mode 100644 index 000000000..f1f4b8402 --- /dev/null +++ b/cdk/lib/web-time-tracker-stack.ts @@ -0,0 +1,366 @@ +import * as cdk from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as events from 'aws-cdk-lib/aws-events'; +import * as targets from 'aws-cdk-lib/aws-events-targets'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; +import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import { Construct } from 'constructs'; + +export class WebTimeTrackerStack extends cdk.Stack { + public readonly apiEndpoint: string; + public readonly websocketEndpoint: string; + + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // S3 Bucket for cold storage + const dataBucket = new s3.Bucket(this, 'WebTimeTrackerDataBucket', { + bucketName: `web-time-tracker-data-${this.account}-${this.region}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, // Change to RETAIN for production + autoDeleteObjects: true, // Remove for production + versioned: true, + lifecycleRules: [{ + id: 'ArchiveOldVersions', + enabled: true, + noncurrentVersionTransitions: [{ + storageClass: s3.StorageClass.GLACIER, + transitionAfter: cdk.Duration.days(30), + }], + }], + cors: [{ + allowedHeaders: ['*'], + allowedMethods: [ + s3.HttpMethods.GET, + s3.HttpMethods.PUT, + s3.HttpMethods.POST, + s3.HttpMethods.DELETE, + ], + allowedOrigins: ['*'], // Restrict in production + maxAge: 3000, + }], + }); + + // DynamoDB Table for hot data (last 7 days) + const hotDataTable = new dynamodb.Table(this, 'WebTimeTrackerHotData', { + tableName: 'WebTimeTrackerHotData', + partitionKey: { + name: 'PK', // {clientId}#{host}#{date} + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'SK', // data | metadata + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, // Change to RETAIN for production + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: true, + }, + stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + timeToLiveAttribute: 'ttl', + }); + + // Global Secondary Index for querying by client + hotDataTable.addGlobalSecondaryIndex({ + indexName: 'ClientIndex', + partitionKey: { + name: 'clientId', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'lastModified', + type: dynamodb.AttributeType.NUMBER, + }, + }); + + // Global Secondary Index for querying by date range + hotDataTable.addGlobalSecondaryIndex({ + indexName: 'DateIndex', + partitionKey: { + name: 'date', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'lastModified', + type: dynamodb.AttributeType.NUMBER, + }, + }); + + // DynamoDB Table for client connections (WebSocket) + const connectionsTable = new dynamodb.Table(this, 'WebTimeTrackerConnections', { + tableName: 'WebTimeTrackerConnections', + partitionKey: { + name: 'connectionId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + timeToLiveAttribute: 'ttl', + }); + + // Lambda Layer for shared dependencies + const sharedLayer = new lambda.LayerVersion(this, 'WebTimeTrackerSharedLayer', { + layerVersionName: 'web-time-tracker-shared', + code: lambda.Code.fromAsset('lambda-layers/shared'), + compatibleRuntimes: [lambda.Runtime.NODEJS_20_X], + description: 'Shared dependencies for Web Time Tracker lambdas', + }); + + // EventBridge custom bus + const eventBus = new events.EventBus(this, 'WebTimeTrackerEventBus', { + eventBusName: 'WebTimeTrackerEvents', + }); + + // Log groups for Lambda functions + const syncLogGroup = new logs.LogGroup(this, 'SyncFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-SyncFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const archiveLogGroup = new logs.LogGroup(this, 'ArchiveFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-ArchiveFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const websocketLogGroup = new logs.LogGroup(this, 'WebSocketFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-WebSocketFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const notifyLogGroup = new logs.LogGroup(this, 'NotifyFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-NotifyFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Lambda functions + const syncLambda = new lambda.Function(this, 'SyncFunction', { + functionName: 'WebTimeTracker-SyncFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'sync.handler', + code: lambda.Code.fromAsset('lambda/sync'), + layers: [sharedLayer], + environment: { + HOT_DATA_TABLE: hotDataTable.tableName, + DATA_BUCKET: dataBucket.bucketName, + EVENT_BUS_NAME: eventBus.eventBusName, + CONNECTIONS_TABLE: connectionsTable.tableName, + }, + timeout: cdk.Duration.seconds(30), + memorySize: 512, + logGroup: syncLogGroup, + }); + + const archiveLambda = new lambda.Function(this, 'ArchiveFunction', { + functionName: 'WebTimeTracker-ArchiveFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'archive.handler', + code: lambda.Code.fromAsset('lambda/archive'), + layers: [sharedLayer], + environment: { + HOT_DATA_TABLE: hotDataTable.tableName, + DATA_BUCKET: dataBucket.bucketName, + }, + timeout: cdk.Duration.minutes(15), + memorySize: 1024, + logGroup: archiveLogGroup, + }); + + const websocketLambda = new lambda.Function(this, 'WebSocketFunction', { + functionName: 'WebTimeTracker-WebSocketFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'websocket.handler', + code: lambda.Code.fromAsset('lambda/websocket'), + layers: [sharedLayer], + environment: { + CONNECTIONS_TABLE: connectionsTable.tableName, + EVENT_BUS_NAME: eventBus.eventBusName, + }, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + logGroup: websocketLogGroup, + }); + + const notifyLambda = new lambda.Function(this, 'NotifyFunction', { + functionName: 'WebTimeTracker-NotifyFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'notify.handler', + code: lambda.Code.fromAsset('lambda/notify'), + layers: [sharedLayer], + environment: { + CONNECTIONS_TABLE: connectionsTable.tableName, + }, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + logGroup: notifyLogGroup, + }); + + // Grant permissions + hotDataTable.grantReadWriteData(syncLambda); + hotDataTable.grantReadWriteData(archiveLambda); + dataBucket.grantReadWrite(syncLambda); + dataBucket.grantReadWrite(archiveLambda); + eventBus.grantPutEventsTo(syncLambda); + connectionsTable.grantReadWriteData(websocketLambda); + connectionsTable.grantReadWriteData(notifyLambda); + + // Grant WebSocket API permissions to notify lambda + notifyLambda.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'execute-api:ManageConnections' + ], + resources: ['*'], // Will be updated after WebSocket API is created + })); + + // REST API Gateway + const api = new apigateway.RestApi(this, 'WebTimeTrackerApi', { + restApiName: 'Web Time Tracker Sync API', + description: 'API for Web Time Tracker real-time sync', + defaultCorsPreflightOptions: { + allowOrigins: apigateway.Cors.ALL_ORIGINS, + allowMethods: apigateway.Cors.ALL_METHODS, + allowHeaders: [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + 'X-Client-Id', + ], + }, + apiKeySourceType: apigateway.ApiKeySourceType.HEADER, + }); + + // API integration + const syncIntegration = new apigateway.LambdaIntegration(syncLambda, { + requestTemplates: { 'application/json': '{ "statusCode": "200" }' }, + }); + + // API resources + const syncResource = api.root.addResource('sync'); + syncResource.addMethod('POST', syncIntegration); + syncResource.addMethod('GET', syncIntegration); + + const dataResource = api.root.addResource('data'); + dataResource.addMethod('GET', syncIntegration); + dataResource.addMethod('PUT', syncIntegration); + + // WebSocket API Gateway + const websocketApi = new apigatewayv2.WebSocketApi(this, 'WebTimeTrackerWebSocket', { + apiName: 'Web Time Tracker WebSocket API', + description: 'WebSocket API for real-time sync notifications', + connectRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('ConnectIntegration', websocketLambda), + }, + disconnectRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('DisconnectIntegration', websocketLambda), + }, + defaultRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('DefaultIntegration', websocketLambda), + }, + }); + + const websocketStage = new apigatewayv2.WebSocketStage(this, 'WebSocketStage', { + webSocketApi: websocketApi, + stageName: 'prod', + autoDeploy: true, + }); + + // Update WebSocket permissions with actual ARN + notifyLambda.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'execute-api:ManageConnections' + ], + resources: [ + `arn:aws:execute-api:${this.region}:${this.account}:${websocketApi.apiId}/${websocketStage.stageName}/*` + ], + })); + + // EventBridge rules + const dataChangeRule = new events.Rule(this, 'DataChangeRule', { + eventBus, + eventPattern: { + source: ['web-time-tracker'], + detailType: ['Data Updated'], + }, + }); + + dataChangeRule.addTarget(new targets.LambdaFunction(notifyLambda)); + + // Scheduled archiving rule (runs daily at 2 AM) + const archiveRule = new events.Rule(this, 'ArchiveRule', { + schedule: events.Schedule.cron({ + minute: '0', + hour: '2', + day: '*', + month: '*', + year: '*', + }), + }); + + archiveRule.addTarget(new targets.LambdaFunction(archiveLambda)); + + // API Key for extension authentication + const apiKey = api.addApiKey('WebTimeTrackerApiKey', { + apiKeyName: 'web-time-tracker-extension-key', + description: 'API key for Web Time Tracker browser extension', + }); + + const usagePlan = api.addUsagePlan('WebTimeTrackerUsagePlan', { + name: 'web-time-tracker-usage-plan', + description: 'Usage plan for Web Time Tracker API', + throttle: { + rateLimit: 1000, + burstLimit: 2000, + }, + quota: { + limit: 100000, + period: apigateway.Period.MONTH, + }, + }); + + usagePlan.addApiKey(apiKey); + usagePlan.addApiStage({ + stage: api.deploymentStage, + }); + + // Outputs + this.apiEndpoint = api.url; + this.websocketEndpoint = websocketStage.url; + + new cdk.CfnOutput(this, 'ApiEndpoint', { + value: this.apiEndpoint, + description: 'Web Time Tracker API Gateway endpoint', + }); + + new cdk.CfnOutput(this, 'WebSocketEndpoint', { + value: this.websocketEndpoint, + description: 'Web Time Tracker WebSocket API endpoint', + }); + + new cdk.CfnOutput(this, 'ApiKeyId', { + value: apiKey.keyId, + description: 'API Key ID for authentication', + }); + + new cdk.CfnOutput(this, 'DataBucket', { + value: dataBucket.bucketName, + description: 'S3 bucket for data storage', + }); + + new cdk.CfnOutput(this, 'HotDataTable', { + value: hotDataTable.tableName, + description: 'DynamoDB table for hot data', + }); + } +} \ No newline at end of file diff --git a/cdk/package.json b/cdk/package.json new file mode 100644 index 000000000..53b7fa1f9 --- /dev/null +++ b/cdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "web-time-tracker-cdk", + "version": "1.0.0", + "description": "CDK infrastructure for Web Time Tracker sync", + "main": "lib/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk", + "deploy": "cdk deploy", + "destroy": "cdk destroy", + "synth": "cdk synth" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^20.11.16", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "~5.3.3" + }, + "dependencies": { + "aws-cdk-lib": "^2.117.0", + "cdk": "^2.1029.0", + "constructs": "^10.3.0" + } +} diff --git a/cdk/tsconfig.json b/cdk/tsconfig.json new file mode 100644 index 000000000..d1be6f4b2 --- /dev/null +++ b/cdk/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": [ + "ES2022" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} \ No newline at end of file diff --git a/src/api/aws.ts b/src/api/aws.ts new file mode 100644 index 000000000..6fa3aa9e6 --- /dev/null +++ b/src/api/aws.ts @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2025 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { fetchGet, fetchPost, fetchPut } from "./http" + +export type AwsConfig = { + apiEndpoint: string + websocketEndpoint: string + apiKey: string + region: string +} + +export type SyncRequest = { + clientId: string + rows: timer.core.EnhancedRow[] + batchId: string +} + +export type SyncResponse = { + success: boolean + processed: number + successful: number + failed: number + results: SyncResult[] +} + +export type SyncResult = { + success?: boolean + error?: string + pk?: string + version?: number + conflicts?: ConflictInfo[] + row?: timer.core.EnhancedRow +} + +export type ConflictInfo = { + type: 'session_conflict' | 'timestamp_conflict' + clientId: string + sessionId: string + rejected?: boolean + reason?: string + overwritten?: { + focus: number + time: number + lastModified: number + } +} + +export type DownloadRequest = { + startDate?: string + endDate?: string + clientId?: string +} + +export type DownloadResponse = { + success: boolean + data: timer.core.Row[] + count: number +} + +export type ClientsResponse = { + clients: timer.backup.Client[] +} + +const DEFAULT_HEADERS = { + 'Content-Type': 'application/json', +} + +function getHeaders(config: AwsConfig, clientId?: string): Record { + const headers: Record = { + ...DEFAULT_HEADERS, + 'X-Api-Key': config.apiKey, + } + + if (clientId) { + headers['X-Client-Id'] = clientId + } + + return headers +} + +/** + * Upload data rows to AWS backend + */ +export async function uploadData(config: AwsConfig, clientId: string, rows: timer.core.EnhancedRow[], batchId: string): Promise { + const url = `${config.apiEndpoint}/sync` + const headers = getHeaders(config, clientId) + + const body: SyncRequest = { + clientId, + rows, + batchId + } + + const response = await fetchPost(url, body, { headers }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Upload failed: ${response.status} ${error}`) + } + + return await response.json() +} + +/** + * Download data from AWS backend + */ +export async function downloadData(config: AwsConfig, clientId: string, request: DownloadRequest): Promise { + const params = new URLSearchParams() + if (request.startDate) params.set('startDate', request.startDate) + if (request.endDate) params.set('endDate', request.endDate) + if (request.clientId) params.set('clientId', request.clientId) + + const url = `${config.apiEndpoint}/data?${params.toString()}` + const headers = getHeaders(config, clientId) + + const response = await fetchGet(url, { headers }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Download failed: ${response.status} ${error}`) + } + + return await response.json() +} + +/** + * List all clients + */ +export async function listClients(config: AwsConfig, clientId: string): Promise { + const url = `${config.apiEndpoint}/sync` + const headers = getHeaders(config, clientId) + + const response = await fetchGet(url, { headers }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`List clients failed: ${response.status} ${error}`) + } + + return await response.json() +} + +/** + * Test API connectivity and authentication + */ +export async function testConnection(config: AwsConfig, clientId: string): Promise { + try { + const url = `${config.apiEndpoint}/sync` + const headers = getHeaders(config, clientId) + + const response = await fetchGet(url, { headers }) + + if (response.ok) { + return undefined // Success + } else { + const error = await response.text() + return `HTTP ${response.status}: ${error}` + } + } catch (error) { + return error instanceof Error ? error.message : 'Connection failed' + } +} + +/** + * Update specific data records + */ +export async function updateData(config: AwsConfig, clientId: string, rows: timer.core.EnhancedRow[]): Promise { + const url = `${config.apiEndpoint}/data` + const headers = getHeaders(config, clientId) + + const body = { + clientId, + rows, + batchId: `update_${Date.now()}` + } + + const response = await fetchPut(url, body, { headers }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Update failed: ${response.status} ${error}`) + } + + return await response.json() +} \ No newline at end of file diff --git a/src/api/http.ts b/src/api/http.ts index a675bbc8a..7030d3031 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -46,6 +46,20 @@ export async function fetchPutText(url: string, bodyText?: string, option?: Opti } } +export async function fetchPut(url: string, body?: T, option?: Option): Promise { + try { + const response = await fetch(url, { + ...(option || {}), + method: "PUT", + body: body ? JSON.stringify(body) : null, + }) + return response + } catch (e) { + console.error("Failed to fetch put", e) + throw Error(e?.toString?.() ?? 'Unknown error') + } +} + export async function fetchDelete(url: string, option?: Option): Promise { try { const response = await fetch(url, { diff --git a/src/background/index.ts b/src/background/index.ts index bcf2d4337..4fa68e3ff 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -23,6 +23,8 @@ import VersionMigrator from "./migrator" import initSidePanel from "./side-panel" import initTrackServer from "./track-server" import initWhitelistMenuManager from "./whitelist-menu-manager" +// Initialize sync integration for AWS real-time sync +import "@service/sync/sync-integration" // Open the log of console openLog() diff --git a/src/common/timer.ts b/src/common/timer.ts index be79c5f0d..bc7b3a736 100644 --- a/src/common/timer.ts +++ b/src/common/timer.ts @@ -46,4 +46,6 @@ declare global { * * @since 0.0.8 */ -window.timer = timer +if (typeof window !== 'undefined') { + window.timer = timer +} diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 3d1b56f38..eedd50203 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -92,7 +92,11 @@ "obsidian_local_rest_api": { "endpointInfo": "因为无法为浏览器插件配置跨域,所以只能使用 HTTP 协议" }, - "web_dav": {} + "web_dav": {}, + "aws": { + "label": "AWS 实时同步", + "authInfo": "需要AWS凭证和端点进行实时同步" + } }, "label": { "endpoint": "服务地址 {info} {input}", @@ -230,6 +234,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "僅支援 HTTP,因無法為擴充功能頁面設定 CORS" + }, + "web_dav": {}, + "aws": { + "label": "AWS 即時同步", + "authInfo": "需要AWS認證和端點進行即時同步" } }, "label": { @@ -369,7 +378,11 @@ "obsidian_local_rest_api": { "endpointInfo": "Only HTTP is available, as CORS cannot be configured for extension pages" }, - "web_dav": {} + "web_dav": {}, + "aws": { + "label": "AWS Real-time Sync", + "authInfo": "AWS credentials and endpoints required for real-time synchronization" + } }, "label": { "endpoint": "Endpoint address {info} {input}", @@ -504,6 +517,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "拡張機能のページに CORS を設定できないため、HTTP のみが利用できます。" + }, + "web_dav": {}, + "aws": { + "label": "AWS リアルタイム同期", + "authInfo": "リアルタイム同期にはAWSクレデンシャルとエンドポイントが必要" } }, "label": { @@ -639,6 +657,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "Apenas HTTP disponível (limitação CORS)" + }, + "web_dav": {}, + "aws": { + "label": "Sincronização AWS em tempo real", + "authInfo": "Credenciais AWS e endpoints necessários para sincronização" } }, "label": { @@ -767,6 +790,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "Підтримується лише HTTP-доступ, оскільки для сторінок розширень неможливо налаштувати CORS" + }, + "web_dav": {}, + "aws": { + "label": "AWS синхронізація в реальному часі", + "authInfo": "Необхідні AWS облікові дані та кінцеві точки для синхронізації" } }, "label": { @@ -898,6 +926,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "Solo HTTP está disponible porque no es posible configurar CORS para las páginas de extensiones" + }, + "web_dav": {}, + "aws": { + "label": "Sincronización AWS en tiempo real", + "authInfo": "Se requieren credenciales AWS y endpoints para sincronización en tiempo real" } }, "label": { @@ -1029,6 +1062,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "Es ist nur HTTP verfügbar, da CORS nicht für Erweiterungsseiten konfiguriert werden kann" + }, + "web_dav": {}, + "aws": { + "label": "AWS Echtzeit-Synchronisation", + "authInfo": "AWS-Anmeldedaten und Endpunkte für Echtzeit-Synchronisation erforderlich" } }, "label": { @@ -1160,6 +1198,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "Seul HTTP est disponible car il n'est pas possible de configurer CORS pour les pages d'extensions" + }, + "web_dav": {}, + "aws": { + "label": "Synchronisation AWS en temps réel", + "authInfo": "Identifiants AWS et points d'extrémité requis pour la synchronisation en temps réel" } }, "label": { @@ -1244,6 +1287,24 @@ }, "backup": { "title": "Резервное копирование данных", + "type": "Тип резервного копирования {input}", + "client": "Имя клиента {input}", + "meta": { + "none": { + "label": "Всегда отключено" + }, + "gist": { + "authInfo": "Требуется токен с разрешениями gist" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Доступен только HTTP, так как нельзя настроить CORS для страниц расширений" + }, + "web_dav": {}, + "aws": { + "label": "AWS синхронизация в реальном времени", + "authInfo": "Требуются учетные данные AWS и конечные точки для синхронизации" + } + }, "operation": "Резервное копирование", "download": { "btn": "Скачать" @@ -1345,6 +1406,11 @@ }, "obsidian_local_rest_api": { "endpointInfo": "متوفر HTTP فقط، حيث لا يمكن تكوين مشاركة الموارد عبر المنشأ (CORS) لصفحات الاضافة" + }, + "web_dav": {}, + "aws": { + "label": "مزامنة AWS في الوقت الفعلي", + "authInfo": "مطلوب بيانات اعتماد AWS ونقاط النهاية للمزامنة في الوقت الفعلي" } }, "label": { diff --git a/src/i18n/message/common/meta-resource.json b/src/i18n/message/common/meta-resource.json index c87684ac2..3792c24af 100644 --- a/src/i18n/message/common/meta-resource.json +++ b/src/i18n/message/common/meta-resource.json @@ -15,8 +15,8 @@ "description": "統計、分析、改善、効率向上" }, "en": { - "name": "Time Tracker for Browser", - "marketName": "Time Tracker for Browser - Web Habit Builder", + "name": "Web Time Tracker", + "marketName": "Web Time Tracker - Web Habit Builder", "description": "Track, analyze, control, then improve efficiency" }, "pt_PT": { diff --git a/src/pages/app/components/Option/components/BackupOption/index.tsx b/src/pages/app/components/Option/components/BackupOption/index.tsx index eda644108..4185dc99e 100644 --- a/src/pages/app/components/Option/components/BackupOption/index.tsx +++ b/src/pages/app/components/Option/components/BackupOption/index.tsx @@ -24,13 +24,15 @@ const ALL_TYPES: timer.backup.Type[] = [ 'gist', 'web_dav', 'obsidian_local_rest_api', + 'aws', ] const TYPE_NAMES: { [t in timer.backup.Type]: string } = { none: t(msg => msg.option.backup.meta.none.label), gist: 'GitHub Gist', obsidian_local_rest_api: 'Obsidian - Local REST API', - web_dav: 'WebDAV' + web_dav: 'WebDAV', + aws: 'AWS Real-time Sync' } const _default = defineComponent((_, ctx) => { @@ -171,6 +173,69 @@ const _default = defineComponent((_, ctx) => { /> } + {backupType.value === 'aws' && <> + 'API Key {info} {input}'} + v-slots={{ + info: () => {'AWS API Gateway key for authentication'} + }} + required + > + auth.value = val?.trim?.() || ''} + placeholder="Enter your AWS API key" + /> + + 'API Endpoint {info} {input}'} + v-slots={{ + info: () => {'AWS API Gateway endpoint URL from CDK deployment'} + }} + required + > + setExtField('apiEndpoint', val)} + placeholder="https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/prod" + /> + + 'WebSocket Endpoint {info} {input}'} + v-slots={{ + info: () => {'AWS WebSocket API endpoint for real-time updates'} + }} + > + setExtField('websocketEndpoint', val)} + placeholder="wss://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/prod" + /> + + 'AWS Region {input}'} + > + setExtField('region', val)} + placeholder="us-east-1" + /> + + } msg.option.backup.client}> { + + private getConfig(context: timer.backup.CoordinatorContext): AwsConfig { + const { auth, ext } = context + + if (!auth?.token) { + throw new Error('AWS API key is required') + } + + if (!ext?.apiEndpoint) { + throw new Error('AWS API endpoint is required') + } + + return { + apiKey: auth.token, + apiEndpoint: ext.apiEndpoint, + websocketEndpoint: ext.websocketEndpoint || '', + region: ext.region || 'us-east-1' + } + } + + private ensureSessionId(context: timer.backup.CoordinatorContext): string { + if (!context.cache.sessionId) { + context.cache.sessionId = generateSessionId() + context.handleCacheChanged() + } + return context.cache.sessionId + } + + private getNextBatchId(context: timer.backup.CoordinatorContext): string { + const counter = (context.cache.batchCounter || 0) + 1 + context.cache.batchCounter = counter + context.handleCacheChanged() + return generateBatchId(counter) + } + + async updateClients( + context: timer.backup.CoordinatorContext, + clients: timer.backup.Client[] + ): Promise { + // For AWS, client management is handled automatically + // The backend tracks clients through their sync operations + // This method exists to maintain interface compatibility + console.log('AWS: Client list updated automatically through sync operations') + } + + async listAllClients(context: timer.backup.CoordinatorContext): Promise { + const config = this.getConfig(context) + + try { + const response = await listClients(config, context.cid) + return response.clients || [] + } catch (error) { + console.error('AWS: Failed to list clients:', error) + return [] + } + } + + async download( + context: timer.backup.CoordinatorContext, + dateStart: Date, + dateEnd: Date, + targetCid?: string + ): Promise { + const config = this.getConfig(context) + + try { + const response = await downloadData(config, context.cid, { + startDate: formatTimeYMD(dateStart), + endDate: formatTimeYMD(dateEnd), + clientId: targetCid + }) + + return response.data || [] + } catch (error) { + console.error('AWS: Failed to download data:', error) + throw new Error(`Failed to download data: ${error instanceof Error ? error.message : error}`) + } + } + + async upload( + context: timer.backup.CoordinatorContext, + rows: timer.core.Row[] + ): Promise { + if (!rows || rows.length === 0) { + return + } + + const config = this.getConfig(context) + const sessionId = this.ensureSessionId(context) + const batchId = this.getNextBatchId(context) + + // Convert rows to enhanced format + const enhancedRows = rows.map(row => enhanceRow(row, sessionId, batchId)) + + try { + const response = await uploadData(config, context.cid, enhancedRows, batchId) + + // Log conflicts for monitoring + const conflicts = response.results + .filter(r => r.conflicts && r.conflicts.length > 0) + .flatMap(r => r.conflicts!) + + if (conflicts.length > 0) { + console.warn(`AWS: Resolved ${conflicts.length} conflicts during upload:`, conflicts) + await this.handleConflicts(context, conflicts) + } + + // Update sync timestamp + context.cache.lastSyncTimestamp = Date.now() + await context.handleCacheChanged() + + console.log(`AWS: Successfully uploaded ${response.successful}/${response.processed} rows`) + + if (response.failed > 0) { + const failures = response.results.filter(r => r.error) + console.error('AWS: Upload failures:', failures) + throw new Error(`${response.failed} rows failed to upload`) + } + } catch (error) { + console.error('AWS: Failed to upload data:', error) + throw new Error(`Failed to upload data: ${error instanceof Error ? error.message : error}`) + } + } + + async testAuth(auth: timer.backup.Auth, ext: timer.backup.TypeExt): Promise { + if (!auth?.token) { + return 'AWS API key is required' + } + + if (!ext?.apiEndpoint) { + return 'AWS API endpoint is required' + } + + const config: AwsConfig = { + apiKey: auth.token, + apiEndpoint: ext.apiEndpoint, + websocketEndpoint: ext.websocketEndpoint || '', + region: ext.region || 'us-east-1' + } + + try { + return await testConnection(config, 'test-client') + } catch (error) { + return error instanceof Error ? error.message : 'Connection test failed' + } + } + + async clear( + context: timer.backup.CoordinatorContext, + client: timer.backup.Client + ): Promise { + // For AWS, we don't directly delete data from the backend + // Instead, we mark the client as inactive or handle it through lifecycle policies + console.warn(`AWS: Clear operation for client ${client.id} not implemented - data will be archived automatically`) + } + + /** + * Handle conflicts that occurred during upload + * This could trigger UI notifications or automatic resolution strategies + */ + private async handleConflicts( + context: timer.backup.CoordinatorContext, + conflicts: ConflictInfo[] + ): Promise { + // Group conflicts by type for analysis + const sessionConflicts = conflicts.filter(c => c.type === 'session_conflict') + const timestampConflicts = conflicts.filter(c => c.type === 'timestamp_conflict') + + if (sessionConflicts.length > 0) { + console.log(`AWS: ${sessionConflicts.length} session conflicts resolved (different browser sessions)`) + } + + if (timestampConflicts.length > 0) { + console.log(`AWS: ${timestampConflicts.length} timestamp conflicts resolved (newer data took precedence)`) + } + + // In a more sophisticated implementation, you might: + // 1. Show user notifications about conflicts + // 2. Store conflict logs for analysis + // 3. Implement custom resolution strategies + // 4. Trigger UI updates to show resolved data + } + + /** + * Perform incremental sync of specific rows (for real-time updates) + */ + async syncIncremental( + context: timer.backup.CoordinatorContext, + rows: timer.core.Row[] + ): Promise { + if (!rows || rows.length === 0) { + return + } + + const config = this.getConfig(context) + const sessionId = this.ensureSessionId(context) + const batchId = this.getNextBatchId(context) + + // Convert rows to enhanced format + const enhancedRows = rows.map(row => enhanceRow(row, sessionId, batchId)) + + try { + const response = await updateData(config, context.cid, enhancedRows) + + console.log(`AWS: Incremental sync completed: ${response.successful}/${response.processed} rows`) + + if (response.failed > 0) { + console.warn(`AWS: ${response.failed} rows failed in incremental sync`) + } + } catch (error) { + console.error('AWS: Incremental sync failed:', error) + // Don't throw here - incremental sync failures shouldn't break the app + } + } +} \ No newline at end of file diff --git a/src/service/backup/processor.ts b/src/service/backup/processor.ts index 63df59955..9ca4e7c94 100644 --- a/src/service/backup/processor.ts +++ b/src/service/backup/processor.ts @@ -13,6 +13,7 @@ import { formatTimeYMD, getBirthday } from "@util/time" import GistCoordinator from "./gist/coordinator" import ObsidianCoordinator from "./obsidian/coordinator" import WebDAVCoordinator from "./web-dav/coordinator" +import AwsCoordinator from "./aws/coordinator" export type AuthCheckResult = { option: timer.option.BackupOption @@ -153,6 +154,7 @@ class Processor { gist: new GistCoordinator(), obsidian_local_rest_api: new ObsidianCoordinator(), web_dav: new WebDAVCoordinator(), + aws: new AwsCoordinator(), } } diff --git a/src/service/sync/background-sync-service.ts b/src/service/sync/background-sync-service.ts new file mode 100644 index 000000000..e95cd7ee1 --- /dev/null +++ b/src/service/sync/background-sync-service.ts @@ -0,0 +1,431 @@ +/** + * Copyright (c) 2025 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import processor from "@service/backup/processor" +import statDatabase from "@db/stat-database" +import optionHolder from "@service/components/option-holder" +import metaService from "@service/meta-service" +import { formatTimeYMD } from "@util/time" +import { hybridSyncManager } from "./hybrid-sync-manager" +import AwsCoordinator from "@service/backup/aws/coordinator" + +type PendingSync = { + rows: timer.core.Row[] + timestamp: number + retryCount: number + batchId: string +} + +type SyncStats = { + totalSynced: number + totalFailed: number + lastSyncTime: number + pendingCount: number + isOnline: boolean +} + +/** + * Background service for automatic real-time sync + */ +export class BackgroundSyncService { + private isEnabled = false + private syncQueue: PendingSync[] = [] + private isProcessing = false + private readonly maxRetries = 3 + private readonly batchSize = 50 // Max rows per sync batch + private readonly syncIntervalMs = 15000 // 15 seconds between batch syncs + private readonly maxQueueSize = 1000 // Max rows in queue before dropping oldest + private syncInterval: NodeJS.Timeout | null = null + private stats: SyncStats = { + totalSynced: 0, + totalFailed: 0, + lastSyncTime: 0, + pendingCount: 0, + isOnline: navigator.onLine + } + + constructor() { + this.init() + this.setupEventListeners() + } + + private async init(): Promise { + // Check if AWS sync is enabled + const option = await optionHolder.get() + this.isEnabled = option.backupType === 'aws' + + if (this.isEnabled) { + this.startBackgroundSync() + } + + // Listen for hybrid sync manager events + hybridSyncManager.on('data-updated', (data: any) => { + this.handleRemoteUpdate(data) + }) + } + + private setupEventListeners(): void { + // In service workers, we need to use navigator.onLine and periodically check + // or rely on network request failures to detect offline status + + // For service workers, we can't listen to window events + // Instead, we'll check online status periodically and on network failures + if (typeof window !== 'undefined') { + // If we're in a window context (not service worker) + window.addEventListener('online', () => { + console.log('BackgroundSyncService: Back online') + this.stats.isOnline = true + if (this.isEnabled) { + this.processQueue() // Process any pending items + } + }) + + window.addEventListener('offline', () => { + console.log('BackgroundSyncService: Gone offline') + this.stats.isOnline = false + }) + + // Handle page unload to save pending syncs + window.addEventListener('beforeunload', () => { + this.savePendingSyncs() + }) + } else { + // In service worker context, we need alternative approaches + // Periodically check navigator.onLine + setInterval(() => { + const wasOnline = this.stats.isOnline + this.stats.isOnline = navigator.onLine + + if (!wasOnline && this.stats.isOnline && this.isEnabled) { + console.log('BackgroundSyncService: Detected back online') + this.processQueue() + } + }, 5000) // Check every 5 seconds + } + } + + /** + * Start the background sync service + */ + async startBackgroundSync(): Promise { + if (this.syncInterval) { + return // Already running + } + + console.log('BackgroundSyncService: Starting background sync') + + // Load any previously pending syncs + await this.loadPendingSyncs() + + // Start periodic processing + this.syncInterval = setInterval(async () => { + if (this.isEnabled && this.stats.isOnline && !this.isProcessing) { + await this.processQueue() + } + }, this.syncIntervalMs) + + // Process queue immediately if there are pending items + if (this.syncQueue.length > 0) { + this.processQueue() + } + } + + /** + * Stop the background sync service + */ + stopBackgroundSync(): void { + console.log('BackgroundSyncService: Stopping background sync') + + if (this.syncInterval) { + clearInterval(this.syncInterval) + this.syncInterval = null + } + + this.savePendingSyncs() + } + + /** + * Enable/disable the background sync service + */ + async setEnabled(enabled: boolean): Promise { + this.isEnabled = enabled + + if (enabled) { + await this.startBackgroundSync() + } else { + this.stopBackgroundSync() + } + } + + /** + * Queue rows for background sync + */ + queueForSync(rows: timer.core.Row[]): void { + if (!this.isEnabled || !rows || rows.length === 0) { + return + } + + const pendingSync: PendingSync = { + rows, + timestamp: Date.now(), + retryCount: 0, + batchId: `bg_${Date.now()}_${Math.random().toString(36).substr(2, 6)}` + } + + this.syncQueue.push(pendingSync) + this.stats.pendingCount = this.syncQueue.length + + // Remove old items if queue is too large + while (this.syncQueue.length > this.maxQueueSize) { + const removed = this.syncQueue.shift() + console.warn('BackgroundSyncService: Dropping old sync batch:', removed?.batchId) + } + + console.log(`BackgroundSyncService: Queued ${rows.length} rows for sync (${this.syncQueue.length} batches pending)`) + + // Process immediately if online and not already processing + if (this.stats.isOnline && !this.isProcessing) { + setTimeout(() => this.processQueue(), 1000) // Small delay to batch multiple calls + } + } + + /** + * Process the sync queue + */ + private async processQueue(): Promise { + if (this.isProcessing || this.syncQueue.length === 0 || !this.stats.isOnline) { + return + } + + this.isProcessing = true + + try { + // Process batches up to the batch size + let processedCount = 0 + const maxBatches = Math.ceil(this.batchSize / 10) // Assume average 10 rows per batch + + while (this.syncQueue.length > 0 && processedCount < maxBatches) { + const batch = this.syncQueue[0] + + try { + await this.syncBatch(batch) + + // Successfully synced - remove from queue + this.syncQueue.shift() + this.stats.totalSynced += batch.rows.length + this.stats.lastSyncTime = Date.now() + processedCount++ + + console.log(`BackgroundSyncService: Synced batch ${batch.batchId} (${batch.rows.length} rows)`) + } catch (error) { + console.error(`BackgroundSyncService: Failed to sync batch ${batch.batchId}:`, error) + + batch.retryCount++ + + if (batch.retryCount >= this.maxRetries) { + // Too many retries - remove from queue + this.syncQueue.shift() + this.stats.totalFailed += batch.rows.length + console.error(`BackgroundSyncService: Dropping batch ${batch.batchId} after ${this.maxRetries} retries`) + } else { + // Keep for retry but move to end of queue + this.syncQueue.shift() + this.syncQueue.push(batch) + break // Stop processing for now + } + } + } + + this.stats.pendingCount = this.syncQueue.length + } finally { + this.isProcessing = false + } + } + + /** + * Sync a single batch + */ + private async syncBatch(batch: PendingSync): Promise { + const { option, coordinator, errorMsg } = await processor.checkAuth() + + if (errorMsg || !coordinator || option.backupType !== 'aws') { + throw new Error(errorMsg || 'AWS sync not configured') + } + + const clientId = await metaService.getCid() + if (!clientId) { + throw new Error('No client ID available') + } + + // Create context for the coordinator + const context: timer.backup.CoordinatorContext = { + cid: clientId, + auth: { token: option.backupAuths?.aws }, + ext: option.backupExts?.aws, + cache: {}, + handleCacheChanged: async () => { + // Cache changes handled by processor + } + } + + // Use incremental sync if available + if (coordinator instanceof AwsCoordinator && (coordinator as any).syncIncremental) { + await (coordinator as any).syncIncremental(context, batch.rows) + } else { + // Fallback to regular upload + await coordinator.upload(context, batch.rows) + } + } + + /** + * Handle remote updates from other clients + */ + private handleRemoteUpdate(data: any): void { + if (!data.rows || !Array.isArray(data.rows)) { + return + } + + console.log(`BackgroundSyncService: Received ${data.rows.length} remote updates`) + + // Merge remote data into local database + this.mergeRemoteData(data.rows) + } + + /** + * Merge remote data into local database + */ + private async mergeRemoteData(remoteRows: timer.backup.Row[]): Promise { + try { + const localClientId = await metaService.getCid() + + for (const remoteRow of remoteRows) { + // Skip our own data + if (remoteRow.cid === localClientId) { + continue + } + + const { host, date, focus, time } = remoteRow + + // Get existing local data + const existing = await statDatabase.get(host, date) + + // Simple conflict resolution: take maximum values + const mergedFocus = Math.max(existing.focus || 0, focus || 0) + const mergedTime = Math.max(existing.time || 0, time || 0) + + // Update local data if there's a change + if (mergedFocus !== (existing.focus || 0) || mergedTime !== (existing.time || 0)) { + await statDatabase.accumulate(host, date, { + focus: mergedFocus - (existing.focus || 0), + time: mergedTime - (existing.time || 0) + }) + } + } + + console.log(`BackgroundSyncService: Merged ${remoteRows.length} remote rows`) + } catch (error) { + console.error('BackgroundSyncService: Error merging remote data:', error) + } + } + + /** + * Save pending syncs to storage for recovery + */ + private async savePendingSyncs(): Promise { + if (this.syncQueue.length === 0) { + return + } + + try { + const pendingData = { + queue: this.syncQueue, + timestamp: Date.now() + } + + await chrome.storage.local.set({ + 'background_sync_pending': pendingData + }) + + console.log(`BackgroundSyncService: Saved ${this.syncQueue.length} pending syncs`) + } catch (error) { + console.error('BackgroundSyncService: Failed to save pending syncs:', error) + } + } + + /** + * Load pending syncs from storage + */ + private async loadPendingSyncs(): Promise { + try { + const result = await chrome.storage.local.get('background_sync_pending') + const pendingData = result.background_sync_pending + + if (pendingData && pendingData.queue && Array.isArray(pendingData.queue)) { + // Only restore recent items (within last hour) + const oneHourAgo = Date.now() - (60 * 60 * 1000) + const recentItems = pendingData.queue.filter((item: PendingSync) => + item.timestamp > oneHourAgo + ) + + this.syncQueue = recentItems + this.stats.pendingCount = this.syncQueue.length + + console.log(`BackgroundSyncService: Loaded ${this.syncQueue.length} pending syncs from storage`) + + // Clear the stored data + await chrome.storage.local.remove('background_sync_pending') + } + } catch (error) { + console.error('BackgroundSyncService: Failed to load pending syncs:', error) + } + } + + /** + * Force immediate sync of all pending items + */ + async forceSync(): Promise { + if (!this.isEnabled || !this.stats.isOnline) { + throw new Error('Sync not available') + } + + console.log('BackgroundSyncService: Forcing immediate sync') + await this.processQueue() + } + + /** + * Get current sync statistics + */ + getStats(): SyncStats { + return { ...this.stats } + } + + /** + * Clear all pending syncs + */ + clearQueue(): void { + this.syncQueue = [] + this.stats.pendingCount = 0 + console.log('BackgroundSyncService: Cleared sync queue') + } + + /** + * Get pending sync count + */ + getPendingCount(): number { + return this.syncQueue.length + } + + /** + * Check if service is actively syncing + */ + isSyncing(): boolean { + return this.isProcessing + } +} + +// Global singleton instance +export const backgroundSyncService = new BackgroundSyncService() \ No newline at end of file diff --git a/src/service/sync/hybrid-sync-manager.ts b/src/service/sync/hybrid-sync-manager.ts new file mode 100644 index 000000000..850161083 --- /dev/null +++ b/src/service/sync/hybrid-sync-manager.ts @@ -0,0 +1,388 @@ +/** + * Copyright (c) 2025 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import processor from "@service/backup/processor" +import metaService from "@service/meta-service" +import optionHolder from "@service/components/option-holder" + +type SyncEvent = { + type: 'data-updated' | 'client-connected' | 'sync-status' + data: any + timestamp: string +} + +type WebSocketConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error' + +/** + * Hybrid sync manager that combines WebSocket push notifications with polling fallback + */ +export class HybridSyncManager { + private websocket: WebSocket | null = null + private pollInterval: NodeJS.Timeout | null = null + private reconnectTimeout: NodeJS.Timeout | null = null + private backoffMultiplier = 1 + private readonly maxBackoffMs = 60000 // 1 minute max backoff + private readonly baseBackoffMs = 1000 // 1 second base backoff + private readonly pollIntervalMs = 30000 // 30 seconds polling fallback + private connectionState: WebSocketConnectionState = 'disconnected' + private listeners: Map = new Map() + private isEnabled = false + private clientId: string | null = null + + constructor() { + this.init() + } + + private async init(): Promise { + this.clientId = (await metaService.getCid()) || null + + // Check if AWS sync is enabled + const option = await optionHolder.get() + this.isEnabled = option.backupType === 'aws' + + if (this.isEnabled && this.clientId) { + this.startSync() + } + } + + /** + * Start the hybrid sync process + */ + async startSync(): Promise { + if (!this.isEnabled || !this.clientId) { + console.log('HybridSyncManager: Sync not enabled or no client ID') + return + } + + console.log('HybridSyncManager: Starting sync...') + + // Try WebSocket connection first + try { + await this.connectWebSocket() + } catch (error) { + console.warn('HybridSyncManager: WebSocket connection failed, falling back to polling:', error) + this.startPolling() + } + } + + /** + * Stop all sync activities + */ + stopSync(): void { + console.log('HybridSyncManager: Stopping sync...') + + this.closeWebSocket() + this.stopPolling() + this.clearReconnectTimeout() + this.connectionState = 'disconnected' + this.emit('sync-status', { connected: false, method: 'none' }) + } + + /** + * Enable/disable the sync manager + */ + async setEnabled(enabled: boolean): Promise { + this.isEnabled = enabled + + if (enabled && this.clientId) { + await this.startSync() + } else { + this.stopSync() + } + } + + /** + * Connect to WebSocket for real-time updates + */ + private async connectWebSocket(): Promise { + if (!this.clientId) { + throw new Error('No client ID available') + } + + const option = await optionHolder.get() + const websocketEndpoint = option.backupExts?.aws?.websocketEndpoint + + if (!websocketEndpoint) { + throw new Error('No WebSocket endpoint configured') + } + + this.connectionState = 'connecting' + this.emit('sync-status', { connected: false, method: 'websocket', status: 'connecting' }) + + // Build WebSocket URL with client ID + const url = new URL(websocketEndpoint) + url.searchParams.set('clientId', this.clientId) + + this.websocket = new WebSocket(url.toString()) + + this.websocket.onopen = () => { + console.log('HybridSyncManager: WebSocket connected') + this.connectionState = 'connected' + this.backoffMultiplier = 1 // Reset backoff on successful connection + this.stopPolling() // Stop polling when WebSocket works + this.emit('sync-status', { connected: true, method: 'websocket' }) + } + + this.websocket.onmessage = (event) => { + try { + const syncEvent: SyncEvent = JSON.parse(event.data) + this.handleSyncEvent(syncEvent) + } catch (error) { + console.error('HybridSyncManager: Error parsing WebSocket message:', error) + } + } + + this.websocket.onclose = (event) => { + console.log('HybridSyncManager: WebSocket closed:', event.code, event.reason) + this.connectionState = 'disconnected' + this.websocket = null + + // Fallback to polling and schedule reconnection + this.startPolling() + this.scheduleReconnect() + this.emit('sync-status', { connected: false, method: 'polling' }) + } + + this.websocket.onerror = (error) => { + console.error('HybridSyncManager: WebSocket error:', error) + this.connectionState = 'error' + this.emit('sync-status', { connected: false, method: 'error', error }) + } + + // Set a connection timeout + setTimeout(() => { + if (this.websocket && this.websocket.readyState === WebSocket.CONNECTING) { + console.warn('HybridSyncManager: WebSocket connection timeout') + this.websocket.close() + } + }, 10000) // 10 second timeout + } + + /** + * Close WebSocket connection + */ + private closeWebSocket(): void { + if (this.websocket) { + this.websocket.close() + this.websocket = null + } + } + + /** + * Start polling as fallback + */ + private startPolling(): void { + if (this.pollInterval) { + return // Already polling + } + + console.log('HybridSyncManager: Starting polling fallback') + + this.pollInterval = setInterval(async () => { + try { + await this.performPollSync() + + // Try to reconnect to WebSocket if we're polling + if (!this.websocket || this.websocket.readyState === WebSocket.CLOSED) { + if (Math.random() < 0.1) { // 10% chance to attempt reconnection on each poll + this.connectWebSocket().catch(() => { + // Ignore reconnection failures during polling + }) + } + } + } catch (error) { + console.error('HybridSyncManager: Polling error:', error) + } + }, this.pollIntervalMs) + } + + /** + * Stop polling + */ + private stopPolling(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval) + this.pollInterval = null + console.log('HybridSyncManager: Stopped polling') + } + } + + /** + * Schedule WebSocket reconnection with exponential backoff + */ + private scheduleReconnect(): void { + if (!this.isEnabled) { + return + } + + this.clearReconnectTimeout() + + const delay = Math.min(this.baseBackoffMs * this.backoffMultiplier, this.maxBackoffMs) + this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, 60) + + console.log(`HybridSyncManager: Scheduling reconnection in ${delay}ms`) + + this.reconnectTimeout = setTimeout(async () => { + if (this.isEnabled && this.clientId) { + try { + await this.connectWebSocket() + } catch (error) { + console.warn('HybridSyncManager: Reconnection failed:', error) + this.scheduleReconnect() + } + } + }, delay) + } + + /** + * Clear reconnection timeout + */ + private clearReconnectTimeout(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + } + + /** + * Perform sync check via polling + */ + private async performPollSync(): Promise { + try { + // Check for remote updates + const result = await processor.query({ + start: new Date(Date.now() - this.pollIntervalMs * 2), // Look back 1 minute + end: new Date(), + excludeLocal: true // Only get remote changes + }) + + if (result.length > 0) { + console.log(`HybridSyncManager: Poll found ${result.length} remote updates`) + this.emit('data-updated', { + rows: result, + source: 'poll', + timestamp: new Date().toISOString() + }) + } + } catch (error) { + console.error('HybridSyncManager: Poll sync error:', error) + } + } + + /** + * Handle incoming sync events + */ + private handleSyncEvent(event: SyncEvent): void { + console.log('HybridSyncManager: Received sync event:', event.type) + + switch (event.type) { + case 'data-updated': + this.emit('data-updated', event.data) + break + case 'client-connected': + this.emit('client-connected', event.data) + break + case 'sync-status': + this.emit('sync-status', event.data) + break + default: + console.warn('HybridSyncManager: Unknown event type:', event.type) + } + } + + /** + * Send a message via WebSocket + */ + sendMessage(message: any): boolean { + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + this.websocket.send(JSON.stringify(message)) + return true + } + return false + } + + /** + * Subscribe to ping to keep connection alive + */ + private startHeartbeat(): void { + setInterval(() => { + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + this.websocket.send(JSON.stringify({ + action: 'ping', + timestamp: Date.now() + })) + } + }, 30000) // Ping every 30 seconds + } + + /** + * Add event listener + */ + on(event: string, callback: Function): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, []) + } + this.listeners.get(event)!.push(callback) + } + + /** + * Remove event listener + */ + off(event: string, callback: Function): void { + const callbacks = this.listeners.get(event) + if (callbacks) { + const index = callbacks.indexOf(callback) + if (index !== -1) { + callbacks.splice(index, 1) + } + } + } + + /** + * Emit event to listeners + */ + private emit(event: string, data: any): void { + const callbacks = this.listeners.get(event) + if (callbacks) { + callbacks.forEach(callback => { + try { + callback(data) + } catch (error) { + console.error(`HybridSyncManager: Error in event callback for ${event}:`, error) + } + }) + } + } + + /** + * Get current connection status + */ + getStatus(): { connected: boolean; method: string; state: WebSocketConnectionState } { + return { + connected: this.connectionState === 'connected', + method: this.websocket ? 'websocket' : (this.pollInterval ? 'polling' : 'none'), + state: this.connectionState + } + } + + /** + * Force a sync check + */ + async forcSync(): Promise { + await this.performPollSync() + } + + /** + * Check if real-time sync is available + */ + isRealTimeAvailable(): boolean { + return this.websocket !== null && this.websocket.readyState === WebSocket.OPEN + } +} + +// Global singleton instance +export const hybridSyncManager = new HybridSyncManager() \ No newline at end of file diff --git a/src/service/sync/sync-integration.ts b/src/service/sync/sync-integration.ts new file mode 100644 index 000000000..5b4cd8cef --- /dev/null +++ b/src/service/sync/sync-integration.ts @@ -0,0 +1,189 @@ +/** + * Copyright (c) 2025 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { backgroundSyncService } from "./background-sync-service" +import { hybridSyncManager } from "./hybrid-sync-manager" +import statDatabase from "@db/stat-database" +import optionHolder from "@service/components/option-holder" + +/** + * Integration layer that automatically syncs database changes + */ +class SyncIntegration { + private isInitialized = false + private isAwsSyncEnabled = false + + async init(): Promise { + if (this.isInitialized) { + return + } + + // Check if AWS sync is enabled + const option = await optionHolder.get() + this.isAwsSyncEnabled = option.backupType === 'aws' + + if (this.isAwsSyncEnabled) { + console.log('SyncIntegration: Initializing AWS real-time sync') + + // Start sync services + await hybridSyncManager.setEnabled(true) + await backgroundSyncService.setEnabled(true) + + // Hook into database operations + this.wrapDatabaseMethods() + } + + this.isInitialized = true + } + + /** + * Wrap database methods to automatically queue changes for sync + */ + private wrapDatabaseMethods(): void { + // Store original methods + const originalAccumulate = statDatabase.accumulate.bind(statDatabase) + const originalAccumulateBatch = statDatabase.accumulateBatch.bind(statDatabase) + const originalForceUpdate = statDatabase.forceUpdate.bind(statDatabase) + + // Wrap accumulate method + statDatabase.accumulate = async (host: string, date: Date | string, item: timer.core.Result): Promise => { + const result = await originalAccumulate(host, date, item) + + // Queue for sync if AWS is enabled + if (this.isAwsSyncEnabled) { + this.queueRowForSync(host, date, result) + } + + return result + } + + // Wrap accumulateBatch method + statDatabase.accumulateBatch = async (data: Record, date: Date | string): Promise> => { + const results = await originalAccumulateBatch(data, date) + + // Queue all rows for sync if AWS is enabled + if (this.isAwsSyncEnabled) { + Object.entries(results).forEach(([host, result]) => { + this.queueRowForSync(host, date, result) + }) + } + + return results + } + + // Wrap forceUpdate method + statDatabase.forceUpdate = async (row: timer.core.Row): Promise => { + await originalForceUpdate(row) + + // Queue for sync if AWS is enabled + if (this.isAwsSyncEnabled) { + this.queueRowForSync(row.host, row.date, { + focus: row.focus || 0, + time: row.time || 0, + run: row.run + }) + } + } + + console.log('SyncIntegration: Database methods wrapped for automatic sync') + } + + /** + * Queue a single row for background sync + */ + private queueRowForSync(host: string, date: Date | string, result: timer.core.Result): void { + const dateStr = typeof date === 'string' ? date : this.formatDate(date) + + const row: timer.core.Row = { + host, + date: dateStr, + focus: result.focus || 0, + time: result.time || 0, + run: result.run + } + + backgroundSyncService.queueForSync([row]) + } + + /** + * Format date as YYYYMMDD + */ + private formatDate(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}${month}${day}` + } + + /** + * Enable/disable sync integration + */ + async setEnabled(enabled: boolean): Promise { + this.isAwsSyncEnabled = enabled + + if (enabled) { + await this.init() + } else { + await hybridSyncManager.setEnabled(false) + await backgroundSyncService.setEnabled(false) + } + } + + /** + * Check if sync integration is enabled + */ + isEnabled(): boolean { + return this.isAwsSyncEnabled + } + + /** + * Get sync status from both services + */ + getStatus(): { + hybrid: ReturnType + background: ReturnType + } { + return { + hybrid: hybridSyncManager.getStatus(), + background: backgroundSyncService.getStats() + } + } + + /** + * Force immediate sync + */ + async forceSync(): Promise { + if (!this.isAwsSyncEnabled) { + throw new Error('AWS sync not enabled') + } + + await Promise.all([ + hybridSyncManager.forcSync(), + backgroundSyncService.forceSync() + ]) + } + + /** + * Listen for remote data updates + */ + onRemoteUpdate(callback: (data: any) => void): void { + hybridSyncManager.on('data-updated', callback) + } + + /** + * Listen for sync status changes + */ + onSyncStatusChange(callback: (status: any) => void): void { + hybridSyncManager.on('sync-status', callback) + } +} + +// Global singleton instance +export const syncIntegration = new SyncIntegration() + +// Auto-initialize when imported +syncIntegration.init().catch(console.error) \ No newline at end of file diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts index 28ea41ce7..2335df5dd 100644 --- a/types/timer/backup.d.ts +++ b/types/timer/backup.d.ts @@ -72,6 +72,9 @@ declare namespace timer.backup { | 'obsidian_local_rest_api' // @since 2.4.5 | 'web_dav' + // AWS real-time sync + // @since 3.7.0 + | 'aws' type AuthType = | 'token' @@ -86,6 +89,14 @@ declare namespace timer.backup { bucket?: string endpoint?: string dirPath?: string + /** + * AWS configuration + * @since 3.7.0 + */ + region?: string + apiEndpoint?: string + websocketEndpoint?: string + apiKey?: string } /** diff --git a/types/timer/core.d.ts b/types/timer/core.d.ts index f2dadc765..3b9be5923 100644 --- a/types/timer/core.d.ts +++ b/types/timer/core.d.ts @@ -37,4 +37,15 @@ declare namespace timer.core { } type Row = RowKey & Result + + /** + * Enhanced row for real-time sync with conflict resolution + * @since 3.7.0 + */ + type EnhancedRow = Row & { + sessionId?: string + lastModified?: number + version?: number + batchId?: string + } } \ No newline at end of file From bd233f4b70eb9aba3b59830e4568d55c5e32aece Mon Sep 17 00:00:00 2001 From: 0HugoHu <0@hugohu.top> Date: Thu, 11 Sep 2025 03:25:32 -0700 Subject: [PATCH 4/9] fix: wrong download API and frontend merging logic --- cdk/lambda/notify/notify.js | 13 +- cdk/lambda/sync/sync.js | 152 ++++++++++++-- cdk/lib/web-time-tracker-stack.ts | 4 + src/api/aws.ts | 15 +- src/api/chrome/runtime.ts | 28 ++- src/background/browser-action-manager.ts | 3 +- .../Option/components/BackupOption/Footer.tsx | 190 +++++++++++++++++- src/service/sync/hybrid-sync-manager.ts | 7 +- src/service/sync/sync-integration.ts | 32 ++- 9 files changed, 411 insertions(+), 33 deletions(-) diff --git a/cdk/lambda/notify/notify.js b/cdk/lambda/notify/notify.js index e6e37b1b9..eaf242cdd 100644 --- a/cdk/lambda/notify/notify.js +++ b/cdk/lambda/notify/notify.js @@ -1,4 +1,4 @@ -const { DynamoDBDocumentClient, ScanCommand } = require('@aws-sdk/lib-dynamodb'); +const { DynamoDBDocumentClient, ScanCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb'); const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi'); @@ -96,12 +96,15 @@ async function sendNotification(connection, notification) { const { connectionId } = connection; // Extract API Gateway endpoint from environment or connection info - // In practice, you'd store this when the connection is established - const endpoint = process.env.WEBSOCKET_ENDPOINT || - `${connectionId.split('/')[0]}.execute-api.${process.env.AWS_REGION}.amazonaws.com/prod`; + // The WebSocket endpoint should be provided by the CDK stack + const endpoint = process.env.WEBSOCKET_ENDPOINT; + + if (!endpoint) { + throw new Error('WEBSOCKET_ENDPOINT environment variable not set'); + } const apiGatewayClient = new ApiGatewayManagementApiClient({ - endpoint: `https://${endpoint}` + endpoint: endpoint.replace('wss://', 'https://').replace('ws://', 'http://') }); try { diff --git a/cdk/lambda/sync/sync.js b/cdk/lambda/sync/sync.js index 0ce912911..15b7fd952 100644 --- a/cdk/lambda/sync/sync.js +++ b/cdk/lambda/sync/sync.js @@ -391,15 +391,19 @@ async function handleDownload(event) { const endDate = queryStringParameters?.endDate; const targetClientId = queryStringParameters?.clientId; - if (!clientId) { - return { - statusCode: 400, - body: JSON.stringify({ error: 'Client ID required' }) - }; - } - try { - const data = await downloadData(targetClientId || clientId, startDate, endDate); + let data; + + if (targetClientId) { + // Download specific client's data + data = await downloadData(targetClientId, startDate, endDate); + } else if (clientId) { + // Download requesting client's data + data = await downloadData(clientId, startDate, endDate); + } else { + // Download ALL data (for private use) + data = await downloadAllData(startDate, endDate); + } return { statusCode: 200, @@ -419,7 +423,7 @@ async function handleDownload(event) { } /** - * Download data for date range + * Download data for date range (specific client) */ async function downloadData(clientId, startDate, endDate) { const result = []; @@ -441,6 +445,32 @@ async function downloadData(clientId, startDate, endDate) { return uniqueData.sort((a, b) => a.date.localeCompare(b.date)); } +/** + * Download ALL data from ALL clients (for private use) + */ +async function downloadAllData(startDate, endDate) { + const result = []; + const start = startDate ? new Date(startDate) : new Date('2020-01-01'); + const end = endDate ? new Date(endDate) : new Date(); + + console.log(`Downloading ALL data from ${start.toISOString()} to ${end.toISOString()}`); + + // Download from hot data (DynamoDB) - scan all records + const hotData = await downloadAllHotData(start, end); + result.push(...hotData); + + // Download from cold data (S3) if needed - scan all S3 objects + if (start < new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)) { + const coldData = await downloadAllColdData(start, end); + result.push(...coldData); + } + + // Deduplicate and sort + const uniqueData = deduplicateRows(result); + console.log(`Downloaded ${uniqueData.length} total records from all clients`); + return uniqueData.sort((a, b) => a.date.localeCompare(b.date)); +} + /** * Download hot data from DynamoDB */ @@ -482,7 +512,7 @@ async function downloadHotData(clientId, startDate, endDate) { } /** - * Download cold data from S3 + * Download cold data from S3 (specific client) */ async function downloadColdData(clientId, startDate, endDate) { const result = []; @@ -517,7 +547,8 @@ async function downloadColdData(clientId, startDate, endDate) { host: record.host, date: record.date, focus: record.focus, - time: record.time + time: record.time, + clientId: clientId }); } }); @@ -531,6 +562,99 @@ async function downloadColdData(clientId, startDate, endDate) { return result; } +/** + * Download ALL hot data from DynamoDB (all clients) + */ +async function downloadAllHotData(startDate, endDate) { + const { ScanCommand } = require('@aws-sdk/lib-dynamodb'); + const result = []; + const startDateStr = formatDate(startDate); + const endDateStr = formatDate(endDate); + + let lastEvaluatedKey; + do { + const response = await dynamoClient.send(new ScanCommand({ + TableName: HOT_DATA_TABLE, + FilterExpression: 'SK = :sk AND #date BETWEEN :startDate AND :endDate AND lastModified BETWEEN :startTime AND :endTime', + ExpressionAttributeNames: { + '#date': 'date' + }, + ExpressionAttributeValues: { + ':sk': 'data', + ':startDate': startDateStr, + ':endDate': endDateStr, + ':startTime': startDate.getTime(), + ':endTime': endDate.getTime() + }, + ExclusiveStartKey: lastEvaluatedKey + })); + + result.push(...response.Items); + lastEvaluatedKey = response.LastEvaluatedKey; + } while (lastEvaluatedKey); + + return result.map(item => ({ + host: item.host, + date: item.date, + focus: item.focus, + time: item.time, + clientId: item.clientId + })); +} + +/** + * Download ALL cold data from S3 (all clients) + */ +async function downloadAllColdData(startDate, endDate) { + const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); + const result = []; + + // List all S3 objects + let continuationToken; + do { + const listResponse = await s3Client.send(new ListObjectsV2Command({ + Bucket: DATA_BUCKET, + ContinuationToken: continuationToken + })); + + const promises = listResponse.Contents?.map(async (object) => { + try { + const response = await s3Client.send(new GetObjectCommand({ + Bucket: DATA_BUCKET, + Key: object.Key + })); + + const compressed = await streamToString(response.Body); + const monthlyData = decompress(compressed); + + // Extract clientId from S3 key or metadata + const clientId = object.Key.split('/')[0]; // Assuming format: clientId/YYYYMM.gz + + // Filter by date range + Object.values(monthlyData).forEach(record => { + const recordDate = new Date(record.date.slice(0, 4) + '-' + record.date.slice(4, 6) + '-' + record.date.slice(6, 8)); + if (recordDate >= startDate && recordDate <= endDate) { + result.push({ + host: record.host, + date: record.date, + focus: record.focus, + time: record.time, + clientId: clientId + }); + } + }); + } catch (error) { + console.warn(`Error downloading ${object.Key}:`, error); + } + }) || []; + + await Promise.all(promises); + continuationToken = listResponse.NextContinuationToken; + } while (continuationToken); + + return result; +} + /** * Handle data update request */ @@ -566,16 +690,16 @@ async function sendUpdateNotification(clientIds, updateData) { } /** - * Deduplicate rows by host+date, keeping latest + * Deduplicate rows by host+date+clientId, keeping latest */ function deduplicateRows(rows) { const map = new Map(); rows.forEach(row => { - const key = `${row.host}#${row.date}`; + const key = `${row.clientId || 'unknown'}#${row.host}#${row.date}`; const existing = map.get(key); - if (!existing || row.lastModified > existing.lastModified) { + if (!existing || (row.lastModified && row.lastModified > existing.lastModified)) { map.set(key, row); } }); diff --git a/cdk/lib/web-time-tracker-stack.ts b/cdk/lib/web-time-tracker-stack.ts index f1f4b8402..0a706681a 100644 --- a/cdk/lib/web-time-tracker-stack.ts +++ b/cdk/lib/web-time-tracker-stack.ts @@ -197,6 +197,7 @@ export class WebTimeTrackerStack extends cdk.Stack { layers: [sharedLayer], environment: { CONNECTIONS_TABLE: connectionsTable.tableName, + // WebSocket endpoint will be added after WebSocket API creation }, timeout: cdk.Duration.seconds(30), memorySize: 256, @@ -286,6 +287,9 @@ export class WebTimeTrackerStack extends cdk.Stack { ], })); + // Add WebSocket endpoint to notify lambda environment + notifyLambda.addEnvironment('WEBSOCKET_ENDPOINT', websocketStage.url); + // EventBridge rules const dataChangeRule = new events.Rule(this, 'DataChangeRule', { eventBus, diff --git a/src/api/aws.ts b/src/api/aws.ts index 6fa3aa9e6..067507ab1 100644 --- a/src/api/aws.ts +++ b/src/api/aws.ts @@ -87,7 +87,8 @@ function getHeaders(config: AwsConfig, clientId?: string): Record { - const url = `${config.apiEndpoint}/sync` + const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint + const url = `${baseUrl}/sync` const headers = getHeaders(config, clientId) const body: SyncRequest = { @@ -115,7 +116,8 @@ export async function downloadData(config: AwsConfig, clientId: string, request: if (request.endDate) params.set('endDate', request.endDate) if (request.clientId) params.set('clientId', request.clientId) - const url = `${config.apiEndpoint}/data?${params.toString()}` + const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint + const url = `${baseUrl}/data?${params.toString()}` const headers = getHeaders(config, clientId) const response = await fetchGet(url, { headers }) @@ -132,7 +134,8 @@ export async function downloadData(config: AwsConfig, clientId: string, request: * List all clients */ export async function listClients(config: AwsConfig, clientId: string): Promise { - const url = `${config.apiEndpoint}/sync` + const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint + const url = `${baseUrl}/sync` const headers = getHeaders(config, clientId) const response = await fetchGet(url, { headers }) @@ -150,7 +153,8 @@ export async function listClients(config: AwsConfig, clientId: string): Promise< */ export async function testConnection(config: AwsConfig, clientId: string): Promise { try { - const url = `${config.apiEndpoint}/sync` + const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint + const url = `${baseUrl}/sync` const headers = getHeaders(config, clientId) const response = await fetchGet(url, { headers }) @@ -170,7 +174,8 @@ export async function testConnection(config: AwsConfig, clientId: string): Promi * Update specific data records */ export async function updateData(config: AwsConfig, clientId: string, rows: timer.core.EnhancedRow[]): Promise { - const url = `${config.apiEndpoint}/data` + const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint + const url = `${baseUrl}/data` const headers = getHeaders(config, clientId) const body = { diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts index f4d8c66e8..534754a6f 100644 --- a/src/api/chrome/runtime.ts +++ b/src/api/chrome/runtime.ts @@ -16,14 +16,38 @@ export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: const request: timer.mq.Request = { code, data } return new Promise((resolve, reject) => { try { + // Check if extension context is still valid + if (!chrome.runtime?.id) { + console.warn('Extension context invalidated, ignoring message:', code) + resolve(undefined) + return + } + chrome.runtime.sendMessage(request, (response: timer.mq.Response) => { - handleError('sendMsg2Runtime') + const lastError = handleError('sendMsg2Runtime') + + // Check if context was invalidated during the call + if (lastError?.includes('Extension context invalidated') || lastError?.includes('message port closed')) { + console.warn('Extension context invalidated during message send:', code) + resolve(undefined) + return + } + const resCode = response?.code resCode === 'fail' && reject(new Error(response?.msg || 'Unknown error')) resCode === 'success' && resolve(response.data) }) } catch (e) { - reject('Failed to send message: ' + (e as Error)?.message || 'Unknown error') + const errorMsg = (e as Error)?.message || 'Unknown error' + + // Handle context invalidation gracefully + if (errorMsg.includes('Extension context invalidated') || errorMsg.includes('message port closed')) { + console.warn('Extension context invalidated, ignoring message:', code) + resolve(undefined) + return + } + + reject('Failed to send message: ' + errorMsg) } }) } diff --git a/src/background/browser-action-manager.ts b/src/background/browser-action-manager.ts index 12af14126..fb4af1ce3 100644 --- a/src/background/browser-action-manager.ts +++ b/src/background/browser-action-manager.ts @@ -86,9 +86,8 @@ const changeLogProps: ChromeContextMenuCreateProps = { } function initBrowserAction() { - // Create sidebar item for Firefox + // Create sidebar item for Firefox or all functions for other browsers createContextMenu(IS_FIREFOX ? sidebarProps : allFunctionProps) - createContextMenu(allFunctionProps) createContextMenu(optionPageProps) createContextMenu(repoPageProps) createContextMenu(feedbackPageProps) diff --git a/src/pages/app/components/Option/components/BackupOption/Footer.tsx b/src/pages/app/components/Option/components/BackupOption/Footer.tsx index 9171a70b7..23bb28e91 100644 --- a/src/pages/app/components/Option/components/BackupOption/Footer.tsx +++ b/src/pages/app/components/Option/components/BackupOption/Footer.tsx @@ -6,12 +6,16 @@ */ import { t } from "@app/locale" -import { Operation, UploadFilled } from "@element-plus/icons-vue" +import { Download as DownloadIcon, Operation, UploadFilled } from "@element-plus/icons-vue" import { useManualRequest, useRequest, useState } from "@hooks" import Flex from "@pages/components/Flex" import processor from "@service/backup/processor" import metaService from "@service/meta-service" import { formatTime } from "@util/time" +import { syncIntegration } from "@service/sync/sync-integration" +import { downloadData } from "@api/aws" +import optionHolder from "@service/components/option-holder" +import statDatabase from "@db/stat-database" import { ElButton, ElDivider, ElLoading, ElMessage, ElText } from "element-plus" import { defineComponent, type StyleValue } from "vue" import Clear from "./Clear" @@ -31,6 +35,174 @@ async function handleTest() { } } +async function handleManualFetch() { + const loading = ElLoading.service({ text: "Fetching cloud data..." }) + try { + const option = await optionHolder.get() + const cid = await metaService.getCid() + + console.log('Client ID for download:', cid || 'No client ID (will download all data)') + + const awsAuth = option.backupAuths?.aws + const awsExt = option.backupExts?.aws + + if (option.backupType !== 'aws' || !awsAuth || !awsExt?.apiEndpoint) { + ElMessage.error("AWS sync not properly configured") + return + } + + console.log('Manual fetch using client ID:', cid) + console.log('AWS config:', { + apiEndpoint: awsExt.apiEndpoint, + region: awsExt.region || 'us-east-1', + hasApiKey: !!awsAuth + }) + + // Validate that we have all required parameters + if (!awsAuth) { + ElMessage.error("AWS API key not configured") + return + } + if (!awsExt.apiEndpoint) { + ElMessage.error("AWS API endpoint not configured") + return + } + + const awsConfig = { + apiEndpoint: awsExt.apiEndpoint, + websocketEndpoint: awsExt.websocketEndpoint || '', + apiKey: awsAuth, + region: awsExt.region || 'us-east-1' + } + + // For private use, fetch ALL data (no date restrictions) + console.log('Fetching ALL data from cloud (no date restrictions)') + + // For private use, download all data without client ID filtering + // Pass cid for authentication header, but don't pass clientId in request to get ALL data + const response = await downloadData(awsConfig, cid || 'anonymous', { + // No startDate, endDate, or clientId to get ALL data + }) + + if (response.success) { + console.log('Fetched cloud data:', response.data) + + // Merge downloaded data into local database + if (response.data && response.data.length > 0) { + const mergeResults = await mergeCloudDataToLocal(response.data) + ElMessage.success(`Successfully merged ${mergeResults.merged} records (${mergeResults.updated} updated, ${mergeResults.added} new)`) + console.log('Merge results:', mergeResults) + } else { + ElMessage.info('No data to merge - cloud storage is empty') + } + } else { + ElMessage.error("Failed to fetch cloud data") + } + } catch (error) { + console.error('Manual fetch error:', error) + ElMessage.error(error instanceof Error ? error.message : 'Unknown error occurred') + } finally { + loading.close() + } +} + +/** + * Merge downloaded cloud data into local database + */ +async function mergeCloudDataToLocal(cloudData: timer.core.Row[]): Promise<{ + merged: number + added: number + updated: number + skipped: number +}> { + let added = 0 + let updated = 0 + let skipped = 0 + + console.log(`Starting merge of ${cloudData.length} cloud records`) + + for (const cloudRow of cloudData) { + try { + // Get existing local data for this host+date + const existingRows = await statDatabase.select({ + keys: cloudRow.host, + date: new Date( + parseInt(cloudRow.date.substring(0, 4)), + parseInt(cloudRow.date.substring(4, 6)) - 1, + parseInt(cloudRow.date.substring(6, 8)) + ) + }) + const existingRow = existingRows.length > 0 ? existingRows[0] : null + + if (!existingRow) { + // No local data exists - add new record + await statDatabase.forceUpdate({ + host: cloudRow.host, + date: cloudRow.date, + focus: cloudRow.focus || 0, + time: cloudRow.time || 0, + run: cloudRow.run || 0 + }) + added++ + console.log(`Added new record: ${cloudRow.host} ${cloudRow.date}`) + } else { + // Local data exists - merge with conflict resolution + const shouldUpdate = await resolveConflict(existingRow, cloudRow) + + if (shouldUpdate) { + // Merge the data (take maximum values approach) + const mergedRow: timer.core.Row = { + host: cloudRow.host, + date: cloudRow.date, + focus: Math.max(existingRow.focus || 0, cloudRow.focus || 0), + time: Math.max(existingRow.time || 0, cloudRow.time || 0), + run: Math.max(existingRow.run || 0, cloudRow.run || 0) + } + + await statDatabase.forceUpdate(mergedRow) + updated++ + console.log(`Updated record: ${cloudRow.host} ${cloudRow.date} (focus: ${existingRow.focus || 0} -> ${mergedRow.focus}, time: ${existingRow.time || 0} -> ${mergedRow.time}, run: ${existingRow.run || 0} -> ${mergedRow.run})`) + } else { + skipped++ + console.log(`Skipped record: ${cloudRow.host} ${cloudRow.date} (local data is newer/better)`) + } + } + } catch (error) { + console.error(`Error merging record ${cloudRow.host} ${cloudRow.date}:`, error) + skipped++ + } + } + + const merged = added + updated + console.log(`Merge completed: ${merged} total merged (${added} added, ${updated} updated, ${skipped} skipped)`) + + return { merged, added, updated, skipped } +} + +/** + * Resolve conflicts between local and cloud data + */ +async function resolveConflict(localRow: timer.core.Row, cloudRow: timer.core.Row): Promise { + // Simple conflict resolution strategy: + // 1. If cloud has more data (focus/time/run), always update + // 2. If local has more data, keep local + // 3. If equal, skip (no update needed) + + const localTotal = (localRow.focus || 0) + (localRow.time || 0) + (localRow.run || 0) + const cloudTotal = (cloudRow.focus || 0) + (cloudRow.time || 0) + (cloudRow.run || 0) + + if (cloudTotal > localTotal) { + return true // Cloud has more data, update local + } + + if (cloudTotal < localTotal) { + return false // Local has more data, keep local + } + + // Equal totals - no update needed + return false +} + const TIME_FORMAT = t(msg => msg.calendar.timeFormat) const _default = defineComponent<{ type: timer.backup.Type }>(props => { @@ -41,7 +213,16 @@ const _default = defineComponent<{ type: timer.backup.Type }>(props => { return type && (await metaService.getLastBackUp(type))?.ts }, { deps: () => props.type, onSuccess: setLastTime }) - const { refresh: handleBackup } = useManualRequest(() => processor.syncData(), { + const { refresh: handleBackup } = useManualRequest(async () => { + // For AWS real-time sync, use the new sync integration + if (props.type === 'aws' && syncIntegration.isEnabled()) { + await syncIntegration.forceSync() + return { success: true, data: Date.now() } + } else { + // For other backup types, use the traditional processor + return processor.syncData() + } + }, { loadingText: "Doing backup....", onSuccess: ({ success, data, errorMsg }) => { if (success) { @@ -64,6 +245,11 @@ const _default = defineComponent<{ type: timer.backup.Type }>(props => { {t(msg => msg.option.backup.operation)} + {props.type === 'aws' && ( + + Download All Data + + )} {t( msg => msg.option.backup.lastTimeTip, diff --git a/src/service/sync/hybrid-sync-manager.ts b/src/service/sync/hybrid-sync-manager.ts index 850161083..d73d582fa 100644 --- a/src/service/sync/hybrid-sync-manager.ts +++ b/src/service/sync/hybrid-sync-manager.ts @@ -114,7 +114,12 @@ export class HybridSyncManager { this.emit('sync-status', { connected: false, method: 'websocket', status: 'connecting' }) // Build WebSocket URL with client ID - const url = new URL(websocketEndpoint) + // Handle different URL formats + const wsUrl = websocketEndpoint.startsWith('wss://') + ? websocketEndpoint + : `wss://${websocketEndpoint}` + + const url = new URL(wsUrl) url.searchParams.set('clientId', this.clientId) this.websocket = new WebSocket(url.toString()) diff --git a/src/service/sync/sync-integration.ts b/src/service/sync/sync-integration.ts index 5b4cd8cef..e8c1d7965 100644 --- a/src/service/sync/sync-integration.ts +++ b/src/service/sync/sync-integration.ts @@ -35,6 +35,8 @@ class SyncIntegration { // Hook into database operations this.wrapDatabaseMethods() + + console.log('SyncIntegration: AWS real-time sync initialized successfully') } this.isInitialized = true @@ -185,5 +187,31 @@ class SyncIntegration { // Global singleton instance export const syncIntegration = new SyncIntegration() -// Auto-initialize when imported -syncIntegration.init().catch(console.error) \ No newline at end of file +// Initialize when extension is ready +let isInitializing = false +const initWhenReady = async () => { + if (isInitializing) return + + try { + isInitializing = true + + // Wait for extension context to be stable + if (!chrome?.runtime?.id) { + isInitializing = false + setTimeout(initWhenReady, 1000) + return + } + + await syncIntegration.init() + console.log('SyncIntegration initialized successfully') + isInitializing = false + } catch (error) { + console.error('Failed to initialize SyncIntegration:', error) + isInitializing = false + // Retry after delay if initialization fails + setTimeout(initWhenReady, 3000) + } +} + +// Start initialization +initWhenReady() \ No newline at end of file From 0510f403154bf3f6d74f81e388fec2c92c6d9c4d Mon Sep 17 00:00:00 2001 From: 0HugoHu <0@hugohu.top> Date: Sat, 13 Sep 2025 22:27:57 -0700 Subject: [PATCH 5/9] fix: remove real-time background sync --- cdk/lambda/sync/sync.js | 1 + cdk/lib/web-time-tracker-stack.d.ts | 7 + cdk/lib/web-time-tracker-stack.js | 334 ++++++++++++++++++ src/api/aws.ts | 110 +++--- src/api/chrome/runtime.ts | 24 +- src/content-script/tracker/run-time.ts | 5 + .../Option/components/BackupOption/Footer.tsx | 11 +- src/service/backup/aws/coordinator.ts | 54 +-- src/service/sync/background-sync-service.ts | 106 ++---- src/service/sync/hybrid-sync-manager.ts | 60 +++- src/service/sync/sync-integration.ts | 185 +--------- 11 files changed, 567 insertions(+), 330 deletions(-) create mode 100644 cdk/lib/web-time-tracker-stack.d.ts create mode 100644 cdk/lib/web-time-tracker-stack.js diff --git a/cdk/lambda/sync/sync.js b/cdk/lambda/sync/sync.js index 15b7fd952..19774cb99 100644 --- a/cdk/lambda/sync/sync.js +++ b/cdk/lambda/sync/sync.js @@ -23,6 +23,7 @@ const EVENT_BUS_NAME = process.env.EVENT_BUS_NAME; /** * Main handler for sync operations + * Updated: 2025-01-15 with download all data support */ exports.handler = async (event) => { console.log('Received event:', JSON.stringify(event, null, 2)); diff --git a/cdk/lib/web-time-tracker-stack.d.ts b/cdk/lib/web-time-tracker-stack.d.ts new file mode 100644 index 000000000..58ec86ce8 --- /dev/null +++ b/cdk/lib/web-time-tracker-stack.d.ts @@ -0,0 +1,7 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +export declare class WebTimeTrackerStack extends cdk.Stack { + readonly apiEndpoint: string; + readonly websocketEndpoint: string; + constructor(scope: Construct, id: string, props?: cdk.StackProps); +} diff --git a/cdk/lib/web-time-tracker-stack.js b/cdk/lib/web-time-tracker-stack.js new file mode 100644 index 000000000..13dd8be51 --- /dev/null +++ b/cdk/lib/web-time-tracker-stack.js @@ -0,0 +1,334 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WebTimeTrackerStack = void 0; +const cdk = require("aws-cdk-lib"); +const dynamodb = require("aws-cdk-lib/aws-dynamodb"); +const s3 = require("aws-cdk-lib/aws-s3"); +const lambda = require("aws-cdk-lib/aws-lambda"); +const apigateway = require("aws-cdk-lib/aws-apigateway"); +const events = require("aws-cdk-lib/aws-events"); +const targets = require("aws-cdk-lib/aws-events-targets"); +const iam = require("aws-cdk-lib/aws-iam"); +const apigatewayv2 = require("aws-cdk-lib/aws-apigatewayv2"); +const integrations = require("aws-cdk-lib/aws-apigatewayv2-integrations"); +const logs = require("aws-cdk-lib/aws-logs"); +class WebTimeTrackerStack extends cdk.Stack { + apiEndpoint; + websocketEndpoint; + constructor(scope, id, props) { + super(scope, id, props); + // S3 Bucket for cold storage + const dataBucket = new s3.Bucket(this, 'WebTimeTrackerDataBucket', { + bucketName: `web-time-tracker-data-${this.account}-${this.region}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, // Change to RETAIN for production + autoDeleteObjects: true, // Remove for production + versioned: true, + lifecycleRules: [{ + id: 'ArchiveOldVersions', + enabled: true, + noncurrentVersionTransitions: [{ + storageClass: s3.StorageClass.GLACIER, + transitionAfter: cdk.Duration.days(30), + }], + }], + cors: [{ + allowedHeaders: ['*'], + allowedMethods: [ + s3.HttpMethods.GET, + s3.HttpMethods.PUT, + s3.HttpMethods.POST, + s3.HttpMethods.DELETE, + ], + allowedOrigins: ['*'], // Restrict in production + maxAge: 3000, + }], + }); + // DynamoDB Table for hot data (last 7 days) + const hotDataTable = new dynamodb.Table(this, 'WebTimeTrackerHotData', { + tableName: 'WebTimeTrackerHotData', + partitionKey: { + name: 'PK', // {clientId}#{host}#{date} + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'SK', // data | metadata + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, // Change to RETAIN for production + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: true, + }, + stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + timeToLiveAttribute: 'ttl', + }); + // Global Secondary Index for querying by client + hotDataTable.addGlobalSecondaryIndex({ + indexName: 'ClientIndex', + partitionKey: { + name: 'clientId', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'lastModified', + type: dynamodb.AttributeType.NUMBER, + }, + }); + // Global Secondary Index for querying by date range + hotDataTable.addGlobalSecondaryIndex({ + indexName: 'DateIndex', + partitionKey: { + name: 'date', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'lastModified', + type: dynamodb.AttributeType.NUMBER, + }, + }); + // DynamoDB Table for client connections (WebSocket) + const connectionsTable = new dynamodb.Table(this, 'WebTimeTrackerConnections', { + tableName: 'WebTimeTrackerConnections', + partitionKey: { + name: 'connectionId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + timeToLiveAttribute: 'ttl', + }); + // Lambda Layer for shared dependencies + const sharedLayer = new lambda.LayerVersion(this, 'WebTimeTrackerSharedLayer', { + layerVersionName: 'web-time-tracker-shared', + code: lambda.Code.fromAsset('lambda-layers/shared'), + compatibleRuntimes: [lambda.Runtime.NODEJS_20_X], + description: 'Shared dependencies for Web Time Tracker lambdas', + }); + // EventBridge custom bus + const eventBus = new events.EventBus(this, 'WebTimeTrackerEventBus', { + eventBusName: 'WebTimeTrackerEvents', + }); + // Log groups for Lambda functions + const syncLogGroup = new logs.LogGroup(this, 'SyncFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-SyncFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + const archiveLogGroup = new logs.LogGroup(this, 'ArchiveFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-ArchiveFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + const websocketLogGroup = new logs.LogGroup(this, 'WebSocketFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-WebSocketFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + const notifyLogGroup = new logs.LogGroup(this, 'NotifyFunctionLogGroup', { + logGroupName: '/aws/lambda/WebTimeTracker-NotifyFunction', + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + // Lambda functions + const syncLambda = new lambda.Function(this, 'SyncFunction', { + functionName: 'WebTimeTracker-SyncFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'sync.handler', + code: lambda.Code.fromAsset('lambda/sync'), + layers: [sharedLayer], + environment: { + HOT_DATA_TABLE: hotDataTable.tableName, + DATA_BUCKET: dataBucket.bucketName, + EVENT_BUS_NAME: eventBus.eventBusName, + CONNECTIONS_TABLE: connectionsTable.tableName, + }, + timeout: cdk.Duration.seconds(30), + memorySize: 512, + logGroup: syncLogGroup, + }); + const archiveLambda = new lambda.Function(this, 'ArchiveFunction', { + functionName: 'WebTimeTracker-ArchiveFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'archive.handler', + code: lambda.Code.fromAsset('lambda/archive'), + layers: [sharedLayer], + environment: { + HOT_DATA_TABLE: hotDataTable.tableName, + DATA_BUCKET: dataBucket.bucketName, + }, + timeout: cdk.Duration.minutes(15), + memorySize: 1024, + logGroup: archiveLogGroup, + }); + const websocketLambda = new lambda.Function(this, 'WebSocketFunction', { + functionName: 'WebTimeTracker-WebSocketFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'websocket.handler', + code: lambda.Code.fromAsset('lambda/websocket'), + layers: [sharedLayer], + environment: { + CONNECTIONS_TABLE: connectionsTable.tableName, + EVENT_BUS_NAME: eventBus.eventBusName, + }, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + logGroup: websocketLogGroup, + }); + const notifyLambda = new lambda.Function(this, 'NotifyFunction', { + functionName: 'WebTimeTracker-NotifyFunction', + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'notify.handler', + code: lambda.Code.fromAsset('lambda/notify'), + layers: [sharedLayer], + environment: { + CONNECTIONS_TABLE: connectionsTable.tableName, + // WebSocket endpoint will be added after WebSocket API creation + }, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + logGroup: notifyLogGroup, + }); + // Grant permissions + hotDataTable.grantReadWriteData(syncLambda); + hotDataTable.grantReadWriteData(archiveLambda); + dataBucket.grantReadWrite(syncLambda); + dataBucket.grantReadWrite(archiveLambda); + eventBus.grantPutEventsTo(syncLambda); + connectionsTable.grantReadWriteData(websocketLambda); + connectionsTable.grantReadWriteData(notifyLambda); + // Grant WebSocket API permissions to notify lambda + notifyLambda.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'execute-api:ManageConnections' + ], + resources: ['*'], // Will be updated after WebSocket API is created + })); + // REST API Gateway + const api = new apigateway.RestApi(this, 'WebTimeTrackerApi', { + restApiName: 'Web Time Tracker Sync API', + description: 'API for Web Time Tracker real-time sync', + defaultCorsPreflightOptions: { + allowOrigins: apigateway.Cors.ALL_ORIGINS, + allowMethods: apigateway.Cors.ALL_METHODS, + allowHeaders: [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + 'X-Client-Id', + ], + }, + apiKeySourceType: apigateway.ApiKeySourceType.HEADER, + }); + // API integration + const syncIntegration = new apigateway.LambdaIntegration(syncLambda, { + requestTemplates: { 'application/json': '{ "statusCode": "200" }' }, + }); + // API resources + const syncResource = api.root.addResource('sync'); + syncResource.addMethod('POST', syncIntegration); + syncResource.addMethod('GET', syncIntegration); + const dataResource = api.root.addResource('data'); + dataResource.addMethod('GET', syncIntegration); + dataResource.addMethod('PUT', syncIntegration); + // WebSocket API Gateway + const websocketApi = new apigatewayv2.WebSocketApi(this, 'WebTimeTrackerWebSocket', { + apiName: 'Web Time Tracker WebSocket API', + description: 'WebSocket API for real-time sync notifications', + connectRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('ConnectIntegration', websocketLambda), + }, + disconnectRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('DisconnectIntegration', websocketLambda), + }, + defaultRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('DefaultIntegration', websocketLambda), + }, + }); + const websocketStage = new apigatewayv2.WebSocketStage(this, 'WebSocketStage', { + webSocketApi: websocketApi, + stageName: 'prod', + autoDeploy: true, + }); + // Update WebSocket permissions with actual ARN + notifyLambda.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'execute-api:ManageConnections' + ], + resources: [ + `arn:aws:execute-api:${this.region}:${this.account}:${websocketApi.apiId}/${websocketStage.stageName}/*` + ], + })); + // Add WebSocket endpoint to notify lambda environment + notifyLambda.addEnvironment('WEBSOCKET_ENDPOINT', websocketStage.url); + // EventBridge rules + const dataChangeRule = new events.Rule(this, 'DataChangeRule', { + eventBus, + eventPattern: { + source: ['web-time-tracker'], + detailType: ['Data Updated'], + }, + }); + dataChangeRule.addTarget(new targets.LambdaFunction(notifyLambda)); + // Scheduled archiving rule (runs daily at 2 AM) + const archiveRule = new events.Rule(this, 'ArchiveRule', { + schedule: events.Schedule.cron({ + minute: '0', + hour: '2', + day: '*', + month: '*', + year: '*', + }), + }); + archiveRule.addTarget(new targets.LambdaFunction(archiveLambda)); + // API Key for extension authentication + const apiKey = api.addApiKey('WebTimeTrackerApiKey', { + apiKeyName: 'web-time-tracker-extension-key', + description: 'API key for Web Time Tracker browser extension', + }); + const usagePlan = api.addUsagePlan('WebTimeTrackerUsagePlan', { + name: 'web-time-tracker-usage-plan', + description: 'Usage plan for Web Time Tracker API', + throttle: { + rateLimit: 1000, + burstLimit: 2000, + }, + quota: { + limit: 100000, + period: apigateway.Period.MONTH, + }, + }); + usagePlan.addApiKey(apiKey); + usagePlan.addApiStage({ + stage: api.deploymentStage, + }); + // Outputs + this.apiEndpoint = api.url; + this.websocketEndpoint = websocketStage.url; + new cdk.CfnOutput(this, 'ApiEndpoint', { + value: this.apiEndpoint, + description: 'Web Time Tracker API Gateway endpoint', + }); + new cdk.CfnOutput(this, 'WebSocketEndpoint', { + value: this.websocketEndpoint, + description: 'Web Time Tracker WebSocket API endpoint', + }); + new cdk.CfnOutput(this, 'ApiKeyId', { + value: apiKey.keyId, + description: 'API Key ID for authentication', + }); + new cdk.CfnOutput(this, 'DataBucket', { + value: dataBucket.bucketName, + description: 'S3 bucket for data storage', + }); + new cdk.CfnOutput(this, 'HotDataTable', { + value: hotDataTable.tableName, + description: 'DynamoDB table for hot data', + }); + } +} +exports.WebTimeTrackerStack = WebTimeTrackerStack; +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid2ViLXRpbWUtdHJhY2tlci1zdGFjay5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIndlYi10aW1lLXRyYWNrZXItc3RhY2sudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7O0FBQUEsbUNBQW1DO0FBQ25DLHFEQUFxRDtBQUNyRCx5Q0FBeUM7QUFDekMsaURBQWlEO0FBQ2pELHlEQUF5RDtBQUN6RCxpREFBaUQ7QUFDakQsMERBQTBEO0FBQzFELDJDQUEyQztBQUMzQyw2REFBNkQ7QUFDN0QsMEVBQTBFO0FBQzFFLDZDQUE2QztBQUc3QyxNQUFhLG1CQUFvQixTQUFRLEdBQUcsQ0FBQyxLQUFLO0lBQ2hDLFdBQVcsQ0FBUztJQUNwQixpQkFBaUIsQ0FBUztJQUUxQyxZQUFZLEtBQWdCLEVBQUUsRUFBVSxFQUFFLEtBQXNCO1FBQzlELEtBQUssQ0FBQyxLQUFLLEVBQUUsRUFBRSxFQUFFLEtBQUssQ0FBQyxDQUFDO1FBRXhCLDZCQUE2QjtRQUM3QixNQUFNLFVBQVUsR0FBRyxJQUFJLEVBQUUsQ0FBQyxNQUFNLENBQUMsSUFBSSxFQUFFLDBCQUEwQixFQUFFO1lBQ2pFLFVBQVUsRUFBRSx5QkFBeUIsSUFBSSxDQUFDLE9BQU8sSUFBSSxJQUFJLENBQUMsTUFBTSxFQUFFO1lBQ2xFLGFBQWEsRUFBRSxHQUFHLENBQUMsYUFBYSxDQUFDLE9BQU8sRUFBRSxrQ0FBa0M7WUFDNUUsaUJBQWlCLEVBQUUsSUFBSSxFQUFFLHdCQUF3QjtZQUNqRCxTQUFTLEVBQUUsSUFBSTtZQUNmLGNBQWMsRUFBRSxDQUFDO29CQUNmLEVBQUUsRUFBRSxvQkFBb0I7b0JBQ3hCLE9BQU8sRUFBRSxJQUFJO29CQUNiLDRCQUE0QixFQUFFLENBQUM7NEJBQzdCLFlBQVksRUFBRSxFQUFFLENBQUMsWUFBWSxDQUFDLE9BQU87NEJBQ3JDLGVBQWUsRUFBRSxHQUFHLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUM7eUJBQ3ZDLENBQUM7aUJBQ0gsQ0FBQztZQUNGLElBQUksRUFBRSxDQUFDO29CQUNMLGNBQWMsRUFBRSxDQUFDLEdBQUcsQ0FBQztvQkFDckIsY0FBYyxFQUFFO3dCQUNkLEVBQUUsQ0FBQyxXQUFXLENBQUMsR0FBRzt3QkFDbEIsRUFBRSxDQUFDLFdBQVcsQ0FBQyxHQUFHO3dCQUNsQixFQUFFLENBQUMsV0FBVyxDQUFDLElBQUk7d0JBQ25CLEVBQUUsQ0FBQyxXQUFXLENBQUMsTUFBTTtxQkFDdEI7b0JBQ0QsY0FBYyxFQUFFLENBQUMsR0FBRyxDQUFDLEVBQUUseUJBQXlCO29CQUNoRCxNQUFNLEVBQUUsSUFBSTtpQkFDYixDQUFDO1NBQ0gsQ0FBQyxDQUFDO1FBRUgsNENBQTRDO1FBQzVDLE1BQU0sWUFBWSxHQUFHLElBQUksUUFBUSxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUUsdUJBQXVCLEVBQUU7WUFDckUsU0FBUyxFQUFFLHVCQUF1QjtZQUNsQyxZQUFZLEVBQUU7Z0JBQ1osSUFBSSxFQUFFLElBQUksRUFBRSwyQkFBMkI7Z0JBQ3ZDLElBQUksRUFBRSxRQUFRLENBQUMsYUFBYSxDQUFDLE1BQU07YUFDcEM7WUFDRCxPQUFPLEVBQUU7Z0JBQ1AsSUFBSSxFQUFFLElBQUksRUFBRSxrQkFBa0I7Z0JBQzlCLElBQUksRUFBRSxRQUFRLENBQUMsYUFBYSxDQUFDLE1BQU07YUFDcEM7WUFDRCxXQUFXLEVBQUUsUUFBUSxDQUFDLFdBQVcsQ0FBQyxlQUFlO1lBQ2pELGFBQWEsRUFBRSxHQUFHLENBQUMsYUFBYSxDQUFDLE9BQU8sRUFBRSxrQ0FBa0M7WUFDNUUsZ0NBQWdDLEVBQUU7Z0JBQ2hDLDBCQUEwQixFQUFFLElBQUk7YUFDakM7WUFDRCxNQUFNLEVBQUUsUUFBUSxDQUFDLGNBQWMsQ0FBQyxrQkFBa0I7WUFDbEQsbUJBQW1CLEVBQUUsS0FBSztTQUMzQixDQUFDLENBQUM7UUFFSCxnREFBZ0Q7UUFDaEQsWUFBWSxDQUFDLHVCQUF1QixDQUFDO1lBQ25DLFNBQVMsRUFBRSxhQUFhO1lBQ3hCLFlBQVksRUFBRTtnQkFDWixJQUFJLEVBQUUsVUFBVTtnQkFDaEIsSUFBSSxFQUFFLFFBQVEsQ0FBQyxhQUFhLENBQUMsTUFBTTthQUNwQztZQUNELE9BQU8sRUFBRTtnQkFDUCxJQUFJLEVBQUUsY0FBYztnQkFDcEIsSUFBSSxFQUFFLFFBQVEsQ0FBQyxhQUFhLENBQUMsTUFBTTthQUNwQztTQUNGLENBQUMsQ0FBQztRQUVILG9EQUFvRDtRQUNwRCxZQUFZLENBQUMsdUJBQXVCLENBQUM7WUFDbkMsU0FBUyxFQUFFLFdBQVc7WUFDdEIsWUFBWSxFQUFFO2dCQUNaLElBQUksRUFBRSxNQUFNO2dCQUNaLElBQUksRUFBRSxRQUFRLENBQUMsYUFBYSxDQUFDLE1BQU07YUFDcEM7WUFDRCxPQUFPLEVBQUU7Z0JBQ1AsSUFBSSxFQUFFLGNBQWM7Z0JBQ3BCLElBQUksRUFBRSxRQUFRLENBQUMsYUFBYSxDQUFDLE1BQU07YUFDcEM7U0FDRixDQUFDLENBQUM7UUFFSCxvREFBb0Q7UUFDcEQsTUFBTSxnQkFBZ0IsR0FBRyxJQUFJLFFBQVEsQ0FBQyxLQUFLLENBQUMsSUFBSSxFQUFFLDJCQUEyQixFQUFFO1lBQzdFLFNBQVMsRUFBRSwyQkFBMkI7WUFDdEMsWUFBWSxFQUFFO2dCQUNaLElBQUksRUFBRSxjQUFjO2dCQUNwQixJQUFJLEVBQUUsUUFBUSxDQUFDLGFBQWEsQ0FBQyxNQUFNO2FBQ3BDO1lBQ0QsV0FBVyxFQUFFLFFBQVEsQ0FBQyxXQUFXLENBQUMsZUFBZTtZQUNqRCxhQUFhLEVBQUUsR0FBRyxDQUFDLGFBQWEsQ0FBQyxPQUFPO1lBQ3hDLG1CQUFtQixFQUFFLEtBQUs7U0FDM0IsQ0FBQyxDQUFDO1FBRUgsdUNBQXVDO1FBQ3ZDLE1BQU0sV0FBVyxHQUFHLElBQUksTUFBTSxDQUFDLFlBQVksQ0FBQyxJQUFJLEVBQUUsMkJBQTJCLEVBQUU7WUFDN0UsZ0JBQWdCLEVBQUUseUJBQXlCO1lBQzNDLElBQUksRUFBRSxNQUFNLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxzQkFBc0IsQ0FBQztZQUNuRCxrQkFBa0IsRUFBRSxDQUFDLE1BQU0sQ0FBQyxPQUFPLENBQUMsV0FBVyxDQUFDO1lBQ2hELFdBQVcsRUFBRSxrREFBa0Q7U0FDaEUsQ0FBQyxDQUFDO1FBRUgseUJBQXlCO1FBQ3pCLE1BQU0sUUFBUSxHQUFHLElBQUksTUFBTSxDQUFDLFFBQVEsQ0FBQyxJQUFJLEVBQUUsd0JBQXdCLEVBQUU7WUFDbkUsWUFBWSxFQUFFLHNCQUFzQjtTQUNyQyxDQUFDLENBQUM7UUFFSCxrQ0FBa0M7UUFDbEMsTUFBTSxZQUFZLEdBQUcsSUFBSSxJQUFJLENBQUMsUUFBUSxDQUFDLElBQUksRUFBRSxzQkFBc0IsRUFBRTtZQUNuRSxZQUFZLEVBQUUseUNBQXlDO1lBQ3ZELFNBQVMsRUFBRSxJQUFJLENBQUMsYUFBYSxDQUFDLFFBQVE7WUFDdEMsYUFBYSxFQUFFLEdBQUcsQ0FBQyxhQUFhLENBQUMsT0FBTztTQUN6QyxDQUFDLENBQUM7UUFFSCxNQUFNLGVBQWUsR0FBRyxJQUFJLElBQUksQ0FBQyxRQUFRLENBQUMsSUFBSSxFQUFFLHlCQUF5QixFQUFFO1lBQ3pFLFlBQVksRUFBRSw0Q0FBNEM7WUFDMUQsU0FBUyxFQUFFLElBQUksQ0FBQyxhQUFhLENBQUMsUUFBUTtZQUN0QyxhQUFhLEVBQUUsR0FBRyxDQUFDLGFBQWEsQ0FBQyxPQUFPO1NBQ3pDLENBQUMsQ0FBQztRQUVILE1BQU0saUJBQWlCLEdBQUcsSUFBSSxJQUFJLENBQUMsUUFBUSxDQUFDLElBQUksRUFBRSwyQkFBMkIsRUFBRTtZQUM3RSxZQUFZLEVBQUUsOENBQThDO1lBQzVELFNBQVMsRUFBRSxJQUFJLENBQUMsYUFBYSxDQUFDLFFBQVE7WUFDdEMsYUFBYSxFQUFFLEdBQUcsQ0FBQyxhQUFhLENBQUMsT0FBTztTQUN6QyxDQUFDLENBQUM7UUFFSCxNQUFNLGNBQWMsR0FBRyxJQUFJLElBQUksQ0FBQyxRQUFRLENBQUMsSUFBSSxFQUFFLHdCQUF3QixFQUFFO1lBQ3ZFLFlBQVksRUFBRSwyQ0FBMkM7WUFDekQsU0FBUyxFQUFFLElBQUksQ0FBQyxhQUFhLENBQUMsUUFBUTtZQUN0QyxhQUFhLEVBQUUsR0FBRyxDQUFDLGFBQWEsQ0FBQyxPQUFPO1NBQ3pDLENBQUMsQ0FBQztRQUVILG1CQUFtQjtRQUNuQixNQUFNLFVBQVUsR0FBRyxJQUFJLE1BQU0sQ0FBQyxRQUFRLENBQUMsSUFBSSxFQUFFLGNBQWMsRUFBRTtZQUMzRCxZQUFZLEVBQUUsNkJBQTZCO1lBQzNDLE9BQU8sRUFBRSxNQUFNLENBQUMsT0FBTyxDQUFDLFdBQVc7WUFDbkMsT0FBTyxFQUFFLGNBQWM7WUFDdkIsSUFBSSxFQUFFLE1BQU0sQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLGFBQWEsQ0FBQztZQUMxQyxNQUFNLEVBQUUsQ0FBQyxXQUFXLENBQUM7WUFDckIsV0FBVyxFQUFFO2dCQUNYLGNBQWMsRUFBRSxZQUFZLENBQUMsU0FBUztnQkFDdEMsV0FBVyxFQUFFLFVBQVUsQ0FBQyxVQUFVO2dCQUNsQyxjQUFjLEVBQUUsUUFBUSxDQUFDLFlBQVk7Z0JBQ3JDLGlCQUFpQixFQUFFLGdCQUFnQixDQUFDLFNBQVM7YUFDOUM7WUFDRCxPQUFPLEVBQUUsR0FBRyxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsRUFBRSxDQUFDO1lBQ2pDLFVBQVUsRUFBRSxHQUFHO1lBQ2YsUUFBUSxFQUFFLFlBQVk7U0FDdkIsQ0FBQyxDQUFDO1FBRUgsTUFBTSxhQUFhLEdBQUcsSUFBSSxNQUFNLENBQUMsUUFBUSxDQUFDLElBQUksRUFBRSxpQkFBaUIsRUFBRTtZQUNqRSxZQUFZLEVBQUUsZ0NBQWdDO1lBQzlDLE9BQU8sRUFBRSxNQUFNLENBQUMsT0FBTyxDQUFDLFdBQVc7WUFDbkMsT0FBTyxFQUFFLGlCQUFpQjtZQUMxQixJQUFJLEVBQUUsTUFBTSxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsZ0JBQWdCLENBQUM7WUFDN0MsTUFBTSxFQUFFLENBQUMsV0FBVyxDQUFDO1lBQ3JCLFdBQVcsRUFBRTtnQkFDWCxjQUFjLEVBQUUsWUFBWSxDQUFDLFNBQVM7Z0JBQ3RDLFdBQVcsRUFBRSxVQUFVLENBQUMsVUFBVTthQUNuQztZQUNELE9BQU8sRUFBRSxHQUFHLENBQUMsUUFBUSxDQUFDLE9BQU8sQ0FBQyxFQUFFLENBQUM7WUFDakMsVUFBVSxFQUFFLElBQUk7WUFDaEIsUUFBUSxFQUFFLGVBQWU7U0FDMUIsQ0FBQyxDQUFDO1FBRUgsTUFBTSxlQUFlLEdBQUcsSUFBSSxNQUFNLENBQUMsUUFBUSxDQUFDLElBQUksRUFBRSxtQkFBbUIsRUFBRTtZQUNyRSxZQUFZLEVBQUUsa0NBQWtDO1lBQ2hELE9BQU8sRUFBRSxNQUFNLENBQUMsT0FBTyxDQUFDLFdBQVc7WUFDbkMsT0FBTyxFQUFFLG1CQUFtQjtZQUM1QixJQUFJLEVBQUUsTUFBTSxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsa0JBQWtCLENBQUM7WUFDL0MsTUFBTSxFQUFFLENBQUMsV0FBVyxDQUFDO1lBQ3JCLFdBQVcsRUFBRTtnQkFDWCxpQkFBaUIsRUFBRSxnQkFBZ0IsQ0FBQyxTQUFTO2dCQUM3QyxjQUFjLEVBQUUsUUFBUSxDQUFDLFlBQVk7YUFDdEM7WUFDRCxPQUFPLEVBQUUsR0FBRyxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsRUFBRSxDQUFDO1lBQ2pDLFVBQVUsRUFBRSxHQUFHO1lBQ2YsUUFBUSxFQUFFLGlCQUFpQjtTQUM1QixDQUFDLENBQUM7UUFFSCxNQUFNLFlBQVksR0FBRyxJQUFJLE1BQU0sQ0FBQyxRQUFRLENBQUMsSUFBSSxFQUFFLGdCQUFnQixFQUFFO1lBQy9ELFlBQVksRUFBRSwrQkFBK0I7WUFDN0MsT0FBTyxFQUFFLE1BQU0sQ0FBQyxPQUFPLENBQUMsV0FBVztZQUNuQyxPQUFPLEVBQUUsZ0JBQWdCO1lBQ3pCLElBQUksRUFBRSxNQUFNLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxlQUFlLENBQUM7WUFDNUMsTUFBTSxFQUFFLENBQUMsV0FBVyxDQUFDO1lBQ3JCLFdBQVcsRUFBRTtnQkFDWCxpQkFBaUIsRUFBRSxnQkFBZ0IsQ0FBQyxTQUFTO2dCQUM3QyxnRUFBZ0U7YUFDakU7WUFDRCxPQUFPLEVBQUUsR0FBRyxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsRUFBRSxDQUFDO1lBQ2pDLFVBQVUsRUFBRSxHQUFHO1lBQ2YsUUFBUSxFQUFFLGNBQWM7U0FDekIsQ0FBQyxDQUFDO1FBRUgsb0JBQW9CO1FBQ3BCLFlBQVksQ0FBQyxrQkFBa0IsQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUM1QyxZQUFZLENBQUMsa0JBQWtCLENBQUMsYUFBYSxDQUFDLENBQUM7UUFDL0MsVUFBVSxDQUFDLGNBQWMsQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUN0QyxVQUFVLENBQUMsY0FBYyxDQUFDLGFBQWEsQ0FBQyxDQUFDO1FBQ3pDLFFBQVEsQ0FBQyxnQkFBZ0IsQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUN0QyxnQkFBZ0IsQ0FBQyxrQkFBa0IsQ0FBQyxlQUFlLENBQUMsQ0FBQztRQUNyRCxnQkFBZ0IsQ0FBQyxrQkFBa0IsQ0FBQyxZQUFZLENBQUMsQ0FBQztRQUVsRCxtREFBbUQ7UUFDbkQsWUFBWSxDQUFDLGVBQWUsQ0FBQyxJQUFJLEdBQUcsQ0FBQyxlQUFlLENBQUM7WUFDbkQsTUFBTSxFQUFFLEdBQUcsQ0FBQyxNQUFNLENBQUMsS0FBSztZQUN4QixPQUFPLEVBQUU7Z0JBQ1AsK0JBQStCO2FBQ2hDO1lBQ0QsU0FBUyxFQUFFLENBQUMsR0FBRyxDQUFDLEVBQUUsaURBQWlEO1NBQ3BFLENBQUMsQ0FBQyxDQUFDO1FBRUosbUJBQW1CO1FBQ25CLE1BQU0sR0FBRyxHQUFHLElBQUksVUFBVSxDQUFDLE9BQU8sQ0FBQyxJQUFJLEVBQUUsbUJBQW1CLEVBQUU7WUFDNUQsV0FBVyxFQUFFLDJCQUEyQjtZQUN4QyxXQUFXLEVBQUUseUNBQXlDO1lBQ3RELDJCQUEyQixFQUFFO2dCQUMzQixZQUFZLEVBQUUsVUFBVSxDQUFDLElBQUksQ0FBQyxXQUFXO2dCQUN6QyxZQUFZLEVBQUUsVUFBVSxDQUFDLElBQUksQ0FBQyxXQUFXO2dCQUN6QyxZQUFZLEVBQUU7b0JBQ1osY0FBYztvQkFDZCxZQUFZO29CQUNaLGVBQWU7b0JBQ2YsV0FBVztvQkFDWCxzQkFBc0I7b0JBQ3RCLGFBQWE7aUJBQ2Q7YUFDRjtZQUNELGdCQUFnQixFQUFFLFVBQVUsQ0FBQyxnQkFBZ0IsQ0FBQyxNQUFNO1NBQ3JELENBQUMsQ0FBQztRQUVILGtCQUFrQjtRQUNsQixNQUFNLGVBQWUsR0FBRyxJQUFJLFVBQVUsQ0FBQyxpQkFBaUIsQ0FBQyxVQUFVLEVBQUU7WUFDbkUsZ0JBQWdCLEVBQUUsRUFBRSxrQkFBa0IsRUFBRSx5QkFBeUIsRUFBRTtTQUNwRSxDQUFDLENBQUM7UUFFSCxnQkFBZ0I7UUFDaEIsTUFBTSxZQUFZLEdBQUcsR0FBRyxDQUFDLElBQUksQ0FBQyxXQUFXLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDbEQsWUFBWSxDQUFDLFNBQVMsQ0FBQyxNQUFNLEVBQUUsZUFBZSxDQUFDLENBQUM7UUFDaEQsWUFBWSxDQUFDLFNBQVMsQ0FBQyxLQUFLLEVBQUUsZUFBZSxDQUFDLENBQUM7UUFFL0MsTUFBTSxZQUFZLEdBQUcsR0FBRyxDQUFDLElBQUksQ0FBQyxXQUFXLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDbEQsWUFBWSxDQUFDLFNBQVMsQ0FBQyxLQUFLLEVBQUUsZUFBZSxDQUFDLENBQUM7UUFDL0MsWUFBWSxDQUFDLFNBQVMsQ0FBQyxLQUFLLEVBQUUsZUFBZSxDQUFDLENBQUM7UUFFL0Msd0JBQXdCO1FBQ3hCLE1BQU0sWUFBWSxHQUFHLElBQUksWUFBWSxDQUFDLFlBQVksQ0FBQyxJQUFJLEVBQUUseUJBQXlCLEVBQUU7WUFDbEYsT0FBTyxFQUFFLGdDQUFnQztZQUN6QyxXQUFXLEVBQUUsZ0RBQWdEO1lBQzdELG1CQUFtQixFQUFFO2dCQUNuQixXQUFXLEVBQUUsSUFBSSxZQUFZLENBQUMsMEJBQTBCLENBQUMsb0JBQW9CLEVBQUUsZUFBZSxDQUFDO2FBQ2hHO1lBQ0Qsc0JBQXNCLEVBQUU7Z0JBQ3RCLFdBQVcsRUFBRSxJQUFJLFlBQVksQ0FBQywwQkFBMEIsQ0FBQyx1QkFBdUIsRUFBRSxlQUFlLENBQUM7YUFDbkc7WUFDRCxtQkFBbUIsRUFBRTtnQkFDbkIsV0FBVyxFQUFFLElBQUksWUFBWSxDQUFDLDBCQUEwQixDQUFDLG9CQUFvQixFQUFFLGVBQWUsQ0FBQzthQUNoRztTQUNGLENBQUMsQ0FBQztRQUVILE1BQU0sY0FBYyxHQUFHLElBQUksWUFBWSxDQUFDLGNBQWMsQ0FBQyxJQUFJLEVBQUUsZ0JBQWdCLEVBQUU7WUFDN0UsWUFBWSxFQUFFLFlBQVk7WUFDMUIsU0FBUyxFQUFFLE1BQU07WUFDakIsVUFBVSxFQUFFLElBQUk7U0FDakIsQ0FBQyxDQUFDO1FBRUgsK0NBQStDO1FBQy9DLFlBQVksQ0FBQyxlQUFlLENBQUMsSUFBSSxHQUFHLENBQUMsZUFBZSxDQUFDO1lBQ25ELE1BQU0sRUFBRSxHQUFHLENBQUMsTUFBTSxDQUFDLEtBQUs7WUFDeEIsT0FBTyxFQUFFO2dCQUNQLCtCQUErQjthQUNoQztZQUNELFNBQVMsRUFBRTtnQkFDVCx1QkFBdUIsSUFBSSxDQUFDLE1BQU0sSUFBSSxJQUFJLENBQUMsT0FBTyxJQUFJLFlBQVksQ0FBQyxLQUFLLElBQUksY0FBYyxDQUFDLFNBQVMsSUFBSTthQUN6RztTQUNGLENBQUMsQ0FBQyxDQUFDO1FBRUosc0RBQXNEO1FBQ3RELFlBQVksQ0FBQyxjQUFjLENBQUMsb0JBQW9CLEVBQUUsY0FBYyxDQUFDLEdBQUcsQ0FBQyxDQUFDO1FBRXRFLG9CQUFvQjtRQUNwQixNQUFNLGNBQWMsR0FBRyxJQUFJLE1BQU0sQ0FBQyxJQUFJLENBQUMsSUFBSSxFQUFFLGdCQUFnQixFQUFFO1lBQzdELFFBQVE7WUFDUixZQUFZLEVBQUU7Z0JBQ1osTUFBTSxFQUFFLENBQUMsa0JBQWtCLENBQUM7Z0JBQzVCLFVBQVUsRUFBRSxDQUFDLGNBQWMsQ0FBQzthQUM3QjtTQUNGLENBQUMsQ0FBQztRQUVILGNBQWMsQ0FBQyxTQUFTLENBQUMsSUFBSSxPQUFPLENBQUMsY0FBYyxDQUFDLFlBQVksQ0FBQyxDQUFDLENBQUM7UUFFbkUsZ0RBQWdEO1FBQ2hELE1BQU0sV0FBVyxHQUFHLElBQUksTUFBTSxDQUFDLElBQUksQ0FBQyxJQUFJLEVBQUUsYUFBYSxFQUFFO1lBQ3ZELFFBQVEsRUFBRSxNQUFNLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQztnQkFDN0IsTUFBTSxFQUFFLEdBQUc7Z0JBQ1gsSUFBSSxFQUFFLEdBQUc7Z0JBQ1QsR0FBRyxFQUFFLEdBQUc7Z0JBQ1IsS0FBSyxFQUFFLEdBQUc7Z0JBQ1YsSUFBSSxFQUFFLEdBQUc7YUFDVixDQUFDO1NBQ0gsQ0FBQyxDQUFDO1FBRUgsV0FBVyxDQUFDLFNBQVMsQ0FBQyxJQUFJLE9BQU8sQ0FBQyxjQUFjLENBQUMsYUFBYSxDQUFDLENBQUMsQ0FBQztRQUVqRSx1Q0FBdUM7UUFDdkMsTUFBTSxNQUFNLEdBQUcsR0FBRyxDQUFDLFNBQVMsQ0FBQyxzQkFBc0IsRUFBRTtZQUNuRCxVQUFVLEVBQUUsZ0NBQWdDO1lBQzVDLFdBQVcsRUFBRSxnREFBZ0Q7U0FDOUQsQ0FBQyxDQUFDO1FBRUgsTUFBTSxTQUFTLEdBQUcsR0FBRyxDQUFDLFlBQVksQ0FBQyx5QkFBeUIsRUFBRTtZQUM1RCxJQUFJLEVBQUUsNkJBQTZCO1lBQ25DLFdBQVcsRUFBRSxxQ0FBcUM7WUFDbEQsUUFBUSxFQUFFO2dCQUNSLFNBQVMsRUFBRSxJQUFJO2dCQUNmLFVBQVUsRUFBRSxJQUFJO2FBQ2pCO1lBQ0QsS0FBSyxFQUFFO2dCQUNMLEtBQUssRUFBRSxNQUFNO2dCQUNiLE1BQU0sRUFBRSxVQUFVLENBQUMsTUFBTSxDQUFDLEtBQUs7YUFDaEM7U0FDRixDQUFDLENBQUM7UUFFSCxTQUFTLENBQUMsU0FBUyxDQUFDLE1BQU0sQ0FBQyxDQUFDO1FBQzVCLFNBQVMsQ0FBQyxXQUFXLENBQUM7WUFDcEIsS0FBSyxFQUFFLEdBQUcsQ0FBQyxlQUFlO1NBQzNCLENBQUMsQ0FBQztRQUVILFVBQVU7UUFDVixJQUFJLENBQUMsV0FBVyxHQUFHLEdBQUcsQ0FBQyxHQUFHLENBQUM7UUFDM0IsSUFBSSxDQUFDLGlCQUFpQixHQUFHLGNBQWMsQ0FBQyxHQUFHLENBQUM7UUFFNUMsSUFBSSxHQUFHLENBQUMsU0FBUyxDQUFDLElBQUksRUFBRSxhQUFhLEVBQUU7WUFDckMsS0FBSyxFQUFFLElBQUksQ0FBQyxXQUFXO1lBQ3ZCLFdBQVcsRUFBRSx1Q0FBdUM7U0FDckQsQ0FBQyxDQUFDO1FBRUgsSUFBSSxHQUFHLENBQUMsU0FBUyxDQUFDLElBQUksRUFBRSxtQkFBbUIsRUFBRTtZQUMzQyxLQUFLLEVBQUUsSUFBSSxDQUFDLGlCQUFpQjtZQUM3QixXQUFXLEVBQUUseUNBQXlDO1NBQ3ZELENBQUMsQ0FBQztRQUVILElBQUksR0FBRyxDQUFDLFNBQVMsQ0FBQyxJQUFJLEVBQUUsVUFBVSxFQUFFO1lBQ2xDLEtBQUssRUFBRSxNQUFNLENBQUMsS0FBSztZQUNuQixXQUFXLEVBQUUsK0JBQStCO1NBQzdDLENBQUMsQ0FBQztRQUVILElBQUksR0FBRyxDQUFDLFNBQVMsQ0FBQyxJQUFJLEVBQUUsWUFBWSxFQUFFO1lBQ3BDLEtBQUssRUFBRSxVQUFVLENBQUMsVUFBVTtZQUM1QixXQUFXLEVBQUUsNEJBQTRCO1NBQzFDLENBQUMsQ0FBQztRQUVILElBQUksR0FBRyxDQUFDLFNBQVMsQ0FBQyxJQUFJLEVBQUUsY0FBYyxFQUFFO1lBQ3RDLEtBQUssRUFBRSxZQUFZLENBQUMsU0FBUztZQUM3QixXQUFXLEVBQUUsNkJBQTZCO1NBQzNDLENBQUMsQ0FBQztJQUNMLENBQUM7Q0FDRjtBQXBXRCxrREFvV0MiLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBjZGsgZnJvbSAnYXdzLWNkay1saWInO1xyXG5pbXBvcnQgKiBhcyBkeW5hbW9kYiBmcm9tICdhd3MtY2RrLWxpYi9hd3MtZHluYW1vZGInO1xyXG5pbXBvcnQgKiBhcyBzMyBmcm9tICdhd3MtY2RrLWxpYi9hd3MtczMnO1xyXG5pbXBvcnQgKiBhcyBsYW1iZGEgZnJvbSAnYXdzLWNkay1saWIvYXdzLWxhbWJkYSc7XHJcbmltcG9ydCAqIGFzIGFwaWdhdGV3YXkgZnJvbSAnYXdzLWNkay1saWIvYXdzLWFwaWdhdGV3YXknO1xyXG5pbXBvcnQgKiBhcyBldmVudHMgZnJvbSAnYXdzLWNkay1saWIvYXdzLWV2ZW50cyc7XHJcbmltcG9ydCAqIGFzIHRhcmdldHMgZnJvbSAnYXdzLWNkay1saWIvYXdzLWV2ZW50cy10YXJnZXRzJztcclxuaW1wb3J0ICogYXMgaWFtIGZyb20gJ2F3cy1jZGstbGliL2F3cy1pYW0nO1xyXG5pbXBvcnQgKiBhcyBhcGlnYXRld2F5djIgZnJvbSAnYXdzLWNkay1saWIvYXdzLWFwaWdhdGV3YXl2Mic7XHJcbmltcG9ydCAqIGFzIGludGVncmF0aW9ucyBmcm9tICdhd3MtY2RrLWxpYi9hd3MtYXBpZ2F0ZXdheXYyLWludGVncmF0aW9ucyc7XHJcbmltcG9ydCAqIGFzIGxvZ3MgZnJvbSAnYXdzLWNkay1saWIvYXdzLWxvZ3MnO1xyXG5pbXBvcnQgeyBDb25zdHJ1Y3QgfSBmcm9tICdjb25zdHJ1Y3RzJztcclxuXHJcbmV4cG9ydCBjbGFzcyBXZWJUaW1lVHJhY2tlclN0YWNrIGV4dGVuZHMgY2RrLlN0YWNrIHtcclxuICBwdWJsaWMgcmVhZG9ubHkgYXBpRW5kcG9pbnQ6IHN0cmluZztcclxuICBwdWJsaWMgcmVhZG9ubHkgd2Vic29ja2V0RW5kcG9pbnQ6IHN0cmluZztcclxuXHJcbiAgY29uc3RydWN0b3Ioc2NvcGU6IENvbnN0cnVjdCwgaWQ6IHN0cmluZywgcHJvcHM/OiBjZGsuU3RhY2tQcm9wcykge1xyXG4gICAgc3VwZXIoc2NvcGUsIGlkLCBwcm9wcyk7XHJcblxyXG4gICAgLy8gUzMgQnVja2V0IGZvciBjb2xkIHN0b3JhZ2VcclxuICAgIGNvbnN0IGRhdGFCdWNrZXQgPSBuZXcgczMuQnVja2V0KHRoaXMsICdXZWJUaW1lVHJhY2tlckRhdGFCdWNrZXQnLCB7XHJcbiAgICAgIGJ1Y2tldE5hbWU6IGB3ZWItdGltZS10cmFja2VyLWRhdGEtJHt0aGlzLmFjY291bnR9LSR7dGhpcy5yZWdpb259YCxcclxuICAgICAgcmVtb3ZhbFBvbGljeTogY2RrLlJlbW92YWxQb2xpY3kuREVTVFJPWSwgLy8gQ2hhbmdlIHRvIFJFVEFJTiBmb3IgcHJvZHVjdGlvblxyXG4gICAgICBhdXRvRGVsZXRlT2JqZWN0czogdHJ1ZSwgLy8gUmVtb3ZlIGZvciBwcm9kdWN0aW9uXHJcbiAgICAgIHZlcnNpb25lZDogdHJ1ZSxcclxuICAgICAgbGlmZWN5Y2xlUnVsZXM6IFt7XHJcbiAgICAgICAgaWQ6ICdBcmNoaXZlT2xkVmVyc2lvbnMnLFxyXG4gICAgICAgIGVuYWJsZWQ6IHRydWUsXHJcbiAgICAgICAgbm9uY3VycmVudFZlcnNpb25UcmFuc2l0aW9uczogW3tcclxuICAgICAgICAgIHN0b3JhZ2VDbGFzczogczMuU3RvcmFnZUNsYXNzLkdMQUNJRVIsXHJcbiAgICAgICAgICB0cmFuc2l0aW9uQWZ0ZXI6IGNkay5EdXJhdGlvbi5kYXlzKDMwKSxcclxuICAgICAgICB9XSxcclxuICAgICAgfV0sXHJcbiAgICAgIGNvcnM6IFt7XHJcbiAgICAgICAgYWxsb3dlZEhlYWRlcnM6IFsnKiddLFxyXG4gICAgICAgIGFsbG93ZWRNZXRob2RzOiBbXHJcbiAgICAgICAgICBzMy5IdHRwTWV0aG9kcy5HRVQsXHJcbiAgICAgICAgICBzMy5IdHRwTWV0aG9kcy5QVVQsXHJcbiAgICAgICAgICBzMy5IdHRwTWV0aG9kcy5QT1NULFxyXG4gICAgICAgICAgczMuSHR0cE1ldGhvZHMuREVMRVRFLFxyXG4gICAgICAgIF0sXHJcbiAgICAgICAgYWxsb3dlZE9yaWdpbnM6IFsnKiddLCAvLyBSZXN0cmljdCBpbiBwcm9kdWN0aW9uXHJcbiAgICAgICAgbWF4QWdlOiAzMDAwLFxyXG4gICAgICB9XSxcclxuICAgIH0pO1xyXG5cclxuICAgIC8vIER5bmFtb0RCIFRhYmxlIGZvciBob3QgZGF0YSAobGFzdCA3IGRheXMpXHJcbiAgICBjb25zdCBob3REYXRhVGFibGUgPSBuZXcgZHluYW1vZGIuVGFibGUodGhpcywgJ1dlYlRpbWVUcmFja2VySG90RGF0YScsIHtcclxuICAgICAgdGFibGVOYW1lOiAnV2ViVGltZVRyYWNrZXJIb3REYXRhJyxcclxuICAgICAgcGFydGl0aW9uS2V5OiB7XHJcbiAgICAgICAgbmFtZTogJ1BLJywgLy8ge2NsaWVudElkfSN7aG9zdH0je2RhdGV9XHJcbiAgICAgICAgdHlwZTogZHluYW1vZGIuQXR0cmlidXRlVHlwZS5TVFJJTkcsXHJcbiAgICAgIH0sXHJcbiAgICAgIHNvcnRLZXk6IHtcclxuICAgICAgICBuYW1lOiAnU0snLCAvLyBkYXRhIHwgbWV0YWRhdGFcclxuICAgICAgICB0eXBlOiBkeW5hbW9kYi5BdHRyaWJ1dGVUeXBlLlNUUklORyxcclxuICAgICAgfSxcclxuICAgICAgYmlsbGluZ01vZGU6IGR5bmFtb2RiLkJpbGxpbmdNb2RlLlBBWV9QRVJfUkVRVUVTVCxcclxuICAgICAgcmVtb3ZhbFBvbGljeTogY2RrLlJlbW92YWxQb2xpY3kuREVTVFJPWSwgLy8gQ2hhbmdlIHRvIFJFVEFJTiBmb3IgcHJvZHVjdGlvblxyXG4gICAgICBwb2ludEluVGltZVJlY292ZXJ5U3BlY2lmaWNhdGlvbjoge1xyXG4gICAgICAgIHBvaW50SW5UaW1lUmVjb3ZlcnlFbmFibGVkOiB0cnVlLFxyXG4gICAgICB9LFxyXG4gICAgICBzdHJlYW06IGR5bmFtb2RiLlN0cmVhbVZpZXdUeXBlLk5FV19BTkRfT0xEX0lNQUdFUyxcclxuICAgICAgdGltZVRvTGl2ZUF0dHJpYnV0ZTogJ3R0bCcsXHJcbiAgICB9KTtcclxuXHJcbiAgICAvLyBHbG9iYWwgU2Vjb25kYXJ5IEluZGV4IGZvciBxdWVyeWluZyBieSBjbGllbnRcclxuICAgIGhvdERhdGFUYWJsZS5hZGRHbG9iYWxTZWNvbmRhcnlJbmRleCh7XHJcbiAgICAgIGluZGV4TmFtZTogJ0NsaWVudEluZGV4JyxcclxuICAgICAgcGFydGl0aW9uS2V5OiB7XHJcbiAgICAgICAgbmFtZTogJ2NsaWVudElkJyxcclxuICAgICAgICB0eXBlOiBkeW5hbW9kYi5BdHRyaWJ1dGVUeXBlLlNUUklORyxcclxuICAgICAgfSxcclxuICAgICAgc29ydEtleToge1xyXG4gICAgICAgIG5hbWU6ICdsYXN0TW9kaWZpZWQnLFxyXG4gICAgICAgIHR5cGU6IGR5bmFtb2RiLkF0dHJpYnV0ZVR5cGUuTlVNQkVSLFxyXG4gICAgICB9LFxyXG4gICAgfSk7XHJcblxyXG4gICAgLy8gR2xvYmFsIFNlY29uZGFyeSBJbmRleCBmb3IgcXVlcnlpbmcgYnkgZGF0ZSByYW5nZVxyXG4gICAgaG90RGF0YVRhYmxlLmFkZEdsb2JhbFNlY29uZGFyeUluZGV4KHtcclxuICAgICAgaW5kZXhOYW1lOiAnRGF0ZUluZGV4JyxcclxuICAgICAgcGFydGl0aW9uS2V5OiB7XHJcbiAgICAgICAgbmFtZTogJ2RhdGUnLFxyXG4gICAgICAgIHR5cGU6IGR5bmFtb2RiLkF0dHJpYnV0ZVR5cGUuU1RSSU5HLFxyXG4gICAgICB9LFxyXG4gICAgICBzb3J0S2V5OiB7XHJcbiAgICAgICAgbmFtZTogJ2xhc3RNb2RpZmllZCcsXHJcbiAgICAgICAgdHlwZTogZHluYW1vZGIuQXR0cmlidXRlVHlwZS5OVU1CRVIsXHJcbiAgICAgIH0sXHJcbiAgICB9KTtcclxuXHJcbiAgICAvLyBEeW5hbW9EQiBUYWJsZSBmb3IgY2xpZW50IGNvbm5lY3Rpb25zIChXZWJTb2NrZXQpXHJcbiAgICBjb25zdCBjb25uZWN0aW9uc1RhYmxlID0gbmV3IGR5bmFtb2RiLlRhYmxlKHRoaXMsICdXZWJUaW1lVHJhY2tlckNvbm5lY3Rpb25zJywge1xyXG4gICAgICB0YWJsZU5hbWU6ICdXZWJUaW1lVHJhY2tlckNvbm5lY3Rpb25zJyxcclxuICAgICAgcGFydGl0aW9uS2V5OiB7XHJcbiAgICAgICAgbmFtZTogJ2Nvbm5lY3Rpb25JZCcsXHJcbiAgICAgICAgdHlwZTogZHluYW1vZGIuQXR0cmlidXRlVHlwZS5TVFJJTkcsXHJcbiAgICAgIH0sXHJcbiAgICAgIGJpbGxpbmdNb2RlOiBkeW5hbW9kYi5CaWxsaW5nTW9kZS5QQVlfUEVSX1JFUVVFU1QsXHJcbiAgICAgIHJlbW92YWxQb2xpY3k6IGNkay5SZW1vdmFsUG9saWN5LkRFU1RST1ksXHJcbiAgICAgIHRpbWVUb0xpdmVBdHRyaWJ1dGU6ICd0dGwnLFxyXG4gICAgfSk7XHJcblxyXG4gICAgLy8gTGFtYmRhIExheWVyIGZvciBzaGFyZWQgZGVwZW5kZW5jaWVzXHJcbiAgICBjb25zdCBzaGFyZWRMYXllciA9IG5ldyBsYW1iZGEuTGF5ZXJWZXJzaW9uKHRoaXMsICdXZWJUaW1lVHJhY2tlclNoYXJlZExheWVyJywge1xyXG4gICAgICBsYXllclZlcnNpb25OYW1lOiAnd2ViLXRpbWUtdHJhY2tlci1zaGFyZWQnLFxyXG4gICAgICBjb2RlOiBsYW1iZGEuQ29kZS5mcm9tQXNzZXQoJ2xhbWJkYS1sYXllcnMvc2hhcmVkJyksXHJcbiAgICAgIGNvbXBhdGlibGVSdW50aW1lczogW2xhbWJkYS5SdW50aW1lLk5PREVKU18yMF9YXSxcclxuICAgICAgZGVzY3JpcHRpb246ICdTaGFyZWQgZGVwZW5kZW5jaWVzIGZvciBXZWIgVGltZSBUcmFja2VyIGxhbWJkYXMnLFxyXG4gICAgfSk7XHJcblxyXG4gICAgLy8gRXZlbnRCcmlkZ2UgY3VzdG9tIGJ1c1xyXG4gICAgY29uc3QgZXZlbnRCdXMgPSBuZXcgZXZlbnRzLkV2ZW50QnVzKHRoaXMsICdXZWJUaW1lVHJhY2tlckV2ZW50QnVzJywge1xyXG4gICAgICBldmVudEJ1c05hbWU6ICdXZWJUaW1lVHJhY2tlckV2ZW50cycsXHJcbiAgICB9KTtcclxuXHJcbiAgICAvLyBMb2cgZ3JvdXBzIGZvciBMYW1iZGEgZnVuY3Rpb25zXHJcbiAgICBjb25zdCBzeW5jTG9nR3JvdXAgPSBuZXcgbG9ncy5Mb2dHcm91cCh0aGlzLCAnU3luY0Z1bmN0aW9uTG9nR3JvdXAnLCB7XHJcbiAgICAgIGxvZ0dyb3VwTmFtZTogJy9hd3MvbGFtYmRhL1dlYlRpbWVUcmFja2VyLVN5bmNGdW5jdGlvbicsXHJcbiAgICAgIHJldGVudGlvbjogbG9ncy5SZXRlbnRpb25EYXlzLk9ORV9XRUVLLFxyXG4gICAgICByZW1vdmFsUG9saWN5OiBjZGsuUmVtb3ZhbFBvbGljeS5ERVNUUk9ZLFxyXG4gICAgfSk7XHJcblxyXG4gICAgY29uc3QgYXJjaGl2ZUxvZ0dyb3VwID0gbmV3IGxvZ3MuTG9nR3JvdXAodGhpcywgJ0FyY2hpdmVGdW5jdGlvbkxvZ0dyb3VwJywge1xyXG4gICAgICBsb2dHcm91cE5hbWU6ICcvYXdzL2xhbWJkYS9XZWJUaW1lVHJhY2tlci1BcmNoaXZlRnVuY3Rpb24nLFxyXG4gICAgICByZXRlbnRpb246IGxvZ3MuUmV0ZW50aW9uRGF5cy5PTkVfV0VFSyxcclxuICAgICAgcmVtb3ZhbFBvbGljeTogY2RrLlJlbW92YWxQb2xpY3kuREVTVFJPWSxcclxuICAgIH0pO1xyXG5cclxuICAgIGNvbnN0IHdlYnNvY2tldExvZ0dyb3VwID0gbmV3IGxvZ3MuTG9nR3JvdXAodGhpcywgJ1dlYlNvY2tldEZ1bmN0aW9uTG9nR3JvdXAnLCB7XHJcbiAgICAgIGxvZ0dyb3VwTmFtZTogJy9hd3MvbGFtYmRhL1dlYlRpbWVUcmFja2VyLVdlYlNvY2tldEZ1bmN0aW9uJyxcclxuICAgICAgcmV0ZW50aW9uOiBsb2dzLlJldGVudGlvbkRheXMuT05FX1dFRUssXHJcbiAgICAgIHJlbW92YWxQb2xpY3k6IGNkay5SZW1vdmFsUG9saWN5LkRFU1RST1ksXHJcbiAgICB9KTtcclxuXHJcbiAgICBjb25zdCBub3RpZnlMb2dHcm91cCA9IG5ldyBsb2dzLkxvZ0dyb3VwKHRoaXMsICdOb3RpZnlGdW5jdGlvbkxvZ0dyb3VwJywge1xyXG4gICAgICBsb2dHcm91cE5hbWU6ICcvYXdzL2xhbWJkYS9XZWJUaW1lVHJhY2tlci1Ob3RpZnlGdW5jdGlvbicsXHJcbiAgICAgIHJldGVudGlvbjogbG9ncy5SZXRlbnRpb25EYXlzLk9ORV9XRUVLLFxyXG4gICAgICByZW1vdmFsUG9saWN5OiBjZGsuUmVtb3ZhbFBvbGljeS5ERVNUUk9ZLFxyXG4gICAgfSk7XHJcblxyXG4gICAgLy8gTGFtYmRhIGZ1bmN0aW9uc1xyXG4gICAgY29uc3Qgc3luY0xhbWJkYSA9IG5ldyBsYW1iZGEuRnVuY3Rpb24odGhpcywgJ1N5bmNGdW5jdGlvbicsIHtcclxuICAgICAgZnVuY3Rpb25OYW1lOiAnV2ViVGltZVRyYWNrZXItU3luY0Z1bmN0aW9uJyxcclxuICAgICAgcnVudGltZTogbGFtYmRhLlJ1bnRpbWUuTk9ERUpTXzIwX1gsXHJcbiAgICAgIGhhbmRsZXI6ICdzeW5jLmhhbmRsZXInLFxyXG4gICAgICBjb2RlOiBsYW1iZGEuQ29kZS5mcm9tQXNzZXQoJ2xhbWJkYS9zeW5jJyksXHJcbiAgICAgIGxheWVyczogW3NoYXJlZExheWVyXSxcclxuICAgICAgZW52aXJvbm1lbnQ6IHtcclxuICAgICAgICBIT1RfREFUQV9UQUJMRTogaG90RGF0YVRhYmxlLnRhYmxlTmFtZSxcclxuICAgICAgICBEQVRBX0JVQ0tFVDogZGF0YUJ1Y2tldC5idWNrZXROYW1lLFxyXG4gICAgICAgIEVWRU5UX0JVU19OQU1FOiBldmVudEJ1cy5ldmVudEJ1c05hbWUsXHJcbiAgICAgICAgQ09OTkVDVElPTlNfVEFCTEU6IGNvbm5lY3Rpb25zVGFibGUudGFibGVOYW1lLFxyXG4gICAgICB9LFxyXG4gICAgICB0aW1lb3V0OiBjZGsuRHVyYXRpb24uc2Vjb25kcygzMCksXHJcbiAgICAgIG1lbW9yeVNpemU6IDUxMixcclxuICAgICAgbG9nR3JvdXA6IHN5bmNMb2dHcm91cCxcclxuICAgIH0pO1xyXG5cclxuICAgIGNvbnN0IGFyY2hpdmVMYW1iZGEgPSBuZXcgbGFtYmRhLkZ1bmN0aW9uKHRoaXMsICdBcmNoaXZlRnVuY3Rpb24nLCB7XHJcbiAgICAgIGZ1bmN0aW9uTmFtZTogJ1dlYlRpbWVUcmFja2VyLUFyY2hpdmVGdW5jdGlvbicsXHJcbiAgICAgIHJ1bnRpbWU6IGxhbWJkYS5SdW50aW1lLk5PREVKU18yMF9YLFxyXG4gICAgICBoYW5kbGVyOiAnYXJjaGl2ZS5oYW5kbGVyJyxcclxuICAgICAgY29kZTogbGFtYmRhLkNvZGUuZnJvbUFzc2V0KCdsYW1iZGEvYXJjaGl2ZScpLFxyXG4gICAgICBsYXllcnM6IFtzaGFyZWRMYXllcl0sXHJcbiAgICAgIGVudmlyb25tZW50OiB7XHJcbiAgICAgICAgSE9UX0RBVEFfVEFCTEU6IGhvdERhdGFUYWJsZS50YWJsZU5hbWUsXHJcbiAgICAgICAgREFUQV9CVUNLRVQ6IGRhdGFCdWNrZXQuYnVja2V0TmFtZSxcclxuICAgICAgfSxcclxuICAgICAgdGltZW91dDogY2RrLkR1cmF0aW9uLm1pbnV0ZXMoMTUpLFxyXG4gICAgICBtZW1vcnlTaXplOiAxMDI0LFxyXG4gICAgICBsb2dHcm91cDogYXJjaGl2ZUxvZ0dyb3VwLFxyXG4gICAgfSk7XHJcblxyXG4gICAgY29uc3Qgd2Vic29ja2V0TGFtYmRhID0gbmV3IGxhbWJkYS5GdW5jdGlvbih0aGlzLCAnV2ViU29ja2V0RnVuY3Rpb24nLCB7XHJcbiAgICAgIGZ1bmN0aW9uTmFtZTogJ1dlYlRpbWVUcmFja2VyLVdlYlNvY2tldEZ1bmN0aW9uJyxcclxuICAgICAgcnVudGltZTogbGFtYmRhLlJ1bnRpbWUuTk9ERUpTXzIwX1gsXHJcbiAgICAgIGhhbmRsZXI6ICd3ZWJzb2NrZXQuaGFuZGxlcicsXHJcbiAgICAgIGNvZGU6IGxhbWJkYS5Db2RlLmZyb21Bc3NldCgnbGFtYmRhL3dlYnNvY2tldCcpLFxyXG4gICAgICBsYXllcnM6IFtzaGFyZWRMYXllcl0sXHJcbiAgICAgIGVudmlyb25tZW50OiB7XHJcbiAgICAgICAgQ09OTkVDVElPTlNfVEFCTEU6IGNvbm5lY3Rpb25zVGFibGUudGFibGVOYW1lLFxyXG4gICAgICAgIEVWRU5UX0JVU19OQU1FOiBldmVudEJ1cy5ldmVudEJ1c05hbWUsXHJcbiAgICAgIH0sXHJcbiAgICAgIHRpbWVvdXQ6IGNkay5EdXJhdGlvbi5zZWNvbmRzKDMwKSxcclxuICAgICAgbWVtb3J5U2l6ZTogMjU2LFxyXG4gICAgICBsb2dHcm91cDogd2Vic29ja2V0TG9nR3JvdXAsXHJcbiAgICB9KTtcclxuXHJcbiAgICBjb25zdCBub3RpZnlMYW1iZGEgPSBuZXcgbGFtYmRhLkZ1bmN0aW9uKHRoaXMsICdOb3RpZnlGdW5jdGlvbicsIHtcclxuICAgICAgZnVuY3Rpb25OYW1lOiAnV2ViVGltZVRyYWNrZXItTm90aWZ5RnVuY3Rpb24nLFxyXG4gICAgICBydW50aW1lOiBsYW1iZGEuUnVudGltZS5OT0RFSlNfMjBfWCxcclxuICAgICAgaGFuZGxlcjogJ25vdGlmeS5oYW5kbGVyJyxcclxuICAgICAgY29kZTogbGFtYmRhLkNvZGUuZnJvbUFzc2V0KCdsYW1iZGEvbm90aWZ5JyksXHJcbiAgICAgIGxheWVyczogW3NoYXJlZExheWVyXSxcclxuICAgICAgZW52aXJvbm1lbnQ6IHtcclxuICAgICAgICBDT05ORUNUSU9OU19UQUJMRTogY29ubmVjdGlvbnNUYWJsZS50YWJsZU5hbWUsXHJcbiAgICAgICAgLy8gV2ViU29ja2V0IGVuZHBvaW50IHdpbGwgYmUgYWRkZWQgYWZ0ZXIgV2ViU29ja2V0IEFQSSBjcmVhdGlvblxyXG4gICAgICB9LFxyXG4gICAgICB0aW1lb3V0OiBjZGsuRHVyYXRpb24uc2Vjb25kcygzMCksXHJcbiAgICAgIG1lbW9yeVNpemU6IDI1NixcclxuICAgICAgbG9nR3JvdXA6IG5vdGlmeUxvZ0dyb3VwLFxyXG4gICAgfSk7XHJcblxyXG4gICAgLy8gR3JhbnQgcGVybWlzc2lvbnNcclxuICAgIGhvdERhdGFUYWJsZS5ncmFudFJlYWRXcml0ZURhdGEoc3luY0xhbWJkYSk7XHJcbiAgICBob3REYXRhVGFibGUuZ3JhbnRSZWFkV3JpdGVEYXRhKGFyY2hpdmVMYW1iZGEpO1xyXG4gICAgZGF0YUJ1Y2tldC5ncmFudFJlYWRXcml0ZShzeW5jTGFtYmRhKTtcclxuICAgIGRhdGFCdWNrZXQuZ3JhbnRSZWFkV3JpdGUoYXJjaGl2ZUxhbWJkYSk7XHJcbiAgICBldmVudEJ1cy5ncmFudFB1dEV2ZW50c1RvKHN5bmNMYW1iZGEpO1xyXG4gICAgY29ubmVjdGlvbnNUYWJsZS5ncmFudFJlYWRXcml0ZURhdGEod2Vic29ja2V0TGFtYmRhKTtcclxuICAgIGNvbm5lY3Rpb25zVGFibGUuZ3JhbnRSZWFkV3JpdGVEYXRhKG5vdGlmeUxhbWJkYSk7XHJcblxyXG4gICAgLy8gR3JhbnQgV2ViU29ja2V0IEFQSSBwZXJtaXNzaW9ucyB0byBub3RpZnkgbGFtYmRhXHJcbiAgICBub3RpZnlMYW1iZGEuYWRkVG9Sb2xlUG9saWN5KG5ldyBpYW0uUG9saWN5U3RhdGVtZW50KHtcclxuICAgICAgZWZmZWN0OiBpYW0uRWZmZWN0LkFMTE9XLFxyXG4gICAgICBhY3Rpb25zOiBbXHJcbiAgICAgICAgJ2V4ZWN1dGUtYXBpOk1hbmFnZUNvbm5lY3Rpb25zJ1xyXG4gICAgICBdLFxyXG4gICAgICByZXNvdXJjZXM6IFsnKiddLCAvLyBXaWxsIGJlIHVwZGF0ZWQgYWZ0ZXIgV2ViU29ja2V0IEFQSSBpcyBjcmVhdGVkXHJcbiAgICB9KSk7XHJcblxyXG4gICAgLy8gUkVTVCBBUEkgR2F0ZXdheVxyXG4gICAgY29uc3QgYXBpID0gbmV3IGFwaWdhdGV3YXkuUmVzdEFwaSh0aGlzLCAnV2ViVGltZVRyYWNrZXJBcGknLCB7XHJcbiAgICAgIHJlc3RBcGlOYW1lOiAnV2ViIFRpbWUgVHJhY2tlciBTeW5jIEFQSScsXHJcbiAgICAgIGRlc2NyaXB0aW9uOiAnQVBJIGZvciBXZWIgVGltZSBUcmFja2VyIHJlYWwtdGltZSBzeW5jJyxcclxuICAgICAgZGVmYXVsdENvcnNQcmVmbGlnaHRPcHRpb25zOiB7XHJcbiAgICAgICAgYWxsb3dPcmlnaW5zOiBhcGlnYXRld2F5LkNvcnMuQUxMX09SSUdJTlMsXHJcbiAgICAgICAgYWxsb3dNZXRob2RzOiBhcGlnYXRld2F5LkNvcnMuQUxMX01FVEhPRFMsXHJcbiAgICAgICAgYWxsb3dIZWFkZXJzOiBbXHJcbiAgICAgICAgICAnQ29udGVudC1UeXBlJyxcclxuICAgICAgICAgICdYLUFtei1EYXRlJyxcclxuICAgICAgICAgICdBdXRob3JpemF0aW9uJyxcclxuICAgICAgICAgICdYLUFwaS1LZXknLFxyXG4gICAgICAgICAgJ1gtQW16LVNlY3VyaXR5LVRva2VuJyxcclxuICAgICAgICAgICdYLUNsaWVudC1JZCcsXHJcbiAgICAgICAgXSxcclxuICAgICAgfSxcclxuICAgICAgYXBpS2V5U291cmNlVHlwZTogYXBpZ2F0ZXdheS5BcGlLZXlTb3VyY2VUeXBlLkhFQURFUixcclxuICAgIH0pO1xyXG5cclxuICAgIC8vIEFQSSBpbnRlZ3JhdGlvblxyXG4gICAgY29uc3Qgc3luY0ludGVncmF0aW9uID0gbmV3IGFwaWdhdGV3YXkuTGFtYmRhSW50ZWdyYXRpb24oc3luY0xhbWJkYSwge1xyXG4gICAgICByZXF1ZXN0VGVtcGxhdGVzOiB7ICdhcHBsaWNhdGlvbi9qc29uJzogJ3sgXCJzdGF0dXNDb2RlXCI6IFwiMjAwXCIgfScgfSxcclxuICAgIH0pO1xyXG5cclxuICAgIC8vIEFQSSByZXNvdXJjZXNcclxuICAgIGNvbnN0IHN5bmNSZXNvdXJjZSA9IGFwaS5yb290LmFkZFJlc291cmNlKCdzeW5jJyk7XHJcbiAgICBzeW5jUmVzb3VyY2UuYWRkTWV0aG9kKCdQT1NUJywgc3luY0ludGVncmF0aW9uKTtcclxuICAgIHN5bmNSZXNvdXJjZS5hZGRNZXRob2QoJ0dFVCcsIHN5bmNJbnRlZ3JhdGlvbik7XHJcblxyXG4gICAgY29uc3QgZGF0YVJlc291cmNlID0gYXBpLnJvb3QuYWRkUmVzb3VyY2UoJ2RhdGEnKTtcclxuICAgIGRhdGFSZXNvdXJjZS5hZGRNZXRob2QoJ0dFVCcsIHN5bmNJbnRlZ3JhdGlvbik7XHJcbiAgICBkYXRhUmVzb3VyY2UuYWRkTWV0aG9kKCdQVVQnLCBzeW5jSW50ZWdyYXRpb24pO1xyXG5cclxuICAgIC8vIFdlYlNvY2tldCBBUEkgR2F0ZXdheVxyXG4gICAgY29uc3Qgd2Vic29ja2V0QXBpID0gbmV3IGFwaWdhdGV3YXl2Mi5XZWJTb2NrZXRBcGkodGhpcywgJ1dlYlRpbWVUcmFja2VyV2ViU29ja2V0Jywge1xyXG4gICAgICBhcGlOYW1lOiAnV2ViIFRpbWUgVHJhY2tlciBXZWJTb2NrZXQgQVBJJyxcclxuICAgICAgZGVzY3JpcHRpb246ICdXZWJTb2NrZXQgQVBJIGZvciByZWFsLXRpbWUgc3luYyBub3RpZmljYXRpb25zJyxcclxuICAgICAgY29ubmVjdFJvdXRlT3B0aW9uczoge1xyXG4gICAgICAgIGludGVncmF0aW9uOiBuZXcgaW50ZWdyYXRpb25zLldlYlNvY2tldExhbWJkYUludGVncmF0aW9uKCdDb25uZWN0SW50ZWdyYXRpb24nLCB3ZWJzb2NrZXRMYW1iZGEpLFxyXG4gICAgICB9LFxyXG4gICAgICBkaXNjb25uZWN0Um91dGVPcHRpb25zOiB7XHJcbiAgICAgICAgaW50ZWdyYXRpb246IG5ldyBpbnRlZ3JhdGlvbnMuV2ViU29ja2V0TGFtYmRhSW50ZWdyYXRpb24oJ0Rpc2Nvbm5lY3RJbnRlZ3JhdGlvbicsIHdlYnNvY2tldExhbWJkYSksXHJcbiAgICAgIH0sXHJcbiAgICAgIGRlZmF1bHRSb3V0ZU9wdGlvbnM6IHtcclxuICAgICAgICBpbnRlZ3JhdGlvbjogbmV3IGludGVncmF0aW9ucy5XZWJTb2NrZXRMYW1iZGFJbnRlZ3JhdGlvbignRGVmYXVsdEludGVncmF0aW9uJywgd2Vic29ja2V0TGFtYmRhKSxcclxuICAgICAgfSxcclxuICAgIH0pO1xyXG5cclxuICAgIGNvbnN0IHdlYnNvY2tldFN0YWdlID0gbmV3IGFwaWdhdGV3YXl2Mi5XZWJTb2NrZXRTdGFnZSh0aGlzLCAnV2ViU29ja2V0U3RhZ2UnLCB7XHJcbiAgICAgIHdlYlNvY2tldEFwaTogd2Vic29ja2V0QXBpLFxyXG4gICAgICBzdGFnZU5hbWU6ICdwcm9kJyxcclxuICAgICAgYXV0b0RlcGxveTogdHJ1ZSxcclxuICAgIH0pO1xyXG5cclxuICAgIC8vIFVwZGF0ZSBXZWJTb2NrZXQgcGVybWlzc2lvbnMgd2l0aCBhY3R1YWwgQVJOXHJcbiAgICBub3RpZnlMYW1iZGEuYWRkVG9Sb2xlUG9saWN5KG5ldyBpYW0uUG9saWN5U3RhdGVtZW50KHtcclxuICAgICAgZWZmZWN0OiBpYW0uRWZmZWN0LkFMTE9XLFxyXG4gICAgICBhY3Rpb25zOiBbXHJcbiAgICAgICAgJ2V4ZWN1dGUtYXBpOk1hbmFnZUNvbm5lY3Rpb25zJ1xyXG4gICAgICBdLFxyXG4gICAgICByZXNvdXJjZXM6IFtcclxuICAgICAgICBgYXJuOmF3czpleGVjdXRlLWFwaToke3RoaXMucmVnaW9ufToke3RoaXMuYWNjb3VudH06JHt3ZWJzb2NrZXRBcGkuYXBpSWR9LyR7d2Vic29ja2V0U3RhZ2Uuc3RhZ2VOYW1lfS8qYFxyXG4gICAgICBdLFxyXG4gICAgfSkpO1xyXG5cclxuICAgIC8vIEFkZCBXZWJTb2NrZXQgZW5kcG9pbnQgdG8gbm90aWZ5IGxhbWJkYSBlbnZpcm9ubWVudFxyXG4gICAgbm90aWZ5TGFtYmRhLmFkZEVudmlyb25tZW50KCdXRUJTT0NLRVRfRU5EUE9JTlQnLCB3ZWJzb2NrZXRTdGFnZS51cmwpO1xyXG5cclxuICAgIC8vIEV2ZW50QnJpZGdlIHJ1bGVzXHJcbiAgICBjb25zdCBkYXRhQ2hhbmdlUnVsZSA9IG5ldyBldmVudHMuUnVsZSh0aGlzLCAnRGF0YUNoYW5nZVJ1bGUnLCB7XHJcbiAgICAgIGV2ZW50QnVzLFxyXG4gICAgICBldmVudFBhdHRlcm46IHtcclxuICAgICAgICBzb3VyY2U6IFsnd2ViLXRpbWUtdHJhY2tlciddLFxyXG4gICAgICAgIGRldGFpbFR5cGU6IFsnRGF0YSBVcGRhdGVkJ10sXHJcbiAgICAgIH0sXHJcbiAgICB9KTtcclxuXHJcbiAgICBkYXRhQ2hhbmdlUnVsZS5hZGRUYXJnZXQobmV3IHRhcmdldHMuTGFtYmRhRnVuY3Rpb24obm90aWZ5TGFtYmRhKSk7XHJcblxyXG4gICAgLy8gU2NoZWR1bGVkIGFyY2hpdmluZyBydWxlIChydW5zIGRhaWx5IGF0IDIgQU0pXHJcbiAgICBjb25zdCBhcmNoaXZlUnVsZSA9IG5ldyBldmVudHMuUnVsZSh0aGlzLCAnQXJjaGl2ZVJ1bGUnLCB7XHJcbiAgICAgIHNjaGVkdWxlOiBldmVudHMuU2NoZWR1bGUuY3Jvbih7XHJcbiAgICAgICAgbWludXRlOiAnMCcsXHJcbiAgICAgICAgaG91cjogJzInLFxyXG4gICAgICAgIGRheTogJyonLFxyXG4gICAgICAgIG1vbnRoOiAnKicsXHJcbiAgICAgICAgeWVhcjogJyonLFxyXG4gICAgICB9KSxcclxuICAgIH0pO1xyXG5cclxuICAgIGFyY2hpdmVSdWxlLmFkZFRhcmdldChuZXcgdGFyZ2V0cy5MYW1iZGFGdW5jdGlvbihhcmNoaXZlTGFtYmRhKSk7XHJcblxyXG4gICAgLy8gQVBJIEtleSBmb3IgZXh0ZW5zaW9uIGF1dGhlbnRpY2F0aW9uXHJcbiAgICBjb25zdCBhcGlLZXkgPSBhcGkuYWRkQXBpS2V5KCdXZWJUaW1lVHJhY2tlckFwaUtleScsIHtcclxuICAgICAgYXBpS2V5TmFtZTogJ3dlYi10aW1lLXRyYWNrZXItZXh0ZW5zaW9uLWtleScsXHJcbiAgICAgIGRlc2NyaXB0aW9uOiAnQVBJIGtleSBmb3IgV2ViIFRpbWUgVHJhY2tlciBicm93c2VyIGV4dGVuc2lvbicsXHJcbiAgICB9KTtcclxuXHJcbiAgICBjb25zdCB1c2FnZVBsYW4gPSBhcGkuYWRkVXNhZ2VQbGFuKCdXZWJUaW1lVHJhY2tlclVzYWdlUGxhbicsIHtcclxuICAgICAgbmFtZTogJ3dlYi10aW1lLXRyYWNrZXItdXNhZ2UtcGxhbicsXHJcbiAgICAgIGRlc2NyaXB0aW9uOiAnVXNhZ2UgcGxhbiBmb3IgV2ViIFRpbWUgVHJhY2tlciBBUEknLFxyXG4gICAgICB0aHJvdHRsZToge1xyXG4gICAgICAgIHJhdGVMaW1pdDogMTAwMCxcclxuICAgICAgICBidXJzdExpbWl0OiAyMDAwLFxyXG4gICAgICB9LFxyXG4gICAgICBxdW90YToge1xyXG4gICAgICAgIGxpbWl0OiAxMDAwMDAsXHJcbiAgICAgICAgcGVyaW9kOiBhcGlnYXRld2F5LlBlcmlvZC5NT05USCxcclxuICAgICAgfSxcclxuICAgIH0pO1xyXG5cclxuICAgIHVzYWdlUGxhbi5hZGRBcGlLZXkoYXBpS2V5KTtcclxuICAgIHVzYWdlUGxhbi5hZGRBcGlTdGFnZSh7XHJcbiAgICAgIHN0YWdlOiBhcGkuZGVwbG95bWVudFN0YWdlLFxyXG4gICAgfSk7XHJcblxyXG4gICAgLy8gT3V0cHV0c1xyXG4gICAgdGhpcy5hcGlFbmRwb2ludCA9IGFwaS51cmw7XHJcbiAgICB0aGlzLndlYnNvY2tldEVuZHBvaW50ID0gd2Vic29ja2V0U3RhZ2UudXJsO1xyXG5cclxuICAgIG5ldyBjZGsuQ2ZuT3V0cHV0KHRoaXMsICdBcGlFbmRwb2ludCcsIHtcclxuICAgICAgdmFsdWU6IHRoaXMuYXBpRW5kcG9pbnQsXHJcbiAgICAgIGRlc2NyaXB0aW9uOiAnV2ViIFRpbWUgVHJhY2tlciBBUEkgR2F0ZXdheSBlbmRwb2ludCcsXHJcbiAgICB9KTtcclxuXHJcbiAgICBuZXcgY2RrLkNmbk91dHB1dCh0aGlzLCAnV2ViU29ja2V0RW5kcG9pbnQnLCB7XHJcbiAgICAgIHZhbHVlOiB0aGlzLndlYnNvY2tldEVuZHBvaW50LFxyXG4gICAgICBkZXNjcmlwdGlvbjogJ1dlYiBUaW1lIFRyYWNrZXIgV2ViU29ja2V0IEFQSSBlbmRwb2ludCcsXHJcbiAgICB9KTtcclxuXHJcbiAgICBuZXcgY2RrLkNmbk91dHB1dCh0aGlzLCAnQXBpS2V5SWQnLCB7XHJcbiAgICAgIHZhbHVlOiBhcGlLZXkua2V5SWQsXHJcbiAgICAgIGRlc2NyaXB0aW9uOiAnQVBJIEtleSBJRCBmb3IgYXV0aGVudGljYXRpb24nLFxyXG4gICAgfSk7XHJcblxyXG4gICAgbmV3IGNkay5DZm5PdXRwdXQodGhpcywgJ0RhdGFCdWNrZXQnLCB7XHJcbiAgICAgIHZhbHVlOiBkYXRhQnVja2V0LmJ1Y2tldE5hbWUsXHJcbiAgICAgIGRlc2NyaXB0aW9uOiAnUzMgYnVja2V0IGZvciBkYXRhIHN0b3JhZ2UnLFxyXG4gICAgfSk7XHJcblxyXG4gICAgbmV3IGNkay5DZm5PdXRwdXQodGhpcywgJ0hvdERhdGFUYWJsZScsIHtcclxuICAgICAgdmFsdWU6IGhvdERhdGFUYWJsZS50YWJsZU5hbWUsXHJcbiAgICAgIGRlc2NyaXB0aW9uOiAnRHluYW1vREIgdGFibGUgZm9yIGhvdCBkYXRhJyxcclxuICAgIH0pO1xyXG4gIH1cclxufSJdfQ== \ No newline at end of file diff --git a/src/api/aws.ts b/src/api/aws.ts index 067507ab1..7afe6136b 100644 --- a/src/api/aws.ts +++ b/src/api/aws.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { fetchGet, fetchPost, fetchPut } from "./http" +import { fetchGet, fetchPost } from "./http" export type AwsConfig = { apiEndpoint: string @@ -70,6 +70,39 @@ const DEFAULT_HEADERS = { 'Content-Type': 'application/json', } +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 1000 + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function withRetry(operation: () => Promise, maxRetries = MAX_RETRIES): Promise { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation() + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + + if (attempt === maxRetries) { + break + } + + // Don't retry on authentication errors (4xx) + if (lastError.message.includes('HTTP 4')) { + break + } + + console.warn(`AWS API attempt ${attempt} failed, retrying in ${RETRY_DELAY_MS * attempt}ms...`) + await sleep(RETRY_DELAY_MS * attempt) + } + } + + throw lastError || new Error('Operation failed after retries') +} + function getHeaders(config: AwsConfig, clientId?: string): Record { const headers: Record = { ...DEFAULT_HEADERS, @@ -87,6 +120,14 @@ function getHeaders(config: AwsConfig, clientId?: string): Record { + if (!config.apiEndpoint || !config.apiKey) { + throw new Error('AWS configuration incomplete: missing apiEndpoint or apiKey') + } + + if (!clientId || !rows || rows.length === 0) { + throw new Error('Invalid upload parameters: missing clientId or rows') + } + const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint const url = `${baseUrl}/sync` const headers = getHeaders(config, clientId) @@ -97,20 +138,30 @@ export async function uploadData(config: AwsConfig, clientId: string, rows: time batchId } - const response = await fetchPost(url, body, { headers }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Upload failed: ${response.status} ${error}`) - } - - return await response.json() + return await withRetry(async () => { + const response = await fetchPost(url, body, { headers }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Upload failed: HTTP ${response.status} - ${errorText}`) + } + + return await response.json() + }) } /** * Download data from AWS backend */ export async function downloadData(config: AwsConfig, clientId: string, request: DownloadRequest): Promise { + if (!config.apiEndpoint || !config.apiKey) { + throw new Error('AWS configuration incomplete: missing apiEndpoint or apiKey') + } + + if (!clientId) { + throw new Error('Invalid download parameters: missing clientId') + } + const params = new URLSearchParams() if (request.startDate) params.set('startDate', request.startDate) if (request.endDate) params.set('endDate', request.endDate) @@ -120,14 +171,16 @@ export async function downloadData(config: AwsConfig, clientId: string, request: const url = `${baseUrl}/data?${params.toString()}` const headers = getHeaders(config, clientId) - const response = await fetchGet(url, { headers }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Download failed: ${response.status} ${error}`) - } - - return await response.json() + return await withRetry(async () => { + const response = await fetchGet(url, { headers }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Download failed: HTTP ${response.status} - ${errorText}`) + } + + return await response.json() + }) } /** @@ -170,26 +223,3 @@ export async function testConnection(config: AwsConfig, clientId: string): Promi } } -/** - * Update specific data records - */ -export async function updateData(config: AwsConfig, clientId: string, rows: timer.core.EnhancedRow[]): Promise { - const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint - const url = `${baseUrl}/data` - const headers = getHeaders(config, clientId) - - const body = { - clientId, - rows, - batchId: `update_${Date.now()}` - } - - const response = await fetchPut(url, body, { headers }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Update failed: ${response.status} ${error}`) - } - - return await response.json() -} \ No newline at end of file diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts index 534754a6f..632c5a95b 100644 --- a/src/api/chrome/runtime.ts +++ b/src/api/chrome/runtime.ts @@ -18,7 +18,13 @@ export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: try { // Check if extension context is still valid if (!chrome.runtime?.id) { - console.warn('Extension context invalidated, ignoring message:', code) + // Only log once per minute to avoid spam + const now = Date.now() + const lastWarning = (window as any).__lastContextWarning || 0 + if (now - lastWarning > 60000) { // 60 seconds + console.warn('Extension context invalidated - tracking paused. Reload extension to resume.') + ;(window as any).__lastContextWarning = now + } resolve(undefined) return } @@ -28,7 +34,13 @@ export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: // Check if context was invalidated during the call if (lastError?.includes('Extension context invalidated') || lastError?.includes('message port closed')) { - console.warn('Extension context invalidated during message send:', code) + // Use same throttled logging + const now = Date.now() + const lastWarning = (window as any).__lastContextWarning || 0 + if (now - lastWarning > 60000) { + console.warn('Extension context invalidated - tracking paused. Reload extension to resume.') + ;(window as any).__lastContextWarning = now + } resolve(undefined) return } @@ -42,7 +54,13 @@ export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: // Handle context invalidation gracefully if (errorMsg.includes('Extension context invalidated') || errorMsg.includes('message port closed')) { - console.warn('Extension context invalidated, ignoring message:', code) + // Use same throttled logging + const now = Date.now() + const lastWarning = (window as any).__lastContextWarning || 0 + if (now - lastWarning > 60000) { + console.warn('Extension context invalidated - tracking paused. Reload extension to resume.') + ;(window as any).__lastContextWarning = now + } resolve(undefined) return } diff --git a/src/content-script/tracker/run-time.ts b/src/content-script/tracker/run-time.ts index 9b1a0a487..88a5a5da6 100644 --- a/src/content-script/tracker/run-time.ts +++ b/src/content-script/tracker/run-time.ts @@ -33,6 +33,11 @@ class RunTimeTracker { } private collect() { + // Skip tracking if extension context is invalidated + if (!chrome.runtime?.id) { + return + } + const now = Date.now() const lastTime = this.start diff --git a/src/pages/app/components/Option/components/BackupOption/Footer.tsx b/src/pages/app/components/Option/components/BackupOption/Footer.tsx index 23bb28e91..e59666849 100644 --- a/src/pages/app/components/Option/components/BackupOption/Footer.tsx +++ b/src/pages/app/components/Option/components/BackupOption/Footer.tsx @@ -12,7 +12,6 @@ import Flex from "@pages/components/Flex" import processor from "@service/backup/processor" import metaService from "@service/meta-service" import { formatTime } from "@util/time" -import { syncIntegration } from "@service/sync/sync-integration" import { downloadData } from "@api/aws" import optionHolder from "@service/components/option-holder" import statDatabase from "@db/stat-database" @@ -214,14 +213,8 @@ const _default = defineComponent<{ type: timer.backup.Type }>(props => { }, { deps: () => props.type, onSuccess: setLastTime }) const { refresh: handleBackup } = useManualRequest(async () => { - // For AWS real-time sync, use the new sync integration - if (props.type === 'aws' && syncIntegration.isEnabled()) { - await syncIntegration.forceSync() - return { success: true, data: Date.now() } - } else { - // For other backup types, use the traditional processor - return processor.syncData() - } + // Use the traditional processor for all backup types (manual sync only) + return processor.syncData() }, { loadingText: "Doing backup....", onSuccess: ({ success, data, errorMsg }) => { diff --git a/src/service/backup/aws/coordinator.ts b/src/service/backup/aws/coordinator.ts index b9102571e..400ab9dd9 100644 --- a/src/service/backup/aws/coordinator.ts +++ b/src/service/backup/aws/coordinator.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { downloadData, listClients, testConnection, updateData, uploadData, type AwsConfig, type ConflictInfo } from "@api/aws" +import { downloadData, listClients, testConnection, uploadData, type AwsConfig, type ConflictInfo } from "@api/aws" import { formatTimeYMD } from "@util/time" /** @@ -135,40 +135,42 @@ export default class AwsCoordinator implements timer.backup.Coordinator enhanceRow(row, sessionId, batchId)) - try { + const config = this.getConfig(context) + const sessionId = this.ensureSessionId(context) + const batchId = this.getNextBatchId(context) + + // Convert rows to enhanced format + const enhancedRows = rows.map(row => enhanceRow(row, sessionId, batchId)) + const response = await uploadData(config, context.cid, enhancedRows, batchId) - // Log conflicts for monitoring + // Handle conflicts if any const conflicts = response.results - .filter(r => r.conflicts && r.conflicts.length > 0) - .flatMap(r => r.conflicts!) + ?.filter(r => r.conflicts && r.conflicts.length > 0) + ?.flatMap(r => r.conflicts!) || [] if (conflicts.length > 0) { - console.warn(`AWS: Resolved ${conflicts.length} conflicts during upload:`, conflicts) + console.warn(`AWS: Resolved ${conflicts.length} conflicts during upload`) await this.handleConflicts(context, conflicts) } - // Update sync timestamp + // Update sync timestamp on success context.cache.lastSyncTimestamp = Date.now() await context.handleCacheChanged() console.log(`AWS: Successfully uploaded ${response.successful}/${response.processed} rows`) + // Throw error if some rows failed if (response.failed > 0) { - const failures = response.results.filter(r => r.error) - console.error('AWS: Upload failures:', failures) - throw new Error(`${response.failed} rows failed to upload`) + const errorMsg = `${response.failed} out of ${response.processed} rows failed to upload` + console.error(errorMsg) + throw new Error(errorMsg) } } catch (error) { - console.error('AWS: Failed to upload data:', error) - throw new Error(`Failed to upload data: ${error instanceof Error ? error.message : error}`) + const errorMsg = error instanceof Error ? error.message : String(error) + console.error('AWS: Upload failed:', errorMsg) + throw new Error(`AWS upload failed: ${errorMsg}`) } } @@ -242,17 +244,15 @@ export default class AwsCoordinator implements timer.backup.Coordinator enhanceRow(row, sessionId, batchId)) - try { - const response = await updateData(config, context.cid, enhancedRows) + const config = this.getConfig(context) + const sessionId = this.ensureSessionId(context) + const batchId = this.getNextBatchId(context) - console.log(`AWS: Incremental sync completed: ${response.successful}/${response.processed} rows`) + // Convert rows to enhanced format + const enhancedRows = rows.map(row => enhanceRow(row, sessionId, batchId)) + + const response = await uploadData(config, context.cid, enhancedRows, batchId) if (response.failed > 0) { console.warn(`AWS: ${response.failed} rows failed in incremental sync`) diff --git a/src/service/sync/background-sync-service.ts b/src/service/sync/background-sync-service.ts index e95cd7ee1..0ccd87349 100644 --- a/src/service/sync/background-sync-service.ts +++ b/src/service/sync/background-sync-service.ts @@ -10,7 +10,6 @@ import statDatabase from "@db/stat-database" import optionHolder from "@service/components/option-holder" import metaService from "@service/meta-service" import { formatTimeYMD } from "@util/time" -import { hybridSyncManager } from "./hybrid-sync-manager" import AwsCoordinator from "@service/backup/aws/coordinator" type PendingSync = { @@ -37,7 +36,7 @@ export class BackgroundSyncService { private isProcessing = false private readonly maxRetries = 3 private readonly batchSize = 50 // Max rows per sync batch - private readonly syncIntervalMs = 15000 // 15 seconds between batch syncs + private readonly syncIntervalMs = 60000 // 1 minute between batch syncs private readonly maxQueueSize = 1000 // Max rows in queue before dropping oldest private syncInterval: NodeJS.Timeout | null = null private stats: SyncStats = { @@ -62,10 +61,6 @@ export class BackgroundSyncService { this.startBackgroundSync() } - // Listen for hybrid sync manager events - hybridSyncManager.on('data-updated', (data: any) => { - this.handleRemoteUpdate(data) - }) } private setupEventListeners(): void { @@ -165,7 +160,11 @@ export class BackgroundSyncService { * Queue rows for background sync */ queueForSync(rows: timer.core.Row[]): void { - if (!this.isEnabled || !rows || rows.length === 0) { + if (!this.isEnabled) { + return + } + + if (!rows || rows.length === 0) { return } @@ -181,12 +180,9 @@ export class BackgroundSyncService { // Remove old items if queue is too large while (this.syncQueue.length > this.maxQueueSize) { - const removed = this.syncQueue.shift() - console.warn('BackgroundSyncService: Dropping old sync batch:', removed?.batchId) + this.syncQueue.shift() } - console.log(`BackgroundSyncService: Queued ${rows.length} rows for sync (${this.syncQueue.length} batches pending)`) - // Process immediately if online and not already processing if (this.stats.isOnline && !this.isProcessing) { setTimeout(() => this.processQueue(), 1000) // Small delay to batch multiple calls @@ -253,18 +249,44 @@ export class BackgroundSyncService { const { option, coordinator, errorMsg } = await processor.checkAuth() if (errorMsg || !coordinator || option.backupType !== 'aws') { - throw new Error(errorMsg || 'AWS sync not configured') + const error = errorMsg || 'AWS sync not configured' + throw new Error(error) } - const clientId = await metaService.getCid() + let clientId = await metaService.getCid() + + // Generate client ID if none exists (same logic as processor) if (!clientId) { - throw new Error('No client ID available') + const uaData = (navigator as any)?.userAgentData + let prefix = 'unknown' + if (uaData) { + const brand = uaData.brands + ?.map((e: any) => e.brand) + ?.find((b: any) => b.toLowerCase().includes('chrome')) + const platform = uaData.platform?.toLowerCase() + if (platform && brand) { + prefix = `${platform}-${brand.toLowerCase().replace(/\s/g, '-')}` + } + } + clientId = `${prefix}-${Date.now()}` + await metaService.updateCid(clientId) + } + + // For AWS sync, use user-specified client name if available + let awsClientId = clientId + if (option.backupType === 'aws' && option.clientName && option.clientName !== 'unknown') { + awsClientId = option.clientName + } + + // Create context for the coordinator (using same structure as processor) + const auth: timer.backup.Auth = { + token: option.backupAuths?.aws, + login: option.backupLogin?.aws } - // Create context for the coordinator const context: timer.backup.CoordinatorContext = { - cid: clientId, - auth: { token: option.backupAuths?.aws }, + cid: awsClientId, + auth, ext: option.backupExts?.aws, cache: {}, handleCacheChanged: async () => { @@ -281,56 +303,6 @@ export class BackgroundSyncService { } } - /** - * Handle remote updates from other clients - */ - private handleRemoteUpdate(data: any): void { - if (!data.rows || !Array.isArray(data.rows)) { - return - } - - console.log(`BackgroundSyncService: Received ${data.rows.length} remote updates`) - - // Merge remote data into local database - this.mergeRemoteData(data.rows) - } - - /** - * Merge remote data into local database - */ - private async mergeRemoteData(remoteRows: timer.backup.Row[]): Promise { - try { - const localClientId = await metaService.getCid() - - for (const remoteRow of remoteRows) { - // Skip our own data - if (remoteRow.cid === localClientId) { - continue - } - - const { host, date, focus, time } = remoteRow - - // Get existing local data - const existing = await statDatabase.get(host, date) - - // Simple conflict resolution: take maximum values - const mergedFocus = Math.max(existing.focus || 0, focus || 0) - const mergedTime = Math.max(existing.time || 0, time || 0) - - // Update local data if there's a change - if (mergedFocus !== (existing.focus || 0) || mergedTime !== (existing.time || 0)) { - await statDatabase.accumulate(host, date, { - focus: mergedFocus - (existing.focus || 0), - time: mergedTime - (existing.time || 0) - }) - } - } - - console.log(`BackgroundSyncService: Merged ${remoteRows.length} remote rows`) - } catch (error) { - console.error('BackgroundSyncService: Error merging remote data:', error) - } - } /** * Save pending syncs to storage for recovery diff --git a/src/service/sync/hybrid-sync-manager.ts b/src/service/sync/hybrid-sync-manager.ts index d73d582fa..593aa3736 100644 --- a/src/service/sync/hybrid-sync-manager.ts +++ b/src/service/sync/hybrid-sync-manager.ts @@ -24,6 +24,7 @@ export class HybridSyncManager { private websocket: WebSocket | null = null private pollInterval: NodeJS.Timeout | null = null private reconnectTimeout: NodeJS.Timeout | null = null + private heartbeatInterval: NodeJS.Timeout | null = null private backoffMultiplier = 1 private readonly maxBackoffMs = 60000 // 1 minute max backoff private readonly baseBackoffMs = 1000 // 1 second base backoff @@ -77,6 +78,7 @@ export class HybridSyncManager { this.closeWebSocket() this.stopPolling() + this.stopHeartbeat() this.clearReconnectTimeout() this.connectionState = 'disconnected' this.emit('sync-status', { connected: false, method: 'none' }) @@ -130,12 +132,27 @@ export class HybridSyncManager { this.backoffMultiplier = 1 // Reset backoff on successful connection this.stopPolling() // Stop polling when WebSocket works this.emit('sync-status', { connected: true, method: 'websocket' }) + + // Start heartbeat to keep connection alive + this.startHeartbeat() } this.websocket.onmessage = (event) => { try { - const syncEvent: SyncEvent = JSON.parse(event.data) - this.handleSyncEvent(syncEvent) + const message = JSON.parse(event.data) + + // Handle WebSocket protocol messages (ping/pong, etc.) + if (message.action) { + this.handleProtocolMessage(message) + } + // Handle sync events + else if (message.type) { + this.handleSyncEvent(message as SyncEvent) + } + // Handle unknown message format + else { + console.warn('HybridSyncManager: Message has no type or action field:', message) + } } catch (error) { console.error('HybridSyncManager: Error parsing WebSocket message:', error) } @@ -278,11 +295,31 @@ export class HybridSyncManager { } } + /** + * Handle WebSocket protocol messages (ping/pong, subscribe, etc.) + */ + private handleProtocolMessage(message: any): void { + switch (message.action) { + case 'pong': + // Handle pong response to keep-alive ping + break + case 'subscribed': + // Handle subscription confirmation + break + default: + // Ignore unknown protocol messages + break + } + } + /** * Handle incoming sync events */ private handleSyncEvent(event: SyncEvent): void { - console.log('HybridSyncManager: Received sync event:', event.type) + if (!event || !event.type) { + console.warn('HybridSyncManager: Invalid sync event:', event) + return + } switch (event.type) { case 'data-updated': @@ -314,7 +351,12 @@ export class HybridSyncManager { * Subscribe to ping to keep connection alive */ private startHeartbeat(): void { - setInterval(() => { + // Clear any existing heartbeat + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + } + + this.heartbeatInterval = setInterval(() => { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { this.websocket.send(JSON.stringify({ action: 'ping', @@ -324,6 +366,16 @@ export class HybridSyncManager { }, 30000) // Ping every 30 seconds } + /** + * Stop heartbeat + */ + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + } + } + /** * Add event listener */ diff --git a/src/service/sync/sync-integration.ts b/src/service/sync/sync-integration.ts index e8c1d7965..6b2066736 100644 --- a/src/service/sync/sync-integration.ts +++ b/src/service/sync/sync-integration.ts @@ -5,13 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { backgroundSyncService } from "./background-sync-service" -import { hybridSyncManager } from "./hybrid-sync-manager" import statDatabase from "@db/stat-database" import optionHolder from "@service/components/option-holder" /** - * Integration layer that automatically syncs database changes + * Integration layer - Background sync disabled, manual sync only */ class SyncIntegration { private isInitialized = false @@ -26,192 +24,19 @@ class SyncIntegration { const option = await optionHolder.get() this.isAwsSyncEnabled = option.backupType === 'aws' - if (this.isAwsSyncEnabled) { - console.log('SyncIntegration: Initializing AWS real-time sync') - - // Start sync services - await hybridSyncManager.setEnabled(true) - await backgroundSyncService.setEnabled(true) - - // Hook into database operations - this.wrapDatabaseMethods() - - console.log('SyncIntegration: AWS real-time sync initialized successfully') - } + // Background sync disabled - manual sync only + console.log('SyncIntegration: Background sync disabled, manual sync only') this.isInitialized = true } - /** - * Wrap database methods to automatically queue changes for sync - */ - private wrapDatabaseMethods(): void { - // Store original methods - const originalAccumulate = statDatabase.accumulate.bind(statDatabase) - const originalAccumulateBatch = statDatabase.accumulateBatch.bind(statDatabase) - const originalForceUpdate = statDatabase.forceUpdate.bind(statDatabase) - - // Wrap accumulate method - statDatabase.accumulate = async (host: string, date: Date | string, item: timer.core.Result): Promise => { - const result = await originalAccumulate(host, date, item) - - // Queue for sync if AWS is enabled - if (this.isAwsSyncEnabled) { - this.queueRowForSync(host, date, result) - } - - return result - } - - // Wrap accumulateBatch method - statDatabase.accumulateBatch = async (data: Record, date: Date | string): Promise> => { - const results = await originalAccumulateBatch(data, date) - - // Queue all rows for sync if AWS is enabled - if (this.isAwsSyncEnabled) { - Object.entries(results).forEach(([host, result]) => { - this.queueRowForSync(host, date, result) - }) - } - - return results - } - - // Wrap forceUpdate method - statDatabase.forceUpdate = async (row: timer.core.Row): Promise => { - await originalForceUpdate(row) - - // Queue for sync if AWS is enabled - if (this.isAwsSyncEnabled) { - this.queueRowForSync(row.host, row.date, { - focus: row.focus || 0, - time: row.time || 0, - run: row.run - }) - } - } - - console.log('SyncIntegration: Database methods wrapped for automatic sync') - } - - /** - * Queue a single row for background sync - */ - private queueRowForSync(host: string, date: Date | string, result: timer.core.Result): void { - const dateStr = typeof date === 'string' ? date : this.formatDate(date) - - const row: timer.core.Row = { - host, - date: dateStr, - focus: result.focus || 0, - time: result.time || 0, - run: result.run - } - - backgroundSyncService.queueForSync([row]) - } - - /** - * Format date as YYYYMMDD - */ - private formatDate(date: Date): string { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}${month}${day}` - } - - /** - * Enable/disable sync integration - */ - async setEnabled(enabled: boolean): Promise { - this.isAwsSyncEnabled = enabled - - if (enabled) { - await this.init() - } else { - await hybridSyncManager.setEnabled(false) - await backgroundSyncService.setEnabled(false) - } - } - /** * Check if sync integration is enabled */ isEnabled(): boolean { return this.isAwsSyncEnabled } - - /** - * Get sync status from both services - */ - getStatus(): { - hybrid: ReturnType - background: ReturnType - } { - return { - hybrid: hybridSyncManager.getStatus(), - background: backgroundSyncService.getStats() - } - } - - /** - * Force immediate sync - */ - async forceSync(): Promise { - if (!this.isAwsSyncEnabled) { - throw new Error('AWS sync not enabled') - } - - await Promise.all([ - hybridSyncManager.forcSync(), - backgroundSyncService.forceSync() - ]) - } - - /** - * Listen for remote data updates - */ - onRemoteUpdate(callback: (data: any) => void): void { - hybridSyncManager.on('data-updated', callback) - } - - /** - * Listen for sync status changes - */ - onSyncStatusChange(callback: (status: any) => void): void { - hybridSyncManager.on('sync-status', callback) - } -} - -// Global singleton instance -export const syncIntegration = new SyncIntegration() - -// Initialize when extension is ready -let isInitializing = false -const initWhenReady = async () => { - if (isInitializing) return - - try { - isInitializing = true - - // Wait for extension context to be stable - if (!chrome?.runtime?.id) { - isInitializing = false - setTimeout(initWhenReady, 1000) - return - } - - await syncIntegration.init() - console.log('SyncIntegration initialized successfully') - isInitializing = false - } catch (error) { - console.error('Failed to initialize SyncIntegration:', error) - isInitializing = false - // Retry after delay if initialization fails - setTimeout(initWhenReady, 3000) - } } -// Start initialization -initWhenReady() \ No newline at end of file +// Global singleton instance - background sync disabled +export const syncIntegration = new SyncIntegration() \ No newline at end of file From a34e782f6fe769229cd7ff9ce34d6e92a9bc6ae9 Mon Sep 17 00:00:00 2001 From: 0HugoHu <0@hugohu.top> Date: Sun, 14 Sep 2025 02:52:46 -0700 Subject: [PATCH 6/9] refactor: remove other sync methods except the AWS one --- .gitignore | 2 + script/crowdin/client.ts | 305 ---------- script/crowdin/common.ts | 220 ------- script/crowdin/export-translation.ts | 77 --- script/crowdin/sync-source.ts | 104 ---- script/crowdin/sync-translation.ts | 90 --- script/user-chart/add.ts | 169 ------ script/user-chart/common.ts | 31 - script/user-chart/render.ts | 220 ------- script/user-chart/user-chart.d.ts | 6 - src/api/crowdin.ts | 61 -- src/api/gist.ts | 165 ----- src/api/obsidian.ts | 62 -- src/api/web-dav.ts | 111 ---- src/background/content-script-handler.ts | 14 +- src/background/index.ts | 3 - src/background/limit-processor.ts | 88 --- src/background/migrator/index.ts | 2 - .../migrator/limit-rule-migrator.ts | 35 -- src/background/track-server.ts | 25 +- src/content-script/index.ts | 2 - src/content-script/limit/common.ts | 43 -- src/content-script/limit/element.ts | 7 - src/content-script/limit/index.ts | 42 -- src/content-script/limit/modal/Main.tsx | 22 - .../limit/modal/components/Alert.tsx | 26 - .../limit/modal/components/Footer.tsx | 81 --- .../limit/modal/components/Reason.tsx | 139 ----- src/content-script/limit/modal/context.ts | 53 -- src/content-script/limit/modal/index.ts | 208 ------- .../limit/modal/style/element-base.css | 303 ---------- .../limit/modal/style/index.sass | 59 -- .../limit/processor/message-adaptor.ts | 66 -- .../limit/processor/period-processor.ts | 55 -- .../limit/processor/visit-processor.ts | 72 --- .../limit/reminder/component.ts | 128 ---- src/content-script/limit/reminder/index.ts | 45 -- src/database/limit-database.ts | 274 --------- src/i18n/message/app/index.ts | 6 - src/i18n/message/app/limit-resource.json | 567 ------------------ src/i18n/message/app/limit.ts | 75 --- src/i18n/message/app/menu-resource.json | 22 - src/i18n/message/app/menu.ts | 2 - src/i18n/message/app/option.ts | 18 - src/i18n/message/cs/index.ts | 3 - src/pages/app/Layout/menu/item.ts | 12 +- src/pages/app/components/BackupSync/index.tsx | 23 + .../app/components/HelpUs/MemberList.tsx | 47 -- .../app/components/HelpUs/ProgressList.tsx | 119 ---- src/pages/app/components/HelpUs/index.tsx | 37 -- .../app/components/Limit/LimitFilter.tsx | 89 --- .../Limit/LimitModify/Sop/Step1.tsx | 44 -- .../Limit/LimitModify/Sop/Step2.tsx | 93 --- .../LimitModify/Sop/Step3/PeriodInput.tsx | 152 ----- .../Limit/LimitModify/Sop/Step3/TimeInput.tsx | 259 -------- .../Limit/LimitModify/Sop/Step3/index.tsx | 61 -- .../LimitModify/Sop/Step3/period-input.sass | 16 - .../Limit/LimitModify/Sop/context.ts | 95 --- .../Limit/LimitModify/Sop/index.tsx | 65 -- .../components/Limit/LimitModify/index.tsx | 84 --- .../Limit/LimitTable/RuleContent.tsx | 78 --- .../app/components/Limit/LimitTable/Waste.tsx | 52 -- .../components/Limit/LimitTable/Weekday.tsx | 28 - .../column/LimitOperationColumn.tsx | 61 -- .../app/components/Limit/LimitTable/index.tsx | 179 ------ src/pages/app/components/Limit/LimitTest.tsx | 98 --- src/pages/app/components/Limit/common.ts | 21 - src/pages/app/components/Limit/context.ts | 176 ------ src/pages/app/components/Limit/index.tsx | 31 - src/pages/app/components/Limit/types.d.ts | 4 - src/pages/app/components/Option/Select.tsx | 2 +- src/pages/app/components/Option/common.tsx | 4 +- .../Option/components/BackupOption/index.tsx | 114 ---- .../Option/components/LimitOption/index.tsx | 182 ------ .../components/LimitOption/limit-option.sass | 19 - .../components/LimitOption/usePswEdit.tsx | 60 -- .../components/LimitOption/useVerify.ts | 20 - src/pages/app/components/Option/index.tsx | 6 - src/pages/app/router/constants.ts | 6 - src/pages/app/router/index.ts | 13 +- src/pages/app/util/limit.tsx | 190 ------ .../popup/components/Header/LangSelect.tsx | 6 - src/service/backup/gist/compressor.ts | 107 ---- src/service/backup/gist/coordinator.ts | 218 ------- src/service/backup/markdown.ts | 114 ---- src/service/backup/obsidian/coordinator.ts | 121 ---- src/service/backup/processor.ts | 6 - src/service/backup/web-dav/coordinator.ts | 133 ---- src/service/components/immigration.ts | 2 - src/service/limit-service/index.ts | 233 ------- .../limit-service/verification/common.ts | 38 -- .../verification/generator/confession.ts | 25 - .../verification/generator/index.ts | 18 - .../verification/generator/pi.ts | 45 -- .../verification/generator/ugly.ts | 33 - .../generator/uncommon-chinese.ts | 32 - .../limit-service/verification/processor.ts | 34 -- src/util/limit.ts | 127 ---- test-e2e/limit/common.ts | 75 --- test-e2e/limit/daily-time.test.ts | 89 --- test-e2e/limit/daily-visit.test.ts | 78 --- test-e2e/limit/visit-limit.test.ts | 47 -- .../background/backup/gist/compressor.test.ts | 49 -- test/database/limit-database.test.ts | 143 ----- test/util/limit.test.ts | 208 ------- types/timer/backup.d.ts | 6 - types/timer/limit.d.ts | 139 ----- 107 files changed, 38 insertions(+), 8736 deletions(-) delete mode 100644 script/crowdin/client.ts delete mode 100644 script/crowdin/common.ts delete mode 100644 script/crowdin/export-translation.ts delete mode 100644 script/crowdin/sync-source.ts delete mode 100644 script/crowdin/sync-translation.ts delete mode 100644 script/user-chart/add.ts delete mode 100644 script/user-chart/common.ts delete mode 100644 script/user-chart/render.ts delete mode 100644 script/user-chart/user-chart.d.ts delete mode 100644 src/api/crowdin.ts delete mode 100644 src/api/gist.ts delete mode 100644 src/api/obsidian.ts delete mode 100644 src/api/web-dav.ts delete mode 100644 src/background/limit-processor.ts delete mode 100644 src/background/migrator/limit-rule-migrator.ts delete mode 100644 src/content-script/limit/common.ts delete mode 100644 src/content-script/limit/element.ts delete mode 100644 src/content-script/limit/index.ts delete mode 100644 src/content-script/limit/modal/Main.tsx delete mode 100644 src/content-script/limit/modal/components/Alert.tsx delete mode 100644 src/content-script/limit/modal/components/Footer.tsx delete mode 100644 src/content-script/limit/modal/components/Reason.tsx delete mode 100644 src/content-script/limit/modal/context.ts delete mode 100644 src/content-script/limit/modal/index.ts delete mode 100644 src/content-script/limit/modal/style/element-base.css delete mode 100644 src/content-script/limit/modal/style/index.sass delete mode 100644 src/content-script/limit/processor/message-adaptor.ts delete mode 100644 src/content-script/limit/processor/period-processor.ts delete mode 100644 src/content-script/limit/processor/visit-processor.ts delete mode 100644 src/content-script/limit/reminder/component.ts delete mode 100644 src/content-script/limit/reminder/index.ts delete mode 100644 src/database/limit-database.ts delete mode 100644 src/i18n/message/app/limit-resource.json delete mode 100644 src/i18n/message/app/limit.ts create mode 100644 src/pages/app/components/BackupSync/index.tsx delete mode 100644 src/pages/app/components/HelpUs/MemberList.tsx delete mode 100644 src/pages/app/components/HelpUs/ProgressList.tsx delete mode 100644 src/pages/app/components/HelpUs/index.tsx delete mode 100644 src/pages/app/components/Limit/LimitFilter.tsx delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step2.tsx delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step3/period-input.sass delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/context.ts delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/index.tsx delete mode 100644 src/pages/app/components/Limit/LimitModify/index.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/RuleContent.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/Waste.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/Weekday.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/index.tsx delete mode 100644 src/pages/app/components/Limit/LimitTest.tsx delete mode 100644 src/pages/app/components/Limit/common.ts delete mode 100644 src/pages/app/components/Limit/context.ts delete mode 100644 src/pages/app/components/Limit/index.tsx delete mode 100644 src/pages/app/components/Limit/types.d.ts delete mode 100644 src/pages/app/components/Option/components/LimitOption/index.tsx delete mode 100644 src/pages/app/components/Option/components/LimitOption/limit-option.sass delete mode 100644 src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx delete mode 100644 src/pages/app/components/Option/components/LimitOption/useVerify.ts delete mode 100644 src/pages/app/util/limit.tsx delete mode 100644 src/service/backup/gist/compressor.ts delete mode 100644 src/service/backup/gist/coordinator.ts delete mode 100644 src/service/backup/markdown.ts delete mode 100644 src/service/backup/obsidian/coordinator.ts delete mode 100644 src/service/backup/web-dav/coordinator.ts delete mode 100644 src/service/limit-service/index.ts delete mode 100644 src/service/limit-service/verification/common.ts delete mode 100644 src/service/limit-service/verification/generator/confession.ts delete mode 100644 src/service/limit-service/verification/generator/index.ts delete mode 100644 src/service/limit-service/verification/generator/pi.ts delete mode 100644 src/service/limit-service/verification/generator/ugly.ts delete mode 100644 src/service/limit-service/verification/generator/uncommon-chinese.ts delete mode 100644 src/service/limit-service/verification/processor.ts delete mode 100644 src/util/limit.ts delete mode 100644 test-e2e/limit/common.ts delete mode 100644 test-e2e/limit/daily-time.test.ts delete mode 100644 test-e2e/limit/daily-visit.test.ts delete mode 100644 test-e2e/limit/visit-limit.test.ts delete mode 100644 test/background/backup/gist/compressor.test.ts delete mode 100644 test/database/limit-database.test.ts delete mode 100644 test/util/limit.test.ts delete mode 100644 types/timer/limit.d.ts diff --git a/.gitignore b/.gitignore index 700bd8dba..dbd4c26d7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ aaa user-chart.svg test.log /cdk/cdk.out/ +/cdk/bin/ +/.claude/settings.local.json diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts deleted file mode 100644 index 2b9361f56..000000000 --- a/script/crowdin/client.ts +++ /dev/null @@ -1,305 +0,0 @@ -import Crowdin, { - type Credentials, - type Pagination, - type PatchRequest, - type ResponseList, - type SourceFilesModel, - type SourceStringsModel, - type StringTranslationsModel, - type UploadStorageModel, -} from '@crowdin/crowdin-api-client' -import { ALL_CROWDIN_LANGUAGES, type CrowdinLanguage, type Dir, type ItemSet } from './common' - -const PROJECT_ID = 516822 - -const MAIN_BRANCH_NAME = 'main' - -/** - * The iterator of response - */ -class PaginationIterator { - private offset = 0 - private limit = 25 - private isEnd = false - private buf: T[] = [] - private cursor = 0 - private query: (pagination: Pagination) => Promise> - - constructor(query: (pagination: Pagination) => Promise>) { - this.query = query - } - - reset(): void { - this.offset = 0 - this.isEnd = false - this.buf = [] - this.cursor = 0 - } - - async findFirst(predicate: (ele: T) => boolean): Promise { - while (true) { - const data = await this.next() - if (!data) { - break - } - if (data && predicate(data)) { - return data - } - } - return undefined - } - - async findAll(predicate?: ((ele: T) => boolean)): Promise { - const result: T[] = [] - while (true) { - const data = await this.next() - if (!data) { - break - } - if (predicate ? predicate(data) : true) { - result.push(data) - } - } - return result - } - - async next(): Promise { - if (this.isEnd) { - return undefined - } - if (this.cursor >= this.buf.length) { - await this.processBuf() - } - if (this.isEnd) { - return undefined - } - return this.buf[this.cursor++] - } - - 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) { - this.isEnd = true - } else { - this.buf = data.map(obj => obj.data) - this.cursor = 0 - this.offset += this.buf.length - } - } -} - -/** - * Key of crowdin file/directory - */ -export type NameKey = { - name: Dir - branchId: number -} - -export type TranslationKey = { - stringId: number - lang: CrowdinLanguage -} - -/** - * The wrapper of client with auth - */ -export class CrowdinClient { - crowdin: Crowdin - - constructor(token: string) { - const credentials: Credentials = { - token: token - } - this.crowdin = new Crowdin(credentials) - console.info("Initialized client successfully") - } - - async createStorage(fileName: string, content: any): Promise { - const response = await this.crowdin.uploadStorageApi.addStorage(fileName, content) - return response.data - } - - /** - * Get the main branch - * - * @returns main branch or undefined - */ - - async getMainBranch(): Promise { - return new PaginationIterator( - pagination => this.crowdin.sourceFilesApi.listProjectBranches(PROJECT_ID, { ...pagination }) - ).findFirst(e => e.name === MAIN_BRANCH_NAME) - } - - /** - * Create the main branch - */ - async createMainBranch(): Promise { - const request: SourceFilesModel.CreateBranchRequest = { - name: MAIN_BRANCH_NAME - } - const res = await this.crowdin.sourceFilesApi.createBranch(PROJECT_ID, request) - return res.data - } - - async getOrCreateMainBranch(): Promise { - let branch = await this.getMainBranch() - if (!branch) { - branch = await this.createMainBranch() - } - console.info("getOrCreateMainBranch: " + JSON.stringify(branch)) - return branch - } - - async createFile( - directoryId: number, - storage: UploadStorageModel.Storage, - fileName: string - ): Promise { - const request: SourceFilesModel.CreateFileRequest = { - name: fileName, - storageId: storage.id, - directoryId, - type: 'json', - } - const response = await this.crowdin.sourceFilesApi.createFile(PROJECT_ID, request) - return response.data - } - - async restoreFile(storage: UploadStorageModel.Storage, existFile: SourceFilesModel.File): Promise { - const response = await this.crowdin.sourceFilesApi.updateOrRestoreFile(PROJECT_ID, existFile.id, { storageId: storage.id }) - return response.data - } - - getFileByName(param: NameKey): Promise { - return new PaginationIterator( - p => this.crowdin.sourceFilesApi.listProjectFiles(PROJECT_ID, { ...p, branchId: param.branchId }) - ).findFirst(t => t.name === param.name) - } - - getDirByName(param: NameKey): Promise { - return new PaginationIterator( - p => this.crowdin.sourceFilesApi.listProjectDirectories(PROJECT_ID, { ...p, branchId: param.branchId }) - ).findFirst(d => d.name === param.name) - } - - async createDirectory(param: NameKey): Promise { - const res = await this.crowdin.sourceFilesApi.createDirectory(PROJECT_ID, { - name: param.name, - branchId: param.branchId, - }) - return res.data - } - - listFilesByDirectory(directoryId: number) { - return new PaginationIterator( - p => this.crowdin.sourceFilesApi.listProjectFiles(PROJECT_ID, { ...p, directoryId: directoryId }) - ).findAll() - } - - listStringsByFile(fileId: number): Promise { - return new PaginationIterator( - p => this.crowdin.sourceStringsApi.listProjectStrings(PROJECT_ID, { ...p, fileId: fileId }) - ).findAll() - } - - async batchCreateString( - fileId: number, - content: ItemSet, - ): Promise { - for (const [path, value] of Object.entries(content)) { - const request: SourceStringsModel.CreateStringRequest = { - fileId, - text: value, - identifier: path, - } - console.log(`Try to create new string: ${JSON.stringify(request)}`) - await this.crowdin.sourceStringsApi.addString(PROJECT_ID, request) - } - } - - async batchUpdateIfNecessary( - content: ItemSet, - existStringsKeyMap: { [path: string]: SourceStringsModel.String } - ): Promise { - console.log("=========start to update strings========") - console.log("Content length: " + Object.keys(content).length) - for (const [path, value] of Object.entries(content)) { - const string = existStringsKeyMap[path] - const patch: PatchRequest[] = [] - string?.text !== value && patch.push({ - op: 'replace', - path: '/text', - value: value - }) - if (!patch.length) { - continue - } - console.log('Try to edit string: ' + string.identifier) - await this.crowdin.sourceStringsApi.editString(PROJECT_ID, string.id, patch) - } - console.log("=========end to update strings========") - } - - async batchDeleteString(stringIds: number[]): Promise { - console.log("=========start to delete strings========") - for (const stringId of stringIds) { - await this.crowdin.sourceStringsApi.deleteString(PROJECT_ID, stringId) - console.log("Delete string: id=" + stringId) - } - console.log("=========end to delete strings========") - } - - async listTranslationByStringAndLang(transKey: TranslationKey): Promise { - const { stringId, lang } = transKey - return await new PaginationIterator( - p => this.crowdin.stringTranslationsApi.listStringTranslations(PROJECT_ID, stringId, lang, { ...p }) - ).findAll() - } - - async deleteTranslation(translationId: number): Promise { - await this.crowdin.stringTranslationsApi.deleteTranslation(PROJECT_ID, translationId) - } - - async createTranslation(transKey: TranslationKey, text: string) { - const { stringId, lang } = transKey - const request: StringTranslationsModel.AddStringTranslationRequest = { - stringId, - languageId: lang, - text - } - await this.crowdin.stringTranslationsApi.addTranslation(PROJECT_ID, request) - } - - async buildProjectTranslation(branchId: number) { - const buildRes = await this.crowdin.translationsApi.buildProject(PROJECT_ID, { - branchId, - targetLanguageIds: [...ALL_CROWDIN_LANGUAGES], - skipUntranslatedStrings: true, - }) - 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 - } - } -} - -/** - * Get the client from environment variable [TIMER_CROWDIN_AUTH] - * - * @returns client - */ -export function getClientFromEnv(): CrowdinClient { - const envVar = process.env?.TIMER_CROWDIN_AUTH - if (!envVar) { - console.error("Failed to get the variable named [TIMER_CROWDIN_AUTH]") - process.exit(1) - } - return new CrowdinClient(envVar) -} diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts deleted file mode 100644 index d02a4399a..000000000 --- a/script/crowdin/common.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { type SourceFilesModel } from '@crowdin/crowdin-api-client' -import fs from 'fs' -import path from 'path' -import { exitWith } from '../util/process' -import { type CrowdinClient } from './client' - -export const ALL_DIRS = ['app', 'common', 'popup', 'side', 'cs'] as const - -/** - * The directory of messages - */ -export type Dir = typeof ALL_DIRS[number] - -/** - * Items of message - */ -export type ItemSet = { - [path: string]: string -} - -export const ALL_CROWDIN_LANGUAGES = ['zh-TW', 'ja', 'pt-PT', 'uk', 'es-ES', 'de', 'fr', 'ru', 'ar'] as const - -/** - * The language code of crowdin - * - * @see https://developer.crowdin.com/language-codes/ - */ -export type CrowdinLanguage = typeof ALL_CROWDIN_LANGUAGES[number] - -export const SOURCE_LOCALE: timer.SourceLocale = 'en' - -// Not include en and zh_CN -export const ALL_TRANS_LOCALES: timer.OptionalLocale[] = [ - 'ja', - 'zh_TW', - 'pt_PT', - 'uk', - 'es', - 'de', - 'fr', - 'ru', - 'ar', -] - -const CROWDIN_I18N_MAP: Record = { - ja: 'ja', - 'zh-TW': 'zh_TW', - 'pt-PT': 'pt_PT', - uk: 'uk', - 'es-ES': 'es', - de: 'de', - fr: 'fr', - ru: 'ru', - ar: 'ar', -} - -const I18N_CROWDIN_MAP: Record = { - ja: 'ja', - zh_TW: 'zh-TW', - pt_PT: 'pt-PT', - uk: 'uk', - es: 'es-ES', - de: 'de', - fr: 'fr', - ru: 'ru', - ar: 'ar', -} - -export const crowdinLangOf = (locale: timer.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale] - -export const localeOf = (crowdinLang: CrowdinLanguage) => CROWDIN_I18N_MAP[crowdinLang] - -const IGNORED_FILE: Partial<{ [dir in Dir]: string[] }> = { - common: [ - // Strings for market - 'meta', - // Name of locales - 'locale', - ] -} - -export function isIgnored(dir: Dir, fileName: string) { - return !!IGNORED_FILE[dir]?.includes(fileName) -} - -export const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message') -export const RSC_FILE_SUFFIX = "-resource.json" - -/** - * Read all messages from source file - * - * @param dir the directory of messages - * @returns - */ -export async function readAllMessages(dir: Dir): Promise>> { - const dirPath = path.join(MSG_BASE, dir) - - const files = fs.readdirSync(dirPath) - const result: Record = {} - await Promise.all(files.map(async file => { - if (!file.endsWith(RSC_FILE_SUFFIX)) { - return - } - const message = (await import(`@i18n/message/${dir}/${file}`))?.default as Messages - const name = file.replace(RSC_FILE_SUFFIX, '') - message && (result[name] = message) - return - })) - return result -} - -/** - * Merge crowdin message into locale resource json files - * - * @param dir dir - * @param filename the name of json file - * @param messages crowdin messages - */ -export async function mergeMessage( - dir: Dir, - filename: string, - 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}`) - return - } - const sourceItemSet = transMsg(existMessages[SOURCE_LOCALE]) - const sourceKeyIdx = Object.keys(sourceItemSet) - Object.entries(messages).forEach(([locale, itemSet]) => { - const newMessage: any = {} - Object.entries(itemSet) - .sort((a, b) => (sourceKeyIdx.findIndex(v => v === a[0]) - sourceKeyIdx.findIndex(v => v === b[0]))) - .forEach(([path, text]) => { - // Not translated - if (!text) return - const sourceText = sourceItemSet[path] - // Deleted key - if (!sourceText) return - if (!checkPlaceholder(text, sourceText)) { - console.error(`Invalid placeholder: 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) - }) - - const newFileContent = JSON.stringify(existMessages, null, 4) - fs.writeFileSync(filePath, newFileContent, { encoding: 'utf-8' }) -} - -function checkPlaceholder(translated: string, source: string) { - const allSourcePlaceholders = - Array.from(source.matchAll(/\{(.*?)\}/g)) - .map(matched => matched[1]) - .sort() - const allTranslatedPlaceholders = - Array.from(translated.matchAll(/\{(.*?)\}/g)) - .map(matched => matched[1]) - .sort() - if (allSourcePlaceholders.length != allTranslatedPlaceholders.length) { - return false - } - for (let i = 0; i++; i < allSourcePlaceholders.length) { - if (allSourcePlaceholders[i] !== allTranslatedPlaceholders[i]) { - return false - } - } - return true -} - -function fillItem(fields: string[], index: number, obj: Record, text: string) { - const field = fields[index] - if (index === fields.length - 1) { - obj[field] = text - return - } - let sub = obj[field] - if (sub === undefined) { - obj[field] = sub = {} - } else if (typeof sub !== 'object') { - exitWith("Invalid key path: " + fields.join('.')) - } - fillItem(fields, index + 1, sub, text) -} - -/** - * Trans msg object to k-v map - */ -export function transMsg(message: any, prefix?: string): ItemSet { - const result: Record = {} - const pathPrefix = prefix ? prefix + '.' : '' - Object.entries(message).forEach(([key, value]) => { - const path = pathPrefix + key - if (typeof value === 'object') { - const subResult = transMsg(value, path) - Object.entries(subResult) - .forEach(([path, val]) => result[path] = val) - } else { - let realVal = value - // Replace tab with blank - typeof value === 'string' && (realVal = value.replace(/\s{4}/g, '')) - result[path] = realVal - } - }) - return result -} - -export async function checkMainBranch(client: CrowdinClient): Promise { - const branch = await client.getMainBranch() - if (!branch) { - exitWith("Main branch is null") - } - return branch! -} diff --git a/script/crowdin/export-translation.ts b/script/crowdin/export-translation.ts deleted file mode 100644 index eca038ee3..000000000 --- a/script/crowdin/export-translation.ts +++ /dev/null @@ -1,77 +0,0 @@ -import decompress from "decompress" -import { existsSync, readdirSync, readFileSync, rm, writeFile } from "fs" -import { join } from "path" -import { getClientFromEnv } from "./client" -import { - ALL_DIRS, ALL_TRANS_LOCALES, - checkMainBranch, - crowdinLangOf, - type Dir, - type ItemSet, - mergeMessage, - RSC_FILE_SUFFIX, - transMsg -} from "./common" - -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>> = {} - for (const locale of ALL_TRANS_LOCALES) { - const crowdinLang = crowdinLangOf(locale) - const dirPath = join(TEMP_DIR, crowdinLang, dir) - const files = readdirSync(dirPath) - for (const fileName of files) { - const json = readFileSync(join(dirPath, fileName)).toString() - const itemSets = fileSets[fileName] || {} - itemSets[locale] = transMsg(JSON.parse(json)) - fileSets[fileName] = itemSets - } - } - for (const [fileName, itemSets] of Object.entries(fileSets)) { - await mergeMessage(dir, fileName.replace('.json', RSC_FILE_SUFFIX), itemSets) - } -} - -async function downloadProjectZip(url: string): Promise { - const res = await fetch(url) - const blob = await res.blob() - const buffer = Buffer.from(await blob.arrayBuffer()) - await new Promise(resolve => writeFile(TEMP_FILE_NAME, buffer, resolve)) -} - -async function compressProjectZip(): Promise { - if (existsSync(TEMP_DIR)) { - await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve)) - } - await decompress(TEMP_FILE_NAME, TEMP_DIR) -} - -async function clearTempFile() { - await new Promise(resolve => rm(TEMP_FILE_NAME, resolve)) - await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve)) -} - -async function main() { - const client = getClientFromEnv() - const branch = await checkMainBranch(client) - const zipUrl = await client.buildProjectTranslation(branch.id) - console.log("Built project translations") - console.log(zipUrl) - await downloadProjectZip(zipUrl) - console.log("Downloaded project zip file") - try { - await compressProjectZip() - console.log("Compressed zip file") - for (const dir of ALL_DIRS) { - await processDir(dir) - console.log("Processed dir: " + dir) - } - } finally { - clearTempFile() - console.log("Cleaned temp files") - } -} - -main() \ No newline at end of file diff --git a/script/crowdin/sync-source.ts b/script/crowdin/sync-source.ts deleted file mode 100644 index a5f3f62ce..000000000 --- a/script/crowdin/sync-source.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { type SourceFilesModel, type SourceStringsModel } from "@crowdin/crowdin-api-client" -import { toMap } from "@util/array" -import { type CrowdinClient, getClientFromEnv, type NameKey } from "./client" -import { - ALL_DIRS, - Dir, - isIgnored, - ItemSet, - readAllMessages, - SOURCE_LOCALE, - transMsg -} from "./common" - -async function initBranch(client: CrowdinClient): Promise { - const branch = await client.getOrCreateMainBranch() - if (!branch) { - console.error("Failed to create main branch") - process.exit(1) - } - return branch -} - -/** - * Process strings - * - * @param client client - * @param existFile exist crowdin file - * @param fileContent strings - */ -async function processStrings( - client: CrowdinClient, - existFile: SourceFilesModel.File, - fileContent: ItemSet, -) { - const existStrings = await client.listStringsByFile(existFile.id) - const existStringsKeyMap = toMap(existStrings, s => s.identifier) - const strings2Delete: SourceStringsModel.String[] = [] - const strings2Create: ItemSet = {} - const strings2Update: ItemSet = {} - Object.entries(fileContent).forEach(([path, text]) => { - if (!text) { - // maybe blank or undefined sometimes - return - } - const existString = existStringsKeyMap[path] - if (existString) { - strings2Update[path] = text - } else { - strings2Create[path] = text - } - }) - Object.entries(existStringsKeyMap).forEach(([path, string]) => !fileContent[path] && strings2Delete.push(string)) - await client.batchCreateString(existFile.id, strings2Create) - await client.batchUpdateIfNecessary(strings2Update, existStringsKeyMap) - await client.batchDeleteString(strings2Delete.map(s => s.id)) -} - - -async function processByDir(client: CrowdinClient, dir: Dir, branch: SourceFilesModel.Branch): Promise { - // 1. init directory - const dirKey: NameKey = { name: dir, branchId: branch.id } - let directory = await client.getDirByName(dirKey) - if (!directory) { - directory = await client.createDirectory(dirKey) - } - console.log('Directory: ' + JSON.stringify(directory)) - // 2. iterate all messages - const messages = await readAllMessages(dir) - console.log(`Found ${Object.keys(messages).length} message(s)`) - // 3. list all files in directory - const existFiles = await client.listFilesByDirectory(directory.id) - console.log("Exists file count: " + existFiles.length) - const existFileNameMap = toMap(existFiles, f => f.name) - // 4. create new files - for (const [fileName, msg] of Object.entries(messages)) { - if (isIgnored(dir, fileName)) { - console.log(`Ignored file: ${dir}/${fileName}`) - continue - } - const crowdinFilename = fileName + '.json' - const fileContent = transMsg(msg[SOURCE_LOCALE]) - let existFile = existFileNameMap[crowdinFilename] - if (!existFile) { - // Create with empty file - const storage = await client.createStorage(crowdinFilename, fileContent) - existFile = await client.createFile(directory.id, storage, crowdinFilename) - console.log(`Created new file: dir=${dir}, fileName=${crowdinFilename}, id=${existFile.id}`) - } - // Process by strings - await processStrings(client, existFile, fileContent) - } -} - -async function main() { - const client = getClientFromEnv() - // Init main branch - const branch = await initBranch(client) - // Process by dir - for (const dir of ALL_DIRS) { - await processByDir(client, dir, branch) - } -} - -main() \ No newline at end of file diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts deleted file mode 100644 index 184cd3026..000000000 --- a/script/crowdin/sync-translation.ts +++ /dev/null @@ -1,90 +0,0 @@ - -import { type SourceFilesModel } from "@crowdin/crowdin-api-client" -import { toMap } from "@util/array" -import { exitWith } from "../util/process" -import { type CrowdinClient, getClientFromEnv } from "./client" -import { - ALL_DIRS, ALL_TRANS_LOCALES, - type CrowdinLanguage, type Dir, type ItemSet, - checkMainBranch, crowdinLangOf, isIgnored, readAllMessages, transMsg, -} from "./common" - -const CROWDIN_USER_ID_OF_OWNER = 15266594 - -async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.File, message: ItemSet, lang: CrowdinLanguage): Promise { - console.log(`Start to process dir message: fileName=${file.name}, lang=${lang}`) - const strings = await client.listStringsByFile(file.id) - const stringMap = toMap(strings, s => s.identifier) - for (const [identifier, text] of Object.entries(message)) { - const string = stringMap[identifier] - if (!string) { - console.log(`Can't found string of identifier: ${identifier}, file: ${file.path}`) - continue - } - if (text === string.text) { - // The same as original text - console.log(`Translation same as origin text of ${string.identifier} in ${file.path}`) - continue - } - const existList = await client.listTranslationByStringAndLang({ stringId: string.id, lang }) - // Deleted old translations different from current - const oldByOwner = existList?.filter(t => t?.user?.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) - for (const toDelete of oldByOwner || []) { - await client.deleteTranslation(toDelete.id) - console.log(`Deleted translation by owner: stringId=${string.id}, lang=${lang}, text=${toDelete.text}`) - } - if (!existList?.find(t => t.text === text)) { - // Create new translation - await client.createTranslation({ stringId: string.id, lang }, text) - console.log(`Created trans: stringId=${string.id}, lang=${lang}, text=${text}`) - } - } -} - -async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesModel.Branch): Promise { - const messages = await readAllMessages(dir) - const directory = await client.getDirByName({ - name: dir, - branchId: branch.id, - }) - if (!directory) { - exitWith("Directory not found: " + dir) - } - const files = await client.listFilesByDirectory(directory!.id) - console.log(`find ${files.length} files of ${dir}`) - const fileMap = toMap(files, f => f.name) - for (const [fileName, message] of Object.entries(messages)) { - console.log(`Start to sync translations of ${dir}/${fileName}`) - if (isIgnored(dir, fileName)) { - console.log("Ignored file: " + fileName) - continue - } - const crowdinFileName = fileName + '.json' - const crowdinFile = fileMap[crowdinFileName] - if (!crowdinFile) { - console.log(`Failed to find file: dir=${dir}, filename=${fileName}`) - continue - } - - for (const locale of ALL_TRANS_LOCALES) { - const translated = message[locale] - if (!translated || !Object.keys(translated).length) { - continue - } - const strings = transMsg(message[locale]) - const crowdinLang = crowdinLangOf(locale) - await processDirMessage(client, crowdinFile, strings, crowdinLang) - } - } -} - -async function main() { - const client = getClientFromEnv() - const branch = await checkMainBranch(client) - - for (const dir of ALL_DIRS) { - await processDir(client, dir, branch) - } -} - -main() \ No newline at end of file diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts deleted file mode 100644 index 5021576f5..000000000 --- a/script/user-chart/add.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { - createGist as createGistApi, - type FileForm, - getJsonFileContent, - type Gist, - type GistForm, - updateGist as updateGistApi -} from "@src/api/gist" -import fs from "fs" -import { exitWith } from "../util/process" -import { descriptionOf, filenameOf, getExistGist, validateTokenFromEnv } from "./common" - -type AddArgv = { - browser: Browser - fileName: string -} - -function parseArgv(): AddArgv { - const argv = process.argv.slice(2) - const browserArgv = argv[0] - const fileName = argv[1] - if (!browserArgv || !fileName) { - exitWith("add.ts [c/e/f] [file_name]") - } - const browserArgvMap: Record = { - c: 'chrome', - e: 'edge', - f: 'firefox', - } - const browser: Browser = browserArgvMap[browserArgv] - if (!browser) { - exitWith("add.ts [c/e/f] [file_name]") - } - return { - browser, - fileName - } -} - -async function createGist(token: string, browser: Browser, data: UserCount) { - const description = descriptionOf(browser) - const filename = filenameOf(browser) - - // 1. sort by key - const sorted: UserCount = {} - Object.keys(data).sort().forEach(key => sorted[key] = data[key]) - // 2. create - const files: Record = {} - files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) } - const gistForm: GistForm = { - public: true, - description, - files - } - await createGistApi(token, gistForm) -} - -async function updateGist(token: string, browser: Browser, data: UserCount, gist: Gist) { - const description = descriptionOf(browser) - const filename = filenameOf(browser) - // 1. merge - const file = gist.files[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]) - const files: Record = {} - files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) } - const gistForm: GistForm = { - public: true, - description, - files - } - updateGistApi(token, gist.id, gistForm) -} - -function parseChrome(content: string): UserCount { - const lines = content.split('\n') - const result: Record = {} - if (!(lines?.length > 2)) { - return result - } - lines.slice(2).forEach(line => { - const [dateStr, numberStr] = line.split(',') - if (!dateStr || !numberStr) { - return - } - // Replace '/' to '-', then rjust month and date - const date = dateStr.split('/').map(str => rjust(str, 2, '0')).join('-') - const number = parseInt(numberStr) - date && number && (result[date] = number) - }) - return result -} - -function parseEdge(content: string): UserCount { - const lines = content.split('\n') - const result: Record = {} - if (!(lines?.length > 1)) { - return result - } - lines.slice(1).forEach(line => { - const splits = line.split(',') - const dateStr = splits[5] - const numberStr = splits[6] - if (!dateStr || !numberStr) { - return - } - // Replace '/' to '-', then rjust month and date - const date = dateStr.split('/').map(str => rjust(str, 2, '0')).join('-') - const number = parseInt(numberStr) - date && number && (result[date] = number) - }) - return result -} - -function parseFirefox(content: string): UserCount { - const lines = content.split('\n') - const result: Record = {} - if (!(lines?.length > 4)) { - return result - } - lines.slice(4).forEach(line => { - const splits = line.split(',') - const date = splits[0] - const numberStr = splits[1] - if (!date || !numberStr) { - return - } - const number = parseInt(numberStr) - date && number && (result[date] = number) - }) - return result -} - -function rjust(str: string, num: number, padding: string): string { - str = str || '' - if (str.length >= num) { - return str - } - return Array.from(new Array(num - str.length).keys()).map(_ => padding).join('') + str -} - -async function main() { - const token = validateTokenFromEnv() - const argv: AddArgv = parseArgv() - const browser = argv.browser - const fileName = argv.fileName - const content = fs.readFileSync(fileName, { encoding: 'utf-8' }) - let newData: UserCount = {} - if (browser === 'chrome') { - newData = parseChrome(content) - } else if (browser === 'edge') { - newData = parseEdge(content) - } else if (browser === 'firefox') { - newData = parseFirefox(content) - } else { - exitWith("Un-supported browser: " + browser) - } - const gist = await getExistGist(token, browser) - if (!gist) { - await createGist(token, browser, newData) - } else { - await updateGist(token, browser, newData, gist) - } -} - -main() \ No newline at end of file diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts deleted file mode 100644 index 6b69633d4..000000000 --- a/script/user-chart/common.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { findTarget, type Gist } from "@api/gist" -import { exitWith } from "../util/process" - -/** - * Validate the token from environment variables - */ -export function validateTokenFromEnv(): string { - const token = process.env.TIMER_USER_COUNT_GIST_TOKEN - if (!token) { - exitWith("Can't find token from env variable [TIMER_USER_COUNT_GIST_TOKEN]") - } - return token! -} - -/** - * Calculate the gist description of target browser - */ -export function descriptionOf(browser: Browser): string { - return `Timer_UserCount_4_${browser}` -} - -/** - * Calculate the gist filename of target browser - */ -export function filenameOf(browser: Browser): string { - return descriptionOf(browser) + '.json' -} - -export async function getExistGist(token: string, browser: Browser): Promise { - return await findTarget(token, gist => gist.description === descriptionOf(browser)) -} \ No newline at end of file diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts deleted file mode 100644 index f12a09516..000000000 --- a/script/user-chart/render.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - createGist, - type FileForm, - findTarget, - getJsonFileContent, - type GistForm, - updateGist -} from "@api/gist" -import { type EChartsType, init } from "echarts" -import { writeFileSync } from "fs" -import { exit } from "process" -import { filenameOf, getExistGist, validateTokenFromEnv } from "./common" - -const ALL_BROWSERS: Browser[] = ['firefox', 'edge', 'chrome'] - -const POINT_COUNT = 200 - -type OriginData = { - [browser in Browser]: UserCount -} - -type ChartData = { - xAxis: string[] - yAxises: { - [browser in Browser]: number[] - } -} - -function preProcess(originData: OriginData): ChartData { - // 1. sort dates - const dateSet = new Set() - Object.values(originData).forEach(ud => Object.keys(ud).forEach(date => dateSet.add(date))) - let allDates = Array.from(dateSet).sort() - - // 2. smooth the count - const ctx: { [browser in Browser]: SmoothContext } = { - chrome: new SmoothContext(), - firefox: new SmoothContext(), - edge: new SmoothContext(), - } - - allDates.forEach( - date => ALL_BROWSERS.forEach(b => ctx[b].process(originData[b][date])) - ) - const result: ChartData = { - xAxis: allDates, - yAxises: { - chrome: ctx.chrome.end(), - firefox: ctx.firefox.end(), - edge: ctx.edge.end(), - } - } - - // 3. zoom - const reduction = Math.floor(Object.keys(allDates).length / POINT_COUNT) - result.xAxis = zoom(result.xAxis, reduction) - ALL_BROWSERS.forEach(b => result.yAxises[b] = zoom(result.yAxises[b], reduction)) - return result -} - -class SmoothContext { - lastVal: number - step: number - data: number[] - - constructor() { - this.lastVal = 0 - this.step = 0 - this.data = [] - } - - /** - * Process value - */ - process(newVal: number | undefined) { - if (newVal) { - this.smooth(newVal) - } else { - this.increaseStep() - } - } - - smooth(currentValue: number): void { - if (this.step < 0) { - return - } - const unitVal = (currentValue - this.lastVal) / (this.step + 1) - Object.keys(Array.from(new Array(this.step))) - .map(key => parseInt(key)) - .map(i => Math.floor(unitVal * (i + 1) + this.lastVal)) - .forEach(smoothedVal => this.data.push(smoothedVal)) - this.data.push(currentValue) - // Reset - this.lastVal = currentValue - this.step = 0 - } - - increaseStep(): void { - this.step += 1 - } - - end(): number[] { - Object.keys(Array.from(new Array(this.step))) - .forEach(() => this.data.push(this.lastVal)) - return this.data - } -} - -function zoom(data: T[], reduction: number): T[] { - let i = 0 - const newData: T[] = [] - while (i < data.length) { - newData.push(data[i]) - i += reduction - } - return newData -} - -function render2Svg(chartData: ChartData): string { - const { xAxis, yAxises } = chartData - const chart: EChartsType = init(null, null, { - renderer: 'svg', - ssr: true, - width: 960, - height: 640 - }) - const totalUserCount = Object.values(yAxises) - .map(v => v[v.length - 1] || 0) - .reduce((a, b) => a + b) - chart.setOption({ - title: { - text: 'Total Active User Count', - subtext: `${xAxis[0]} to ${xAxis[xAxis.length - 1]} | currently ${totalUserCount} ` - }, - legend: { data: ALL_BROWSERS }, - grid: { - left: '3%', - right: '4%', - bottom: '3%', - containLabel: true - }, - xAxis: [{ - type: 'category', - boundaryGap: false, - data: xAxis - }], - yAxis: [ - { type: 'value' } - ], - series: ALL_BROWSERS.map(b => ({ - name: b, - type: 'line', - stack: 'Total', - // Fill the area - areaStyle: {}, - data: yAxises[b] - })) - }) - return chart.renderToSVGString() -} - -const USER_COUNT_GIST_DESC = "User count of timer, auto-generated" -const USER_COUNT_SVG_FILE_NAME = "user_count.svg" - -async function getOriginData(token: string): Promise { - const [firefox, edge, chrome]: UserCount[] = await Promise.all( - ALL_BROWSERS.map(b => getDataFromGist(token, b)) - ) - return { chrome, firefox, edge } -} - -/** - * Get the data from gist - */ -async function getDataFromGist(token: string, browser: Browser): Promise { - const gist = await getExistGist(token, browser) - const file = gist?.files[filenameOf(browser)] - return (file && await getJsonFileContent(file)) ?? {} -} - -/** - * Upload svg string to gist - */ -async function upload2Gist(token: string, svg: string) { - const files: Record = {} - files[USER_COUNT_SVG_FILE_NAME] = { - filename: USER_COUNT_SVG_FILE_NAME, - content: svg - } - const form: GistForm = { - public: true, - description: USER_COUNT_GIST_DESC, - files - } - const gist = await findTarget(token, gist => gist.description === USER_COUNT_GIST_DESC) - if (gist) { - await updateGist(token, gist.id, form) - console.log('Updated gist') - } else { - await createGist(token, form) - console.log('Created new gist') - } -} - -async function main(): Promise { - const token = validateTokenFromEnv() - // 1. get all data - const originData: OriginData = await getOriginData(token) - // 2. pre-process data - const chartData = preProcess(originData) - // 3. render csv - const svg = render2Svg(chartData) - writeFileSync('user-chart.svg', svg, 'utf-8') - // 4. upload - await upload2Gist(token, svg) - // 5. finish - exit() -} - -main() diff --git a/script/user-chart/user-chart.d.ts b/script/user-chart/user-chart.d.ts deleted file mode 100644 index 56d0300c8..000000000 --- a/script/user-chart/user-chart.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -type Browser = - | 'chrome' - | 'firefox' - | 'edge' - -type UserCount = Record \ No newline at end of file diff --git a/src/api/crowdin.ts b/src/api/crowdin.ts deleted file mode 100644 index 127774e88..000000000 --- a/src/api/crowdin.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** -* Copyright (c) 2022-present Hengyang Zhang -* -* This software is released under the MIT License. -* https://opensource.org/licenses/MIT -*/ - -import { CROWDIN_PROJECT_ID } from "@util/constant/url" -import axios, { type AxiosResponse } from "axios" - -/** - * Used to obtain translation status - */ -const PUBLIC_TOKEN = '5e0d53accb6e8c490a1af2914c0963f78082221d7bc1dcb0d56b8e3856a875e432a2e353a948688e' - -export type TranslationStatusInfo = { - /** - * https://developer.crowdin.com/language-codes/ - */ - languageId: string - translationProgress: number -} - -export type MemberInfo = { - username: string - joinedAt: string - avatarUrl: string -} - -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: AxiosResponse = await axios.get(url, { - headers: { "Authorization": auth } - }) - const data: { data: { data: TranslationStatusInfo }[] } = response.data - return data.data.map(i => i.data) -} - -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: AxiosResponse = await axios.get(url, { - headers: { "Authorization": auth } - }) - const data: { data: { data: MemberInfo }[] } = response.data - const newItems = data?.data?.map(i => i.data) ?? [] - result.push(...newItems) - - if (newItems.length < limit) break - - offset += limit - } - return result -} \ No newline at end of file diff --git a/src/api/gist.ts b/src/api/gist.ts deleted file mode 100644 index 53ee29746..000000000 --- a/src/api/gist.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright (c) 2022-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import FIFOCache from "@util/fifo-cache" -import { fetchGet, fetchPost } from "./http" - -type BaseFile = { - filename: string -} - -export type FileForm = BaseFile & { - content: string -} - -export type File = BaseFile & { - type: string - language: string - raw_url: string - content?: string -} - -type BaseGist = { - public: boolean - description: string - files: { [filename: string]: FileInfo | null } -} - -export type Gist = BaseGist & { - id: string -} - -export type GistForm = BaseGist - -const BASE_URL = 'https://api.github.com/gists' - -/** - * Cache of get requests - */ -const GET_CACHE = new FIFOCache(20) - -async function get(token: string, uri: string): Promise { - const cacheKey = uri + token - return await GET_CACHE.getOrSupply(cacheKey, async () => { - const headers = { - "Accept": "application/vnd.github+json", - "Authorization": `token ${token}` - } - const url = BASE_URL + uri - const response = await fetchGet(url, { headers }) - return await response.json() - }) as T -} - -async function post(token: string, uri: string, body?: R): Promise { - const response = await fetchPost(BASE_URL + uri, body, { - headers: { - "Accept": "application/vnd.github+json", - "Authorization": `token ${token}` - } - }) - const result = await response.json() - const { status } = response - if (status >= 200 && status < 300) { - // Clear cache if success to request - GET_CACHE.clear() - return result as T - } - throw new Error(JSON.stringify(result)) -} - -/** - * @param token token - * @param id id - * @returns detail of Gist - */ -export function getGist(token: string, id: string): Promise { - return get(token, `/${id}`) -} - -/** - * Find the first target gist with predicate - * - * @param token gist token - * @param predicate predicate - * @returns - */ -export async function findTarget(token: string, predicate: (gist: Gist) => boolean): Promise { - let pageNum = 1 - while (true) { - const uri = `?per_page=100&page=${pageNum}` - const gists: Gist[] = await get(token, uri) - if (!gists?.length) { - break - } - const satisfied = gists.find(predicate) - if (satisfied) { - return satisfied - } - pageNum += 1 - } - return null -} - -/** - * Create one gist - * - * @param token token - * @param gist gist info - * @returns gist info with id - */ -export function createGist(token: string, gist: GistForm): Promise { - return post(token, "", gist) -} - -/** - * Update gist - * - * @param token token - * @param gist gist - * @returns - */ -export async function updateGist(token: string, id: string, gist: GistForm): Promise { - await post(token, `/${id}`, gist) -} - -/** - * Get content of file - */ -export async function getJsonFileContent(file: File): Promise { - const content = file.content - if (content) { - try { - return JSON.parse(content) - } catch (e) { - console.warn("Failed to parse content of: " + file.raw_url) - console.warn(e) - } - } - const rawUrl = file.raw_url - if (!rawUrl) { - return null - } - const response = await fetchGet(rawUrl) - return await response.json() -} - -/** - * Test token to process gist - * - * @returns errorMsg or null/undefined - */ -export async function testToken(token: string): Promise { - const response = await fetchGet(BASE_URL + '?per_page=1&page=1', { - headers: { - "Accept": "application/vnd.github+json", - "Authorization": `token ${token}` - } - }) - const { status, statusText } = response || {} - return status === 200 ? undefined : statusText || ("ERROR " + status) -} diff --git a/src/api/obsidian.ts b/src/api/obsidian.ts deleted file mode 100644 index 51207be58..000000000 --- a/src/api/obsidian.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { fetchDelete, fetchGet, fetchPutText } from "./http" - -export const DEFAULT_ENDPOINT = "http://127.0.0.1:27123" -export const DEFAULT_VAULT = "vault" -export const INVALID_AUTH_CODE = 40101 -export const NOT_FOUND_CODE = 40400 - -export type ObsidianResult = { - message?: string - errorCode?: number -} & T - -export type ObsidianRequestContext = { - endpoint?: string - vault?: string - auth: string -} - -const authHeaders = (auth: string): Record => ({ - "Authorization": `Bearer ${auth}` -}) - -export async function listAllFiles(context: ObsidianRequestContext, dirPath: string): Promise> { - const { endpoint, auth, vault } = context || {} - const url = `${endpoint || DEFAULT_ENDPOINT}/${vault || DEFAULT_VAULT}/${dirPath || ''}` - const response = await fetchGet(url, { headers: authHeaders(auth) }) - return await response?.json() -} - -export async function updateFile(context: ObsidianRequestContext, filePath: string, content: string): Promise { - const { endpoint, auth, vault } = context || {} - const url = `${endpoint || DEFAULT_ENDPOINT}/${vault || DEFAULT_VAULT}/${filePath}` - const headers = authHeaders(auth) - headers["Content-Type"] = "text/markdown" - await fetchPutText(url, content, { headers }) -} - -export async function getFileContent(context: ObsidianRequestContext, filePath: string): Promise { - const { endpoint, auth, vault } = context || {} - const url = `${endpoint || DEFAULT_ENDPOINT}/${vault || DEFAULT_VAULT}/${filePath}` - const headers = authHeaders(auth) - const response = await fetchGet(url, { headers }) - const { status } = response - return status >= 200 && status < 300 ? await response.text() : null -} - -export async function deleteFile(context: ObsidianRequestContext, filePath: string): Promise { - const { endpoint, auth, vault } = context || {} - const url = `${endpoint || DEFAULT_ENDPOINT}/${vault || DEFAULT_VAULT}/${filePath}` - const headers = authHeaders(auth) - const response = await fetchDelete(url, { headers }) - if (response.status !== 200) { - console.log(`Failed to delete file of Obsidian. filePath=${filePath}`) - } -} \ No newline at end of file diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts deleted file mode 100644 index 39a4eb240..000000000 --- a/src/api/web-dav.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * WebDAV client api - * - * Testing with server implemented by https://github.com/svtslv/webdav-cli - */ -import { encode } from 'js-base64' -import { fetchDelete, fetchGet } from './http' - -// Only support password for now -export type WebDAVAuth = { - type: 'password' - username: string - password: string -} - -export type WebDAVContext = { - auth: WebDAVAuth - endpoint: string -} - -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}`)}`) - } - return headers -} - -export async function judgeDirExist(context: WebDAVContext, dirPath: string): Promise { - const { auth, endpoint } = context || {} - const headers = authHeaders(auth) - const url = `${endpoint}/${dirPath}` - const method = 'PROPFIND' - headers.append('Accept', 'text/plain,application/xml') - headers.append('Depth', '1') - const response = await fetch(url, { method, headers }) - const status = response?.status - if (status == 207) { - return true - } else if (status === 300) { - throw new Error("Your server does't support PROPFIND method!") - } else if (status == 404) { - return false - } else if (status == 401) { - throw new Error("Authorization is invalid!") - } else { - throw new Error("Unknown directory status") - } -} - -export async function makeDir(context: WebDAVContext, dirPath: string) { - const { auth, endpoint } = context || {} - const url = `${endpoint}/${dirPath}` - const headers = authHeaders(auth) - const response = await fetch(url, { method: 'MKCOL', headers }) - handleWriteResponse(response) -} - -export async function deleteDir(context: WebDAVContext, dirPath: string) { - const { auth, endpoint } = context || {} - const url = `${endpoint}/${dirPath}` - const headers = authHeaders(auth) - const response = await fetchDelete(url, { headers }) - const status = response.status - if (status === 403) { - throw new Error("Unauthorized to delete directory") - } - if (status !== 201 && status !== 200) { - throw new Error("Failed to delete directory: " + status) - } -} - -export async function writeFile(context: WebDAVContext, filePath: string, content: string): Promise { - const { auth, endpoint } = context || {} - const headers = authHeaders(auth) - headers.set("Content-Type", "application/octet-stream") - const url = `${endpoint}/${filePath}` - const response = await fetch(url, { headers, method: 'put', body: content }) - handleWriteResponse(response) -} - -function handleWriteResponse(response: Response) { - const status = response.status - if (status === 403) { - throw new Error("Unauthorized to write file or create directory") - } - if (status !== 201 && status !== 200) { - throw new Error("Failed to write file or create directory: " + status) - } -} - -export async function readFile(context: WebDAVContext, filePath: string): Promise { - const { auth, endpoint } = context || {} - const headers = authHeaders(auth) - const url = `${endpoint}/${filePath}` - try { - const response = await fetchGet(url, { headers }) - const status = response?.status - if (status === 200) { - return response.text() - } - if (status !== 404) { - console.warn("Unexpected status: " + status) - } - return null - } catch (e) { - console.error("Failed to read WebDAV content", e) - return null - } -} \ No newline at end of file diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 7c39b149e..7accc78e4 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -7,10 +7,9 @@ import { executeScript } from "@api/chrome/script" import { createTab } from "@api/chrome/tab" -import { ANALYSIS_ROUTE, LIMIT_ROUTE } from "@app/router/constants" +import { ANALYSIS_ROUTE } from "@app/router/constants" import optionHolder from "@service/components/option-holder" import whitelistHolder from "@service/components/whitelist-holder" -import limitService from "@service/limit-service" import siteService from "@service/site-service" import { saveTimelineEvent } from '@service/timeline-service' import { getAppPageUrl } from "@util/constant/url" @@ -30,14 +29,6 @@ const handleOpenAnalysisPage = (sender: ChromeMessageSender) => { createTab({ url: newTabUrl, index: newTabIndex }) } -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 }) -} const handleInjected = async (sender: ChromeMessageSender) => { const tabId = sender?.tab?.id @@ -61,10 +52,7 @@ export default function init(dispatcher: MessageDispatcher) { 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)) // Get sites which need to count run time .register('cs.getRunSites', async url => { diff --git a/src/background/index.ts b/src/background/index.ts index 4fa68e3ff..069efabf6 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -17,7 +17,6 @@ import initBrowserAction from "./browser-action-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" import handleInstall from "./install-handler" -import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" import VersionMigrator from "./migrator" import initSidePanel from "./side-panel" @@ -40,8 +39,6 @@ initDataCleaner() const messageDispatcher = new MessageDispatcher() -// Limit processor -initLimitProcessor(messageDispatcher) // Content-script's request handler initCsHandler(messageDispatcher) diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts deleted file mode 100644 index 3723b35a8..000000000 --- a/src/background/limit-processor.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Copyright (c) 2022-present Hengyang Zhang - * - * This software is released under the MIT License. - * 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 { matches } from "@util/limit" -import { isBrowserUrl } from "@util/pattern" -import { getStartOfDay, MILL_PER_DAY, MILL_PER_SECOND } from "@util/time" -import alarmManager from "./alarm-manager" -import MessageDispatcher from "./message-dispatcher" - -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(), - ) -} - -const processMoreMinutes = async (url: string) => { - const rules = await limitService.moreMinutes(url) - - const tabs = await listTabs({ status: 'complete' }) - tabs.forEach(tab => processLimitWaking(rules, tab)) -} - -const processAskHitVisit = async (item: timer.limit.Item) => { - let tabs = await listTabs() - const { visitTime = 0, cond } = item || {} - 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 - } catch { - // Ignored - } - } - return false -} - -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) -} \ No newline at end of file diff --git a/src/background/migrator/index.ts b/src/background/migrator/index.ts index 3884040fd..c21d0bbbe 100644 --- a/src/background/migrator/index.ts +++ b/src/background/migrator/index.ts @@ -9,7 +9,6 @@ import { getVersion, onInstalled } from "@api/chrome/runtime" import CateInitializer from "./cate-initializer" import { type Migrator } from "./common" import HostMergeInitializer from "./host-merge-initializer" -import LimitRuleMigrator from "./limit-rule-migrator" import LocalFileInitializer from "./local-file-initializer" import WhitelistInitializer from "./whitelist-initializer" @@ -27,7 +26,6 @@ class VersionManager { new LocalFileInitializer(), new WhitelistInitializer(), new CateInitializer(), - new LimitRuleMigrator(), ) } 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/track-server.ts b/src/background/track-server.ts index 3fe1dac29..ed8ef1791 100644 --- a/src/background/track-server.ts +++ b/src/background/track-server.ts @@ -3,7 +3,6 @@ import { getWindow } from "@api/chrome/window" import optionHolder from "@service/components/option-holder" import whitelistHolder from "@service/components/whitelist-holder" import itemService, { type ItemIncContext } from "@service/item-service" -import limitService from "@service/limit-service" import periodService from "@service/period-service" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname } from "@util/pattern" @@ -17,13 +16,7 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], const focusTime = end - start // 1. Save async await itemService.addFocusTime(context, focusTime) - // 2. Process limit - const { limited, reminder } = await limitService.addFocusTime(host, url, focusTime) - // If time limited after this operation, send messages - limited?.length && sendLimitedMessage(limited) - // If need to reminder, send messages - reminder?.items?.length && tabId && sendMsg2Tab(tabId, 'limitReminder', reminder) - // 3. Add period time + // 2. Add period time await periodService.add(start, focusTime) return focusTime } @@ -65,25 +58,9 @@ async function tabNotActive(tabId: number | undefined): Promise { return !tab?.active } -async function sendLimitedMessage(items: timer.limit.Item[]) { - const tabs = await listTabs() - if (!tabs?.length) return - for (const tab of tabs) { - try { - const { id } = tab - id && await sendMsg2Tab(id, 'limitTimeMeet', items) - } catch { - /* Ignored */ - } - } -} async function handleVisit(context: ItemIncContext) { await itemService.increaseVisit(context) - const { host, url } = context - const metLimits = await limitService.incVisit(host, url) - // If time limited after this operation, send messages - metLimits?.length && sendLimitedMessage(metLimits) } async function handleIncVisitEvent(param: { host: string, url: string }, sender: ChromeMessageSender): Promise { diff --git a/src/content-script/index.ts b/src/content-script/index.ts index 11e3da50d..744e0b9e8 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -7,7 +7,6 @@ import { sendMsg2Runtime } from "@api/chrome/runtime" import { initLocale } from "@i18n" -import processLimit from "./limit" import printInfo from "./printer" import processTimeline from './timeline' import NormalTracker from "./tracker/normal" @@ -72,7 +71,6 @@ async function main() { await initLocale() const needPrintInfo = await sendMsg2Runtime('cs.printTodayInfo') !!needPrintInfo && printInfo(host) - await processLimit(url) processTimeline() diff --git a/src/content-script/limit/common.ts b/src/content-script/limit/common.ts deleted file mode 100644 index 37fe3abae..000000000 --- a/src/content-script/limit/common.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type LimitReason = - & RequiredPick - & PartialPick - & { - type: timer.limit.ReasonType - getVisitTime?: () => number - } - -export function isSameReason(a: LimitReason, b: LimitReason): boolean { - if (a?.id !== b?.id || a?.type !== b?.type) return false - if (a?.type === 'DAILY' || a?.type === 'VISIT') { - // Need judge allow delay - if (a?.allowDelay !== b?.allowDelay) return false - } - 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 - try { - await document.exitFullscreen() - } catch (e) { - console.warn('Failed to exit fullscreen', e) - } -} \ No newline at end of file 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 deleted file mode 100644 index 8180df1b1..000000000 --- a/src/content-script/limit/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 PeriodProcessor from "./processor/period-processor" -import VisitProcessor from "./processor/visit-processor" -import Reminder from "./reminder" - -export default async function processLimit(url: string) { - const modal: MaskModal = new ModalInstance(url) - const context: ModalContext = { modal, url } - - const processors: Processor[] = [ - new MessageAdaptor(context), - 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 allIgnore = allMatch(results, r => r.code === "ignore") - if (allIgnore) return { code: "ignore" } - - 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 } - }) -} diff --git a/src/content-script/limit/modal/Main.tsx b/src/content-script/limit/modal/Main.tsx deleted file mode 100644 index b80353944..000000000 --- a/src/content-script/limit/modal/Main.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { defineComponent } from "vue" -import Alert from "./components/Alert" -import Footer from "./components/Footer" -import Reason from "./components/Reason" -import { provideRule } from "./context" -import "./style" - -const _default = defineComponent(() => { - provideRule() - - return () => ( -
-
- - -
-
-
- ) -}) - -export default _default \ No newline at end of file diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx deleted file mode 100644 index f57cf2fde..000000000 --- a/src/content-script/limit/modal/components/Alert.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getUrl } from "@api/chrome/runtime" -import { t } from "@cs/locale" -import { useRequest } from "@hooks" -import optionHolder from "@service/components/option-holder" -import { defineComponent } from "vue" - -const ICON_URL = getUrl('static/images/icon.png') - -const _default = defineComponent(() => { - const defaultPrompt = t(msg => msg.modal.defaultPrompt) - const { data: prompt } = useRequest(async () => { - const option = await optionHolder.get() - return option?.limitPrompt || defaultPrompt - }, { defaultValue: defaultPrompt }) - return () => ( -
-

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

-

{prompt.value}

-
- ) -}) - -export default _default \ No newline at end of file diff --git a/src/content-script/limit/modal/components/Footer.tsx b/src/content-script/limit/modal/components/Footer.tsx deleted file mode 100644 index bb741df15..000000000 --- a/src/content-script/limit/modal/components/Footer.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 { t } from "@cs/locale" -import { Plus, Timer } from "@element-plus/icons-vue" -import optionHolder from "@service/components/option-holder" -import { meetTimeLimit } from '@util/limit' -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() - } -} - -const _default = defineComponent(() => { - const reason = useReason() - const rule = useRule() - const showDelay = computed(() => { - const { type, allowDelay, delayCount = 0 } = reason.value || {} - if (!allowDelay) return false - - const { time, weekly, visitTime, waste, weeklyWaste } = rule.value || {} - let realLimit = 0, realWaste = 0 - if (type === 'DAILY') { - realLimit = time ?? 0 - realWaste = waste ?? 0 - } else if (type === 'WEEKLY') { - realLimit = weekly ?? 0 - realWaste = weeklyWaste ?? 0 - } else if (type === 'VISIT') { - realLimit = visitTime ?? 0 - realWaste = reason.value?.getVisitTime?.() ?? 0 - } else { - return false - } - return meetTimeLimit(realLimit, realWaste, allowDelay, delayCount) - }) - - const delayHandler = useDelayHandler() - - return () => ( - - ) -}) - -export default _default \ No newline at end of file diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx deleted file mode 100644 index 3f905d562..000000000 --- a/src/content-script/limit/modal/components/Reason.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { t } from "@cs/locale" -import { useRequest } from "@hooks/useRequest" -import Flex from "@pages/components/Flex" -import { matchCond, meetLimit, meetTimeLimit, period2Str } from "@util/limit" -import { formatPeriodCommon, MILL_PER_SECOND } from "@util/time" -import { ElDescriptions, ElDescriptionsItem, ElTag } from "element-plus" -import { computed, defineComponent } from "vue" -import { useGlobalParam, useReason, useRule } from "../context" - -const renderBaseItems = (rule: timer.limit.Rule | null, url: string) => <> - msg.limit.item.name)} labelAlign="right"> - {rule?.name ?? '-'} - - msg.limit.item.condition)} labelAlign='right'> - {matchCond(rule?.cond ?? [], url)?.join(', ')} - - - -const TimeDescriptions = defineComponent({ - props: { - // Seconds - time: Number, - // Milliseconds - waste: Number, - count: Number, - visit: Number, - ruleLabel: String, - dataLabel: String, - }, - setup(props) { - const rule = useRule() - const reason = useReason() - const { url } = useGlobalParam() - - const timeLimited = computed(() => meetTimeLimit(props.time ?? 0, props.waste ?? 0, !!reason.value?.allowDelay, reason.value?.delayCount ?? 0)) - const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) - - return () => ( - - {renderBaseItems(rule.value, url)} - - - {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} - {`${props.count ?? 0} ${t(msg => msg.limit.item.visits)}`} - - - - - - {formatPeriodCommon(props.waste ?? 0)} - - - {`${props.visit ?? 0} ${t(msg => msg.limit.item.visits)}`} - - - - msg.limit.item.delayCount)} - labelAlign="right" - > - {reason.value?.delayCount ?? 0} - - - ) - }, -}) - -const _default = defineComponent(() => { - const reason = useReason() - const rule = useRule() - const { url } = useGlobalParam() - const type = computed(() => reason.value?.type) - - const { data: browsingTime, refresh: refreshBrowsingTime } = useRequest(() => { - const { getVisitTime, type } = reason.value || {} - if (type !== 'VISIT') return - return getVisitTime?.() || 0 - }) - - setInterval(refreshBrowsingTime, 1000) - - return () => ( -
- msg.limit.item.daily)} - dataLabel={t(msg => msg.calendar.range.today)} - /> - msg.limit.item.weekly)} - dataLabel={t(msg => msg.calendar.range.thisWeek)} - /> - - {renderBaseItems(rule.value, url)} - msg.limit.item.visitTime)} labelAlign="right"> - {formatPeriodCommon((rule.value?.visitTime ?? 0) * MILL_PER_SECOND) || '-'} - - msg.modal.browsingTime)} labelAlign="right"> - {browsingTime.value ? formatPeriodCommon(browsingTime.value) : '-'} - - msg.limit.item.delayCount)} labelAlign="right"> - {reason.value?.delayCount ?? 0} - - - - {renderBaseItems(rule.value, url)} - msg.limit.item.period)} labelAlign="right"> - { - rule.value?.periods?.length - ?
- {rule.value?.periods.map(p => {period2Str(p)})} -
- : '-' - } -
-
-
- ) -}) - -export default _default \ No newline at end of file diff --git a/src/content-script/limit/modal/context.ts b/src/content-script/limit/modal/context.ts deleted file mode 100644 index e45b2ec01..000000000 --- a/src/content-script/limit/modal/context.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useRequest } from "@hooks/useRequest" -import limitService from "@service/limit-service" -import { useWindowFocus } from "@vueuse/core" -import { type App, inject, provide, type Ref, ref, 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 = { - url: string -} - -export const provideGlobalParam = (app: App, gp: GlobalParam) => { - app.provide(GLOBAL_KEY, gp) -} - -export const useGlobalParam = () => inject(GLOBAL_KEY) as GlobalParam - -export const provideReason = (app: App): Ref => { - const reason = ref() - app.provide(REASON_KEY, reason) - return reason -} - -export const useReason = () => inject(REASON_KEY) as Ref - -export const provideRule = () => { - const reason = useReason() - const windowFocus = useWindowFocus() - - const { data: rule, refresh } = useRequest(async () => { - if (!windowFocus.value) return null - const reasonId = reason.value?.id - if (!reasonId) return null - const rules = await limitService.select({ id: reasonId, filterDisabled: false }) - return rules?.[0] - }) - - watch([reason, windowFocus], 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 diff --git a/src/content-script/limit/modal/index.ts b/src/content-script/limit/modal/index.ts deleted file mode 100644 index a6534c3f9..000000000 --- a/src/content-script/limit/modal/index.ts +++ /dev/null @@ -1,208 +0,0 @@ -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 Main from './Main' -import { provideDelayHandler, provideGlobalParam, provideReason } 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) - } -} - -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) - - // body - this.body = document.createElement('body') - html.append(this.body) - - // 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) - } -} - -export default ModalInstance diff --git a/src/content-script/limit/modal/style/element-base.css b/src/content-script/limit/modal/style/element-base.css deleted file mode 100644 index 2431a5068..000000000 --- a/src/content-script/limit/modal/style/element-base.css +++ /dev/null @@ -1,303 +0,0 @@ -@charset "UTF-8"; - -:host { - --el-color-white: #ffffff; - --el-color-black: #000000; - --el-color-primary-rgb: 64, 158, 255; - --el-color-success-rgb: 103, 194, 58; - --el-color-warning-rgb: 230, 162, 60; - --el-color-danger-rgb: 245, 108, 108; - --el-color-error-rgb: 245, 108, 108; - --el-color-info-rgb: 144, 147, 153; - --el-font-size-extra-large: 20px; - --el-font-size-large: 18px; - --el-font-size-medium: 16px; - --el-font-size-base: 14px; - --el-font-size-small: 13px; - --el-font-size-extra-small: 12px; - --el-font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; - --el-font-weight-primary: 500; - --el-font-line-height-primary: 24px; - --el-index-normal: 1; - --el-index-top: 1000; - --el-index-popper: 2000; - --el-border-radius-base: 4px; - --el-border-radius-small: 2px; - --el-border-radius-round: 20px; - --el-border-radius-circle: 100%; - --el-transition-duration: 0.3s; - --el-transition-duration-fast: 0.2s; - --el-transition-function-ease-in-out-bezier: cubic-bezier(0.645, 0.045, 0.355, 1); - --el-transition-function-fast-bezier: cubic-bezier(0.23, 1, 0.32, 1); - --el-transition-all: all var(--el-transition-duration) var(--el-transition-function-ease-in-out-bezier); - --el-transition-fade: opacity var(--el-transition-duration) var(--el-transition-function-fast-bezier); - --el-transition-md-fade: transform var(--el-transition-duration) var(--el-transition-function-fast-bezier), opacity var(--el-transition-duration) var(--el-transition-function-fast-bezier); - --el-transition-fade-linear: opacity var(--el-transition-duration-fast) linear; - --el-transition-border: border-color var(--el-transition-duration-fast) var(--el-transition-function-ease-in-out-bezier); - --el-transition-box-shadow: box-shadow var(--el-transition-duration-fast) var(--el-transition-function-ease-in-out-bezier); - --el-transition-color: color var(--el-transition-duration-fast) var(--el-transition-function-ease-in-out-bezier); - --el-component-size-large: 40px; - --el-component-size: 32px; - --el-component-size-small: 24px -} - -:host { - color-scheme: light; - --el-color-primary: #409eff; - --el-color-primary-light-3: #79bbff; - --el-color-primary-light-5: #a0cfff; - --el-color-primary-light-7: #c6e2ff; - --el-color-primary-light-8: #d9ecff; - --el-color-primary-light-9: #ecf5ff; - --el-color-primary-dark-2: #337ecc; - --el-color-success: #67c23a; - --el-color-success-light-3: #95d475; - --el-color-success-light-5: #b3e19d; - --el-color-success-light-7: #d1edc4; - --el-color-success-light-8: #e1f3d8; - --el-color-success-light-9: #f0f9eb; - --el-color-success-dark-2: #529b2e; - --el-color-warning: #e6a23c; - --el-color-warning-light-3: #eebe77; - --el-color-warning-light-5: #f3d19e; - --el-color-warning-light-7: #f8e3c5; - --el-color-warning-light-8: #faecd8; - --el-color-warning-light-9: #fdf6ec; - --el-color-warning-dark-2: #b88230; - --el-color-danger: #f56c6c; - --el-color-danger-light-3: #f89898; - --el-color-danger-light-5: #fab6b6; - --el-color-danger-light-7: #fcd3d3; - --el-color-danger-light-8: #fde2e2; - --el-color-danger-light-9: #fef0f0; - --el-color-danger-dark-2: #c45656; - --el-color-error: #f56c6c; - --el-color-error-light-3: #f89898; - --el-color-error-light-5: #fab6b6; - --el-color-error-light-7: #fcd3d3; - --el-color-error-light-8: #fde2e2; - --el-color-error-light-9: #fef0f0; - --el-color-error-dark-2: #c45656; - --el-color-info: #909399; - --el-color-info-light-3: #b1b3b8; - --el-color-info-light-5: #c8c9cc; - --el-color-info-light-7: #dedfe0; - --el-color-info-light-8: #e9e9eb; - --el-color-info-light-9: #f4f4f5; - --el-color-info-dark-2: #73767a; - --el-bg-color: #ffffff; - --el-bg-color-page: #f2f3f5; - --el-bg-color-overlay: #ffffff; - --el-text-color-primary: #303133; - --el-text-color-regular: #606266; - --el-text-color-secondary: #909399; - --el-text-color-placeholder: #a8abb2; - --el-text-color-disabled: #c0c4cc; - --el-border-color: #dcdfe6; - --el-border-color-light: #e4e7ed; - --el-border-color-lighter: #ebeef5; - --el-border-color-extra-light: #f2f6fc; - --el-border-color-dark: #d4d7de; - --el-border-color-darker: #cdd0d6; - --el-fill-color: #f0f2f5; - --el-fill-color-light: #f5f7fa; - --el-fill-color-lighter: #fafafa; - --el-fill-color-extra-light: #fafcff; - --el-fill-color-dark: #ebedf0; - --el-fill-color-darker: #e6e8eb; - --el-fill-color-blank: #ffffff; - --el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08); - --el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12); - --el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12); - --el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12), 0px 8px 16px -8px rgba(0, 0, 0, 0.16); - --el-disabled-bg-color: var(--el-fill-color-light); - --el-disabled-text-color: var(--el-text-color-placeholder); - --el-disabled-border-color: var(--el-border-color-light); - --el-overlay-color: rgba(0, 0, 0, 0.8); - --el-overlay-color-light: rgba(0, 0, 0, 0.7); - --el-overlay-color-lighter: rgba(0, 0, 0, 0.5); - --el-mask-color: rgba(255, 255, 255, 0.9); - --el-mask-color-extra-light: rgba(255, 255, 255, 0.3); - --el-border-width: 1px; - --el-border-style: solid; - --el-border-color-hover: var(--el-text-color-disabled); - --el-border: var(--el-border-width) var(--el-border-style) var(--el-border-color); - --el-svg-monochrome-grey: var(--el-border-color) -} - -.fade-in-linear-enter-active, -.fade-in-linear-leave-active { - transition: var(--el-transition-fade-linear) -} - -.fade-in-linear-enter-from, -.fade-in-linear-leave-to { - opacity: 0 -} - -.el-fade-in-linear-enter-active, -.el-fade-in-linear-leave-active { - transition: var(--el-transition-fade-linear) -} - -.el-fade-in-linear-enter-from, -.el-fade-in-linear-leave-to { - opacity: 0 -} - -.el-fade-in-enter-active, -.el-fade-in-leave-active { - transition: all var(--el-transition-duration) cubic-bezier(.55, 0, .1, 1) -} - -.el-fade-in-enter-from, -.el-fade-in-leave-active { - opacity: 0 -} - -.el-zoom-in-center-enter-active, -.el-zoom-in-center-leave-active { - transition: all var(--el-transition-duration) cubic-bezier(.55, 0, .1, 1) -} - -.el-zoom-in-center-enter-from, -.el-zoom-in-center-leave-active { - opacity: 0; - transform: scaleX(0) -} - -.el-zoom-in-top-enter-active, -.el-zoom-in-top-leave-active { - opacity: 1; - transform: scaleY(1); - transform-origin: center top; - transition: var(--el-transition-md-fade) -} - -.el-zoom-in-top-enter-active[data-popper-placement^=top], -.el-zoom-in-top-leave-active[data-popper-placement^=top] { - transform-origin: center bottom -} - -.el-zoom-in-top-enter-from, -.el-zoom-in-top-leave-active { - opacity: 0; - transform: scaleY(0) -} - -.el-zoom-in-bottom-enter-active, -.el-zoom-in-bottom-leave-active { - opacity: 1; - transform: scaleY(1); - transform-origin: center bottom; - transition: var(--el-transition-md-fade) -} - -.el-zoom-in-bottom-enter-from, -.el-zoom-in-bottom-leave-active { - opacity: 0; - transform: scaleY(0) -} - -.el-zoom-in-left-enter-active, -.el-zoom-in-left-leave-active { - opacity: 1; - transform: scale(1); - transform-origin: top left; - transition: var(--el-transition-md-fade) -} - -.el-zoom-in-left-enter-from, -.el-zoom-in-left-leave-active { - opacity: 0; - transform: scale(.45) -} - -.collapse-transition { - transition: var(--el-transition-duration) height ease-in-out, var(--el-transition-duration) padding-top ease-in-out, var(--el-transition-duration) padding-bottom ease-in-out -} - -.el-collapse-transition-enter-active, -.el-collapse-transition-leave-active { - transition: var(--el-transition-duration) max-height ease-in-out, var(--el-transition-duration) padding-top ease-in-out, var(--el-transition-duration) padding-bottom ease-in-out -} - -.horizontal-collapse-transition { - transition: var(--el-transition-duration) width ease-in-out, var(--el-transition-duration) padding-left ease-in-out, var(--el-transition-duration) padding-right ease-in-out -} - -.el-list-enter-active, -.el-list-leave-active { - transition: all 1s -} - -.el-list-enter-from, -.el-list-leave-to { - opacity: 0; - transform: translateY(-30px) -} - -.el-list-leave-active { - position: absolute !important -} - -.el-opacity-transition { - transition: opacity var(--el-transition-duration) cubic-bezier(.55, 0, .1, 1) -} - -.el-icon-loading { - -webkit-animation: rotating 2s linear infinite; - animation: rotating 2s linear infinite -} - -.el-icon--right { - margin-inline-start: 5px -} - -.el-icon--left { - margin-inline-end: 5px -} - -@-webkit-keyframes rotating { - 0% { - transform: rotate(0deg) - } - - to { - transform: rotate(1turn) - } -} - -@keyframes rotating { - 0% { - transform: rotate(0deg) - } - - to { - transform: rotate(1turn) - } -} - -.el-icon { - --color: inherit; - align-items: center; - display: inline-flex; - height: 1em; - justify-content: center; - line-height: 1em; - position: relative; - width: 1em; - fill: currentColor; - color: var(--color); - font-size: inherit -} - -.el-icon.is-loading { - -webkit-animation: rotating 2s linear infinite; - animation: rotating 2s linear infinite -} - -.el-icon svg { - height: 1em; - width: 1em -} \ No newline at end of file diff --git a/src/content-script/limit/modal/style/index.sass b/src/content-script/limit/modal/style/index.sass deleted file mode 100644 index c33f4ab8e..000000000 --- a/src/content-script/limit/modal/style/index.sass +++ /dev/null @@ -1,59 +0,0 @@ -@use "sass:meta" -@import url("./element-base.css") -@include meta.load-css("../../../../pages/element-ui/dark-theme") -@import url("element-plus/theme-chalk/el-descriptions.css") -@import url("element-plus/theme-chalk/el-descriptions-item.css") -@import url("element-plus/theme-chalk/el-button.css") -@import url("element-plus/theme-chalk/el-button-group.css") -@import url("element-plus/theme-chalk/el-message.css") -@import url("element-plus/theme-chalk/el-message-box.css") -@import url("element-plus/theme-chalk/el-input.css") -@import url("element-plus/theme-chalk/el-tag.css") - -#app - width: 100% - height: 100% - text-align: center - display: flex - justify-content: space-around - flex-direction: column - align-items: center - color: var(--el-text-color-primary) - .alert-container - margin-bottom: 80px - .name-line - margin-block-end: 0.67em - img - width: 1.4em - height: 1.4em - margin-inline-end: .4em - img,span - vertical-align: middle - line-height: 2em - h1 - font-size: 2.7em - max-width: 50vw - margin: auto - margin-block-start: 0.67em - margin-block-end: 0.67em - .reason-container - margin-bottom: 30px - .el-descriptions - margin: auto - width: 400px - .footer-container - margin-bottom: 60px -body - height: 100vh - width: 100vw - padding: 0 - margin: 0 - position: absolute - top: 0 - left: 0 - z-index: 999999 - min-height: 600px - background-color: var(--el-fill-color-blank) -html[data-theme=dark] - body - background-color: var(--el-fill-color-dark) diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts deleted file mode 100644 index 0cea7bf7c..000000000 --- a/src/content-script/limit/processor/message-adaptor.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" -import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" -import { type LimitReason, type ModalContext, type Processor } from "../common" - -const cvtItem2AddReason = (item: timer.limit.Item): 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 }) - 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(context: ModalContext) { - this.context = context - } - - 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" } - } - - async init(): Promise { - 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) - - limitedRules - ?.flatMap?.(cvtItem2AddReason) - ?.forEach(reason => this.context.modal.addReason(reason)) - } -} - -export default MessageAdaptor \ No newline at end of file diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts deleted file mode 100644 index e22c7f29c..000000000 --- a/src/content-script/limit/processor/period-processor.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" -import { date2Idx } from "@util/limit" -import { MILL_PER_SECOND } from "@util/time" -import { type LimitReason, type ModalContext, type Processor } from "../common" - -function processRule(rule: timer.limit.Rule, nowSeconds: number, context: ModalContext): NodeJS.Timeout[] { - const { cond, periods, id } = rule - 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[] = [] - 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)) - } else if (nowSeconds >= startSeconds && nowSeconds <= endSeconds) { - context.modal.addReason(reason) - 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[] = [] - - constructor(context: ModalContext) { - this.context = context - } - - 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" } - } - - init(): Promise { - return this.init0() - } - - private async init0(rules?: timer.limit.Item[]) { - rules = rules || await sendMsg2Runtime("cs.getRelatedRules", this.context.url) - // Clear first - this.context.modal.removeReasonsByType("PERIOD") - const nowSeconds = date2Idx(new Date()) - this.timers = rules?.flatMap?.(r => processRule(r, nowSeconds, this.context)) || [] - } -} - -export default PeriodProcessor \ No newline at end of file diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts deleted file mode 100644 index 9514eb442..000000000 --- a/src/content-script/limit/processor/visit-processor.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" -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" - -class VisitProcessor implements Processor { - - private context: ModalContext - private focusTime: number = 0 - private rules: timer.limit.Rule[] = [] - private tracker: NormalTracker | undefined - private delayCount: number = 0 - - constructor(context: ModalContext) { - this.context = context - } - - 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" } - } - - hasLimited(rule: timer.limit.Rule): boolean { - const { visitTime } = rule || {} - if (!visitTime) return false - return visitTime * MILL_PER_SECOND + this.delayCount * DELAY_MILL < this.focusTime - } - - async handleTracker(data: timer.core.Event) { - const diff = (data?.end ?? 0) - (data?.start ?? 0) - this.focusTime += diff - this.rules?.forEach?.(rule => { - if (!this.hasLimited(rule)) return - const { id, cond, allowDelay } = rule - this.context.modal.addReason({ - id, - cond, - type: "VISIT", - allowDelay, - delayCount: this.delayCount, - getVisitTime: () => this.focusTime, - }) - }) - } - - async initRules() { - this.rules = await sendMsg2Runtime("cs.getRelatedRules", 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()) - } - - private processMore5Minutes() { - this.delayCount = (this.delayCount ?? 0) + 1 - this.context.modal.removeReasonsByType("VISIT") - } -} - -export default VisitProcessor \ No newline at end of file diff --git a/src/content-script/limit/reminder/component.ts b/src/content-script/limit/reminder/component.ts deleted file mode 100644 index 06378f176..000000000 --- a/src/content-script/limit/reminder/component.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { getUrl } from "@api/chrome/runtime" -import { t } from "@cs/locale" - -const containerStyle = (dark: boolean): Partial => ({ - position: 'fixed', - top: '16px', - right: '16px', - zIndex: '9999', - display: 'flex', - alignItems: 'center', - width: '330px', - padding: '14px 26px 14px 13px', - borderRadius: '8px', - transition: 'opacity .3s, transform .3s, left .3s, right .3s, top .4s, bottom .3s', - overflowWrap: 'break-word', - overflow: 'hidden', - border: dark ? '1px solid #363637' : '', - backgroundColor: dark ? '#1D1E1F' : '', - boxShadow: dark ? '0px 0px 12px rgba(0, 0, 0, .72)' : '', -}) - -const GROUP_STYLE: Partial = { - flex: '1', - minWidth: '0px', - marginInline: '13px 8px', -} - -const titleStyle = (dark: boolean): Partial => ({ - fontWeight: '700', - fontSize: '16px', - lineHeight: '24px', - margin: '0', - paddingBottom: '.3rem', - color: dark ? '#E5EAF3' : '', -}) - -const contentStyle = (dark: boolean): Partial => ({ - fontSize: '14px', - lineHeight: '24px', - margin: '6px 0 0', - color: dark ? '#CFD3DC' : '', -}) - -const closeBtnStyle = (dark: boolean): Partial => ({ - position: 'absolute', - top: '18px', - right: '15px', - cursor: 'pointer', - fontSize: '16px', - height: '1em', - width: '1em', - lineHeight: '1em', - fill: 'current-color', - color: dark ? '#A3A6AD' : '', -}) - -function mountStyle(el: HTMLElement, style: Partial) { - if (!el || !style) return - Object.entries(style).forEach(([key, val]) => typeof val === 'string' && el.style.setProperty(key, val)) -} - -function createIcon(): HTMLImageElement { - const icon = document.createElement('img') - icon.width = 32 - icon.height = 32 - icon.src = getUrl('static/images/icon.png') - return icon -} - -function createCloseBtn(dark: boolean, onClose: () => void): HTMLElement { - const btn = document.createElement('i') - mountStyle(btn, closeBtnStyle(dark)) - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') - svg.setAttribute('viewBox', '0 0 1024 1024') - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute('fill', 'currentColor') - path.setAttribute( - 'd', - 'M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.752 512 214.528 \ - 764.224a31.936 31.936 0 1 0 45.12 45.184L512 557.184l252.288 252.288a31.936 31.936 0 0 0 \ - 45.12-45.12L557.12 512.064l252.288-252.352a31.936 31.936 0 1 0-45.12-45.184z' - ) - svg.append(path) - - btn.append(svg) - - btn.addEventListener('click', ev => { - onClose?.() - ev.stopPropagation() - }) - return btn -} - -function createGroup(dark: boolean, data: timer.limit.ReminderInfo, onClose: () => void): HTMLDivElement { - const group = document.createElement('div') - mountStyle(group, GROUP_STYLE) - - const title = document.createElement('h2') - title.innerText = t(msg => msg.meta.name) - mountStyle(title, titleStyle(dark)) - group.append(title) - - const content = document.createElement('div') - mountStyle(content, contentStyle(dark)) - const p = document.createElement('p') - const duration = data?.duration ?? 'NaN' - p.innerText = t(msg => msg.limit.reminder, { min: duration }) - p.style.margin = '0' - content.append(p) - group.append(content) - - const closeBtn = createCloseBtn(dark, onClose) - group.append(closeBtn) - - return group -} - -export function createComponent(dark: boolean, data: timer.limit.ReminderInfo, onClose: () => void) { - const el = document.createElement('div') - mountStyle(el, containerStyle(dark)) - - // Icon - el.append(createIcon()) - // Group - el.append(createGroup(dark, data, onClose)) - - return el -} \ No newline at end of file diff --git a/src/content-script/limit/reminder/index.ts b/src/content-script/limit/reminder/index.ts deleted file mode 100644 index 44f1663ca..000000000 --- a/src/content-script/limit/reminder/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import optionService from "@service/option-service" -import { MILL_PER_MINUTE } from "@util/time" -import { exitFullscreen, type Processor } from "../common" -import { createComponent } from "./component" - -class Reminder implements Processor { - 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) { - if (!document?.body || this.el) return - - await exitFullscreen() - - const el = createComponent(this.darkMode, data, () => this.close()) - const domId = `time-tracker-notification-${this.id++}` - el.id = domId - document.body.append(el) - - this.el = el - const duration = data?.duration - duration && setTimeout(() => this.close(), duration * MILL_PER_MINUTE) - } - - private close() { - if (!this.el) return - this.el.remove() - this.el = undefined - } - - async init(): Promise { - this.darkMode = await optionService.isDarkMode() - } -} - -export default Reminder \ No newline at end of file diff --git a/src/database/limit-database.ts b/src/database/limit-database.ts deleted file mode 100644 index f8327bd3e..000000000 --- a/src/database/limit-database.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { formatTimeYMD, MILL_PER_DAY } from "@util/time" -import BaseDatabase from "./common/base-database" -import { REMAIN_WORD_PREFIX } from "./common/constant" - -const KEY = REMAIN_WORD_PREFIX + 'LIMIT' - -type DateRecords = { - [date: string]: { - mill: number - visit: number - delay?: number - } -} - -type LimitRecord = timer.limit.Rule & { - records: DateRecords -} - -type ItemValue = { - /** - * ID - */ - i: number - /** - * Condition - */ - c: string[] - /** - * Name - */ - n: string - /** - * Limited time, second - */ - t?: number - /** - * Limited count - */ - ct?: number - /** - * Limited time weekly, second - */ - wt?: number - /** - * Limited count weekly - */ - wct?: number - /** - * Limited time per visit, second - */ - v?: number - /** - * Forbidden periods - */ - p?: Vector<2>[] - /** - * Enabled flag - */ - e: boolean - /** - * Locked flag - */ - l: boolean - /** - * Allow to delay - */ - ad: boolean - /** - * Effective days - */ - wd?: number[] - /** - * Date records - */ - r?: { - [date: string]: { - /** - * Milliseconds - */ - m: number - /** - * Visit count - */ - c: number - /** - * Delay count - */ - d?: number - } - } -} - -const cvtItem2Rec = (item: ItemValue): LimitRecord => { - const { i, n, c, t, v, p, e, l, ad, wd, wt, r, ct, wct, } = item - const records: DateRecords = {} - Object.entries(r || {}).forEach?.(([date, { m, d, c }]) => records[date] = { mill: m, delay: d, visit: c }) - return { - id: i, - name: n, - cond: c, - time: t, - count: ct, - weekly: wt, - weeklyCount: wct, - visitTime: v, - periods: p?.map(i => [i?.[0], i?.[1]]), - enabled: e, - allowDelay: !!ad, - weekdays: wd, - records: records, - locked: l, - } -} - -type Items = Record - -function migrate(exist: Items, toMigrate: any) { - const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 - Object.values(toMigrate).forEach((value, idx) => { - const id = idBase + idx - const itemValue: ItemValue = value as ItemValue - const { c, n, t, e, l, ad, v, p } = itemValue - exist[id] = { - i: id, c, n, t, e: !!e, l: !!l, ad: !!ad, v, p, - r: {}, - } - }) -} - -/** - * Time limit - * - * @since 0.2.2 - */ -class LimitDatabase extends BaseDatabase { - private async getItems(): Promise { - let items = await this.storage.getOne(KEY) || {} - return items - } - - private update(items: Items): Promise { - const days10Ago = new Date(Date.now() - MILL_PER_DAY * 10) - const days10AgoStr = formatTimeYMD(days10Ago) - // Clear early date - Object.values(items).forEach(item => { - const records = item.r - if (!records) return - const keys2Del = Object.keys(records).filter(k => k <= days10AgoStr) - keys2Del.forEach(k => delete records[k]) - }) - return this.setByKey(KEY, items) - } - - async all(): Promise { - const items = await this.getItems() - return Object.values(items).map(cvtItem2Rec) - } - - async save(data: MakeOptional, rewrite?: boolean): Promise { - 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, - } - await this.update(items) - return id - } - - async remove(id: number): Promise { - const items = await this.getItems() - delete items[id] - await this.update(items) - } - - async updateWaste(date: string, toUpdate: { [id: number]: number }): Promise { - const items = await this.getItems() - Object.entries(toUpdate).forEach(([k, waste]) => { - const id = parseInt(k) - const entry = items[id] - if (!entry) return - const records = entry.r = entry.r || {} - const record = records[date] = records[date] || { m: 0, c: 0 } - record.m = waste - }) - await this.update(items) - } - - async increaseVisit(date: string, ids: number[]) { - const items = await this.getItems() - ids?.forEach(id => { - const entry = items[id] - if (!entry) return - const records = entry.r = entry.r || {} - const record = records[date] = records[date] || { m: 0, c: 0 } - record.c++ - }) - await this.update(items) - } - - async updateDelayCount(date: string, toUpdate: timer.limit.Item[]): Promise { - const items = await this.getItems() - toUpdate?.forEach(({ id, delayCount }) => { - const entry = items[id] - if (!entry) return - const records = entry.r = entry.r || {} - const record = records[date] = records[date] || { m: 0, c: 0 } - record.d = delayCount - }) - await this.update(items) - } - - async updateDelay(id: number, allowDelay: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].ad = allowDelay - await this.update(items) - } - - async updateEnabled(id: number, enabled: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].e = !!enabled - await this.update(items) - } - - async updateLocked(id: number, locked: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].l = !!locked - await this.update(items) - } - - async importData(data: 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) - } -} - -const limitDatabase = new LimitDatabase() - -export default limitDatabase diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index 77cb8a8a6..3196ad880 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -11,7 +11,6 @@ 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 limitModalMessages, { type ModalMessage } from "../cs/modal" import { merge, type MessageRoot } from "../merge" import aboutMessages, { type AboutMessage } from "./about" import analysisMessages, { type AnalysisMessage } from "./analysis" @@ -19,7 +18,6 @@ import dashboardMessages, { type DashboardMessage } from "./dashboard" import dataManageMessages, { type DataManageMessage } from "./data-manage" import habitMessages, { type HabitMessage } from "./habit" import helpUsMessages, { type HelpUsMessage } from "./help-us" -import limitMessages, { type LimitMessage } from "./limit" import menuMessages, { type MenuMessage } from "./menu" import mergeRuleMessages, { type MergeRuleMessage } from "./merge-rule" import operationMessages, { type OperationMessage } from './operation' @@ -41,7 +39,6 @@ export type AppMessage = { analysis: AnalysisMessage menu: MenuMessage habit: HabitMessage - limit: LimitMessage siteManage: SiteManageMessage operation: OperationMessage dashboard: DashboardMessage @@ -51,7 +48,6 @@ export type AppMessage = { button: ButtonMessage meta: MetaMessage base: BaseMessage - limitModal: ModalMessage } const MESSAGE_ROOT: MessageRoot = { @@ -66,7 +62,6 @@ const MESSAGE_ROOT: MessageRoot = { analysis: analysisMessages, menu: menuMessages, habit: habitMessages, - limit: limitMessages, siteManage: siteManageManages, operation: operationMessages, dashboard: dashboardMessages, @@ -76,7 +71,6 @@ const MESSAGE_ROOT: MessageRoot = { button: buttonMessages, meta: metaMessages, base: baseMessages, - limitModal: limitModalMessages, } const _default = merge(MESSAGE_ROOT) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json deleted file mode 100644 index dfb779507..000000000 --- a/src/i18n/message/app/limit-resource.json +++ /dev/null @@ -1,567 +0,0 @@ -{ - "zh_CN": { - "filterDisabled": "过滤无效规则", - "wildcardTip": "您可以使用通配符来匹配子域名或子页面!", - "item": { - "name": "规则名称", - "condition": "限制网址", - "daily": "每日上限", - "weekly": "每周上限", - "weekStartInfo": "每周的第一天是【{weekStart}】,你可以在统计选项中修改该值", - "delayCount": "延时次数", - "detail": "规则详情", - "visitTime": "单次访问最长时间", - "period": "不可访问的时间段", - "enabled": "启用", - "locked": "锁定", - "effectiveDay": "生效日期", - "delayAllowed": "可延时", - "delayAllowedInfo": "上网时间超过限制时,点击【再看 5 分钟】短暂延时。如果关闭该功能则不能延时。", - "visits": "次访问", - "or": "或者", - "notEffective": "未生效" - }, - "step": { - "base": "基本信息", - "url": "设置 URL", - "rule": "设置规则" - }, - "button": { - "test": "网址测试", - "option": "全局设置" - }, - "message": { - "noUrl": "未配置限制网址", - "noRule": "未填写任何规则", - "deleteConfirm": "是否要删除规则 [{name}]?", - "lockConfirm": "锁定后,即使未触发此规则,所有操作也需要验证", - "noPermissionFirefox": "请先在插件管理页[about:addons]开启该插件的粘贴板权限", - "inputTestUrl": "请先输入需要测试的网址链接", - "clickTestButton": "输入完成后请点击【{buttonText}】按钮", - "noRuleMatched": "该网址未命中任何规则", - "rulesMatched": "该网址命中以下规则:", - "timeout": "倒计时已结束 XD" - }, - "verification": { - "inputTip": "规则已触发或者被锁定,如要继续操作,请在 {second} 秒内输入以下问题的答案:{prompt}", - "inputTip2": "时限规则已触发或者被锁定,如要继续操作,请于 {second} 秒内在下列输入框中原样输入:{answer}", - "pswInputTip": "时限规则已触发或者被锁定,如要继续操作,所以请在下列输入框中输入您的解锁密码", - "strictTip": "时限规则已触发,不允许手动解锁!", - "incorrectPsw": "密码错误", - "incorrectAnswer": "回答错误", - "pi": "圆周率 π 的小数部分第 {startIndex} 位到第 {endIndex} 位的共 {digitCount} 位数字", - "confession": "一寸光阴一寸金,寸金难买寸光阴" - }, - "reminder": "距离时间限制不到 {min} 分钟!" - }, - "zh_TW": { - "filterDisabled": "過濾無效規則", - "wildcardTip": "你可以使用萬用字元來匹配子網域或子頁面!", - "item": { - "name": "規則名稱", - "condition": "限制網址", - "daily": "每日上限", - "weekly": "每週上限", - "weekStartInfo": "每週起始日為「{weekStart}」,您可於統計設定中調整", - "delayCount": "延遲次數", - "detail": "規則詳細內容", - "visitTime": "單次造訪時長限制", - "period": "限制時段", - "enabled": "是否啟用", - "locked": "已鎖定", - "effectiveDay": "生效日期", - "delayAllowed": "允許延遲", - "delayAllowedInfo": "當使用時間超過限制時,可點擊【再看5分鐘】暫時延長。若關閉此功能則無法延時。", - "visits": "次造訪", - "or": "或", - "notEffective": "未生效" - }, - "step": { - "base": "基本設定", - "url": "網址設定", - "rule": "規則設定" - }, - "button": { - "test": "測試網址" - }, - "message": { - "noUrl": "未填寫限制網址", - "noRule": "未設定任何規則", - "deleteConfirm": "確定要刪除規則「{name}」嗎?", - "lockConfirm": "如果鎖定,所有操作都需要驗證,即使規則未觸發也一樣。", - "inputTestUrl": "請輸入要測試的網址", - "clickTestButton": "輸入完成後請點擊【{buttonText}】按鈕", - "noRuleMatched": "此網址未符合任何規則", - "rulesMatched": "此網址符合以下規則:", - "timeout": "時間到啦!XDDD" - }, - "verification": { - "inputTip": "規則已觸發或鎖定,請於 {second} 秒內回答:{prompt}", - "inputTip2": "規則已觸發或鎖定。請在 {second} 秒內輸入:{answer}", - "pswInputTip": "時間限制已觸發或鎖定,請輸入解鎖密碼繼續", - "strictTip": "時間限制已觸發,禁止手動解鎖!", - "incorrectPsw": "密碼錯誤", - "incorrectAnswer": "回答錯誤", - "pi": "圓周率 π 小數點後第 {startIndex} 至 {endIndex} 位共 {digitCount} 位數字", - "confession": "一寸光陰一寸金,寸金難買寸光陰" - }, - "reminder": "距離時間限制僅剩 {min} 分鐘!" - }, - "en": { - "filterDisabled": "Only enabled", - "wildcardTip": "You can use wildcards to match subdomains or subpages!", - "item": { - "name": "Rule name", - "condition": "Restricted URL", - "daily": "Daily limit", - "weekly": "Weekly limit", - "weekStartInfo": "The first day of each week is {weekStart}, you can change this value in the statistics options", - "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", - "or": "or", - "notEffective": "Not effective" - }, - "step": { - "base": "Basic Information", - "url": "Config URL", - "rule": "Config rule" - }, - "button": { - "test": "Test URL" - }, - "message": { - "noUrl": "No restriction URLs configured", - "noRule": "No rules filled in", - "deleteConfirm": "Do you want to delete the rule [{name}]?", - "lockConfirm": "If locked, all operations will require verification even if the rule is not triggered.", - "inputTestUrl": "Please enter the URL link to be tested first", - "clickTestButton": "After inputting, please click the button ({buttonText})", - "noRuleMatched": "The URL does not hit any rules", - "rulesMatched": "The URL hits the following rules:", - "timeout": "Time is up! XD" - }, - "verification": { - "inputTip": "The rule has been triggered or locked. To continue, please enter the answer to the following question within {second} seconds: {prompt}", - "inputTip2": "The rule has been triggered or locked. To continue, please enter it as it is within {second} seconds: {answer}", - "pswInputTip": "The rule has been triggered or locked. To continue, please enter your unlock password", - "strictTip": "Triggered, no operation is allowed before release!", - "incorrectPsw": "Incorrect password", - "incorrectAnswer": "Incorrect answer", - "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": "有效", - "item": { - "name": "規則名", - "condition": "制限 URL", - "daily": "1日の限度", - "weekly": "週間の限度", - "weekStartInfo": "各週の最初の日は「{weekStart}」, 統計オプションでこの値を変更することができます", - "delayCount": "遅延回数", - "detail": "規則明細", - "visitTime": "訪問ごとの制限", - "period": "許可されない期間", - "enabled": "有效", - "effectiveDay": "発効日", - "delayAllowed": "さらに5分間閲覧する", - "delayAllowedInfo": "時間が経過した場合は、一時的に5分遅らせることができます", - "visits": "訪問数", - "or": "や", - "notEffective": "効果がない" - }, - "step": { - "base": "基本情報", - "url": "設定 URL", - "rule": "設定ルール" - }, - "button": { - "test": "テストURL" - }, - "message": { - "noUrl": "埋められていない制限URL", - "noRule": "ルールが記入されていません", - "deleteConfirm": "ルール [{name}] を削除しますか?", - "inputTestUrl": "最初にテストする URL リンクを入力してください", - "clickTestButton": "入力後、ボタン({buttonText})をクリックしてください", - "noRuleMatched": "URL がどのルールとも一致しません", - "rulesMatched": "URL は次のルールに一致します。" - }, - "verification": { - "inputTip": "ルールがトリガーされたかロックされました。続行するには、次の質問に対する回答を {second} 秒以内に入力してください: {prompt}", - "inputTip2": "ルールがトリガーされたかロックされました。続行するには、{second} 秒以内にそのまま入力してください: {answer}", - "pswInputTip": "このルールはすでにトリガーされています。続行するには、ロック解除パスワードを入力してください", - "strictTip": "制限ルールがすでにトリガーされており、手動によるロック解除は許可されていません。", - "incorrectPsw": "間違ったパスワード", - "incorrectAnswer": "間違った回答", - "pi": "πの小数部の{digitCount} から {startIndex} までの {endIndex} 桁の数", - "confession": "人生とは今日一日のことである" - }, - "reminder": "制限時間まで {min} 分未満!" - }, - "pt_PT": { - "filterDisabled": "Apenas ativos", - "wildcardTip": "Pode usar wildcards para corresponder a subdomínios ou subpáginas!", - "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", - "or": "ou", - "notEffective": "Não aplicável" - }, - "step": { - "base": "Informação básica", - "url": "Configurar URL", - "rule": "Configurar regra" - }, - "button": { - "test": "Testar URL" - }, - "message": { - "noUrl": "Nenhum URL configurado", - "noRule": "Nenhuma regra preenchida", - "deleteConfirm": "Eliminar a regra [{name}]?", - "lockConfirm": "Se bloqueado, exigirá verificação mesmo sem ser acionado", - "inputTestUrl": "Introduza primeiro o URL a testar", - "clickTestButton": "Clique no botão ({buttonText}) após introduzir", - "noRuleMatched": "O URL não corresponde a nenhuma regra", - "rulesMatched": "O URL corresponde às seguintes regras:", - "timeout": "Tempo esgotado! XD" - }, - "verification": { - "inputTip": "Regra acionada/bloqueada. Responda em {second}s: {prompt}", - "inputTip2": "Regra acionada/bloqueada. Repita em {second}s: {answer}", - "pswInputTip": "Regra acionada/bloqueada. Introduza a palavra-passe", - "strictTip": "Acionado - operações bloqueadas até libertação!", - "incorrectPsw": "Palavra-passe incorreta", - "incorrectAnswer": "Resposta incorreta", - "pi": "{digitCount} dígitos (posições {startIndex}-{endIndex}) da parte decimal de π", - "confession": "O tempo é efémero" - }, - "reminder": "Menos de {min} minutos até ao limite!" - }, - "uk": { - "filterDisabled": "Лише увімкнені", - "item": { - "name": "Назва правила", - "condition": "Обмежена URL-адреса", - "daily": "Денний ліміт", - "weekly": "Тижневий ліміт", - "weekStartInfo": "Перший день кожного тижня {weekStart}. Ви можете змінити це значення у налаштуваннях статистики", - "delayCount": "Лічильник затримок", - "detail": "Подробиці правила", - "visitTime": "Ліміт на відвідування", - "period": "Недозволені періоди", - "enabled": "Увімкнено", - "effectiveDay": "Діє", - "delayAllowed": "Ще 5 хвилин", - "delayAllowedInfo": "Якщо час вичерпано, дозволити тимчасову затримку 5 хвилин", - "visits": "візитів", - "or": "або", - "notEffective": "Неефективний" - }, - "step": { - "base": "Загальна інформація", - "url": "URL конфігурації", - "rule": "Правило конфігурації" - }, - "button": { - "test": "Тестова URL-адреса" - }, - "message": { - "noUrl": "Не заповнена обмежена URL-адреса", - "noRule": "Не заповнено жодного правила", - "deleteConfirm": "Ви дійсно хочете видалити правило {cond}?", - "inputTestUrl": "Спочатку введіть URL-адресу посилання для перевірки", - "clickTestButton": "Після введення натисніть кнопку ({buttonText})", - "noRuleMatched": "URL не відповідає жодному правилу", - "rulesMatched": "URL-адреса отримує такі правила:" - }, - "verification": { - "pswInputTip": "Правило обмеження вже було запущено. Щоб продовжити, введіть пароль", - "strictTip": "Правило обмеження вже активовано і ручне розблокування не дозволено!", - "incorrectPsw": "Неправильний пароль", - "incorrectAnswer": "Неправильна відповідь", - "pi": "{digitCount} цифр від {startIndex} до {endIndex} десяткової частини числа π", - "confession": "Час спливає" - }, - "reminder": "Менше ніж {min} хвилин до ліміту часу!" - }, - "es": { - "filterDisabled": "Solo habilitados", - "wildcardTip": "¡Puedes usar comodines para coincidir con subdominios o 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", - "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" - }, - "step": { - "base": "Información básica", - "url": "Configurar URL", - "rule": "Configurar regla" - }, - "button": { - "test": "Probar URL" - }, - "message": { - "noUrl": "URL restringida sin completar", - "noRule": "No hay reglas llenadas", - "deleteConfirm": "¿Deseas eliminar la regla de {cond}?", - "lockConfirm": "Si está bloqueado, todas las operaciones requerirán verificación incluso si no se activa la regla.", - "inputTestUrl": "Por favor, introduce primero el enlace URL a ser probado", - "clickTestButton": "Después de ingresarla, haz clic en el botón ({buttonText})", - "noRuleMatched": "La URL no sigue ninguna regla", - "rulesMatched": "La URL sigue las siguientes reglas:", - "timeout": "¡Tiempo se acabó! XD" - }, - "verification": { - "inputTip": "La regla se ha activado o bloqueado. Para continuar, responda la siguiente pregunta en menos de {second} segundos: {prompt}", - "inputTip2": "La regla se ha activado o bloqueado. Para continuar, introdúzcala en {second} segundos: {answer}", - "pswInputTip": "La regla de límite ya ha sido activada. Para continuar, por favor introduce tu contraseña de desbloqueo", - "strictTip": "¡La regla de límite ya ha sido activada y el desbloqueo manual no está permitido!", - "incorrectPsw": "Contraseña incorrecta", - "incorrectAnswer": "Respuesta incorrecta", - "pi": "{digitCount} dígitos de {startIndex} a {endIndex} de la parte decimal de π", - "confession": "El tiempo se está fugando" - }, - "reminder": "¡Menos de {min} minutos hasta el límite de tiempo!" - }, - "de": { - "filterDisabled": "Nur aktiviert", - "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": "Ermöglicht", - "locked": "Gesperrt", - "effectiveDay": "Wirksam auf", - "delayAllowed": "Weitere 5 Minuten", - "delayAllowedInfo": "Wenn es zu einer Zeitüberschreitung kommt, erlauben Sie eine vorübergehende Verzögerung von 5 Minuten", - "visits": "Besuche", - "or": "oder", - "notEffective": "Nicht wirksam" - }, - "step": { - "base": "Basisdaten", - "url": "URL konfigurieren", - "rule": "Konfiguration Regel" - }, - "button": { - "test": "Test-URL" - }, - "message": { - "noUrl": "Nicht ausgefüllte eingeschränkte URL", - "noRule": "Keine Regeln ausgefüllt", - "deleteConfirm": "Möchten Sie die Regel [{name}] löschen?", - "lockConfirm": "Wenn gesperrt, müssen alle Operationen geprüft werden, auch wenn die Regel nicht ausgelöst wird.", - "inputTestUrl": "Bitte geben Sie zunächst den zu testenden URL-Link ein", - "clickTestButton": "Klicken Sie nach der Eingabe bitte auf die Schaltfläche ({buttonText}).", - "noRuleMatched": "Die URL entspricht keinen Regeln", - "rulesMatched": "Die URL erfüllt die folgenden Regeln:", - "timeout": "Zeit ist abgelaufen! XD" - }, - "verification": { - "inputTip": "Die Regel wurde ausgelöst oder gesperrt. Um fortzufahren, geben Sie bitte innerhalb von {second} Sekunden die Antwort auf die folgende Frage ein: {prompt}", - "inputTip2": "Die Regel wurde ausgelöst oder gesperrt. Um fortzufahren, geben Sie sie bitte innerhalb von {second} Sekunden unverändert ein: {answer}", - "pswInputTip": "Die Limit regel wurde bereits ausgelöst. Um fortzufahren, geben Sie bitte Ihr Entsperrkennwort ein", - "strictTip": "Die Limit-Regel wurde bereits ausgelöst und eine manuelle Entsperrung ist nicht zulässig!", - "incorrectPsw": "Falsches Passwort", - "incorrectAnswer": "Falsche Antwort", - "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 !", - "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", - "or": "ou", - "notEffective": "Non efficace" - }, - "step": { - "base": "Informations de base", - "url": "URL de configuration", - "rule": "Règle de configuration" - }, - "button": { - "test": "Test URL" - }, - "message": { - "noUrl": "Aucune URL de restriction configurée", - "noRule": "Aucune règle remplie", - "deleteConfirm": "Voulez-vous supprimer la règle [{name}]?", - "lockConfirm": "Si verrouillé, toutes les opérations nécessiteront une vérification, même si la règle n'est pas activée.", - "inputTestUrl": "Veuillez entrer le lien URL à tester en premier", - "clickTestButton": "Après l'entrée, cliquez sur le bouton ({buttonText})", - "noRuleMatched": "L'URL ne correspond à aucune règle", - "rulesMatched": "L'URL atteint les règles suivantes :", - "timeout": "Temps écoulé ! XD" - }, - "verification": { - "inputTip": "La règle a été déclenchée ou verrouillée. Pour continuer, veuillez répondre à la question suivante dans un délai de {second} secondes : {prompt}", - "inputTip2": "La règle a été déclenchée ou verrouillée. Pour continuer, veuillez la saisir telle quelle dans un délai de {second} secondes : {answer}", - "pswInputTip": "La règle de limite a déjà été déclenchée. Pour continuer, veuillez saisir votre mot de passe de déverrouillage", - "strictTip": "La règle de limite a déjà été déclenchée et le déverrouillage manuel n'est pas autorisé !", - "incorrectPsw": "Mot de passe incorrect", - "incorrectAnswer": "Réponse incorrecte", - "pi": "{digitCount} chiffres de {startIndex} à {endIndex} de la partie décimale de π", - "confession": "Le temps, c'est de l'argent" - }, - "reminder": "Moins de {min} minutes jusqu'à la limite de temps !" - }, - "ru": { - "filterDisabled": "Только включен", - "item": { - "name": "Имя правила", - "condition": "Ограниченный URL", - "weekly": "Недельный лимит", - "weekStartInfo": "Первый день каждой недели {weekStart}, вы можете изменить это значение в настройках статистики", - "delayCount": "Отложенный", - "detail": "Детали правила", - "visitTime": "Лимит за посещение", - "period": "Заблокированное время", - "enabled": "Включено", - "effectiveDay": "Эффективный", - "delayAllowed": "Еще 5 минут", - "delayAllowedInfo": "Если время истекло, разрешите временную задержку 5 минут" - }, - "step": { - "base": "Основная информация", - "url": "Установить URL-адрес", - "rule": "Установить правило" - }, - "button": { - "test": "Тестовый URL" - }, - "message": { - "noUrl": "Ограничение URL-адресов не настроено", - "noRule": "Нет заполненных правил", - "deleteConfirm": "Вы хотите удалить правило [{name}]?", - "inputTestUrl": "Пожалуйста, введите ссылку для тестирования", - "clickTestButton": "После ввода информации, пожалуйста, нажмите кнопку ({buttonText})", - "noRuleMatched": "URL не содержит правил", - "rulesMatched": "URL попадает в следующие правила:" - }, - "verification": { - "inputTip": "Правило было активировано или заблокировано. Чтобы продолжить, введите ответ на следующий вопрос в течение {second} секунд: {prompt}", - "inputTip2": "Правило было запущено или заблокировано. Чтобы продолжить, введите его как есть в течение {second} секунд: {answer}", - "pswInputTip": "Лимит правило уже срабатывается. Чтобы продолжить, введите пароль разблокировки", - "strictTip": "Ограниченное правило уже было вызвано и разблокировка вручную запрещена!", - "incorrectPsw": "Неправильный пароль", - "incorrectAnswer": "Неправильный ответ", - "pi": "{digitCount} цифр от {startIndex} до {endIndex} десятичной части числа π", - "confession": "Время быстротечно" - } - }, - "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": "غير فعال" - }, - "step": { - "base": "معلومات أساسية", - "url": "عنوان URL للتكوين", - "rule": "قاعدة التكوين" - }, - "button": { - "test": "اختبار عنوان URL" - }, - "message": { - "noUrl": "لم يتم تكوين عناوين URL الخاصة بالقيود", - "noRule": "لم يتم ملء أي قواعد", - "deleteConfirm": "هل تريد حذف القاعدة [{name}]؟", - "lockConfirm": "إذا تم القَفل، فإن جميع العمليات ستتطلب التحقق حتى وإن لم يتم تفعيل القاعدة.", - "inputTestUrl": "الرجاء إدخال رابط URL ليتم اختباره أولاً", - "clickTestButton": "بعد الإدخال، الرجاء الضغط على الزر ({buttonText})", - "noRuleMatched": "عنوان URL لا يصطدم بأي قواعد", - "rulesMatched": "يتوافق عنوان URL مع القواعد التالية:", - "timeout": "انتهى الوقت! XD" - }, - "verification": { - "inputTip": "تم تفعيل القاعدة أو قفلها. للمتابعة، يُرجى إدخال إجابة السؤال التالي خلال {second} ثانية: {prompt}", - "inputTip2": "تم تفعيل القاعدة أو قفلها. للمتابعة، يُرجى إدخالها كما هي خلال {second} ثانية: {answer}", - "pswInputTip": "تم فعلًا تشغيل قاعدة الحد. للمتابعة، يرجى إدخال كلمة مرور إلغاء القُفْل الخاصة بك", - "strictTip": "لقد تم فعلًا تشغيل قاعدة الحد الأقصى ولا يُسمح بإلغاء القُفْل يدويًا!", - "incorrectPsw": "كلمة المرور غير صحيحة", - "incorrectAnswer": "إجابة خاطئة", - "pi": "{digitCount} أرقام من {startIndex} إلى {endIndex} من الجزء العشري من π", - "confession": "الوقت سريع الزوال" - }, - "reminder": "أقل من {min} دقيقة على انتهاء الوقت المحدد!" - } -} \ No newline at end of file diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts deleted file mode 100644 index e01ff833c..000000000 --- a/src/i18n/message/app/limit.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import resource from './limit-resource.json' - -export type LimitMessage = { - filterDisabled: string - wildcardTip: string - step: { - base: string - url: string - rule: string - } - 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 - delayCount: string - detail: string - visits: string - or: string - notEffective: string - } - button: { - test: string - } - message: { - noUrl: string - noRule: string - deleteConfirm: string - lockConfirm: string - inputTestUrl: string - clickTestButton: string - noRuleMatched: string - rulesMatched: string - timeout: string - } - verification: { - inputTip: string - inputTip2: string - pswInputTip: string - strictTip: string - incorrectPsw: string - incorrectAnswer: string - pi: string - confession: string - } - reminder: string -} - -export const verificationMessages: Messages = { - en: resource.en?.verification, - zh_CN: resource.zh_CN?.verification, - zh_TW: resource.zh_TW?.verification, - ja: resource.ja?.verification, - pt_PT: resource.pt_PT?.verification, - uk: resource.uk?.verification, -} - -const _default: Messages = resource - -export default _default diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index a9f496b1d..c10c9aedb 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -11,9 +11,7 @@ "mergeRule": "子域名合并", "behavior": "上网行为", "habit": "上网习惯", - "limit": "时间限制", "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", "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": "حول" } } \ 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/option.ts b/src/i18n/message/app/option.ts index 55a0d55e4..5ab623355 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -60,24 +60,6 @@ export type OptionMessage = { weekStart: string weekStartAsNormal: string } - dailyLimit: { - prompt: string - reminder: string - level: { - [level in timer.limit.RestrictionLevel]: string - } & { - label: string - passwordLabel: string - verificationLabel: string - verificationDifficulty: { - [diff in timer.limit.VerificationDifficulty]: string - } - strictTitle: string - strictContent: string - pswFormLabel: string - pswFormAgain: string - } - } backup: { title: string type: string diff --git a/src/i18n/message/cs/index.ts b/src/i18n/message/cs/index.ts index 7bdb9f4dc..b09ef73df 100644 --- a/src/i18n/message/cs/index.ts +++ b/src/i18n/message/cs/index.ts @@ -1,4 +1,3 @@ -import limitMessages, { type LimitMessage } from "../app/limit" import menuMessages, { type MenuMessage } from "../app/menu" import calendarMessages, { type CalendarMessage } from "../common/calendar" import metaMessages, { type MetaMessage } from "../common/meta" @@ -10,7 +9,6 @@ export type CsMessage = { console: ConsoleMessage modal: ModalMessage meta: MetaMessage - limit: LimitMessage menu: MenuMessage calendar: CalendarMessage } @@ -19,7 +17,6 @@ const CHILD_MESSAGES: MessageRoot = { console: consoleMessages, modal: modalMessages, meta: metaMessages, - limit: limitMessages, menu: menuMessages, calendar: calendarMessages, } diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index 5901ef939..3305cdd50 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -62,6 +62,10 @@ export const MENUS: MenuGroup[] = [{ title: msg => msg.menu.dataClear, route: '/data/manage', icon: Database + }, { + title: _ => 'AWS Backup Sync', + route: '/data/backup-sync', + icon: SetUp }] }, { title: msg => msg.menu.behavior, @@ -72,10 +76,6 @@ export const MENUS: MenuGroup[] = [{ route: '/behavior/habit', icon: Aim, mobile: true, - }, { - title: msg => msg.menu.limit, - route: '/behavior/limit', - icon: Timer }] }, { title: msg => msg.menu.additional, @@ -108,10 +108,6 @@ export const MENUS: MenuGroup[] = [{ href: getGuidePageUrl(), icon: Memo, index: '_guide', - }, { - title: msg => msg.menu.helpUs, - route: '/other/help', - icon: HelpFilled, }, { title: msg => msg.menu.about, route: '/other/about', diff --git a/src/pages/app/components/BackupSync/index.tsx b/src/pages/app/components/BackupSync/index.tsx new file mode 100644 index 000000000..848e1878b --- /dev/null +++ b/src/pages/app/components/BackupSync/index.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { ElScrollbar } from "element-plus" +import { defineComponent, type StyleValue } from "vue" +import BackupOption from "../Option/components/BackupOption" +import ContentContainer from "../common/ContentContainer" + +const _default = defineComponent(() => { + return () => ( + + + + + + ) +}) + + +export default _default \ No newline at end of file diff --git a/src/pages/app/components/HelpUs/MemberList.tsx b/src/pages/app/components/HelpUs/MemberList.tsx deleted file mode 100644 index 44d2c54f7..000000000 --- a/src/pages/app/components/HelpUs/MemberList.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) 2023-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getMembers } from "@api/crowdin" -import { t } from "@app/locale" -import { useRequest } from "@hooks" -import Box from "@pages/components/Box" -import Flex from "@pages/components/Flex" -import { ElDivider } from "element-plus" -import { defineComponent } from "vue" - -const _default = defineComponent(() => { - const { data: list } = useRequest(async () => { - const members = await getMembers() || [] - return members.sort((a, b) => (a.joinedAt || "").localeCompare(b.joinedAt || "")) - }) - return () => ( - - {t(msg => msg.helpUs.contributors)} - - {list.value?.map(({ avatarUrl, username }, idx, arr) => ( - - {username} - - ))} - - - ) -}) - -export default _default diff --git a/src/pages/app/components/HelpUs/ProgressList.tsx b/src/pages/app/components/HelpUs/ProgressList.tsx deleted file mode 100644 index ce9eecd9c..000000000 --- a/src/pages/app/components/HelpUs/ProgressList.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getTranslationStatus, type TranslationStatusInfo } from "@api/crowdin" -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 - -const localeCrowdMap: { [locale in SupportedLocale]: string } = { - en: "en", - zh_CN: "zh-CN", - ja: "ja", - zh_TW: "zh-TW", - de: "de", - es: "es-ES", - ko: "ko", - pl: "pl", - pt_PT: "pt-PT", - ru: "ru", - uk: "uk", - fr: "fr", - it: "it", - sv: "sv-SE", - fi: "fi", - da: "da", - hr: "hr", - id: "id", - tr: "tr", - cs: "cs", - ro: "ro", - nl: "nl", - vi: "vi", - sk: "sk", - mn: "mn", - ar: "ar", - hi: "hi", -} - -const crowdLocaleMap: { [locale: string]: SupportedLocale } = {} - -Object.entries(localeCrowdMap).forEach(([locale, crowdLang]) => crowdLocaleMap[crowdLang] = locale as SupportedLocale) - -type ProgressInfo = { - locale: SupportedLocale | string - progress: number -} - -const convert2Info = ({ languageId, translationProgress }: TranslationStatusInfo): ProgressInfo => ({ - locale: crowdLocaleMap[languageId] || languageId, - progress: translationProgress -} satisfies ProgressInfo) - -const compareLocale = ({ progress: pa, locale: la }: ProgressInfo, { progress: pb, locale: lb }: ProgressInfo): number => { - const progressDiff = (pb ?? 0) - (pa ?? 0) - return progressDiff || la?.localeCompare?.(lb) || 0 -} - -function computeType(progress: number): ProgressProps["status"] { - if (progress >= 95) { - return "success" - } else if (progress >= 50) { - return "" - } else { - return "warning" - } -} - -const CONTAINER_STYLE: StyleValue = { - display: 'grid', - paddingInline: '30px', - boxSizing: 'border-box', - width: '100%', - minHeight: '200px', - gridTemplateColumns: 'repeat(3, 30%)', - columnGap: '5%', - rowGap: '30px', - marginBottom: '40px', -} - -async function queryData(): Promise { - const langList = await getTranslationStatus() - return langList?.map?.(convert2Info)?.sort(compareLocale) -} - -const _default = defineComponent(() => { - const container = ref() - const { data: list } = useRequest( - queryData, - { loadingTarget: container, loadingText: t(msg => msg.helpUs.loading) }, - ) - return () => ( -
- {list.value?.map?.(({ locale, progress }) => ( - - - {`${progress}%`} - {localeMessages[locale as timer.Locale]?.name ?? locale} - - - ))} -
- ) -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/HelpUs/index.tsx b/src/pages/app/components/HelpUs/index.tsx deleted file mode 100644 index 0e4a50d5a..000000000 --- a/src/pages/app/components/HelpUs/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { createTabAfterCurrent } from "@api/chrome/tab" -import { t } from "@app/locale" -import { Pointer } from "@element-plus/icons-vue" -import Box from "@pages/components/Box" -import { CROWDIN_HOMEPAGE } from "@util/constant/url" -import { ElAlert, ElButton, ElCard, ElScrollbar } from "element-plus" -import { type FunctionalComponent, type StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" -import MemberList from "./MemberList" -import ProgressList from "./ProgressList" - -const handleJump = () => createTabAfterCurrent(CROWDIN_HOMEPAGE) - -const HelpUs: FunctionalComponent = () => ( - - - - msg.helpUs.title)}> -
  • {t(msg => msg.helpUs.alert.l1)}
  • -
  • {t(msg => msg.helpUs.alert.l2)}
  • -
  • {t(msg => msg.helpUs.alert.l3)}
  • -
  • {t(msg => msg.helpUs.alert.l4)}
  • -
    - - - {t(msg => msg.helpUs.button)} - - - - -
    -
    -
    -) -HelpUs.displayName = 'HelpUs' - -export default HelpUs \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitFilter.tsx b/src/pages/app/components/Limit/LimitFilter.tsx deleted file mode 100644 index 8dca8d56a..000000000 --- a/src/pages/app/components/Limit/LimitFilter.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { createTabAfterCurrent } from "@api/chrome/tab" -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 { Delete, Open, Operation, Plus, SetUp, TurnOff } from "@element-plus/icons-vue" -import Flex from "@pages/components/Flex" -import { getAppPageUrl } from "@util/constant/url" -import { defineComponent } from "vue" -import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" -import { useLimitAction, useLimitBatch, useLimitFilter } from "./context" - -const optionPageUrl = getAppPageUrl(OPTION_ROUTE, { i: 'dailyLimit' }) - -type BatchOpt = 'delete' | 'enable' | 'disable' - -const _default = defineComponent(() => { - const { create, test } = useLimitAction() - const filter = useLimitFilter() - const { batchDelete, batchDisable, batchEnable } = useLimitBatch() - - const batchItems: DropdownButtonItem[] = [ - { - key: 'delete', - label: t(msg => msg.button.batchDelete), - icon: Delete, - onClick: batchDelete, - }, { - key: 'enable', - label: t(msg => msg.button.batchEnable), - icon: Open, - onClick: batchEnable, - }, { - key: 'disable', - label: t(msg => msg.button.batchDisable), - icon: TurnOff, - onClick: batchDisable, - }, - ] - - return () => ( - - - msg.limit.item.condition)} - onSearch={val => filter.url = val} - /> - msg.limit.filterDisabled)} - defaultValue={filter.onlyEnabled} - onChange={val => filter.onlyEnabled = val} - /> - - - - msg.limit.button.test)} - type="primary" - icon={Operation} - onClick={test} - /> - msg.base.option)} - icon={SetUp} - type="primary" - onClick={() => createTabAfterCurrent(optionPageUrl)} - /> - msg.button.create)} - type="success" - icon={Plus} - onClick={create} - /> - - - ) -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx deleted file mode 100644 index d4dd76e86..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { t } from "@app/locale" -import { ElCol, ElForm, ElFormItem, ElInput, ElOption, ElRow, ElSelect, ElSwitch } from "element-plus" -import { defineComponent } from "vue" -import { useSopData } from "./context" - -const _default = defineComponent(() => { - const data = useSopData() - - return () => ( - - - - msg.limit.item.name)} required> - data.name = val} - clearable onClear={() => data.name = ''} - /> - - - - msg.limit.item.enabled)} required> - data.enabled = !!v} /> - - - - - - msg.limit.item.effectiveDay)} required> - data.weekdays = v} - placeholder="" - > - {t(msg => msg.calendar.weekDays).split('|').map((weekDay, idx) => )} - - - - - - ) -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step2.tsx deleted file mode 100644 index 47fb2e487..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step2.tsx +++ /dev/null @@ -1,93 +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, WarnTriangleFilled } from "@element-plus/icons-vue" -import { useState } from "@hooks/index" -import Flex from "@pages/components/Flex" -import { cleanCond } from "@util/limit" -import { ElButton, ElDivider, ElIcon, ElInput, ElLink, ElMessage, ElScrollbar, ElText, type ScrollbarInstance } from "element-plus" -import { type StyleValue, defineComponent, ref } from "vue" -import { useSopData } from "./context" - -const _default = defineComponent(() => { - const data = useSopData() - const scrollbar = ref() - - const handleAdd = () => { - let url: string | undefined = inputting.value?.trim?.()?.toLowerCase?.() - if (!url) return ElMessage.warning('URL is blank') - url = cleanCond(url) - if (!url) return ElMessage.warning('URL is invalid') - const urls = data.cond - if (urls.includes(url)) return ElMessage.warning('Duplicated URL') - urls.unshift(url) - scrollbar.value?.scrollTo(0) - - clearInputting() - } - - const handleRemove = (idx: number) => data.cond.splice(idx, 1) - - const [inputting, setInputting, clearInputting] = useState('') - - return () => ( - - - (ev as KeyboardEvent)?.code === "Enter" && handleAdd()} - v-slots={{ - append: () => ( - - {t(msg => msg.button.add)} - - ) - }} - placeholder="www.demo.com, *.demo.com, demo.com/blog/*, demo.com/**" - /> - - {t(msg => msg.limit.wildcardTip)} - - - - - - {data.cond?.map((url, idx) => ( - - {url} - handleRemove(idx)} /> - - ))} - - - - - - - {t(msg => msg.limit.message.noUrl)} - - - - - ) -}) - -export default _default diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx deleted file mode 100644 index c47714918..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx +++ /dev/null @@ -1,152 +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 { Check, Close, Plus } from "@element-plus/icons-vue" -import { useState, useSwitch } from "@hooks" -import Flex from "@pages/components/Flex" -import { dateMinute2Idx, period2Str } from "@util/limit" -import { MILL_PER_HOUR } from "@util/time" -import { ElButton, ElTag, ElTimePicker } from "element-plus" -import { type PropType, type StyleValue, defineComponent } from "vue" -import './period-input.sass' - -const BUTTON_STYLE: StyleValue = { - padding: '8px', - height: '32px', - lineHeight: '32px', -} - -const range2Period = (range: [Date, Date]): [number, number] => { - const [start, end] = range - const startIdx = dateMinute2Idx(start) - const endIdx = dateMinute2Idx(end) - return [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)] -} - -const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Period) => { - if (!toInsert || !periods) return - let len = periods.length - if (!len) { - periods.push(toInsert) - return - } - for (let i = 0; i < len; i++) { - const pre = periods[i] - const next = periods[i + 1] - if (checkImpact(pre, toInsert)) { - mergePeriod(pre, toInsert) - if (checkImpact(pre, next)) { - mergePeriod(pre, next) - periods.splice(i + 1, 1) - } - return - } - if (checkImpact(toInsert, next)) { - mergePeriod(next, toInsert) - return - } - } - // Append - periods.push(toInsert) - periods.sort((a, b) => a[0] - b[0]) -} - -const mergePeriod = (target: timer.limit.Period, toMerge: timer.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 => { - if (!p1 || !p2) return false - const [s1, e1] = p1 - const [s2, e2] = p2 - return (s1 >= s2 && s1 <= e2) || (s2 >= s1 && s2 <= e1) -} - -const rangeInitial = (): [Date, Date] => { - const now = new Date() - return [now, new Date(now.getTime() + MILL_PER_HOUR)] -} - -const _default = defineComponent({ - props: { - modelValue: Array as PropType - }, - emits: { - change: (_periods: timer.limit.Period[]) => true, - }, - setup(props, ctx) { - const [editing, openEditing, closeEditing] = useSwitch(false) - const [editingRange, setEditingRange] = useState(rangeInitial()) - - const handleEdit = () => { - openEditing() - setEditingRange(rangeInitial()) - } - - const handleSave = () => { - const val = range2Period(editingRange.value) - const oldPeriods = props.modelValue?.map(p => ([p?.[0], p?.[1]] satisfies Vector)) || [] - insertPeriods(oldPeriods, val) - ctx.emit('change', oldPeriods) - closeEditing() - } - - const handleDelete = (idx: number) => { - const newPeriods = props.modelValue?.filter((_, i) => i !== idx) - ?.map(p => ([p?.[0], p?.[1]] satisfies Vector)) || [] - ctx.emit('change', newPeriods) - } - - return () => ( - - {props.modelValue?.map((p, idx) => - handleDelete(idx)} - > - {period2Str(p)} - - )} -
    - - - -
    - - {t(msg => msg.button.create)} - -
    - ) - } -}) - -export default _default \ No newline at end of file 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 5a70848ff..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { CircleClose, Clock } from "@element-plus/icons-vue" -import { useState } from "@hooks" -import { getStyle } from "@pages/util/style" -import { range } from "@util/array" -import { useDebounceFn } from "@vueuse/core" -import { Effect, ElIcon, ElInput, ElPopover, ElScrollbar, ScrollbarInstance, useLocale, useNamespace } from "element-plus" -import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" - -function computeSecond2LimitInfo(time: number): [number, number, number] { - time = time || 0 - const second = time % 60 - const totalMinutes = (time - second) / 60 - const minute = totalMinutes % 60 - const hour = (totalMinutes - minute) / 60 - return [hour, minute, second] -} - -const formatTimeVal = (val: number): string => { - return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' -} - -const TimeSpinner = defineComponent({ - props: { - max: { - type: Number, - required: true, - }, - visible: Boolean, - modelValue: { - type: Number, - required: true, - }, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const ns = useNamespace('time') - const scrollbar = ref() - const scrolling = ref(false) - - const debounceChangeValue = useDebounceFn((val: number) => { - scrolling.value = false - ctx.emit('change', val) - }, 200) - - const getScrollbarElement = () => { - const el = scrollbar.value?.$el - return el?.querySelector(`.${ns.namespace.value}-scrollbar__wrap`) as HTMLElement - } - - const adjustSpinner = (value: number) => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.scrollTop = Math.max(0, value * typeItemHeight()) - } - - watch(() => props.modelValue, () => adjustSpinner(props.modelValue)) - watch(() => props.visible, () => props.visible && nextTick(() => adjustSpinner(props.modelValue))) - - const typeItemHeight = (): number => { - const listItem = scrollbar.value?.$el.querySelector('li') as HTMLLinkElement - if (listItem) { - return Number.parseFloat(getStyle(listItem, 'height')) || 0 - } - return 0 - } - - const bindScroll = () => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.addEventListener('scroll', () => { - scrolling.value = true - const scrollTop = getScrollbarElement()?.scrollTop ?? 0 - const scrollbarH = (scrollbar.value?.$el as HTMLUListElement)!.offsetHeight ?? 0 - const itemH = typeItemHeight() - const estimatedIdx = Math.round((scrollTop - (scrollbarH * 0.5 - 10) / itemH + 3) / itemH) - const value = Math.min(estimatedIdx, props.max - 1) - debounceChangeValue(value) - }) - } - - onMounted(() => { - bindScroll() - adjustSpinner(props.modelValue) - }) - - return () => ( - - {range(props.max).map(idx => ( -
  • ctx.emit('change', idx)} - class={[ - ns.be('spinner', 'item'), - ns.is('active', idx === props.modelValue), - ]} - > - {idx.toString().padStart(2, '0')} -
  • - ))} -
    - ) - }, -}) - -const useTimeInput = (source: () => number) => { - const [initialHour, initialMin, initialSec] = computeSecond2LimitInfo(source?.() ?? 0) - const [hour, setHour] = useState(initialHour) - const [minute, setMinute] = useState(initialMin) - const [second, setSecond] = useState(initialSec) - - const reset = () => { - const [hour, min, sec] = computeSecond2LimitInfo(source?.() ?? 0) - setHour(hour) - setMinute(min) - setSecond(sec) - } - - watch(source, reset) - - const getTotalSecond = () => { - let time = 0 - time += (hour.value ?? 0) * 3600 - time += (minute.value ?? 0) * 60 - time += (second.value ?? 0) - return time - } - - return { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } -} - -/** - * Rewrite - * - * https://github.com/element-plus/element-plus/blob/dev/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue - */ -const TimeInput = defineComponent({ - props: { - modelValue: { - type: Number, - required: true, - }, - hourMax: Number, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const [popoverVisible, setPopoverVisible] = useState(false) - const { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } = useTimeInput(() => props.modelValue) - - const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) - - const ns = useNamespace('time') - const nsDate = useNamespace('date') - const nsInput = useNamespace('input') - - const { t: tEle } = useLocale() - - const transitionName = computed(() => popoverVisible.value ? '' : `${ns.namespace.value}-zoom-in-top`) - - const handleCancel = () => { - reset() - setPopoverVisible(false) - } - - const handleConfirm = () => { - ctx.emit('change', getTotalSecond()) - setPopoverVisible(false) - } - - const handleVisibleChange = (newVal: boolean) => { - setPopoverVisible(newVal) - !newVal && handleCancel() - } - - const handleClear = (ev: MouseEvent) => { - ctx.emit('change', 0) - ev.stopPropagation() - } - - return () => ( - ( - !!props.modelValue && ( -
    - - - -
    - ) - }} - /> - ) - }}> - -
    -
    -
    - - - -
    -
    -
    - - -
    -
    -
    -
    - ) - }, -}) - -export default TimeInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx deleted file mode 100644 index 25acb2b8f..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/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 { t } from "@app/locale" -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() - - return () => ( - - - msg.limit.item.daily)}> - - data.time = v} /> - {t(msg => msg.limit.item.or)} - data.count = v ?? 0} - v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} - /> - - - msg.limit.item.weekly)}> - - data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> - {t(msg => msg.limit.item.or)} - data.weeklyCount = v ?? 0} - v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} - /> - - - msg.limit.item.visitTime)}> - data.visitTime = v} /> - - msg.limit.item.period)}> - data.periods = v} /> - - - - ) -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/period-input.sass b/src/pages/app/components/Limit/LimitModify/Sop/Step3/period-input.sass deleted file mode 100644 index f7713222d..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/period-input.sass +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -.limit-period-input-time-picker.el-date-editor - width: 120px !important - padding: 0 5px !important - border-top-right-radius: 0px - border-bottom-right-radius: 0px - .el-range__close-icon - width: 0px - .el-range-input - height: 28px 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 5391caf09..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/context.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { t } from "@app/locale" -import { useProvide, useProvider } from "@hooks/useProvider" -import { range } from "@util/array" -import { ElMessage } from "element-plus" -import { type Reactive, reactive, ref, toRaw } from "vue" - -type Step = 0 | 1 | 2 - -type SopData = Required> - -type Context = { - data: Reactive -} - -const createInitial = (): SopData => ({ - name: '', - time: 3600, - weekly: 0, - cond: [], - visitTime: 0, - periods: [], - enabled: true, - weekdays: range(7), - count: 0, - weeklyCount: 0, - allowDelay: false, - locked: false, -}) - -type Options = { - onSave?: (data: SopData) => void -} - -const NAMESPACE = 'limit_sop_model' - -export const initSop = ({ onSave }: Options) => { - const step = ref(0) - const data = reactive(createInitial()) - - const validator: Record Promise> = { - 0: async () => { - const nameVal = data.name?.trim?.() - const weekdaysVal = data.weekdays - if (!nameVal) { - ElMessage.error("Name is empty") - return false - } if (!weekdaysVal?.length) { - ElMessage.error("Effective days are empty") - return false - } - return true - }, - 1: async () => { - if (!data.cond?.length) { - ElMessage.error(t(msg => msg.limit.message.noUrl)) - return false - } - return true - }, - 2: async () => { - const { time, count, weekly, weeklyCount, visitTime, periods } = data - if (true - && !time && !count - && !weekly && !weeklyCount - && !visitTime && !periods?.length - ) { - ElMessage.error(t(msg => msg.limit.message.noRule)) - return false - } - return true - }, - } - - const reset = (rule?: timer.limit.Rule) => { - const rawRule = rule ? toRaw(rule) : createInitial() - Object.entries(rawRule).forEach(([k, v]) => (data as any)[k] = v) - step.value = 0 - } - - const handleNext = async () => { - const stepVal = step.value - const isValid = await validator[stepVal]?.() - if (isValid) { - stepVal === 2 ? onSave?.(toRaw(data)) : step.value++ - } - } - - useProvide(NAMESPACE, { data }) - - return { - step, reset, handleNext - } -} - -export const useSopData = () => useProvider(NAMESPACE, 'data').data \ 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 46333b734..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/index.tsx +++ /dev/null @@ -1,65 +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 -} - -const _default = defineComponent({ - props: { - condDisabled: Boolean, - }, - emits: { - cancel: () => true, - save: (_rule: MakeOptional) => true, - }, - setup(_, ctx) { - const { reset, step, handleNext } = initSop({ onSave: data => ctx.emit('save', data) }) - const last = computed(() => step.value === 2) - const first = computed(() => step.value === 0) - ctx.expose({ reset } satisfies SopInstance) - - return () => ( - step.value--} - onCancel={() => ctx.emit('cancel')} - onNext={handleNext} - onFinish={handleNext} - v-slots={{ - steps: () => ( - - msg.limit.step.base)} /> - msg.limit.step.url)} /> - msg.limit.step.rule)} /> - - ), - content: () => <> - - - - - }} - /> - ) - } -}) - -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() - const mode = ref() - const title = computed(() => mode.value === "create" ? t(msg => msg.button.create) : t(msg => msg.button.modify)) - // Cache - let modifyingItem: timer.limit.Rule | undefined = undefined - - const handleSave = async (rule: MakeOptional) => { - if (!rule) return - const { cond, enabled, name, time, weekly, visitTime, periods, weekdays, count, weeklyCount } = rule - let saved: timer.limit.Rule - if (mode.value === 'modify') { - if (!modifyingItem) return - saved = { - ...modifyingItem, - cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, - // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector)), - } satisfies timer.limit.Rule - await limitService.update(saved) - } else { - const toCreate = { - cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, - // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector)), - allowDelay: false, locked: false, - } satisfies MakeOptional - const id = await limitService.create(toCreate) - saved = { ...toCreate, id } - } - 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 () => ( - - - - ) -}) - -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 () => ( -
    - - {props.label}: {content.value} - -
    - ) - }, -}) - -const RuleContent = defineComponent({ - props: { - value: Object as PropType - }, - setup(props) { - const row = toRef(props, 'value') - - return () => ( - - msg.limit.item.daily)} - /> - msg.limit.item.weekly)} - /> - {!!row.value?.visitTime && ( -
    - - {t(msg => msg.limit.item.visitTime)}: {formatPeriod(row.value?.visitTime * MILL_PER_SECOND, TIME_FORMAT)} - -
    - )} - {!!row.value?.periods?.length && <> -
    - {t(msg => msg.limit.item.period)} -
    - - {row.value?.periods?.map(p => {period2Str(p)})} - - } -
    - ) - }, -}) - -export default RuleContent \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Waste.tsx b/src/pages/app/components/Limit/LimitTable/Waste.tsx deleted file mode 100644 index 0b1baa8f4..000000000 --- a/src/pages/app/components/Limit/LimitTable/Waste.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" -import Flex from "@pages/components/Flex" -import { meetLimit, meetTimeLimit } from "@util/limit" -import { formatPeriodCommon } from "@util/time" -import { ElTag } from "element-plus" -import { computed, defineComponent } from "vue" - -const Waste = defineComponent({ - props: { - time: Number, - waste: { - type: Number, - required: true, - }, - count: Number, - visit: Number, - delayCount: Number, - allowDelay: Boolean, - }, - setup(props) { - const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') - const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') - - return () => ( - -
    - `${t(msg => msg.limit.item.delayCount)}: ${props.delayCount ?? 0}`, - default: () => ( - - {formatPeriodCommon(props.waste)} - - ), - }} - /> -
    -
    - - {props.visit ?? 0} {t(msg => msg.limit.item.visits)} - -
    -
    - ) - }, -}) - -export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Weekday.tsx b/src/pages/app/components/Limit/LimitTable/Weekday.tsx deleted file mode 100644 index 33a8804a9..000000000 --- a/src/pages/app/components/Limit/LimitTable/Weekday.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Flex from "@pages/components/Flex" -import { t } from "@popup/locale" -import { ElTag } from "element-plus" -import { computed, defineComponent, toRef, type PropType } from "vue" - -const ALL_WEEKDAYS = t(msg => msg.calendar.weekDays)?.split('|') - -const Weekday = defineComponent({ - props: { - value: Array as PropType, - }, - setup(props) { - const weekdays = toRef(props, 'value') - const isFull = computed(() => !weekdays.value?.length || weekdays.value?.length === 7) - - return () => isFull.value ? ( - - {t(msg => msg.calendar.range.everyday)} - - ) : ( - - {weekdays.value?.map(w => {ALL_WEEKDAYS[w]})} - - ) - }, -}) - -export default Weekday \ 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 9c82e745e..000000000 --- a/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.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 { t } from "@app/locale" -import { Delete, Edit } from "@element-plus/icons-vue" -import { locale } from "@i18n" -import { type ElTableRowScope } from "@pages/element-ui/table" -import { ElButton, ElTableColumn } 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, -} - -const _default = defineComponent(() => { - const { deleteRow } = useLimitTable() - const { modify } = useLimitAction() - - const handleModify = (row: timer.limit.Item) => verifyCanModify(row) - .then(() => modify(row)) - .catch(() => {/** Do nothing */ }) - - return () => msg.button.operation)} - width={LOCALE_WIDTH[locale]} - align="center" - fixed="right" - v-slots={({ row }: ElTableRowScope) => <> - deleteRow(row)}> - {t(msg => msg.button.delete)} - - handleModify(row)} - > - {t(msg => msg.button.modify)} - - - } - /> -}) - -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 443f0d329..000000000 --- a/src/pages/app/components/Limit/LimitTable/index.tsx +++ /dev/null @@ -1,179 +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 { useRequest } from "@hooks" -import { type ElTableRowScope } from "@pages/element-ui/table" -import weekHelper from "@service/components/week-helper" -import { isEffective } from "@util/limit" -import { useLocalStorage } from "@vueuse/core" -import { ElSwitch, ElTable, ElTableColumn, ElTag, type Sort } from "element-plus" -import { defineComponent } 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 - -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 historySort = useLocalStorage('__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' }) - - return () => ( - historySort.value = { prop: val?.prop, order: val?.order }} - > - - msg.limit.item.name)} - minWidth={120} - align="center" - formatter={({ name }: timer.limit.Item) => name || '-'} - fixed - sortable - sortBy={(row: timer.limit.Item) => row.name} - /> - msg.limit.item.condition)} - minWidth={180} - align="center" - formatter={({ cond }: timer.limit.Item) => <>{cond?.map?.(c => {c}) || ''}} - /> - msg.limit.item.detail)} - minWidth={200} - align="center" - > - {({ row }: ElTableRowScope) => } - - msg.limit.item.effectiveDay)} - minWidth={170} - align="center" - sortable - sortMethod={sortByEffectiveDays} - > - {({ row: { weekdays } }: ElTableRowScope) => } - - msg.calendar.range.today)} - minWidth={90} - align="center" - > - {({ row }: ElTableRowScope) => isEffective(row.weekdays) ? ( - - ) : ( - - {t(msg => msg.limit.item.notEffective)} - - )} - - ( - msg.calendar.range.thisWeek)} - tooltipContent={t(msg => msg.limit.item.weekStartInfo, { weekStart: weekStartName.value })} - /> - ), - default: ({ row: { - weeklyWaste, weekly, - weeklyVisit, weeklyCount, - weeklyDelayCount, allowDelay, - } }: ElTableRowScope) => ( - - ), - }} - /> - msg.button.configuration)}> - msg.limit.item.enabled)} - minWidth={80} - align="center" - fixed="right" - > - {({ row }: ElTableRowScope) => ( - changeEnabled(row, !!v)} /> - )} - - msg.limit.item.delayAllowed)} - minWidth={80} - align="center" - fixed="right" - > - {({ row }: ElTableRowScope) => ( - changeDelay(row, !!v)} /> - )} - - msg.limit.item.locked)} - minWidth={80} - align="center" - fixed="right" - > - {({ row }: ElTableRowScope) => ( - changeLocked(row, !!v)} /> - )} - - - - - ) -}) - -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/LimitTest.tsx deleted file mode 100644 index 0f78ab604..000000000 --- a/src/pages/app/components/Limit/LimitTest.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { t } from "@app/locale" -import { useState, useSwitch } from "@hooks" -import limitService from "@service/limit-service" -import { AlertProps, ElAlert, ElButton, ElDialog, ElFormItem, ElInput } from "element-plus" -import { computed, defineComponent } from "vue" -import { type TestInstance } from "./context" - -function computeResultTitle(url: string | undefined, inputting: boolean, matched: timer.limit.Rule[]): string { - if (!url) { - return t(msg => msg.limit.message.inputTestUrl) - } - if (inputting) { - return t(msg => msg.limit.message.clickTestButton, { buttonText: t(msg => msg.button.test) }) - } - if (!matched?.length) { - return t(msg => msg.limit.message.noRuleMatched) - } else { - return t(msg => msg.limit.message.rulesMatched) - } -} - -function computeResultDesc(url: string | undefined, inputting: boolean, matched: timer.limit.Rule[]): string[] { - if (!url || inputting || !matched?.length) { - return [] - } - return matched.map(m => m.name) -} - -type _ResultType = AlertProps['type'] - -function computeResultType(url: string | undefined, inputting: boolean, matched: timer.limit.Rule[]): _ResultType { - if (!url || inputting) { - return 'info' - } - return matched?.length ? 'success' : 'warning' -} - -const _default = defineComponent((_props, ctx) => { - const [url, , clearUrl] = useState() - const [matched, , clearMatched] = useState([]) - const [visible, open, close] = useSwitch() - const [urlInputting, startInput, endInput] = useSwitch(true) - const resultTitle = computed(() => computeResultTitle(url.value, urlInputting.value, matched.value)) - const resultType = computed(() => computeResultType(url.value, urlInputting.value, matched.value)) - const resultDesc = computed(() => computeResultDesc(url.value, urlInputting.value, matched.value)) - - const changeInput = (newVal?: string) => { - startInput() - url.value = newVal?.trim() - } - - const handleTest = async () => { - endInput() - matched.value = await limitService.select({ url: url.value, filterDisabled: true }) - } - - ctx.expose({ - show: () => { - clearUrl() - open() - startInput() - clearMatched() - } - } satisfies TestInstance) - return () => ( - msg.button.test)} - modelValue={visible.value} - closeOnClickModal={false} - onClose={close} - > - msg.limit.button.test)}> - changeInput()} - onKeydown={ev => (ev as KeyboardEvent).key === "Enter" && handleTest()} - onInput={changeInput} - v-slots={{ - append: () => {t(msg => msg.button.test)} - }} - /> - - - {resultDesc.value.map(desc =>
  • {desc}
  • )} -
    -
    - ) -}) - -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 deleted file mode 100644 index 791e2263c..000000000 --- a/src/pages/app/components/Limit/common.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { judgeVerificationRequired, processVerification } from "@app/util/limit" -import optionHolder from "@service/components/option-holder" - -const batchJudge = async (items: timer.limit.Item[]): Promise => { - if (!items?.length) return false - for (const item of items) { - if (!item) continue - const needVerify = await judgeVerificationRequired(item) - if (needVerify) return true - } - return false -} - -export const verifyCanModify = async (...items: timer.limit.Item[]) => { - const needVerify = await batchJudge(items) - if (!needVerify) return - - // Open delay for limited rules, so verification is required - const option = await optionHolder.get() - await processVerification(option) -} \ No newline at end of file diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts deleted file mode 100644 index 06822a393..000000000 --- a/src/pages/app/components/Limit/context.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { t } from "@app/locale" -import { useManualRequest, useProvide, useProvider, useRequest } from "@hooks" -import limitService from "@service/limit-service" -import { useDocumentVisibility } from "@vueuse/core" -import { ElMessage, ElMessageBox, type TableInstance } from "element-plus" -import { 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 -} - -type Context = { - table: Ref - filter: Reactive - list: Ref, refresh: NoArgCallback, - deleteRow: ArgCallback - batchDelete: NoArgCallback - batchEnable: NoArgCallback - batchDisable: NoArgCallback - changeEnabled: (item: timer.limit.Item, val: boolean) => Promise - changeDelay: (item: timer.limit.Item, val: boolean) => Promise - changeLocked: (item: timer.limit.Item, val: boolean) => Promise - modify: (item: timer.limit.Item) => void - create: () => void - test: () => void -} - -const NAMESPACE = 'limit' - -const initialUrl = () => { - // Init with url parameter - const urlParam = useRoute().query['url'] as string - useRouter().replace({ query: {} }) - return urlParam ? decodeURIComponent(urlParam) : '' -} - -export const useLimitProvider = () => { - const filter = reactive({ url: initialUrl(), onlyEnabled: false }) - - const { data: list, refresh } = useRequest( - () => limitService.select({ filterDisabled: filter.onlyEnabled, url: filter.url ?? '' }), - { - defaultValue: [], - deps: [() => filter.url, () => filter.onlyEnabled], - }, - ) - - // Query data if the window become visible - const docVisible = useDocumentVisibility() - watch(docVisible, () => docVisible.value && refresh()) - - const { refresh: deleteRow } = useManualRequest(async (row: timer.limit.Item) => { - await verifyCanModify(row) - const message = t(msg => msg.limit.message.deleteConfirm, { name: row.name }) - await ElMessageBox.confirm(message, { type: "warning" }) - await limitService.remove(row) - }, { - onSuccess() { - ElMessage.success(t(msg => msg.operation.successMsg)) - refresh() - } - }) - - const table = ref() - - const selectedAndThen = (then: (list: timer.limit.Item[]) => void): void => { - const list = table.value?.getSelectionRows?.() - if (!list?.length) { - ElMessage.info('No limit rule selected') - return - } - then(list) - } - - const onBatchSuccess = () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - refresh() - } - - const handleBatchDelete = (list: timer.limit.Item[]) => verifyCanModify(...list) - .then(() => limitService.remove(...list)) - .then(onBatchSuccess) - .catch(() => { }) - - const handleBatchEnable = (list: timer.limit.Item[]) => { - list.forEach(item => item.enabled = true) - limitService.updateEnabled(...list).then(onBatchSuccess).catch(() => { }) - } - - const handleBatchDisable = (list: timer.limit.Item[]) => verifyCanModify(...list) - .then(() => { - list.forEach(item => item.enabled = false) - return limitService.updateEnabled(...list) - }) - .then(onBatchSuccess) - .catch(() => { }) - - const changeEnabled = async (row: timer.limit.Item, newVal: boolean) => { - const enabled = !!newVal - try { - (row.locked || !enabled) && await verifyCanModify(row) - row.enabled = enabled - await limitService.updateEnabled(toRaw(row)) - } catch (e) { - console.log(e) - } - } - - const changeDelay = async (row: timer.limit.Item, newVal: boolean) => { - const delayable = !!newVal - try { - (row.locked || delayable) && await verifyCanModify(row) - row.allowDelay = delayable - await limitService.updateDelay(toRaw(row)) - } catch (e) { - console.log(e) - } - } - - const changeLocked = async (row: timer.limit.Item, newVal: boolean) => { - const locked = !!newVal - try { - if (locked) { - const msg = t(msg => msg.limit.message.lockConfirm) - await ElMessageBox.confirm(msg, { type: 'warning' }) - } else { - await verifyCanModify(row) - } - row.locked = locked - await limitService.updateLocked(toRaw(row)) - } catch (e) { - console.log(e) - } - } - - - const modifyInst = ref() - const testInst = ref() - const modify = (row: timer.limit.Item) => modifyInst.value?.modify?.(toRaw(row)) - const create = () => modifyInst.value?.create?.() - const test = () => testInst.value?.show?.() - - useProvide(NAMESPACE, { - table, - filter, - list, refresh, - deleteRow, - batchDelete: () => selectedAndThen(handleBatchDelete), - batchEnable: () => selectedAndThen(handleBatchEnable), - batchDisable: () => selectedAndThen(handleBatchDisable), - changeEnabled, changeDelay, changeLocked, - modify, create, test, - }) - - return { modifyInst, testInst } -} - -export const useLimitFilter = (): Reactive => useProvider(NAMESPACE, "filter").filter - -export const useLimitTable = () => useProvider( - NAMESPACE, 'list', 'table', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' -) - -export const useLimitBatch = () => useProvider( - NAMESPACE, 'batchDelete', 'batchDisable', 'batchEnable' -) - -export const useLimitAction = () => useProvider(NAMESPACE, 'modify', 'test', 'create') \ No newline at end of file diff --git a/src/pages/app/components/Limit/index.tsx b/src/pages/app/components/Limit/index.tsx deleted file mode 100644 index 77877f850..000000000 --- a/src/pages/app/components/Limit/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { defineComponent } from "vue" -import ContentContainer from "../common/ContentContainer" -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() - - return () => ( - , - content: () => <> - - - - - }} /> - ) -}) - -export default _default diff --git a/src/pages/app/components/Limit/types.d.ts b/src/pages/app/components/Limit/types.d.ts deleted file mode 100644 index 9e00296ce..000000000 --- a/src/pages/app/components/Limit/types.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type LimitFilterOption = { - url: string | undefined - onlyEnabled: boolean -} diff --git a/src/pages/app/components/Option/Select.tsx b/src/pages/app/components/Option/Select.tsx index b39d68c05..7d0bd5238 100644 --- a/src/pages/app/components/Option/Select.tsx +++ b/src/pages/app/components/Option/Select.tsx @@ -5,7 +5,7 @@ import { useRouter } from "vue-router" import ContentContainer from "../common/ContentContainer" import { CATE_LABELS, changeQuery, type OptionCategory, parseQuery } from "./common" -const IGNORED_CATE: OptionCategory[] = ['dailyLimit'] +const IGNORED_CATE: OptionCategory[] = [] const _default = defineComponent(() => { const tab = ref(parseQuery() || 'appearance') diff --git a/src/pages/app/components/Option/common.tsx b/src/pages/app/components/Option/common.tsx index 27a2f7183..53bf3b71d 100644 --- a/src/pages/app/components/Option/common.tsx +++ b/src/pages/app/components/Option/common.tsx @@ -8,7 +8,7 @@ import { type I18nKey } from "@app/locale" import { type Router, useRoute } from "vue-router" -export const ALL_CATEGORIES = ["appearance", "statistics", "popup", 'dailyLimit', 'accessibility', 'backup'] as const +export const ALL_CATEGORIES = ["appearance", "statistics", "popup", 'accessibility'] as const export type OptionCategory = typeof ALL_CATEGORIES[number] export type OptionInstance = { @@ -35,7 +35,5 @@ export const CATE_LABELS: Record = { appearance: msg => msg.option.appearance.title, statistics: msg => msg.option.statistics.title, popup: msg => msg.option.popup.title, - dailyLimit: 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/index.tsx b/src/pages/app/components/Option/components/BackupOption/index.tsx index 4185dc99e..0ccacc56f 100644 --- a/src/pages/app/components/Option/components/BackupOption/index.tsx +++ b/src/pages/app/components/Option/components/BackupOption/index.tsx @@ -4,10 +4,6 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { - DEFAULT_VAULT as DEFAULT_OBSIDIAN_BUCKET, - DEFAULT_ENDPOINT as DEFAULT_OBSIDIAN_ENDPOINT, -} from "@api/obsidian" import { t } from "@app/locale" import { ElInput, ElOption, ElSelect } from "element-plus" import { computed, defineComponent } from "vue" @@ -21,17 +17,11 @@ import "./style.sass" const ALL_TYPES: timer.backup.Type[] = [ 'none', - 'gist', - 'web_dav', - 'obsidian_local_rest_api', 'aws', ] const TYPE_NAMES: { [t in timer.backup.Type]: string } = { none: t(msg => msg.option.backup.meta.none.label), - gist: 'GitHub Gist', - obsidian_local_rest_api: 'Obsidian - Local REST API', - web_dav: 'WebDAV', aws: 'AWS Real-time Sync' } @@ -69,110 +59,6 @@ const _default = defineComponent((_, ctx) => { onIntervalChange={val => autoBackUpInterval.value = val} />
    - {backupType.value === 'gist' && <> - 'Personal Access Token {info} {input}'} - v-slots={{ - info: () => {t(msg => msg.option.backup.meta.gist.authInfo)} - }} - > - auth.value = val?.trim?.() || ''} - /> - - } - {backupType.value === 'obsidian_local_rest_api' && <> - msg.option.backup.label.endpoint} - v-slots={{ - info: () => {t(msg => msg.option.backup.meta.obsidian_local_rest_api.endpointInfo)} - }} - > - setExtField('endpoint', val)} - /> - - "Vault Name {input}"}> - setExtField('bucket', val)} - /> - - msg.option.backup.label.path} required> - setExtField('dirPath', val)} - /> - - "Authorization {input}"}> - auth.value = val?.trim?.() || ''} - /> - - } - {backupType.value === 'web_dav' && <> - msg.option.backup.label.endpoint} - v-slots={{ info: () => '' }} - required - > - setExtField('endpoint', val)} - /> - - msg.option.backup.label.path} required> - setExtField('dirPath', val)} - /> - - msg.option.backup.label.account} required> - account.value = val?.trim?.()} - /> - - msg.option.backup.label.password} required> - password.value = val?.trim?.()} - /> - - } {backupType.value === 'aws' && <> = defaultDailyLimit() - // Not to reset limitPassword - delete defaultValue.limitPassword - // Not to reset difficulty - delete defaultValue.limitVerifyDifficulty - // Not to reset notification duration - delete defaultValue.limitReminderDuration - Object.entries(defaultValue).forEach(([key, val]) => (target as any)[key] = val) -} - -const confirm4Strict = async (): Promise => { - const title = t(msg => msg.option.dailyLimit.level.strictTitle) - const content = t(msg => msg.option.dailyLimit.level.strictContent) - await ElMessageBox.confirm(content, title, { - type: "warning", - confirmButtonText: t(msg => msg.button.confirm), - cancelButtonText: t(msg => msg.button.cancel), - }) -} - -const _default = defineComponent((_, ctx) => { - const { option } = useOption({ defaultValue: defaultDailyLimit, copy }) - const { verified, verify } = useVerify(option) - const { modifyPsw } = usePswEdit({ reset: () => option.limitPassword }) - - ctx.expose({ - reset: () => verify().then(() => reset(option)).catch(() => { }) - } satisfies OptionInstance) - - const handleLevelChange = async (val: timer.limit.RestrictionLevel) => { - try { - await verify() - if (val === "strict") { - await confirm4Strict() - } else if (val === "password") { - option.limitPassword = await modifyPsw() - } - option.limitLevel = val - } catch (e) { - console.log("Failed to verify", e) - } - } - - const handlePswEdit = async () => { - try { - await verify() - option.limitPassword = await modifyPsw() - ElMessage.success(t(msg => msg.operation.successMsg)) - } catch (e) { - console.log("Failed to verify", e) - } - } - - return () => <> - msg.option.dailyLimit.reminder} - defaultValue={t(msg => msg.option.no)} - v-slots={{ - default: () => ( - option.limitReminder = val as boolean} - /> - ), - minInput: () => ( - val && (option.limitReminderDuration = val)} - min={1} max={20} - size="small" - /> - ), - }} - /> - msg.option.dailyLimit.level.label} - defaultValue={t(msg => msg.option.dailyLimit.level[defaultDailyLimit().limitLevel])} - > - - {ALL_LEVEL.map(item => msg.option.dailyLimit.level[item])} />)} - - - msg.option.dailyLimit.level.passwordLabel} - > - , - }} - /> - - msg.option.dailyLimit.level.verificationLabel} - defaultValue={t(msg => (msg.option.dailyLimit.level as any)[defaultDailyLimit().limitVerifyDifficulty])} - > - verify() - .then(() => option.limitVerifyDifficulty = val) - .catch(console.log) - } - > - {ALL_DIFF.map(item => msg.option.dailyLimit.level.verificationDifficulty[item])} />)} - - processVerification(option)} - > - {t(msg => msg.button.test)} - - - msg.option.dailyLimit.prompt}> - option.limitPrompt = val} - placeholder={t(msg => msg.limitModal.defaultPrompt)} - style={{ width: "280px" }} - /> - - -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/components/LimitOption/limit-option.sass b/src/pages/app/components/Option/components/LimitOption/limit-option.sass deleted file mode 100644 index 6d7b69884..000000000 --- a/src/pages/app/components/Option/components/LimitOption/limit-option.sass +++ /dev/null @@ -1,19 +0,0 @@ -.option-daily-limit-psw-box - .el-message-box__container - padding: 15px 10px - .el-message-box__status - display: none - .el-message-box__message - flex: 1 - -.option-daily-limit-level-select>.el-select__wrapper - width: 100% !important - -.option-daily-limit-level-select - width: 370px !important -html[data-locale="en"],html[data-locale="uk"] - .option-daily-limit-level-select - width: 330px !important -html[data-locale="zh_CN"],html[data-locale="zh_TW"] - .option-daily-limit-level-select - width: 210px !important diff --git a/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx b/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx deleted file mode 100644 index e96bba8eb..000000000 --- a/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { t } from "@app/locale" -import { useState } from "@hooks" -import { ElForm, ElFormItem, ElInput, ElMessage, ElMessageBox } from "element-plus" - -type Options = { - reset: () => string | undefined -} - -export const usePswEdit = (options: Options) => { - const { reset } = options || {} - - const [psw, setPsw] = useState(reset?.()) - const [confirmPsw, setConfirmPsw, resetConfirmPsw] = useState('') - - const modifyPsw = async (): Promise => { - setPsw('') - resetConfirmPsw() - - const action = await ElMessageBox({ - title: t(msg => msg.option.dailyLimit.level.passwordLabel, { input: '' }), - message: ( - - msg.option.dailyLimit.level.pswFormLabel)}> - - - msg.option.dailyLimit.level.pswFormAgain)}> - - - - ), - customClass: 'option-daily-limit-psw-box', - confirmButtonText: t(msg => msg.button.confirm), - showCancelButton: true, - cancelButtonText: t(msg => msg.button.cancel), - closeOnClickModal: false, - beforeClose(action, instance, done) { - if (action === 'confirm') { - // check input - if (!psw.value) { - return ElMessage.error("No password filled in") - } else if (!confirmPsw.value) { - return ElMessage.error("Please re-enter the password") - } else if (psw.value !== confirmPsw.value) { - return ElMessage.error("The two passwords you entered are different") - } else { - instance.action = action - instance.inputValue = psw.value - } - } - done() - }, - }) - if (action !== 'confirm') { - ElMessage.warning("Unknown action: " + action) - throw "Ignore this message" - } - return psw.value - } - return { modifyPsw } -} \ 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 dab534573..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 => { - if (verified.value) return - const items = await limitService.select({ filterDisabled: true, url: undefined }) - 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/index.tsx b/src/pages/app/components/Option/index.tsx index d1732c994..bec0553d3 100644 --- a/src/pages/app/components/Option/index.tsx +++ b/src/pages/app/components/Option/index.tsx @@ -11,8 +11,6 @@ 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 PopupOption from "./components/PopupOption" import StatisticsOption from "./components/StatisticsOption" import Select from "./Select" @@ -24,8 +22,6 @@ const _default = defineComponent(() => { appearance: ref(), statistics: ref(), popup: ref(), - backup: ref(), - dailyLimit: ref(), accessibility: ref(), } @@ -35,9 +31,7 @@ const _default = defineComponent(() => { appearance: () => , statistics: () => , popup: () => , - dailyLimit: () => , accessibility: () => , - backup: () => , } const handleReset = (cate: OptionCategory) => paneRefMap[cate]?.value?.reset?.() diff --git a/src/pages/app/router/constants.ts b/src/pages/app/router/constants.ts index ff96e795c..8eb84583f 100644 --- a/src/pages/app/router/constants.ts +++ b/src/pages/app/router/constants.ts @@ -11,12 +11,6 @@ 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 diff --git a/src/pages/app/router/index.ts b/src/pages/app/router/index.ts index ccea273d5..0c5806527 100644 --- a/src/pages/app/router/index.ts +++ b/src/pages/app/router/index.ts @@ -8,7 +8,7 @@ import metaService 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" +import { ANALYSIS_ROUTE, DASHBOARD_ROUTE, MERGE_ROUTE, OPTION_ROUTE, REPORT_ROUTE } from "./constants" const dataRoutes: RouteRecordRaw[] = [ { @@ -29,6 +29,9 @@ const dataRoutes: RouteRecordRaw[] = [ }, { path: '/data/manage', component: () => import('../components/DataManage') + }, { + path: '/data/backup-sync', + component: () => import('../components/BackupSync') } ] @@ -39,9 +42,6 @@ const behaviorRoutes: RouteRecordRaw[] = [ }, { path: '/behavior/habit', component: () => import('../components/Habit'), - }, { - path: LIMIT_ROUTE, - component: () => import('../components/Limit'), } ] @@ -67,10 +67,7 @@ const additionalRoutes: RouteRecordRaw[] = [ const otherRoutes: RouteRecordRaw[] = [ { path: '/other', - redirect: '/other/help' - }, { - path: '/other/help', - component: () => import('../components/HelpUs'), + redirect: '/other/about' }, { path: '/other/about', component: () => import('../components/About'), diff --git a/src/pages/app/util/limit.tsx b/src/pages/app/util/limit.tsx deleted file mode 100644 index 141070b6b..000000000 --- a/src/pages/app/util/limit.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" -import { t, tN } from "@app/locale" -import { I18nResultItem, locale } from "@i18n" -import { getCssVariable } from "@pages/util/style" -import verificationProcessor from "@service/limit-service/verification/processor" -import { dateMinute2Idx, hasLimited, isEnabledAndEffective } from "@util/limit" -import { ElMessage, ElMessageBox, type ElMessageBoxOptions, useId } from "element-plus" -import { defineComponent, onMounted, ref, type VNode } from "vue" - -/** - * Judge wether verification is required - * - * @returns T/F - */ -export async function judgeVerificationRequired(item: timer.limit.Item): Promise { - if (item.locked) return true - if (!isEnabledAndEffective(item)) return false - - const { visitTime, periods } = item - // Daily or weekly - if (hasLimited(item)) return true - // Period - if (periods?.length) { - const idx = dateMinute2Idx(new Date()) - const hitPeriod = periods?.some(([s, e]) => s <= idx && e >= idx) - if (hitPeriod) return true - } - // Visit - if (visitTime) { - let hitVisit = false - try { - hitVisit = !!await sendMsg2Runtime("askHitVisit", item) - } catch (e) { - // If error occurs, regarded as not hitting - // ignored - } - if (hitVisit) return true - } - return false -} - - -const ANSWER_CANVAS_FONT_SIZE = 24 - -const AnswerCanvas = defineComponent(((props: { text: string }) => { - const dom = ref() - const wrapper = ref() - const { text } = props - - onMounted(() => { - const ele = dom.value - if (!ele) return - const ctx = ele.getContext("2d") - const height = Math.floor(ANSWER_CANVAS_FONT_SIZE * 1.3) - ele.height = height - const wrapperEl = wrapper.value - if (!wrapperEl || !ctx) return - const font = getComputedStyle(wrapperEl).font - // Set font to measure width - ctx.font = font - const { width } = ctx.measureText(text) - ele.width = width - // Need set font again after width changed - ctx.font = font - const color = getCssVariable("--el-text-color-primary") - color && (ctx.fillStyle = color) - ctx.fillText(text, 0, ANSWER_CANVAS_FONT_SIZE) - }) - - return () => ( -
    - -
    - ) -}), { props: ['text'] }) - -const useCountdown = (option: { countdown: number, onComplete: NoArgCallback, onTick: ArgCallback }): NoArgCallback => { - const { countdown, onComplete, onTick } = option - - const start = Date.now() - let left = countdown * 1000 - - const timer = setInterval(() => { - left = Math.max(start + countdown * 1000 - Date.now(), 0) - onTick(left) - if (!left) { - onComplete() - clearInterval(timer) - } - }, 100) - - return () => clearInterval(timer) -} - -/** - * NOT TO return Promise.resolve() - * - * NOT TO use async - * - * @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 }): Promise { - const { limitLevel, limitPassword, limitVerifyDifficulty } = option - const { appendTo } = context || {} - if (limitLevel === "strict") { - return new Promise(() => ElMessageBox({ - appendTo, - boxType: 'alert', - type: 'warning', - title: '', - message:
    {t(msg => msg.limit.verification.strictTip)}
    , - }).catch(() => { })) - } - let answerValue: string | undefined - let messageNode: I18nResultItem[] | undefined | I18nResultItem - let incorrectMessage: string - let countdown: number | undefined - if (limitLevel === 'password' && limitPassword) { - answerValue = limitPassword - messageNode = t(msg => msg.limit.verification.pswInputTip) - incorrectMessage = t(msg => msg.limit.verification.incorrectPsw) - } else if (limitLevel === 'verification') { - const pair = verificationProcessor.generate(limitVerifyDifficulty ?? 'easy', locale) - 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) - if (prompt) { - const promptTxt = typeof prompt === 'function' - ? t(msg => prompt(msg.limit.verification), { ...promptParam, answer: answerValue }) - : prompt - messageNode = tN(msg => msg.limit.verification.inputTip, { prompt: {promptTxt}, second }) - } else if (answerValue) { - messageNode = tN(msg => msg.limit.verification.inputTip2, { answer: , second }) - } - } - if (!messageNode || !answerValue) return Promise.resolve() - - 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:
    {messageNode}
    , - showInput: true, - showCancelButton: true, - showClose: false, - confirmButtonText: countdown ? btnText(countdown) : okBtnTxt, - confirmButtonClass: okBtnClz, - buttonSize: "small", - }) - - let cleanCountdown = countdown ? useCountdown({ - countdown, - onComplete: () => { - const btn = (appendTo ?? document).querySelector(`.${okBtnClz}`) - if (!btn) return - ElMessage.warning(t(msg => msg.limit.message.timeout)) - btn.remove() - }, - onTick: (val: number) => { - const btnSpan = (appendTo ?? document).querySelector(`.${okBtnClz} span`) - if (!btnSpan) return - btnSpan.innerHTML = btnText(Math.floor(val / 1000)) - }, - }) : undefined - - return new Promise(resolve => { - msgData.then(data => { - // Double check - const btn = (appendTo ?? document).querySelector(`.${okBtnClz}`) - if (!btn) return - const { value } = data - if (value === answerValue) return resolve() - ElMessage.error({ appendTo, message: incorrectMessage }) - }).catch(() => cleanCountdown?.()) - }) -} diff --git a/src/pages/popup/components/Header/LangSelect.tsx b/src/pages/popup/components/Header/LangSelect.tsx index a5ec4ee5f..3f5044473 100644 --- a/src/pages/popup/components/Header/LangSelect.tsx +++ b/src/pages/popup/components/Header/LangSelect.tsx @@ -74,12 +74,6 @@ const LangSelect = defineComponent(() => { > {t(optionMessages, { key: m => m.appearance.locale.default })} - createTab(CROWDIN_HOMEPAGE)} - divided - > - {tPopup(msg => msg.menu.helpUs)} - ) }} diff --git a/src/service/backup/gist/compressor.ts b/src/service/backup/gist/compressor.ts deleted file mode 100644 index f9e6a045d..000000000 --- a/src/service/backup/gist/compressor.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { groupBy } from "@util/array" -import { formatTimeYMD, getBirthday, parseTime } from "@util/time" - -/** - * Data format in each json file in gist - */ -export type GistData = { - /** - * Index = month_of_part * 32 + date_of_month - */ - [index: string]: GistRow -} - -/** - * Row stored in the gist - */ -export type GistRow = { - [host: string]: [ - number, // Visit count - number, // Browsing time - ] -} - -function calcGroupKey(row: timer.core.Row): string | undefined { - const date = row.date - if (!date) { - return undefined - } - return date.substring(0, 6) -} - -/** - * Compress row array to gist row - * - * @param rows row array - */ -function compress(rows: timer.core.Row[]): GistData { - const result: GistData = groupBy( - rows, - row => row.date.substring(6), - groupedRows => { - const gistRow: GistRow = {} - groupedRows.forEach(({ host, focus, time }) => gistRow[host] = [time, focus]) - return gistRow - } - ) - return result -} - -/** - * Divide rows to buckets - * - * @returns [bucket, data][] - */ -export function divide2Buckets(rows: timer.core.Row[]): [string, GistData][] { - const grouped: { [yearAndPart: string]: GistData } = groupBy(rows.filter(r => !!r), calcGroupKey, compress) - return Object.entries(grouped) -} - -/** - * Calculate all the buckets between {@param startDate} and {@param endDate} - */ -export function calcAllBuckets(startDate: string | undefined, endDate: string | undefined) { - endDate = endDate || formatTimeYMD(new Date()) - const result: string[] = [] - const start = parseTime(startDate) ?? getBirthday() - const end = parseTime(endDate) ?? new Date() - while (start < end) { - result.push(formatTimeYMD(start)) - start.setMonth(start.getMonth() + 1) - } - const lastMonth = formatTimeYMD(end) - !result.includes(lastMonth) && (result.push(lastMonth)) - return result -} - -/** - * Gist data 2 rows - * - * @param filename yearMonth - * @param gistData gistData - * @returns rows - */ -export function gistData2Rows(yearMonth: string, gistData: GistData): timer.core.Row[] { - const result: timer.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 = { - date, - host, - time, - focus - } - result.push(row) - }) - }) - return result -} \ No newline at end of file diff --git a/src/service/backup/gist/coordinator.ts b/src/service/backup/gist/coordinator.ts deleted file mode 100644 index 80226a3a9..000000000 --- a/src/service/backup/gist/coordinator.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { - createGist, findTarget, getGist, getJsonFileContent, testToken, updateGist, - type FileForm, type Gist, type GistForm -} from "@api/gist" -import { SOURCE_CODE_PAGE } from "@util/constant/url" -import MonthIterator from "@util/month-iterator" -import { formatTimeYMD } 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 :)" -const TIMER_DATA_GIST_DESC = "Used for timer to save stat data. Don't change this description :)" - -const README_FILE_NAME = "README.md" -const CLIENT_FILE_NAME = "clients.json" - -const INIT_README_MD: FileForm = { - filename: README_FILE_NAME, - content: `Created by [TIMER](${SOURCE_CODE_PAGE}) automatically, please DO NOT edit this gist!` -} - -const INIT_CLIENT_JSON: FileForm = { - filename: CLIENT_FILE_NAME, - content: "[]" -} - -/** - * Local cache of gist - */ -type Cache = { - /** - * Gist id with meta info - */ - metaGistId: string - /** - * Gist id with stat info - */ - statGistId: string -} - -function bucket2filename(bucket: string, cid: string) { - return `${bucket}_${cid}.json` -} - -function filterDate(row: timer.core.Row, start: string, end: string) { - const { date } = row - if (!date) return false - if (start && date < start) return false - if (end && date > end) return false - return true -} - -function checkTokenExist(context: timer.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") - } - return token -} - -export default class GistCoordinator implements timer.backup.Coordinator { - async updateClients( - context: timer.backup.CoordinatorContext, - clients: timer.backup.Client[], - ): Promise { - const gist = await this.getMetaGist(context) - if (!gist) { - return - } - const files: { [filename: string]: FileForm } = {} - files[README_FILE_NAME] = INIT_README_MD - files[CLIENT_FILE_NAME] = { - filename: CLIENT_FILE_NAME, - content: JSON.stringify(clients) - } - await updateGist(checkTokenExist(context), gist.id, { description: gist.description, public: false, files }) - } - - async listAllClients(context: timer.backup.CoordinatorContext): Promise { - const gist = await this.getMetaGist(context) - if (!gist) { - return [] - } - const file = gist?.files[CLIENT_FILE_NAME] - 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) - await Promise.all(allYearMonth.map(async yearMonth => { - const filename = bucket2filename(yearMonth, targetCid || context.cid) - const gist: Gist = await this.getStatGist(context) - const file = gist.files[filename] - if (file) { - const gistData = await getJsonFileContent(file) - const rows = gistData && gistData2Rows(yearMonth, gistData) - rows?.filter(row => filterDate(row, start, end)) - ?.forEach(row => result.push(row)) - } - })) - return result - } - - async upload(context: timer.backup.CoordinatorContext, rows: timer.core.Row[]): Promise { - const cid = context.cid - const buckets = divide2Buckets(rows) - const gist = await this.getStatGist(context) - const files2Update: { [filename: string]: FileForm } = {} - for (const [bucket, gistData] of buckets) { - const filename = bucket2filename(bucket, cid) - let content: string = JSON.stringify(gistData) - files2Update[filename] = { content, filename } - } - - const gist2update: GistForm = { - public: false, - files: files2Update, - description: TIMER_DATA_GIST_DESC - } - updateGist(checkTokenExist(context), gist.id, gist2update) - } - - private isTargetMetaGist(gist: Gist): boolean { - return gist.description === TIMER_META_GIST_DESC - } - - private isTargetStatGist(gist: Gist): boolean { - return gist.description === TIMER_DATA_GIST_DESC - } - - private async getMetaGist(context: timer.backup.CoordinatorContext): Promise { - const gistId = context.cache.metaGistId - const token = checkTokenExist(context) - // 1. Find by id - if (gistId) { - const gist = await getGist(token, gistId) - if (gist && this.isTargetMetaGist(gist)) { - return gist - } - } - // 2. Find another - const anotherGist = await findTarget(token, gist => this.isTargetMetaGist(gist)) - if (anotherGist) { - context.cache.metaGistId = anotherGist.id - context.handleCacheChanged() - return anotherGist - } - // 3. Create new one - const files: Record = {} - files[INIT_README_MD.filename] = INIT_README_MD - files[INIT_CLIENT_JSON.filename] = INIT_CLIENT_JSON - const gist2Create: GistForm = { description: TIMER_META_GIST_DESC, files, public: false } - const created = await createGist(token, gist2Create) - const newId = created?.id - newId && (context.cache.metaGistId = newId) && context.handleCacheChanged() - return created - } - - private async getStatGist(context: timer.backup.CoordinatorContext): Promise { - const gistId = context.cache.statGistId - const token = checkTokenExist(context) - // 1. Find by id - if (gistId) { - const gist = await getGist(token, gistId) - if (gist && this.isTargetStatGist(gist)) { - return gist - } - } - // 2. Find another - const anotherGist = await findTarget(token, gist => this.isTargetStatGist(gist)) - if (anotherGist) { - context.cache.statGistId = anotherGist.id - context.handleCacheChanged() - return anotherGist - } - // 3. Create new one - const files: Record = {} - files[README_FILE_NAME] = INIT_README_MD - const gist2Create: GistForm = { description: TIMER_DATA_GIST_DESC, files, public: false } - const created = await createGist(token, gist2Create) - const newId = created?.id - newId && (context.cache.statGistId = newId) && context.handleCacheChanged() - return created - } - - async testAuth(auth: timer.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 { - // 1. Find the names of file to delete - const { minDate, maxDate, id: cid } = client || {} - const allBuckets = calcAllBuckets(minDate, maxDate) - const allFileNames = allBuckets.map(bucket => bucket2filename(bucket, cid)) - const gist = await this.getStatGist(context) - const deletingFileNames = Object.keys(gist?.files || {}).filter(fileName => allFileNames.includes(fileName)) - // 2. delete - const files2Delete: { [filename: string]: FileForm | null } = {} - deletingFileNames.forEach(fileName => files2Delete[fileName] = null) - const gist2update: GistForm = { - public: false, - files: files2Delete, - description: TIMER_DATA_GIST_DESC - } - await updateGist(checkTokenExist(context), gist.id, gist2update) - } -} diff --git a/src/service/backup/markdown.ts b/src/service/backup/markdown.ts deleted file mode 100644 index 2f3c9a961..000000000 --- a/src/service/backup/markdown.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { groupBy } from "@util/array" -import { formatPeriodCommon } from "@util/time" - -export const CLIENT_FILE_NAME = "clients_no_modify.md" - -const CLIENT_FIELDS: MarkdownTableField[] = [ - { - name: "Client Id", - formatter: r => r.id, - }, { - name: "Client Name", - formatter: r => r.name, - }, { - name: "Earliest Date", - formatter: r => r.minDate ?? '', - }, { - name: "Latest Date", - formatter: r => r.maxDate ?? '', - } -] - -type Meta = { - version: number - ts: number -} - -const CURRENT_VER = 1 - -function genMetaLine(): string { - const meta: Meta = { version: CURRENT_VER, ts: Date.now() } - return genJsonLine(meta) -} - -function genJsonLine(data: any): string { - return `` -} - -export function convertClients2Markdown(clients: timer.backup.Client[]): string { - return genMarkdownTable(clients, CLIENT_FIELDS) -} - -type CellFormatter = string | number | ((row: T) => string | number) - -type MarkdownTableField = { - name: string - formatter: CellFormatter -} - -function genTableRow(t: T, formatters: CellFormatter[]): string { - const cells = formatters?.map(f => { - const str = typeof f === 'function' - ? f?.(t)?.toString?.()?.replace('|', '|') - : f?.toString?.() - return str ?? '-' - }) - return `|${cells.join('|')}|` -} - -function genMarkdownTable(list: T[], fields: MarkdownTableField[]): string { - const lines: string[] = [ - genMetaLine(), - genJsonLine(list), - ] - list = list || [] - fields = fields || [] - if (fields.length) { - // header - lines.push(genTableRow(null, fields.map(f => f.name))) - lines.push(genTableRow(null, fields.map(_ => '----'))) - const cellFormatters = fields.map(f => f.formatter) - lines.push( - ...list.map(row => genTableRow(row, cellFormatters)) - ) - } - return lines.join('\n') -} - -const ROW_FIELDS: MarkdownTableField[] = [ - { - name: "Date", - formatter: r => r.date, - }, { - name: "Domain/URL", - formatter: r => r.host, - }, { - name: "Focus Time", - formatter: r => formatPeriodCommon(r.focus || 0) - }, { - name: "Visit Count", - formatter: r => r.time || 0, - }, -] - -export function divideByDate(rows: timer.core.Row[]): { [date: string]: string } { - return groupBy(rows, row => row.date, list => genMarkdownTable(list, ROW_FIELDS)) -} - -export function parseData(markdown: string | undefined | null): T | undefined { - if (!markdown) return undefined - let line2 = markdown?.split('\n')?.[1] - if (!line2) { - return - } - line2 = line2?.replace("", "").trim() - if (!line2) return - return JSON.parse(line2) -} \ No newline at end of file diff --git a/src/service/backup/obsidian/coordinator.ts b/src/service/backup/obsidian/coordinator.ts deleted file mode 100644 index 3a96a8eeb..000000000 --- a/src/service/backup/obsidian/coordinator.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - DEFAULT_VAULT, - deleteFile, - getFileContent, - INVALID_AUTH_CODE, - listAllFiles, - NOT_FOUND_CODE, - type ObsidianRequestContext, - updateFile -} from "@api/obsidian" -import DateIterator from "@util/date-iterator" -import { processDir } from "../common" -import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" - -function prepareContext(context: timer.backup.CoordinatorContext) { - const { auth, ext, cid } = context - const { token } = auth || {} - if (!token) { - throw new Error("Token must not be empty. This can't happen, please contact the developer") - } - let { endpoint, dirPath, bucket } = ext || {} - dirPath = processDir(dirPath) - const ctx: ObsidianRequestContext = { auth: token, endpoint, vault: bucket } - return { ctx, dirPath, cid } -} - -export default class ObsidianCoordinator implements timer.backup.Coordinator { - - async updateClients(context: timer.backup.CoordinatorContext, clients: timer.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 { - const { ctx, dirPath } = prepareContext(context) - const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` - try { - const content = await getFileContent(ctx, clientFilePath) - return parseData(content) || [] - } catch (e) { - console.error(e) - return [] - } - } - - async download(context: timer.backup.CoordinatorContext, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { - const { ctx, dirPath, cid } = prepareContext(context) - - const dateIterator = new DateIterator(dateStart, dateEnd) - const result: timer.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) - rows?.forEach?.(row => result.push(row)) - })) - return result - } - - async upload(context: timer.backup.CoordinatorContext, rows: timer.core.Row[]): Promise { - const { ctx, dirPath, cid } = prepareContext(context) - - const dateAndContents = divideByDate(rows) - await Promise.all( - Object.entries(dateAndContents).map(async ([date, content]) => { - const filePath = `${dirPath}${cid}/${date}.md` - await updateFile(ctx, filePath, content) - }) - ) - } - - async testAuth(authInfo: timer.backup.Auth, ext: timer.backup.TypeExt): Promise { - let { endpoint, dirPath, bucket } = ext || {} - let { token: auth } = authInfo || {} - dirPath = processDir(dirPath) - if (!dirPath) { - return "Path of directory is blank" - } - if (!auth) { - return "Authorization is blank" - } - try { - const result = await listAllFiles({ endpoint, auth, vault: bucket }, dirPath) - const { errorCode, message } = result || {} - if (errorCode === NOT_FOUND_CODE) { - return `Directory[vault=${bucket || DEFAULT_VAULT}, path=${dirPath}] not found` - } else if (errorCode === INVALID_AUTH_CODE) { - return 'Your authorization token is invalid' - } - return message - } catch (e) { - const { message: errMsg } = e as Error - const lowerErrMsg = errMsg?.toLocaleLowerCase?.() - if (lowerErrMsg?.includes("failed to fetch")) { - return "Unable to fetch this endpoint, please make sure it is accessible" - } else if (lowerErrMsg?.includes("failed to parse url from")) { - return "The endpoint is invalid, please check it" - } - return errMsg ?? e?.toString?.() ?? 'Unknown error' - } - } - - async clear(context: timer.backup.CoordinatorContext, client: timer.backup.Client): Promise { - const cid = client.id - const { ctx, dirPath } = prepareContext(context) - const clientDirPath = `${dirPath}${cid}/` - let files: string[] = [] - try { - const result = await listAllFiles(ctx, clientDirPath) - files = result.files || [] - } catch { } - await Promise.all( - files.map(async file => { - const filePath = clientDirPath + file - await deleteFile(ctx, filePath) - }) - ) - } -} \ No newline at end of file diff --git a/src/service/backup/processor.ts b/src/service/backup/processor.ts index 9ca4e7c94..688b874a3 100644 --- a/src/service/backup/processor.ts +++ b/src/service/backup/processor.ts @@ -10,9 +10,6 @@ import optionHolder from "@service/components/option-holder" import itemService from "@service/item-service" import metaService 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" import AwsCoordinator from "./aws/coordinator" export type AuthCheckResult = { @@ -151,9 +148,6 @@ class Processor { constructor() { this.coordinators = { none: null as unknown as timer.backup.Coordinator, - gist: new GistCoordinator(), - obsidian_local_rest_api: new ObsidianCoordinator(), - web_dav: new WebDAVCoordinator(), aws: new AwsCoordinator(), } } diff --git a/src/service/backup/web-dav/coordinator.ts b/src/service/backup/web-dav/coordinator.ts deleted file mode 100644 index a8a040aa6..000000000 --- a/src/service/backup/web-dav/coordinator.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - deleteDir, judgeDirExist, makeDir, readFile, writeFile, - type WebDAVAuth, type WebDAVContext, -} from "@api/web-dav" -import DateIterator from "@util/date-iterator" -import { processDir } from "../common" -import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" - -function getEndpoint(ext: timer.backup.TypeExt | undefined): string | undefined { - let { endpoint } = ext || {} - if (endpoint?.endsWith('/')) { - endpoint = endpoint.substring(0, endpoint.length - 1) - } - return endpoint -} - -function prepareContext(context: timer.backup.CoordinatorContext): WebDAVContext { - const { auth, ext } = context - const endpoint = getEndpoint(ext) - if (!endpoint) { - throw new Error('Endpoint must be empty. This can\'t happen, please contact the developer') - } - const { acc: username, psw: password } = auth?.login || {} - if (!username || !password) { - throw new Error('Neither username nor password must be empty. This can\'t happen, please contact the developer') - } - const webDavAuth: WebDAVAuth = { type: "password", username, password } - return { auth: webDavAuth, endpoint } -} - -export default class WebDAVCoordinator implements timer.backup.Coordinator { - async updateClients(context: timer.backup.CoordinatorContext, clients: timer.backup.Client[]): Promise { - const dirPath = processDir(context?.ext?.dirPath) - const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` - const content = convertClients2Markdown(clients) - const davContext = prepareContext(context) - await writeFile(davContext, clientFilePath, content) - } - - async listAllClients(context: timer.backup.CoordinatorContext): Promise { - const dirPath = processDir(context?.ext?.dirPath) - const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` - const davContext = prepareContext(context) - try { - const content = await readFile(davContext, clientFilePath) - return parseData(content) ?? [] - } catch (e) { - console.warn("Failed to read WebDav file content", e) - return [] - } - } - - async download(context: timer.backup.CoordinatorContext, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { - const dirPath = processDir(context?.ext?.dirPath) - const davContext = prepareContext(context) - targetCid = targetCid || context?.cid - - const dateIterator = new DateIterator(dateStart, dateEnd) - const result: timer.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) - rows?.forEach?.(row => result.push(row)) - })) - return result - } - - async upload(context: timer.backup.CoordinatorContext, rows: timer.core.Row[]): Promise { - const dateAndContents = divideByDate(rows) - const dirPath = processDir(context?.ext?.dirPath) - const cid = context?.cid - const davContext = prepareContext(context) - - const clientPath = await this.checkClientDirExist(davContext, dirPath, cid) - - await Promise.all( - Object.entries(dateAndContents).map(async ([date, content]) => { - const filePath = `${clientPath}/${date}.md` - await writeFile(davContext, filePath, content) - }) - ) - } - - private async checkClientDirExist(davContext: WebDAVContext, dirPath: string, cid: string) { - const clientDirPath = `${dirPath}${cid}` - const clientExist = await judgeDirExist(davContext, clientDirPath) - if (!clientExist) { - await makeDir(davContext, clientDirPath) - } - return clientDirPath - } - - async testAuth(auth: timer.backup.Auth, ext: timer.backup.TypeExt): Promise { - const endpoint = getEndpoint(ext) - if (!endpoint) { - return "The endpoint is blank" - } - const { dirPath } = ext || {} - if (!dirPath) { - return "The path of directory is blank" - } - const { acc, psw } = auth?.login || {} - if (!acc) { - return 'Account is blank' - } - if (!psw) { - return 'Password is blank' - } - const davAuth: WebDAVAuth = { type: 'password', username: acc, password: psw } - const davContext: WebDAVContext = { endpoint, auth: davAuth } - const webDavPath = processDir(dirPath) - try { - const exist = await judgeDirExist(davContext, webDavPath) - if (!exist) { - return "Directory not found" - } - } catch (e) { - return (e as Error)?.message ?? e?.toString?.() ?? 'Unknown error' - } - } - - async clear(context: timer.backup.CoordinatorContext, client: timer.backup.Client): Promise { - const cid = client.id - const dirPath = processDir(context.ext?.dirPath) - const davContext = prepareContext(context) - const clientDirPath = `${dirPath}${cid}/` - const exist = await judgeDirExist(davContext, clientDirPath) - - if (!exist) return - await deleteDir(davContext, clientDirPath) - } -} \ No newline at end of file diff --git a/src/service/components/immigration.ts b/src/service/components/immigration.ts index cc74ad70e..94940b2ce 100644 --- a/src/service/components/immigration.ts +++ b/src/service/components/immigration.ts @@ -7,7 +7,6 @@ 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" @@ -28,7 +27,6 @@ function initDatabase(): BaseDatabase[] { const result: BaseDatabase[] = [ statDatabase, periodDatabase, - limitDatabase, mergeRuleDatabase, whitelistDatabase, siteCateDatabase, diff --git a/src/service/limit-service/index.ts b/src/service/limit-service/index.ts deleted file mode 100644 index 2a42ce20e..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 { sum } from "@util/array" -import { calcTimeState, hasLimited, isEnabledAndEffective, matches } from "@util/limit" -import { formatTimeYMD, MILL_PER_MINUTE } from "@util/time" -import whitelistHolder from '../components/whitelist-holder' - -export type QueryParam = { - filterDisabled: boolean - id?: number - url?: string -} - -async function select(cond?: QueryParam): Promise { - 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.log(err.message)) - }) -} - -async function updateEnabled(...items: timer.limit.Rule[]): Promise { - 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 { - 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 { - if (!items?.length) return - for (const item of items) { - await db.remove(item.id) - } - await noticeLimitChanged() -} - -async function getLimited(url: string): Promise { - const list: timer.limit.Item[] = await getRelated(url) - return list.filter(item => hasLimited(item)) -} - -async function getRelated(url: string): Promise { - 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 { - 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 { - 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 { - 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): Promise { - 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/common.ts b/src/service/limit-service/verification/common.ts deleted file mode 100644 index 22cf89669..000000000 --- a/src/service/limit-service/verification/common.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * 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 -} - -export type VerificationPair = { - prompt?: I18nKey | string - promptParam?: any - answer: I18nKey | string - second: number -} - -/** - * Verification code generator - */ -export interface VerificationGenerator { - /** - * Whether to support - */ - supports(context: VerificationContext): boolean - - /** - * Render the prompt - */ - generate(context: VerificationContext): VerificationPair -} \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/confession.ts b/src/service/limit-service/verification/generator/confession.ts deleted file mode 100644 index bc39c5fd9..000000000 --- a/src/service/limit-service/verification/generator/confession.ts +++ /dev/null @@ -1,25 +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" - -/** - * Generator of confession - */ -class ConfessionGenerator implements VerificationGenerator { - supports(context: VerificationContext): boolean { - return context.difficulty === 'easy' - } - generate(_: VerificationContext): VerificationPair { - return { - answer: msg => msg.confession, - second: 20, - } - } -} - -export default ConfessionGenerator \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/index.ts b/src/service/limit-service/verification/generator/index.ts deleted file mode 100644 index a57bb658d..000000000 --- a/src/service/limit-service/verification/generator/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import { type VerificationGenerator } from "../common" -import ConfessionGenerator from "./confession" -import PiGenerator from "./pi" -import UglyGenerator from "./ugly" -import UncommonChinese from "./uncommon-chinese" - -export const ALL_GENERATORS: VerificationGenerator[] = [ - new PiGenerator(), - new ConfessionGenerator(), - new UglyGenerator(), - new UncommonChinese(), -] \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/pi.ts b/src/service/limit-service/verification/generator/pi.ts deleted file mode 100644 index 5c673d7f4..000000000 --- a/src/service/limit-service/verification/generator/pi.ts +++ /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 { randomIntBetween } from "@util/number" -import { type VerificationContext, type VerificationGenerator, type VerificationPair } from "../common" - -const MIN_START_IDX = 10 -const MAX_START_IDX = 25 -const MIN_LEN = 5 -const MAX_LEN = 15 - -const DIGIT_PART_PI = '14159265358979323846264338327950288419716939937510' - + '58209749445923078164062862089986280348253421170679' - -/** - * Generator of pi - */ -class PiGenerator implements VerificationGenerator { - generate(_: VerificationContext): VerificationPair { - const startIndex = randomIntBetween(MIN_START_IDX, MAX_START_IDX) - const digitCount = randomIntBetween(MIN_LEN, MAX_LEN) - const endIndex = startIndex + digitCount - 1 - const answer = DIGIT_PART_PI.substring(startIndex - 1, endIndex) - return { - answer, - prompt: msg => msg.pi, - promptParam: { - startIndex, - endIndex, - digitCount, - }, - second: 20, - } - } - - supports(context: VerificationContext): boolean { - return context.difficulty === 'disgusting' - } -} - -export default PiGenerator \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/ugly.ts b/src/service/limit-service/verification/generator/ugly.ts deleted file mode 100644 index 15cb8458d..000000000 --- a/src/service/limit-service/verification/generator/ugly.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { range } from "@util/array" -import { randomIntBetween } from "@util/number" -import type { VerificationContext, VerificationGenerator, VerificationPair } from "../common" - -const BASE = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\`-=[]/.,:\"<>?!@#$%^&*()_+;'".replaceAll(/[01IlLOo]/g, "") -const BASE_LEN = BASE.length - -class UglyGenerator implements VerificationGenerator { - supports(context: VerificationContext): boolean { - const { difficulty } = context - return difficulty === 'hard' || difficulty === 'disgusting' - } - - generate(context: VerificationContext): VerificationPair { - const { difficulty } = context - const len = difficulty === 'hard' ? randomIntBetween(8, 12) : randomIntBetween(16, 20) - const answer = range(len) - .map(() => randomIntBetween(0, BASE_LEN)) - .map(decimal => BASE.charAt(decimal)) - .join('') - - return { answer, second: 20 } - } -} - -export default UglyGenerator \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/uncommon-chinese.ts b/src/service/limit-service/verification/generator/uncommon-chinese.ts deleted file mode 100644 index 4c19eea71..000000000 --- a/src/service/limit-service/verification/generator/uncommon-chinese.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { randomIntBetween } from "@util/number" -import type { VerificationContext, VerificationGenerator, VerificationPair } from "../common" - -const UNCOMMON_WORDS = '龘靐齉齾爩鱻麤龗灪吁龖厵滟爨癵籱饢驫鲡鹂鸾麣纞虋讟钃骊郁鸜麷鞻韽韾响顟顠饙饙騳騱饐' -const LENGTH = UNCOMMON_WORDS.length - -class UncommonChinese implements VerificationGenerator { - supports(context: VerificationContext): boolean { - return context.difficulty === 'disgusting' && context.locale === 'zh_CN' - } - - generate(_: VerificationContext): VerificationPair { - let answer = '' - while (answer.length < 3) { - const idx = randomIntBetween(0, LENGTH) - const ch = UNCOMMON_WORDS[idx] - if (!answer.includes(ch)) { - answer += ch - } - } - return { answer, second: 15 } - } -} - -export default UncommonChinese \ No newline at end of file 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/util/limit.ts b/src/util/limit.ts deleted file mode 100644 index d902fbb6c..000000000 --- a/src/util/limit.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { getWeekDay, MILL_PER_MINUTE, MILL_PER_SECOND } from "./time" - -export const DELAY_MILL = 5 * MILL_PER_MINUTE - -export const cleanCond = (origin: string | undefined): string | undefined => { - if (!origin) return undefined - - const startIdx = origin?.indexOf('//') - const endIdx = origin?.indexOf('?') - let res = origin.substring(startIdx === -1 ? 0 : startIdx + 2, endIdx === -1 ? undefined : endIdx) - while (res.endsWith('/')) { - res = res.substring(0, res.length - 1) - } - return res || undefined -} - -const matchUrl = (cond: string, url: string): boolean => { - return new RegExp(`^.*//${cond.split('*').join('.*')}`).test(url) -} - -export function matches(cond: timer.limit.Item['cond'], url: string): boolean { - return cond.some(c => matchUrl(c, url)) -} - -export function matchCond(cond: timer.limit.Item['cond'], url: string): string[] { - return cond.filter(c => matchUrl(c, url)) -} - -export const meetLimit = (limit: number | undefined, value: number | undefined): boolean => { - 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 -} - -export function hasLimited(item: timer.limit.Item): boolean { - return hasDailyLimited(item) || hasWeeklyLimited(item) -} - -export function hasDailyLimited(item: timer.limit.Item): boolean { - const { time, count = 0, waste = 0, visit = 0, delayCount = 0, allowDelay = false } = item || {} - const timeMeet = meetTimeLimit(time, waste, allowDelay, delayCount) - const countMeet = meetLimit(count, visit) - return timeMeet || countMeet -} - -export function 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 isEnabledAndEffective(rule: timer.limit.Rule): boolean { - return !!rule?.enabled && isEffective(rule.weekdays) -} - -export function isEffective(weekdays: timer.limit.Rule['weekdays']): boolean { - const weekdayLen = weekdays?.length - if (!weekdayLen || weekdayLen <= 0 || weekdayLen >= 7) { - return true - } - const weekday = getWeekDay(new Date()) - return weekdays.includes(weekday) -} - -const idx2Str = (time: number | undefined): string => { - time = time ?? 0 - const hour = Math.floor(time / 60) - const min = time - hour * 60 - const hourStr = (hour < 10 ? "0" : "") + hour - const minStr = (min < 10 ? "0" : "") + min - return `${hourStr}:${minStr}` -} - -export const date2Idx = (date: Date): number => date.getHours() * 60 * 60 + date.getMinutes() * 60 + date.getSeconds() - -export const dateMinute2Idx = (date: Date): number => { - const hour = date.getHours() - const min = date.getMinutes() - return hour * 60 + min -} - -export const period2Str = (p: timer.limit.Period | undefined): string => { - const [start, end] = p || [] - return `${idx2Str(start)}-${idx2Str(end)}` -} diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts deleted file mode 100644 index e8a34a321..000000000 --- a/test-e2e/limit/common.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ElementHandle, type Page } from "puppeteer" -import { sleep } from "../common/base" - -export async function createLimitRule(rule: timer.limit.Rule, page: Page) { - const createButton = await page.$('.el-card:first-child .el-button:last-child') - await createButton!.click() - // 1 Fill the name - await page.waitForSelector('.el-dialog .el-input input') - const nameInput = await page.$('.el-dialog .el-input input') - await nameInput!.focus() - page.keyboard.type(rule.name) - await new Promise(resolve => setTimeout(resolve, 400)) - await page.click('.el-dialog .el-button.el-button--primary') - // 2. Fill the condition - const configInput = await page.$('.el-dialog .el-input.el-input-group input') - for (const url of rule.cond || []) { - await configInput!.focus() - await page.keyboard.type(url) - await sleep(.1) - await page.keyboard.press('Enter') - } - await sleep(.1) - await page.click('.el-dialog .el-button.el-button--primary') - // 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) - - // 4. Save - await sleep(.3) - await page.click('.el-dialog .el-button.el-button--success') - if (rule.allowDelay) { - await page.waitForSelector('.el-table__body .el-table__row td:nth-child(9) .el-switch') - await page.evaluate(async () => { - document.querySelector('.el-table__body .el-table__row td:nth-child(9) .el-switch')?.click() - }) - await sleep(.3) - } -} - -export async function fillTimeLimit(value: number | undefined, input: ElementHandle, page: Page): Promise { - value = value ?? 0 - const hour = Math.floor(value / 3600) - value = value - hour * 3600 - const minute = Math.floor(value / 60) - const second = value - minute * 60 - await input.click() - await sleep(.5) - const panel = await page.$('.el-popper:not([style*="display:none"]):not([style*="display: none"]) div.el-time-panel') - await panel!.evaluate(async (el, hour, minute, second) => { - const hourSpinner = el.querySelector('.el-scrollbar:first-child .el-scrollbar__wrap') - hourSpinner!.scrollTo(0, hour * 32) - const minuteSpinner = el.querySelector('.el-scrollbar:nth-child(2) .el-scrollbar__wrap') - minuteSpinner!.scrollTo(0, minute * 32) - const secondSpinner = el.querySelector('.el-scrollbar:nth-child(3) .el-scrollbar__wrap') - secondSpinner!.scrollTo(0, second * 32) - // Wait scroll handler finished - await new Promise(resolve => setTimeout(resolve, 250)) - const confirmBtn = el.querySelector('.el-time-panel__footer .el-time-panel__btn.confirm') as HTMLButtonElement - confirmBtn.click() - }, hour, minute, second) - await sleep(.2) -} - -export async function fillVisitLimit(value: number, input: ElementHandle, page: Page) { - await input.focus() - await page.keyboard.press('Delete') - await page.keyboard.type(`${value ?? 0}`) -} \ No newline at end of file diff --git a/test-e2e/limit/daily-time.test.ts b/test-e2e/limit/daily-time.test.ts deleted file mode 100644 index 37c7ffd4c..000000000 --- a/test-e2e/limit/daily-time.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { launchBrowser, type LaunchContext, MOCK_URL, sleep } from "../common/base" -import { createLimitRule, fillTimeLimit } from "./common" - -let context: LaunchContext - -describe('Daily time limit', () => { - 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 = { - id: 1, name: 'TEST DAILY LIMIT', - cond: [MOCK_URL], - time: limitTime, - enabled: true, allowDelay: false, locked: false, - } - - // 1. Insert limit rule - await createLimitRule(demoRule, limitPage) - - // 2. Open test page - const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) - await sleep(1.1) - - // Assert not limited - await limitPage.bringToFront() - // Wait refreshing the table - await sleep(.1) - let wastedTime = await limitPage.evaluate(() => { - const timeTag = document.querySelector('.el-table .el-table__body-wrapper table tbody tr td:nth-child(6) .el-tag:first-child') - const timeStr = timeTag?.textContent - return parseInt(timeStr?.replace('s', '')?.trim() ?? '0') - }) - expect(wastedTime).toBeGreaterThanOrEqual(1) - - // 3. Switch to test page again - await testPage.bringToFront() - 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 trs = descEl?.querySelectorAll('tr') - const name = trs?.[0]?.querySelector('td:nth-child(2)')?.textContent - const timeStr = trs?.[2]?.querySelector('td:nth-child(2) .el-tag--danger')?.textContent - return { name, time: parseInt(timeStr?.replace('s', '').trim() ?? '0') } - }) - expect(name).toEqual(demoRule.name) - expect(time).toBeGreaterThanOrEqual(limitTime) - - // 5. Check limit page - await limitPage.bringToFront() - await sleep(.1) - wastedTime = await limitPage.evaluate(() => { - const timeTag = document.querySelector('.el-table .el-table__body-wrapper table tbody tr td:nth-child(6) .el-tag--danger') - const timeStr = timeTag?.textContent - return parseInt(timeStr?.replace('s', '').trim() ?? '') - }) - expect(wastedTime).toBeGreaterThanOrEqual(limitTime) - - // 6. Change daily limit time - await limitPage.click('.el-card__body .el-table tr td .el-button--primary') - - await sleep(.1) - await limitPage.click('.el-dialog .el-button.el-button--primary') - - await sleep(.1) - await limitPage.click('.el-dialog .el-button.el-button--primary') - - await sleep(.1) - const timeInput = await limitPage.$('.el-dialog .el-date-editor:first-child input') - await fillTimeLimit(10, timeInput!, limitPage) - await limitPage.click('.el-dialog .el-button.el-button--success') - - // 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"])') - }) - 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 deleted file mode 100644 index a4280fb99..000000000 --- a/test-e2e/limit/daily-visit.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { launchBrowser, type LaunchContext, MOCK_URL, sleep } from "../common/base" -import { createLimitRule } from "./common" - -let context: LaunchContext - -describe('Daily time limit', () => { - 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 = { - id: 1, name: 'TEST DAILY LIMIT', - cond: [MOCK_URL], - time: 0, count: 1, - enabled: true, allowDelay: false, locked: false, - } - - // 1. Insert limit rule - await createLimitRule(demoRule, limitPage) - - // 2. Open test page - const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) - - // Assert not limited - await limitPage.bringToFront() - // Wait refreshing the table - await sleep(.1) - const infoTag = await limitPage.$$('.el-table .el-table__body-wrapper table tbody tr td:nth-child(6) .el-tag.el-tag--info') - expect(infoTag.length).toEqual(2) - - // 3. Reload page - await testPage.bringToFront() - await testPage.reload({ waitUntil: 'domcontentloaded' }) - - // 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[2].querySelector('td:nth-child(2) .el-tag--danger')!.textContent - return { name, count } - }) - - expect(name).toBe(demoRule.name) - expect(count!.split?.(' ')[0]).toBe('2') - - // 4. Change visit limit - await limitPage.bringToFront() - await limitPage.click('.el-card__body .el-table tr td .el-button--primary') - - await sleep(.1) - await limitPage.click('.el-dialog .el-button.el-button--primary') - - await sleep(.1) - await limitPage.click('.el-dialog .el-button.el-button--primary') - - await sleep(.1) - const visitInput = await limitPage.$('.el-dialog .el-input-number input') - await visitInput!.focus() - await limitPage.keyboard.type('2') - await limitPage.click('.el-dialog .el-button.el-button--success') - - // 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"])') - }) - expect(modalExist).toBeFalsy() - }, 60000) -}) \ No newline at end of file diff --git a/test-e2e/limit/visit-limit.test.ts b/test-e2e/limit/visit-limit.test.ts deleted file mode 100644 index 8585cce86..000000000 --- a/test-e2e/limit/visit-limit.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { launchBrowser, LaunchContext, MOCK_URL, sleep } from '../common/base' -import { createLimitRule } from './common' - -describe('Time limit per visit', () => { - let context: LaunchContext - - beforeEach(async () => context = await launchBrowser()) - - afterEach(() => context.close()) - - test("More 5 minutes", async () => { - const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: timer.limit.Rule = { - id: 1, name: 'TEST DAILY LIMIT', - cond: [MOCK_URL], - visitTime: 1, - enabled: true, allowDelay: true, locked: false, - } - - // 1. Insert limit rule - await createLimitRule(demoRule, limitPage) - - // 2. Open test page - const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) - 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('.el-button--primary') - button?.click() - return !!button - }) - expect(clicked).toBeTruthy() - - // 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"])') - }) - expect(modalExist).toBeFalsy() - - }, 10000) -}) \ No newline at end of file diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts deleted file mode 100644 index 8346fef0f..000000000 --- a/test/background/backup/gist/compressor.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { divide2Buckets, GistData, gistData2Rows } from "@service/backup/gist/compressor" - -test('divide 1', () => { - const rows: timer.core.Row[] = [{ - host: 'www.baidu.com', - date: '20220801', - focus: 0, - time: 10, - }, { - host: 'www.baidu.com', - // Invalid date, count be compress - date: '', - focus: 0, - time: 10, - }] - const divided = divide2Buckets(rows) - expect(divided.length).toEqual(1) - const [bucket, gistData] = divided[0] - expect(bucket).toEqual('202208') - const expectData: GistData = { - "01": { - "www.baidu.com": [10, 0] - } - } - expect(gistData).toEqual(expectData) -}) - -test('gistData2Rows', () => { - const gistData: GistData = { - '01': { - 'baidu.com': [0, 1] - }, - '08': { - 'google.com': [1, 1] - } - } - const rows = gistData2Rows('202209', gistData) - expect(rows.length).toEqual(2) - 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(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/database/limit-database.test.ts deleted file mode 100644 index 49d3244c3..000000000 --- a/test/database/limit-database.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import db from "@db/limit-database" -import { formatTimeYMD } from "@util/time" -import { mockStorage } from "../__mock__/storage" - -describe('limit-database', () => { - beforeAll(() => mockStorage()) - - beforeEach(async () => chrome.storage.local.clear()) - test('test1', async () => { - const toAdd: timer.limit.Rule = { - id: 1, - name: "foobar", - cond: ['123'], - time: 20, - enabled: true, - allowDelay: false, - locked: false, - } - const id = await db.save(toAdd) - let all: timer.limit.Rule[] = await db.all() - expect(all.length).toEqual(1) - let saved = all[0] - expect(saved.cond).toEqual(toAdd.cond) - expect(saved.time).toEqual(toAdd.time) - expect(saved.name).toEqual(toAdd.name) - expect(saved.enabled).toEqual(toAdd.enabled) - expect(saved.allowDelay).toEqual(toAdd.allowDelay) - const toRewrite = { - id, - name: 'hahah', - cond: ['123'], - time: 21, - enabled: true, - allowDelay: false, - locked: false, - } - // Not rewrite - await db.save(toRewrite) - all = await db.all() - saved = all[0] - expect(saved.cond).toEqual(toAdd.cond) - expect(saved.time).toEqual(toAdd.time) - expect(saved.name).toEqual(toAdd.name) - expect(saved.enabled).toEqual(toAdd.enabled) - expect(saved.allowDelay).toEqual(toAdd.allowDelay) - - await db.remove(id) - - expect((await db.all()).length).toEqual(0) - }) - - test("update waste", async () => { - const date = formatTimeYMD(new Date()) - const id1 = await db.save({ - name: "foobar", - cond: ["a.*.com"], - time: 21, - enabled: true, - allowDelay: false, - locked: false, - }) - await db.save({ - name: "foobar", - cond: ["*.b.com"], - time: 20, - enabled: true, - allowDelay: false, - locked: false, - }) - await db.updateWaste(date, { - [id1]: 10, - // Not exist, no error throws - [-1]: 20, - }) - 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) - }) - - test("import data", async () => { - const cond1: MakeOptional = { - name: 'foobar1', - cond: ["cond1"], - time: 20, - allowDelay: false, - enabled: true, - locked: false, - } - const cond2: MakeOptional = { - name: 'foobar2', - cond: ["cond2"], - time: 20, - allowDelay: false, - enabled: false, - locked: false, - } - await db.save(cond1) - await db.save(cond2) - const data2Import = await db.storage.get() - - // clear - chrome.storage.local.clear() - expect(await db.all()).toEqual([]) - - await db.importData(data2Import) - const imported = await db.all() - - const cond2After = imported.find(a => a.cond?.includes("cond2")) - expect(Object.values(cond2After?.records || {})).toBeTruthy() - expect(cond2After?.allowDelay).toEqual(cond2.allowDelay) - expect(cond2After?.enabled).toEqual(cond2.enabled) - }) - - test("import data2", async () => { - const importData: Record = {} - // Invalid data, no error throws - await db.importData(importData) - // Valid data - importData["__timer__LIMIT"] = {} - await db.importData(importData) - expect(await db.all()).toEqual([]) - }) - - test("update delay", async () => { - const data: MakeOptional = { - 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/util/limit.test.ts b/test/util/limit.test.ts deleted file mode 100644 index e5332e82f..000000000 --- a/test/util/limit.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - calcTimeState, cleanCond, dateMinute2Idx, hasLimited, hasWeeklyLimited, isEffective, isEnabledAndEffective, - 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/0HugoHu/?a=2')).toEqual('www.github.com/0HugoHu') - expect(cleanCond('https://')).toBeUndefined() - }) - - test('matches', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/0HugoHu'] - - expect(matches(cond, 'https://www.baidu.com')).toBe(true) - expect(matches(cond, 'http://hk.google.com')).toBe(true) - expect(matches(cond, 'http://github.com/0HugoHu/time-tracker-4-browser')).toBe(true) - expect(matches(cond, 'http://github.com')).toBe(false) - }) - - test('matchCond', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/0HugoHu', 'github.com'] - expect(matchCond(cond, 'http://www.baidu.com')).toEqual(['www.baidu.com']) - expect(matchCond(cond, 'https://github.com/0HugoHu/time-tracker-4-browser')).toEqual(['github.com/0HugoHu', 'github.com']) - expect(matchCond(cond, 'https://www.github.com')).toEqual([]) - }) - - test('meetLimit', () => { - expect(meetLimit(undefined, undefined)).toBe(false) - expect(meetLimit(1, undefined)).toBe(false) - expect(meetLimit(1, 0)).toBe(false) - expect(meetLimit(0, 100)).toBe(false) - expect(meetLimit(undefined, 100)).toBe(false) - - expect(meetLimit(100, 101)).toBe(true) - expect(meetLimit(100, 100)).toBe(false) - }) - - test('meetTimeLimit', () => { - expect(meetTimeLimit(undefined, undefined, undefined, undefined)).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) - }) - - test('period2Str', () => { - expect(period2Str(undefined)).toBe('00:00-00:00') - expect(period2Str([0, 100])).toBe('00:00-01:40') - expect(period2Str([100, 900])).toBe('01:40-15:00') - }) - - test('dateMinute2Idx', () => { - const date = new Date() - date.setHours(20) - date.setMinutes(6) - expect(dateMinute2Idx(date)).toEqual(20 * 60 + 6) - }) - - test('isEffective', () => { - expect(isEffective(undefined)).toBe(true) - expect(isEffective([])).toBe(true) - - 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) - - 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) - }) - - test('hasWeeklyLimited', () => { - const item: timer.limit.Item = { - id: 1, - name: 'foobar', - cond: [], - time: 0, - waste: 0, - visit: 0, - delayCount: 0, - weeklyWaste: 0, - weeklyVisit: 0, - weeklyDelayCount: 0, - enabled: true, - allowDelay: false, - locked: false, - } - - expect(hasWeeklyLimited(item)).toBe(false) - - item.weekly = 299 - expect(hasWeeklyLimited(item)).toBe(false) - - item.weeklyWaste = 299 * 1000 + 1 - expect(hasWeeklyLimited(item)).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') - - item.allowDelay = true - item.delayCount = 1 - - item.weeklyWaste = 9000 - assert('NORMAL', 'NORMAL') - item.weeklyWaste = 9001 - assert('NORMAL', 'REMINDER') - item.weeklyWaste = 10001 - assert('NORMAL', 'LIMITED') - }) - - test('hasLimit', () => { - const assert = (setup: (item: timer.limit.Item) => void, limited: boolean) => { - const item: timer.limit.Item = { - id: 1, - name: 'foobar', - cond: [], - time: 1, - weekly: 1, - waste: 0, - visit: 0, - delayCount: 0, - weeklyWaste: 0, - weeklyVisit: 0, - weeklyDelayCount: 0, - enabled: true, - allowDelay: false, - locked: false, - } - setup(item) - expect(hasLimited(item)).toBe(limited) - } - - assert(item => item.waste = 1000, false) - assert(item => item.waste = 1001, true) - - assert(item => item.weeklyWaste = 1000, false) - assert(item => item.weeklyWaste = 1001, true) - }) -}) \ No newline at end of file diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts index 2335df5dd..8aeaa58c5 100644 --- a/types/timer/backup.d.ts +++ b/types/timer/backup.d.ts @@ -66,12 +66,6 @@ declare namespace timer.backup { 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' // AWS real-time sync // @since 3.7.0 | 'aws' 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 - } -} From d6e4b654a512712698abb0ed759c6fda0d5065be Mon Sep 17 00:00:00 2001 From: 0HugoHu <0@hugohu.top> Date: Sun, 14 Sep 2025 03:21:28 -0700 Subject: [PATCH 7/9] fix: handle background context for system info detection --- src/api/aws.ts | 43 ---- src/background/migrator/cate-initializer.ts | 107 ++++++++- src/background/migrator/index.ts | 2 + .../migrator/limit-cleanup-migrator.ts | 43 ++++ src/i18n/message/app/option-resource.json | 6 - src/i18n/message/app/option.ts | 6 - .../components/BackupOption/Clear/Sop.tsx | 2 +- .../components/BackupOption/Download/Sop.tsx | 105 -------- .../BackupOption/Download/Step2.tsx | 45 ---- .../BackupOption/Download/index.tsx | 37 --- .../Option/components/BackupOption/Footer.tsx | 179 +------------- .../Option/components/BackupOption/index.tsx | 7 +- src/service/backup/aws/coordinator.ts | 20 +- src/util/constant/option.ts | 18 +- src/util/system-info.ts | 224 ++++++++++++++++++ types/timer/option.d.ts | 31 --- 16 files changed, 386 insertions(+), 489 deletions(-) create mode 100644 src/background/migrator/limit-cleanup-migrator.ts delete mode 100644 src/pages/app/components/Option/components/BackupOption/Download/Sop.tsx delete mode 100644 src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx delete mode 100644 src/pages/app/components/Option/components/BackupOption/Download/index.tsx create mode 100644 src/util/system-info.ts diff --git a/src/api/aws.ts b/src/api/aws.ts index 7afe6136b..456d88344 100644 --- a/src/api/aws.ts +++ b/src/api/aws.ts @@ -50,17 +50,6 @@ export type ConflictInfo = { } } -export type DownloadRequest = { - startDate?: string - endDate?: string - clientId?: string -} - -export type DownloadResponse = { - success: boolean - data: timer.core.Row[] - count: number -} export type ClientsResponse = { clients: timer.backup.Client[] @@ -150,38 +139,6 @@ export async function uploadData(config: AwsConfig, clientId: string, rows: time }) } -/** - * Download data from AWS backend - */ -export async function downloadData(config: AwsConfig, clientId: string, request: DownloadRequest): Promise { - if (!config.apiEndpoint || !config.apiKey) { - throw new Error('AWS configuration incomplete: missing apiEndpoint or apiKey') - } - - if (!clientId) { - throw new Error('Invalid download parameters: missing clientId') - } - - const params = new URLSearchParams() - if (request.startDate) params.set('startDate', request.startDate) - if (request.endDate) params.set('endDate', request.endDate) - if (request.clientId) params.set('clientId', request.clientId) - - const baseUrl = config.apiEndpoint.endsWith('/') ? config.apiEndpoint.slice(0, -1) : config.apiEndpoint - const url = `${baseUrl}/data?${params.toString()}` - const headers = getHeaders(config, clientId) - - return await withRetry(async () => { - const response = await fetchGet(url, { headers }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Download failed: HTTP ${response.status} - ${errorText}`) - } - - return await response.json() - }) -} /** * List all clients diff --git a/src/background/migrator/cate-initializer.ts b/src/background/migrator/cate-initializer.ts index d42cb61f4..b3b4ef189 100644 --- a/src/background/migrator/cate-initializer.ts +++ b/src/background/migrator/cate-initializer.ts @@ -9,22 +9,117 @@ type InitialCate = { const DEMO_ITEMS: InitialCate[] = [ { - name: 'Video', + name: 'Development', + hosts: [ + 'github.com', + 'stackoverflow.com', + ], + }, { + name: 'Cloud Services', + hosts: [ + 'console.aws.amazon.com', + 'us-east-1.console.aws.amazon.com', + 'us-east-2.console.aws.amazon.com', + 'eu-west-1.console.aws.amazon.com', + 'us-west-2.console.aws.amazon.com', + 'signin.aws.amazon.com', + ], + }, { + name: 'Work Tools', + hosts: [ + // Amazon development tools + 'code.amazon.com', + 'build.amazon.com', + 'pipelines.amazon.com', + 'issues.amazon.com', + 'apollo.amazon.com', + 'mcm.amazon.com', + 'mcm.amazon.dev', + 'sim.amazon.com', + 'weblab.amazon.com', + 'sage.amazon.com', + 'sage.amazon.dev', + // Amazon work platforms + 'datacentral.a2z.com', + 't.corp.amazon.com', + 'w.amazon.com', + 'monitorportal.amazon.com', + 'phonetool.amazon.com', + 'quip-amazon.com', + 'sushi.amazon.com', + 'cpjobui-na.amazon.com', + 'cpjobui-eu.amazon.com', + 'atoz.amazon.work', + // Amazon infrastructure & ops + 'oncall.corp.amazon.com', + 'oncall.ai.amazon.dev', + 'retro.corp.amazon.com', + 'midway-auth.amazon.com', + 'i.amazon.com', + 'tiny.amazon.com', + 'kingpin.amazon.com', + 'is-it-down.amazon.com', + 'nexus.corp.amazon.com', + 'meetings.amazon.com', + 'gather.a2z.com', + 'console.harmony.a2z.com', + 'shepherd.a2z.com', + 'console.clickstream.amazon.com', + 'deployment-group.pipelines.a2z.com', + 'forecasts.cloudtune.a2z.com', + 'live-forecast-monitoring.cloudtune.amazon.dev', + 'cti.amazon.com', + 'fua.corp.amazon.com', + 'sas.corp.amazon.com', + 'policyengine.amazon.com', + 'conduit.security.a2z.com', + 'asr.security.amazon.dev', + 'alation.spektr.a2z.com', + 'fatals.amazon.com', + // Amazon infrastructure endpoints + 'iad.merlon.amazon.dev', + 'toolbelt.irm.amazon.dev', + 'docs.hub.amazon.dev', + 'idp-integ.federate.amazon.com', + // Amazon ORCA endpoints + 'pdx-o-orca.amazon.com', + 'pdx-t-orca.amazon.com', + 'sfo-ab-orca.amazon.com', + 'sfo-aj-orca.amazon.com', + 'sfo-v-orca.amazon.com', + ], + }, { + name: 'Entertainment', hosts: [ 'www.youtube.com', 'www.bilibili.com', + 'www.fifa.com', + 'auth.fifa.com', + 'access.tickets.fifa.com', + 'fifa-fwc26-us.tickets.fifa.com', ], }, { - name: 'Tech', + name: 'Gaming', hosts: [ - 'github.com', - 'stackoverflow.com', + 'act.hoyoverse.com', ], }, { - name: 'Info', + name: 'Search & Info', hosts: [ - 'www.baidu.com', 'www.google.com', + 'www.baidu.com', + ], + }, { + name: 'Social Media', + hosts: [ + 'x.com', + 'twitter.com', + ], + }, { + name: 'Productivity', + hosts: [ + 'www.deepl.com', + 'translate.google.com', ], } ] diff --git a/src/background/migrator/index.ts b/src/background/migrator/index.ts index c21d0bbbe..1d2e58b9b 100644 --- a/src/background/migrator/index.ts +++ b/src/background/migrator/index.ts @@ -7,6 +7,7 @@ import { getVersion, onInstalled } from "@api/chrome/runtime" import CateInitializer from "./cate-initializer" +import LimitCleanupMigrator from "./limit-cleanup-migrator" import { type Migrator } from "./common" import HostMergeInitializer from "./host-merge-initializer" import LocalFileInitializer from "./local-file-initializer" @@ -26,6 +27,7 @@ class VersionManager { new LocalFileInitializer(), new WhitelistInitializer(), new CateInitializer(), + new LimitCleanupMigrator(), ) } diff --git a/src/background/migrator/limit-cleanup-migrator.ts b/src/background/migrator/limit-cleanup-migrator.ts new file mode 100644 index 000000000..bd0ab3c43 --- /dev/null +++ b/src/background/migrator/limit-cleanup-migrator.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import optionHolder from "@service/components/option-holder" +import { Migrator } from "./common" + +export default class LimitCleanupMigrator implements Migrator { + onInstall(): void { + // No action needed on install, limit options are not part of defaults anymore + } + + async onUpdate(version: string): Promise { + // Clean up limit-related options starting from version where limit was removed + if (version >= '3.7.0') { + await this.cleanupLimitOptions() + } + } + + private async cleanupLimitOptions(): Promise { + const option = await optionHolder.get() + + // Check if any limit-related properties exist and clean them + const limitProps = ['limitLevel', 'limitPassword', 'limitPrompt', 'limitVerifyDifficulty', 'limitReminder', 'limitReminderDuration'] + const hasLimitOptions = limitProps.some(prop => (option as any)[prop] !== undefined) + + if (hasLimitOptions) { + // Remove limit-related properties by setting them to undefined + const cleanupOptions: any = {} + limitProps.forEach(prop => { + if ((option as any)[prop] !== undefined) { + cleanupOptions[prop] = undefined + } + }) + + // Use set to update options + await optionHolder.set(cleanupOptions) + } + } +} \ 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 eedd50203..95da901e1 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -106,12 +106,6 @@ }, "lastTimeTip": "上次备份时间: {lastTime}", "operation": "备份数据", - "download": { - "btn": "下载数据", - "step2": "确认数据", - "willDownload": "待下载数据", - "confirmTip": "将下载来自【{clientName}】的 {size} 条数据" - }, "clear": { "btn": "清除数据", "confirmTip": "将删除【{clientName}】所追踪的 {hostCount} 个站点的 {rowCount} 条数据!" diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 5ab623355..55286c952 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -87,12 +87,6 @@ export type OptionMessage = { notSelected: string current: string } - download: { - btn: string - step2: string - willDownload: string - confirmTip: string - } clear: { btn: string confirmTip: string diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx b/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx index 7ce384f32..10bebb6bd 100644 --- a/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx +++ b/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx @@ -50,7 +50,7 @@ const _default = defineComponent((props, ctx) => { steps: () => ( msg.option.backup.clientTable.selectTip)} /> - msg.option.backup.download.step2)} /> + ), content: () => step.value === 0 ? : 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 d70cafd80..000000000 --- a/src/pages/app/components/Option/components/BackupOption/Download/Sop.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import DialogSop, { type SopInstance, type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useManualRequest, useState } from "@hooks" -import processor from "@service/backup/processor" -import { fillExist, processImportedData } from "@service/components/import-processor" -import { getBirthday, parseTime } from "@util/time" -import { ElMessage, ElStep, ElSteps } from "element-plus" -import { defineComponent, ref } from "vue" -import ClientTable from "../ClientTable" -import Step2 from "./Step2" - -async function fetchData(client: timer.backup.Client): Promise { - const { id: specCid, maxDate, minDate } = client - const start = parseTime(minDate) ?? getBirthday() - const end = parseTime(maxDate) ?? new Date() - const remoteRows = await processor.query({ specCid, start, end }) - const rows: timer.imported.Row[] = remoteRows.map(rr => ({ - date: rr.date, - host: rr.host, - focus: rr.focus, - time: rr.time, - })) - await fillExist(rows) - return { rows, focus: true, time: true } -} - -type Props = { - onCancel: NoArgCallback - onDownload: NoArgCallback -} - -const _default = defineComponent((props, ctx) => { - const [step, setStep] = useState<0 | 1>(0) - const [client, setClient] = useState() - const step2 = ref>() - - const init = () => { - setStep(0) - setClient() - } - - ctx.expose({ init } satisfies SopInstance) - - const { data, refresh: handleNext, loading: dataFetching } = useManualRequest(() => { - if (step.value !== 0) throw new Error("Data already loaded") - - const clientVal = client.value - if (!clientVal) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) - return fetchData(clientVal) - }, { - defaultValue: { rows: [] }, - onSuccess: () => step.value = 1, - onError: e => ElMessage.error((e as Error)?.message || 'Unknown error...'), - }) - - const { refresh: handleDownload, loading: downloading } = useManualRequest(async () => { - const resolution = await step2.value?.parseData?.() - if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) - - await processImportedData(data.value, resolution) - }, { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onDownload?.() - }, - onError: e => ElMessage.error((e as Error)?.message || 'Unknown error...'), - }) - - return () => ( - ( - - msg.option.backup.clientTable.selectTip)} /> - msg.option.backup.download.step2)} /> - - ), - content: () => step.value === 0 - ? - : - }} - /> - ) -}, { 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 a53b5e610..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, ctx) => { - const [resolution, setResolution] = useState() - - ctx.expose({ parseData: () => resolution.value } satisfies SopStepInstance) - - return () => ( - - - { - t(msg => msg.option.backup.download.confirmTip, { - clientName: props.clientName, - size: props.data?.rows?.length || 0 - }) - } - - msg.option.backup.download.willDownload)} /> - - - - - ) -}, { 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() - - return () => <> - - {t(msg => msg.option.backup.download.btn)} - - msg.option.backup.download.btn)} - width="70%" - modelValue={dialogVisible.value} - onOpen={() => sop.value?.init?.()} - onClose={close} - > - - - -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/Footer.tsx b/src/pages/app/components/Option/components/BackupOption/Footer.tsx index e59666849..c6697783a 100644 --- a/src/pages/app/components/Option/components/BackupOption/Footer.tsx +++ b/src/pages/app/components/Option/components/BackupOption/Footer.tsx @@ -6,19 +6,15 @@ */ import { t } from "@app/locale" -import { Download as DownloadIcon, Operation, UploadFilled } from "@element-plus/icons-vue" +import { Operation, UploadFilled } from "@element-plus/icons-vue" import { useManualRequest, useRequest, useState } from "@hooks" import Flex from "@pages/components/Flex" import processor from "@service/backup/processor" import metaService from "@service/meta-service" import { formatTime } from "@util/time" -import { downloadData } from "@api/aws" -import optionHolder from "@service/components/option-holder" -import statDatabase from "@db/stat-database" import { ElButton, ElDivider, ElLoading, ElMessage, ElText } 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...." }) @@ -34,173 +30,6 @@ async function handleTest() { } } -async function handleManualFetch() { - const loading = ElLoading.service({ text: "Fetching cloud data..." }) - try { - const option = await optionHolder.get() - const cid = await metaService.getCid() - - console.log('Client ID for download:', cid || 'No client ID (will download all data)') - - const awsAuth = option.backupAuths?.aws - const awsExt = option.backupExts?.aws - - if (option.backupType !== 'aws' || !awsAuth || !awsExt?.apiEndpoint) { - ElMessage.error("AWS sync not properly configured") - return - } - - console.log('Manual fetch using client ID:', cid) - console.log('AWS config:', { - apiEndpoint: awsExt.apiEndpoint, - region: awsExt.region || 'us-east-1', - hasApiKey: !!awsAuth - }) - - // Validate that we have all required parameters - if (!awsAuth) { - ElMessage.error("AWS API key not configured") - return - } - if (!awsExt.apiEndpoint) { - ElMessage.error("AWS API endpoint not configured") - return - } - - const awsConfig = { - apiEndpoint: awsExt.apiEndpoint, - websocketEndpoint: awsExt.websocketEndpoint || '', - apiKey: awsAuth, - region: awsExt.region || 'us-east-1' - } - - // For private use, fetch ALL data (no date restrictions) - console.log('Fetching ALL data from cloud (no date restrictions)') - - // For private use, download all data without client ID filtering - // Pass cid for authentication header, but don't pass clientId in request to get ALL data - const response = await downloadData(awsConfig, cid || 'anonymous', { - // No startDate, endDate, or clientId to get ALL data - }) - - if (response.success) { - console.log('Fetched cloud data:', response.data) - - // Merge downloaded data into local database - if (response.data && response.data.length > 0) { - const mergeResults = await mergeCloudDataToLocal(response.data) - ElMessage.success(`Successfully merged ${mergeResults.merged} records (${mergeResults.updated} updated, ${mergeResults.added} new)`) - console.log('Merge results:', mergeResults) - } else { - ElMessage.info('No data to merge - cloud storage is empty') - } - } else { - ElMessage.error("Failed to fetch cloud data") - } - } catch (error) { - console.error('Manual fetch error:', error) - ElMessage.error(error instanceof Error ? error.message : 'Unknown error occurred') - } finally { - loading.close() - } -} - -/** - * Merge downloaded cloud data into local database - */ -async function mergeCloudDataToLocal(cloudData: timer.core.Row[]): Promise<{ - merged: number - added: number - updated: number - skipped: number -}> { - let added = 0 - let updated = 0 - let skipped = 0 - - console.log(`Starting merge of ${cloudData.length} cloud records`) - - for (const cloudRow of cloudData) { - try { - // Get existing local data for this host+date - const existingRows = await statDatabase.select({ - keys: cloudRow.host, - date: new Date( - parseInt(cloudRow.date.substring(0, 4)), - parseInt(cloudRow.date.substring(4, 6)) - 1, - parseInt(cloudRow.date.substring(6, 8)) - ) - }) - const existingRow = existingRows.length > 0 ? existingRows[0] : null - - if (!existingRow) { - // No local data exists - add new record - await statDatabase.forceUpdate({ - host: cloudRow.host, - date: cloudRow.date, - focus: cloudRow.focus || 0, - time: cloudRow.time || 0, - run: cloudRow.run || 0 - }) - added++ - console.log(`Added new record: ${cloudRow.host} ${cloudRow.date}`) - } else { - // Local data exists - merge with conflict resolution - const shouldUpdate = await resolveConflict(existingRow, cloudRow) - - if (shouldUpdate) { - // Merge the data (take maximum values approach) - const mergedRow: timer.core.Row = { - host: cloudRow.host, - date: cloudRow.date, - focus: Math.max(existingRow.focus || 0, cloudRow.focus || 0), - time: Math.max(existingRow.time || 0, cloudRow.time || 0), - run: Math.max(existingRow.run || 0, cloudRow.run || 0) - } - - await statDatabase.forceUpdate(mergedRow) - updated++ - console.log(`Updated record: ${cloudRow.host} ${cloudRow.date} (focus: ${existingRow.focus || 0} -> ${mergedRow.focus}, time: ${existingRow.time || 0} -> ${mergedRow.time}, run: ${existingRow.run || 0} -> ${mergedRow.run})`) - } else { - skipped++ - console.log(`Skipped record: ${cloudRow.host} ${cloudRow.date} (local data is newer/better)`) - } - } - } catch (error) { - console.error(`Error merging record ${cloudRow.host} ${cloudRow.date}:`, error) - skipped++ - } - } - - const merged = added + updated - console.log(`Merge completed: ${merged} total merged (${added} added, ${updated} updated, ${skipped} skipped)`) - - return { merged, added, updated, skipped } -} - -/** - * Resolve conflicts between local and cloud data - */ -async function resolveConflict(localRow: timer.core.Row, cloudRow: timer.core.Row): Promise { - // Simple conflict resolution strategy: - // 1. If cloud has more data (focus/time/run), always update - // 2. If local has more data, keep local - // 3. If equal, skip (no update needed) - - const localTotal = (localRow.focus || 0) + (localRow.time || 0) + (localRow.run || 0) - const cloudTotal = (cloudRow.focus || 0) + (cloudRow.time || 0) + (cloudRow.run || 0) - - if (cloudTotal > localTotal) { - return true // Cloud has more data, update local - } - - if (cloudTotal < localTotal) { - return false // Local has more data, keep local - } - - // Equal totals - no update needed - return false -} const TIME_FORMAT = t(msg => msg.calendar.timeFormat) @@ -234,15 +63,9 @@ const _default = defineComponent<{ type: timer.backup.Type }>(props => { {t(msg => msg.button.test)} - {t(msg => msg.option.backup.operation)} - {props.type === 'aws' && ( - - Download All Data - - )} {t( msg => msg.option.backup.lastTimeTip, diff --git a/src/pages/app/components/Option/components/BackupOption/index.tsx b/src/pages/app/components/Option/components/BackupOption/index.tsx index 0ccacc56f..f09bd04ca 100644 --- a/src/pages/app/components/Option/components/BackupOption/index.tsx +++ b/src/pages/app/components/Option/components/BackupOption/index.tsx @@ -122,12 +122,15 @@ const _default = defineComponent((_, ctx) => { />
    } - msg.option.backup.client}> + "Client Name {info} {input}"} v-slots={{ + info: () => {'Unique name to identify this device/browser in sync operations. Auto-generated based on system info.'} + }}> clientName.value = val?.trim?.() || ''} + placeholder="Auto-generated device name" /> {isNotNone.value &&