├── .gitignore
├── LICENSE
├── PRIVACY POLICY.md
├── README.md
├── assets
├── basicboard.jpg
├── bear.jpg
├── bear.png
├── listboard.jpg
└── page.jpg
├── changelog.md
├── deploy
├── clear.js
├── generate-manifest.js
├── unzip.js
└── zip.js
├── dist
├── leetcode.js
├── leetcode.js.map
├── leetcodecn.js
├── leetcodecn.js.map
├── options.js
├── options.js.map
├── popup.js
├── popup.js.LICENSE.txt
└── popup.js.map
├── lib
├── bootstrap.bundle.min.js
├── bootstrap.min.css
└── fontawesome.js
├── manifest.base.json
├── options.html
├── package-lock.json
├── package.json
├── popup.html
├── src
├── content-scripts
│ └── reminder.js
└── popup
│ ├── daily-review.js
│ ├── delegate
│ ├── cloudStorageDelegate.js
│ ├── fsrsDelegate.js
│ ├── leetCodeDelegate.js
│ ├── localStorageDelegate.js
│ └── storageDelegate.js
│ ├── entity
│ ├── operationHistory.js
│ └── problem.js
│ ├── handler
│ ├── configJumpHandler.js
│ ├── handlerRegister.js
│ ├── modeSwitchHandler.js
│ ├── noteHandler.js
│ ├── pageJumpHandler.js
│ ├── popupUnloadHandler.js
│ └── recordOperationHandler.js
│ ├── options.js
│ ├── popup.css
│ ├── popup.js
│ ├── script
│ ├── leetcode.js
│ ├── leetcodecn.js
│ └── submission.js
│ ├── service
│ ├── configService.js
│ ├── fsrsService.js
│ ├── modeService.js
│ ├── operationHistoryService.js
│ └── problemService.js
│ ├── store.js
│ ├── util
│ ├── constants.js
│ ├── doms.js
│ ├── fsrs.js
│ ├── keys.js
│ ├── sort.js
│ └── utils.js
│ └── view
│ └── view.js
└── webpack.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.zip
3 | *.pem
4 | *.dev.*
5 | manifest.json
6 | Leetcode-Mastery-Scheduler
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Hacode
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/PRIVACY POLICY.md:
--------------------------------------------------------------------------------
1 | # Privacy Policy
2 |
3 | Welcome to Leetcode-Mastery-Scheduler! This privacy policy is designed to help you understand how we collect, use, and protect your personal information. Please read this privacy policy carefully before using our plugin.
4 |
5 | 1. **Data Collection and Use**
6 |
7 | - Leetcode-Mastery-Scheduler collects data from your activity on the LeetCode.com and LeetCode.cn (力扣) website, including your problem name, problem url, submission times, and other relevant learning progress information.
8 |
9 | - This data is used for the following purposes:
10 | - Recording the successful submission times to help you track your learning progress.
11 | - Initiating or updating review reminders to enhance your learning retention.
12 |
13 | 2. **Data Security**
14 |
15 | - All collected data are stored locally.
16 | - In the future, the data might be synchronized over your google account, so you can browse problem submission records and review times across devices. Leetcode-Mastery-Scheduler does not upload the data to any third-party servers.
17 |
18 | 3. **Privacy Policy Updates**
19 | - We reserve the right to update this privacy policy at any time. The updated policy will be posted and effective within the plugin.
20 |
21 |
22 | Please note that this privacy policy may be subject to changes due to alterations in laws, regulations, or business requirements. Please check this policy regularly for the latest information.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | L eetcode M astery S cheduler
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | [
](https://github.com/xiaohajiayou/Leetcode-Mastery-Scheduler/blob/main/LICENSE)
16 | 
17 | 
18 | [](https://www.bilibili.com/video/BV1RnAYeEEu6/?spm_id_from=333.1387.homepage.video_card.click&vd_source=09dab0452e2548023f6f83174148ee0c)
19 | [](https://www.youtube.com/watch?v=N-_q4tvyBiA&t=5s)
20 |
21 |
22 |
23 |
24 | Train Memory Curves, Smart Prioritization, Flexible Review, Code Smarter!
25 |
26 | 训练记忆曲线,智能评估优先级,灵活复习,更聪明地刷题!
27 |
28 |
29 | 
30 |
31 | # 🚀 Get Started
32 |
33 | 1. Install the LMS plugin. Initially, click the `rate` button at the bottom-right corner of the LeetCode/Li Kou page (you can drag it to a different position). Rate your mastery of the problem, and the algorithm will adjust your review schedule based on your rating.
34 |
35 | 2. The plugin's homepage automatically assesses the retrievability priority of each problem (the probability of being able to recall it). You can flexibly adjust your daily review volume based on your schedule.
36 |
37 | 3. The FSRS algorithm allows for breaks and cramming sessions. It will automatically infer the overall recall probability of the problems over time and dynamically adjust the next review time to fit your learning pace.
38 |
39 | 4. You can complete your review by ticking the box in the plugin's `popup` window, or you can click into the problem page and complete the review via the `rate it` button.
40 |
41 | 5. Open the question list, view all the questions in the current review plan, write notes, and export notes as Markdown.
42 |
43 | 6. Happy problem-solving! The key to mastering things quickly is to avoid forgetting!
44 | # 🚀 用法
45 | 1. 安装LMS插件. 初始在LeetCode / 力扣页面右下角点击`rate`按钮(可拖动位置),为题目掌握情况打分,算法参考打分情况实时调整复习计划。
46 | 2. 插件主页自动评估每道题的可检索性优先级(能够回忆起来的概率),用户可根据时间安排,灵活调整每日的复习量。
47 | 3. FSRS算法允许休息和提前突击复习,其算法会随时间流逝,自动推理整体题目的回忆概率,动态调整下一次复习时间,以适应你的学习节奏。
48 | 4. 可在插件的`popup`弹窗中打勾完成复习;也可点击进入题目页面,通过`rate`按钮完成复习。
49 | 5. 打开题目列表,查看当前复习规划的所有题目,撰写笔记,导出笔记为Markdown
50 | 6. 刷题快乐,速成的本质在于不要遗忘!
51 |
52 | 
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | # 📥 How to Install / 安装方法
63 |
64 |
65 |
66 |
69 |
70 |
73 |
74 |
75 |
76 | # 📝 安排
77 |
78 | | 任务/功能 | 状态 | 备注 |
79 | |--------------------|------------|--------------------|
80 | | 多设备数据云同步 | ✅ 已完成 | Edge、Chrome |
81 | | 监控提醒 | ✅ 已完成 | bilibili、youtube |
82 | | url添加力扣题目 | ✅ 已完成 | 配合 IDE 刷题,工位摸鱼专用 |
83 | | url添加自定义卡片 | ✅ 已完成 | 用于记录面试手撕题、其他刷题网站用户暂时替代方案 |
84 | | 提供笔记功能 | ✅ 已完成 | 题目列表中新增笔记按钮,支持导出所有笔记为Markdown |
85 | | 收集Anki fsrs 训练数据 | ✅ 已完成 | 待用于测试fsrs官方端口训练 |
86 | | 接入Anki fsrs官方训练端口 | ✅ 已完成 | 目前仅支持本地复习记录训练(云同步用户可能存在影响) |
87 | | 扩展webdav云同步服务 | ❌ 待完成 | 待接入坚果云 |
88 | | 支持语言切换 | ❌ 待完成 | 待完成 |
89 | | 不同网站题目数据源切换 | ❌ 待完成 | 待完成(目前仅支持力扣国际站和中国站,待兼容洛谷等) |
90 | | 兼容火狐 | ❌ 待完成 | 待完成 |
91 | | 兼容`ctrl + enter` | ❌ 待完成 | 目前优先级较低 |
92 |
93 |
94 | # 📝 Next Steps
95 | | Task/Feature | Status | Remarks |
96 | |----------------------------|-----------|----------------------------------------------|
97 | | Multi-device cloud sync | ✳️ Completed | Edge, Chrome |
98 | | Monitoring reminders | ✳️ Completed | bilibili, youtube |
99 | | Add LeetCode URL | ✳️ Completed | For IDE coding practice, perfect for working |
100 | | Add custom card URL | ✳️ Completed | For recording interview problems, alternative for other coding websites |
101 | | Provide note-taking feature | ✳️ Completed | Add note button in problem list, support exporting all notes to Markdown |
102 | | Collect Anki FSRS training data | ✳️ Completed | To be used for testing FSRS official training endpoint |
103 | | Integrate Anki FSRS official training endpoint | ✳️ Completed | Currently supports training with local review records (may affect cloud sync users) |
104 | | Expand webdav cloud sync service | ❌ Pending | To be integrated with Nutstore |
105 | | Support language switching | ❌ Pending | Pending completion |
106 | | Switch data sources for different websites | ❌ Pending | Pending completion (currently only supports LeetCode international and Chinese sites, to be compatible with Luogu, etc.) |
107 | | Compatibility with Firefox | ❌ Pending | Pending completion |
108 | | Compatibility with `ctrl + enter` | ❌ Pending | Lower priority for now |
109 |
110 |
111 |
112 |
113 |
114 |
115 | # 🌟 Star History
116 |
117 | [](https://star-history.com/#xiaohajiayou/Leetcode-Mastery-Scheduler&Date)
118 |
119 | We welcome every user to try LMS! If you find it helpful, please give our GitHub repository a Star - it's the greatest support for our work. If you encounter any issues or find bugs during use, feel free to submit an Issue and we'll address it as soon as possible.
120 |
121 | 我们欢迎每一位用户试用LMS!如果你觉得它对你有帮助,请为我们的GitHub仓库点一个Star,这将是对我们工作的最大支持。如果你在使用过程中遇到任何问题或发现bug,欢迎随时提交Issue,我们会尽快为你解决。
122 |
123 |
124 |
125 | # 🙏 Acknowledgments / 致谢
126 | This project is based on [PMCA (Practice Makes Code Accepted)](https://github.com/HaolinZhong/PMCA), with improvements to its codebase. While maintaining the core concept of spaced repetition learning, we have optimized it specifically for time-constrained review scenarios. The improvements include smarter algorithm implementation for priority assessment and more flexible user interaction logic to help users make the most of their limited study time.
127 |
128 | 本项目基于 [PMCA (Practice Makes Code Accepted)](https://github.com/HaolinZhong/PMCA) 的代码开发,在保持重复复习的核心理念的同时,我们特别针对有限时间内的复习场景进行了优化。改进包括更智能的优先级评估算法实现,以及更灵活的使用交互逻辑,帮助用户在有限的学习时间内获得最大收益。
129 |
--------------------------------------------------------------------------------
/assets/basicboard.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaohajiayou/Leetcode-Mastery-Scheduler/9fec6da168c62e6b195b10c09cef1618b2fb91b2/assets/basicboard.jpg
--------------------------------------------------------------------------------
/assets/bear.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaohajiayou/Leetcode-Mastery-Scheduler/9fec6da168c62e6b195b10c09cef1618b2fb91b2/assets/bear.jpg
--------------------------------------------------------------------------------
/assets/bear.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaohajiayou/Leetcode-Mastery-Scheduler/9fec6da168c62e6b195b10c09cef1618b2fb91b2/assets/bear.png
--------------------------------------------------------------------------------
/assets/listboard.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaohajiayou/Leetcode-Mastery-Scheduler/9fec6da168c62e6b195b10c09cef1618b2fb91b2/assets/listboard.jpg
--------------------------------------------------------------------------------
/assets/page.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaohajiayou/Leetcode-Mastery-Scheduler/9fec6da168c62e6b195b10c09cef1618b2fb91b2/assets/page.jpg
--------------------------------------------------------------------------------
/changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 |
4 | ### [0.1.5] - 2025-04-14
5 | ----------------------
6 |
7 | #### Fixed
8 |
9 | - Fix the issue where the timezone was fixed in the FSRS parameter optimization commit (#38)
10 | - 修复fsrs参数优化提交中固定了时区的问题。(#38)
11 |
12 | ### [0.1.4] - 2025-04-08
13 | ----------------------
14 |
15 | #### Added
16 |
17 | - Add local FSRS algorithm parameter optimization, allowing users to fit the memory curve that best suits them (#15)
18 | - 新增本地 FSRS 算法参数优化,用户可以拟合最适合自己的记忆曲线。(#15)
19 |
20 | #### Fixed
21 |
22 | - Fix the issue where the rate button disappeared when the page was zoomed (#32)
23 | - 修复了在页面缩放时,rate 按钮消失的问题。(#32)
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | ## [0.1.3] - 2025-04-02
42 |
43 | ### Added
44 | - Add note writing and export features to enhance the learning experience and allow users to better organize their knowledge (#5)
45 | 新增笔记撰写和导出功能,提升学习体验,帮助用户更好地整理知识(#5)
46 |
47 | ### Fixed
48 | - Fix the issue where LeetCode URLs were incorrectly matched, leading to errors in question identification (#31)
49 | 修复LeetCode URL错误匹配问题,避免题目识别出错(#31)
50 |
51 | - Fix the issue where the S value in the FSRS algorithm was not updated due to incorrect matching, ensuring accurate data for training (#27)
52 | 修复因错误匹配导致FSRS算法中S值未更新问题,确保训练数据准确(#27)
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | ## [0.1.1] - 2025-03-24
67 | ### Fixed
68 | - Fix the issue where the same question could be rated multiple times within one day on the web version (#XX)
69 | 修复网页端同一道题一日内可以多次评分问题(#21)
70 |
71 | - Fix the issue where the review status did not refresh immediately after reviewing a question in the popup (#XX)
72 | 修复popup复习题目后状态没有立即刷新问题(#25)
73 |
74 | ### Added
75 | - Add review history logging feature to prepare for the integration with FSRS training in the future (#XX)
76 | 新增复习记录日志收集,为后续接入fsrs训练做准备(#15)
77 |
78 |
79 |
80 |
81 |
82 |
83 | ## [0.1.0] - 2024-03-16
84 | ### Fixed
85 | - Integrate official FSRS R calculation interface (#18)
86 | 接入fsrs官方R计算接口 (#18)
87 |
88 | - Optimize popup speed (#11)
89 | 优化popup弹出速度 (#11)
90 |
91 | ### Added
92 | - Add blank card feature, allowing users to create external problems (#13、#3)
93 | 新增空白卡片功能,允许用户新建外部题目(其他网站题目或笔试原创题)(#13、#3)
94 |
95 |
96 |
97 |
98 |
99 | ## [0.0.10] - 2024-03-10
100 | ### Fixed
101 | - Fix issue with page jump functionality (#8)
102 | 修复页面跳转功能问题 (#8)
103 |
104 | - Resolve icon freezing problem (#9)
105 | 解决图标卡住的问题 (#9)
106 |
107 | ### Added
108 | - Add feature to customize the position of the "Rate It" button
109 | 新增功能:支持自定义“rate it”按钮的位置
110 |
111 |
112 |
113 |
114 |
115 | ## [0.0.9] - 2025-02-28
116 | ### Initial Release
117 | - release Basic functionality of Leetcode-Mastery-Scheduler
118 | 发布Leetcode-Mastery-Scheduler 的基础功能
--------------------------------------------------------------------------------
/deploy/clear.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 |
3 | function deleteFolder(folderPath) {
4 | fs.rm(folderPath, { recursive: true, force: true }, (err) => {
5 | if (err) {
6 | console.error(`Error deleting folder: ${err}`);
7 | return;
8 | }
9 | console.log('Folder deleted successfully');
10 | });
11 | }
12 |
13 | deleteFolder('Leetcode-Mastery-Scheduler');
--------------------------------------------------------------------------------
/deploy/generate-manifest.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | const manifestPath = path.join(__dirname, '../manifest.base.json');
5 | const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
6 |
7 | const isDev = process.env.NODE_ENV === 'dev';
8 | const privateKey = process.env.EXTENSION_PRIVATE_KEY;
9 |
10 | /**
11 | * set env variable:
12 |
13 | export NODE_ENV=development
14 | export EXTENSION_PRIVATE_KEY="your-private-key-content-here"
15 |
16 | private key is generated by: openssl genpkey -algorithm RSA -out extension-key.pem -pkeyopt rsa_keygen_bits:2048
17 |
18 | This is for cloud sync feature testing. Need to include a private key in manifest.json
19 | to let the local deployed Leetcode-Mastery-Scheduler instances in different devices share the same extension id, by which sync data will be
20 | shared across instances.
21 | *
22 | */
23 |
24 | if (isDev && privateKey) {
25 | manifest.key = privateKey;
26 | }
27 |
28 | const outputPath = path.join(__dirname, '../', 'manifest.json');
29 | fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
30 |
31 | console.log(`Manifest generated for ${isDev ? 'development' : 'production'} environment`);
32 |
--------------------------------------------------------------------------------
/deploy/unzip.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const JSZip = require('jszip');
3 | const path = require('path');
4 |
5 |
6 | function unzipToFolder(zipFilePath) {
7 |
8 | const outputFolder = path.join(__dirname, path.basename(zipFilePath, '.zip'));
9 |
10 | // 读取ZIP文件
11 | fs.readFile(zipFilePath, function(err, data) {
12 | if (err) {
13 | throw err;
14 | }
15 |
16 | JSZip.loadAsync(data).then(function(zip) {
17 | Object.keys(zip.files).forEach(function(filename) {
18 | zip.files[filename].async('nodebuffer').then(function(content) {
19 | const destPath = path.join(outputFolder, filename);
20 | const destDir = path.dirname(destPath);
21 |
22 | fs.mkdirSync(destDir, { recursive: true });
23 |
24 | fs.writeFile(destPath, content, function(err) {
25 | if (err) {
26 | throw err;
27 | }
28 | console.log(`Extracted: ${destPath}`);
29 | });
30 | });
31 | });
32 | });
33 | });
34 | }
35 |
36 | unzipToFolder('Leetcode-Mastery-Scheduler.zip');
--------------------------------------------------------------------------------
/deploy/zip.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const archiver = require('archiver');
4 |
5 | function zipFoldersAndFiles(folders, files, targetZip) {
6 | const output = fs.createWriteStream(targetZip);
7 | const archive = archiver('zip');
8 |
9 | output.on('close', () => {
10 | console.log('zip success');
11 | });
12 |
13 | archive.on('error', (err) => {
14 | throw err;
15 | });
16 |
17 | archive.pipe(output);
18 |
19 | const addToArchive = (itemPath, itemName) => {
20 | const stats = fs.statSync(itemPath);
21 | if (stats.isDirectory()) {
22 | archive.directory(itemPath, itemName);
23 | } else {
24 | archive.file(itemPath, { name: itemName });
25 | }
26 | };
27 |
28 | const addFoldersToArchive = (foldersArray) => {
29 | foldersArray.forEach((folder) => {
30 | addToArchive(folder, path.basename(folder));
31 | });
32 | };
33 |
34 | const addFilesToArchive = (filesArray) => {
35 | filesArray.forEach((file) => {
36 | addToArchive(file, path.basename(file));
37 | });
38 | };
39 |
40 | if (folders && folders.length > 0) {
41 | addFoldersToArchive(folders);
42 | }
43 |
44 | if (files && files.length > 0) {
45 | addFilesToArchive(files);
46 | }
47 |
48 | archive.finalize();
49 | }
50 |
51 | const foldersToZip = ['dist', 'assets', 'lib'];
52 | const filesToZip = [
53 | 'popup.html',
54 | 'options.html',
55 | 'LICENSE',
56 | 'manifest.json',
57 | 'PRIVACY POLICY.md',
58 | 'README.md'
59 | ];
60 |
61 | zipFoldersAndFiles(foldersToZip, filesToZip, 'Leetcode-Mastery-Scheduler.zip');
62 |
--------------------------------------------------------------------------------
/dist/popup.js.LICENSE.txt:
--------------------------------------------------------------------------------
1 | /*!
2 | * Bootstrap v5.3.2 (https://getbootstrap.com/)
3 | * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 | */
6 |
--------------------------------------------------------------------------------
/lib/fontawesome.js:
--------------------------------------------------------------------------------
1 | window.FontAwesomeKitConfig = {"asyncLoading":{"enabled":false},"autoA11y":{"enabled":true},"baseUrl":"https://ka-f.fontawesome.com","baseUrlKit":"https://kit.fontawesome.com","detectConflictsUntil":null,"iconUploads":{},"id":15296281,"license":"free","method":"css","minify":{"enabled":true},"token":"17cf16ca8b","v4FontFaceShim":{"enabled":true},"v4shim":{"enabled":true},"v5FontFaceShim":{"enabled":true},"version":"6.4.2"};
2 | !function(t){"function"==typeof define&&define.amd?define("kit-loader",t):t()}((function(){"use strict";function t(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),n.push.apply(n,r)}return n}function e(e){for(var n=1;nt.length)&&(e=t.length);for(var n=0,r=new Array(e);n-1}(t)&&("URLSearchParams"in window?(i=new URL(t)).searchParams.set("token",o):i=i+"?token="+encodeURIComponent(o)),i=i.toString(),new E((function(t,e){if("function"==typeof n)n(i,{mode:"cors",cache:"default"}).then((function(t){if(t.ok)return t.text();throw new Error("")})).then((function(e){t(e)})).catch(e);else if("function"==typeof r){var o=new r;o.addEventListener("loadend",(function(){this.responseText?t(this.responseText):e(new Error(""))}));["abort","error","timeout"].map((function(t){o.addEventListener(t,(function(){e(new Error(""))}))})),o.open("GET",i),o.send()}else{e(new Error(""))}}))}function _(t,e,n){var r=t;return[[/(url\("?)\.\.\/\.\.\/\.\./g,function(t,n){return"".concat(n).concat(e)}],[/(url\("?)\.\.\/webfonts/g,function(t,r){return"".concat(r).concat(e,"/releases/v").concat(n,"/webfonts")}],[/(url\("?)https:\/\/kit-free([^.])*\.fontawesome\.com/g,function(t,n){return"".concat(n).concat(e)}]].forEach((function(t){var e=o(t,2),n=e[0],i=e[1];r=r.replace(n,i)})),r}function F(t,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){},o=n.document||o,i=a.bind(a,o,["fa","fab","fas","far","fal","fad","fak"]);t.autoA11y.enabled&&r(i);var u=t.subsetPath&&t.baseUrl+"/"+t.subsetPath,f=[{id:"fa-main",addOn:void 0,url:u}];if(t.v4shim&&t.v4shim.enabled&&f.push({id:"fa-v4-shims",addOn:"-v4-shims"}),t.v5FontFaceShim&&t.v5FontFaceShim.enabled&&f.push({id:"fa-v5-font-face",addOn:"-v5-font-face"}),t.v4FontFaceShim&&t.v4FontFaceShim.enabled&&f.push({id:"fa-v4-font-face",addOn:"-v4-font-face"}),!u&&t.customIconsCssPath){var s=t.customIconsCssPath.indexOf("kit-upload.css")>-1?t.baseUrlKit:t.baseUrl,l=s+"/"+t.customIconsCssPath;f.push({id:"fa-kit-upload",url:l})}var d=f.map((function(r){return new E((function(o,i){var a=r.url||c(t,{addOn:r.addOn,minify:t.minify.enabled}),u={id:r.id},f=t.subset?u:e(e(e({},n),u),{},{baseUrl:t.baseUrl,version:t.version,id:r.id,contentFilter:function(t,e){return _(t,e.baseUrl,e.version)}});P(a,n).then((function(t){o(C(t,f))})).catch(i)}))}));return E.all(d)}function C(t,e){var n=e.contentFilter||function(t,e){return t},r=document.createElement("style"),o=document.createTextNode(n(t,e));return r.appendChild(o),r.media="all",e.id&&r.setAttribute("id",e.id),e&&e.detectingConflicts&&e.detectionIgnoreAttr&&r.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)),r}function I(t,n){n.autoA11y=t.autoA11y.enabled,"pro"===t.license&&(n.autoFetchSvg=!0,n.fetchSvgFrom=t.baseUrl+"/releases/"+("latest"===t.version?"latest":"v".concat(t.version))+"/svgs",n.fetchUploadedSvgFrom=t.uploadsUrl);var r=[];return t.v4shim.enabled&&r.push(new E((function(r,o){P(c(t,{addOn:"-v4-shims",minify:t.minify.enabled}),n).then((function(t){r(U(t,e(e({},n),{},{id:"fa-v4-shims"})))})).catch(o)}))),r.push(new E((function(r,o){P(t.subsetPath&&t.baseUrl+"/"+t.subsetPath||c(t,{minify:t.minify.enabled}),n).then((function(t){var o=U(t,e(e({},n),{},{id:"fa-main"}));r(function(t,e){var n=e&&void 0!==e.autoFetchSvg?e.autoFetchSvg:void 0,r=e&&void 0!==e.autoA11y?e.autoA11y:void 0;void 0!==r&&t.setAttribute("data-auto-a11y",r?"true":"false");n&&(t.setAttributeNode(document.createAttribute("data-auto-fetch-svg")),t.setAttribute("data-fetch-svg-from",e.fetchSvgFrom),t.setAttribute("data-fetch-uploaded-svg-from",e.fetchUploadedSvgFrom));return t}(o,n))})).catch(o)}))),E.all(r)}function U(t,e){var n=document.createElement("SCRIPT"),r=document.createTextNode(t);return n.appendChild(r),n.referrerPolicy="strict-origin",e.id&&n.setAttribute("id",e.id),e&&e.detectingConflicts&&e.detectionIgnoreAttr&&n.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)),n}function T(t){var e,n=[],r=document,o=r.documentElement.doScroll,i=(o?/^loaded|^c/:/^loaded|^i|^c/).test(r.readyState);i||r.addEventListener("DOMContentLoaded",e=function(){for(r.removeEventListener("DOMContentLoaded",e),i=1;e=n.shift();)e()}),i?setTimeout(t,0):n.push(t)}function L(t){"undefined"!=typeof MutationObserver&&new MutationObserver(t).observe(document,{childList:!0,subtree:!0})}try{if(window.FontAwesomeKitConfig){var k=window.FontAwesomeKitConfig,x={detectingConflicts:k.detectConflictsUntil&&new Date<=new Date(k.detectConflictsUntil),detectionIgnoreAttr:"data-fa-detection-ignore",fetch:window.fetch,token:k.token,XMLHttpRequest:window.XMLHttpRequest,document:document},M=document.currentScript,N=M?M.parentElement:document.head;(function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return"js"===t.method?I(t,e):"css"===t.method?F(t,e,(function(t){T(t),L(t)})):void 0})(k,x).then((function(t){t.map((function(t){try{N.insertBefore(t,M?M.nextSibling:null)}catch(e){N.appendChild(t)}})),x.detectingConflicts&&M&&T((function(){M.setAttributeNode(document.createAttribute(x.detectionIgnoreAttr));var t=function(t,e){var n=document.createElement("script");return e&&e.detectionIgnoreAttr&&n.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)),n.src=c(t,{baseFilename:"conflict-detection",fileSuffix:"js",subdir:"js",minify:t.minify.enabled}),n}(k,x);document.body.appendChild(t)}))})).catch((function(t){console.error("".concat("Font Awesome Kit:"," ").concat(t))}))}}catch(t){console.error("".concat("Font Awesome Kit:"," ").concat(t))}}));
3 |
--------------------------------------------------------------------------------
/manifest.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Leetcode Mastery Scheduler",
4 | "version": "0.1.5",
5 | "author": "Hacode",
6 | "description": "Leetcode-Mastery-Scheduler tracks your LeetCode progress and prompt you to review based FSRS",
7 | "homepage_url": "https://github.com/xiaohajiayou/Leetcode-Mastery-Scheduler",
8 | "options_ui": {
9 | "page": "options.html",
10 | "open_in_tab": false
11 | },
12 | "icons": {
13 | "128": "assets/bear.png",
14 | "48": "assets/bear.png",
15 | "16": "assets/bear.png"
16 | },
17 | "action": {
18 | "default_icon": "assets/bear.png",
19 | "default_popup": "popup.html"
20 | },
21 | "permissions": [
22 | "unlimitedStorage",
23 | "storage"
24 | ],
25 |
26 | "content_scripts": [
27 | {
28 | "matches": [
29 | "https://*.bilibili.com/*",
30 | "https://*.youtube.com/*",
31 | "https://*.douyu.com/*",
32 | "https://*.huya.com/*",
33 | "https://*.netflix.com/*",
34 | "https://*.iqiyi.com/*",
35 | "https://*.youku.com/*",
36 | "https://v.qq.com/*",
37 | "https://*.zhihu.com/*",
38 | "https://*.weibo.com/*",
39 | "https://*.douyin.com/*",
40 | "https://*.tiktok.com/*"
41 | ],
42 | "js": [
43 | "dist/reminder.js"
44 | ],
45 | "run_at": "document_end",
46 | "all_frames": false
47 | },
48 | {
49 | "matches": [
50 | "https://leetcode.com/problems/*"
51 | ],
52 | "js": [
53 | "dist/leetcode.js"
54 | ],
55 | "run_at": "document_idle"
56 | },
57 | {
58 | "matches": [
59 | "https://leetcode.cn/problems/*"
60 | ],
61 | "js": [
62 | "dist/leetcodecn.js"
63 | ],
64 | "run_at": "document_idle"
65 | }
66 | ]
67 | }
--------------------------------------------------------------------------------
/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Leetcode-Mastery-Scheduler Options
6 |
7 |
8 |
9 |
10 |
11 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
Leetcode-Mastery-Scheduler Options
59 |
60 |
61 |
62 |
63 |
64 |
65 |
90 |
91 |
92 | Options are successfully saved!
93 |
94 |
95 |
96 |
97 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Leetcode-Mastery-Scheduler",
3 | "version": "0.1.5",
4 | "description": "\r \r \r P ractice M akes C ode A ccepted\r \r ",
5 | "main": "src/popup.js",
6 | "directories": {
7 | "lib": "lib"
8 | },
9 | "scripts": {
10 | "test": "echo \"Error: no test specified\" && exit 1",
11 | "build": "webpack",
12 | "manifest:dev": "cross-env NODE_ENV=dev node ./deploy/generate-manifest.js",
13 | "manifest:prod": "cross-env NODE_ENV=prod node ./deploy/generate-manifest.js",
14 | "release:dev": "npm run build && npm run manifest:dev && node ./deploy/zip.js",
15 | "release:prod": "npm run build && npm run manifest:prod && node ./deploy/zip.js",
16 | "clear": "node ./deploy/clear.js",
17 | "deploy:dev": "npm run clear && npm run release:dev && node ./deploy/unzip.js",
18 | "deploy:prod": "npm run clear && npm run release:prod && node ./deploy/unzip.js"
19 | },
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/xiaohajiayou/Leetcode-Mastery-Scheduler"
23 | },
24 | "keywords": [],
25 | "author": "Haolin Zhong",
26 | "license": "ISC",
27 | "bugs": {
28 | "url": "https://github.com/xiaohajiayou/Leetcode-Mastery-Scheduler"
29 | },
30 | "homepage": "https://github.com/xiaohajiayou/Leetcode-Mastery-Scheduler",
31 | "dependencies": {
32 | "archiver": "^6.0.1",
33 | "css-loader": "^6.8.1",
34 | "jszip": "^3.10.1",
35 | "style-loader": "^3.3.3",
36 | "sweetalert2": "^11.15.10",
37 | "ts-fsrs": "^4.7.0",
38 | "webpack": "^5.89.0",
39 | "webpack-cli": "^5.1.4"
40 | },
41 | "devDependencies": {
42 | "cross-env": "^7.0.3"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/content-scripts/reminder.js:
--------------------------------------------------------------------------------
1 | console.log('Reminder content script loaded');
2 |
3 | // 初始化提醒功能
4 | function initializeReminder() {
5 | const CHECK_INTERVAL_MINUTES = 1; // 检查间隔(分钟)
6 | const CHECK_INTERVAL_MS = CHECK_INTERVAL_MINUTES * 60 * 1000;
7 |
8 | // 检查是否应该显示提醒
9 | async function shouldShowReminder() {
10 | try {
11 |
12 | // 检查提醒功能是否启用
13 | const { reminderEnabled } = await getStorageData(['reminderEnabled']);
14 | if (!reminderEnabled) {
15 | console.log('提醒功能未启用');
16 | return false;
17 | }
18 |
19 | // 检查上次提醒时间
20 | const { lastReminderTime, nextReminderDelay } = await getStorageData([
21 | 'lastReminderTime',
22 | 'nextReminderDelay'
23 | ]);
24 |
25 | const now = Date.now();
26 | const lastTime = lastReminderTime || 0;
27 | const delay = nextReminderDelay || CHECK_INTERVAL_MS;
28 |
29 | console.log('检查提醒时间:', {
30 | 现在: new Date(now).toLocaleString(),
31 | 上次提醒: new Date(lastTime).toLocaleString(),
32 | 延迟时间: Math.round(delay / (60 * 1000)) + '分钟',
33 | 是否可以提醒: now - lastTime >= delay
34 | });
35 |
36 | if (now - lastTime >= delay) {
37 | chrome.storage.local.remove('nextReminderDelay', () => {
38 | console.log('延迟时间已重置为默认值');
39 | });
40 | }
41 |
42 | return now - lastTime >= delay;
43 | } catch (error) {
44 | console.error('检查提醒条件时出错:', error);
45 | return false;
46 | }
47 | }
48 |
49 | // 显示提醒
50 | async function showReminder() {
51 | try {
52 | console.log('准备显示提醒');
53 | await createReminderPopup();
54 | console.log('提醒弹窗已创建');
55 | } catch (error) {
56 | console.error('显示提醒失败:', error);
57 | }
58 | }
59 |
60 | // 创建提醒弹窗
61 | function createReminderPopup() {
62 | console.log('开始创建提醒弹窗');
63 |
64 | // 检查是否已存在提醒弹窗
65 | const existingPopup = document.getElementById('leetcode-reminder-popup');
66 | if (existingPopup) {
67 | console.log('已存在提醒弹窗,不重复创建');
68 | return;
69 | }
70 |
71 | // 创建一个 style 元素
72 | const style = document.createElement('style');
73 | style.textContent = `
74 | .header-section {
75 | position: relative;
76 | border-radius: 15px;
77 | overflow: hidden;
78 | background-color: #1d2e3d;
79 | border: 1px solid rgba(74, 157, 156, 0.1);
80 | box-shadow: 0 0 8px rgba(74, 157, 156, 0.1);
81 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
82 | padding: 10px;
83 | margin: 10px 15px;
84 | }
85 |
86 | .header-section:hover {
87 | border-color: rgba(74, 157, 156, 0.2);
88 | box-shadow:
89 | 0 0 12px rgba(74, 157, 156, 0.15),
90 | inset 0 0 8px rgba(74, 157, 156, 0.05);
91 | transform: translateY(-1px);
92 | }
93 |
94 | .header-section::before {
95 | content: '';
96 | position: absolute;
97 | top: -1px;
98 | left: -1px;
99 | right: -1px;
100 | bottom: -1px;
101 | border-radius: 15px;
102 | background: linear-gradient(45deg,
103 | rgba(74, 157, 156, 0.05),
104 | rgba(74, 157, 156, 0.1),
105 | rgba(74, 157, 156, 0.05)
106 | );
107 | z-index: -1;
108 | opacity: 0;
109 | transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
110 | }
111 |
112 | .header-section:hover::before {
113 | opacity: 1;
114 | }
115 |
116 | `;
117 |
118 | const popup = document.createElement('div');
119 | popup.id = 'leetcode-reminder-popup'; // 添加唯一ID
120 | popup.innerHTML = `
121 |
135 |
141 |
Daily Progress
142 | ×
149 |
150 |
151 |
158 |
159 |
Time for your daily practice session. Keep your skills sharp!
160 |
165 |
166 | Remind Later
174 | Not Today
182 |
183 |
184 |
185 | `;
186 |
187 | document.body.appendChild(popup);
188 |
189 | document.getElementById('closeReminder').addEventListener('click', () => {
190 | popup.remove();
191 | console.log('提醒已关闭');
192 | });
193 |
194 | document.getElementById('remindLater').addEventListener('click', () => {
195 | const delay = 30 * 60 * 1000; // 30分钟
196 | updateReminderDelay(delay);
197 | popup.remove();
198 | console.log(`提醒已推迟 ${delay / (60 * 1000)} 分钟`);
199 | });
200 |
201 | document.getElementById('remindTomorrow').addEventListener('click', () => {
202 | const now = new Date();
203 | const tomorrow = new Date(now);
204 | tomorrow.setDate(tomorrow.getDate() + 1);
205 | tomorrow.setHours(9, 0, 0, 0); // 明天早上9点
206 | const delay = tomorrow - now;
207 |
208 | updateReminderDelay(delay);
209 | popup.remove();
210 | console.log(`提醒已推迟到明天早上9点`);
211 | });
212 | }
213 |
214 | // 检查提醒并显示
215 | async function checkAndRemind() {
216 | console.log('开始检查提醒条件');
217 | const shouldRemind = await shouldShowReminder();
218 | if (shouldRemind) {
219 | await showReminder();
220 | } else {
221 | console.log('当前不需要显示提醒');
222 | }
223 |
224 | // await showReminder();
225 |
226 | }
227 |
228 | // 设置定时器
229 | setInterval(checkAndRemind, CHECK_INTERVAL_MS);
230 | console.log('内容脚本定时器已启动,检查间隔:', CHECK_INTERVAL_MINUTES, '分钟');
231 | }
232 |
233 | // 获取存储的数据
234 | function getStorageData(keys) {
235 | return new Promise(resolve => {
236 | chrome.storage.local.get(keys, resolve);
237 | });
238 | }
239 |
240 | // 更新提醒延迟时间
241 | function updateReminderDelay(delay) {
242 | chrome.storage.local.set({
243 | lastReminderTime: Date.now(),
244 | nextReminderDelay: delay
245 | }, () => {
246 | console.log(`已设置延迟时间为 ${delay / (60 * 1000)} 分钟`);
247 | });
248 | }
249 |
250 |
251 |
252 | // 初始化提醒功能
253 | initializeReminder();
--------------------------------------------------------------------------------
/src/popup/delegate/cloudStorageDelegate.js:
--------------------------------------------------------------------------------
1 | import { simpleStringHash } from "../util/utils";
2 | import { StorageDelegate } from "./storageDelegate";
3 |
4 |
5 | const getCloudStorageData = async (key) => {
6 | return new Promise((resolve, reject) => {
7 | chrome.storage.sync.get(key, (result) => {
8 | if (result === undefined || result[key] === undefined) {
9 | reject(key);
10 | } else {
11 | resolve(result[key]);
12 | }
13 | })
14 | }).catch((key) => {
15 | console.log(`get sync storage data failed for key = ${key}`);
16 | });
17 | }
18 |
19 | const setCloudStorageData = async (key, val) => {
20 |
21 | console.log("set to cloud");
22 | console.log([key, val]);
23 |
24 | return new Promise((resolve) => {
25 | chrome.storage.sync.set({ [key]: val });
26 | resolve();
27 | }).catch(e => console.log(e));
28 | }
29 |
30 | const batchSetCloudStorageDate = async (object) => {
31 | return new Promise((resolve) => {
32 | chrome.storage.sync.set(object);
33 | resolve();
34 | }).catch(e => console.log(e));
35 | }
36 |
37 | const batchGetCloudStorageDate = async (keyArr) => {
38 | return new Promise((resolve, reject) => {
39 | chrome.storage.sync.get(keyArr, (result) => {
40 | if (result === undefined) {
41 | reject(key);
42 | } else {
43 | resolve(result);
44 | }
45 | })
46 | }).catch(e => {
47 | console.log(console.log(e));
48 | });
49 | }
50 |
51 | /**
52 | * sharding
53 | */
54 |
55 | const shardCount = 20;
56 |
57 | const hashKeyToShardIdx = (key) => {
58 | const hash = simpleStringHash(key);
59 | const shardIndex = (hash % shardCount + shardCount) % shardCount;
60 | return shardIndex;
61 | }
62 |
63 | const isJsonObj = (obj) => {
64 | return Object.getPrototypeOf(obj) === Object.prototype;
65 | }
66 |
67 | const shardedSetCloudStorageData = async (key, val) => {
68 | // val should be a JSON object
69 | if (!isJsonObj(val)) {
70 | throw "shardedSet only supports JSON type val";
71 | }
72 | const shardedVal = {};
73 | const objectKeys = Object.keys(val);
74 | Array.prototype.forEach.call(objectKeys, (objKey) => {
75 | const shardedIdx = hashKeyToShardIdx(objKey);
76 | const shardedKey = `${key}#${shardedIdx}`;
77 | if (!(shardedKey in shardedVal)) {
78 | shardedVal[shardedKey] = {};
79 | }
80 | shardedVal[shardedKey][objKey] = val[objKey];
81 | })
82 |
83 | console.log("set shareded data to cloud:");
84 | console.log(shardedVal);
85 |
86 | await batchSetCloudStorageDate(shardedVal);
87 | }
88 |
89 | const shardedGetCloudStorageData = async (key) => {
90 | const shardedKeyArr = [];
91 | for (let i = 0; i < shardCount; i++) {
92 | shardedKeyArr.push(`${key}#${i}`);
93 | }
94 |
95 | const vals = await batchGetCloudStorageDate(shardedKeyArr);
96 | const res = {};
97 |
98 | if (vals === undefined) return res;
99 | for (const shardKey in vals) {
100 | Object.assign(res, vals[shardKey]);
101 | }
102 | console.log(`get ${key} sharded from cloud`)
103 | console.log(res);
104 | return res;
105 | }
106 |
107 | class CloudStorageDelegate extends StorageDelegate {
108 | constructor(){
109 | super();
110 | this.get = shardedGetCloudStorageData;
111 | this.set = shardedSetCloudStorageData;
112 | }
113 | }
114 |
115 | const cloudStorageDelegate = new CloudStorageDelegate();
116 | export default cloudStorageDelegate;
--------------------------------------------------------------------------------
/src/popup/delegate/fsrsDelegate.js:
--------------------------------------------------------------------------------
1 | // FSRS参数优化相关的API请求处理
2 | export const optimizeFSRSParams = async (csvContent, onProgress) => {
3 | try {
4 | const formData = new FormData();
5 | const csvBlob = new Blob([csvContent], { type: 'text/csv' });
6 | // ref: https://github.com/ishiko732/fsrs-online-training/blob/73b3281e4c972bf965083dcfe61f087383b4a083/components/lib/tz.ts#L3-L4
7 | // Chrome > 24, Edge > 12, Firefox > 29
8 | const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
9 | formData.append('file', csvBlob, 'revlog.csv');
10 | formData.append('sse', '1');
11 | formData.append('hour_offset', '4');
12 | formData.append('enable_short_term', '0');
13 | formData.append('timezone', timeZone);
14 |
15 | const response = await fetch('https://ishiko732-fsrs-online-training.hf.space/api/train', {
16 | method: 'POST',
17 | body: formData
18 | });
19 |
20 | if (!response.ok) {
21 | throw new Error(`HTTP error! status: ${response.status}`);
22 | }
23 |
24 | // 手动解析SSE响应
25 | const reader = response.body.getReader();
26 | const decoder = new TextDecoder();
27 | let result = null;
28 | let lastProgress = null;
29 | let doneParams = null;
30 |
31 | while (true) {
32 | const { done, value } = await reader.read();
33 | if (done) break;
34 |
35 | const chunk = decoder.decode(value, { stream: true });
36 | const lines = chunk.split('\n');
37 |
38 | // 处理SSE响应
39 | for (let i = 0; i < lines.length; i++) {
40 | const line = lines[i];
41 |
42 | // 处理事件类型
43 | if (line.startsWith('event: ')) {
44 | const eventType = line.substring(7);
45 | console.log('事件类型:', eventType);
46 |
47 | // 查找下一个data行
48 | let dataLine = '';
49 | for (let j = i + 1; j < lines.length; j++) {
50 | if (lines[j].startsWith('data: ')) {
51 | dataLine = lines[j];
52 | break;
53 | }
54 | }
55 |
56 | if (dataLine) {
57 | try {
58 | const data = JSON.parse(dataLine.substring(6));
59 |
60 | // 处理进度信息
61 | if (eventType === 'progress') {
62 | lastProgress = data;
63 | // 如果提供了进度回调函数,则调用它
64 | if (onProgress) {
65 | onProgress(data);
66 | }
67 | }
68 |
69 | // 处理完成事件
70 | if (eventType === 'done') {
71 | doneParams = data;
72 | console.log('捕获到done事件中的参数:', doneParams);
73 | }
74 |
75 | // 处理训练结果
76 | if (eventType === 'info' && data.type === 'Train') {
77 | result = data;
78 | }
79 | } catch (e) {
80 | console.warn('Error parsing SSE data:', e, dataLine);
81 | }
82 | }
83 | }
84 | }
85 | }
86 |
87 | // 优先返回done标签中的参数
88 | if (doneParams) {
89 | return doneParams;
90 | }
91 |
92 | // 如果没有获取到done参数,但有进度信息,则返回进度信息
93 | if (!result && lastProgress) {
94 | result = {
95 | type: 'Progress',
96 | progress: lastProgress
97 | };
98 | }
99 |
100 | return result || { type: 'Error', message: 'No result received' };
101 | } catch (error) {
102 | console.error('Error optimizing FSRS parameters:', error);
103 | throw error;
104 | }
105 | };
--------------------------------------------------------------------------------
/src/popup/delegate/leetCodeDelegate.js:
--------------------------------------------------------------------------------
1 | const user_agent =
2 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36";
3 | const params = {
4 | operationName: "questionTitle",
5 | variables: { titleSlug: "" }
6 | };
7 | const headers = {
8 | 'User-Agent': user_agent,
9 | 'Connection': 'keep-alive',
10 | 'Content-Type': 'application/json',
11 | 'Referer': "",
12 | };
13 |
14 | export const queryProblemInfo = async (slug, site) => {
15 | const baseUrl = `https://leetcode.${site}`;
16 | params.variables.titleSlug = slug;
17 | params.query = `query questionTitle($titleSlug: String!) {
18 | question(titleSlug: $titleSlug) {
19 | questionFrontendId
20 | ${site === "cn" ? "translatedTitle" : "title"}
21 | difficulty
22 | }
23 | }`
24 | headers.Referer = `${baseUrl}/problems/${slug}`
25 |
26 | const requestOptions = {
27 | method: 'POST',
28 | headers: headers,
29 | body: JSON.stringify(params),
30 | timeout: 10000
31 | };
32 |
33 | const response = await fetch(`${baseUrl}/graphql`, requestOptions);
34 | const content = await response.json();
35 |
36 | return content.data.question;
37 | }
38 |
39 | // 从URL获取站点和题目标识
40 | function extractProblemInfo(url) {
41 | const match = url.match(/(com|cn)(\/|$)/);
42 | const site = match ? match[1] : "com";
43 | console.log(`site is ${site}`);
44 |
45 | let cleanUrl = url;
46 | const possible_suffix = ["/submissions/", "/description/", "/discussion/", "/solutions/"];
47 | for (const suffix of possible_suffix) {
48 | if (cleanUrl.includes(suffix)) {
49 | cleanUrl = cleanUrl.substring(0, cleanUrl.lastIndexOf(suffix) + 1);
50 | break;
51 | }
52 | }
53 |
54 | const problemSlug = cleanUrl.split("/").splice(-2)[0];
55 | return { site, problemSlug, cleanUrl };
56 | }
57 |
58 | // 基础的获取题目信息函数
59 | export const getProblemInfo = async (url) => {
60 | const { site, problemSlug, cleanUrl } = extractProblemInfo(url);
61 |
62 | const question = await queryProblemInfo(problemSlug, site);
63 |
64 | return {
65 | problemIndex: question.questionFrontendId,
66 | problemName: `${question.questionFrontendId}. ${site === "cn" ? question.translatedTitle : question.title}`,
67 | problemLevel: question.difficulty,
68 | problemUrl: cleanUrl
69 | };
70 | }
71 |
72 | // 从当前页面URL获取题目信息
73 | export const getProblemInfoByHref = async () => {
74 | const currentUrl = window.location.href;
75 | return await getProblemInfo(currentUrl);
76 | }
77 |
78 | // 从指定URL获取题目信息
79 | export const getProblemInfoByUrl = async (url) => {
80 | if (!url.includes('leetcode.com/problems/') && !url.includes('leetcode.cn/problems/')) {
81 | throw new Error('请输入有效的 LeetCode 题目链接');
82 | }
83 | return await getProblemInfo(url);
84 | }
85 |
86 |
--------------------------------------------------------------------------------
/src/popup/delegate/localStorageDelegate.js:
--------------------------------------------------------------------------------
1 | import { StorageDelegate } from "./storageDelegate";
2 |
3 | export const getLocalStorageData = async (key) => {
4 | return new Promise((resolve, reject) => {
5 | chrome.storage.local.get(key, (result) => {
6 | if (result === undefined || result[key] === undefined) {
7 | reject(key);
8 | } else {
9 | resolve(result[key]);
10 | }
11 | })
12 | }).catch((key) => {
13 | console.log(`get local storage data failed for key = ${key}`);
14 | });
15 | }
16 |
17 | export const setLocalStorageData = async (key, val) => {
18 | return new Promise((resolve) => {
19 | chrome.storage.local.set({ [key]: val });
20 | resolve();
21 | }).catch(e => console.log(e));
22 | }
23 |
24 | class LocalStorageDelegate extends StorageDelegate {
25 | constructor(){
26 | super();
27 | this.get = getLocalStorageData;
28 | this.set = setLocalStorageData;
29 | }
30 | }
31 |
32 | const localStorageDelegate = new LocalStorageDelegate();
33 | export default localStorageDelegate;
--------------------------------------------------------------------------------
/src/popup/delegate/storageDelegate.js:
--------------------------------------------------------------------------------
1 | export class StorageDelegate {
2 | constructor(){
3 | this.get = async (key) => null;
4 | this.set = async (key, val) => {};
5 | }
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/src/popup/entity/operationHistory.js:
--------------------------------------------------------------------------------
1 | export class OperationHistory {
2 | constructor(before, isInCnMode, type, time) {
3 | this.before = before;
4 | this.isInCnMode = isInCnMode;
5 | this.type = type;
6 | this.time = time;
7 | }
8 | }
9 |
10 | export const OPS_TYPE = Object.freeze({
11 | MASTER: "mark as mastered",
12 | RESET: "reset progress",
13 | DELETE: "delete record"
14 | });
--------------------------------------------------------------------------------
/src/popup/entity/problem.js:
--------------------------------------------------------------------------------
1 | export class Problem {
2 | constructor(index, name, level, url, submissionTime, proficiency, modificationTime) {
3 | this.index = index;
4 | this.name = name;
5 | this.level = level;
6 | this.url = url;
7 | this.submissionTime = submissionTime;
8 | this.proficiency = proficiency;
9 | this.modificationTime = modificationTime;
10 | this.isDeleted = false;
11 |
12 | // 更新 FSRS 状态结构
13 | this.fsrsState = {
14 | difficulty: null, // 用户反馈的难度 (1-5)
15 | quality: null, // 答题质量 (1-5)
16 | lastReview: null, // 上次复习时间
17 | nextReview: null, // 下次复习时间
18 | reviewCount: 0, // 复习次数
19 | stability: 0, // 记忆稳定性
20 | state: 'New', // FSRS 状态
21 | lapses: 0 // 遗忘次数
22 | };
23 | }
24 | };
25 |
26 | export const getDeletedProblem = (problemId) => {
27 | const deletedProblem = new Problem(problemId, '', '', '', 0, 0, Date.now());
28 | deletedProblem.isDeleted = true;
29 | return deletedProblem;
30 | }
31 |
32 | export const copy = (p) => {
33 | const newProblem = new Problem(
34 | p.index,
35 | p.name,
36 | p.level,
37 | p.url,
38 | p.submissionTime,
39 | p.proficiency,
40 | p.modificationTime
41 | );
42 |
43 | // 复制 isDeleted 状态
44 | newProblem.isDeleted = p.isDeleted;
45 |
46 | // 深拷贝 fsrsState 对象
47 | // 深拷贝 fsrsState 对象,兼容旧版本
48 | newProblem.fsrsState = {
49 | difficulty: p.fsrsState ? p.fsrsState.difficulty : null,
50 | quality: p.fsrsState ? p.fsrsState.quality : null,
51 | lastReview: p.fsrsState ? p.fsrsState.lastReview : null,
52 | nextReview: p.fsrsState ? p.fsrsState.nextReview : null,
53 | reviewCount: p.fsrsState ? p.fsrsState.reviewCount : 0,
54 | stability: p.fsrsState ? p.fsrsState.stability : 0,
55 | state: p.fsrsState ? p.fsrsState.state : 'New',
56 | lapses: p.fsrsState ? p.fsrsState.lapses : 0
57 | };
58 |
59 | return newProblem;
60 | }
--------------------------------------------------------------------------------
/src/popup/handler/configJumpHandler.js:
--------------------------------------------------------------------------------
1 | import { configButtonDOMs } from "../util/doms"
2 |
3 | export const setConfigJumpHandlers = () => {
4 | if (configButtonDOMs !== undefined) {
5 | Array.prototype.forEach.call(configButtonDOMs, (btn) => btn.onclick = async (e) => {
6 | chrome.runtime.openOptionsPage();
7 | });
8 | }
9 | }
--------------------------------------------------------------------------------
/src/popup/handler/handlerRegister.js:
--------------------------------------------------------------------------------
1 | import { setConfigJumpHandlers } from "./configJumpHandler";
2 | import { setModeSwitchHandlers } from "./modeSwitchHandler";
3 | import { setPageJumpHandlers } from "./pageJumpHandler"
4 | import { setPopupUnloadHandler } from "./popupUnloadHandler";
5 | import { setRecordOperationHandlers } from "./recordOperationHandler";
6 | import { setNoteHandlers } from "./noteHandler";
7 |
8 | export const registerAllHandlers = () => {
9 | setPageJumpHandlers();
10 | setModeSwitchHandlers();
11 | setRecordOperationHandlers();
12 | setConfigJumpHandlers();
13 | setPopupUnloadHandler();
14 | setNoteHandlers();
15 | }
--------------------------------------------------------------------------------
/src/popup/handler/modeSwitchHandler.js:
--------------------------------------------------------------------------------
1 | import { toggleMode } from "../service/modeService";
2 | import { switchButtonDOM } from "../util/doms";
3 | import { renderAll } from "../view/view";
4 | import { initializeReviewPage} from '../daily-review';
5 |
6 | export const switchMode = async () => {
7 | await toggleMode();
8 | await renderAll();
9 | // 更新每日复习视图
10 | await initializeReviewPage();
11 | }
12 |
13 | export const setModeSwitchHandlers = () => {
14 | switchButtonDOM.onclick = switchMode;
15 | }
--------------------------------------------------------------------------------
/src/popup/handler/noteHandler.js:
--------------------------------------------------------------------------------
1 | import { getLocalStorageData, setLocalStorageData } from "../delegate/localStorageDelegate";
2 | import { getAllProblems } from "../service/problemService";
3 | import { renderScheduledTableContent } from "../view/view";
4 | import { store } from "../store";
5 |
6 | // 获取所有笔记
7 | const getAllNotes = async () => {
8 | try {
9 | const notes = await getLocalStorageData("notes");
10 | return notes || {};
11 | } catch (e) {
12 | console.error("获取笔记数据失败", e);
13 | return {}; // 返回空对象而不是抛出错误
14 | }
15 | };
16 |
17 | // 同步笔记到存储
18 | const syncNotes = async (notes) => {
19 | if (!notes) {
20 | notes = await getAllNotes();
21 | }
22 | await setLocalStorageData("notes", notes);
23 | return notes;
24 | };
25 |
26 | // 注册笔记相关事件处理
27 | export const setNoteHandlers = () => {
28 | console.log("注册笔记处理程序");
29 |
30 | // 使用事件委托来处理笔记按钮点击
31 | document.removeEventListener('click', handleNoteButtonClick); // 先移除之前的监听器,避免重复
32 | document.addEventListener('click', handleNoteButtonClick);
33 |
34 | // 注册保存笔记按钮事件
35 | const saveNoteBtn = document.getElementById('saveNoteBtn');
36 | if (saveNoteBtn) {
37 | console.log("找到保存按钮");
38 | saveNoteBtn.addEventListener('click', saveNote);
39 | } else {
40 | console.error("找不到保存按钮");
41 | }
42 |
43 | // 注册取消按钮事件
44 | const cancelBtns = document.querySelectorAll('[data-bs-dismiss="modal"]');
45 | if (cancelBtns.length > 0) {
46 | console.log("找到取消按钮");
47 | cancelBtns.forEach(btn => {
48 | btn.addEventListener('click', () => {
49 | // 关闭模态框
50 | const noteModal = document.getElementById('noteModal');
51 | if (noteModal) {
52 | noteModal.style.display = 'none';
53 | noteModal.classList.remove('show');
54 | }
55 | });
56 | });
57 | } else {
58 | console.error("找不到取消按钮");
59 | }
60 |
61 | // 注册导出笔记按钮事件
62 | const exportNotesBtn = document.getElementById('exportNotesBtn');
63 | if (exportNotesBtn) {
64 | console.log("找到导出按钮");
65 | exportNotesBtn.addEventListener('click', exportAllNotes);
66 | } else {
67 | console.error("找不到导出按钮");
68 | }
69 |
70 | // 初始化工具提示
71 | const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
72 | tooltipTriggerList.map(function (tooltipTriggerEl) {
73 | return new bootstrap.Tooltip(tooltipTriggerEl);
74 | });
75 | }
76 |
77 | // 单独定义处理函数,便于移除
78 | const handleNoteButtonClick = (e) => {
79 | const noteButton = e.target.closest('.note-btn-mark');
80 | if (noteButton) {
81 | console.log("点击了笔记按钮", noteButton);
82 | console.log("按钮元素:", noteButton);
83 | console.log("data-id属性:", noteButton.getAttribute('data-id'));
84 |
85 | const problemIndex = noteButton.getAttribute('data-id');
86 | if (problemIndex) {
87 | openNoteModal(problemIndex);
88 | } else {
89 | console.error("笔记按钮没有 data-id 属性");
90 | }
91 | }
92 | };
93 |
94 | // 打开笔记模态框
95 | const openNoteModal = async (problemIndex) => {
96 | try {
97 | console.log("打开笔记模态框,问题索引:", problemIndex);
98 |
99 | // 使用 getAllProblems 获取问题数据
100 | const problems = await getAllProblems();
101 | const problem = problems[problemIndex];
102 |
103 | // 如果没有找到问题数据
104 | if (!problem) {
105 | console.error("找不到问题数据:", problemIndex);
106 | return;
107 | }
108 |
109 | // 获取笔记数据
110 | const notes = await getAllNotes();
111 | const noteData = notes[problemIndex];
112 |
113 | console.log("问题数据:", problem);
114 |
115 | // 使用自定义方式打开模态框
116 | const noteModal = document.getElementById('noteModal');
117 | if (!noteModal) {
118 | console.error("找不到模态框元素");
119 | return;
120 | }
121 |
122 | // 显示模态框
123 | noteModal.style.display = 'block';
124 | noteModal.classList.add('show');
125 |
126 | // 设置问题索引到隐藏字段
127 | const problemIndexInput = document.getElementById('problemIndex');
128 | if (problemIndexInput) {
129 | problemIndexInput.value = problemIndex;
130 | } else {
131 | console.error("找不到问题索引输入框");
132 | }
133 |
134 | // 设置问题名称 - 使用 innerHTML 直接设置
135 | const problemNameContainer = document.querySelector('.modal-body .mb-3:first-of-type');
136 | if (problemNameContainer) {
137 | // 如果有自定义名称,优先使用自定义名称
138 | const customName = noteData && typeof noteData === 'object' ? noteData.customName : undefined;
139 | const problemName = customName || problem.name || "未知问题";
140 |
141 | problemNameContainer.innerHTML = `
142 | 问题名称 (Problem Name)
143 |
144 | `;
145 | console.log("重新创建了问题名称输入框,值为:", problemName);
146 | } else {
147 | console.error("找不到问题名称容器");
148 | }
149 |
150 | // 设置笔记内容
151 | const noteContentTextarea = document.getElementById('noteContent');
152 | if (noteContentTextarea) {
153 | noteContentTextarea.value = noteData ? (typeof noteData === 'object' ? noteData.content : noteData) : '';
154 | } else {
155 | console.error("找不到笔记内容文本框");
156 | }
157 |
158 | // 设置焦点到文本区域
159 | setTimeout(() => {
160 | if (document.getElementById('noteContent')) {
161 | document.getElementById('noteContent').focus();
162 | }
163 | }, 100);
164 | } catch (e) {
165 | console.error("打开笔记模态框失败", e);
166 | alert("打开笔记失败,请查看控制台获取详细错误信息");
167 | }
168 | }
169 |
170 | // 保存笔记
171 | const saveNote = async () => {
172 | try {
173 | const problemIndex = document.getElementById('problemIndex').value;
174 | const problemNameInput = document.getElementById('noteProblemName');
175 | const noteContent = document.getElementById('noteContent').value;
176 |
177 | // 获取用户输入的问题名称,如果输入框为空则使用占位符
178 | let problemName = "";
179 | if (problemNameInput) {
180 | problemName = problemNameInput.value.trim() || problemNameInput.getAttribute('placeholder') || "";
181 | }
182 |
183 | console.log("保存笔记,问题索引:", problemIndex);
184 | console.log("保存笔记,问题名称:", problemName);
185 |
186 | const notes = await getAllNotes();
187 |
188 | // 使用 getAllProblems 获取问题数据
189 | const problems = await getAllProblems();
190 | const problem = problems[problemIndex];
191 |
192 | if (!problem) {
193 | console.error("找不到问题数据:", problemIndex);
194 | return;
195 | }
196 |
197 | console.log("原问题名称:", problem.name);
198 |
199 | // 如果笔记为空,则删除该条目
200 | if (noteContent.trim() === '') {
201 | delete notes[problemIndex];
202 | } else {
203 | // 保存笔记内容和用户输入的问题名称
204 | notes[problemIndex] = {
205 | content: noteContent,
206 | customName: problemName !== problem.name ? problemName : undefined
207 | };
208 | }
209 |
210 | // 保存到本地存储
211 | await syncNotes(notes);
212 |
213 | // 清除焦点
214 | document.activeElement?.blur();
215 |
216 | // 关闭模态框
217 | const noteModal = document.getElementById('noteModal');
218 | noteModal.style.display = 'none';
219 | noteModal.classList.remove('show');
220 |
221 | // 获取最新的问题数据
222 | const allProblems = await getAllProblems();
223 |
224 | // 先销毁所有现有的工具提示
225 | const existingTooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
226 | existingTooltips.forEach(el => {
227 | const tooltip = bootstrap.Tooltip.getInstance(el);
228 | if (tooltip) {
229 | tooltip.dispose();
230 | }
231 | });
232 |
233 | // 刷新表格以更新笔记图标和问题名称
234 | await renderScheduledTableContent(store.reviewScheduledProblems, store.scheduledPage);
235 |
236 | // 重新初始化工具提示
237 | setTimeout(() => {
238 | // 确保先销毁所有可能存在的工具提示实例
239 | const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
240 | tooltipTriggerList.forEach(el => {
241 | // 创建新的工具提示实例
242 | new bootstrap.Tooltip(el, {
243 | trigger: 'hover', // 只在悬停时显示
244 | container: 'body', // 将工具提示附加到 body
245 | boundary: 'window' // 确保工具提示不会超出窗口边界
246 | });
247 | });
248 |
249 | // 重新注册事件监听器
250 | setNoteHandlers();
251 | }, 200); // 增加延迟时间确保 DOM 完全更新
252 |
253 | console.log("笔记已保存");
254 | } catch (e) {
255 | console.error("保存笔记失败", e);
256 | alert("保存笔记失败,请查看控制台获取详细错误信息");
257 | }
258 | }
259 |
260 | // 导出所有笔记
261 | const exportAllNotes = async () => {
262 | try {
263 | // 使用 getAllProblems 获取问题数据
264 | const problems = await getAllProblems();
265 | const notes = await getAllNotes();
266 | let notesContent = "# LeetCode Mastery Scheduler notes\n\n";
267 | notesContent += "开源仓库链接/repo url: https://github.com/xiaohajiayou/Leetcode-Mastery-Scheduler" + "\n\n";
268 |
269 | // 筛选有笔记的问题
270 | const problemIndicesWithNotes = Object.keys(notes).filter(index =>
271 | problems[index] && !problems[index].isDeleted &&
272 | (typeof notes[index] === 'string' ? notes[index].trim().length > 0 :
273 | (notes[index].content && notes[index].content.trim().length > 0))
274 | );
275 |
276 | if (problemIndicesWithNotes.length === 0) {
277 | alert("没有找到任何笔记!");
278 | return;
279 | }
280 |
281 | // 按问题名称排序
282 | problemIndicesWithNotes.sort((a, b) =>
283 | (problems[a].name || "").localeCompare(problems[b].name || "")
284 | );
285 |
286 | // 生成markdown格式的笔记内容
287 | problemIndicesWithNotes.forEach(index => {
288 | const problem = problems[index];
289 | const noteData = notes[index];
290 | const noteContent = typeof noteData === 'string' ? noteData : noteData.content;
291 | const problemName = (typeof noteData === 'object' && noteData.customName) || problem.name || "未命名问题";
292 |
293 | notesContent += `## ${problemName}\n\n`;
294 | notesContent += `- 难度: ${problem.level || '未知'}\n`;
295 | notesContent += `- 链接: ${problem.url || '#'}\n\n`;
296 | notesContent += `### 笔记\n\n${noteContent}\n\n---\n\n`;
297 | });
298 |
299 | // 创建下载链接
300 | const blob = new Blob([notesContent], { type: 'text/markdown' });
301 | const url = URL.createObjectURL(blob);
302 | const a = document.createElement('a');
303 | a.href = url;
304 | a.download = `leetcode_notes_${new Date().toISOString().slice(0, 10)}.md`;
305 | document.body.appendChild(a);
306 | a.click();
307 | document.body.removeChild(a);
308 | URL.revokeObjectURL(url);
309 |
310 | console.log("笔记已导出");
311 | } catch (e) {
312 | console.error("导出笔记失败", e);
313 | alert("导出笔记失败,请查看控制台获取详细错误信息");
314 | }
315 | }
--------------------------------------------------------------------------------
/src/popup/handler/pageJumpHandler.js:
--------------------------------------------------------------------------------
1 | import { input0DOM, input1DOM, input2DOM, nextButton0DOM, nextButton1DOM, nextButton2DOM, prevButton0DOM, prevButton1DOM, prevButton2DOM } from "../util/doms";
2 | import { renderCompletedTableContent, renderReviewTableContent, renderScheduledTableContent } from "../view/view";
3 | import { store } from "../store";
4 | import { setRecordOperationHandlers } from "./recordOperationHandler";
5 | import { setNoteHandlers } from "./noteHandler";
6 |
7 | const goToPrevReviewPage = () => {
8 | renderReviewTableContent(store.needReviewProblems, store.toReviewPage - 1);
9 | setRecordOperationHandlers();
10 | setNoteHandlers();
11 | }
12 | const goToNextReviewPage = () => {
13 | renderReviewTableContent(store.needReviewProblems, store.toReviewPage + 1);
14 | setRecordOperationHandlers();
15 | setNoteHandlers();
16 | }
17 | const goToPrevSchedulePage = async () => {
18 | await renderScheduledTableContent(store.reviewScheduledProblems, store.scheduledPage - 1);
19 | setRecordOperationHandlers();
20 | setNoteHandlers();
21 | }
22 |
23 | const goToNextSchedulePage = async () => {
24 | await renderScheduledTableContent(store.reviewScheduledProblems, store.scheduledPage + 1);
25 | setRecordOperationHandlers();
26 | setNoteHandlers();
27 | }
28 |
29 | const goToPrevCompletedPage = () => {
30 | renderCompletedTableContent(store.completedProblems, store.completedPage - 1);
31 | setRecordOperationHandlers();
32 | setNoteHandlers();
33 | }
34 |
35 | const goToNextCompletedPage = () => {
36 | renderCompletedTableContent(store.completedProblems, store.completedPage + 1);
37 | setRecordOperationHandlers();
38 | setNoteHandlers();
39 | }
40 |
41 | const jumpToReviewPage = (event) => {
42 | if (event.keyCode !== 13) return;
43 | let page = parseInt(event.target.value);
44 | if (isNaN(page) || !Number.isInteger(page)) {
45 | input0DOM.classList.add("is-invalid");
46 | return;
47 | }
48 | input0DOM.classList.remove("is-invalid");
49 | if (page === store.toReviewPage) return;
50 | renderReviewTableContent(store.needReviewProblems, page);
51 | setRecordOperationHandlers();
52 | }
53 |
54 | const jumpToSchedulePage = (event) => {
55 | if (event.keyCode !== 13) return;
56 | let page = parseInt(event.target.value);
57 | if (isNaN(page) || !Number.isInteger(page)) {
58 | input1DOM.classList.add("is-invalid");
59 | return;
60 | }
61 | input1DOM.classList.remove("is-invalid");
62 | if (page === store.scheduledPage) return;
63 | renderScheduledTableContent(store.reviewScheduledProblems, page);
64 | setRecordOperationHandlers();
65 | }
66 |
67 | const jumpToCompletedPage = (event) => {
68 | if (event.keyCode !== 13) return;
69 | let page = parseInt(event.target.value);
70 | if (isNaN(page) || !Number.isInteger(page)) {
71 | input2DOM.classList.add("is-invalid");
72 | return;
73 | }
74 | input2DOM.classList.remove("is-invalid");
75 | if (page === store.completedPage) return;
76 | renderCompletedTableContent(store.needReviewProblems, page);
77 | setRecordOperationHandlers();
78 | }
79 |
80 | export const setPageJumpHandlers = () => {
81 | // prevButton0DOM.onclick = goToPrevReviewPage;
82 | // nextButton0DOM.onclick = goToNextReviewPage;
83 | prevButton1DOM.onclick = goToPrevSchedulePage;
84 | nextButton1DOM.onclick = goToNextSchedulePage;
85 | // prevButton2DOM.onclick = goToPrevCompletedPage;
86 | // nextButton2DOM.onclick = goToNextCompletedPage;
87 |
88 | // input0DOM.onkeydown = jumpToReviewPage;
89 | input1DOM.onkeydown = jumpToSchedulePage;
90 | // input2DOM.onkeydown = jumpToCompletedPage;
91 | }
--------------------------------------------------------------------------------
/src/popup/handler/popupUnloadHandler.js:
--------------------------------------------------------------------------------
1 | import { syncProblems } from "../service/problemService";
2 | import { popupPageDOM } from "../util/doms"
3 |
4 |
5 | export const setPopupUnloadHandler = () => {
6 | if (popupPageDOM !== undefined) {
7 |
8 | popupPageDOM.addEventListener('unload', async () => {
9 | await syncProblems();
10 | })
11 | }
12 | }
--------------------------------------------------------------------------------
/src/popup/handler/recordOperationHandler.js:
--------------------------------------------------------------------------------
1 | import { checkButtonDOMs, deleteButtonDOMs, resetButtonDOMs, undoButtonDOMs } from "../util/doms";
2 | import { store } from "../store";
3 | import { deleteProblem, markProblemAsMastered, resetProblem } from "../service/problemService";
4 | import { renderAll } from "../view/view";
5 | import { undoLatestOperation } from "../service/operationHistoryService";
6 |
7 | const initTooltips = () => {
8 | store.tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
9 | store.tooltipList = [...store.tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
10 | }
11 |
12 | const hide_all_tooltips = () => {
13 | store.tooltipList.forEach(tooltip => tooltip._hideModalHandler());
14 | }
15 |
16 | export const setRecordOperationHandlers = () => {
17 |
18 | initTooltips();
19 |
20 | if (checkButtonDOMs !== undefined) {
21 | Array.prototype.forEach.call(checkButtonDOMs, (btn) => btn.onclick = async (event) => {
22 | hide_all_tooltips();
23 | await markProblemAsMastered(event.target.dataset.id);
24 | await renderAll();
25 | });
26 | }
27 |
28 | if (deleteButtonDOMs !== undefined) {
29 | Array.prototype.forEach.call(deleteButtonDOMs, (btn) => btn.onclick = async (event) => {
30 | hide_all_tooltips();
31 | await deleteProblem(event.target.dataset.id);
32 | await renderAll();
33 | });
34 | }
35 |
36 | if (resetButtonDOMs !== undefined) {
37 | Array.prototype.forEach.call(resetButtonDOMs, (btn) => btn.onclick = async (event) => {
38 | hide_all_tooltips();
39 | await resetProblem(event.target.dataset.id);
40 | await renderAll();
41 | });
42 | }
43 |
44 | if (undoButtonDOMs !== undefined) {
45 | Array.prototype.forEach.call(undoButtonDOMs, (btn) => btn.onclick = async () => {
46 | hide_all_tooltips();
47 | await undoLatestOperation();
48 | await renderAll();
49 | });
50 | }
51 | }
--------------------------------------------------------------------------------
/src/popup/options.js:
--------------------------------------------------------------------------------
1 | import './popup.css';
2 | import { isCloudSyncEnabled, loadConfigs, setCloudSyncEnabled, setProblemSorter } from "./service/configService";
3 | import { store } from './store';
4 | import { optionPageFeedbackMsgDOM } from './util/doms';
5 | import { descriptionOf, idOf, problemSorterArr } from "./util/sort";
6 |
7 | document.addEventListener('DOMContentLoaded', async () => {
8 |
9 | await loadConfigs();
10 |
11 | const optionsForm = document.getElementById('optionsForm');
12 |
13 | // problem sorted setting
14 | const problemSorterSelect = document.getElementById('problemSorterSelect');
15 | const problemSorterMetaArr = problemSorterArr.map(sorter => {
16 | return {id: idOf(sorter), text: descriptionOf(sorter)};
17 | });
18 |
19 | problemSorterMetaArr.forEach(sorterMeta => {
20 | const optionElement = document.createElement('option');
21 | optionElement.value = sorterMeta.id;
22 | optionElement.textContent = sorterMeta.text;
23 | problemSorterSelect.append(optionElement);
24 | })
25 |
26 | // cloud sync setting
27 | const syncToggle = document.getElementById('syncToggle');
28 | syncToggle.checked = store.isCloudSyncEnabled || false;
29 |
30 | optionsForm.addEventListener('submit', async e => {
31 | e.preventDefault();
32 | const selectedSorterId = problemSorterSelect.value;
33 | const isCloudSyncEnabled = syncToggle.checked;
34 | await setProblemSorter(Number(selectedSorterId));
35 | await setCloudSyncEnabled(isCloudSyncEnabled);
36 | optionPageFeedbackMsgDOM.style.display = 'block';
37 | setTimeout(() => optionPageFeedbackMsgDOM.style.display = 'none', 1000);
38 | })
39 | });
--------------------------------------------------------------------------------
/src/popup/popup.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #0D1F2D; /* 深色背景 */
3 | background-size: cover; /* 背景覆盖 */
4 | color: #ffffff; /* 白色字体 */
5 | font-family: 'Raleway', sans-serif;
6 | position: relative;
7 | }
8 | /* 导航栏样式 */
9 | .nav-bar {
10 | background-color: #0D1F2D;
11 |
12 | display: flex;
13 | flex-direction: column; /* 改为纵向排列 */
14 | align-items: center; /* 水平居中 */
15 | border-bottom: 1px solid #4a9d9c;
16 | }
17 |
18 | .nav-row {
19 | display: flex;
20 | justify-content: center; /* 内容居中 */
21 | align-items: center;
22 | margin: 0; /* 移除外边距 */
23 | padding: 2px 0; /* 减小上下内边距 */
24 | line-height: 1; /* 减小行高 */
25 | margin-top: -5px;
26 | }
27 |
28 | /* 标题行样式 */
29 | .nav-title {
30 | color: #FF3D3D;
31 | font-weight: 900; /* 更粗的字体 */
32 | font-size: 1.2em; /* 更大的字号 */
33 | text-transform: uppercase; /* 转换为大写 */
34 | letter-spacing: 2px; /* 字母间距 */
35 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); /* 添加阴影效果 */
36 | font-family: 'Arial Black', Gadget, sans-serif; /* 更粗重的字体 */
37 | margin-top: 5px;
38 | padding: 5px 10px; /* 添加内边距 */
39 | background: linear-gradient(180deg, #ff6b6b 0%, #FF3D3D 100%); /* 渐变色 */
40 | -webkit-background-clip: text; /* 使渐变色应用到文字 */
41 | -webkit-text-fill-color: transparent; /* 使文字透明以显示背景 */
42 | display: inline-block; /* 确保渐变效果生效 */
43 | }
44 |
45 | /* 专门为标题容器添加类 */
46 | .nav-row.title-row {
47 | margin: 0;
48 | padding: 0;
49 | line-height: 1;
50 | }
51 |
52 | /* 特别处理第二个标题行 */
53 | .nav-row.title-row + .nav-row.title-row {
54 | margin-top: -10px; /* 调整这个值来控制两行标题间的间距 */
55 | }
56 |
57 | /* 网站信息行样式 */
58 | .nav-site {
59 | color: #4a9d9c;
60 | font-size: 0.9em;
61 | padding: 2px 8px;
62 | border-radius: 4px;
63 | background-color: rgba(74, 157, 156, 0.1);
64 | }
65 |
66 | /* 导航按钮行样式 */
67 | .nav-btn {
68 | background: none;
69 | border: none;
70 | color: #888;
71 | padding: 5px 15px;
72 | cursor: pointer;
73 | transition: all 0.3s ease;
74 | font-size: 1em;
75 | position: relative;
76 | }
77 |
78 | .nav-btn:hover {
79 | color: #fff;
80 | }
81 |
82 | .nav-btn.active {
83 | color: #fff;
84 | }
85 |
86 | .nav-btn.active::after {
87 | content: '';
88 | position: absolute;
89 | bottom: -5px;
90 | left: 50%;
91 | transform: translateX(-50%);
92 | width: 20px;
93 | height: 2px;
94 | background-color: #4a9d9c;
95 | border-radius: 2px;
96 | }
97 |
98 | .nav-right {
99 | display: flex;
100 | gap: 10px;
101 | }
102 |
103 | /* 开关按钮样式 */
104 | .switch-btn {
105 | background-color: #2a2b30;
106 | border: 1px solid #3a3b40;
107 | color: #888;
108 | padding: 5px 15px;
109 | border-radius: 4px;
110 | cursor: pointer;
111 | transition: all 0.3s ease;
112 | }
113 |
114 | .switch-btn:hover {
115 | background-color: #3a3b40;
116 | color: #fff;
117 | }
118 |
119 |
120 | .text-date {
121 | color: #e0e0e0 !important; /* 更亮的灰色,使用 !important 确保优先级 */
122 | font-size: 1em; /* 修改字体大小 */
123 | align-items: center;
124 | gap: 2px;
125 | }
126 | .text-muted {
127 | color: #e0e0e0 !important; /* 更亮的灰色,使用 !important 确保优先级 */
128 | font-size: 0.8em; /* 修改字体大小 */
129 | display: flex;
130 | align-items: center;
131 | gap: 5px;
132 | }
133 |
134 |
135 |
136 | .review-card {
137 | background-color: #1d2e3d; /* 卡片背景 */
138 | border-radius: 15px;
139 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
140 | padding: 10px;
141 | margin: 20px 0;
142 | transition: transform 0.2s;
143 |
144 | }
145 |
146 | .review-card:hover {
147 | transform: translateY(-5px);
148 | box-shadow: 0 8px 16px #4a9d9c;
149 | }
150 |
151 | .problem-title {
152 | font-family: 'Press Start 2P', cursive;
153 | font-size: 0.9em;
154 | color: #ffffff; /* 白色字体 */
155 | }
156 |
157 |
158 |
159 | .difficulty-Easy {
160 | color: #4a9d9c;
161 | }
162 |
163 | .difficulty-Medium {
164 | color: #f0b215;
165 | }
166 |
167 | .difficulty-Hard {
168 | color: #FF3D3D;
169 | }
170 |
171 | .progress {
172 | height: 8px;
173 | margin-top: 10px;
174 | }
175 |
176 | .btn-review {
177 |
178 | border: none;
179 | color: #e0e0e0;
180 | font-size: 1.5em;
181 | border-radius: 50%;
182 | cursor: pointer;
183 | transition: all 0.3s ease;
184 | font-weight: 500;
185 | position: relative;
186 | z-index: 1;
187 | pointer-events: auto !important;
188 | }
189 |
190 |
191 | .btn-review:hover {
192 | color: #afffff;
193 | transform: translateY(-1px);
194 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
195 | }
196 |
197 | .btn-review:active {
198 | transform: translateY(0);
199 | }
200 |
201 | .btn-review:disabled {
202 |
203 | color: white;
204 | opacity: 0.8;
205 | cursor: not-allowed;
206 | }
207 |
208 | .btn-review.btn-lg {
209 | font-size: 1.1em;
210 | padding: 10px 24px;
211 | }
212 |
213 | .review-card .btn-review,
214 | .container .btn-review {
215 | pointer-events: auto !important;
216 | cursor: pointer !important;
217 | }
218 |
219 | .review-card.reviewed {
220 | opacity: 0.6;
221 | }
222 |
223 | .header-section {
224 | position: relative;
225 | border-radius: 15px;
226 | overflow: hidden;
227 | background-color: #1d2e3d;
228 | border: 1px solid rgba(74, 157, 156, 0.1); /* 降低边框透明度 */
229 | box-shadow: 0 0 8px rgba(74, 157, 156, 0.1); /* 降低阴影透明度 */
230 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
231 | padding: 10px;
232 | margin: 10px 15px;
233 | }
234 |
235 | .header-section:hover {
236 | border-color: rgba(74, 157, 156, 0.2); /* 降低悬停时边框透明度 */
237 | box-shadow:
238 | 0 0 12px rgba(74, 157, 156, 0.15), /* 降低外阴影透明度 */
239 | inset 0 0 8px rgba(74, 157, 156, 0.05); /* 降低内阴影透明度 */
240 | transform: translateY(-1px);
241 | }
242 |
243 | .header-section::before {
244 | content: '';
245 | position: absolute;
246 | top: -1px;
247 | left: -1px;
248 | right: -1px;
249 | bottom: -1px;
250 | border-radius: 15px;
251 | background: linear-gradient(45deg,
252 | rgba(74, 157, 156, 0.05), /* 降低渐变透明度 */
253 | rgba(74, 157, 156, 0.1),
254 | rgba(74, 157, 156, 0.05)
255 | );
256 | z-index: -1;
257 | opacity: 0;
258 | transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
259 | }
260 |
261 | .header-section:hover::before {
262 | opacity: 1;
263 | }
264 |
265 | .completion-circle {
266 | width: 120px;
267 | height: 120px;
268 | border-radius: 50%;
269 | background: conic-gradient(#afffff var(--percentage), #3a3a4d var(--percentage)); /* 使用深色作为背景 */
270 | display: flex;
271 | align-items: center;
272 | justify-content: center;
273 | margin: 0 auto;
274 | transition: background 0.5s ease; /* 背景渐变动画 */
275 | }
276 |
277 | .inner-circle {
278 | width: 100px;
279 | height: 100px;
280 | background: #1d2e3d; /* 内圈背景 */
281 | border-radius: 50%;
282 | display: flex;
283 | align-items: center;
284 | justify-content: center;
285 | font-size: 1.5em;
286 | font-weight: bold;
287 | color: #FF3D3D; /* 内圈字体颜色 */
288 | transition: transform 0.5s ease; /* 内圈缩放动画 */
289 | }
290 |
291 | .retrievability {
292 | font-size: 1.0em;
293 | color: #ffffff; /* 白色字体 */
294 | display: flex;
295 | align-items: center;
296 | justify-content: center;
297 | }
298 |
299 | .retrievability-icon {
300 | margin-right: 10px;
301 | color: #4a9d9c;
302 | }
303 |
304 | .retrievability-value {
305 | font-weight: bold;
306 | margin-left: 10px;
307 | color: #4a9d9c; /* Green color for good retrievability */
308 | transition: color 0.3s;
309 | }
310 |
311 | .retrievability-value.low {
312 | color: #FF3D3D; /* Red color for low retrievability */
313 | }
314 |
315 | .trend-icon {
316 | margin-left: 10px;
317 | font-size: 1.5em;
318 | transition: transform 0.3s;
319 | }
320 |
321 | .trend-up {
322 | color: #4a9d9c; /* Green for upward trend */
323 | }
324 |
325 | .trend-down {
326 | color: #FF3D3D; /* Red for downward trend */
327 | }
328 |
329 | .low-memory-warning {
330 | position: absolute;
331 | top: 0;
332 | left: 0;
333 | width: 100%;
334 | height: 100%;
335 | display: none;
336 | z-index: 0;
337 | }
338 |
339 | .low-memory-warning.active {
340 | display: flex;
341 | }
342 |
343 | .card-limit-input input[type="number"]::-webkit-inner-spin-button,
344 | .card-limit-input input[type="number"]::-webkit-outer-spin-button {
345 | -webkit-appearance: none;
346 | margin: 0;
347 | }
348 |
349 | .card-limit-input input[type="number"] {
350 | -moz-appearance: textfield;
351 | background: #3a3a4d; /* 输入框背景 */
352 | color: #ffffff; /* 输入框字体颜色 */
353 | width: 40px;
354 | height: 40px;
355 | text-align: center;
356 | font-size: 1.2em;
357 | padding: 5px;
358 | border: 2px solid #0D6E6E; /* 输入框边框颜色 */
359 | border-radius: 8px;
360 | margin: 0 10px;
361 | }
362 |
363 | .gear-button {
364 | background: none;
365 | border: none;
366 | cursor: pointer;
367 | transition: all 0.3s ease;
368 | padding: 5px;
369 | position: relative;
370 | width: 40px;
371 | height: 40px;
372 | display: flex;
373 | align-items: center;
374 | justify-content: center;
375 | }
376 |
377 | .gear-button .fa-gear {
378 | font-size: 1.8em;
379 | color: #0D6E6E; /* 齿轮图标颜色 */
380 | transition: all 0.3s ease;
381 | }
382 |
383 | .gear-button .direction-icon {
384 | position: absolute;
385 | font-size: 1em;
386 | color: #e2c027;
387 | background-color: #fff;
388 | border-radius: 50%;
389 | width: 16px;
390 | height: 16px;
391 | display: flex;
392 | align-items: center;
393 | justify-content: center;
394 | transition: all 0.3s ease;
395 | left: 50%;
396 | top: 50%;
397 | transform: translate(-50%, -50%);
398 | box-shadow: 0 1px 3px rgba(0,0,0,0.2);
399 | font-weight: bold;
400 | }
401 |
402 | /* 悬停效果 */
403 | .gear-button:hover .fa-gear {
404 | color: #4a9d9c; /* 悬停时齿轮图标颜色 */
405 | filter: drop-shadow(0 0 2px rgba(255, 152, 0, 0.5));
406 | }
407 |
408 | .gear-button:hover .direction-icon {
409 | color: #000;
410 | background-color: #fff;
411 | box-shadow: 0 2px 4px rgba(0,0,0,0.3);
412 | transform: translate(-50%, -50%) scale(1.1);
413 | }
414 |
415 | /* 点击动画 */
416 | .gear-button.left:active {
417 | transform: rotate(-45deg);
418 | }
419 |
420 | .gear-button.right:active {
421 | transform: rotate(45deg);
422 | }
423 |
424 | .card-limit-input {
425 | display: flex;
426 | align-items: center;
427 | justify-content: center;
428 | gap: 5px;
429 | margin-top: 10px;
430 | }
431 |
432 | .card-limit-label {
433 | font-size: 1.1em;
434 | color: #ffffff; /* 白色字体 */
435 | margin-right: 5px;
436 | }
437 |
438 | /* 工具提示 */
439 | .gear-button::after {
440 | content: attr(data-tooltip);
441 | position: absolute;
442 | bottom: -25px;
443 | left: 50%;
444 | transform: translateX(-50%);
445 | background-color: rgba(0, 0, 0, 0.8);
446 | color: white;
447 | padding: 4px 8px;
448 | border-radius: 4px;
449 | font-size: 0.8em;
450 | opacity: 0;
451 | transition: opacity 0.3s ease;
452 | pointer-events: none;
453 | white-space: nowrap;
454 | }
455 |
456 | .gear-button:hover::after {
457 | opacity: 1;
458 | }
459 |
460 |
461 | .add-problem-wrapper {
462 | display: flex;
463 | justify-content: center;
464 | }
465 |
466 | .empty-state {
467 | text-align: center;
468 | margin-top: 15px;
469 | margin-bottom: 5px;
470 | color: #4a9d9c;
471 | font-size: 0.9em;
472 | opacity: 0.8;
473 | font-style: italic;
474 | margin-bottom: 10px;
475 | display: flex;
476 | align-items: center;
477 | justify-content: center;
478 | gap: 8px;
479 | }
480 |
481 | .empty-state i {
482 | font-size: 1em;
483 | color: #ffd700; /* 给灯泡图标一个金色 */
484 | }
485 |
486 |
487 |
488 | .add-problem {
489 | display: flex;
490 | align-items: center;
491 | justify-content: center;
492 | background: transparent;
493 | border: 1px dashed rgba(74, 157, 156, 0.5);
494 | width: 30px;
495 | height: 30px;
496 | border-radius: 8px;
497 | color: #4a9d9c;
498 | opacity: 0.8;
499 | }
500 |
501 | .add-problem-content {
502 | display: flex;
503 | align-items: center;
504 | }
505 |
506 | .add-problem i {
507 | font-size: 0.8em;
508 | }
509 |
510 |
511 | /* 悬停效果 */
512 | .add-problem:hover {
513 | background: rgba(74, 157, 156, 0.1);
514 | border-style: solid;
515 | opacity: 1;
516 | }
517 |
518 | /* 添加虚线分隔 */
519 | .add-problem-wrapper::before {
520 | content: '';
521 | position: absolute;
522 | top: -8px;
523 | left: 10%;
524 | right: 10%;
525 | height: 1px;
526 | background: linear-gradient(
527 | to right,
528 | transparent,
529 | rgba(74, 157, 156, 0.3),
530 | transparent
531 | );
532 | }
533 | .modal {
534 | position: fixed;
535 | top: 0;
536 | left: 0;
537 | right: 0;
538 | bottom: 0;
539 | display: flex;
540 | justify-content: center;
541 | align-items: center;
542 | background: rgba(0, 0, 0, 0.5);
543 | z-index: 1000;
544 | backdrop-filter: blur(4px);
545 | }
546 |
547 | .modal-content {
548 | background: #1d2e3d;
549 | border-radius: 8px;
550 | padding: 16px; /* 减小内边距 */
551 | width: 280px; /* 固定更小的宽度 */
552 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
553 | border: 1px solid rgba(97, 218, 251, 0.2);
554 | animation: modalFadeIn 0.3s ease;
555 | }
556 |
557 | .modal-content h3 {
558 | color: #61dafb;
559 | margin-bottom: 12px; /* 减小标题下方间距 */
560 | font-size: 1em; /* 减小标题字体 */
561 | }
562 |
563 | .form-group {
564 | margin-bottom: 12px; /* 减小表单组间距 */
565 | }
566 |
567 | .form-group label {
568 | display: block;
569 | margin-bottom: 4px; /* 减小标签下方间距 */
570 | color: #e9ecef;
571 | font-size: 0.85em; /* 减小标签字体 */
572 | }
573 |
574 | .form-group input {
575 | width: 100%;
576 | padding: 6px 8px; /* 减小输入框内边距 */
577 | border: 1px solid rgba(97, 218, 251, 0.3);
578 | border-radius: 4px;
579 | background: rgba(29, 46, 61, 0.8);
580 | color: #e9ecef;
581 | font-size: 0.85em; /* 减小输入框字体 */
582 | }
583 |
584 | .button-group {
585 | display: flex;
586 | justify-content: flex-end;
587 | gap: 8px; /* 减小按钮间距 */
588 | margin-top: 12px; /* 减小按钮组上方间距 */
589 | }
590 |
591 | .button-group button {
592 | padding: 4px 12px; /* 减小按钮内边距 */
593 | border-radius: 4px;
594 | font-size: 0.85em; /* 减小按钮字体 */
595 | }
596 |
597 | /* 自定义按钮样式 */
598 | .custom-btn {
599 | border: 1px solid;
600 | }
601 |
602 | .btn-outline-warning {
603 | border-color: #ffc107;
604 | color: #ffc107;
605 | }
606 |
607 | .btn-outline-warning:hover {
608 | background: rgba(255, 193, 7, 0.1);
609 | }
610 |
611 | .btn-outline-secondary {
612 | border-color: #6c757d;
613 | color: #6c757d;
614 | }
615 |
616 | .btn-outline-secondary:hover {
617 | background: rgba(108, 117, 125, 0.1);
618 | }
619 |
620 |
621 |
622 |
623 |
624 |
625 | /* 视图容器样式 */
626 | .view {
627 | display: none;
628 | transition: opacity 0.3s ease;
629 | }
630 |
631 | .view.active {
632 | display: block;
633 | }
634 |
635 | /* 题目列表页面样式 */
636 | .problem-list-header {
637 | display: flex;
638 | justify-content: space-between;
639 | align-items: center;
640 | margin-bottom: 20px;
641 | }
642 |
643 | .search-bar {
644 | position: relative;
645 | width: 300px;
646 | }
647 |
648 | .search-input {
649 | width: 100%;
650 | padding: 8px 35px 8px 15px;
651 | border: none;
652 | border-radius: 20px;
653 | background: #2a2b30;
654 | color: #fff;
655 | }
656 |
657 | .search-icon {
658 | position: absolute;
659 | right: 15px;
660 | top: 50%;
661 | transform: translateY(-50%);
662 | color: #888;
663 | }
664 |
665 | .problem-grid {
666 | display: grid;
667 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
668 | gap: 20px;
669 | padding: 20px 0;
670 | }
671 |
672 | /* 更多选项页面样式 */
673 | .options-grid {
674 | display: grid;
675 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
676 | gap: 20px;
677 | padding: 20px 0;
678 | }
679 |
680 |
681 |
682 | /* 导航标签样式 */
683 | #problemListView .nav-tabs {
684 | border-bottom: 1px solid #4a9d9c;
685 |
686 | }
687 |
688 | #problemListView .nav-tabs .nav-link {
689 | color: #888;
690 | border: none;
691 | background: none;
692 |
693 |
694 | transition: all 0.3s ease;
695 | }
696 |
697 | #problemListView .nav-tabs .nav-link:hover {
698 | color: #fff;
699 | }
700 |
701 | #problemListView .nav-tabs .nav-link.active {
702 | color: #4a9d9c;
703 | background: none;
704 | border-bottom: 2px solid #4a9d9c;
705 | }
706 |
707 | /* 确保tab内容区域正确显示 */
708 | #problemListView .tab-content {
709 | display: flex;
710 | }
711 |
712 | #problemListView .tab-pane {
713 | transition: opacity 0.3s ease;
714 | }
715 |
716 | #problemListView .tab-pane.active {
717 | opacity: 1;
718 | }
719 |
720 |
721 |
722 |
723 |
724 | iframe {
725 | overflow: hidden;
726 | border: 0;
727 | }
728 |
729 | .custom-btn {
730 | border-color: #0D6E6E;
731 | color: #4a9d9c;
732 | }
733 |
734 | .custom-btn:hover {
735 | border-color: rgba(235, 173, 129, 1);
736 | background-color: rgba(235, 173, 129, 1);
737 | }
738 |
739 | .custom-btn:disabled {
740 | border-color: #e0e0e0;
741 | color: #e0e0e0;
742 | }
743 |
744 |
745 | .footer {
746 | background: linear-gradient(to bottom, rgba(29, 46, 61, 0.8) 0%, #1d2e3d 100%);
747 | backdrop-filter: blur(5px);
748 | display: flex;
749 | align-items: center;
750 | justify-content: center;
751 | gap: 12px; /* 按钮之间的间距 */
752 | border-top: 1px solid rgba(255, 255, 255, 0.1);
753 | margin-top: auto;
754 | padding: 8px 0; /* 添加上下内边距 */
755 | }
756 |
757 | #github-star-container {
758 | display: flex;
759 | align-items: center;
760 | height: 30px;
761 | }
762 |
763 | /* GitHub Star 按钮样式 */
764 | .github-star-btn {
765 | font-size: 0.875rem;
766 | font-family: 'Courier Prime', monospace;
767 | background: #1d2e3d;
768 | border: 1px solid rgba(97, 218, 251, 0.3);
769 | color: #61dafb;
770 | border-radius: 6px;
771 | display: flex;
772 | align-items: center;
773 | gap: 0.6rem;
774 | transition: all 0.3s ease;
775 | position: relative;
776 | overflow: hidden;
777 | padding: 0.35rem 0.8rem;
778 | animation: starPulse 2s infinite;
779 | }
780 |
781 | @keyframes starPulse {
782 | 0% {
783 | box-shadow: 0 0 0 0 rgba(97, 218, 251, 0.4);
784 | }
785 | 70% {
786 | box-shadow: 0 0 0 10px rgba(97, 218, 251, 0);
787 | }
788 | 100% {
789 | box-shadow: 0 0 0 0 rgba(97, 218, 251, 0);
790 | }
791 | }
792 |
793 | .github-star-btn i {
794 | font-size: 0.875rem;
795 | color: #61dafb;
796 | transition: all 0.3s ease;
797 | animation: starTwinkle 2s infinite;
798 | }
799 |
800 | @keyframes starTwinkle {
801 | 0% {
802 | transform: scale(1);
803 | opacity: 1;
804 | }
805 | 50% {
806 | transform: scale(1.2);
807 | opacity: 0.8;
808 | }
809 | 100% {
810 | transform: scale(1);
811 | opacity: 1;
812 | }
813 | }
814 |
815 | .github-star-btn:hover {
816 | background: #1a3244;
817 | border-color: #61dafb;
818 | box-shadow: 0 0 15px rgba(97, 218, 251, 0.7);
819 | color: #61dafb;
820 | animation: none; /* 悬停时停止脉冲动画 */
821 | }
822 |
823 | .github-star-btn:hover i {
824 | animation: none; /* 悬停时停止星星闪烁动画 */
825 | transform: scale(1.2);
826 | }
827 |
828 | .feedback-btn-review {
829 | padding: 0.35rem 0.8rem !important; /* 减小按钮内边距 */
830 | font-size: 0.8rem !important; /* 稍微减小字体 */
831 | min-height: 28px; /* 设置最小高度 */
832 | padding: 0 12px; /* 水平内边距 */
833 | }
834 |
835 | .feedback-btn-review .btn-content {
836 | gap: 0.4rem !important; /* 减小图标和文字间距 */
837 | }
838 |
839 | .feedback-btn-review i {
840 | font-size: 0.8rem !important; /* 减小图标大小 */
841 | }
842 |
843 |
844 | .page-input {
845 | background-color: transparent;
846 | color: #e0e0e0;
847 | border: 1px solid #e0e0e0;
848 | border-radius: 4px;
849 | text-align: center;
850 | font-size: 0.875rem; /* 相当于 Bootstrap 的 sm 大小 */
851 | margin-left: 5px !important;
852 | margin-right: 5px !important;
853 | }
854 |
855 | .page-input:focus {
856 | outline: none;
857 | border-color: #afffff;
858 | box-shadow: 0 0 5px rgba(74, 157, 156, 0.5);
859 | }
860 |
861 | .multifont {
862 | font-family: 'Courier Prime', 'Noto Sans SC', sans-serif;
863 | }
864 |
865 | a {
866 | color: chocolate;
867 | }
868 |
869 | .custom-tooltip {
870 | --bs-tooltip-bg: var(--bd-violet-bg);
871 | --bs-tooltip-color: var(--bs-white);
872 | }
873 |
874 | /* 题目列表样式 */
875 | #problemListView {
876 | padding: 5px;
877 | }
878 |
879 | .problem-list-header {
880 | margin-bottom: 15px;
881 | }
882 |
883 | .nav-tabs {
884 | border-bottom: 1px solid #dee2e6;
885 | }
886 |
887 | .nav-tabs .nav-link {
888 | margin-bottom: -1px;
889 | color: #495057;
890 | border: 1px solid transparent;
891 | border-top-left-radius: 0.25rem;
892 | border-top-right-radius: 0.25rem;
893 | }
894 |
895 | .nav-tabs .nav-link.active {
896 | color: #495057;
897 | background-color: #fff;
898 | border-color: #dee2e6 #dee2e6 #fff;
899 | }
900 |
901 | .tab-content {
902 | padding: 10px;
903 | background-color: #fff;
904 | border: 1px solid #dee2e6;
905 | border-top: none;
906 | }
907 |
908 | .tab-pane {
909 | display: none;
910 | }
911 | .tab-pane.active {
912 | display: block;
913 | }
914 |
915 |
916 |
917 | /* 确保switch容器不会阻挡点击事件 */
918 | #switch-area {
919 | pointer-events: auto;
920 | position: relative;
921 | z-index: 100;
922 | }
923 |
924 |
925 |
926 |
927 |
928 |
929 | /* 自定义表格样式 */
930 | .table {
931 | width: 100%;
932 | table-layout: fixed;
933 | word-wrap: break-word;
934 |
935 |
936 | --bs-table-border-color: #afffff !important; /* 边框颜色 */
937 |
938 | --bs-table-hover-color: #f56464 !important; /* 悬停文字颜色 */
939 | --bs-table-hover-bg: #ebe3e3 !important; /* 悬停背景颜色 */
940 | border: none !important; /* 移除表格外边框 */
941 | border-collapse: collapse !important; /* 确保边框合并 */
942 | }
943 |
944 |
945 | td, th {
946 | padding: 4px !important;
947 | }
948 |
949 | .table {
950 | margin-bottom: 0;
951 | min-width: auto !important;
952 | }
953 |
954 | /* 确保表格容器有正确的宽度和溢出处理 */
955 | .table-responsive {
956 | width: 100%;
957 | overflow-x: hidden;
958 | }
959 |
960 |
961 |
962 | /* 专门设置表头样式 */
963 | .table thead,
964 | .table > thead{
965 | border: none !important;
966 | background: linear-gradient(to right, #0D6E6E, #4a9d9c) !important;
967 | }
968 |
969 | /* 确保表头单元格没有背景色 */
970 | .table thead tr,
971 | .table thead th {
972 | background: transparent !important; /* 确保tr和th是透明的 */
973 | border: none !important;
974 | color: #ffffff !important; /* 表头文字颜色 */
975 | }
976 |
977 |
978 |
979 |
980 | /* 记忆概率指示器样式 */
981 | .memory-indicator {
982 | display: inline-flex;
983 | align-items: center;
984 | padding: 4px 8px;
985 | border-radius: 12px;
986 | transition: all 0.3s ease;
987 | }
988 |
989 | .memory-indicator:hover {
990 | background-color: rgba(255, 255, 255, 0.1);
991 | transform: scale(1.05);
992 | }
993 |
994 | .memory-indicator i {
995 | font-size: 1.1em;
996 | }
997 |
998 | /* 颜色类 */
999 | .text-success { color: #4caf50 !important; }
1000 | .text-warning { color: #ff9800 !important; }
1001 | .text-danger { color: #f44336 !important; }
1002 |
1003 |
1004 |
1005 | /* 设置卡片样式调整 */
1006 | .option-card {
1007 | background-color: #1d2e3d;
1008 | border-radius: 10px;
1009 | padding: 20px;
1010 | text-align: center;
1011 | transition: all 0.3s ease;
1012 | }
1013 |
1014 | .option-card:hover {
1015 | transform: translateY(-5px);
1016 | box-shadow: 0 8px 16px rgba(74, 157, 156, 0.2);
1017 | }
1018 |
1019 | .option-card i {
1020 | font-size: 2em;
1021 | color: #4a9d9c;
1022 | margin-bottom: 15px;
1023 | }
1024 |
1025 | .option-card h4 {
1026 | color: #fff;
1027 | margin-bottom: 15px;
1028 | }
1029 |
1030 | .option-card p {
1031 | color: #888;
1032 | font-size: 0.9em;
1033 | }
1034 |
1035 | /* 表单控件样式 */
1036 | .form-select {
1037 | background-color: #0D1F2D;
1038 | color: #fff;
1039 | border: 1px solid #4a9d9c;
1040 | margin-top: 10px;
1041 | }
1042 |
1043 | .sync-tips {
1044 | margin-top: 10px;
1045 | font-size: 1.0em;
1046 | color: #888;
1047 | display: flex;
1048 | flex-direction: column;
1049 | gap: 5px;
1050 | }
1051 | .reminder-tips {
1052 | margin-top: 10px;
1053 | font-size: 1.0em;
1054 | color: #888;
1055 | display: flex;
1056 | flex-direction: column;
1057 | gap: 5px;
1058 | }
1059 | .save-section {
1060 | grid-column: 1 / -1;
1061 | text-align: center;
1062 | margin-top: 20px;
1063 | }
1064 |
1065 | /* 开关按钮样式 */
1066 | .form-check-input.custom-switch {
1067 | background-color: #0D1F2D !important;
1068 | border-color: #4a9d9c !important;
1069 | box-shadow: 0 0 12px rgba(74, 157, 156, 0.9);
1070 | transition: all 0.3s ease;
1071 | cursor: pointer !important;
1072 | pointer-events: auto !important;
1073 | opacity: 1;
1074 | z-index: 100;
1075 | position: relative;
1076 | outline: none !important;
1077 | /* 自定义滑块圆圈颜色为亮蓝色 */
1078 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2361dafb'/%3e%3c/svg%3e") !important;
1079 | }
1080 |
1081 | /* 选中状态下的样式 */
1082 | .form-check-input.custom-switch:checked {
1083 | background-color: #0D6E6E !important;
1084 | border-color: #0D6E6E !important;
1085 | box-shadow: 0 0 12px rgba(74, 157, 156, 0.9);
1086 | /* 选中状态下保持相同的蓝色圆圈 */
1087 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2361dafb'/%3e%3c/svg%3e") !important;
1088 | }
1089 |
1090 | /* 悬停状态加强发光效果 */
1091 | .form-check-input.custom-switch:hover {
1092 | box-shadow: 0 0 15px rgba(97, 218, 251, 0.9);
1093 | }
1094 |
1095 | /* 焦点状态 */
1096 | .form-check-input.custom-switch:focus {
1097 | background-color: inherit !important; /* 继承当前状态的背景色 */
1098 | border-color: #4a9d9c !important;
1099 | box-shadow: 0 0 12px rgba(74, 157, 156, 0.9);
1100 | outline: none !important;
1101 | }
1102 |
1103 | /* 选中且焦点状态 */
1104 | .form-check-input.custom-switch:checked:focus {
1105 | background-color: #4a9d9c !important;
1106 | }
1107 |
1108 |
1109 |
1110 |
1111 |
1112 | /* SweetAlert2 自定义样式 */
1113 | .colored-toast.swal2-icon-success {
1114 | background-color: #1d2e3d !important;
1115 | border: 1px solid #4a9d9c !important;
1116 | }
1117 |
1118 | .colored-toast .swal2-title {
1119 | color: #ffffff !important;
1120 | font-size: 1em !important;
1121 | }
1122 |
1123 | .colored-toast .swal2-close {
1124 | color: #4a9d9c !important;
1125 | }
1126 |
1127 | .colored-toast .swal2-html-container {
1128 | color: #888 !important;
1129 | font-size: 0.9em !important;
1130 | }
1131 |
1132 | /* 成功图标颜色 */
1133 | .colored-toast .swal2-success-line-tip,
1134 | .colored-toast .swal2-success-line-long {
1135 | background-color: #4a9d9c !important;
1136 | }
1137 |
1138 | .colored-toast .swal2-success-ring {
1139 | border-color: #4a9d9c !important;
1140 | }
1141 |
1142 | /* 更新概要样式 */
1143 | .update-badge {
1144 | background-color: #FF3D3D;
1145 | color: white;
1146 | padding: 2px 5px;
1147 | border-radius: 3px;
1148 | font-size: 0.8em;
1149 | margin-right: 5px;
1150 | font-weight: bold;
1151 | }
1152 |
1153 | .update-summary {
1154 | background-color: rgba(74, 157, 156, 0.1);
1155 | border-radius: 4px;
1156 | padding: 3px 0 !important;
1157 | margin-bottom: 8px !important;
1158 | }
1159 |
1160 | .update-summary a {
1161 | color: #4a9d9c;
1162 | text-decoration: none;
1163 | margin-left: 5px;
1164 | }
1165 |
1166 | .update-summary a:hover {
1167 | text-decoration: underline;
1168 | color: #afffff;
1169 | }
1170 |
1171 | /* 图标按钮样式 */
1172 | .btn-icon {
1173 | background: none;
1174 | border: none;
1175 | color: #4a9d9c;
1176 | font-size: 1em;
1177 | width: 28px;
1178 | height: 28px;
1179 | border-radius: 6px;
1180 | display: flex;
1181 | align-items: center;
1182 | justify-content: center;
1183 | cursor: pointer;
1184 | transition: all 0.3s ease;
1185 | background-color: transparent;
1186 | }
1187 |
1188 | .btn-icon:hover {
1189 | background-color: rgba(74, 157, 156, 0.1);
1190 | color: #61dafb;
1191 | transform: translateY(-1px);
1192 | }
1193 |
1194 | .btn-icon:active {
1195 | transform: translateY(0);
1196 | }
1197 |
1198 | .btn-icon-sm {
1199 | width: 24px;
1200 | height: 24px;
1201 | }
1202 |
1203 | /* 优化参数进度条样式 */
1204 | .optimize-progress {
1205 | height: 3px !important;
1206 | background-color: rgba(74, 157, 156, 0.1) !important;
1207 | border-radius: 4px !important;
1208 | margin-top: 12px !important;
1209 | overflow: hidden !important;
1210 | }
1211 |
1212 | .optimize-progress .progress-bar {
1213 | background: linear-gradient(90deg, #4a9d9c, #61dafb) !important;
1214 | transition: width 0.3s ease !important;
1215 | }
1216 |
1217 | .optimize-progress .progress-bar-animated {
1218 | animation: progress-bar-stripes 1s linear infinite !important;
1219 | }
1220 |
1221 | .optimize-progress .progress-bar-striped {
1222 | background-image: linear-gradient(
1223 | 45deg,
1224 | rgba(255, 255, 255, 0.15) 25%,
1225 | transparent 25%,
1226 | transparent 50%,
1227 | rgba(255, 255, 255, 0.15) 50%,
1228 | rgba(255, 255, 255, 0.15) 75%,
1229 | transparent 75%,
1230 | transparent
1231 | ) !important;
1232 | background-size: 1rem 1rem !important;
1233 | }
--------------------------------------------------------------------------------
/src/popup/popup.js:
--------------------------------------------------------------------------------
1 | import './popup.css';
2 | import { renderAll } from './view/view.js';
3 |
4 | console.log("Hello Leetcode-Mastery-Scheduler!");
5 | await renderAll();
--------------------------------------------------------------------------------
/src/popup/script/leetcode.js:
--------------------------------------------------------------------------------
1 | import { loadConfigs } from "../service/configService";
2 | import { addRecordButton } from "./submission";
3 |
4 | console.log(`Hello Leetcode-Mastery-Scheduler!`);
5 |
6 | await loadConfigs();
7 |
8 |
9 |
10 | document.addEventListener('DOMContentLoaded', addRecordButton);
11 |
12 | // 检查并确保按钮存在
13 | const ensureButton = () => {
14 | // 如果按钮不存在,添加按钮
15 | if (!document.querySelector('.Leetcode-Mastery-Scheduler-record-btn')) {
16 | addRecordButton();
17 | }
18 | };
19 |
20 | // 创建观察器实例
21 | const observer = new MutationObserver(() => {
22 | // 每次 DOM 变化时检查按钮
23 | ensureButton();
24 | });
25 |
26 | // 开始观察
27 | const startObserving = () => {
28 | if (document.body) {
29 | observer.observe(document.body, {
30 | childList: true, // 观察子节点变化
31 | subtree: true // 观察所有后代节点
32 | });
33 | // 初始检查
34 | ensureButton();
35 | } else {
36 | // 如果 body 还不存在,等待后重试
37 | setTimeout(startObserving, 100);
38 | }
39 | };
40 |
41 | // 启动观察
42 | startObserving();
43 |
44 | // 在页面卸载时停止观察
45 | window.addEventListener('unload', () => {
46 | observer.disconnect();
47 | });
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/popup/script/leetcodecn.js:
--------------------------------------------------------------------------------
1 | import { loadConfigs } from "../service/configService";
2 | import { addRecordButton } from "./submission";
3 |
4 | console.log(`Hello Leetcode-Mastery-Scheduler!`);
5 |
6 | await loadConfigs();
7 |
8 |
9 |
10 | document.addEventListener('DOMContentLoaded', addRecordButton);
11 |
12 | // 检查并确保按钮存在
13 | const ensureButton = () => {
14 | // 如果按钮不存在,添加按钮
15 | if (!document.querySelector('.Leetcode-Mastery-Scheduler-record-btn')) {
16 | addRecordButton();
17 | }
18 | };
19 |
20 | // 创建观察器实例
21 | const observer = new MutationObserver(() => {
22 | // 每次 DOM 变化时检查按钮
23 | ensureButton();
24 | });
25 |
26 | // 开始观察
27 | const startObserving = () => {
28 | if (document.body) {
29 | observer.observe(document.body, {
30 | childList: true, // 观察子节点变化
31 | subtree: true // 观察所有后代节点
32 | });
33 | // 初始检查
34 | ensureButton();
35 | } else {
36 | // 如果 body 还不存在,等待后重试
37 | setTimeout(startObserving, 100);
38 | }
39 | };
40 |
41 | // 启动观察
42 | startObserving();
43 |
44 | // 在页面卸载时停止观察
45 | window.addEventListener('unload', () => {
46 | observer.disconnect();
47 | });
48 |
--------------------------------------------------------------------------------
/src/popup/script/submission.js:
--------------------------------------------------------------------------------
1 | import { getDifficultyBasedSteps, getSubmissionResult, isSubmissionSuccess, isSubmitButton, needReview, updateProblemUponSuccessSubmission } from "../util/utils";
2 | import { getAllProblems, createOrUpdateProblem, getCurrentProblemInfoFromLeetCodeByHref,getCurrentProblemInfoFromLeetCodeByUrl, syncProblems } from "../service/problemService";
3 | import { Problem } from "../entity/problem";
4 | import { updateProblemWithFSRS } from "../service/fsrsService";
5 |
6 |
7 |
8 |
9 |
10 |
11 | export const addRecordButton = () => {
12 | // 添加按钮样式
13 | const style = document.createElement('style');
14 | style.textContent = `
15 | .Leetcode-Mastery-Scheduler-record-btn {
16 | position: fixed;
17 | bottom: 20px;
18 | right: 20px;
19 | padding: 8px 12px; /* 减小内边距 */
20 | background: #2563eb;
21 | color: white;
22 | border: none;
23 | border-radius: 6px; /* 稍微减小圆角 */
24 | cursor: pointer;
25 | font-size: 13px; /* 减小字体大小 */
26 | box-shadow: 0 2px 8px rgba(37, 99, 235, 0.2);
27 | transition: background 0.2s ease, box-shadow 0.2s ease;
28 | z-index: 9999;
29 | user-select: none;
30 | display: flex;
31 | align-items: center;
32 | line-height: 1;
33 | }
34 |
35 | .Leetcode-Mastery-Scheduler-record-btn:hover {
36 | box-shadow: 0 3px 10px rgba(37, 99, 235, 0.3);
37 | }
38 |
39 | .Leetcode-Mastery-Scheduler-record-btn.dragging {
40 | opacity: 0.8;
41 | cursor: grabbing;
42 | transition: none;
43 | }
44 |
45 | .Leetcode-Mastery-Scheduler-record-btn .drag-handle {
46 | display: inline-block;
47 | margin-right: 6px; /* 减小间距 */
48 | cursor: grab;
49 | opacity: 0.7;
50 | font-size: 12px; /* 减小拖动手柄大小 */
51 | }
52 |
53 | .Leetcode-Mastery-Scheduler-record-btn .drag-handle:hover {
54 | opacity: 1;
55 | }
56 |
57 | .Leetcode-Mastery-Scheduler-record-btn .reset-position {
58 | margin-left: 6px; /* 减小间距 */
59 | opacity: 0.7;
60 | cursor: pointer;
61 | font-size: 12px; /* 减小重置按钮大小 */
62 | }
63 |
64 | .Leetcode-Mastery-Scheduler-record-btn .reset-position:hover {
65 | opacity: 1;
66 | }
67 |
68 | .Leetcode-Mastery-Scheduler-record-btn .star-icon {
69 | margin-right: 4px;
70 | font-size: 11px;
71 | }
72 | `;
73 | document.head.appendChild(style);
74 |
75 | // 从localStorage获取保存的位置
76 | const savedPosition = JSON.parse(localStorage.getItem('LMS_rateButtonPosition') || '{"bottom": 20, "right": 20}');
77 |
78 | // 创建按钮
79 | const button = document.createElement('button');
80 | button.className = 'Leetcode-Mastery-Scheduler-record-btn';
81 | button.innerHTML = `
82 | ⋮
83 | Rate
84 | ↺
85 | `;
86 |
87 | // 设置保存的位置
88 | button.style.bottom = `${savedPosition.bottom}px`;
89 | button.style.right = `${savedPosition.right}px`;
90 |
91 | // 添加点击事件
92 | button.addEventListener('click', async (e) => {
93 | // 如果点击的是拖动手柄或重置按钮,不触发评分
94 | if (e.target.classList.contains('drag-handle') || e.target.classList.contains('reset-position')) {
95 | return;
96 | }
97 |
98 | const result = await handleFeedbackSubmission();
99 | if (result) {
100 | console.log("Submission successfully tracked!");
101 | console.log("难度记录成功!");
102 | }
103 | });
104 |
105 | // 重置位置
106 | const resetButton = button.querySelector('.reset-position');
107 | resetButton.addEventListener('click', (e) => {
108 | e.stopPropagation();
109 | button.style.bottom = '20px';
110 | button.style.right = '20px';
111 | localStorage.setItem('LMS_rateButtonPosition', JSON.stringify({bottom: 20, right: 20}));
112 | });
113 |
114 | // 添加拖拽功能
115 | let isDragging = false;
116 | let startX, startY, startBottom, startRight;
117 |
118 | const dragHandle = button.querySelector('.drag-handle');
119 |
120 | // 鼠标按下事件
121 | const onMouseDown = (e) => {
122 | e.preventDefault();
123 | e.stopPropagation();
124 |
125 | isDragging = true;
126 | button.classList.add('dragging');
127 |
128 | // 记录初始位置
129 | startX = e.clientX;
130 | startY = e.clientY;
131 | startBottom = parseInt(getComputedStyle(button).bottom);
132 | startRight = parseInt(getComputedStyle(button).right);
133 |
134 | // 添加鼠标移动和松开事件
135 | document.addEventListener('mousemove', onMouseMove);
136 | document.addEventListener('mouseup', onMouseUp);
137 | };
138 |
139 | // 鼠标移动事件
140 | const onMouseMove = (e) => {
141 | if (!isDragging) return;
142 |
143 | // 计算新位置
144 | const deltaX = startX - e.clientX;
145 | const deltaY = e.clientY - startY; // 修正垂直方向
146 |
147 | const newRight = Math.max(10, startRight + deltaX);
148 | const newBottom = Math.max(10, startBottom - deltaY);
149 |
150 | // 确保按钮不会超出屏幕
151 | const maxRight = window.innerWidth - button.offsetWidth - 10;
152 | const maxBottom = window.innerHeight - button.offsetHeight - 10;
153 |
154 | button.style.right = `${Math.min(newRight, maxRight)}px`;
155 | button.style.bottom = `${Math.min(newBottom, maxBottom)}px`;
156 | };
157 |
158 | // 鼠标松开事件
159 | const onMouseUp = () => {
160 | if (!isDragging) return;
161 |
162 | isDragging = false;
163 | button.classList.remove('dragging');
164 |
165 | // 保存新位置到localStorage
166 | localStorage.setItem('LMS_rateButtonPosition', JSON.stringify({
167 | bottom: parseInt(button.style.bottom),
168 | right: parseInt(button.style.right)
169 | }));
170 |
171 | // 移除事件监听器
172 | document.removeEventListener('mousemove', onMouseMove);
173 | document.removeEventListener('mouseup', onMouseUp);
174 | };
175 |
176 | // 添加事件监听器
177 | dragHandle.addEventListener('mousedown', onMouseDown);
178 |
179 | // 添加到页面
180 | document.body.appendChild(button);
181 |
182 | // 添加窗口大小变化监听器
183 | window.addEventListener('resize', () => {
184 | const buttonRect = button.getBoundingClientRect();
185 | const maxRight = window.innerWidth - button.offsetWidth - 10;
186 | const maxBottom = window.innerHeight - button.offsetHeight - 10;
187 |
188 | // 如果按钮超出可视区域,调整位置
189 | if (parseInt(button.style.right) > maxRight) {
190 | button.style.right = `${maxRight}px`;
191 | }
192 | if (parseInt(button.style.bottom) > maxBottom) {
193 | button.style.bottom = `${maxBottom}px`;
194 | }
195 |
196 | // 保存调整后的位置
197 | localStorage.setItem('LMS_rateButtonPosition', JSON.stringify({
198 | bottom: parseInt(button.style.bottom),
199 | right: parseInt(button.style.right)
200 | }));
201 | });
202 | };
203 |
204 |
205 | // 抽取成通用的处理函数
206 | export async function handleFeedbackSubmission(problem = null) {
207 | try {
208 | // 记录是否为页面提交
209 | const isPageSubmission = !problem;
210 |
211 | // 显示难度反馈弹窗
212 | const feedback = await showDifficultyFeedbackDialog().catch(error => {
213 | console.log(error); // "用户取消评分"
214 | return null; // 返回 null 表示用户取消
215 | });
216 |
217 | // 如果用户取消,直接返回
218 | if (!feedback) {
219 | return null;
220 | }
221 |
222 | // 如果没有传入 problem,说明是页面提交,需要获取题目信息
223 | if (!problem) {
224 | await syncProblems(); // 同步云端数据
225 | const { problemIndex, problemName, problemLevel, problemUrl } = await getCurrentProblemInfoFromLeetCodeByHref();
226 | const problems = await getAllProblems();
227 | problem = problems[problemIndex];
228 |
229 | if (!problem || problem.isDeleted == true) {
230 | problem = new Problem(problemIndex, problemName, problemLevel, problemUrl, Date.now(), getDifficultyBasedSteps(problemLevel)[0], Date.now());
231 | }
232 | }
233 |
234 | // 检查上次复习时间是否是今天,如果是则不允许再次复习
235 | if (problem.fsrsState && problem.fsrsState.lastReview) {
236 | const lastReviewDate = new Date(problem.fsrsState.lastReview);
237 | const today = new Date();
238 |
239 | // 比较年、月、日是否相同(考虑时区影响)
240 | if (lastReviewDate.getFullYear() === today.getFullYear() &&
241 | lastReviewDate.getMonth() === today.getMonth() &&
242 | lastReviewDate.getDate() === today.getDate()) {
243 |
244 | // 显示双语警告提示
245 | showToast("今天已经复习过这道题了,请明天再来!\nYou've already reviewed this problem today. Please come back tomorrow!", "warning");
246 | return null;
247 | }
248 | }
249 |
250 | problem = await updateProblemWithFSRS(problem, feedback);
251 | await createOrUpdateProblem(problem);
252 |
253 | // 只有在页面提交时才显示成功提示
254 | if (isPageSubmission) {
255 | // 计算下次复习时间与今天的天数差
256 | const nextReviewDate = new Date(problem.fsrsState.nextReview);
257 | const today = new Date();
258 | const diffTime = nextReviewDate.getTime() - today.getTime();
259 | const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
260 |
261 | // 显示复习成功提示,包含下次复习时间
262 | showToast(`复习成功!下次复习时间:${nextReviewDate.toLocaleDateString()}(${diffDays}天后)\nReview successful! Next review: ${nextReviewDate.toLocaleDateString()} (in ${diffDays} days)`, "success");
263 | }
264 |
265 | await syncProblems(); // 同步到云端
266 | console.log("提交成功!");
267 | return problem;
268 | } catch (error) {
269 | console.error("提交时出错:", error);
270 | return null;
271 | }
272 | }
273 |
274 | // 添加一个更醒目的提示框函数,支持不同类型的提示
275 | function showToast(message, type = "info", duration = 4000) {
276 | // 检查是否已存在toast样式
277 | if (!document.getElementById('lms-toast-style')) {
278 | const style = document.createElement('style');
279 | style.id = 'lms-toast-style';
280 | style.textContent = `
281 | .lms-toast {
282 | position: fixed;
283 | top: 20px;
284 | left: 50%;
285 | transform: translateX(-50%);
286 | padding: 12px 24px;
287 | border-radius: 4px;
288 | z-index: 10000;
289 | font-size: 14px;
290 | box-shadow: 0 4px 12px rgba(0,0,0,0.15);
291 | animation: lms-toast-in 0.3s ease;
292 | max-width: 80%;
293 | text-align: center;
294 | white-space: pre-line;
295 | font-weight: 500;
296 | }
297 |
298 | .lms-toast-info {
299 | background-color: #1890ff;
300 | color: white;
301 | border-left: 4px solid #096dd9;
302 | }
303 |
304 | .lms-toast-success {
305 | background-color: #52c41a;
306 | color: white;
307 | border-left: 4px solid #389e0d;
308 | }
309 |
310 | .lms-toast-warning {
311 | background-color: #ffd666;
312 | color: #874d00;
313 | border-left: 4px solid #faad14;
314 | font-weight: bold;
315 | }
316 |
317 | .lms-toast-error {
318 | background-color: #ff4d4f;
319 | color: white;
320 | border-left: 4px solid #cf1322;
321 | font-weight: bold;
322 | }
323 |
324 | @keyframes lms-toast-in {
325 | from {
326 | opacity: 0;
327 | transform: translate(-50%, -20px);
328 | }
329 | to {
330 | opacity: 1;
331 | transform: translate(-50%, 0);
332 | }
333 | }
334 |
335 | .lms-toast-icon {
336 | margin-right: 8px;
337 | font-weight: bold;
338 | }
339 | `;
340 | document.head.appendChild(style);
341 | }
342 |
343 | // 移除可能存在的旧提示
344 | const existingToast = document.querySelector('.lms-toast');
345 | if (existingToast) {
346 | existingToast.remove();
347 | }
348 |
349 | const toast = document.createElement('div');
350 | toast.className = `lms-toast lms-toast-${type}`;
351 |
352 | // 添加图标
353 | let icon = '';
354 | switch(type) {
355 | case 'info': icon = 'ℹ️'; break;
356 | case 'success': icon = '✅'; break;
357 | case 'warning': icon = '⚠️'; break;
358 | case 'error': icon = '❌'; break;
359 | }
360 |
361 | toast.innerHTML = `${icon} ${message}`;
362 | document.body.appendChild(toast);
363 |
364 | // 添加点击关闭功能
365 | toast.addEventListener('click', () => {
366 | toast.style.opacity = '0';
367 | toast.style.transition = 'opacity 0.3s ease';
368 | setTimeout(() => toast.remove(), 300);
369 | });
370 |
371 | setTimeout(() => {
372 | toast.style.opacity = '0';
373 | toast.style.transition = 'opacity 0.3s ease';
374 | setTimeout(() => toast.remove(), 300);
375 | }, duration);
376 | }
377 |
378 | // 6. 显示评分对话框
379 | const showDifficultyFeedbackDialog = () => {
380 | return new Promise((resolve) => {
381 | addDialogStyles();
382 |
383 | const overlay = document.createElement('div');
384 | overlay.className = 'fsrs-modal-overlay';
385 |
386 | const dialog = document.createElement('div');
387 | dialog.className = 'feedback-dialog';
388 | dialog.innerHTML = `
389 | ×
390 | How difficult was this problem for you?
391 |
392 | Very Hard
393 | Hard
394 | Medium
395 | Easy
396 |
397 | `;
398 | // 点击遮罩层关闭
399 | overlay.addEventListener('click', (e) => {
400 | if (e.target === overlay) {
401 | overlay.remove();
402 | resolve(null);
403 | }
404 | });
405 |
406 | // 单独设置关闭按钮的事件
407 | const closeButton = dialog.querySelector('.close-button');
408 | closeButton.addEventListener('click', () => {
409 | overlay.remove();
410 | resolve(null);
411 | });
412 |
413 | // 只为难度按钮设置事件
414 | dialog.querySelectorAll('.quality-buttons button').forEach(button => {
415 | button.addEventListener('click', () => {
416 | const quality = parseInt(button.dataset.quality);
417 | resolve({ quality });
418 | overlay.remove();
419 | });
420 | });
421 |
422 |
423 |
424 | overlay.appendChild(dialog);
425 | document.body.appendChild(overlay);
426 | });
427 | };
428 |
429 | // 7. 添加样式
430 | const addDialogStyles = () => {
431 | const style = document.createElement('style');
432 | style.textContent = `
433 | .fsrs-modal-overlay {
434 | position: fixed;
435 | top: 0;
436 | left: 0;
437 | right: 0;
438 | bottom: 0;
439 | background: rgba(0, 0, 0, 0.6);
440 | display: flex;
441 | justify-content: center;
442 | align-items: center;
443 | z-index: 9999;
444 | backdrop-filter: blur(2px);
445 | }
446 |
447 | .feedback-dialog {
448 | background: #ffffff;
449 | padding: 24px;
450 | border-radius: 12px;
451 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
452 | min-width: 320px;
453 | animation: slideIn 0.3s ease;
454 | }
455 |
456 | @keyframes slideIn {
457 | from {
458 | transform: translateY(-20px);
459 | opacity: 0;
460 | }
461 | to {
462 | transform: translateY(0);
463 | opacity: 1;
464 | }
465 | }
466 |
467 | .close-button {
468 | float: right; /* 使用浮动靠右 */
469 | margin: -12px -12px 0 0; /* 调整位置,抵消父元素的 padding */
470 | background: none;
471 | border: none;
472 | font-size: 24px;
473 | color: #666;
474 | cursor: pointer;
475 | padding: 0;
476 | width: 30px;
477 | height: 30px;
478 | display: flex;
479 | align-items: center;
480 | justify-content: center;
481 | border-radius: 50%;
482 | transition: all 0.2s;
483 | }
484 |
485 | .close-button:hover {
486 | background: #f3f4f6;
487 | color: #1f2937;
488 | }
489 |
490 | .feedback-dialog h3 {
491 | color: #2c3e50;
492 | font-size: 18px;
493 | margin: 0 0 20px 0; /* 添加底部间距 */
494 | text-align: center;
495 | font-weight: 600;
496 | clear: both; /* 清除浮动 */
497 | }
498 |
499 | .quality-buttons {
500 | display: flex;
501 | flex-direction: column;
502 | gap: 12px;
503 | }
504 |
505 | .quality-buttons button {
506 | padding: 12px 20px;
507 | border: none;
508 | border-radius: 8px;
509 | background: #f8f9fa;
510 | cursor: pointer;
511 | transition: all 0.2s ease;
512 | font-size: 15px;
513 | color: #495057;
514 | border: 1px solid #e9ecef;
515 | }
516 |
517 | .quality-buttons button:hover {
518 | background: #2563eb;
519 | color: white;
520 | transform: translateY(-1px);
521 | box-shadow: 0 2px 8px rgba(37, 99, 235, 0.2);
522 | }
523 |
524 | .quality-buttons button:nth-child(1) { border-left: 4px solid #dc2626; }
525 | .quality-buttons button:nth-child(2) { border-left: 4px solid #ea580c; }
526 | .quality-buttons button:nth-child(3) { border-left: 4px solid #16a34a; }
527 | .quality-buttons button:nth-child(4) { border-left: 4px solid #2563eb; }
528 |
529 | .quality-buttons button:nth-child(1):hover { background: #dc2626; }
530 | .quality-buttons button:nth-child(2):hover { background: #ea580c; }
531 | .quality-buttons button:nth-child(3):hover { background: #16a34a; }
532 | .quality-buttons button:nth-child(4):hover { background: #2563eb; }
533 | `;
534 | document.head.appendChild(style);
535 | };
536 |
537 |
538 |
539 |
540 |
541 |
542 | // 处理新建题目 - 设置为今天待复习
543 | export async function handleAddProblem(url) {
544 | try {
545 | await syncProblems(); // 同步云端数据
546 | const problems = await getAllProblems();
547 |
548 | // 使用新的API获取题目信息
549 | const problemInfo = await getCurrentProblemInfoFromLeetCodeByUrl(url);
550 |
551 | const { problemIndex, problemName, problemLevel, problemUrl } = problemInfo;
552 |
553 | // 检查是否已存在
554 | if (problems[problemIndex] && !problems[problemIndex].isDeleted) {
555 | throw new Error('Duplicate problem name exists.');
556 | }
557 |
558 | const now = Date.now();
559 | // 创建新问题
560 | const problem = new Problem(
561 | problemIndex,
562 | problemName,
563 | problemLevel,
564 | problemUrl,
565 | now, // createTime
566 | 0, // nextStep
567 | null // lastReviewTime
568 | );
569 |
570 | // 设置初始状态
571 | problem.proficiency = 0;
572 | problem.isDeleted = false;
573 | problem.modificationTime = now;
574 |
575 | // 设置初始 FSRS 状态 - 设置 nextReview 为今天
576 | problem.fsrsState = {
577 | difficulty: null,
578 | stability: null,
579 | state: 'New',
580 | lastReview: null,
581 | nextReview: now, // 设置为当前时间,使其显示在今天的待复习列表中
582 | reviewCount: 0,
583 | lapses: 0,
584 | quality: null
585 | };
586 |
587 | await createOrUpdateProblem(problem);
588 | await syncProblems();
589 |
590 | return problem;
591 | } catch (error) {
592 | console.error('Failed to add card:', error);
593 | throw error;
594 | }
595 | }
596 |
597 | // 处理添加空白卡片
598 | export async function handleAddBlankProblem(name, level, customUrl = '') {
599 | try {
600 | await syncProblems(); // 同步云端数据
601 | const problems = await getAllProblems();
602 |
603 | // 获取当前自定义题目的数量,用于生成递增的索引
604 | const customProblems = Object.values(problems).filter(p =>
605 | p.index && p.index.startsWith('custom_') && !p.isDeleted);
606 | const customCount = customProblems.length + 1;
607 |
608 | // 生成有规律的索引: custom_年月日_序号
609 | const today = new Date();
610 | const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
611 | const customIndex = `custom_${dateStr}_${String(customCount).padStart(3, '0')}`;
612 |
613 | // 检查名称是否已存在
614 | const existingProblem = Object.values(problems).find(p =>
615 | p.name === name && !p.isDeleted);
616 |
617 | if (existingProblem) {
618 | throw new Error('Duplicate problem name exists.');
619 | }
620 |
621 | const now = Date.now();
622 | // 创建新问题,在名称前添加索引前缀
623 | const formattedName = `Ext-${customCount}. ${name}`;
624 |
625 | const problem = new Problem(
626 | customIndex,
627 | formattedName, // 名称前添加索引前缀
628 | level,
629 | customUrl,
630 | now, // createTime
631 | 0, // nextStep
632 | null // lastReviewTime
633 | );
634 |
635 | // 设置初始状态
636 | problem.proficiency = 0;
637 | problem.isDeleted = false;
638 | problem.modificationTime = now;
639 | problem.isCustom = true; // 标记为自定义题目
640 |
641 | // 设置初始 FSRS 状态 - 设置 nextReview 为今天
642 | problem.fsrsState = {
643 | difficulty: null,
644 | stability: null,
645 | state: 'New',
646 | lastReview: null,
647 | nextReview: now, // 设置为当前时间,使其显示在今天的待复习列表中
648 | reviewCount: 0,
649 | lapses: 0,
650 | quality: null
651 | };
652 |
653 | await createOrUpdateProblem(problem);
654 | await syncProblems();
655 |
656 | return problem;
657 | } catch (error) {
658 | console.error('Failed to add blank card:', error);
659 | throw error;
660 | }
661 | }
662 |
663 |
--------------------------------------------------------------------------------
/src/popup/service/configService.js:
--------------------------------------------------------------------------------
1 | import { getLocalStorageData, setLocalStorageData } from "../delegate/localStorageDelegate"
2 | import { store } from "../store";
3 | import { CONFIG_INNER_KEY_ENABLE_CLOUD, CONFIG_KEY, PROBLEM_SORT_BY_KEY, REVIEW_INTV_KEY,DEFAULT_CARD_LIMIT_KEY, DEFAULT_CARD_LIMIT_VALUE } from "../util/keys"
4 | import { getSorterById, idOf, problemSorters } from "../util/sort";
5 |
6 | // configurable review intervals (to be integrated)
7 |
8 | export const getReviewIntervals = async () => {
9 | return await getLocalStorageData(REVIEW_INTV_KEY);
10 | }
11 |
12 | export const setReviewIntervals = async (customIntv) => {
13 | if (customIntv == null || customIntv == undefined) return;
14 | const {easyIntv, mediumIntv, hardIntv} = store;
15 | customIntv.easyIntv = customIntv.easyIntv || easyIntv;
16 | customIntv.mediumIntv = customIntv.mediumIntv || mediumIntv;
17 | customIntv.hardIntv = customIntv.hardIntv || hardIntv;
18 | await setLocalStorageData(REVIEW_INTV_KEY, customIntv);
19 | }
20 |
21 | export const loadReviewIntervals = async () => {
22 | const customIntv = await getReviewIntervals();
23 | if (customIntv !== undefined) {
24 | Object.assign(store, customIntv);
25 | }
26 | }
27 |
28 |
29 | // configurable problem sort by
30 | export const getProblemSorter = async () => {
31 | return await getLocalStorageData(PROBLEM_SORT_BY_KEY);
32 | }
33 |
34 | export const setProblemSorter = async (sorterId) => {
35 | await setLocalStorageData(PROBLEM_SORT_BY_KEY, sorterId);
36 | }
37 |
38 | export const loadProblemSorter = async () => {
39 | const sorterId = await getProblemSorter() | idOf(problemSorters.sortByReviewTimeAsc);
40 | store.problemSortBy = getSorterById(sorterId);
41 | }
42 |
43 |
44 |
45 | // config cloud sync
46 | export const isCloudSyncEnabled = async () => {
47 | const configs = await getLocalStorageData(CONFIG_KEY);
48 | const isEnabled = configs !== undefined ? configs[CONFIG_INNER_KEY_ENABLE_CLOUD] : false;
49 | if (isEnabled === undefined) {
50 | isEnabled = false;
51 | }
52 | return isEnabled;
53 | }
54 |
55 | export const switchCloudSyncEnabled = async () => {
56 | const configs = await getLocalStorageData(CONFIG_KEY);
57 | const isEnabled = configs[CONFIG_INNER_KEY_ENABLE_CLOUD];
58 | if (isEnabled === undefined) {
59 | isEnabled = false;
60 | }
61 | configs[CONFIG_INNER_KEY_ENABLE_CLOUD] = !isEnabled;
62 | await setLocalStorageData(CONFIG_KEY, configs);
63 | }
64 |
65 | export const setCloudSyncEnabled = async (isEnabled) => {
66 | const configs = await getLocalStorageData(CONFIG_KEY) || {
67 | CONFIG_INNER_KEY_ENABLE_CLOUD: false
68 | };
69 | configs[CONFIG_INNER_KEY_ENABLE_CLOUD] = isEnabled;
70 | await setLocalStorageData(CONFIG_KEY, configs);
71 | }
72 |
73 |
74 | export const loadCloudSyncConfig = async () => {
75 | store.isCloudSyncEnabled = await isCloudSyncEnabled();
76 | }
77 |
78 | // 获取默认卡片数量
79 | export const getDefaultCardLimit = async () => {
80 | const limit = await getLocalStorageData(DEFAULT_CARD_LIMIT_KEY);
81 | return limit !== undefined ? limit : DEFAULT_CARD_LIMIT_VALUE;
82 | }
83 |
84 | // 设置默认卡片数量
85 | export const setDefaultCardLimit = async (limit) => {
86 | if (limit == null || limit == undefined) return;
87 | await setLocalStorageData(DEFAULT_CARD_LIMIT_KEY, limit);
88 | }
89 |
90 | // 加载默认卡片数量到 store
91 | export const loadDefaultCardLimit = async () => {
92 | store.defaultCardLimit = await getDefaultCardLimit();
93 | }
94 |
95 | // 添加新的配置项和方法
96 | export async function setReminderEnabled(enabled) {
97 | await chrome.storage.local.set({ reminderEnabled: enabled });
98 | }
99 |
100 | export async function isReminderEnabled() {
101 | const { reminderEnabled } = await chrome.storage.local.get('reminderEnabled');
102 | return reminderEnabled || false;
103 | }
104 | // 添加加载提醒设置到 store 的函数
105 | export const loadReminderConfig = async () => {
106 | store.isReminderEnabled = await isReminderEnabled();
107 | }
108 |
109 | // 更新 loadConfigs 函数
110 | export const loadConfigs = async () => {
111 | await loadReviewIntervals();
112 | await loadProblemSorter();
113 | await loadCloudSyncConfig();
114 | await loadDefaultCardLimit();
115 | await loadReminderConfig(); // 添加这一行
116 | }
--------------------------------------------------------------------------------
/src/popup/service/fsrsService.js:
--------------------------------------------------------------------------------
1 | import { FSRS, Rating, S_MIN, State, TypeConvert, createEmptyCard } from 'ts-fsrs';
2 | import { defaultParams, qualityToRating, getFSRSParams, saveFSRSParams, saveRevlog, getAllRevlogs, exportRevlogsToCSV } from '../util/fsrs.js';
3 | import { optimizeFSRSParams } from '../delegate/fsrsDelegate.js';
4 | import { syncLocalAndCloudStorage } from '../util/utils.js';
5 | import localStorageDelegate from '../delegate/localStorageDelegate.js';
6 | import { store } from "../store";
7 | import { mergeFSRSParams, mergeRevlogs } from '../util/utils';
8 |
9 |
10 |
11 | // 创建FSRS实例
12 | let fsrsInstance = null;
13 |
14 | // 获取FSRS实例
15 | export const getFSRSInstance = async () => {
16 | if (fsrsInstance) {
17 | return fsrsInstance;
18 | }
19 |
20 | // 获取本地参数
21 | const localParams = await getFSRSParams();
22 |
23 | // 创建新的FSRS实例
24 | fsrsInstance = new FSRS(localParams);
25 | console.log('创建新的FSRS实例,参数:', localParams);
26 |
27 | return fsrsInstance;
28 | };
29 |
30 | // 更新FSRS实例
31 | export const updateFSRSInstance = async (newParams) => {
32 | // 创建新的FSRS实例
33 | fsrsInstance = new FSRS(newParams);
34 | console.log('更新FSRS实例,新参数:', newParams);
35 |
36 | return fsrsInstance;
37 | };
38 |
39 | // 计算下次复习时间
40 | export const calculateNextReview = async (problem, feedback) => {
41 | try {
42 | const now = new Date();
43 |
44 | // 确保有一个有效的 lastReview 日期
45 | let lastReview;
46 | if (problem.fsrsState && problem.fsrsState.lastReview) {
47 | lastReview = new Date(problem.fsrsState.lastReview);
48 | } else if (problem.submissionTime) {
49 | lastReview = new Date(problem.submissionTime);
50 | } else {
51 | lastReview = new Date(now.getTime()); // 默认为昨天
52 | }
53 |
54 | // 检查日期是否有效
55 | if (isNaN(lastReview.getTime())) {
56 | lastReview = new Date(now.getTime()); // 如果无效,使用昨天
57 | }
58 |
59 | // 如果没有 fsrsState,创建一个默认的
60 | if (!problem.fsrsState) {
61 | problem.fsrsState = createEmptyCard(lastReview, (card) => {
62 | return {
63 | nextReview: +card.due,
64 | stability: card.stability,
65 | difficulty: card.difficulty,
66 | state: card.state,
67 | reviewCount: card.reps,
68 | lapses: card.lapses,
69 | lastReview: +lastReview // 存储为时间戳
70 | }
71 | });
72 | }
73 | let card = problem.fsrsState;
74 |
75 | // 确保 nextReview 有效
76 | if (!card.nextReview || isNaN(card.nextReview)) {
77 | card.nextReview = +lastReview; // 默认为一天后
78 | }
79 |
80 | const rating = qualityToRating(feedback.quality);
81 |
82 | // 确保所有参数都有有效值
83 | const scheduledDays = Math.max(0, Math.floor((card.nextReview - card.lastReview) / (1000 * 60 * 60 * 24)));
84 | const elapsedDays = Math.max(0, (now.getTime() - lastReview.getTime()) / (1000 * 60 * 60 * 24));
85 |
86 | // 获取FSRS实例
87 | const fsrs = await getFSRSInstance();
88 |
89 | const result = fsrs.next({
90 | due: card.nextReview,
91 | stability: card.stability,
92 | difficulty: card.difficulty,
93 | elapsed_days: elapsedDays,
94 | scheduled_days: scheduledDays,
95 | reps: card.reviewCount,
96 | lapse_count: card.lapses,
97 | state: card.state,
98 | last_review: lastReview, // 使用已经转换好的 Date 对象
99 | }, now, rating);
100 |
101 | return {
102 | /**长期调度模式,ivl一定大于1d */
103 | nextReview: +result.card.due,
104 | stability: result.card.stability,
105 | difficulty: result.card.difficulty,
106 | state: result.card.state,
107 | reviewCount: result.card.reps,
108 | lapses: result.card.lapses
109 | };
110 | } catch (error) {
111 | console.error('Error in calculateNextReview:', error);
112 | const now = new Date(); // 在 catch 块中定义 now 变量
113 | return {
114 | nextReview: now.getTime() + (24 * 60 * 60 * 1000),
115 | stability: problem.fsrsState.stability || S_MIN,
116 | /** ref: https://github.com/open-spaced-repetition/ts-fsrs/blob/5eabd189d4740027ce1018cc968e67ca46c048a3/src/fsrs/default.ts#L20-L40 */
117 | difficulty: problem.fsrsState.difficulty || defaultParams.w[4],
118 | /** 长期调度下状态一定是New或Review */
119 | state: problem.fsrsState.state || State.Review,
120 | reviewCount: (problem.fsrsState.reviewCount || 0) + 1,
121 | lapses: problem.fsrsState.lapses || 0
122 | };
123 | }
124 | };
125 |
126 | // 更新问题状态
127 | export const updateProblemWithFSRS = async (problem, feedback) => {
128 | const now = Date.now();
129 | const fsrsResult = await calculateNextReview(problem, feedback);
130 |
131 | // 创建新的复习日志条目,只包含必要字段
132 | const newRevlog = {
133 | card_id: problem.index, // 使用问题索引作为卡片ID
134 | review_time: now, // 复习时间(毫秒时间戳)
135 | review_rating: qualityToRating(feedback.quality), // 复习评分 (1-4)
136 | review_state: TypeConvert.state(problem.fsrsState ? problem.fsrsState?.state ?? State.New : 'New') // 复习状态 (0-3)
137 | };
138 |
139 | // 将复习日志存储到单独的 localStorage 键中
140 | await saveRevlog(problem.index, newRevlog);
141 |
142 | // 更新问题状态(不修改原有结构)
143 | problem.fsrsState = {
144 | ...problem.fsrsState,
145 | difficulty: fsrsResult.difficulty,
146 | stability: fsrsResult.stability,
147 | state: fsrsResult.state,
148 | lastReview: now,
149 | nextReview: fsrsResult.nextReview,
150 | reviewCount: fsrsResult.reps,
151 | lapses: fsrsResult.lapses,
152 | quality: feedback.quality
153 | };
154 |
155 | problem.modificationTime = now;
156 | return problem;
157 | };
158 |
159 | // 获取复习记录数量
160 | export const getRevlogCount = async () => {
161 | try {
162 | const allRevlogs = await getAllRevlogs();
163 | let totalCount = 0;
164 |
165 | // 计算所有卡片的复习记录总数
166 | Object.values(allRevlogs).forEach(cardRevlogs => {
167 | totalCount += cardRevlogs.length;
168 | });
169 |
170 | return totalCount;
171 | } catch (error) {
172 | console.error('Error getting revlog count:', error);
173 | return 0;
174 | }
175 | };
176 |
177 | // 优化FSRS参数
178 | export const optimizeParameters = async (onProgress) => {
179 | try {
180 | // 获取并导出CSV格式的复习记录
181 | const csvContent = await exportRevlogsToCSV();
182 |
183 | // 调用API进行参数优化
184 | const result = await optimizeFSRSParams(csvContent, onProgress);
185 |
186 | // 检查结果是否包含params字段(来自done标签)
187 | if (result && result.params) {
188 | console.log('获取到优化后的FSRS参数:', result.params);
189 |
190 | // 不再自动保存参数,而是返回结果供用户确认
191 | return {
192 | type: 'Success',
193 | params: result.params,
194 | metrics: result.metrics || {}
195 | };
196 | }
197 |
198 | // 如果是进度信息
199 | if (result && result.type === 'Progress') {
200 | return result;
201 | }
202 |
203 | // 如果是训练结果
204 | if (result && result.type === 'Train') {
205 | return {
206 | type: 'Train',
207 | message: '训练完成,但未获取到完整参数'
208 | };
209 | }
210 |
211 | // 其他情况
212 | return result;
213 | } catch (error) {
214 | console.error('Error optimizing parameters:', error);
215 | throw error;
216 | }
217 | };
218 |
219 | // 同步FSRS历史记录
220 | export const syncFSRSHistory = async () => {
221 | try {
222 | // 检查是否启用了云同步
223 | if (!store.isCloudSyncEnabled) {
224 | console.log('云同步未启用,跳过FSRS历史记录同步');
225 | return;
226 | }
227 |
228 | // 同步FSRS参数和复习日志
229 | await syncFSRSParams();
230 | await syncRevlogs();
231 |
232 | // 更新FSRS实例
233 | const updatedParams = await getFSRSParams();
234 | await updateFSRSInstance(updatedParams);
235 |
236 | console.log('FSRS历史记录同步完成');
237 | } catch (error) {
238 | console.error('同步FSRS历史记录失败:', error);
239 | }
240 | };
241 |
242 |
243 | export const syncFSRSParams = async () => {
244 | if (!store.isCloudSyncEnabled) return;
245 | await syncLocalAndCloudStorage('fsrs_params', mergeFSRSParams);
246 | }
247 |
248 | export const syncRevlogs = async () => {
249 | if (!store.isCloudSyncEnabled) return;
250 | await syncLocalAndCloudStorage('fsrs_revlogs', mergeRevlogs);
251 | }
--------------------------------------------------------------------------------
/src/popup/service/modeService.js:
--------------------------------------------------------------------------------
1 | import { getLocalStorageData, setLocalStorageData } from "../delegate/localStorageDelegate"
2 | import { CN_MODE } from "../util/keys"
3 |
4 | export const isInCnMode = async () => {
5 | let cnMode = await getLocalStorageData(CN_MODE);
6 | console.log(`current cnMode is ${cnMode}`);
7 | if (cnMode === undefined) {
8 | await setLocalStorageData(CN_MODE, false);
9 | cnMode = false;
10 | }
11 | return cnMode;
12 | }
13 |
14 | export const toggleMode = async () => {
15 | const cnMode = await isInCnMode();
16 | console.log(`got current cnMode before toggle}`);
17 | await setLocalStorageData(CN_MODE, !cnMode);
18 | console.log("cnMode toggled");
19 | }
--------------------------------------------------------------------------------
/src/popup/service/operationHistoryService.js:
--------------------------------------------------------------------------------
1 | import { OperationHistory } from "../entity/operationHistory"
2 | import { isInCnMode } from "./modeService";
3 | import { OPS_HISTORY_KEY } from "../util/keys";
4 | import { getLocalStorageData, setLocalStorageData } from "../delegate/localStorageDelegate";
5 | import { getProblemsByMode, setProblemsByMode } from "./problemService";
6 | import { copy } from "../entity/problem";
7 |
8 | const CACHE_SIZE = 10;
9 |
10 | export const addNewOperationHistory = async (before, type, time) => {
11 | const snapShot = copy(before);
12 | snapShot.isDeleted = false;
13 | const newOperationHistory = new OperationHistory(snapShot, await isInCnMode(), type, time);
14 | let opsHistory = await getLocalStorageData(OPS_HISTORY_KEY);
15 | if (opsHistory === undefined) {
16 | opsHistory = [];
17 | }
18 | if (opsHistory.length === CACHE_SIZE) {
19 | opsHistory.shift();
20 | }
21 | opsHistory.push(newOperationHistory);
22 | await setLocalStorageData(OPS_HISTORY_KEY, opsHistory);
23 | }
24 |
25 | export const popLatestOperationHistory = async () => {
26 | const opsHistory = await getLocalStorageData(OPS_HISTORY_KEY);
27 | if (opsHistory === undefined || opsHistory.length === 0) {
28 | return undefined;
29 | }
30 |
31 | const latestOpsHistory = opsHistory.pop();
32 | await setLocalStorageData(OPS_HISTORY_KEY, opsHistory);
33 | return latestOpsHistory;
34 | }
35 |
36 | export const undoLatestOperation = async () => {
37 | const operationHistory = await popLatestOperationHistory();
38 | if (operationHistory === undefined) {
39 | return;
40 | }
41 | const { before: problemBefore, isInCnMode } = operationHistory;
42 | problemBefore.modificationTime = Date.now(); // need to update the mod time to make this latest change to override cloud data
43 |
44 | const problems = await getProblemsByMode(isInCnMode);
45 | problems[problemBefore.index] = problemBefore;
46 | await setProblemsByMode(problems, isInCnMode);
47 | }
48 |
49 | export const hasOperationHistory = async () => {
50 | const opsHistory = await getLocalStorageData(OPS_HISTORY_KEY);
51 | return opsHistory !== undefined && opsHistory.length > 0;
52 | }
--------------------------------------------------------------------------------
/src/popup/service/problemService.js:
--------------------------------------------------------------------------------
1 | import { getProblemInfoByHref,getProblemInfoByUrl } from "../delegate/leetCodeDelegate";
2 | import { getLocalStorageData, setLocalStorageData } from "../delegate/localStorageDelegate";
3 | import { addNewOperationHistory } from "./operationHistoryService";
4 | import { OPS_TYPE } from "../entity/operationHistory";
5 | import { forggettingCurve } from "../util/constants";
6 | import { CN_PROBLEM_KEY, PROBLEM_KEY } from "../util/keys";
7 | import { isInCnMode } from "./modeService";
8 | import { store } from "../store";
9 | import { mergeProblems, syncLocalAndCloudStorage } from "../util/utils";
10 | import cloudStorageDelegate from "../delegate/cloudStorageDelegate";
11 | import { copy, getDeletedProblem } from "../entity/problem";
12 |
13 | export const getAllProblems = async () => {
14 | let cnMode = await isInCnMode();
15 | const queryKey = cnMode ? CN_PROBLEM_KEY : PROBLEM_KEY;
16 | let problems = await getLocalStorageData(queryKey);
17 | if (problems === undefined) problems = {};
18 | return problems;
19 | }
20 |
21 | export const getAllProblemsInCloud = async () => {
22 | let cnMode = await isInCnMode();
23 | const queryKey = cnMode ? CN_PROBLEM_KEY : PROBLEM_KEY;
24 | let problems = await cloudStorageDelegate.get(queryKey);
25 | if (problems === undefined) problems = {};
26 | return problems;
27 | }
28 |
29 | export const getProblemsByMode = async (useCnMode) => {
30 | const queryKey = useCnMode ? CN_PROBLEM_KEY : PROBLEM_KEY;
31 | let problems = await getLocalStorageData(queryKey);
32 | if (problems === undefined) problems = {};
33 | return problems;
34 | }
35 |
36 | // 从当前页面获取题目信息
37 | export const getCurrentProblemInfoFromLeetCodeByHref = async () => {
38 | return await getProblemInfoByHref();
39 | }
40 |
41 | // 从指定URL获取题目信息
42 | export const getCurrentProblemInfoFromLeetCodeByUrl = async (url) => {
43 | return await getProblemInfoByUrl(url);
44 | }
45 |
46 |
47 | export const setProblems = async (problems) => {
48 | let cnMode = await isInCnMode();
49 | const key = cnMode ? CN_PROBLEM_KEY : PROBLEM_KEY;
50 | await setLocalStorageData(key, problems);
51 | }
52 |
53 | export const setProblemsToCloud = async (problems) => {
54 | let cnMode = await isInCnMode();
55 | const key = cnMode ? CN_PROBLEM_KEY : PROBLEM_KEY;
56 | await cloudStorageDelegate.set(key, problems);
57 | }
58 |
59 | export const setProblemsByMode = async (problems, useCnMode) => {
60 | const key = useCnMode ? CN_PROBLEM_KEY : PROBLEM_KEY;
61 | await setLocalStorageData(key, problems);
62 | }
63 |
64 | export const createOrUpdateProblem = async (problem) => {
65 | problem.modificationTime = Date.now();
66 | const problems = await getAllProblems();
67 | problems[problem.index] = problem;
68 | await setProblems(problems);
69 | }
70 |
71 | export const markProblemAsMastered = async (problemId) => {
72 | let problems = await getAllProblems();
73 | let problem = problems[problemId];
74 |
75 | await addNewOperationHistory(problem, OPS_TYPE.MASTER, Date.now());
76 |
77 | problem.proficiency = forggettingCurve.length;
78 | problem.modificationTime = Date.now();
79 |
80 | problems[problemId] = problem;
81 |
82 | await setProblems(problems);
83 | };
84 |
85 | export const deleteProblem = async (problemId) => {
86 |
87 | let problems = await getAllProblems();
88 | const problem = problems[problemId];
89 |
90 | // soft delete
91 | if (problem) {
92 | problem.isDeleted = true;
93 | problem.modificationTime = Date.now();
94 | await addNewOperationHistory(problem, OPS_TYPE.DELETE, Date.now());
95 | problems[problemId] = problem;
96 | await setProblems(problems);
97 | }
98 | };
99 |
100 | export const resetProblem = async (problemId) => {
101 | let problems = await getAllProblems();
102 | let problem = problems[problemId];
103 |
104 | problem.proficiency = 0;
105 | problem.submissionTime = Date.now() - 24 * 60 * 60 * 1000;
106 | problem.modificationTime = Date.now();
107 |
108 | await addNewOperationHistory(problem, OPS_TYPE.RESET, Date.now());
109 |
110 | problems[problemId] = problem;
111 |
112 | await setProblems(problems);
113 | };
114 |
115 | export const syncProblems = async () => {
116 | if (!store.isCloudSyncEnabled) return;
117 | let cnMode = await isInCnMode();
118 | const key = cnMode ? CN_PROBLEM_KEY : PROBLEM_KEY;
119 | await syncLocalAndCloudStorage(key, mergeProblems);
120 | }
--------------------------------------------------------------------------------
/src/popup/store.js:
--------------------------------------------------------------------------------
1 | import { problemSorters } from "./util/sort";
2 |
3 | export const store = {
4 | needReviewProblems: null,
5 | reviewScheduledProblems: null,
6 | completedProblems: null,
7 | toReviewPage: 1,
8 | scheduledPage: 1,
9 | completedPage: 1,
10 | toReviewMaxPage: null,
11 | scheduledMaxPage: null,
12 | completedMaxPage: null,
13 | tooltipTriggerList: null,
14 | tooltipList: null,
15 | easyIntv: [1, 3],
16 | mediumIntv: [1, 3, 4],
17 | hardIntv: [0, 1, 2, 3, 4],
18 | problemSortBy: problemSorters.sortByReviewTimeAsc,
19 | isCloudSyncEnabled: false,
20 | defaultCardLimit: 1,
21 | isReminderEnabled: false
22 | }
23 |
24 | export const daily_store = {
25 | dailyReviewProblems: null,
26 | reviewScheduledProblems: null
27 |
28 | }
--------------------------------------------------------------------------------
/src/popup/util/constants.js:
--------------------------------------------------------------------------------
1 | export const forggettingCurve = [
2 | 1 * 24 * 60, // 1 day
3 | 2 * 24 * 60, // 2 day
4 | 4 * 24 * 60, // 4 day
5 | 7 * 24 * 60, // 7 day
6 | 15 * 24 * 60 // 15 day
7 | ];
8 |
9 | export const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
10 |
11 | export const PAGE_SIZE = 5;
12 |
13 | export const CN_LABLE = 'LeetCode - China ';
14 | export const GL_LABLE = 'LeetCode - Global';
15 |
16 | export const SUBMIT_BUTTON_ATTRIBUTE_NAME = "data-e2e-locator";
17 | export const SUBMIT_BUTTON_ATTRIBUTE_VALUE = "console-submit-button";
18 |
19 | // leetcode UI classnames
20 |
21 | // cn
22 | export const SUCCESS_CLASSNAME_CN = "text-green-s dark:text-dark-green-s flex flex-1 items-center gap-2 text-[16px] font-medium leading-6";
23 | export const WRONG_ANSWER_CLASSNAME_CN = "whitespace-nowrap text-xl font-medium text-red-s dark:text-dark-red-s";
24 | export const COMPILE_ERROR_AND_TLE_CLASSNAME_CN = "mr-1 flex-1 whitespace-nowrap text-xl font-medium text-red-s dark:text-dark-red-s";
25 |
26 | // global
27 | // old UI
28 | export const SUCCESS_CLASSNAME = "success__3Ai7";
29 | export const WRONG_ANSWER_CLASSNAME = "error__2Ft1";
30 | export const COMPILE_ERROR_AND_TLE_CLASSNAME = "error__10k9";
31 |
32 | // new UI
33 | export const SUCCESS_CLASSNAME_NEW = "text-green-s dark:text-dark-green-s flex flex-1 items-center gap-2 text-[16px] font-medium leading-6";
34 | export const WRONG_ANSWER_CLASSNAME_NEW = "whitespace-nowrap text-xl font-medium text-red-s dark:text-dark-red-s";
35 | export const COMPILE_ERROR_AND_TLE_CLASSNAME_NEW = "mr-1 flex-1 whitespace-nowrap text-xl font-medium text-red-s dark:text-dark-red-s";
--------------------------------------------------------------------------------
/src/popup/util/doms.js:
--------------------------------------------------------------------------------
1 | export const input0DOM = document.getElementById("pageInput0");
2 | export const inputLabel0DOM = document.getElementById("pageInputLabel0");
3 | export const prevButton0DOM = document.getElementById("prevButton0");
4 | export const nextButton0DOM = document.getElementById("nextButton0");
5 |
6 | export const input1DOM = document.getElementById("pageInput1");
7 | export const inputLabel1DOM = document.getElementById("pageInputLabel1");
8 | export const prevButton1DOM = document.getElementById("prevButton1");
9 | export const nextButton1DOM = document.getElementById("nextButton1");
10 |
11 | export const input2DOM = document.getElementById("pageInput2");
12 | export const inputLabel2DOM = document.getElementById("pageInputLabel2");
13 | export const prevButton2DOM = document.getElementById("prevButton2");
14 | export const nextButton2DOM = document.getElementById("nextButton2");
15 |
16 | export const needReviewTableDOM = document.getElementById("need-review-table");
17 | export const noReviewTableDOM = document.getElementById("no-review-table");
18 | export const completedTableDOM = document.getElementById("completed-table");
19 |
20 | export const checkButtonDOMs = document.getElementsByClassName("check-btn-mark");
21 | export const deleteButtonDOMs = document.getElementsByClassName("delete-btn-mark");
22 | export const resetButtonDOMs = document.getElementsByClassName("reset-btn-mark");
23 | export const undoButtonDOMs = document.getElementsByClassName("undo-ops-btn");
24 | export const configButtonDOMs = document.getElementsByClassName("config-btn");
25 |
26 | export const siteLabelDOM = document.getElementById("siteLabel");
27 | export const switchButtonDOM = document.getElementById('switchButton');
28 |
29 | export const optionPageFeedbackMsgDOM = document.getElementById('feedbackMessage');
30 |
31 | export const popupPageDOM = document.defaultView;
--------------------------------------------------------------------------------
/src/popup/util/fsrs.js:
--------------------------------------------------------------------------------
1 | import { FSRS, Rating, S_MIN, State, TypeConvert, createEmptyCard, dateDiffInDays, generatorParameters } from 'ts-fsrs';
2 | import localStorageDelegate from '../delegate/localStorageDelegate.js';
3 | import cloudStorageDelegate from '../delegate/cloudStorageDelegate.js';
4 | import { store } from '../store';
5 |
6 | // 1. 创建自定义参数
7 | export const defaultParams = generatorParameters({
8 | request_retention: 0.9, // 期望记忆保持率 90%
9 | maximum_interval: 365, // 最大间隔天数
10 | enable_fuzz: false, // 禁用时间模糊化
11 | enable_short_term: false // 启用短期记忆影响
12 | });
13 |
14 | // 2. 评分映射(4个等级)
15 | export const qualityToRating = (quality) => {
16 | switch(quality) {
17 | case 1: return Rating.Again; // 完全不会
18 | case 2: return Rating.Hard; // 有点难
19 | case 3: return Rating.Good; // 正常
20 | case 4: return Rating.Easy; // 简单
21 | default: return Rating.Good;
22 | }
23 | };
24 |
25 | // 3. 获取本地FSRS参数
26 | export const getFSRSParams = async () => {
27 | try {
28 | const result = await localStorageDelegate.get('fsrs_params');
29 | console.log('找到本地FSRS参数:', result);
30 | if (!result) {
31 | console.log('未找到本地FSRS参数,使用默认参数');
32 | return defaultParams;
33 | }
34 |
35 | // 如果结果是字符串,尝试解析它
36 | if (typeof result === 'string') {
37 | try {
38 | const localParams = JSON.parse(result);
39 | console.log('获取到本地FSRS参数:', localParams);
40 | return localParams;
41 | } catch (e) {
42 | console.error('解析本地FSRS参数失败:', e);
43 | return defaultParams;
44 | }
45 | }
46 |
47 | // 如果结果已经是对象,直接返回
48 | return result;
49 | } catch (error) {
50 | console.error('获取本地FSRS参数失败:', error);
51 | return defaultParams;
52 | }
53 | };
54 |
55 | // 4. 保存FSRS参数到本地存储
56 | export const saveFSRSParams = async (newParams) => {
57 | try {
58 | // 为参数添加时间戳
59 | const paramsWithTimestamp = {
60 | ...newParams,
61 | timestamp: Date.now()
62 | };
63 |
64 | // 保存到本地存储(字符串格式)
65 | await localStorageDelegate.set('fsrs_params', JSON.stringify(paramsWithTimestamp));
66 | console.log('FSRS参数已保存到本地存储');
67 |
68 | // 保存到云端存储(对象格式)
69 | if (store.isCloudSyncEnabled) {
70 | await cloudStorageDelegate.set('fsrs_params', paramsWithTimestamp);
71 | console.log('FSRS参数已保存到云端存储');
72 | }
73 |
74 | return true;
75 | } catch (error) {
76 | console.error('保存FSRS参数失败:', error);
77 | return false;
78 | }
79 | };
80 |
81 | // 5. 保存单个复习日志
82 | export const saveRevlog = async (cardId, revlog) => {
83 | try {
84 | // 从 localStorage 获取现有的复习日志
85 | const existingRevlogsStr = await new Promise((resolve) => {
86 | chrome.storage.local.get(['fsrs_revlogs'], (result) => {
87 | resolve(result.fsrs_revlogs || '{}');
88 | });
89 | });
90 |
91 | let existingRevlogs;
92 | try {
93 | existingRevlogs = JSON.parse(existingRevlogsStr);
94 | } catch (e) {
95 | console.error('Error parsing revlogs:', e);
96 | existingRevlogs = {};
97 | }
98 |
99 | // 确保该卡片的日志数组存在
100 | if (!existingRevlogs[cardId]) {
101 | existingRevlogs[cardId] = [];
102 | }
103 |
104 | // 添加新的复习日志
105 | existingRevlogs[cardId].push(revlog);
106 |
107 | // 保存到本地存储
108 | await new Promise((resolve) => {
109 | chrome.storage.local.set({ 'fsrs_revlogs': JSON.stringify(existingRevlogs) });
110 | resolve();
111 | });
112 |
113 | // 如果启用了云同步,同时保存到云端
114 | if (store.isCloudSyncEnabled) {
115 | await cloudStorageDelegate.set('fsrs_revlogs', existingRevlogs);
116 | }
117 |
118 | return true;
119 | } catch (error) {
120 | console.error('Error saving revlog:', error);
121 | return false;
122 | }
123 | };
124 |
125 | // 6. 获取所有复习日志
126 | export const getAllRevlogs = async () => {
127 | try {
128 | let result;
129 |
130 | // 如果启用了云同步,优先从云端获取
131 | if (store.isCloudSyncEnabled) {
132 | result = await cloudStorageDelegate.get('fsrs_revlogs');
133 | if (result && Object.keys(result).length > 0) {
134 | console.log('从云端获取复习日志:', result);
135 | return result;
136 | }
137 | }
138 |
139 | // 如果云端没有数据或未启用云同步,从本地获取
140 | result = await new Promise((resolve) => {
141 | chrome.storage.local.get(['fsrs_revlogs'], (result) => {
142 | resolve(result.fsrs_revlogs || '{}');
143 | });
144 | });
145 |
146 | // 如果结果是字符串,尝试解析它
147 | if (typeof result === 'string') {
148 | try {
149 | return JSON.parse(result);
150 | } catch (e) {
151 | console.error('Error parsing revlogs:', e);
152 | return {};
153 | }
154 | }
155 |
156 | // 如果结果已经是对象,直接返回
157 | return result || {};
158 | } catch (error) {
159 | console.error('Error getting revlogs:', error);
160 | return {};
161 | }
162 | };
163 |
164 | // 7. 导出复习日志为CSV格式
165 | export const exportRevlogsToCSV = async () => {
166 | try {
167 | // 获取所有复习日志
168 | const allRevlogs = await getAllRevlogs();
169 |
170 | // CSV 头部 - 只包含必要字段
171 | const csvHeader = 'card_id,review_time,review_rating,review_state\n';
172 |
173 | // 收集所有卡片的复习日志
174 | let csvContent = csvHeader;
175 |
176 | Object.keys(allRevlogs).forEach(cardId => {
177 | const cardRevlogs = allRevlogs[cardId] || [];
178 | cardRevlogs.forEach(log => {
179 | // 只导出必要字段
180 | csvContent += `${log.card_id},${log.review_time},${log.review_rating},${log.review_state}\n`;
181 | });
182 | });
183 |
184 | return csvContent;
185 | } catch (error) {
186 | console.error('Error exporting revlogs to CSV:', error);
187 | return '';
188 | }
189 | };
190 |
--------------------------------------------------------------------------------
/src/popup/util/keys.js:
--------------------------------------------------------------------------------
1 | export const CN_MODE = 'cn_mode';
2 | export const CN_PROBLEM_KEY = 'cn_records';
3 | export const PROBLEM_KEY = 'records';
4 | export const REVIEW_INTV_KEY = 'review_intervals';
5 | export const OPS_HISTORY_KEY = 'operation_history';
6 | export const PROBLEM_SORT_BY_KEY = 'problem_sort_by';
7 | export const CONFIG_KEY = 'configs';
8 | export const CONFIG_INNER_KEY_ENABLE_CLOUD = 'enable_cloud';
9 | // 添加新的常量
10 | export const DEFAULT_CARD_LIMIT_KEY = 'defaultCardLimit';
11 | export const DEFAULT_CARD_LIMIT_VALUE = 3;
--------------------------------------------------------------------------------
/src/popup/util/sort.js:
--------------------------------------------------------------------------------
1 | import { getDelayedHours, getNextReviewTime } from "./utils";
2 |
3 | const reverse = (sorter) => {
4 | return (p1, p2) => -sorter(p1, p2)
5 | }
6 |
7 | const problemReviewTimeComparator = (p1, p2) => {
8 | return getNextReviewTime(p1).valueOf() - getNextReviewTime(p2).valueOf();
9 | }
10 |
11 | const problemDelayTimeComparator = (p1, p2) => {
12 | return getDelayedHours(p2).valueOf() - getDelayedHours(p1).valueOf();
13 | }
14 |
15 | // functions used to sort problems
16 | export const problemSorters = {
17 | // reviewTimeSorter:
18 | sortByReviewTimeDesc: reverse(problemReviewTimeComparator),
19 | sortByReviewTimeAsc: problemReviewTimeComparator,
20 | sortByDelayHoursDesc: problemDelayTimeComparator,
21 | sortByDelayHoursAsc: reverse(problemDelayTimeComparator)
22 | }
23 |
24 | export const problemSorterArr = [
25 | problemSorters.sortByReviewTimeAsc,
26 | problemSorters.sortByReviewTimeDesc,
27 | problemSorters.sortByDelayHoursAsc,
28 | problemSorters.sortByDelayHoursDesc
29 | ];
30 |
31 | export const idOf = (sorter) => {
32 | return problemSorterArr.indexOf(sorter);
33 | }
34 |
35 | export const getSorterById = (id) => {
36 | return problemSorterArr[id];
37 | }
38 |
39 | export const descriptionOf = (sorter) => {
40 | let description;
41 | switch (sorter) {
42 | case problemSorters.sortByDelayHoursAsc:
43 | description = "Sort By Review Delayed Hours (ASC)";
44 | break;
45 | case problemSorters.sortByDelayHoursDesc:
46 | description = "Sort By Review Delayed Hours (DESC)";
47 | break;
48 | case problemSorters.sortByReviewTimeAsc:
49 | description = "Sort By Next Scheduled Review Time (ASC)";
50 | break;
51 | case problemSorters.sortByReviewTimeDesc:
52 | description = "Sort By Next Scheduled Review Time (DESC)";
53 | break;
54 | default:
55 | description = "";
56 | }
57 | return description;
58 | }
--------------------------------------------------------------------------------
/src/popup/util/utils.js:
--------------------------------------------------------------------------------
1 | import localStorageDelegate from "../delegate/localStorageDelegate";
2 | import cloudStorageDelegate from "../delegate/cloudStorageDelegate";
3 | import { store } from "../store";
4 | import { COMPILE_ERROR_AND_TLE_CLASSNAME, COMPILE_ERROR_AND_TLE_CLASSNAME_CN, COMPILE_ERROR_AND_TLE_CLASSNAME_NEW, PAGE_SIZE, SUBMIT_BUTTON_ATTRIBUTE_NAME, SUBMIT_BUTTON_ATTRIBUTE_VALUE, SUCCESS_CLASSNAME, SUCCESS_CLASSNAME_CN, SUCCESS_CLASSNAME_NEW, WRONG_ANSWER_CLASSNAME, WRONG_ANSWER_CLASSNAME_CN, WRONG_ANSWER_CLASSNAME_NEW, forggettingCurve } from "./constants";
5 | import { forgetting_curve, dateDiffInDays } from "ts-fsrs"
6 |
7 | export const needReview = (problem) => {
8 | if (problem.proficiency >= forggettingCurve.length) {
9 | return false;
10 | }
11 |
12 | const currentTime = Date.now();
13 | const timeDiffInMinute = (currentTime - problem.submissionTime) / (1000 * 60);
14 | return timeDiffInMinute >= forggettingCurve[problem.proficiency];
15 | };
16 |
17 | export const scheduledReview = (problem) => {
18 | // return !needReview(problem) && problem.proficiency < 5;
19 | return true;
20 | };
21 |
22 | export const isCompleted = (problem) => {
23 | return problem.proficiency === 5;
24 | };
25 |
26 | export const calculatePageNum = (problems) => {
27 | return Math.max(Math.ceil(problems.length / PAGE_SIZE), 1);;
28 | }
29 |
30 | export const getLevelColor = (level) => {
31 | switch (level) {
32 | case "Easy":
33 | return "rgb(67, 1 71)"; // 绿色
34 | case "Medium":
35 | return "#ff9800"; // 橙色
36 | case "Hard":
37 | return "rgb(233, 30, 99)"; // 红色
38 | default:
39 | return "inherit";
40 | }
41 | };
42 |
43 |
44 | export const getNextReviewTime = (problem) => {
45 | // 如果有 FSRS 的 nextReview,优先使用它
46 | let date;
47 | if (problem.fsrsState && problem.fsrsState.nextReview) {
48 | date = new Date(problem.fsrsState.nextReview);
49 | } else {
50 | // 否则使用旧的计算方式(向后兼容)
51 | date = new Date(problem.submissionTime + forggettingCurve[problem.proficiency] * 60 * 1000);
52 | }
53 |
54 | return date;
55 | }
56 |
57 |
58 | export const getDelayedHours = (problem) => {
59 | const nextReviewDate = getNextReviewTime(problem);
60 | return Math.round((Date.now() - nextReviewDate) / (60 * 60 * 1000));
61 | }
62 |
63 | export const getDifficultyBasedSteps = (diffculty) => {
64 | if (diffculty === "Easy") {
65 | return store.easyIntv;
66 | } else if (diffculty === "Medium") {
67 | return store.mediumIntv;
68 | } else {
69 | return store.hardIntv;
70 | }
71 | }
72 |
73 | export const isSubmitButton = (element) => {
74 | return element.getAttribute(SUBMIT_BUTTON_ATTRIBUTE_NAME) === SUBMIT_BUTTON_ATTRIBUTE_VALUE;
75 | }
76 |
77 | export const getSubmissionResult = () => {
78 | return document.getElementsByClassName(SUCCESS_CLASSNAME_CN)[0] ||
79 | document.getElementsByClassName(WRONG_ANSWER_CLASSNAME_CN)[0] ||
80 | document.getElementsByClassName(COMPILE_ERROR_AND_TLE_CLASSNAME_CN)[0] ||
81 | document.getElementsByClassName(SUCCESS_CLASSNAME)[0] ||
82 | document.getElementsByClassName(WRONG_ANSWER_CLASSNAME)[0] ||
83 | document.getElementsByClassName(COMPILE_ERROR_AND_TLE_CLASSNAME)[0] ||
84 | document.getElementsByClassName(SUCCESS_CLASSNAME_NEW)[0] ||
85 | document.getElementsByClassName(WRONG_ANSWER_CLASSNAME_NEW)[0] ||
86 | document.getElementsByClassName(COMPILE_ERROR_AND_TLE_CLASSNAME_NEW)[0];
87 | }
88 |
89 | export const isSubmissionSuccess = (submissionResult) => {
90 | return submissionResult.className.includes(SUCCESS_CLASSNAME_CN) ||
91 | submissionResult.className.includes(SUCCESS_CLASSNAME_NEW) ||
92 | submissionResult.className.includes(SUCCESS_CLASSNAME);
93 | }
94 |
95 | export const updateProblemUponSuccessSubmission = (problem) => {
96 | const steps = getDifficultyBasedSteps(problem.problemLevel);
97 | let nextProficiencyIndex;
98 | for (const i of steps) {
99 | if (i > problem.proficiency) {
100 | nextProficiencyIndex = i;
101 | break;
102 | }
103 | }
104 |
105 | // further review needed
106 | if (nextProficiencyIndex !== undefined) {
107 | problem.proficiency = nextProficiencyIndex;
108 | // already completed all review
109 | } else {
110 | problem.proficiency = forggettingCurve.length;
111 | }
112 | problem.submissionTime = Date.now();
113 | problem.modificationTime = Date.now();
114 | return problem;
115 | }
116 |
117 | // for sync data over cloud & local
118 | export const mergeProblem = (p1, p2) => {
119 | if (p2 === undefined || p2 === null) return p1;
120 | if (p1 === undefined || p1 === null) return p2;
121 | if (p2.modificationTime === undefined || p2.modificationTime === null) return p1;
122 | if (p1.modificationTime === undefined || p1.modificationTime === null) return p2;
123 |
124 | return p1.modificationTime > p2.modificationTime ? p1 : p2;
125 | }
126 |
127 | export const mergeProblems = (ps1, ps2) => {
128 | const problemIdSet = new Set([...Object.keys(ps1), ...Object.keys(ps2)]);
129 | const ps = {}
130 | problemIdSet.forEach(id => {
131 | const p1 = ps1[id], p2 = ps2[id];
132 | const p = mergeProblem(p1, p2);
133 | ps[id] = p;
134 | })
135 |
136 | return ps;
137 | }
138 |
139 | export const syncStorage = async (sd1, sd2, key, merger) => {
140 | if (!store.isCloudSyncEnabled) return;
141 | const data1 = await sd1.get(key) || {};
142 | const data2 = await sd2.get(key) || {};
143 | const merged = merger(data1, data2);
144 |
145 | console.log("merging data from local and from cloud. local:")
146 | console.log(data1);
147 | console.log("merging data from local and from cloud. cloud:")
148 | console.log(data2);
149 | await sd1.set(key, merged);
150 | await sd2.set(key, merged);
151 | }
152 |
153 | export const syncLocalAndCloudStorage = async (key, merger) => {
154 | await syncStorage(localStorageDelegate, cloudStorageDelegate, key, merger);
155 | }
156 |
157 | export const simpleStringHash = (key) => {
158 | let hash = 0;
159 | for (let i = 0; i < key.length; i++) {
160 | const char = key.charCodeAt(i);
161 | hash = (hash << 5) - hash + char;
162 | hash |= 0;
163 | }
164 | return hash;
165 | }
166 |
167 | // 获取当前可检索性的辅助函数
168 | export const getCurrentRetrievability = (problem) => {
169 | if (!problem.fsrsState?.stability || !problem.fsrsState?.lastReview) {
170 | return 1;
171 | }
172 |
173 | const elapsedDays = dateDiffInDays(new Date(problem.fsrsState.lastReview), new Date());
174 | return forgetting_curve(elapsedDays, problem.fsrsState.stability);
175 | };
176 |
177 | export const mergeFSRSParams = (params1, params2) => {
178 | if (params2 === undefined || params2 === null) return params1;
179 | if (params1 === undefined || params1 === null) return params2;
180 |
181 | // 如果云端数据比本地数据新,使用云端数据
182 | const timestamp1 = params1.timestamp || 0;
183 | const timestamp2 = params2.timestamp || 0;
184 |
185 | // 返回较新的数据
186 | const mergedParams = timestamp1 > timestamp2 ? params1 : params2;
187 |
188 | // 确保返回的数据包含最新的时间戳
189 | mergedParams.timestamp = Date.now();
190 |
191 | return mergedParams;
192 | }
193 |
194 | export const mergeRevlogs = (revlogs1, revlogs2) => {
195 | if (revlogs2 === undefined || revlogs2 === null) return revlogs1 || {};
196 | if (revlogs1 === undefined || revlogs1 === null) return revlogs2 || {};
197 |
198 | // 确保 revlogs1 和 revlogs2 是对象
199 | revlogs1 = typeof revlogs1 === 'object' ? revlogs1 : {};
200 | revlogs2 = typeof revlogs2 === 'object' ? revlogs2 : {};
201 |
202 | // 合并复习日志
203 | const mergedRevlogs = { ...revlogs1 };
204 |
205 | // 遍历第二个复习日志集合
206 | Object.keys(revlogs2).forEach(cardId => {
207 | if (!mergedRevlogs[cardId]) {
208 | // 如果第一个集合没有该卡片的复习日志,直接使用第二个集合的
209 | mergedRevlogs[cardId] = Array.isArray(revlogs2[cardId]) ? revlogs2[cardId] : [];
210 | } else {
211 | // 如果两个集合都有该卡片的复习日志,合并两边的日志
212 | const logs2 = Array.isArray(revlogs2[cardId]) ? revlogs2[cardId] : [];
213 | const logs1 = Array.isArray(mergedRevlogs[cardId]) ? mergedRevlogs[cardId] : [];
214 |
215 | // 创建一个Map来存储唯一的复习日志
216 | const uniqueLogsMap = new Map();
217 |
218 | // 添加第一个集合的日志
219 | logs1.forEach(log => {
220 | if (log && typeof log === 'object') {
221 | const key = `${log.card_id}_${log.review_time}_${log.review_rating}`;
222 | uniqueLogsMap.set(key, log);
223 | }
224 | });
225 |
226 | // 添加第二个集合的日志
227 | logs2.forEach(log => {
228 | if (log && typeof log === 'object') {
229 | const key = `${log.card_id}_${log.review_time}_${log.review_rating}`;
230 | uniqueLogsMap.set(key, log);
231 | }
232 | });
233 |
234 | // 转换回数组并按时间排序
235 | mergedRevlogs[cardId] = Array.from(uniqueLogsMap.values())
236 | .sort((a, b) => b.review_time - a.review_time);
237 | }
238 | });
239 |
240 | return mergedRevlogs;
241 | }
242 |
243 |
244 |
--------------------------------------------------------------------------------
/src/popup/view/view.js:
--------------------------------------------------------------------------------
1 | import { store } from "../store";
2 | import { isInCnMode } from "../service/modeService";
3 | import { getAllProblems, syncProblems } from "../service/problemService";
4 | import { CN_LABLE, GL_LABLE, PAGE_SIZE, months } from "../util/constants";
5 | import { completedTableDOM, input0DOM, input1DOM, input2DOM, inputLabel0DOM, inputLabel1DOM, inputLabel2DOM, needReviewTableDOM, nextButton0DOM, nextButton1DOM, nextButton2DOM, noReviewTableDOM, prevButton0DOM, prevButton1DOM, prevButton2DOM, siteLabelDOM, switchButtonDOM, undoButtonDOMs } from "../util/doms";
6 | import { getCurrentRetrievability,calculatePageNum, getLevelColor, getDelayedHours, getNextReviewTime, isCompleted, needReview, scheduledReview } from "../util/utils";
7 | import { registerAllHandlers } from "../handler/handlerRegister";
8 | import { hasOperationHistory } from "../service/operationHistoryService";
9 | import { loadConfigs } from "../service/configService";
10 | import { getLocalStorageData, setLocalStorageData } from "../../popup/delegate/localStorageDelegate";
11 | import { syncFSRSHistory } from "../service/fsrsService";
12 |
13 | /*
14 | Tag for problem records
15 | */
16 | const getProblemUrlCell = (problem, width) => {
17 | const levelColor = getLevelColor(problem.level);
18 | return `\
19 | \
25 | ${problem.name} \
26 | \
27 | `;
28 | };
29 |
30 | // const getProblemLevelCell = (problem, width) => `${decorateProblemLevel(problem.level)} `;
31 |
32 | // 新增:生成可检索性单元格的函数
33 | const getRetrievabilityCell = (problem) => {
34 | const retrievability = getCurrentRetrievability(problem);
35 | const probability = (retrievability * 100).toFixed(1); // 保留一位小数
36 | const exactValue = retrievability.toFixed(5); // 保留五位小数
37 |
38 | // 根据概率设置不同的样式
39 | let style;
40 | if (retrievability >= 0.8) {
41 | style = 'text-success'; // 绿色
42 | } else if (retrievability >= 0.5) {
43 | style = 'text-warning'; // 橙色
44 | } else {
45 | style = 'text-danger'; // 红色
46 | }
47 |
48 | return `\
49 | \
50 | \
54 | ${probability}% \
55 |
\
56 | \
57 | `;
58 | }
59 |
60 | const getCheckButtonTag = (problem) => ` `;
63 |
64 | const getDeleteButtonTag = (problem) => ` `;
67 |
68 | const getResetButtonTag = (problem) => ` `;
71 |
72 | const getNoteButtonTag = (problem, notes) => {
73 | const hasNote = notes[problem.index] && notes[problem.index].content.trim().length > 0;
74 | return ` `;
77 | }
78 |
79 | const createReviewProblemRecord = (problem) => {
80 | const htmlTag =
81 | `\
82 | \
83 | ${getProblemUrlCell(problem)}\
84 | ${getDelayedHours(problem)} hour(s) \
85 | ${getRetrievabilityCell(problem)}\
86 | \
87 | ${getCheckButtonTag(problem)}\
88 | ${getResetButtonTag(problem)}\
89 | ${getDeleteButtonTag(problem)}\
90 | \
91 | \
92 | `;
93 | return htmlTag;
94 | ;
95 | }
96 |
97 | const createScheduleProblemRecord = async (problem) => {
98 | const nextReviewDate = getNextReviewTime(problem);
99 |
100 | // 获取笔记数据
101 | let notes = {};
102 | try {
103 | notes = await getLocalStorageData("notes") || {};
104 | } catch (e) {
105 | console.error("获取笔记数据失败", e);
106 | }
107 |
108 | const htmlTag =
109 | `\
110 | \
111 | ${getProblemUrlCell(problem, 45)}\
112 | ${formatDateTime(nextReviewDate)} \
113 | ${getRetrievabilityCell(problem)}\
114 | \
115 | ${getDeleteButtonTag(problem)}\
116 | ${getNoteButtonTag(problem, notes)}\
117 | \
118 | \
119 | `;
120 | return htmlTag;
121 | }
122 |
123 | // 添加一个日期格式化辅助函数
124 | const formatDateTime = (date) => {
125 | const pad = (n) => n < 10 ? `0${n}` : n;
126 | return `${months[date.getMonth()]} ${date.getDate()}`;
127 | }
128 | // 添加一个完整日期格式化函数
129 | const formatFullDate = (date) => {
130 | const year = date.getFullYear();
131 | const month = date.getMonth() + 1; // getMonth() 返回 0-11
132 | const day = date.getDate();
133 | const hours = date.getHours();
134 | const minutes = date.getMinutes();
135 |
136 | // 补零函数
137 | const pad = (n) => n < 10 ? `0${n}` : n;
138 |
139 | return `${year}/${pad(month)}/${pad(day)} ${pad(hours)}:${pad(minutes)}`;
140 | }
141 |
142 | const createCompletedProblemRecord = (problem) => {
143 | const htmlTag =
144 | `\
145 | \
146 | ${getProblemUrlCell(problem, 35)}\
147 | ${getProblemLevelCell(problem)}\
148 | \
149 | ${getResetButtonTag(problem)}\
150 | ${getDeleteButtonTag(problem)}\
151 | \
152 | \
153 | `;
154 | return htmlTag;
155 | ;
156 | }
157 |
158 | // 添加笔记模态框HTML
159 | const renderNoteModal = () => {
160 | // 检查是否已经存在模态框
161 | if (document.getElementById('noteModal')) {
162 | console.log("笔记模态框已存在,不再创建");
163 | return; // 如果已存在,不再创建
164 | }
165 |
166 | console.log("开始创建笔记模态框");
167 |
168 | const modalHTML = `
169 |
170 |
171 |
172 |
176 |
177 |
178 |
179 | 问题名称 (Problem Name)
180 |
181 |
182 |
183 | 笔记内容 (Note Content)
184 |
185 |
186 |
187 |
191 |
192 |
193 |
`;
194 |
195 | document.body.insertAdjacentHTML('beforeend', modalHTML);
196 |
197 | // 添加模态框样式
198 | const style = document.createElement('style');
199 | style.textContent = `
200 | .modal.show {
201 | display: block !important;
202 | background-color: rgba(0, 0, 0, 0.5);
203 | }
204 | #problemName, #noteContent {
205 | color: #000 !important;
206 | background-color: #fff !important;
207 | }
208 | #problemName::placeholder {
209 | color: #555 !important;
210 | opacity: 1 !important;
211 | }
212 | `;
213 | document.head.appendChild(style);
214 |
215 | console.log("笔记模态框已创建,检查元素:");
216 | console.log("问题名称输入框:", document.getElementById('problemName'));
217 | console.log("笔记内容文本框:", document.getElementById('noteContent'));
218 | }
219 |
220 |
221 |
222 | // 添加一个全局函数用于初始化所有 tooltip
223 | const initializeTooltips = () => {
224 | // 先移除所有现有的 tooltip 元素
225 | document.querySelectorAll('.tooltip').forEach(el => {
226 | el.remove();
227 | });
228 |
229 | // 销毁所有现有的 tooltip 实例
230 | document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
231 | const tooltip = bootstrap.Tooltip.getInstance(el);
232 | if (tooltip) {
233 | tooltip.dispose();
234 | }
235 | });
236 |
237 | // 初始化新的 tooltip
238 | document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
239 | new bootstrap.Tooltip(el, {
240 | trigger: 'hover focus', // 只在悬停或获取焦点时显示
241 | container: 'body', // 将 tooltip 附加到 body
242 | boundary: 'window' // 确保 tooltip 不超出窗口
243 | });
244 | });
245 | };
246 |
247 | export const renderReviewTableContent = (problems, page) => {
248 | /* validation */
249 | console.log(store.toReviewMaxPage);
250 | if (page > store.toReviewMaxPage || page < 1) {
251 | input0DOM.classList.add("is-invalid");
252 | return;
253 | }
254 | input0DOM.classList.remove("is-invalid");
255 |
256 | store.toReviewPage = page;
257 |
258 | /* update pagination elements */
259 | input0DOM.value = page;
260 | inputLabel0DOM.innerText = `/${store.toReviewMaxPage}`;
261 |
262 | if (page === 1) prevButton0DOM.setAttribute("disabled", "disabled");
263 | if (page !== 1) prevButton0DOM.removeAttribute("disabled");
264 | if (page === store.toReviewMaxPage) nextButton0DOM.setAttribute("disabled", "disabled");
265 | if (page !== store.toReviewMaxPage) nextButton0DOM.removeAttribute("disabled");
266 |
267 | let content_html =
268 | '\
269 | \
270 | \
271 | Problem \
272 | Delay \
273 | Recall \
274 | Operation \
275 | \
276 | \
277 | \
278 | ';
279 |
280 | problems = problems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
281 |
282 | let keys = Object.keys(problems);
283 | for (const i of keys) {
284 | content_html += createReviewProblemRecord(problems[i]) + '\n';
285 | }
286 | content_html += ` `
287 |
288 | needReviewTableDOM.innerHTML = content_html;
289 | }
290 |
291 | export const renderScheduledTableContent = async (problems, page) => {
292 | /* validation */
293 | if (page > store.scheduledMaxPage || page < 1) {
294 | input1DOM.classList.add("is-invalid");
295 | return;
296 | }
297 | input1DOM.classList.remove("is-invalid");
298 |
299 | store.scheduledPage = page;
300 |
301 | /* update pagination elements */
302 | input1DOM.value = page;
303 | inputLabel1DOM.innerText = `/${store.scheduledMaxPage}`;
304 |
305 | if (page === 1) prevButton1DOM.setAttribute("disabled", "disabled");
306 | if (page !== 1) prevButton1DOM.removeAttribute("disabled");
307 | if (page === store.scheduledMaxPage) nextButton1DOM.setAttribute("disabled", "disabled");
308 | if (page !== store.scheduledMaxPage) nextButton1DOM.removeAttribute("disabled");
309 |
310 | let content_html =
311 | '\
312 | \
313 | \
314 | Problem \
315 | Review \
316 | Recall \
317 | Action \
318 | \
319 | \
320 | \
321 | ';
322 |
323 | // if (!Array.isArray(problems)) {
324 | // problems = Object.values(problems);
325 | // }
326 | // problems为store.reviewScheduledProblems,即滤除了delete的题目
327 | problems = problems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
328 |
329 | let keys = Object.keys(problems);
330 |
331 | // 获取笔记数据
332 | let notes = {};
333 | try {
334 | notes = await getLocalStorageData("notes") || {};
335 | } catch (e) {
336 | console.error("获取笔记数据失败", e);
337 | }
338 |
339 | for (const i of keys) {
340 | const problem = problems[i];
341 | // 使用 createScheduleProblemRecord 函数创建问题记录
342 | content_html += await createScheduleProblemRecord(problem);
343 | }
344 |
345 | content_html += ` `
346 |
347 | noReviewTableDOM.innerHTML = content_html;
348 |
349 | // 初始化 tooltip
350 | setTimeout(() => {
351 | initializeTooltips();
352 | }, 100);
353 | }
354 |
355 | export const renderCompletedTableContent = (problems, page) => {
356 |
357 | /* validation */
358 | if (page > store.completedMaxPage || page < 1) {
359 | input2DOM.classList.add("is-invalid");
360 | return;
361 | }
362 | input2DOM.classList.remove("is-invalid");
363 |
364 | store.completedPage = page;
365 |
366 | /* update pagination elements */
367 | input2DOM.value = page;
368 | inputLabel2DOM.innerText = `/${store.completedMaxPage}`;
369 |
370 | if (page === 1) prevButton2DOM.setAttribute("disabled", "disabled");
371 | if (page !== 1) prevButton2DOM.removeAttribute("disabled");
372 | if (page === store.completedMaxPage) nextButton2DOM.setAttribute("disabled", "disabled");
373 | if (page !== store.completedMaxPage) nextButton2DOM.removeAttribute("disabled");
374 |
375 | let content_html =
376 | '\
377 | \
378 | \
379 | Problem \
380 | Level \
381 | Operation \
382 | \
383 | \
384 | \
385 | ';
386 |
387 | problems = problems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
388 |
389 | let keys = Object.keys(problems);
390 | for (const i of keys) {
391 | content_html += createCompletedProblemRecord(problems[i]) + '\n';
392 | }
393 |
394 | content_html += ` `
395 | completedTableDOM.innerHTML = content_html;
396 |
397 | // 初始化 tooltip
398 | setTimeout(() => {
399 | initializeTooltips();
400 | }, 100);
401 | }
402 |
403 | export const renderSiteMode = async () => {
404 | let cnMode = await isInCnMode();
405 | if (cnMode) {
406 | switchButtonDOM.setAttribute("checked", "checked");
407 | siteLabelDOM.innerHTML = CN_LABLE;
408 | } else {
409 | switchButtonDOM.removeAttribute("checked");
410 | siteLabelDOM.innerHTML = GL_LABLE;
411 | }
412 | }
413 |
414 | export const renderUndoButton = async () => {
415 | if (await hasOperationHistory()) {
416 | Array.prototype.forEach.call(undoButtonDOMs, btn => btn.removeAttribute("disabled"));
417 | } else {
418 | Array.prototype.forEach.call(undoButtonDOMs, btn => btn.setAttribute("disabled", "disabled"));
419 | }
420 | }
421 |
422 | export const renderAll = async () => {
423 | await loadConfigs();
424 | await renderSiteMode();
425 | await syncProblems();
426 | // await syncFSRSHistory();
427 |
428 | // 创建笔记模态框
429 |
430 |
431 |
432 |
433 | const problems = Object.values(await getAllProblems()).filter(p => p.isDeleted !== true);
434 | console.log('Filtering and sorting problems...');
435 |
436 | // 过滤不同类型的问题
437 | store.needReviewProblems = problems.filter(needReview);
438 | console.log('Need Review Problems:', {
439 | count: store.needReviewProblems.length,
440 | problems: store.needReviewProblems
441 | });
442 |
443 | store.reviewScheduledProblems = problems.filter(scheduledReview);
444 | console.log('Scheduled Review Problems:', {
445 | count: store.reviewScheduledProblems.length,
446 | problems: store.reviewScheduledProblems
447 | });
448 |
449 | store.completedProblems = problems.filter(isCompleted);
450 | console.log('Completed Problems:', {
451 | count: store.completedProblems.length,
452 | problems: store.completedProblems
453 | });
454 |
455 | // 计算页数
456 | store.toReviewMaxPage = calculatePageNum(store.needReviewProblems);
457 | store.scheduledMaxPage = calculatePageNum(store.reviewScheduledProblems);
458 | store.completedMaxPage = calculatePageNum(store.completedProblems);
459 | console.log('Page Counts:', {
460 | toReview: store.toReviewMaxPage,
461 | scheduled: store.scheduledMaxPage,
462 | completed: store.completedMaxPage
463 | });
464 |
465 | // 排序
466 | console.log('Sorting by:', store.problemSortBy);
467 | store.needReviewProblems.sort(store.problemSortBy);
468 | store.reviewScheduledProblems.sort(store.problemSortBy);
469 | store.completedProblems.sort(store.problemSortBy);
470 |
471 | console.log('Filtering and sorting completed.');
472 |
473 | // renderReviewTableContent(store.needReviewProblems, 1);
474 | await renderScheduledTableContent(store.reviewScheduledProblems, 1);
475 | // renderCompletedTableContent(store.completedProblems, 1);
476 | await renderUndoButton();
477 | renderNoteModal();
478 |
479 | registerAllHandlers();
480 |
481 | // 初始化所有 tooltip
482 | setTimeout(() => {
483 | initializeTooltips();
484 | }, 200);
485 |
486 | // 添加全局点击事件监听器,点击页面任何地方时隐藏所有 tooltip
487 | document.addEventListener('click', (e) => {
488 | // 如果点击的不是 tooltip 触发元素,则隐藏所有 tooltip
489 | if (!e.target.hasAttribute('data-bs-toggle') || e.target.getAttribute('data-bs-toggle') !== 'tooltip') {
490 | document.querySelectorAll('.tooltip').forEach(el => {
491 | el.remove();
492 | });
493 | }
494 | });
495 | }
496 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | // module.exports = {
2 | // entry: {
3 | // popup: './src/popup/popup.js',
4 | // options: './src/popup/options.js',
5 | // leetcode: './src/popup/script/leetcode.js',
6 | // leetcodecn: './src/popup/script/leetcodecn.js'
7 | // },
8 | // output: {
9 | // filename: '[name].js'
10 | // },
11 | // module: {
12 | // rules: [
13 | // {
14 | // test: /.css$/,
15 | // use: [
16 | // 'style-loader',
17 | // 'css-loader'
18 | // ]
19 | // }
20 | // ]
21 | // },
22 | // mode: 'production'
23 | // }
24 |
25 |
26 |
27 |
28 | module.exports = {
29 | entry: {
30 | popup: {
31 | import: [
32 | './src/popup/popup.js',
33 | './src/popup/daily-review.js'
34 | ],
35 | filename: 'popup.js'
36 | },
37 | options: './src/popup/options.js',
38 | leetcode: './src/popup/script/leetcode.js',
39 | leetcodecn: './src/popup/script/leetcodecn.js',
40 | reminder: './src/content-scripts/reminder.js',
41 | },
42 | output: {
43 | filename: '[name].js',
44 | path: __dirname + '/dist'
45 | },
46 | module: {
47 | rules: [
48 | {
49 | test: /\.css$/,
50 | use: [
51 | 'style-loader',
52 | 'css-loader'
53 | ]
54 | }
55 | ]
56 | },
57 | mode: 'production',
58 | // 开发模式,代码不会被压缩
59 | // mode: 'development',
60 | // 生成源码映射,方便调试
61 | devtool: 'source-map',
62 | // 关闭代码最小化
63 | optimization: {
64 | minimize: false
65 | }
66 | }
--------------------------------------------------------------------------------