├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── changelog.md ├── config.json ├── designer ├── Leaderboard.ui ├── achievement.ui ├── banUser.ui ├── config.ui ├── icons │ ├── krone.png │ ├── person.png │ └── settings.png ├── report.ui ├── reset_password.ui └── user_info.ui ├── manifest.json ├── screenshots ├── homescreen_dark.png ├── homescreen_light.png ├── lb_dark.png ├── lb_light.png ├── league_dark.png └── league_light.png ├── server ├── api.py ├── api2.py ├── api3.py ├── checkInput.py ├── manage_leagues.py ├── models.py ├── notification_netlify │ └── index.html ├── templates │ ├── authError.html │ ├── header+footer.html │ ├── leagues.html │ ├── messages.html │ ├── newPassword.html │ ├── privacy.html │ ├── retention.html │ ├── reviews.html │ ├── streak.html │ ├── time.html │ ├── upload.html │ └── user.html ├── tests.py ├── urls.py └── website.py ├── src ├── Leaderboard.py ├── League.py ├── Stats.py ├── __init__.py ├── api_connect.py ├── banUser.py ├── colors.json ├── config.py ├── config_manager.py ├── homescreenLeaderboard.py ├── reportUser.py ├── resetPassword.py ├── streakAchievement │ ├── __init__.py │ ├── calendar.js │ ├── calendar_License.txt │ ├── canvas-confetti.js │ ├── confetti_License.txt │ ├── streak.html │ ├── streakAchievement.py │ └── style.css ├── userInfo.py └── version.py └── tools ├── ankiaddon.py └── build_ui.sh /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve the leaderboard 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Screenshots** 14 | If applicable, add screenshots to help explain your problem. 15 | 16 | **Steps to reproduce bug** 17 | If possible, step by step instructions for how to reproduce the bug 18 | 19 | **Info** 20 | - Anki version (Help>About; e.g. 2.1.24) 21 | - Leaderboard version (Leaderboard>Config>About tab; e.g. v.1.4.4) 22 | - If relevant Add-on config info (start of new day, autoscrolling enabled etc.) 23 | - OS: (e.g. Windows 10) 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # ******** NOTE ******** 12 | 13 | name: "CodeQL" 14 | 15 | on: 16 | push: 17 | branches: [ master ] 18 | pull_request: 19 | # The branches below must be a subset of the branches above 20 | branches: [ master ] 21 | schedule: 22 | - cron: '26 5 * * 0' 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [ 'python' ] 33 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 34 | # Learn more... 35 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v2 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v1 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /forms 2 | /releases 3 | /meta.json 4 | /License.txt 5 | /todo.md 6 | __pycache__ 7 | /ankiaddon.py 8 | data.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Thore Tyborski 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leaderboard for Anki 2.1 2 | 3 | This add-on ranks all of its users by the number of cards reviewed today, time spend studying today, current streak, reviews in the past 31 days and retention. You can also invite friends, join a group and/or country leaderboard and compete in leagues. 4 | 5 | The online version of the add-on is available on [this website](https://ankileaderboard.pythonanywhere.com/). 6 | 7 | ## Installing 8 | You can download the newest stable release from [AnkiWeb](https://ankiweb.net/shared/info/41708974). 9 | 10 | If you want to test the newest, potentially unstable version from GitHub, clone this repository into the addons21 folder and run /tools/build_ui.sh. If you already have an account, you might want to copy the meta.json file into the cloned folder. 11 | 12 | The newest stable version can also be downloaded from GitHub. Just download the .ankiaddon file from releases and open it. 13 | 14 | ## License 15 | 16 | This project is licensed under the MIT License - see the [LICENSE.md](https://github.com/ThoreBor/Anki_Leaderboard/blob/master/LICENSE) file for details 17 | 18 | © Thore Tyborski 2023 19 | See also the list of [contributors](https://github.com/ThoreBor/Anki_Leaderboard/contributors) who participated in this project. 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .src import __init__ -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | ## v3.0.2 3 | - fixed bug when trying to open the leaderboard without being logged in 4 | - fixed highlight bug 5 | 6 | ## v3.0.1 7 | - fixed auto-sync bug 8 | - fixed group numbering bug 9 | 10 | ## v3.0.0 11 | - rewrote API 12 | - fixed dark mode colors 13 | - fixed minor Anki 2.1.55 UI bugs 14 | - fixed Anki freezing while loading leaderboard 15 | - removed refresh option 16 | - removed legacy support for migrating old (<2.0.0) accounts 17 | - reduced API calls 18 | 19 | ## v2.1.0 20 | - fixed reset password error message 21 | - fixed homescreen group bug 22 | - added PyQt6 support 23 | - added option to change username 24 | - added privacy policy 25 | 26 | ## v2.0.0 27 | - use email address and password to authenticate users 28 | - minor UI changes 29 | - minor performance improvements 30 | - fixed some bugs 31 | 32 | ## v1.7.0 33 | - added option to join multiple groups (__you have to rejoin the group you previously joined__) 34 | - added option to report and suspend users 35 | - added config shortcut 36 | - added option to delete account and/or create a backup of the configurations before deleting the add-on (this option will be available in Anki >2.1.44) 37 | - removed E-Mail from group request 38 | - fixed review stats bug (rescheduled cards were counted as reviews) 39 | - fixed days studied bug 40 | - minimum study time for leagues is now 5 minutes 41 | - adjusted dark mode colors 42 | - UI changes 43 | - various under the hood changes (add-on and server-side) 44 | 45 | ## v1.6.3.1 46 | - streak hotfix 47 | - increased window width 48 | 49 | ## v1.6.3 50 | - fixed odd number bug on home screen leaderboard 51 | - reduced home screen leaderboard server requests (improves performance) 52 | - home screen leaderboard users are clickable (for more info about user) 53 | - top three users of each league will get a medal that can be shown next to the username (optional) and will appear in the profile (starting from season 4) 54 | - season results are being saved now for each user and appear in their profile (starting from season 4) 55 | - "🥇", "🥈", "🥉" and "|" aren't allowed in usernames anymore 56 | - minor UI changes 57 | - improve the efficiency of statistical calculations (improves performance) 58 | - added "days studied" (in the current season) to the league tab (a day counts when the user studied for at least 10 minutes) 59 | - users with 0 XP will be relegated in addition to the last 20% 60 | - notifications (server downtime, updates, etc.) will only be shown once 61 | - new XP formula: `XP = days studied percentage x ((6 x time) + (2 x reviews x retention))` __starting from season 6__ 62 | 63 | ## v1.6.2 64 | - join group bug fix 65 | 66 | ## v1.6.1 67 | - added # column to home screen leaderboard 68 | - leagues also work on the home screen leaderboard now 69 | - added option to focus on user on home screen leaderboard 70 | - config UI changes 71 | - added password-protected groups (Users that requested a new group in v1.6.0 are automatically admins. Groups can only be 72 | changed in v1.6.1 and future versions. Older versions won't be supported anymore) 73 | - fixed various bugs 74 | 75 | ## v1.6.0 76 | - added leagues 77 | - added option to request groups from config 78 | - added option to show the leaderboard on the home screen 79 | - added auto-sync option after finishing reviews 80 | - added option to set a status message 81 | - added option to click on a user to add them as a friend/hide them, show status message & other info 82 | - added some tooltips 83 | - config UI changes 84 | - fixed dark mode bug and adjusted colors 85 | - better timeout error handling 86 | - more info in about tab 87 | - display HTML in notification properly 88 | 89 | ## v1.5.4 90 | - store verification token in config.json 91 | - fixed delete account bug 92 | - added "sort by..." to config 93 | 94 | ## v1.5.3: 95 | - fixed verification issue 96 | - sync version number 97 | - added ENEM/Vestibular to groups 98 | 99 | ## v1.5.2: 100 | - stats bug fix 101 | - sync token fix 102 | 103 | ## v1.5.1: 104 | - store and sync SHA1 token to verify users 105 | - added timeout for post/get requests + error message 106 | - added UCFCOM and Concursos to groups 107 | 108 | ## v1.5: 109 | - changed "N/A" to an empty string in retention stats 110 | - fixed dark mode highlight bug 111 | - adjustments for new API (older versions of this add-on won't be supported for much longer) 112 | - added error messages when syncing fails 113 | - threading timer stops when the leaderboard is closed 114 | - added option to sync without opening the leaderboard (Shift+S) 115 | - added MCAT to groups 116 | - added option to choose default leaderboard 117 | 118 | ## v1.4.6: 119 | - Added retention to stats 120 | - Bug fixes 121 | 122 | ## v1.4.5: 123 | - Fixed stats bug 124 | - Added notification for maintenance, downtime, etc. 125 | - Fixed typo 126 | 127 | ## v1.4.4.1: 128 | - Patches for v.1.4.4 129 | - Automatic refresh opt-in option (only for Anki 2.1.24) 130 | 131 | ## v1.4.4: 132 | - When left open, the leaderboard automatically refreshes every two minutes (only for Anki 2.1.24+). Enable "Scroll to yourself" in the config and watch 133 | yourself climb up the leaderboard in real-time while you do your reviews. 134 | - Minor UI changes 135 | - Friends can be exported to a text file 136 | - Various bug fixes 137 | - Added a few error messages 138 | - Added change log to about tab 139 | 140 | ## v1.4.3: 141 | - various hotfixes 142 | - friends are now sorted alphabetically in the config and can be imported from a text file 143 | 144 | ## v1.4.2: 145 | - new config UI 146 | - leaderboard is now resizable 147 | - leaderboard can be left open during reviews (I'm working on automatically refreshing the leaderboard every x minutes) 148 | - added option to automatically scroll to username 149 | - changed 'Reviews past 30 days' to 'Reviews past 31 days' and fixed a calculation bug 150 | - username is now highlighted on all leaderboards 151 | - friends are now highlighted on all leaderboards, but the friends' leaderboard 152 | - first three places are now highlighted on all leaderboards 153 | - max length for new usernames is now 15 characters 154 | - added Medical School Anki as a group/subject 155 | - config UI also opens when you go to Tools>Add-on>Config 156 | 157 | ## v1.4.1: 158 | - small fixes for v.1.4 159 | - added dropdown list for countries 160 | - I can only officially support Anki 2.1.22, but it should also run on 2.1.23 and even 2.1.24 thanks to zjosua 161 | 162 | ## v1.4: 163 | - UI changes 164 | - added country leaderboard 165 | - added subject leaderboard (you can choose between languages, medicine, and law for now. I can add more later) 166 | - added reviews in the last 30 days to stats 167 | - fixed some more stats issues 168 | - reduced server requests 169 | - other bug fixes 170 | 171 | ## v1.3.1 (hotfix): 172 | - fixed calculation bug of stats between midnight and start of the new day 173 | 174 | ## v1.3: 175 | - the beginning of the new day is now completely customizable (this affects how the streak is calculated and which users are shown on the leaderboard) 176 | - friends are now blue on the global leaderboard 177 | - added error message if you're not connected to the internet 178 | 179 | ## v1.2: 180 | - fixed calculating the streak (new day starts now at 4:00 am) 181 | - logging in, creating an account, and deleting an account is now more user friendly 182 | - you can now add friends and compete with them in the "Friends" Tab 183 | - leaderboard only shows people that synced the same day as you 184 | 185 | ## v1.1 186 | - fixed calculating reviews and time bug 187 | - username is limited to 10 characters for now 188 | - fixed alignment issue 189 | - added option to delete an account 190 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "", 3 | "friends": [], 4 | "newday": 4, 5 | "current_group": null, 6 | "groups": [], 7 | "country": "Country", 8 | "scroll": false, 9 | "tab": 0, 10 | "authToken": null, 11 | "achievement": true, 12 | "sortby": "Cards", 13 | "hidden_users": [], 14 | "homescreen": false, 15 | "autosync": false, 16 | "maxUsers": 5, 17 | "focus_on_user": false, 18 | "import_error": true, 19 | "show_medals": true, 20 | "notification_id": null, 21 | "homescreen_data": [], 22 | "medal_users": [] 23 | } -------------------------------------------------------------------------------- /designer/Leaderboard.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 828 10 | 677 11 | 12 | 13 | 14 | Leaderboard 15 | 16 | 17 | 18 | 19 | 20 | 0 21 | 22 | 23 | 24 | Global 25 | 26 | 27 | 28 | 29 | 30 | WhatsThisCursor 31 | 32 | 33 | false 34 | 35 | 36 | QHeaderView::down-arrow { subcontrol-position: center left} 37 | QHeaderView::up-arrow { subcontrol-position: center left} 38 | 39 | 40 | 41 | 1 42 | 43 | 44 | QAbstractScrollArea::AdjustToContentsOnFirstShow 45 | 46 | 47 | QAbstractItemView::NoEditTriggers 48 | 49 | 50 | true 51 | 52 | 53 | QAbstractItemView::SingleSelection 54 | 55 | 56 | QAbstractItemView::SelectRows 57 | 58 | 59 | true 60 | 61 | 62 | true 63 | 64 | 65 | false 66 | 67 | 68 | false 69 | 70 | 71 | false 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | Username 81 | 82 | 83 | 84 | 85 | Reviews today 86 | 87 | 88 | AlignLeading|AlignVCenter 89 | 90 | 91 | 92 | 93 | Minutes today 94 | 95 | 96 | 97 | 98 | Streak 99 | 100 | 101 | 102 | 103 | Past 31 days 104 | 105 | 106 | 107 | 108 | Retention % 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | Friends 118 | 119 | 120 | 121 | 122 | 123 | WhatsThisCursor 124 | 125 | 126 | QHeaderView::down-arrow { subcontrol-position: center left} 127 | QHeaderView::up-arrow { subcontrol-position: center left} 128 | 129 | 130 | 131 | QAbstractScrollArea::AdjustToContentsOnFirstShow 132 | 133 | 134 | QAbstractItemView::NoEditTriggers 135 | 136 | 137 | true 138 | 139 | 140 | QAbstractItemView::SingleSelection 141 | 142 | 143 | QAbstractItemView::SelectRows 144 | 145 | 146 | true 147 | 148 | 149 | true 150 | 151 | 152 | false 153 | 154 | 155 | false 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | Username 165 | 166 | 167 | 168 | 169 | Reviews today 170 | 171 | 172 | 173 | 174 | Minutes today 175 | 176 | 177 | 178 | 179 | Streak 180 | 181 | 182 | 183 | 184 | Past 31 days 185 | 186 | 187 | 188 | 189 | Retention % 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | Country 199 | 200 | 201 | 202 | 203 | 204 | WhatsThisCursor 205 | 206 | 207 | QHeaderView::down-arrow { subcontrol-position: center left} 208 | QHeaderView::up-arrow { subcontrol-position: center left} 209 | 210 | 211 | 212 | QAbstractScrollArea::AdjustToContentsOnFirstShow 213 | 214 | 215 | QAbstractItemView::NoEditTriggers 216 | 217 | 218 | true 219 | 220 | 221 | QAbstractItemView::SingleSelection 222 | 223 | 224 | QAbstractItemView::SelectRows 225 | 226 | 227 | true 228 | 229 | 230 | true 231 | 232 | 233 | false 234 | 235 | 236 | false 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | Username 246 | 247 | 248 | 249 | 250 | Reviews today 251 | 252 | 253 | 254 | 255 | Minutes today 256 | 257 | 258 | 259 | 260 | Streak 261 | 262 | 263 | 264 | 265 | Past 31 days 266 | 267 | 268 | 269 | 270 | Retention % 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | Groups 280 | 281 | 282 | 283 | 284 | 285 | PointingHandCursor 286 | 287 | 288 | true 289 | 290 | 291 | 292 | 293 | 294 | 295 | WhatsThisCursor 296 | 297 | 298 | QHeaderView::down-arrow { subcontrol-position: center left} 299 | QHeaderView::up-arrow { subcontrol-position: center left} 300 | 301 | 302 | 303 | QAbstractScrollArea::AdjustToContentsOnFirstShow 304 | 305 | 306 | QAbstractItemView::NoEditTriggers 307 | 308 | 309 | true 310 | 311 | 312 | QAbstractItemView::SingleSelection 313 | 314 | 315 | QAbstractItemView::SelectRows 316 | 317 | 318 | true 319 | 320 | 321 | true 322 | 323 | 324 | false 325 | 326 | 327 | false 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | Username 337 | 338 | 339 | 340 | 341 | Reviews today 342 | 343 | 344 | 345 | 346 | Minutes today 347 | 348 | 349 | 350 | 351 | Streak 352 | 353 | 354 | 355 | 356 | Past 31 days 357 | 358 | 359 | 360 | 361 | Retention % 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | League 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 10 380 | 75 381 | true 382 | 383 | 384 | 385 | font-weight: bold 386 | 387 | 388 | League Name 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 10 397 | 75 398 | true 399 | 400 | 401 | 402 | font-weight: bold 403 | 404 | 405 | 00 days 00 hours remaining 406 | 407 | 408 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 409 | 410 | 411 | 412 | 413 | 414 | 415 | WhatsThisCursor 416 | 417 | 418 | QHeaderView::down-arrow { subcontrol-position: center left} 419 | QHeaderView::up-arrow { subcontrol-position: center left} 420 | 421 | 422 | 423 | QAbstractScrollArea::AdjustToContentsOnFirstShow 424 | 425 | 426 | QAbstractItemView::NoEditTriggers 427 | 428 | 429 | true 430 | 431 | 432 | QAbstractItemView::SingleSelection 433 | 434 | 435 | QAbstractItemView::SelectRows 436 | 437 | 438 | false 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | Username 448 | 449 | 450 | 451 | 452 | XP 453 | 454 | 455 | 456 | 457 | Minutes 458 | 459 | 460 | 461 | 462 | Reviews 463 | 464 | 465 | 466 | 467 | Retention % 468 | 469 | 470 | 471 | 472 | Days studied % 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | -------------------------------------------------------------------------------- /designer/achievement.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 994 10 | 334 11 | 12 | 13 | 14 | Leaderboard 15 | 16 | 17 | true 18 | 19 | 20 | color: rgb(104, 104, 104); 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | QWebEngineView 31 | QWidget 32 |
PyQt6.QtWebEngineWidgets
33 | 1 34 |
35 |
36 | 37 | 38 |
39 | -------------------------------------------------------------------------------- /designer/banUser.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 336 10 | 112 11 | 12 | 13 | 14 | Confirm group password 15 | 16 | 17 | 18 | 19 | 20 | QLineEdit::Password 21 | 22 | 23 | Password 24 | 25 | 26 | 27 | 28 | 29 | 30 | PointingHandCursor 31 | 32 | 33 | Ban user 34 | 35 | 36 | 37 | 38 | 39 | 40 | groupPassword 41 | banButton 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /designer/icons/krone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/designer/icons/krone.png -------------------------------------------------------------------------------- /designer/icons/person.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/designer/icons/person.png -------------------------------------------------------------------------------- /designer/icons/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/designer/icons/settings.png -------------------------------------------------------------------------------- /designer/report.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 368 10 | 161 11 | 12 | 13 | 14 | Report user 15 | 16 | 17 | 18 | 19 | 20 | PointingHandCursor 21 | 22 | 23 | Report user 24 | 25 | 26 | 27 | 28 | 29 | 30 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> 31 | <html><head><meta name="qrichtext" content="1" /><style type="text/css"> 32 | p, li { white-space: pre-wrap; } 33 | </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:7.8pt; font-weight:400; font-style:normal;"> 34 | <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> 35 | 36 | 37 | 38 | 39 | 40 | 41 | Please explain why you want to report [user]: 42 | 43 | 44 | 45 | 46 | 47 | 48 | reportReason 49 | sendReport 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /designer/reset_password.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 336 10 | 112 11 | 12 | 13 | 14 | Reset password 15 | 16 | 17 | 18 | 19 | 20 | Email address 21 | 22 | 23 | 24 | 25 | 26 | 27 | QLineEdit::Normal 28 | 29 | 30 | Username 31 | 32 | 33 | 34 | 35 | 36 | 37 | PointingHandCursor 38 | 39 | 40 | Send email to reset password 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /designer/user_info.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | Qt::ApplicationModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 757 13 | 393 14 | 15 | 16 | 17 | User 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | PointingHandCursor 26 | 27 | 28 | Hide user 29 | 30 | 31 | false 32 | 33 | 34 | 35 | 36 | 37 | 38 | PointingHandCursor 39 | 40 | 41 | Report 42 | 43 | 44 | false 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 10 53 | 75 54 | true 55 | 56 | 57 | 58 | font-weight: bold 59 | 60 | 61 | Username 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 9 70 | 71 | 72 | 73 | Medals: None 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 9 82 | 75 83 | true 84 | 85 | 86 | 87 | font-weight: bold 88 | 89 | 90 | History: 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 9 99 | 75 100 | true 101 | 102 | 103 | 104 | font-weight: bold 105 | 106 | 107 | Bio 108 | 109 | 110 | 111 | 112 | 113 | 114 | false 115 | 116 | 117 | PointingHandCursor 118 | 119 | 120 | Ban from group 121 | 122 | 123 | false 124 | 125 | 126 | 127 | 128 | 129 | 130 | QAbstractItemView::NoEditTriggers 131 | 132 | 133 | true 134 | 135 | 136 | QAbstractItemView::NoSelection 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 9 145 | 146 | 147 | 148 | League: None 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 8 157 | 158 | 159 | 160 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> 161 | <html><head><meta name="qrichtext" content="1" /><style type="text/css"> 162 | p, li { white-space: pre-wrap; } 163 | </style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8pt; font-weight:400; font-style:normal;"> 164 | <p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">This user hasn't set a bio yet.</p></body></html> 165 | 166 | 167 | true 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 9 176 | 177 | 178 | 179 | Country: None 180 | 181 | 182 | 183 | 184 | 185 | 186 | PointingHandCursor 187 | 188 | 189 | Add friend 190 | 191 | 192 | false 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 9 201 | 202 | 203 | 204 | Groups: 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | true 213 | 214 | 215 | 216 | QHeaderView { 217 | font-family: Arial 218 | } 219 | 220 | 221 | QAbstractItemView::NoEditTriggers 222 | 223 | 224 | true 225 | 226 | 227 | QAbstractItemView::NoSelection 228 | 229 | 230 | true 231 | 232 | 233 | false 234 | 235 | 236 | 237 | Season 238 | 239 | 240 | 241 | 50 242 | false 243 | false 244 | 245 | 246 | 247 | 248 | 249 | Rank 250 | 251 | 252 | 253 | 50 254 | false 255 | false 256 | 257 | 258 | 259 | 260 | 261 | XP 262 | 263 | 264 | 265 | 50 266 | false 267 | false 268 | 269 | 270 | 271 | 272 | 273 | League 274 | 275 | 276 | 277 | 50 278 | false 279 | false 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | hideUser 291 | addFriend 292 | reportUser 293 | banUser 294 | group_list 295 | history 296 | status_message 297 | 298 | 299 | 300 | 301 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Leaderboard", 3 | "package": "41708974", 4 | "ankiweb_id": "41708974", 5 | "author": "Thore Tyborski", 6 | "homepage": "https://github.com/ThoreBor/Anki_Leaderboard" 7 | } 8 | -------------------------------------------------------------------------------- /screenshots/homescreen_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/screenshots/homescreen_dark.png -------------------------------------------------------------------------------- /screenshots/homescreen_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/screenshots/homescreen_light.png -------------------------------------------------------------------------------- /screenshots/lb_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/screenshots/lb_dark.png -------------------------------------------------------------------------------- /screenshots/lb_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/screenshots/lb_light.png -------------------------------------------------------------------------------- /screenshots/league_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/screenshots/league_dark.png -------------------------------------------------------------------------------- /screenshots/league_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/screenshots/league_light.png -------------------------------------------------------------------------------- /server/api3.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.views.decorators.csrf import csrf_exempt 3 | from django.db.models.functions import Lower 4 | from django.contrib.auth.models import User 5 | from django.contrib.auth import authenticate 6 | 7 | import json 8 | import praw 9 | from argon2 import PasswordHasher 10 | import secrets 11 | 12 | from .config import praw_config 13 | from .checkInput import * 14 | from .models import Groups, User_Profile, User_Leaderboard, User_League 15 | 16 | 17 | # Authentication 18 | 19 | def authUser(username, token): 20 | # Authenticate user 21 | if User.objects.filter(username=username).exists(): 22 | user = User.objects.get(username=username) 23 | profile = User_Profile.objects.get(user=user) 24 | if token == profile.auth_token: 25 | return 200 26 | else: 27 | print("authUser 401") 28 | return 401 29 | else: 30 | return 404 31 | 32 | def authGroup(username, group, pwd): 33 | if Groups.objects.filter(group_name=group).exists(): 34 | # Authenticate group password, check if user is banned 35 | group = Groups.objects.get(group_name=group) 36 | if group.pwd_hash == pwd: 37 | if username in group.banned: 38 | print("authGroup 403") 39 | return 403 40 | else: 41 | return 200 42 | else: 43 | print("authGroup 401") 44 | return 401 45 | else: 46 | print("authGroup 404") 47 | return 404 48 | 49 | def authAdmin(username, group): 50 | # Check if user is admin 51 | group = Groups.objects.get(group_name=group) 52 | if username in group.admins: 53 | return 200 54 | else: 55 | print("authAdmin 403") 56 | return 403 57 | 58 | # Manage Account 59 | 60 | @csrf_exempt 61 | def signUp(request): 62 | # Get data from client 63 | email = request.POST.get("email", "") 64 | username = request.POST.get("username", "") 65 | pwd = request.POST.get("pwd", "") 66 | syncDate = request.POST.get("syncDate", "") 67 | version = request.POST.get("version", "") 68 | authToken = secrets.token_hex(nbytes=64) 69 | 70 | # Check if username is valid 71 | if User.objects.filter(username=username).exists(): 72 | response = HttpResponse("

Sign-up Error

This username is already taken. Please choose another one.") 73 | response.status_code = 400 74 | return response 75 | if not usernameIsValid(username): 76 | response = HttpResponse("

Sign-up Error

This username is too long. The username must have less than 15 characters and can't contain any of these characters: 🥇🥈🥉|") 77 | response.status_code = 400 78 | return response 79 | 80 | # Check email and date 81 | if not emailIsValid(email) or not dateIsValid(syncDate): 82 | response = HttpResponse("

Sign-up Error

Invalid input.") 83 | response.status_code = 400 84 | return response 85 | 86 | # Create User 87 | user = User.objects.create_user(username=username, password=pwd, email=email) 88 | user.save() 89 | 90 | user_profile = User_Profile.objects.create( 91 | user=user, 92 | auth_token=authToken, 93 | old_hash=None, 94 | country=None, 95 | groups=None, 96 | suspended=None, 97 | bio=None, 98 | version=version, 99 | sync_date=syncDate, 100 | league="Delta", 101 | history=None 102 | ) 103 | 104 | user_leaderboard = User_Leaderboard.objects.create( 105 | user=user, 106 | streak=0, 107 | cards_today=0, 108 | cards_month=0, 109 | time_today=0, 110 | retention=0, 111 | ) 112 | 113 | user_league = User_League.objects.create( 114 | user=user, 115 | xp=0, 116 | time_spent=0, 117 | cards=0, 118 | retention=0, 119 | days_studied=0, 120 | ) 121 | 122 | response = HttpResponse(json.dumps(authToken)) 123 | response.status_code = 201 124 | print("New sign-up") 125 | return response 126 | 127 | @csrf_exempt 128 | def logIn(request): 129 | # Get data from client 130 | username = request.POST.get("username", "") 131 | pwd = request.POST.get("pwd", "") 132 | 133 | 134 | if User.objects.filter(username=username).exists(): 135 | user = User.objects.get(username=username) 136 | profile = User_Profile.objects.get(user=user) 137 | 138 | # Authenticate user 139 | # Check if user already has a usable password (after migrating the db it is unusable) 140 | if user.has_usable_password(): 141 | user = authenticate(username=username, password=pwd) 142 | if not user: 143 | response = HttpResponse("

Log-in Error

Password and username doesn't match.") 144 | response.status_code = 401 145 | return response 146 | else: 147 | try: 148 | ph = PasswordHasher() 149 | ph.verify(profile.old_hash, pwd) 150 | user.set_password(pwd) 151 | user.save() 152 | except: 153 | response = HttpResponse("

Log-in Error

Password and username doesn't match.") 154 | response.status_code = 401 155 | return response 156 | 157 | # Create token for authentication 158 | authToken = secrets.token_hex(nbytes=64) 159 | 160 | # Update token in database and return token 161 | profile.auth_token = authToken 162 | profile.save() 163 | response = HttpResponse(json.dumps(authToken)) 164 | response.status_code = 200 165 | print("New login") 166 | return response 167 | 168 | else: 169 | response = HttpResponse("

Log-in Error

This user doesn't exist.") 170 | response.status_code = 404 171 | return response 172 | 173 | @csrf_exempt 174 | def deleteAccount(request): 175 | # Get data from client 176 | username = request.POST.get("username", "") 177 | pwd = request.POST.get("pwd", "") 178 | 179 | # Authenticate user 180 | # Check if user already has a usable password (after migrating the db it is unusable) 181 | if User.objects.filter(username=username).exists(): 182 | user = User.objects.get(username=username) 183 | profile = User_Profile.objects.get(user=user) 184 | if user.has_usable_password(): 185 | user = authenticate(username=username, password=pwd) 186 | if not user: 187 | response = HttpResponse("

Log-in Error

Password and username doesn't match.") 188 | response.status_code = 401 189 | return response 190 | else: 191 | try: 192 | ph = PasswordHasher() 193 | ph.verify(profile.old_hash, pwd) 194 | except: 195 | response = HttpResponse("

Log-in Error

Password and username doesn't match.") 196 | response.status_code = 401 197 | return response 198 | 199 | # Update group member number of the groups the user was in 200 | groupList = profile.groups 201 | if not groupList: 202 | groups = [] 203 | else: 204 | groups = groupList 205 | for i in groups: 206 | if Groups.objects.filter(group_name=i).exists(): 207 | group = Groups.objects.get(group_name=i) 208 | group.members -= 1 209 | group.save() 210 | 211 | # Delete user 212 | user = User.objects.get(username=username) 213 | user.delete() 214 | print("Deleted account") 215 | return HttpResponse(status=204) 216 | 217 | else: 218 | response = HttpResponse("

Delete Error

This user doesn't exist.") 219 | response.status_code = 404 220 | return response 221 | 222 | @csrf_exempt 223 | def changeUsername(request): 224 | # Get data from client 225 | username = request.POST.get("username", None) 226 | newUsername = request.POST.get("newUsername", None) 227 | pwd = request.POST.get("pwd", None) 228 | 229 | authToken = secrets.token_hex(nbytes=64) 230 | 231 | # Check if new username is valid 232 | if not usernameIsValid(newUsername): 233 | response = HttpResponse("

Change Username Error

This username is too long. The username must have less than 15 characters.") 234 | response.status_code = 400 235 | return response 236 | 237 | if User.objects.filter(username=newUsername).exists(): 238 | response = HttpResponse("

Change Username Error

This username is already taken. Please choose another one.") 239 | response.status_code = 401 240 | return response 241 | else: 242 | # Authenticate user 243 | user = User.objects.get(username=username) 244 | profile = User_Profile.objects.get(user=user) 245 | if user.has_usable_password(): 246 | user = authenticate(username=username, password=pwd) 247 | if not user: 248 | response = HttpResponse("

Log-in Error

Password and username doesn't match.") 249 | response.status_code = 401 250 | return response 251 | else: 252 | try: 253 | ph = PasswordHasher() 254 | ph.verify(profile.old_hash, pwd) 255 | user.set_password(pwd) 256 | user.save() 257 | except: 258 | response = HttpResponse("

Log-in Error

Password and username doesn't match.") 259 | response.status_code = 401 260 | return response 261 | 262 | # Change username and update token and hash, return token 263 | user.username = newUsername 264 | user.save() 265 | response = HttpResponse(json.dumps(authToken)) 266 | response.status_code = 200 267 | print("Changed username") 268 | return response 269 | 270 | #Manage groups 271 | 272 | @csrf_exempt 273 | def groups(request): 274 | # Return all groups 275 | groups = Groups.objects.values_list("group_name", flat=True).order_by(Lower("group_name")) 276 | response = HttpResponse(json.dumps(list(groups))) 277 | response.status_code = 200 278 | return response 279 | 280 | @csrf_exempt 281 | def joinGroup(request): 282 | # Get data from client 283 | username = request.POST.get("username", None) 284 | group_name = request.POST.get("group", None) 285 | pwd = request.POST.get("pwd", None) 286 | authToken = request.POST.get("authToken", None) 287 | 288 | userAuth = authUser(username, authToken) 289 | if userAuth == 200: 290 | groupAuth = authGroup(username, group_name, pwd) 291 | if groupAuth == 200: 292 | # Get groups and add new group 293 | user = User.objects.get(username=username) 294 | profile = User_Profile.objects.get(user=user) 295 | userGroups = profile.groups 296 | 297 | userGroups.append(group_name) 298 | # Update members 299 | group = Groups.objects.get(group_name=group_name) 300 | group.members += 1 301 | group.save() 302 | profile.save() 303 | 304 | print(f"Somebody joined {group_name}") 305 | return HttpResponse(status=200) 306 | 307 | if groupAuth == 401: 308 | response = HttpResponse("

Join Group Error

Wrong group password.") 309 | response.status_code = 401 310 | return response 311 | 312 | if groupAuth == 403: 313 | response = HttpResponse("

Join Group Error

You're banned from this group") 314 | response.status_code = 403 315 | return response 316 | 317 | if groupAuth == 404: 318 | response = HttpResponse("

Join Group Error

Couldn't find group.") 319 | response.status_code = 404 320 | return response 321 | 322 | if userAuth == 401: 323 | response = HttpResponse("

Join Group Error

Couldn't authenticate user. Please go to Leaderboard>Config>Account and login, or use the 'reset password' option if you forgot your password.") 324 | response.status_code = 401 325 | return response 326 | 327 | if userAuth == 404: 328 | response = HttpResponse("

Join Group Error

Couldn't find user.") 329 | response.status_code = 404 330 | return response 331 | 332 | @csrf_exempt 333 | def createGroup(request): 334 | group_name = request.POST.get("groupName", None).strip() 335 | username = request.POST.get("username", None) 336 | pwd = request.POST.get("pwd", None) 337 | 338 | if Groups.objects.filter(group_name=group_name).exists() or not strIsValid(group_name, 50): 339 | response = HttpResponse("

Create Group Error

This group name is already taken or too long.") 340 | response.status_code = 400 341 | return response 342 | else: 343 | # Create group 344 | group = Groups.objects.create( 345 | group_name=group_name, 346 | pwd_hash=pwd, 347 | admins=[username], 348 | banned=[], 349 | members=1 350 | ) 351 | group.save() 352 | print(f"New group: {group_name}") 353 | return HttpResponse(status=200) 354 | 355 | @csrf_exempt 356 | def leaveGroup(request): 357 | # Get data from client 358 | group_name = request.POST.get("group", None) 359 | authToken = request.POST.get("authToken", None) 360 | username = request.POST.get("username", None) 361 | 362 | # Check if group exists 363 | if not Groups.objects.filter(group_name=group_name).exists(): 364 | # So that users can delete groups in the add-on even if it doesn't exist anymore 365 | return HttpResponse(status=200) 366 | 367 | userAuth = authUser(username, authToken) 368 | if userAuth == 200: 369 | # Remove group 370 | user = User.objects.get(username=username) 371 | profile = User_Profile.objects.get(user=user) 372 | profile.groups.remove(group_name) 373 | profile.save() 374 | # Remove member 375 | group = Groups.objects.get(group_name=group_name) 376 | group.members -= 1 377 | group.save() 378 | print(f"Somebody left {group_name}") 379 | return HttpResponse(status=200) 380 | 381 | if userAuth == 401: 382 | response = HttpResponse("

Leave Group Error

Couldn't authenticate user. Please go to Leaderboard>Config>Account and login, or use the 'reset password' option if you forgot your password.") 383 | response.status_code = 401 384 | return response 385 | 386 | if userAuth == 404: 387 | response = HttpResponse("

Leave Group Error

Couldn't find user.") 388 | response.status_code = 404 389 | return response 390 | 391 | @csrf_exempt 392 | def manageGroup(request): 393 | # Get data from client 394 | username = request.POST.get("username", None) 395 | group = request.POST.get("group", None) 396 | oldPwd = request.POST.get("oldPwd", None) 397 | newPwd = request.POST.get("newPwd", None) 398 | authToken = request.POST.get("authToken", None) 399 | addAdmin = request.POST.get("addAdmin", None) 400 | 401 | # Check input 402 | if not strIsValid(newPwd, 41) or not strIsValid(addAdmin, 16): 403 | response = HttpResponse("

Manage Group Error

Invalid input.") 404 | response = HttpResponse("

Sign-up Error

Invalid input.") 405 | response.status_code = 400 406 | return response 407 | 408 | userAuth = authUser(username, authToken) 409 | if userAuth == 200: 410 | groupAuth = authGroup(username, group, oldPwd) 411 | if groupAuth == 200: 412 | adminAuth = authAdmin(username, group) 413 | 414 | if adminAuth == 200: 415 | group_to_change = Groups.objects.get(group_name=group) 416 | group_to_change.admins.append(addAdmin) 417 | group_to_change.pwd_hash = newPwd 418 | group_to_change.save() 419 | print(f"Somebody made some changes to {group}") 420 | return HttpResponse(status=200) 421 | 422 | if adminAuth == 403: 423 | response = HttpResponse("

Manage Group Error

You're not an admin of this group.") 424 | response.status_code = 403 425 | return response 426 | 427 | if groupAuth == 401: 428 | response = HttpResponse("

Manage Group Error

Wrong group password.") 429 | response.status_code = 401 430 | return response 431 | 432 | if groupAuth == 403: 433 | response = HttpResponse("

Manage Group Error

You're banned from this group") 434 | response.status_code = 403 435 | return response 436 | 437 | if groupAuth == 404: 438 | response = HttpResponse("

Manage Group Error

Couldn't find group.") 439 | response.status_code = 404 440 | return response 441 | 442 | if userAuth == 401: 443 | response = HttpResponse("

Manage Group Error

Couldn't authenticate user. Please go to Leaderboard>Config>Account and login, or use the 'reset password' option if you forgot your password.") 444 | response.status_code = 401 445 | return response 446 | 447 | if userAuth == 404: 448 | response = HttpResponse("

Manage Group Error

Couldn't find user.") 449 | response.status_code = 404 450 | return response 451 | 452 | @csrf_exempt 453 | def banUser(request): 454 | # Get data from client 455 | toBan = request.POST.get("toBan", None) 456 | group = request.POST.get("group", None) 457 | pwd = request.POST.get("pwd", None) 458 | authToken = request.POST.get("authToken", None) 459 | username = request.POST.get("username", None) 460 | 461 | userAuth = authUser(username, authToken) 462 | if userAuth == 200: 463 | groupAuth = authGroup(username, group, pwd) 464 | if groupAuth == 200: 465 | adminAuth = authAdmin(username, group) 466 | if adminAuth == 200: 467 | # Remove group from user and ban user in group 468 | user = User.objects.get(username=toBan) 469 | profile = User_Profile.objects.get(user=user) 470 | profile.groups.remove(group) 471 | profile.save() 472 | group_to_change = Groups.objects.get(group_name=group) 473 | group_to_change.banned.append(toBan) 474 | group_to_change.members -= 1 475 | group_to_change.save() 476 | print(f"Somebody was banned from {group}") 477 | return HttpResponse(status=200) 478 | 479 | if adminAuth == 403: 480 | response = HttpResponse("

Ban User Error

You're not an admin of this group.") 481 | response.status_code = 403 482 | return response 483 | 484 | if groupAuth == 401: 485 | response = HttpResponse("

Ban User Error

Wrong group password.") 486 | response.status_code = 401 487 | return response 488 | 489 | if groupAuth == 403: 490 | response = HttpResponse("

Ban User Error

You're banned from this group") 491 | response.status_code = 403 492 | return response 493 | 494 | if groupAuth == 404: 495 | response = HttpResponse("

Ban User Error

Couldn't find group.") 496 | response.status_code = 404 497 | return response 498 | 499 | if userAuth == 401: 500 | response = HttpResponse("

Ban User Error

Couldn't authenticate user. Please go to Leaderboard>Config>Account and login, or use the 'reset password' option if you forgot your password.") 501 | response.status_code = 401 502 | return response 503 | 504 | if userAuth == 404: 505 | response = HttpResponse("

Ban User Error

Couldn't find user.") 506 | response.status_code = 404 507 | return response 508 | 509 | # Sync 510 | 511 | @csrf_exempt 512 | def sync(request): 513 | # Get data from client 514 | username = request.POST.get("username", "") 515 | streak = request.POST.get("streak", 0) 516 | cards = request.POST.get("cards", 0) 517 | time = request.POST.get("time", 0) 518 | syncDate = request.POST.get("syncDate", "") 519 | month = request.POST.get("month", 0) 520 | country = request.POST.get("country","") 521 | retention = request.POST.get("retention", 0.0) 522 | leagueReviews = request.POST.get("leagueReviews", 0) 523 | leagueTime = request.POST.get("leagueTime", 0) 524 | leagueRetention = request.POST.get("leagueRetention", 0) 525 | leagueDaysLearned = request.POST.get("leagueDaysPercent", 0) 526 | updateLeague = request.POST.get("updateLeague", "True") 527 | authToken = request.POST.get("authToken", None) 528 | version = request.POST.get("version", None) 529 | sortby = request.POST.get("sortby", "cards_today") 530 | 531 | # Check input 532 | if not syncIsValid(streak, cards, time, syncDate, month, country, retention, leagueReviews, leagueTime, leagueRetention, leagueDaysLearned): 533 | response = HttpResponse("

Sync Error

Invalid input.") 534 | response.status_code = 400 535 | return response 536 | 537 | 538 | # Calculate xp for leagues 539 | 540 | if float(leagueRetention) >= 85: 541 | retentionBonus = 1 542 | if float(leagueRetention) < 85 and float(leagueRetention) >= 70: 543 | retentionBonus = 0.85 544 | if float(leagueRetention) < 70 and float(leagueRetention) >= 55: 545 | retentionBonus = 0.70 546 | if float(leagueRetention) < 55 and float(leagueRetention) >= 40: 547 | retentionBonus = 0.55 548 | if float(leagueRetention) < 40 and float(leagueRetention) >= 25: 549 | retentionBonus = 0.40 550 | if float(leagueRetention) < 25 and float(leagueRetention) >= 10: 551 | retentionBonus = 0.25 552 | if float(leagueRetention) < 10: 553 | retentionBonus = 0 554 | 555 | xp = int(float(leagueDaysLearned) * ((6 * float(leagueTime) * 1) + (2 * int(leagueReviews) * float(retentionBonus)))) 556 | 557 | # Authenticate and commit 558 | auth = authUser(username, authToken) 559 | if auth == 200: 560 | user = User.objects.get(username=username) 561 | profile = User_Profile.objects.get(user=user) 562 | leaderboard = User_Leaderboard.objects.get(user=user) 563 | league = User_League.objects.get(user=user) 564 | 565 | if profile.suspended: 566 | response = HttpResponse(f"

Account suspended

This account was suspended due to the following reason:

{sus}

Please write an e-mail to leaderboard_support@protonmail.com or a message me on Reddit, if you think that this was a mistake.") 567 | response.status_code = 403 568 | return response 569 | 570 | leaderboard.streak = streak 571 | leaderboard.cards_today = cards 572 | leaderboard.cards_month = month 573 | leaderboard.time_today = time 574 | leaderboard.retention = retention 575 | leaderboard.save() 576 | profile.country = country 577 | profile.version = version 578 | profile.sync_date = syncDate 579 | profile.save() 580 | 581 | 582 | if updateLeague == "True": 583 | league.xp = xp 584 | league.time_spent = leagueTime 585 | league.cards = leagueReviews 586 | league.retention = leagueRetention 587 | league.days_studied = leagueDaysLearned 588 | league.save() 589 | (xp, leagueTime, leagueReviews, leagueRetention, leagueDaysLearned, username) 590 | 591 | 592 | # Get leaderboard data 593 | 594 | data = [] 595 | user_data = User.objects.values_list( 596 | "user_profile__user__username", 597 | "user_leaderboard__streak", 598 | "user_leaderboard__cards_today", 599 | "user_leaderboard__time_today", 600 | "user_profile__sync_date", 601 | "user_leaderboard__cards_month", 602 | "user_profile__country", 603 | "user_leaderboard__retention", 604 | "user_profile__groups" 605 | ).order_by("-user_leaderboard__{}".format(sortby)) 606 | data.append(list(user_data)) 607 | 608 | # Get league data 609 | user_data = User.objects.values_list( 610 | "user_profile__user__username", 611 | "user_league__xp", 612 | "user_league__time_spent", 613 | "user_league__cards", 614 | "user_league__retention", 615 | "user_profile__league", 616 | "user_profile__history", 617 | "user_league__days_studied", 618 | ).order_by("-user_league__xp") 619 | 620 | data.append(list(user_data)) 621 | print(f"Updated account ({version})") 622 | response = HttpResponse(json.dumps(data)) 623 | response.status_code = 200 624 | return response 625 | 626 | if auth == 401: 627 | response = HttpResponse("

Sync Error

Couldn't authenticate user. Please go to Leaderboard>Config>Account and login, or use the 'reset password' option if you forgot your password.") 628 | response.status_code = 401 629 | return response 630 | 631 | if auth == 404: 632 | response = HttpResponse("

Sync Error

Couldn't find user.") 633 | response.status_code = 404 634 | return response 635 | 636 | # Other 637 | 638 | @csrf_exempt 639 | def reportUser(request): 640 | # Get data from client 641 | username = request.POST.get("username", "") 642 | reportUser = request.POST.get("reportUser", "") 643 | message = request.POST.get("message", "") 644 | 645 | # Send message via reddit 646 | try: 647 | data = praw_config 648 | r = praw.Reddit(username = data["un"], password = data["pw"], client_id = data["cid"], client_secret = data["cs"], user_agent = data["ua"]) 649 | r.redditor('Ttime5').message('Report', f"{username} reported {reportUser}. \n Message: {message}") 650 | return HttpResponse(status=200) 651 | except Exception as e: 652 | response = HttpResponse("

Report User Error

Something went wrong while reporting the user. Please try again.") 653 | response.status_code = 500 654 | print(e) 655 | return response 656 | 657 | @csrf_exempt 658 | def setBio(request): 659 | # Get data from client 660 | statusMsg = request.POST.get("status", None) 661 | if not strIsValid(statusMsg, 281): 662 | statusMsg = None 663 | username = request.POST.get("username", None) 664 | authToken = request.POST.get("authToken", None) 665 | 666 | userAuth = authUser(username, authToken) 667 | if userAuth == 200: 668 | # Set bio 669 | user = User.objects.get(username=username) 670 | profile = User_Profile.objects.get(user=user) 671 | profile.bio = statusMsg 672 | profile.save() 673 | return HttpResponse(status=200) 674 | 675 | if userAuth == 401: 676 | response = HttpResponse("

Set Bio Error

Couldn't authenticate user. Please go to Leaderboard>Config>Account and login, or use the 'reset password' option if you forgot your password.") 677 | response.status_code = 401 678 | return response 679 | 680 | if userAuth == 404: 681 | response = HttpResponse("

Set Bio Error

Couldn't find user.") 682 | response.status_code = 404 683 | return response 684 | 685 | @csrf_exempt 686 | def getBio(request): 687 | # Get data from client 688 | username = request.POST.get("username", None) 689 | 690 | # Return users bio 691 | if User.objects.filter(username=username).exists(): 692 | user = User.objects.get(username=username) 693 | profile = User_Profile.objects.get(user=user) 694 | response = HttpResponse(json.dumps(profile.bio)) 695 | response.status_code = 200 696 | return response 697 | else: 698 | response = HttpResponse("

Get Bio Error

Couldn't find user.") 699 | response.status_code = 404 700 | return response 701 | 702 | @csrf_exempt 703 | def getUserinfo(request): 704 | # Get data from client 705 | username = request.POST.get("username", None) 706 | 707 | if User.objects.filter(username=username).exists(): 708 | # Get user info 709 | user = User.objects.get(username=username) 710 | profile = User_Profile.objects.get(user=user) 711 | response = HttpResponse(json.dumps([profile.country, profile.groups, profile.league, profile.history, profile.bio])) 712 | response.status_code = 200 713 | return response 714 | else: 715 | response = HttpResponse("

Get User Info Error

Couldn't find user.") 716 | response.status_code = 404 717 | return response 718 | 719 | @csrf_exempt 720 | def users(request): 721 | # return list of all usernames 722 | usernames = User.objects.values_list("username", flat=True) 723 | response = HttpResponse(json.dumps(list(usernames))) 724 | response.status_code = 200 725 | return response 726 | 727 | @csrf_exempt 728 | def season(request): 729 | response = HttpResponse(json.dumps([[2023,5,15,0,0,0],[2023,5,29,0,0,0], "Season 67"])) 730 | response.status_code = 200 731 | return response 732 | -------------------------------------------------------------------------------- /server/checkInput.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import re 3 | 4 | # To make sure nothing unwanted gets in the database 5 | 6 | def syncIsValid(streak, cards, time, syncDate, month, country, retention, leagueReviews, leagueTime, leagueRetention, leagueDaysLearned): 7 | if not intIsValid(streak, 10000): 8 | return False 9 | if not intIsValid(cards, 10000): 10 | return False 11 | if not floatIsValid(time, 1000): 12 | return False 13 | if not dateIsValid(syncDate): 14 | return False 15 | if not intIsValid(month, 300000): 16 | return False 17 | if not strIsValid(country, 50): 18 | return False 19 | if not floatIsValid(retention, 101): 20 | return False 21 | if not intIsValid(leagueReviews, 300000): 22 | return False 23 | if not floatIsValid(leagueTime, 30000): 24 | return False 25 | if not floatIsValid(leagueRetention, 101): 26 | return False 27 | if not floatIsValid(leagueDaysLearned, 101): 28 | return False 29 | return True 30 | 31 | def usernameIsValid(username): 32 | if username != "" and len(username) < 16 and "🥇" not in username and "🥈" not in username and "🥉" not in username and "|" not in username: 33 | return True 34 | else: 35 | return False 36 | 37 | def emailIsValid(email): 38 | if "@" in email and "." in email and len(email) < 250: 39 | return True 40 | else: 41 | return False 42 | 43 | def dateIsValid(date): 44 | try: 45 | date = datetime.strptime(date, '%Y-%m-%d %H:%M:%S.%f') 46 | return True 47 | except: 48 | return False 49 | 50 | def intIsValid(i, maximum): 51 | try: 52 | int(i) 53 | if int(i) < maximum: 54 | return True 55 | else: 56 | return False 57 | except Exception as e: 58 | return False 59 | 60 | def floatIsValid(i, maximum): 61 | try: 62 | float(i) 63 | if float(i) < maximum: 64 | return True 65 | else: 66 | return False 67 | except: 68 | return False 69 | 70 | def strIsValid(s, maximum): 71 | try: 72 | str(s) 73 | if len(str(s)) < maximum: 74 | return True 75 | else: 76 | return False 77 | except: 78 | return False -------------------------------------------------------------------------------- /server/manage_leagues.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import json 3 | 4 | alpha_ranking = [] 5 | beta_ranking = [] 6 | gamma_ranking = [] 7 | delta_ranking = [] 8 | 9 | alpha_zero = [] 10 | beta_zero = [] 11 | gamma_zero = [] 12 | 13 | SEASON = int(input("Past season:")) 14 | 15 | conn = sqlite3.connect('/home/ankileaderboard/anki_leaderboard_pythonanywhere/Leaderboard.db') 16 | #conn = sqlite3.connect('Leaderboard.db') 17 | c = conn.cursor() 18 | 19 | alpha = c.execute("SELECT * FROM League WHERE league = 'Alpha' ").fetchall() 20 | beta = c.execute("SELECT * FROM League WHERE league = 'Beta' ").fetchall() 21 | gamma = c.execute("SELECT * FROM League WHERE league = 'Gamma' ").fetchall() 22 | delta = c.execute("SELECT * FROM League WHERE league = 'Delta' ").fetchall() 23 | 24 | print(f"Alpha: {len(alpha)} \nBeta: {len(beta)} \nGamma: {len(gamma)} \nDelta: {len(delta)}") 25 | print("") 26 | 27 | def rewrite_history(username, league, counter): 28 | data = c.execute("SELECT xp, history FROM League WHERE username = (?)", (username,)).fetchone() 29 | xp = data[0] 30 | try: 31 | history = json.loads(data[1]) 32 | except: 33 | history = {"gold": 0, "silver": 0, "bronze": 0, "results": { "leagues": [], "seasons": [], "xp": [], "rank": []}} 34 | 35 | if counter == 1: 36 | history["gold"] += 1 37 | if counter == 2: 38 | history["silver"] += 1 39 | if counter == 3: 40 | history["bronze"] += 1 41 | 42 | results = history["results"] 43 | results["leagues"].append(league) 44 | results["seasons"].append(SEASON) 45 | results["xp"].append(xp) 46 | results["rank"].append(counter) 47 | 48 | new_history = {"gold": history["gold"], "silver": history["silver"], "bronze": history["bronze"], "results":{"leagues": results["leagues"], "seasons": results["seasons"], "xp": results["xp"], "rank": results["rank"]}} 49 | c.execute("""UPDATE League SET history = (?) WHERE username = (?) """, (json.dumps(new_history), username)) 50 | 51 | 52 | c.execute("SELECT username, league, xp FROM League WHERE suspended IS NULL ORDER BY xp DESC") 53 | 54 | for row in c.fetchall(): 55 | user = row[0] 56 | league_name = row[1] 57 | xp = row[2] 58 | 59 | 60 | if league_name == "Alpha": 61 | if xp != 0: 62 | alpha_ranking.append(user) 63 | else: 64 | alpha_zero.append(user) 65 | if league_name == "Beta": 66 | if xp != 0: 67 | beta_ranking.append(user) 68 | else: 69 | beta_zero.append(user) 70 | if league_name == "Gamma": 71 | if xp != 0: 72 | gamma_ranking.append(user) 73 | else: 74 | gamma_zero.append(user) 75 | if league_name == "Delta": 76 | if xp != 0: 77 | delta_ranking.append(user) 78 | 79 | counter = 1 80 | for i in alpha_ranking: 81 | rewrite_history(i, "Alpha", counter) 82 | counter += 1 83 | 84 | counter = 1 85 | for i in beta_ranking: 86 | rewrite_history(i, "Beta", counter) 87 | counter += 1 88 | 89 | counter = 1 90 | for i in gamma_ranking: 91 | rewrite_history(i, "Gamma", counter) 92 | counter += 1 93 | 94 | counter = 1 95 | for i in delta_ranking: 96 | rewrite_history(i, "Delta", counter) 97 | counter += 1 98 | 99 | 100 | for i in alpha_ranking[-int((len(alpha_ranking) / 100) * 20):]: 101 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Beta", i)) 102 | for i in alpha_zero: 103 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Beta", i)) 104 | 105 | for i in beta_ranking[:int((len(beta_ranking) / 100) * 20)]: 106 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Alpha", i)) 107 | for i in beta_ranking[-int((len(beta_ranking) / 100) * 20):]: 108 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Gamma", i)) 109 | for i in beta_zero: 110 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Gamma", i)) 111 | 112 | for i in gamma_ranking[:int((len(gamma_ranking) / 100) * 20)]: 113 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Beta", i)) 114 | for i in gamma_ranking[-int((len(gamma_ranking) / 100) * 20):]: 115 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Delta", i)) 116 | for i in gamma_zero: 117 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Delta", i)) 118 | 119 | for i in delta_ranking[:int((len(delta_ranking) / 100) * 20)]: 120 | c.execute("UPDATE League SET league = (?) WHERE username = (?) ", ("Gamma", i)) 121 | 122 | c.execute("UPDATE League SET xp = 0, time_spend = 0, reviews = 0, retention = 0") 123 | 124 | alpha = c.execute("SELECT * FROM League WHERE league = 'Alpha' ").fetchall() 125 | beta = c.execute("SELECT * FROM League WHERE league = 'Beta' ").fetchall() 126 | gamma = c.execute("SELECT * FROM League WHERE league = 'Gamma' ").fetchall() 127 | delta = c.execute("SELECT * FROM League WHERE league = 'Delta' ").fetchall() 128 | 129 | print(f"Alpha: {len(alpha)} \nBeta: {len(beta)} \nGamma: {len(gamma)} \nDelta: {len(delta)}") 130 | print("") 131 | 132 | conn.commit() -------------------------------------------------------------------------------- /server/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | class Groups(models.Model): 5 | group_name = models.TextField() 6 | pwd_hash = models.TextField(null=True) 7 | admins = models.JSONField(default=list) 8 | banned = models.JSONField(default=list) 9 | members = models.IntegerField() 10 | 11 | class User_Profile(models.Model): 12 | user = models.OneToOneField(User, on_delete=models.CASCADE) 13 | 14 | auth_token = models.TextField(null=True) 15 | old_hash = models.TextField(null=True) 16 | country = models.TextField(null=True) 17 | groups = models.JSONField(null=True, default=list) 18 | league = models.TextField() 19 | history = models.JSONField(null=True, default=dict) 20 | suspended = models.TextField(null=True) 21 | bio = models.TextField(null=True) 22 | version = models.TextField(null=True) 23 | sync_date = models.TextField() 24 | 25 | class User_Leaderboard(models.Model): 26 | user = models.OneToOneField(User, on_delete=models.CASCADE) 27 | 28 | streak = models.IntegerField() 29 | cards_today = models.IntegerField() 30 | cards_month = models.IntegerField(null=True) 31 | time_today = models.FloatField() 32 | retention = models.FloatField(null=True) 33 | 34 | class User_League(models.Model): 35 | user = models.OneToOneField(User, on_delete=models.CASCADE) 36 | 37 | xp = models.IntegerField() 38 | time_spent = models.IntegerField() 39 | cards = models.IntegerField() 40 | retention = models.FloatField() 41 | days_studied = models.FloatField() 42 | 43 | 44 | -------------------------------------------------------------------------------- /server/notification_netlify/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Anki Leaderboard Info 5 | 6 | 7 |
False
8 |
notification_id
9 |
10 |

Title

11 |

12 | Text 13 |

Please open a new issue on GitHub if you want to report a bug or make a feature request. You can also message me on Reddit or write an email to leaderboard_support@protonmail.com. 14 |
15 | 16 | -------------------------------------------------------------------------------- /server/templates/authError.html: -------------------------------------------------------------------------------- 1 |

401 error - invalid token

2 | The verification token you sent doesn't match the one in the database.

3 | If you are using v2.0.0+ you can try to login again (config>account>login). Use the "reset password" button if you forgot your password.

4 | If you need help, please contact me via Reddit or send an email to leaderboard_support@protonmail.com.

5 | Please don't open a new GitHub issue for this. -------------------------------------------------------------------------------- /server/templates/header+footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Leaderboard 5 | 6 | 182 | 183 | 184 | 185 | {% block nav %} 186 | {% endblock %} 187 | 188 | {% include 'messages.html' %} 189 | 190 | {% block table %} 191 | {% endblock %} 192 | 193 | {% block user_info %} 194 | {% endblock %} 195 | 196 |




197 | 203 | 204 | -------------------------------------------------------------------------------- /server/templates/leagues.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block table %} 24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {% for i in data %} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% endfor %} 47 |
#UsernameXPTimeReviewsRetentionDays studied
{{i.place}}{{i.username}}{{i.xp}}{{i.time}}{{i.reviews}}{{i.retention}}%{{i.days_learned}}%
48 |
49 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/messages.html: -------------------------------------------------------------------------------- 1 | {% if messages %} 2 | 7 | {% endif %} -------------------------------------------------------------------------------- /server/templates/newPassword.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block table %} 24 |
25 |
26 | {% csrf_token %} 27 |
28 |

29 |
37 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/privacy.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block table %} 24 |
25 |

Privacy Policy of Anki Leaderboard

26 | 27 |

Anki Leaderboard operates the ankileaderboard.pythonanywhere.com website and the Anki Leaderboard add-on, which provides the SERVICE.

28 | 29 |

This page is used to inform website visitors and add-on users regarding our policies with the collection, use, and disclosure of Personal Information if anyone decided to use our Service.

30 | 31 |

If you choose to use our Service, then you agree to the collection and use of information in relation with this policy. The Personal Information that we collect are used for providing the Service. We will not use or share your information with anyone except as described in this Privacy Policy. Our Privacy Policy was created with the help of the Privacy Policy Template Generator.

32 | 33 |

Information Collection and Use

34 | 35 |

For a better experience while using our Service, we may require you to provide us with certain personally identifiable information, including your username, email address and password. The information that we collect will be used to identify you. The add-on also collects your Anki stats (see below for more information). We don’t share your Personal Information with any third-parties.

36 | 37 |

Anki Stats

38 |

Every time you use the add-on, the following Anki stats will be sent to the server: How many cards you reviewed on that day, how many minutes you studied on that day, your streak (days studied in a row) on that day, how many cards you reviewed in the past 31 days, your retention on that day, how many cards you studied during the current season, how many minutes you studied during the current season, your retention during the current season and how many days you studied during the current season. The regular leaderboard stats will be overwritten every time you sync. Your league stats for the current season will be updated during the season, and will be overwritten when the new season begins. Only your XP (calculated based on your stats) will be saved after the season ended. Groups you joined will be saved until you leave them. The country you picked will be overwritten when you change it (picking a country is optional). If you create a new group, your username will be saved as the admin of that group. Everyone using our Service can see your Anki stats.

39 | 40 |

Deleting Personal Information

41 |

You can delete your Personal Information anytime by deleting your account.

42 | 43 |

Log Data

44 | 45 |

We want to inform you that whenever you visit our Service, we collect information that your browser sends to us that is called Log Data. This Log Data may include information such as your computer’s Internet Protocol ("IP") address, browser version, pages of our Service that you visit, the time and date of your visit. This information is collected automatically and will be deleted after a few days

46 | 47 |

Service Providers

48 | 49 | The website and database are hosted on www.pythonanywhere.com. Pythonanywhere can only access the data of Anki Leaderboard with explicit permission from Anki Leaderboard, or in emergencies, or when required by law enforcement. More information can be found here. 50 | 51 |

Security

52 | 53 |

We value your trust in providing us your Personal Information and protect your data as best as we can, but remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and we cannot guarantee its absolute security.

54 | 55 |

Links to Other Sites

56 | 57 |

Our Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy of these websites. We have no control over, and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services.

58 | 59 |

Children's Privacy

60 | 61 |

Our Services do not address anyone under the age of 13. We do not knowingly collect personal identifiable information from children under 13. In the case we discover that a child under 13 has provided us with personal information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us so that we will be able to do necessary actions.

62 | 63 |

Changes to This Privacy Policy

64 | 65 |

We may update our Privacy Policy from time to time. Thus, we advise you to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on this page. These changes are effective immediately, after they are posted on this page.

66 | 67 |

Contact Us

68 | 69 |

If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us via leaderboard_support@protonmail.com

70 | 71 |
72 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/retention.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block table %} 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for i in data %} 32 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 |
#UsernameRetention %
{{i.place}}{{i.username}}{{i.value}}
39 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/reviews.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block table %} 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for i in data %} 32 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 |
#UsernameReviews
{{i.place}}{{i.username}}{{i.value}}
39 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/streak.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block table %} 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for i in data %} 32 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 |
#UsernameStreak
{{i.place}}{{i.username}}{{i.value}}
39 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/time.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block table %} 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for i in data %} 32 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 |
#UsernameMinutes
{{i.place}}{{i.username}}{{i.value}}
39 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/upload.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | 24 | {% block upload %} 25 |

Mobile Sync (Beta)

26 |
27 | {% csrf_token %} 28 |

29 |
Start of next day (hours after midnight):
30 |

31 |

32 | 33 |
34 | Check this GitHub-Issue before testing this feature.
35 | Upload your Anki database to update your stats. You'll find the 'collection.anki2' file in the AnkiDroid folder on your phone.

36 | This might take a while depending on the size of your collection and internet connection.

37 | After calculating the stats the file will be deleted from the server. 38 |

39 | 40 |
41 |

42 |
43 | 51 | {% endblock %} -------------------------------------------------------------------------------- /server/templates/user.html: -------------------------------------------------------------------------------- 1 | {% extends "header+footer.html" %} 2 | 3 | {% block nav %} 4 | 21 | {% endblock %} 22 | 23 | {% block user_info %} 24 | {% for i in data %} 25 |

{{i.username}}

26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
Reviews{{i.cards}}
Streak{{i.streak}}
Minutes{{i.time}}
Retention{{i.retention}}%
Reviews past 31 days{{i.month}}
Country{{i.country}}
Group{{i.subject}}
League{{i.league}}
60 | {% endfor %} 61 | {% endblock %} -------------------------------------------------------------------------------- /server/urls.py: -------------------------------------------------------------------------------- 1 | from . import website 2 | from . import api 3 | from . import api2 4 | from django.urls import path, re_path 5 | from django.http import HttpResponse 6 | 7 | app_name = "main" 8 | urlpatterns = [ 9 | re_path(r'^robots.txt', lambda x: HttpResponse("User-Agent: *\nDisallow: /", content_type="text/plain"), name="robots_file"), 10 | # Website 11 | path("", website.reviews, name="reviews"), 12 | path("time/", website.time, name="time"), 13 | path("streak/", website.streak, name="streak"), 14 | path("retention/", website.retention, name="retention"), 15 | path(r'user//', website.user, name="user"), 16 | path('alpha/', website.alpha, name="alpha"), 17 | path('beta/', website.beta, name="beta"), 18 | path('gamma/', website.gamma, name="gamma"), 19 | path('delta/', website.delta, name="delta"), 20 | path('privacy/', website.privacy, name="privacy"), 21 | # API v1 22 | path('sync/', api.sync, name="sync"), 23 | path('delete/', api.delete, name="delete"), 24 | path('allusers/', api.all_users, name="allusers"), 25 | path('getdata/', api.get_data, name="get_data"), 26 | path('groups/', api.groups, name="groups"), 27 | path('create_group/', api.create_group, name="create_group"), 28 | path('league/', api.league_data, name="league_data"), 29 | path('season/', api.season, name="season"), 30 | path('setStatus/', api.setStatus, name="setStatus"), 31 | path('getStatus/', api.getStatus, name="getStatus"), 32 | path('getUserinfo/', api.getUserinfo, name="Userinfo"), 33 | path('joinGroup/', api.joinGroup, name="joinGroup"), 34 | path('manageGroup/', api.manageGroup, name="manageGroup"), 35 | path('banUser/', api.banUser, name="banUser"), 36 | path('leaveGroup/', api.leaveGroup, name="leaveGroup"), 37 | path('reportUser/', api.reportUser, name="reportUser"), 38 | path('signUp/', api.signUp, name="signUp"), 39 | path('logIn/', api.logIn, name="logIn"), 40 | path('deleteAccount/', api.deleteAccount, name="deleteAccount"), 41 | path('updateAccount/', api.updateAccount, name="updateAccount"), 42 | path('resetPassword/', api.resetPassword, name="resetPassword"), 43 | path('newPassword/', api.newPassword, name="newPassword"), 44 | path('changeUsername/', api.changeUsername, name="changeUsername"), 45 | # API v2 46 | path('api/v2/signUp/', api2.signUp, name="signUp"), 47 | path('api/v2/logIn/', api2.logIn, name="logIn"), 48 | path('api/v2/deleteAccount/', api2.deleteAccount, name="deleteAccount"), 49 | path('api/v2/changeUsername/', api2.changeUsername, name="changeUsername"), 50 | path('api/v2/resetPassword/', api2.resetPassword, name="resetPassword"), 51 | path('api/v2/newPassword/', api2.newPassword, name="newPassword"), 52 | path('api/v2/groups/', api2.groups, name="groups"), 53 | path('api/v2/joinGroup/', api2.joinGroup, name="joinGroup"), 54 | path('api/v2/createGroup/', api2.createGroup, name="createGroup"), 55 | path('api/v2/leaveGroup/', api2.leaveGroup, name="leaveGroup"), 56 | path('api/v2/manageGroup/', api2.manageGroup, name="manageGroup"), 57 | path('api/v2/banUser/', api2.banUser, name="banUser"), 58 | path('api/v2/reportUser/', api2.reportUser, name="reportUser"), 59 | path('api/v2/setBio/', api2.setBio, name="setBio"), 60 | path('api/v2/getBio/', api2.getBio, name="getBio"), 61 | path('api/v2/getUserinfo/', api2.getUserinfo, name="getUserinfo"), 62 | path('api/v2/users/', api2.users, name="users"), 63 | path('api/v2/season/', api2.season, name="season"), 64 | path('api/v2/sync/', api2.sync, name="sync"), 65 | ] 66 | -------------------------------------------------------------------------------- /server/website.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | import sqlite3 3 | from datetime import datetime, timedelta 4 | import json 5 | 6 | database_path = '/home/ankileaderboard/anki_leaderboard_pythonanywhere/Leaderboard.db' 7 | #database_path = 'Leaderboard.db' 8 | 9 | def reviews(request): 10 | data = [] 11 | counter = 1 12 | start_day = datetime.now() - timedelta(days=1) 13 | conn = sqlite3.connect(database_path) 14 | c = conn.cursor() 15 | c.execute("SELECT Username, Cards, Sync_Date FROM Leaderboard WHERE suspended IS NULL ORDER BY Cards DESC") 16 | 17 | for row in c.fetchall(): 18 | sync_date = row[2] 19 | sync_date = datetime.strptime(sync_date, '%Y-%m-%d %H:%M:%S.%f') 20 | 21 | if sync_date > start_day: 22 | x = {"place": counter, "username": row[0], "value": row[1]} 23 | data.append(x) 24 | counter += 1 25 | return render(request, "reviews.html", {"data": data}) 26 | 27 | def time(request): 28 | data = [] 29 | counter = 1 30 | start_day = datetime.now() - timedelta(days=1) 31 | conn = sqlite3.connect(database_path) 32 | c = conn.cursor() 33 | c.execute("SELECT Username, Time_Spend, Sync_Date FROM Leaderboard WHERE suspended IS NULL ORDER BY Time_Spend DESC") 34 | 35 | for row in c.fetchall(): 36 | sync_date = row[2] 37 | sync_date = datetime.strptime(sync_date, '%Y-%m-%d %H:%M:%S.%f') 38 | 39 | if sync_date > start_day: 40 | x = {"place": counter, "username": row[0], "value": row[1]} 41 | data.append(x) 42 | counter += 1 43 | return render(request, "time.html", {"data": data}) 44 | 45 | def streak(request): 46 | data = [] 47 | counter = 1 48 | start_day = datetime.now() - timedelta(days=1) 49 | conn = sqlite3.connect(database_path) 50 | c = conn.cursor() 51 | c.execute("SELECT Username, Streak, Sync_Date FROM Leaderboard WHERE suspended IS NULL ORDER BY Streak DESC") 52 | 53 | for row in c.fetchall(): 54 | sync_date = row[2] 55 | sync_date = datetime.strptime(sync_date, '%Y-%m-%d %H:%M:%S.%f') 56 | 57 | if sync_date > start_day: 58 | x = {"place": counter, "username": row[0], "value": row[1]} 59 | data.append(x) 60 | counter += 1 61 | return render(request, "streak.html", {"data": data}) 62 | 63 | def retention(request): 64 | data = [] 65 | counter = 1 66 | start_day = datetime.now() - timedelta(days=1) 67 | conn = sqlite3.connect(database_path) 68 | c = conn.cursor() 69 | c.execute("SELECT Username, Retention, Sync_Date FROM Leaderboard WHERE suspended IS NULL ORDER BY Retention DESC") 70 | 71 | for row in c.fetchall(): 72 | sync_date = row[2] 73 | sync_date = datetime.strptime(sync_date, '%Y-%m-%d %H:%M:%S.%f') 74 | 75 | if sync_date > start_day and row[1] != "N/A" and row[1] != "": 76 | x = {"place": counter, "username": row[0], "value": row[1]} 77 | data.append(x) 78 | counter += 1 79 | return render(request, "retention.html", {"data": data}) 80 | 81 | def user(request, username): 82 | conn = sqlite3.connect(database_path) 83 | c = conn.cursor() 84 | user_data = c.execute("SELECT * FROM Leaderboard WHERE Username = (?)",(username,)).fetchone() 85 | league = c.execute("SELECT league, history FROM League WHERE Username = (?)",(username,)).fetchone() 86 | if not league: 87 | league = ["None", "None"] 88 | if user_data[7] == "Country" or "": 89 | country = "-" 90 | else: 91 | country = user_data[7] 92 | if user_data[12]: 93 | groups = json.loads(user_data[12]) 94 | else: 95 | groups = [] 96 | if user_data[6] == "Custom" or user_data[6] == "" or user_data[6] == None: 97 | groups = ["-"] 98 | else: 99 | subject = user_data[6] 100 | if subject not in [group.replace(" ", "") for group in groups]: 101 | groups.append(subject) 102 | data = [{"username": username, "streak": user_data[1], "cards": user_data[2], "time": user_data[3], "month": user_data[5], 103 | "subject": ', '.join(groups), "country": country, "retention": user_data[8], "league": league[0]}] 104 | return render(request, "user.html", {"data": data}) 105 | 106 | def alpha(request): 107 | data = [] 108 | counter = 1 109 | conn = sqlite3.connect(database_path) 110 | c = conn.cursor() 111 | c.execute("SELECT username, xp, time_spend, reviews, retention, league, days_learned FROM League WHERE suspended IS NULL ORDER BY xp DESC") 112 | 113 | for row in c.fetchall(): 114 | if row[5] == "Alpha" and row[1] != 0: 115 | x = {"place": counter, "username": row[0], "xp": row[1], "time": row[2], "reviews": row[3], "retention": row[4], "days_learned": row[6]} 116 | data.append(x) 117 | counter += 1 118 | return render(request, "leagues.html", {"data": data}) 119 | 120 | def beta(request): 121 | data = [] 122 | counter = 1 123 | conn = sqlite3.connect(database_path) 124 | c = conn.cursor() 125 | c.execute("SELECT username, xp, time_spend, reviews, retention, league, days_learned FROM League WHERE suspended IS NULL ORDER BY xp DESC") 126 | 127 | for row in c.fetchall(): 128 | if row[5] == "Beta" and row[1] != 0: 129 | x = {"place": counter, "username": row[0], "xp": row[1], "time": row[2], "reviews": row[3], "retention": row[4], "days_learned": row[6]} 130 | data.append(x) 131 | counter += 1 132 | return render(request, "leagues.html", {"data": data}) 133 | 134 | def gamma(request): 135 | data = [] 136 | counter = 1 137 | conn = sqlite3.connect(database_path) 138 | c = conn.cursor() 139 | c.execute("SELECT username, xp, time_spend, reviews, retention, league, days_learned FROM League WHERE suspended IS NULL ORDER BY xp DESC") 140 | 141 | for row in c.fetchall(): 142 | if row[5] == "Gamma" and row[1] != 0: 143 | x = {"place": counter, "username": row[0], "xp": row[1], "time": row[2], "reviews": row[3], "retention": row[4], "days_learned": row[6]} 144 | data.append(x) 145 | counter += 1 146 | return render(request, "leagues.html", {"data": data}) 147 | 148 | def delta(request): 149 | data = [] 150 | counter = 1 151 | conn = sqlite3.connect(database_path) 152 | c = conn.cursor() 153 | c.execute("SELECT username, xp, time_spend, reviews, retention, league, days_learned FROM League WHERE suspended IS NULL ORDER BY xp DESC") 154 | 155 | for row in c.fetchall(): 156 | if row[5] == "Delta" and row[1] != 0: 157 | x = {"place": counter, "username": row[0], "xp": row[1], "time": row[2], "reviews": row[3], "retention": row[4], "days_learned": row[6]} 158 | data.append(x) 159 | counter += 1 160 | return render(request, "leagues.html", {"data": data}) 161 | 162 | def privacy(request): 163 | return render(request, "privacy.html") -------------------------------------------------------------------------------- /src/Leaderboard.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from datetime import date, timedelta 3 | import json 4 | from pathlib import Path 5 | 6 | from aqt import mw 7 | from aqt.theme import theme_manager 8 | from aqt.qt import QDialog, Qt, QIcon, QPixmap, qtmajor, QAbstractItemView 9 | from aqt.operations import QueryOp 10 | from aqt.utils import showWarning 11 | 12 | if qtmajor > 5: 13 | from ..forms.pyqt6UI import Leaderboard 14 | from PyQt6 import QtCore, QtGui, QtWidgets 15 | else: 16 | from ..forms.pyqt5UI import Leaderboard 17 | from PyQt5 import QtCore, QtGui, QtWidgets 18 | from .Stats import Stats 19 | from .streakAchievement.streakAchievement import streak 20 | from .config_manager import write_config 21 | from .League import load_league 22 | from .userInfo import start_user_info 23 | from .version import version 24 | from .api_connect import postRequest 25 | 26 | class start_main(QDialog): 27 | def __init__(self, season_start, season_end, current_season, parent=None): 28 | self.parent = parent 29 | self.season_start = season_start 30 | self.season_end = season_end 31 | self.current_season = current_season 32 | self.groups_lb = [] 33 | self.config = mw.addonManager.getConfig(__name__) 34 | QDialog.__init__(self, parent, Qt.WindowType.Window) 35 | self.dialog = Leaderboard.Ui_dialog() 36 | self.dialog.setupUi(self) 37 | try: 38 | nightmode = theme_manager.night_mode 39 | except: 40 | #for older versions 41 | try: 42 | nightmode = mw.pm.night_mode() 43 | except: 44 | nightmode = False 45 | nightmode = False 46 | 47 | path = Path(__file__).parents[0] 48 | with open(f"{path}/colors.json", "r") as colors_file: 49 | data = colors_file.read() 50 | colors_themes = json.loads(data) 51 | self.colors = colors_themes["dark"] if nightmode else colors_themes["light"] 52 | self.setupUI() 53 | 54 | def setupUI(self): 55 | _translate = QtCore.QCoreApplication.translate 56 | root = Path(__file__).parents[1] 57 | 58 | icon = QIcon() 59 | icon.addPixmap(QPixmap(f"{root}/designer/icons/krone.png"), QIcon.Mode.Normal, QIcon.State.Off) 60 | self.setWindowIcon(icon) 61 | 62 | header1 = self.dialog.Global_Leaderboard.horizontalHeader() 63 | header1.sectionClicked.connect(lambda: self.updateTable(self.dialog.Global_Leaderboard)) 64 | header2 = self.dialog.Friends_Leaderboard.horizontalHeader() 65 | header2.sectionClicked.connect(lambda: self.updateTable(self.dialog.Friends_Leaderboard)) 66 | header3 = self.dialog.Country_Leaderboard.horizontalHeader() 67 | header3.sectionClicked.connect(lambda: self.updateTable(self.dialog.Country_Leaderboard)) 68 | header4 = self.dialog.Custom_Leaderboard.horizontalHeader() 69 | header4.sectionClicked.connect(lambda: self.updateTable(self.dialog.Custom_Leaderboard)) 70 | 71 | tab_widget = self.dialog.Parent 72 | country_tab = tab_widget.indexOf(self.dialog.tab_3) 73 | subject_tab = tab_widget.indexOf(self.dialog.tab_4) 74 | tab_widget.setTabText(country_tab, self.config["country"]) 75 | for i in range(0, len(self.config["groups"])): 76 | self.dialog.groups.addItem("") 77 | self.dialog.groups.setItemText(i, _translate("Dialog", self.config["groups"][i])) 78 | self.dialog.groups.setCurrentText(self.config["current_group"]) 79 | self.dialog.groups.currentTextChanged.connect(lambda: self.updateTable(self.dialog.Custom_Leaderboard)) 80 | self.dialog.Parent.setCurrentIndex(self.config["tab"]) 81 | 82 | self.dialog.Global_Leaderboard.doubleClicked.connect(lambda: self.user_info(self.dialog.Global_Leaderboard)) 83 | self.dialog.Global_Leaderboard.setToolTip("Double click on user for more info.") 84 | self.dialog.Friends_Leaderboard.doubleClicked.connect(lambda: self.user_info(self.dialog.Friends_Leaderboard)) 85 | self.dialog.Friends_Leaderboard.setToolTip("Double click on user for more info.") 86 | self.dialog.Country_Leaderboard.doubleClicked.connect(lambda: self.user_info(self.dialog.Country_Leaderboard)) 87 | self.dialog.Country_Leaderboard.setToolTip("Double click on user for more info.") 88 | self.dialog.Custom_Leaderboard.doubleClicked.connect(lambda: self.user_info(self.dialog.Custom_Leaderboard)) 89 | self.dialog.Custom_Leaderboard.setToolTip("Double click on user for more info.") 90 | self.dialog.League.doubleClicked.connect(lambda: self.user_info(self.dialog.League)) 91 | self.dialog.League.setToolTip("Double click on user for more info.") 92 | self.dialog.league_label.setToolTip("Leagues (from lowest to highest): Delta, Gamma, Beta, Alpha") 93 | 94 | self.startSync() 95 | 96 | def header(self): 97 | lb_list = [self.dialog.Global_Leaderboard, self.dialog.Friends_Leaderboard, 98 | self.dialog.Country_Leaderboard, self.dialog.Custom_Leaderboard, self.dialog.League] 99 | for l in lb_list: 100 | header = l.horizontalHeader() 101 | header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) 102 | header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch) 103 | header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Stretch) 104 | header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.Stretch) 105 | header.setSectionResizeMode(4, QtWidgets.QHeaderView.ResizeMode.Stretch) 106 | header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeMode.Stretch) 107 | 108 | for i in range(0, 6): 109 | headerItem = l.horizontalHeaderItem(i) 110 | headerItem.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter|QtCore.Qt.AlignmentFlag.AlignVCenter) 111 | 112 | def add_row(self, tab, username, cards, time, streak, month, retention): 113 | rowPosition = tab.rowCount() 114 | tab.setColumnCount(7) 115 | tab.insertRow(rowPosition) 116 | 117 | item = QtWidgets.QTableWidgetItem() 118 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(rowPosition + 1)) 119 | tab.setItem(rowPosition, 0, item) 120 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 121 | 122 | tab.setItem(rowPosition, 1, QtWidgets.QTableWidgetItem(str(username))) 123 | 124 | item = QtWidgets.QTableWidgetItem() 125 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(cards)) 126 | tab.setItem(rowPosition, 2, item) 127 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 128 | 129 | item = QtWidgets.QTableWidgetItem() 130 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, float(time)) 131 | tab.setItem(rowPosition, 3, item) 132 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 133 | 134 | item = QtWidgets.QTableWidgetItem() 135 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(streak)) 136 | tab.setItem(rowPosition, 4, item) 137 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 138 | 139 | item = QtWidgets.QTableWidgetItem() 140 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(month)) 141 | tab.setItem(rowPosition, 5, item) 142 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 143 | 144 | item = QtWidgets.QTableWidgetItem() 145 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, float(retention)) 146 | tab.setItem(rowPosition, 6, item) 147 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 148 | 149 | def switchGroup(self): 150 | self.dialog.Custom_Leaderboard.setSortingEnabled(False) 151 | write_config("current_group", self.dialog.groups.currentText()) 152 | self.dialog.Custom_Leaderboard.setRowCount(0) 153 | for i in self.groups_lb: 154 | if self.dialog.groups.currentText().replace(" ", "") in i[6]: 155 | self.add_row(self.dialog.Custom_Leaderboard, i[0], i[1], i[2], i[3], i[4], i[5]) 156 | self.dialog.Custom_Leaderboard.setSortingEnabled(True) 157 | 158 | def streakAchievement(self, days): 159 | achievementStreak = [7, 31, 50, 75, 100, 150, 200, 300, 365, 400, 500, 600, 700, 800, 900, 1000, 1500, 2000, 3000, 4000, 5000, 6000] 160 | if self.config["achievement"] == True and days in achievementStreak: 161 | s = streak(days) 162 | if s.exec(): 163 | pass 164 | write_config("achievement", False) 165 | 166 | def startSync(self): 167 | op = QueryOp(parent=mw, op=lambda col: self.sync(), success=self.on_success) 168 | op.with_progress().run_in_background() 169 | 170 | def sync(self): 171 | self.streak, cards, time, cardsPast30Days, retention, leagueReviews, leagueTime, leagueRetention, leagueDaysPercent = Stats(self.season_start, self.season_end) 172 | 173 | if datetime.datetime.now() < self.season_end: 174 | data = {"username": self.config["username"], "streak": self.streak, "cards": cards, "time": time, "syncDate": datetime.datetime.now(), 175 | "month": cardsPast30Days, "country": self.config["country"].replace(" ", ""), "retention": retention, 176 | "leagueReviews": leagueReviews, "leagueTime": leagueTime, "leagueRetention": leagueRetention, "leagueDaysPercent": leagueDaysPercent, 177 | "authToken": self.config["authToken"], "version": version, "updateLeague": True, "sortby": self.config["sortby"]} 178 | else: 179 | data = {"username": self.config["username"], "streak": self.streak, "cards": cards, "time": time, "syncDate": datetime.datetime.now(), 180 | "month": cardsPast30Days, "country": self.config["country"].replace(" ", ""), "retention": retention, 181 | "authToken": self.config["authToken"], "version": version, "updateLeague": False, "sortby": self.config["sortby"]} 182 | 183 | self.response = postRequest("sync/", data, 200, False) 184 | try: 185 | if self.response.status_code == 200: 186 | self.response = self.response.json() 187 | self.buildLeaderboard() 188 | load_league(self) 189 | return False 190 | else: 191 | return self.response.text 192 | except Exception as e: 193 | response = f"

Something went wrong

{self.response if isinstance(self.response, str) else ''}

{str(e)}" 194 | return response 195 | 196 | def on_success(self, result): 197 | if result: 198 | showWarning(result, title="Leaderboard Error") 199 | else: 200 | self.header() 201 | self.streakAchievement(self.streak) 202 | self.show() 203 | self.activateWindow() 204 | 205 | def buildLeaderboard(self): 206 | 207 | ### CLEAR TABLE ### 208 | 209 | self.dialog.Global_Leaderboard.setRowCount(0) 210 | self.dialog.Friends_Leaderboard.setRowCount(0) 211 | self.dialog.Country_Leaderboard.setRowCount(0) 212 | self.dialog.Custom_Leaderboard.setRowCount(0) 213 | self.dialog.League.setRowCount(0) 214 | 215 | new_day = datetime.time(int(self.config["newday"]),0,0) 216 | time_now = datetime.datetime.now().time() 217 | if time_now < new_day: 218 | start_day = datetime.datetime.combine(date.today() - timedelta(days=1), new_day) 219 | else: 220 | start_day = datetime.datetime.combine(date.today(), new_day) 221 | 222 | medal_users = self.config["medal_users"] 223 | self.groups_lb = [] 224 | c_groups = [x.replace(" ", "") for x in self.config["groups"]] 225 | 226 | for i in self.response[0]: 227 | username = i[0] 228 | streak = i[1] 229 | cards = i[2] 230 | time = i[3] 231 | sync_date = i[4] 232 | sync_date = datetime.datetime.strptime(sync_date, '%Y-%m-%d %H:%M:%S.%f') 233 | month = i[5] 234 | groups = [] 235 | if i[6]: 236 | groups.append(i[6].replace(" ", "")) 237 | country = i[7] 238 | retention = i[8] 239 | if i[9]: 240 | for group in json.loads(i[9]): 241 | groups.append(group) 242 | groups = [x.replace(" ", "") for x in groups] 243 | 244 | if self.config["show_medals"] == True: 245 | for i in medal_users: 246 | if username in i: 247 | username = f"{username} |" 248 | if i[1] > 0: 249 | username = f"{username} {i[1] if i[1] != 1 else ''}🥇" 250 | if i[2] > 0: 251 | username = f"{username} {i[2] if i[2] != 1 else ''}🥈" 252 | if i[3] > 0: 253 | username = f"{username} {i[3] if i[3] != 1 else ''}🥉" 254 | 255 | if sync_date > start_day and username.split(" |")[0] not in self.config["hidden_users"]: 256 | self.add_row(self.dialog.Global_Leaderboard, username, cards, time, streak, month, retention) 257 | 258 | if country == self.config["country"].replace(" ", "") and country != "Country": 259 | self.add_row(self.dialog.Country_Leaderboard, username, cards, time, streak, month, retention) 260 | 261 | c_groups = [x.replace(" ", "") for x in self.config["groups"]] 262 | if any(i in c_groups for i in groups): 263 | self.groups_lb.append([username, cards, time, streak, month, retention, groups]) 264 | if self.config["current_group"].replace(" ", "") in groups: 265 | self.add_row(self.dialog.Custom_Leaderboard, username, cards, time, streak, month, retention) 266 | 267 | if username.split(" |")[0] in self.config["friends"]: 268 | self.add_row(self.dialog.Friends_Leaderboard, username, cards, time, streak, month, retention) 269 | 270 | self.highlight(self.dialog.Global_Leaderboard) 271 | self.highlight(self.dialog.Friends_Leaderboard) 272 | self.highlight(self.dialog.Country_Leaderboard) 273 | self.highlight(self.dialog.Custom_Leaderboard) 274 | 275 | def updateTable(self, tab): 276 | if tab == self.dialog.Custom_Leaderboard: 277 | self.switchGroup() 278 | self.updateNumbers(tab) 279 | self.highlight(tab) 280 | else: 281 | self.updateNumbers(tab) 282 | self.highlight(tab) 283 | 284 | def updateNumbers(self, tab): 285 | rows = tab.rowCount() 286 | for i in range(0, rows): 287 | item = QtWidgets.QTableWidgetItem() 288 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(i + 1)) 289 | tab.setItem(i, 0, item) 290 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 291 | 292 | def highlight(self, tab): 293 | for i in range(tab.rowCount()): 294 | item = tab.item(i, 1).text().split(" |")[0] 295 | if i % 2 == 0: 296 | for j in range(tab.columnCount()): 297 | tab.item(i, j).setBackground(QtGui.QColor(self.colors["ROW_LIGHT"])) 298 | else: 299 | for j in range(tab.columnCount()): 300 | tab.item(i, j).setBackground(QtGui.QColor(self.colors["ROW_DARK"])) 301 | if item in self.config["friends"] and tab != self.dialog.Friends_Leaderboard: 302 | for j in range(tab.columnCount()): 303 | tab.item(i, j).setBackground(QtGui.QColor(self.colors["FRIEND_COLOR"])) 304 | if item == self.config["username"]: 305 | for j in range(tab.columnCount()): 306 | tab.item(i, j).setBackground(QtGui.QColor(self.colors["USER_COLOR"])) 307 | if item == self.config["username"] and self.config["scroll"] == True: 308 | userposition = tab.item(i, 1) 309 | tab.selectRow(i) 310 | tab.scrollToItem(userposition, QAbstractItemView.PositionAtCenter) 311 | tab.clearSelection() 312 | 313 | if tab.rowCount() >= 3: 314 | for j in range(tab.columnCount()): 315 | tab.item(0, j).setBackground(QtGui.QColor(self.colors["GOLD_COLOR"])) 316 | tab.item(1, j).setBackground(QtGui.QColor(self.colors["SILVER_COLOR"])) 317 | tab.item(2, j).setBackground(QtGui.QColor(self.colors["BRONZE_COLOR"])) 318 | 319 | def user_info(self, tab): 320 | for idx in tab.selectionModel().selectedIndexes(): 321 | row = idx.row() 322 | user_clicked = tab.item(row, 1).text() 323 | if tab == self.dialog.Custom_Leaderboard: 324 | enabled = True 325 | else: 326 | enabled = False 327 | mw.user_info = start_user_info(user_clicked, enabled) 328 | mw.user_info.show() 329 | mw.user_info.raise_() 330 | mw.user_info.activateWindow() -------------------------------------------------------------------------------- /src/League.py: -------------------------------------------------------------------------------- 1 | import json 2 | from aqt import mw 3 | from aqt.qt import Qt, qtmajor, QAbstractItemView 4 | import datetime 5 | 6 | if qtmajor > 5: 7 | from PyQt6 import QtCore, QtGui, QtWidgets 8 | else: 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | from .config_manager import write_config 11 | 12 | def load_league(self): 13 | for i in self.response[1]: 14 | if self.config["username"] in i: 15 | user_league_name = i[5] 16 | self.dialog.league_label.setText(f"{user_league_name}: {self.current_season}") 17 | 18 | time_remaining = self.season_end - datetime.datetime.now() 19 | tr_days = time_remaining.days 20 | 21 | if tr_days < 0: 22 | self.dialog.time_left.setText(f"The next season is going to start soon.") 23 | else: 24 | self.dialog.time_left.setText(f"{tr_days} days remaining") 25 | self.dialog.time_left.setToolTip(f"Season start: {self.season_start} \nSeason end: {self.season_end} (local time)") 26 | 27 | ### BUILD TABLE ### 28 | 29 | medal_users = [] 30 | counter = 0 31 | for i in self.response[1]: 32 | username = i[0] 33 | xp = i[1] 34 | reviews = i[2] 35 | time_spend = i[3] 36 | retention = i[4] 37 | league_name = i[5] 38 | days_learned = i[7] 39 | 40 | if i[6]: 41 | if self.config["show_medals"] == True: 42 | history = json.loads(i[6]) 43 | if history["gold"] != 0 or history["silver"] != 0 or history["bronze"] != 0: 44 | medal_users.append([username, history["gold"], history["silver"], history["bronze"]]) 45 | username = f"{username} |" 46 | if history["gold"] > 0: 47 | username = f"{username} {history['gold'] if history['gold'] != 1 else ''}🥇" 48 | if history["silver"] > 0: 49 | username = f"{username} {history['silver'] if history['silver'] != 1 else ''}🥈" 50 | if history["bronze"] > 0: 51 | username = f"{username} {history['bronze'] if history['bronze'] != 1 else ''}🥉" 52 | 53 | if league_name == user_league_name and xp != 0: 54 | counter += 1 55 | 56 | rowPosition = self.dialog.League.rowCount() 57 | self.dialog.League.setColumnCount(7) 58 | self.dialog.League.insertRow(rowPosition) 59 | 60 | self.dialog.League.setItem(rowPosition, 0, QtWidgets.QTableWidgetItem(str(rowPosition + 1))) 61 | self.dialog.League.item(rowPosition, 0).setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 62 | 63 | self.dialog.League.setItem(rowPosition, 1, QtWidgets.QTableWidgetItem(str(username))) 64 | 65 | self.dialog.League.setItem(rowPosition, 2, QtWidgets.QTableWidgetItem(str(xp))) 66 | self.dialog.League.item(rowPosition, 2).setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 67 | 68 | self.dialog.League.setItem(rowPosition, 3, QtWidgets.QTableWidgetItem(str(reviews))) 69 | self.dialog.League.item(rowPosition, 3).setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 70 | 71 | self.dialog.League.setItem(rowPosition, 4, QtWidgets.QTableWidgetItem(str(time_spend))) 72 | self.dialog.League.item(rowPosition, 4).setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 73 | 74 | self.dialog.League.setItem(rowPosition, 5, QtWidgets.QTableWidgetItem(str(retention))) 75 | self.dialog.League.item(rowPosition, 5).setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 76 | 77 | self.dialog.League.setItem(rowPosition, 6, QtWidgets.QTableWidgetItem(str(days_learned))) 78 | self.dialog.League.item(rowPosition, 6).setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 79 | 80 | if username.split(" |")[0] in self.config['friends']: 81 | for j in range(self.dialog.League.columnCount()): 82 | self.dialog.League.item(counter-1, j).setBackground(QtGui.QColor(self.colors['FRIEND_COLOR'])) 83 | if username.split(" |")[0] == self.config['username']: 84 | for j in range(self.dialog.League.columnCount()): 85 | self.dialog.League.item(counter-1, j).setBackground(QtGui.QColor(self.colors['USER_COLOR'])) 86 | 87 | ### HIGHLIGHT ### 88 | 89 | users = self.dialog.League.rowCount() 90 | 91 | if user_league_name == "Delta": 92 | threshold = int((users / 100) * 20) 93 | if user_league_name == "Gamma": 94 | threshold = int((users / 100) * 20) 95 | if user_league_name == "Beta": 96 | threshold = int((users / 100) * 20) 97 | if user_league_name == "Alpha": 98 | threshold = int((users / 100) * 20) 99 | 100 | for i in range(threshold): 101 | for j in range(self.dialog.League.columnCount()): 102 | item = self.dialog.League.item(i, 1).text().split(" |")[0] 103 | if item == self.config['username'] or item == self.config['friends'] or user_league_name == "Alpha": 104 | continue 105 | else: 106 | self.dialog.League.item(i, j).setBackground(QtGui.QColor(self.colors['LEAGUE_TOP'])) 107 | 108 | if self.dialog.League.rowCount() >= 3: 109 | for j in range(self.dialog.League.columnCount()): 110 | self.dialog.League.item(0, j).setBackground(QtGui.QColor(self.colors['GOLD_COLOR'])) 111 | self.dialog.League.item(1, j).setBackground(QtGui.QColor(self.colors['SILVER_COLOR'])) 112 | self.dialog.League.item(2, j).setBackground(QtGui.QColor(self.colors['BRONZE_COLOR'])) 113 | 114 | for i in range((users - threshold), users): 115 | for j in range(self.dialog.League.columnCount()): 116 | item = self.dialog.League.item(i, 1).text().split(" |")[0] 117 | if item == self.config['username'] and user_league_name != "Delta": 118 | self.dialog.League.item(i, j).setBackground(QtGui.QColor(self.colors['LEAGUE_BOTTOM_USER'])) 119 | if user_league_name == "Delta" or item == self.config['friends']: 120 | continue 121 | else: 122 | self.dialog.League.item(i, j).setBackground(QtGui.QColor(self.colors['LEAGUE_BOTTOM'])) 123 | 124 | ### SCROLL ### 125 | 126 | for i in range(self.dialog.League.rowCount()): 127 | item = self.dialog.League.item(i, 1).text().split(" |")[0] 128 | if item == self.config['username']: 129 | userposition = self.dialog.League.item(i, 1) 130 | self.dialog.League.selectRow(i) 131 | self.dialog.League.scrollToItem(userposition, QAbstractItemView.ScrollHint.PositionAtCenter) 132 | self.dialog.League.clearSelection() 133 | 134 | ### HEADER ### 135 | 136 | for i in range(0, 6): 137 | headerItem = self.dialog.League.horizontalHeaderItem(i) 138 | headerItem.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter|QtCore.Qt.AlignmentFlag.AlignVCenter) 139 | 140 | 141 | write_config("medal_users", medal_users) 142 | -------------------------------------------------------------------------------- /src/Stats.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import date, timedelta, time 3 | import datetime 4 | 5 | from aqt import mw 6 | 7 | def Stats(season_start, season_end): 8 | config = mw.addonManager.getConfig(__name__) 9 | new_day = datetime.time(int(config['newday']),0,0) 10 | time_now = datetime.datetime.now().time() 11 | Streak = streak(config, new_day, time_now) 12 | 13 | cards_past_30_days = reviews_past_31_days(new_day, time_now) 14 | total_cards, retention = reviews_and_retention_today(new_day, time_now) 15 | time_today = time_spend_today(new_day, time_now) 16 | 17 | league_reviews, league_retention = league_reviews_and_retention(season_start, season_end) 18 | league_time = league_time_spend(season_start, season_end) 19 | league_days_percent = league_days_learned(season_start, season_end, new_day, time_now) 20 | 21 | return(Streak, total_cards, time_today, cards_past_30_days, retention, league_reviews, league_time, league_retention, league_days_percent) 22 | 23 | 24 | def get_reviews_and_retention(start_date, end_date): 25 | start = int(start_date.timestamp() * 1000) 26 | end = int(end_date.timestamp() * 1000) 27 | reviews = mw.col.db.scalar("SELECT COUNT(*) FROM revlog WHERE id >= ? AND id < ? AND ease > 0", start, end) 28 | flunked_total = mw.col.db.scalar("SELECT COUNT(*) FROM revlog WHERE ease == 1 AND id >= ? AND id < ?", start, end) 29 | 30 | if reviews == 0: 31 | return 0, 0 32 | 33 | retention = round((100 / reviews) * (reviews - flunked_total), 1) 34 | return reviews, retention 35 | 36 | def get_time_spend(start_date, end_date): 37 | start = int(start_date.timestamp() * 1000) 38 | end = int(end_date.timestamp() * 1000) 39 | 40 | time = mw.col.db.scalar("SELECT SUM(time) FROM revlog WHERE id >= ? AND id < ?", start, end) 41 | if not time or time <= 0: 42 | return 0 43 | return round(time / 60000, 1) 44 | 45 | ###LEADERBOARD### 46 | 47 | def streak(config, new_day, time_now): 48 | new_day_shift_in_ms= int(config['newday']) * 60 * 60 * 1000 49 | date_list = [] 50 | Streak = 0 51 | 52 | date_list = mw.col.db.list("SELECT DISTINCT strftime('%Y-%m-%d', datetime((id - ?) / 1000, 'unixepoch', 'localtime')) FROM revlog WHERE ease > 0 ORDER BY id DESC;", new_day_shift_in_ms) 53 | 54 | if time_now < new_day: 55 | start_date = date.today() - timedelta(days=1) 56 | else: 57 | start_date = date.today() 58 | 59 | end_date = date(2006, 10, 15) 60 | delta = timedelta(days=1) 61 | while start_date >= end_date: 62 | if not start_date.strftime("%Y-%m-%d") in date_list: 63 | break 64 | Streak = Streak + 1 65 | start_date -= delta 66 | return Streak 67 | 68 | def reviews_past_31_days(new_day, time_now): 69 | if time_now < new_day: 70 | end_day = datetime.datetime.combine(date.today(), new_day) 71 | else: 72 | end_day = datetime.datetime.combine(date.today() + timedelta(days=1), new_day) 73 | 74 | start_day = end_day - timedelta(days=31) 75 | reviews, _ = get_reviews_and_retention(start_day, end_day) 76 | return reviews 77 | 78 | def reviews_and_retention_today(new_day, time_now): 79 | if time_now < new_day: 80 | start_day = datetime.datetime.combine(date.today() - timedelta(days=1), new_day) 81 | else: 82 | start_day = datetime.datetime.combine(date.today(), new_day) 83 | return get_reviews_and_retention(start_day, start_day + timedelta(days=1)) 84 | 85 | def time_spend_today(new_day, time_now): 86 | if time_now < new_day: 87 | start_day = datetime.datetime.combine(date.today() - timedelta(days=1), new_day) 88 | else: 89 | start_day = datetime.datetime.combine(date.today(), new_day) 90 | return get_time_spend(start_day, start_day + timedelta(days=1)) 91 | 92 | ###LEAGUE### 93 | 94 | def league_reviews_and_retention(season_start, season_end): 95 | return get_reviews_and_retention(season_start, season_end) 96 | 97 | def league_time_spend(season_start, season_end): 98 | return get_time_spend(season_start, season_end) 99 | 100 | def league_days_learned(season_start, season_end, new_day, time_now): 101 | date_list = [datetime.datetime.combine(season_start, new_day) + timedelta(days=x) for x in range((season_end - season_start).days + 1)] 102 | days_learned = 0 103 | days_over = 0 104 | for i in date_list: 105 | time = get_time_spend(i, i + timedelta(days=1)) 106 | if time >= 5: 107 | days_learned += 1 108 | if i.date() == date.today() and time_now < new_day: 109 | continue 110 | if i.date() <= date.today(): 111 | days_over += 1 112 | 113 | return round((100 / days_over) * days_learned, 1) 114 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | from aqt import mw 2 | from aqt.qt import QAction, QMenu, QKeySequence 3 | from aqt.utils import showInfo, showWarning, tooltip, askUser 4 | from aqt.operations import QueryOp 5 | from anki.utils import pointVersion 6 | 7 | from pathlib import Path 8 | import os 9 | import webbrowser 10 | import requests 11 | from bs4 import BeautifulSoup 12 | import datetime 13 | import json 14 | 15 | from .Leaderboard import start_main 16 | from .config import start_config 17 | from .Stats import Stats 18 | from .config_manager import write_config 19 | from .homescreenLeaderboard import homescreenLeaderboard 20 | from .version import version 21 | from .api_connect import * 22 | from .streakAchievement.streakAchievement import streak as strk 23 | 24 | 25 | class startup(): 26 | def __init__(self): 27 | config = mw.addonManager.getConfig(__name__) 28 | self.root = Path(__file__).parents[1] 29 | self.hL = homescreenLeaderboard() 30 | 31 | # Create menu 32 | self.addMenu('&Leaderboard', "&Open", self.leaderboard, 'Shift+L') 33 | self.addMenu('&Leaderboard', "&Sync and update the home screen leaderboard", self.startBackgroundSync, "Shift+S") 34 | self.addMenu('&Leaderboard', "&Config", self.invokeSetup, "Alt+C") 35 | self.addMenu('&Leaderboard', "&Streak", self.showStreak) 36 | self.addMenu('&Leaderboard', "&Make a feature request or report a bug", self.github) 37 | mw.addonManager.setConfigAction(__name__, self.configSetup) 38 | 39 | try: 40 | from aqt import gui_hooks 41 | gui_hooks.profile_did_open.append(self.profileHook) 42 | gui_hooks.addons_dialog_will_delete_addons.append(self.deleteHook) 43 | if config["autosync"] == True: 44 | gui_hooks.reviewer_will_end.append(self.startBackgroundSync) 45 | except: 46 | if config["import_error"] == True: 47 | showInfo("Because you're using an older Anki version some features of the Leaderboard add-on can't be used.", title="Leaderboard") 48 | write_config("import_error", False) 49 | 50 | def profileHook(self): 51 | config = mw.addonManager.getConfig(__name__) 52 | self.checkInfo() 53 | self.checkBackup() 54 | write_config("achievement", True) 55 | write_config("homescreen_data", []) 56 | self.addUsernameToFriendlist() 57 | self.season() 58 | if config["homescreen"] == True: 59 | self.startBackgroundSync() 60 | 61 | def leaderboard(self): 62 | config = mw.addonManager.getConfig(__name__) 63 | if config["username"] == "" or not config["authToken"]: 64 | self.invokeSetup() 65 | else: 66 | mw.leaderboard = start_main(self.start, self.end, self.currentSeason) 67 | 68 | def invokeSetup(self): 69 | mw.lb_setup = start_config(self.start, self.end, self.hL) 70 | mw.lb_setup.show() 71 | mw.lb_setup.raise_() 72 | mw.lb_setup.activateWindow() 73 | 74 | def configSetup(self): 75 | s = start_config(self.start, self.end, self.hL) 76 | if s.exec(): 77 | pass 78 | 79 | def github(self): 80 | webbrowser.open('https://github.com/ThoreBor/Anki_Leaderboard/issues') 81 | 82 | def checkInfo(self): 83 | config = mw.addonManager.getConfig(__name__) 84 | try: 85 | url = 'https://ankileaderboardinfo.netlify.app' 86 | page = requests.get(url, timeout=10) 87 | soup = BeautifulSoup(page.content, 'html.parser') 88 | if soup.find(id='show_message').get_text() == "True": 89 | info = soup.find("div", id="Message") 90 | notification_id = soup.find("div", id="id").get_text() 91 | if config["notification_id"] != notification_id: 92 | showInfo(str(info), title="Leaderboard") 93 | write_config("notification_id", notification_id) 94 | except Exception as e: 95 | showWarning(f"Timeout error [checkInfo] - No internet connection, or server response took too long.\n {e}", title="Leaderboard error") 96 | 97 | def addUsernameToFriendlist(self): 98 | # Legacy 99 | config = mw.addonManager.getConfig(__name__) 100 | if config['username'] != "" and config['username'] not in config['friends']: 101 | friends = config["friends"] 102 | friends.append(config['username']) 103 | write_config("friends", friends) 104 | 105 | def startBackgroundSync(self): 106 | op = QueryOp(parent=mw, op=lambda col: self.backgroundSync(), success=self.on_success) 107 | if pointVersion() >= 231000: 108 | op.without_collection() 109 | op.run_in_background() 110 | 111 | def backgroundSync(self): 112 | config = mw.addonManager.getConfig(__name__) 113 | streak, cards, time, cardsPast30Days, retention, leagueReviews, leagueTime, leagueRetention, leagueDaysPercent = Stats(self.start, self.end) 114 | 115 | if datetime.datetime.now() < self.end: 116 | data = {'username': config['username'], "streak": streak, "cards": cards, "time": time, "syncDate": datetime.datetime.now(), 117 | "month": cardsPast30Days, "country": config['country'].replace(" ", ""), "retention": retention, 118 | "leagueReviews": leagueReviews, "leagueTime": leagueTime, "leagueRetention": leagueRetention, "leagueDaysPercent": leagueDaysPercent, 119 | "authToken": config["authToken"], "version": version, "updateLeague": True, "sortby": config["sortby"]} 120 | else: 121 | data = {'username': config['username'], "streak": streak, "cards": cards, "time": time, "syncDate": datetime.datetime.now(), 122 | "month": cardsPast30Days, "country": config['country'].replace(" ", ""), "retention": retention, 123 | "authToken": config["authToken"], "version": version, "updateLeague": False, "sortby": config["sortby"]} 124 | 125 | self.response = postRequest("sync/", data, 200, False) 126 | try: 127 | if self.response.status_code == 200: 128 | write_config("homescreen_data", []) 129 | return False 130 | else: 131 | return self.response.text 132 | except: 133 | return self.response 134 | 135 | def on_success(self, result): 136 | if result: 137 | showWarning(result, title="Leaderboard Error") 138 | else: 139 | self.hL.leaderboard_on_deck_browser(self.response.json()) 140 | 141 | def showStreak(self): 142 | streak, _, _, _, _, _, _, _, _ = Stats(self.start, self.end) 143 | if streak > 0: 144 | mw.streak = strk(streak) 145 | mw.streak.show() 146 | mw.streak.raise_() 147 | mw.streak.activateWindow() 148 | else: 149 | tooltip("You don't have a streak") 150 | 151 | def season(self): 152 | response = getRequest("season/") 153 | if response: 154 | response = response.json() 155 | self.start = response[0] 156 | self.start = datetime.datetime(self.start[0],self.start[1],self.start[2],self.start[3],self.start[4],self.start[5]) 157 | self.end = response[1] 158 | self.end = datetime.datetime(self.end[0],self.end[1],self.end[2],self.end[3],self.end[4],self.end[5]) 159 | self.currentSeason = response[2] 160 | else: 161 | self.start = datetime.datetime.now() 162 | self.end = datetime.datetime.now() 163 | self.currentSeason = "" 164 | 165 | def deleteHook(self, dialog, ids): 166 | config = mw.addonManager.getConfig(__name__) 167 | showInfoDeleteAccount = """

Deleting Leaderboard Account

168 | Keep in mind that deleting the add-on only removes the local files. If you also want to delete your account, go to 169 | Leaderboard>Config>Account>Delete account. 170 | """ 171 | askUserCreateMetaBackup = """ 172 |

Leaderboard Configuration Backup

173 | If you want to reinstall this add-on in the future, creating a backup of the configurations is recommended. Do you want to create a backup? 174 | """ 175 | if "41708974" in ids or "Anki_Leaderboard" in ids: 176 | showInfo(showInfoDeleteAccount) 177 | if askUser(askUserCreateMetaBackup): 178 | meta_backup = open(f"{self.root}/leaderboard_meta_backup.json", "w", encoding="utf-8") 179 | meta_backup.write(json.dumps({"config": config})) 180 | meta_backup.close() 181 | tooltip("Successfully created a backup") 182 | 183 | def checkBackup(self): 184 | askUserRestoreFromBackup = """

Leaderboard configuration backup found

185 | Do you want to restore your configurations? 186 | """ 187 | backup_path = f"{self.root}/leaderboard_meta_backup.json" 188 | if os.path.exists(backup_path): 189 | meta_backup = open(backup_path, "r", encoding="utf-8") 190 | if askUser(askUserRestoreFromBackup): 191 | new_meta = open(f"{self.root}/meta.json", "w", encoding="utf-8") 192 | new_meta.write(json.dumps(json.loads(meta_backup.read()))) 193 | new_meta.close() 194 | meta_backup.close() 195 | os.remove(backup_path) 196 | 197 | def addMenu(self, parent, child, function, shortcut=None): 198 | menubar = [i for i in mw.form.menubar.actions()] 199 | if parent in [i.text() for i in menubar]: 200 | menu = [i.parent() for i in menubar][[i.text() for i in menubar].index(parent)] 201 | else: 202 | menu = mw.form.menubar.addMenu(parent) 203 | item = QAction(child, menu) 204 | item.triggered.connect(function) 205 | if shortcut: 206 | item.setShortcut(QKeySequence(shortcut)) 207 | menu.addAction(item) 208 | 209 | startup() -------------------------------------------------------------------------------- /src/api_connect.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from aqt.utils import showWarning 4 | 5 | def postRequest(endpoint, data, statusCode, warning=True): 6 | #url = f"http://127.0.0.1:8000/api/v2/{endpoint}" 7 | url = f"https://ankileaderboard.pythonanywhere.com/api/v2/{endpoint}" 8 | try: 9 | response = requests.post(url, data=data, timeout=15) 10 | 11 | if response.status_code == statusCode: 12 | return response 13 | else: 14 | if warning: 15 | showWarning(str(response.text)) 16 | return False 17 | else: 18 | return response 19 | except Exception as e: 20 | errormsg = f"Timeout error [{url}] - No internet connection, or server response took too long. \n\n{str(e)}" 21 | if warning: 22 | showWarning(errormsg, title="Leaderboard Error") 23 | return False 24 | else: 25 | return errormsg 26 | 27 | 28 | def getRequest(endpoint): 29 | #url = f"http://127.0.0.1:8000/api/v2/{endpoint}" 30 | url = f"https://ankileaderboard.pythonanywhere.com/api/v2/{endpoint}" 31 | try: 32 | response = requests.get(url, timeout=15) 33 | 34 | if response.status_code == 200: 35 | return response 36 | else: 37 | showWarning(str(response.text)) 38 | return False 39 | except Exception as e: 40 | showWarning(f"Timeout error [{url}] - No internet connection, or server response took too long. \n\n{str(e)}", title="Leaderboard Error") 41 | return False -------------------------------------------------------------------------------- /src/banUser.py: -------------------------------------------------------------------------------- 1 | from aqt.qt import QDialog, Qt, QIcon, QPixmap, qtmajor 2 | from aqt.utils import tooltip 3 | from aqt import mw 4 | import hashlib 5 | from pathlib import Path 6 | 7 | if qtmajor > 5: 8 | from ..forms.pyqt6UI import banUser 9 | else: 10 | from ..forms.pyqt5UI import banUser 11 | from .api_connect import postRequest 12 | 13 | 14 | class start_banUser(QDialog): 15 | def __init__(self, user_clicked, parent=None): 16 | self.parent = parent 17 | QDialog.__init__(self, parent, Qt.WindowType.Window) 18 | self.dialog = banUser.Ui_Dialog() 19 | self.dialog.setupUi(self) 20 | self.user_clicked = user_clicked 21 | self.setupUI() 22 | 23 | def setupUI(self): 24 | self.dialog.banButton.clicked.connect(self.banUser) 25 | root = Path(__file__).parents[1] 26 | 27 | icon = QIcon() 28 | icon.addPixmap(QPixmap(f"{root}/designer/icons/person.png"), QIcon.Mode.Normal, QIcon.State.Off) 29 | self.setWindowIcon(icon) 30 | 31 | def banUser(self): 32 | config = mw.addonManager.getConfig(__name__) 33 | password = hashlib.sha1(self.dialog.groupPassword.text().encode('utf-8')).hexdigest().upper() 34 | toBan = self.user_clicked 35 | data = {"toBan": toBan, "group": config["current_group"], "pwd": password, "authToken": config["authToken"], "username": config["username"]} 36 | response = postRequest("banUser/", data, 200) 37 | if response: 38 | tooltip(f"{toBan} is now banned from {config['current_group']}") 39 | 40 | -------------------------------------------------------------------------------- /src/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "light": { 3 | "USER_COLOR": "#51f564", 4 | "FRIEND_COLOR": "#2176ff", 5 | "GOLD_COLOR": "#ffd700", 6 | "SILVER_COLOR": "#c0c0c0", 7 | "BRONZE_COLOR": "#bf8970", 8 | "ROW_LIGHT": "#ffffff", 9 | "ROW_DARK": "#f5f5f5", 10 | "LEAGUE_TOP": "#abffc7", 11 | "LEAGUE_BOTTOM": "#f75e5e", 12 | "LEAGUE_BOTTOM_USER": "#d14f4f" 13 | }, 14 | "dark": { 15 | "USER_COLOR": "#0aad1d", 16 | "FRIEND_COLOR": "#0058e6", 17 | "GOLD_COLOR": "#ccac00", 18 | "SILVER_COLOR": "#999999", 19 | "BRONZE_COLOR": "#a7684a", 20 | "ROW_LIGHT": "#3A3A3A", 21 | "ROW_DARK": "#2F2F31", 22 | "LEAGUE_TOP": "#42a663", 23 | "LEAGUE_BOTTOM": "#b83333", 24 | "LEAGUE_BOTTOM_USER": "#9e2c2c" 25 | } 26 | } -------------------------------------------------------------------------------- /src/config_manager.py: -------------------------------------------------------------------------------- 1 | from aqt import mw 2 | 3 | def write_config(name, value): 4 | config = mw.addonManager.getConfig(__name__) 5 | config_content = { 6 | "username": config["username"], 7 | "friends": config["friends"], 8 | "newday": config["newday"], 9 | "current_group": config["current_group"], 10 | "groups": config["groups"], 11 | "country": config["country"], 12 | "scroll": config["scroll"], 13 | "tab": config["tab"], 14 | "authToken": config["authToken"], 15 | "achievement": config["achievement"], 16 | "sortby": config["sortby"], 17 | "hidden_users": config["hidden_users"], 18 | "homescreen": config["homescreen"], 19 | "autosync": config["autosync"], 20 | "maxUsers": config["maxUsers"], 21 | "focus_on_user": config["focus_on_user"], 22 | "import_error": config["import_error"], 23 | "show_medals": config["show_medals"], 24 | "notification_id": config["notification_id"], 25 | "homescreen_data": config["homescreen_data"], 26 | "medal_users": config["medal_users"] 27 | } 28 | config_content[name] = value 29 | mw.addonManager.writeConfig(__name__, config_content) -------------------------------------------------------------------------------- /src/homescreenLeaderboard.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from datetime import date, timedelta 3 | import json 4 | 5 | from aqt import gui_hooks 6 | from aqt.deckbrowser import DeckBrowser 7 | from aqt import mw 8 | from aqt.deckbrowser import DeckBrowser 9 | from anki.hooks import wrap 10 | 11 | from .userInfo import start_user_info 12 | from .config_manager import write_config 13 | 14 | 15 | class homescreenLeaderboard(): 16 | def __init__(self): 17 | pass 18 | 19 | def getData(self): 20 | config = mw.addonManager.getConfig(__name__) 21 | medal_users = config["medal_users"] 22 | if config["tab"] != 4: 23 | newDay = datetime.time(int(config['newday']),0,0) 24 | timeNow = datetime.datetime.now().time() 25 | if timeNow < newDay: 26 | startDay = datetime.datetime.combine(date.today() - timedelta(days=1), newDay) 27 | else: 28 | startDay = datetime.datetime.combine(date.today(), newDay) 29 | 30 | counter = 0 31 | for i in self.data[0]: 32 | username = i[0] 33 | streak = i[1] 34 | cards = i[2] 35 | time = i[3] 36 | syncDate = i[4] 37 | syncDate = datetime.datetime.strptime(syncDate, '%Y-%m-%d %H:%M:%S.%f') 38 | month = i[5] 39 | country = i[7] 40 | retention = i[8] 41 | groups = [] 42 | if i[6]: 43 | groups.append(i[6]) 44 | if i[9]: 45 | for group in json.loads(i[9]): 46 | groups.append(group) 47 | groups = [x.replace(" ", "") for x in groups] 48 | 49 | if config["show_medals"] == True: 50 | for i in medal_users: 51 | if username in i: 52 | username = f"{username} |" 53 | if i[1] > 0: 54 | username = f"{username} {i[1] if i[1] != 1 else ''}🥇" 55 | if i[2] > 0: 56 | username = f"{username} {i[2] if i[2] != 1 else ''}🥈" 57 | if i[3] > 0: 58 | username = f"{username} {i[3] if i[3] != 1 else ''}🥉" 59 | 60 | if syncDate > startDay and username not in config["hidden_users"]: 61 | if config["tab"] == 0: 62 | counter += 1 63 | self.lbList.append([counter, username, cards, time, streak, month, retention]) 64 | if config["tab"] == 1 and username in config["friends"]: 65 | counter += 1 66 | self.lbList.append([counter, username, cards, time, streak, month, retention]) 67 | if config["tab"] == 2 and country == config["country"].replace(" ", ""): 68 | counter += 1 69 | self.lbList.append([counter, username, cards, time, streak, month, retention]) 70 | if config["tab"] == 3 and config["current_group"].replace(" ", "") in groups: 71 | counter += 1 72 | self.lbList.append([counter, username, cards, time, streak, month, retention]) 73 | 74 | if config["tab"] == 4: 75 | for i in self.data[1]: 76 | if config["username"] in i: 77 | userLeagueName = i[5] 78 | counter = 0 79 | for i in self.data[1]: 80 | username = i[0] 81 | xp = i[1] 82 | reviews = i[2] 83 | time = i[3] 84 | retention = i[4] 85 | leagueName = i[5] 86 | daysLearned = i[7] 87 | 88 | for i in medal_users: 89 | if username in i: 90 | username = f"{username} |" 91 | if i[1] > 0: 92 | username = f"{username} {i[1] if i[1] != 1 else ''}🥇" 93 | if i[2] > 0: 94 | username = f"{username} {i[2] if i[2] != 1 else ''}🥈" 95 | if i[3] > 0: 96 | username = f"{username} {i[3] if i[3] != 1 else ''}🥉" 97 | 98 | if leagueName == userLeagueName and xp != 0: 99 | counter += 1 100 | self.lbList.append([counter, username, xp, reviews, time, retention, daysLearned]) 101 | 102 | write_config("homescreen_data", self.lbList) 103 | 104 | def userSublist(self, n, index): 105 | startIdx = index - (n-1) // 2 106 | endIdx = index + n // 2 107 | return self.lbList[startIdx:endIdx+1] 108 | 109 | def on_deck_browser_will_render_content(self, overview, content): 110 | config = mw.addonManager.getConfig(__name__) 111 | result = [] 112 | if not self.lbList: 113 | self.getData() 114 | 115 | if config["tab"] == 0: 116 | title = "

Global

" 117 | if config["tab"] == 1: 118 | title = "

Friends

" 119 | if config["tab"] == 2: 120 | title = f"

{config['country']}

" 121 | if config["tab"] == 3: 122 | title = f"

{config['current_group']}

" 123 | if config["tab"] == 4: 124 | title = "

League

" 125 | 126 | if config["focus_on_user"] == True and len(self.lbList) > config["maxUsers"]: 127 | for i in self.lbList: 128 | if config["username"] == i[1].split(" |")[0]: 129 | userIndex = self.lbList.index(i) 130 | result = self.userSublist(config["maxUsers"], userIndex) 131 | 132 | if not result: 133 | result = self.lbList[:config["maxUsers"]] 134 | 135 | tableStyle = """ 136 | 156 | """ 157 | 158 | else: 159 | result = self.lbList[:config["maxUsers"]] 160 | 161 | tableStyle = """ 162 | 194 | """ 195 | 196 | if config["tab"] != 4: 197 | tableHeader = """ 198 |
199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | """ 210 | tableContent = "" 211 | for i in result: 212 | tableContent += f""" 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | """ 223 | if config["tab"] == 4: 224 | tableHeader = """ 225 |
226 |
#UsernameReviewsMinutesStreakPast monthRetention
{i[0]}{i[2]}{i[3]}{i[4]}{i[5]}{i[6]}%
227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | """ 237 | tableContent = "" 238 | for i in result: 239 | tableContent += f""" 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | """ 250 | 251 | content.stats += title + tableStyle + tableHeader + tableContent + "
#UsernameXPMinutesReviewsRetentionDays learned
{i[0]}{i[2]}{i[3]}{i[4]}{i[5]}%{i[6]}%
" 252 | 253 | def deleteLeaderboard(self): 254 | gui_hooks.deck_browser_will_render_content.remove(self.on_deck_browser_will_render_content) 255 | DB = DeckBrowser(mw) 256 | DB.refresh() 257 | 258 | def leaderboard_on_deck_browser(self, response): 259 | config = mw.addonManager.getConfig(__name__) 260 | self.data = response 261 | self.lbList = config["homescreen_data"] 262 | self.deleteLeaderboard() 263 | if config["homescreen"] == True: 264 | gui_hooks.deck_browser_will_render_content.append(self.on_deck_browser_will_render_content) 265 | DB = DeckBrowser(mw) 266 | DB.refresh() 267 | 268 | 269 | def deckbrowser_linkHandler_wrapper(overview, url): 270 | url = url.split(":") 271 | if url[0] == "userinfo": 272 | mw.user_info = start_user_info(url[1], False) 273 | mw.user_info.show() 274 | mw.user_info.raise_() 275 | mw.user_info.activateWindow() 276 | 277 | DeckBrowser._linkHandler = wrap(DeckBrowser._linkHandler, deckbrowser_linkHandler_wrapper, "after") -------------------------------------------------------------------------------- /src/reportUser.py: -------------------------------------------------------------------------------- 1 | from aqt.qt import QDialog, Qt, QIcon, QPixmap, qtmajor 2 | from aqt.utils import tooltip 3 | from aqt import mw 4 | from pathlib import Path 5 | 6 | if qtmajor > 5: 7 | from ..forms.pyqt6UI import report 8 | else: 9 | from ..forms.pyqt5UI import report 10 | from .api_connect import postRequest 11 | 12 | class start_report(QDialog): 13 | def __init__(self, user_clicked, parent=None): 14 | self.parent = parent 15 | self.user_clicked = user_clicked 16 | QDialog.__init__(self, parent, Qt.WindowType.Window) 17 | self.dialog = report.Ui_Dialog() 18 | self.dialog.setupUi(self) 19 | self.setupUI() 20 | 21 | def setupUI(self): 22 | root = Path(__file__).parents[1] 23 | icon = QIcon() 24 | icon.addPixmap(QPixmap(f"{root}/designer/icons/person.png"), QIcon.Mode.Normal, QIcon.State.Off) 25 | self.setWindowIcon(icon) 26 | 27 | self.dialog.reportLabel.setText(f"Please explain why you want to report {self.user_clicked}:") 28 | self.dialog.sendReport.clicked.connect(self.sendReport) 29 | 30 | def sendReport(self): 31 | config = mw.addonManager.getConfig(__name__) 32 | data = {"username": config["username"], "reportUser": self.user_clicked, "message": self.dialog.reportReason.toPlainText()} 33 | response = postRequest("reportUser/", data, 200) 34 | if response: 35 | tooltip(f"{self.user_clicked} was succsessfully reported") 36 | -------------------------------------------------------------------------------- /src/resetPassword.py: -------------------------------------------------------------------------------- 1 | from aqt.qt import QDialog, Qt, QIcon, QPixmap, qtmajor 2 | from aqt.utils import tooltip, showWarning 3 | from pathlib import Path 4 | 5 | if qtmajor > 5: 6 | from ..forms.pyqt6UI import reset_password 7 | else: 8 | from ..forms.pyqt5UI import reset_password 9 | from .api_connect import postRequest 10 | 11 | class start_resetPassword(QDialog): 12 | def __init__(self, parent=None): 13 | self.parent = parent 14 | QDialog.__init__(self, parent, Qt.WindowType.Window) 15 | self.dialog = reset_password.Ui_Dialog() 16 | self.dialog.setupUi(self) 17 | self.setupUI() 18 | 19 | def setupUI(self): 20 | self.dialog.resetButton.clicked.connect(self.resetPassword) 21 | root = Path(__file__).parents[1] 22 | 23 | icon = QIcon() 24 | icon.addPixmap(QPixmap(f"{root}/designer/icons/person.png"), QIcon.Mode.Normal, QIcon.State.Off) 25 | self.setWindowIcon(icon) 26 | 27 | def resetPassword(self): 28 | email = self.dialog.resetEmail.text() 29 | username = self.dialog.resetUsername.text() 30 | if not email or not username: 31 | showWarning("Please enter your email address and username first.") 32 | return 33 | 34 | data = {"email": email, "username": username} 35 | response = postRequest("resetPassword/", data, 200) 36 | if response: 37 | tooltip("Email sent") 38 | -------------------------------------------------------------------------------- /src/streakAchievement/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThoreBor/Anki_Leaderboard/e746bd382396c7732ffc2a14ad86b1af291376e0/src/streakAchievement/__init__.py -------------------------------------------------------------------------------- /src/streakAchievement/calendar.js: -------------------------------------------------------------------------------- 1 | const pages = document.querySelector('.pages'); 2 | const locale = window.navigator.language || 'en-us'; 3 | 4 | var data = JSON.parse(data); 5 | var streak = data["streak"]; 6 | 7 | let today = new Date(); 8 | let newDate = new Date(); 9 | if (streak > 50) { 10 | newDate.setDate(newDate.getDate() - 50); 11 | } else { 12 | newDate.setDate(newDate.getDate() - streak); 13 | } 14 | let dayNum = newDate.getDate(); 15 | let month = newDate.getMonth(); 16 | let dayName = newDate.toLocaleString(locale, { weekday: 'long' }); 17 | let monthName = newDate.toLocaleString(locale, { month: 'long' }); 18 | let year = newDate.getFullYear(); 19 | let lastPage = null; 20 | 21 | function daysInMonth(month, year) { 22 | return new Date(year, month + 1, 0).getDate(); 23 | } 24 | 25 | function getNewDate() { 26 | if (dayNum < daysInMonth(month, year)) { 27 | dayNum++; 28 | } else { 29 | dayNum = 1; 30 | } 31 | if (dayNum === 1 && month < 11) { 32 | month++; 33 | } else if (dayNum === 1 && month === 11) { 34 | month = 0; 35 | } 36 | if (dayNum === 1 && month === 0) { 37 | year++; 38 | } 39 | newDate = new Date(year, month, dayNum); 40 | dayName = newDate.toLocaleString('en-us', { weekday: 'long' }); 41 | monthName = newDate.toLocaleString('en-us', { month: 'long' }); 42 | } 43 | 44 | function updateCalendar(target) { 45 | if (target && target.classList.contains('page')) { 46 | target.classList.add('tear'); 47 | setTimeout(() => { 48 | pages.removeChild(target); 49 | }, 800); 50 | } else { 51 | return; 52 | } 53 | renderPage(); 54 | } 55 | 56 | function renderPage() { 57 | const newPage = document.createElement('div'); 58 | newPage.classList.add('page'); 59 | newPage.innerHTML = ` 60 |

${monthName}

61 |

${dayNum}

62 |

${dayName}

63 |

${year}

64 | `; 65 | pages.appendChild(newPage); 66 | lastPage = newPage; 67 | } 68 | 69 | function updateText() { 70 | days = parseInt(streak - (today.getTime() - newDate.getTime()) / (1000 * 3600 * 24)) 71 | document.getElementById("streak").innerHTML = `Congratulations on your ${days} day streak.
Keep it up!`; 72 | } 73 | 74 | function party() { 75 | setInterval(function() { 76 | confetti({ 77 | particleCount: 7, 78 | angle: 60, 79 | spread: 55, 80 | origin: { x: 0 } 81 | }); 82 | confetti({ 83 | particleCount: 7, 84 | angle: 120, 85 | spread: 55, 86 | origin: { x: 1 } 87 | }); 88 | }, 100) 89 | } 90 | 91 | renderPage(); 92 | updateText(); 93 | 94 | var timer = setInterval(function(){ 95 | getNewDate(); 96 | if (newDate < today) { 97 | updateCalendar(lastPage); 98 | updateText(); 99 | } else { 100 | updateText(); 101 | clearInterval(timer); 102 | party(); 103 | } 104 | }, 400) -------------------------------------------------------------------------------- /src/streakAchievement/calendar_License.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 by Nikita Hlopov (https://codepen.io/nikitahl/pen/xmvVmW) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /src/streakAchievement/canvas-confetti.js: -------------------------------------------------------------------------------- 1 | // canvas-confetti v1.5.1 built on 2022-02-08T22:20:40.944Z 2 | !(function (window, module) { 3 | // source content 4 | (function main(global, module, isWorker, workerSize) { 5 | var canUseWorker = !!( 6 | global.Worker && 7 | global.Blob && 8 | global.Promise && 9 | global.OffscreenCanvas && 10 | global.OffscreenCanvasRenderingContext2D && 11 | global.HTMLCanvasElement && 12 | global.HTMLCanvasElement.prototype.transferControlToOffscreen && 13 | global.URL && 14 | global.URL.createObjectURL); 15 | 16 | function noop() {} 17 | 18 | // create a promise if it exists, otherwise, just 19 | // call the function directly 20 | function promise(func) { 21 | var ModulePromise = module.exports.Promise; 22 | var Prom = ModulePromise !== void 0 ? ModulePromise : global.Promise; 23 | 24 | if (typeof Prom === 'function') { 25 | return new Prom(func); 26 | } 27 | 28 | func(noop, noop); 29 | 30 | return null; 31 | } 32 | 33 | var raf = (function () { 34 | var TIME = Math.floor(1000 / 60); 35 | var frame, cancel; 36 | var frames = {}; 37 | var lastFrameTime = 0; 38 | 39 | if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { 40 | frame = function (cb) { 41 | var id = Math.random(); 42 | 43 | frames[id] = requestAnimationFrame(function onFrame(time) { 44 | if (lastFrameTime === time || lastFrameTime + TIME - 1 < time) { 45 | lastFrameTime = time; 46 | delete frames[id]; 47 | 48 | cb(); 49 | } else { 50 | frames[id] = requestAnimationFrame(onFrame); 51 | } 52 | }); 53 | 54 | return id; 55 | }; 56 | cancel = function (id) { 57 | if (frames[id]) { 58 | cancelAnimationFrame(frames[id]); 59 | } 60 | }; 61 | } else { 62 | frame = function (cb) { 63 | return setTimeout(cb, TIME); 64 | }; 65 | cancel = function (timer) { 66 | return clearTimeout(timer); 67 | }; 68 | } 69 | 70 | return { frame: frame, cancel: cancel }; 71 | }()); 72 | 73 | var getWorker = (function () { 74 | var worker; 75 | var prom; 76 | var resolves = {}; 77 | 78 | function decorate(worker) { 79 | function execute(options, callback) { 80 | worker.postMessage({ options: options || {}, callback: callback }); 81 | } 82 | worker.init = function initWorker(canvas) { 83 | var offscreen = canvas.transferControlToOffscreen(); 84 | worker.postMessage({ canvas: offscreen }, [offscreen]); 85 | }; 86 | 87 | worker.fire = function fireWorker(options, size, done) { 88 | if (prom) { 89 | execute(options, null); 90 | return prom; 91 | } 92 | 93 | var id = Math.random().toString(36).slice(2); 94 | 95 | prom = promise(function (resolve) { 96 | function workerDone(msg) { 97 | if (msg.data.callback !== id) { 98 | return; 99 | } 100 | 101 | delete resolves[id]; 102 | worker.removeEventListener('message', workerDone); 103 | 104 | prom = null; 105 | done(); 106 | resolve(); 107 | } 108 | 109 | worker.addEventListener('message', workerDone); 110 | execute(options, id); 111 | 112 | resolves[id] = workerDone.bind(null, { data: { callback: id }}); 113 | }); 114 | 115 | return prom; 116 | }; 117 | 118 | worker.reset = function resetWorker() { 119 | worker.postMessage({ reset: true }); 120 | 121 | for (var id in resolves) { 122 | resolves[id](); 123 | delete resolves[id]; 124 | } 125 | }; 126 | } 127 | 128 | return function () { 129 | if (worker) { 130 | return worker; 131 | } 132 | 133 | if (!isWorker && canUseWorker) { 134 | var code = [ 135 | 'var CONFETTI, SIZE = {}, module = {};', 136 | '(' + main.toString() + ')(this, module, true, SIZE);', 137 | 'onmessage = function(msg) {', 138 | ' if (msg.data.options) {', 139 | ' CONFETTI(msg.data.options).then(function () {', 140 | ' if (msg.data.callback) {', 141 | ' postMessage({ callback: msg.data.callback });', 142 | ' }', 143 | ' });', 144 | ' } else if (msg.data.reset) {', 145 | ' CONFETTI.reset();', 146 | ' } else if (msg.data.resize) {', 147 | ' SIZE.width = msg.data.resize.width;', 148 | ' SIZE.height = msg.data.resize.height;', 149 | ' } else if (msg.data.canvas) {', 150 | ' SIZE.width = msg.data.canvas.width;', 151 | ' SIZE.height = msg.data.canvas.height;', 152 | ' CONFETTI = module.exports.create(msg.data.canvas);', 153 | ' }', 154 | '}', 155 | ].join('\n'); 156 | try { 157 | worker = new Worker(URL.createObjectURL(new Blob([code]))); 158 | } catch (e) { 159 | // eslint-disable-next-line no-console 160 | typeof console !== undefined && typeof console.warn === 'function' ? console.warn('🎊 Could not load worker', e) : null; 161 | 162 | return null; 163 | } 164 | 165 | decorate(worker); 166 | } 167 | 168 | return worker; 169 | }; 170 | })(); 171 | 172 | var defaults = { 173 | particleCount: 50, 174 | angle: 90, 175 | spread: 45, 176 | startVelocity: 45, 177 | decay: 0.9, 178 | gravity: 1, 179 | drift: 0, 180 | ticks: 200, 181 | x: 0.5, 182 | y: 0.5, 183 | shapes: ['square', 'circle'], 184 | zIndex: 100, 185 | colors: [ 186 | '#26ccff', 187 | '#a25afd', 188 | '#ff5e7e', 189 | '#88ff5a', 190 | '#fcff42', 191 | '#ffa62d', 192 | '#ff36ff' 193 | ], 194 | // probably should be true, but back-compat 195 | disableForReducedMotion: false, 196 | scalar: 1 197 | }; 198 | 199 | function convert(val, transform) { 200 | return transform ? transform(val) : val; 201 | } 202 | 203 | function isOk(val) { 204 | return !(val === null || val === undefined); 205 | } 206 | 207 | function prop(options, name, transform) { 208 | return convert( 209 | options && isOk(options[name]) ? options[name] : defaults[name], 210 | transform 211 | ); 212 | } 213 | 214 | function onlyPositiveInt(number){ 215 | return number < 0 ? 0 : Math.floor(number); 216 | } 217 | 218 | function randomInt(min, max) { 219 | // [min, max) 220 | return Math.floor(Math.random() * (max - min)) + min; 221 | } 222 | 223 | function toDecimal(str) { 224 | return parseInt(str, 16); 225 | } 226 | 227 | function colorsToRgb(colors) { 228 | return colors.map(hexToRgb); 229 | } 230 | 231 | function hexToRgb(str) { 232 | var val = String(str).replace(/[^0-9a-f]/gi, ''); 233 | 234 | if (val.length < 6) { 235 | val = val[0]+val[0]+val[1]+val[1]+val[2]+val[2]; 236 | } 237 | 238 | return { 239 | r: toDecimal(val.substring(0,2)), 240 | g: toDecimal(val.substring(2,4)), 241 | b: toDecimal(val.substring(4,6)) 242 | }; 243 | } 244 | 245 | function getOrigin(options) { 246 | var origin = prop(options, 'origin', Object); 247 | origin.x = prop(origin, 'x', Number); 248 | origin.y = prop(origin, 'y', Number); 249 | 250 | return origin; 251 | } 252 | 253 | function setCanvasWindowSize(canvas) { 254 | canvas.width = document.documentElement.clientWidth; 255 | canvas.height = document.documentElement.clientHeight; 256 | } 257 | 258 | function setCanvasRectSize(canvas) { 259 | var rect = canvas.getBoundingClientRect(); 260 | canvas.width = rect.width; 261 | canvas.height = rect.height; 262 | } 263 | 264 | function getCanvas(zIndex) { 265 | var canvas = document.createElement('canvas'); 266 | 267 | canvas.style.position = 'fixed'; 268 | canvas.style.top = '0px'; 269 | canvas.style.left = '0px'; 270 | canvas.style.pointerEvents = 'none'; 271 | canvas.style.zIndex = zIndex; 272 | 273 | return canvas; 274 | } 275 | 276 | function ellipse(context, x, y, radiusX, radiusY, rotation, startAngle, endAngle, antiClockwise) { 277 | context.save(); 278 | context.translate(x, y); 279 | context.rotate(rotation); 280 | context.scale(radiusX, radiusY); 281 | context.arc(0, 0, 1, startAngle, endAngle, antiClockwise); 282 | context.restore(); 283 | } 284 | 285 | function randomPhysics(opts) { 286 | var radAngle = opts.angle * (Math.PI / 180); 287 | var radSpread = opts.spread * (Math.PI / 180); 288 | 289 | return { 290 | x: opts.x, 291 | y: opts.y, 292 | wobble: Math.random() * 10, 293 | wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05), 294 | velocity: (opts.startVelocity * 0.5) + (Math.random() * opts.startVelocity), 295 | angle2D: -radAngle + ((0.5 * radSpread) - (Math.random() * radSpread)), 296 | tiltAngle: (Math.random() * (0.75 - 0.25) + 0.25) * Math.PI, 297 | color: opts.color, 298 | shape: opts.shape, 299 | tick: 0, 300 | totalTicks: opts.ticks, 301 | decay: opts.decay, 302 | drift: opts.drift, 303 | random: Math.random() + 2, 304 | tiltSin: 0, 305 | tiltCos: 0, 306 | wobbleX: 0, 307 | wobbleY: 0, 308 | gravity: opts.gravity * 3, 309 | ovalScalar: 0.6, 310 | scalar: opts.scalar 311 | }; 312 | } 313 | 314 | function updateFetti(context, fetti) { 315 | fetti.x += Math.cos(fetti.angle2D) * fetti.velocity + fetti.drift; 316 | fetti.y += Math.sin(fetti.angle2D) * fetti.velocity + fetti.gravity; 317 | fetti.wobble += fetti.wobbleSpeed; 318 | fetti.velocity *= fetti.decay; 319 | fetti.tiltAngle += 0.1; 320 | fetti.tiltSin = Math.sin(fetti.tiltAngle); 321 | fetti.tiltCos = Math.cos(fetti.tiltAngle); 322 | fetti.random = Math.random() + 2; 323 | fetti.wobbleX = fetti.x + ((10 * fetti.scalar) * Math.cos(fetti.wobble)); 324 | fetti.wobbleY = fetti.y + ((10 * fetti.scalar) * Math.sin(fetti.wobble)); 325 | 326 | var progress = (fetti.tick++) / fetti.totalTicks; 327 | 328 | var x1 = fetti.x + (fetti.random * fetti.tiltCos); 329 | var y1 = fetti.y + (fetti.random * fetti.tiltSin); 330 | var x2 = fetti.wobbleX + (fetti.random * fetti.tiltCos); 331 | var y2 = fetti.wobbleY + (fetti.random * fetti.tiltSin); 332 | 333 | context.fillStyle = 'rgba(' + fetti.color.r + ', ' + fetti.color.g + ', ' + fetti.color.b + ', ' + (1 - progress) + ')'; 334 | context.beginPath(); 335 | 336 | if (fetti.shape === 'circle') { 337 | context.ellipse ? 338 | context.ellipse(fetti.x, fetti.y, Math.abs(x2 - x1) * fetti.ovalScalar, Math.abs(y2 - y1) * fetti.ovalScalar, Math.PI / 10 * fetti.wobble, 0, 2 * Math.PI) : 339 | ellipse(context, fetti.x, fetti.y, Math.abs(x2 - x1) * fetti.ovalScalar, Math.abs(y2 - y1) * fetti.ovalScalar, Math.PI / 10 * fetti.wobble, 0, 2 * Math.PI); 340 | } else { 341 | context.moveTo(Math.floor(fetti.x), Math.floor(fetti.y)); 342 | context.lineTo(Math.floor(fetti.wobbleX), Math.floor(y1)); 343 | context.lineTo(Math.floor(x2), Math.floor(y2)); 344 | context.lineTo(Math.floor(x1), Math.floor(fetti.wobbleY)); 345 | } 346 | 347 | context.closePath(); 348 | context.fill(); 349 | 350 | return fetti.tick < fetti.totalTicks; 351 | } 352 | 353 | function animate(canvas, fettis, resizer, size, done) { 354 | var animatingFettis = fettis.slice(); 355 | var context = canvas.getContext('2d'); 356 | var animationFrame; 357 | var destroy; 358 | 359 | var prom = promise(function (resolve) { 360 | function onDone() { 361 | animationFrame = destroy = null; 362 | 363 | context.clearRect(0, 0, size.width, size.height); 364 | 365 | done(); 366 | resolve(); 367 | } 368 | 369 | function update() { 370 | if (isWorker && !(size.width === workerSize.width && size.height === workerSize.height)) { 371 | size.width = canvas.width = workerSize.width; 372 | size.height = canvas.height = workerSize.height; 373 | } 374 | 375 | if (!size.width && !size.height) { 376 | resizer(canvas); 377 | size.width = canvas.width; 378 | size.height = canvas.height; 379 | } 380 | 381 | context.clearRect(0, 0, size.width, size.height); 382 | 383 | animatingFettis = animatingFettis.filter(function (fetti) { 384 | return updateFetti(context, fetti); 385 | }); 386 | 387 | if (animatingFettis.length) { 388 | animationFrame = raf.frame(update); 389 | } else { 390 | onDone(); 391 | } 392 | } 393 | 394 | animationFrame = raf.frame(update); 395 | destroy = onDone; 396 | }); 397 | 398 | return { 399 | addFettis: function (fettis) { 400 | animatingFettis = animatingFettis.concat(fettis); 401 | 402 | return prom; 403 | }, 404 | canvas: canvas, 405 | promise: prom, 406 | reset: function () { 407 | if (animationFrame) { 408 | raf.cancel(animationFrame); 409 | } 410 | 411 | if (destroy) { 412 | destroy(); 413 | } 414 | } 415 | }; 416 | } 417 | 418 | function confettiCannon(canvas, globalOpts) { 419 | var isLibCanvas = !canvas; 420 | var allowResize = !!prop(globalOpts || {}, 'resize'); 421 | var globalDisableForReducedMotion = prop(globalOpts, 'disableForReducedMotion', Boolean); 422 | var shouldUseWorker = canUseWorker && !!prop(globalOpts || {}, 'useWorker'); 423 | var worker = shouldUseWorker ? getWorker() : null; 424 | var resizer = isLibCanvas ? setCanvasWindowSize : setCanvasRectSize; 425 | var initialized = (canvas && worker) ? !!canvas.__confetti_initialized : false; 426 | var preferLessMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion)').matches; 427 | var animationObj; 428 | 429 | function fireLocal(options, size, done) { 430 | var particleCount = prop(options, 'particleCount', onlyPositiveInt); 431 | var angle = prop(options, 'angle', Number); 432 | var spread = prop(options, 'spread', Number); 433 | var startVelocity = prop(options, 'startVelocity', Number); 434 | var decay = prop(options, 'decay', Number); 435 | var gravity = prop(options, 'gravity', Number); 436 | var drift = prop(options, 'drift', Number); 437 | var colors = prop(options, 'colors', colorsToRgb); 438 | var ticks = prop(options, 'ticks', Number); 439 | var shapes = prop(options, 'shapes'); 440 | var scalar = prop(options, 'scalar'); 441 | var origin = getOrigin(options); 442 | 443 | var temp = particleCount; 444 | var fettis = []; 445 | 446 | var startX = canvas.width * origin.x; 447 | var startY = canvas.height * origin.y; 448 | 449 | while (temp--) { 450 | fettis.push( 451 | randomPhysics({ 452 | x: startX, 453 | y: startY, 454 | angle: angle, 455 | spread: spread, 456 | startVelocity: startVelocity, 457 | color: colors[temp % colors.length], 458 | shape: shapes[randomInt(0, shapes.length)], 459 | ticks: ticks, 460 | decay: decay, 461 | gravity: gravity, 462 | drift: drift, 463 | scalar: scalar 464 | }) 465 | ); 466 | } 467 | 468 | // if we have a previous canvas already animating, 469 | // add to it 470 | if (animationObj) { 471 | return animationObj.addFettis(fettis); 472 | } 473 | 474 | animationObj = animate(canvas, fettis, resizer, size , done); 475 | 476 | return animationObj.promise; 477 | } 478 | 479 | function fire(options) { 480 | var disableForReducedMotion = globalDisableForReducedMotion || prop(options, 'disableForReducedMotion', Boolean); 481 | var zIndex = prop(options, 'zIndex', Number); 482 | 483 | if (disableForReducedMotion && preferLessMotion) { 484 | return promise(function (resolve) { 485 | resolve(); 486 | }); 487 | } 488 | 489 | if (isLibCanvas && animationObj) { 490 | // use existing canvas from in-progress animation 491 | canvas = animationObj.canvas; 492 | } else if (isLibCanvas && !canvas) { 493 | // create and initialize a new canvas 494 | canvas = getCanvas(zIndex); 495 | document.body.appendChild(canvas); 496 | } 497 | 498 | if (allowResize && !initialized) { 499 | // initialize the size of a user-supplied canvas 500 | resizer(canvas); 501 | } 502 | 503 | var size = { 504 | width: canvas.width, 505 | height: canvas.height 506 | }; 507 | 508 | if (worker && !initialized) { 509 | worker.init(canvas); 510 | } 511 | 512 | initialized = true; 513 | 514 | if (worker) { 515 | canvas.__confetti_initialized = true; 516 | } 517 | 518 | function onResize() { 519 | if (worker) { 520 | // TODO this really shouldn't be immediate, because it is expensive 521 | var obj = { 522 | getBoundingClientRect: function () { 523 | if (!isLibCanvas) { 524 | return canvas.getBoundingClientRect(); 525 | } 526 | } 527 | }; 528 | 529 | resizer(obj); 530 | 531 | worker.postMessage({ 532 | resize: { 533 | width: obj.width, 534 | height: obj.height 535 | } 536 | }); 537 | return; 538 | } 539 | 540 | // don't actually query the size here, since this 541 | // can execute frequently and rapidly 542 | size.width = size.height = null; 543 | } 544 | 545 | function done() { 546 | animationObj = null; 547 | 548 | if (allowResize) { 549 | global.removeEventListener('resize', onResize); 550 | } 551 | 552 | if (isLibCanvas && canvas) { 553 | document.body.removeChild(canvas); 554 | canvas = null; 555 | initialized = false; 556 | } 557 | } 558 | 559 | if (allowResize) { 560 | global.addEventListener('resize', onResize, false); 561 | } 562 | 563 | if (worker) { 564 | return worker.fire(options, size, done); 565 | } 566 | 567 | return fireLocal(options, size, done); 568 | } 569 | 570 | fire.reset = function () { 571 | if (worker) { 572 | worker.reset(); 573 | } 574 | 575 | if (animationObj) { 576 | animationObj.reset(); 577 | } 578 | }; 579 | 580 | return fire; 581 | } 582 | 583 | // Make default export lazy to defer worker creation until called. 584 | var defaultFire; 585 | function getDefaultFire() { 586 | if (!defaultFire) { 587 | defaultFire = confettiCannon(null, { useWorker: true, resize: true }); 588 | } 589 | return defaultFire; 590 | } 591 | 592 | module.exports = function() { 593 | return getDefaultFire().apply(this, arguments); 594 | }; 595 | module.exports.reset = function() { 596 | getDefaultFire().reset(); 597 | }; 598 | module.exports.create = confettiCannon; 599 | }((function () { 600 | if (typeof window !== 'undefined') { 601 | return window; 602 | } 603 | 604 | if (typeof self !== 'undefined') { 605 | return self; 606 | } 607 | 608 | return this || {}; 609 | })(), module, false)); 610 | 611 | // end source content 612 | 613 | window.confetti = module.exports; 614 | }(window, {})); 615 | -------------------------------------------------------------------------------- /src/streakAchievement/confetti_License.txt: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020, Kiril Vatev 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /src/streakAchievement/streak.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/streakAchievement/streakAchievement.py: -------------------------------------------------------------------------------- 1 | from aqt.qt import QDialog, Qt, QIcon, QPixmap, qtmajor 2 | from pathlib import Path 3 | import json 4 | 5 | if qtmajor > 5: 6 | from ...forms.pyqt6UI import achievement 7 | from PyQt6 import QtCore 8 | else: 9 | from ...forms.pyqt5UI import achievement 10 | from PyQt5 import QtCore 11 | 12 | 13 | class streak(QDialog): 14 | def __init__(self, days, parent=None): 15 | self.parent = parent 16 | QDialog.__init__(self, parent, Qt.WindowType.Window) 17 | self.dialog = achievement.Ui_Dialog() 18 | self.dialog.setupUi(self) 19 | self.days = days 20 | self.setupUI() 21 | self.loadWebpage() 22 | 23 | def setupUI(self): 24 | root = Path(__file__).parents[1] 25 | icon = QIcon() 26 | icon.addPixmap(QPixmap(f"{root}/designer/icons/krone.png"), QIcon.Mode.Normal, QIcon.State.Off) 27 | self.setWindowIcon(icon) 28 | 29 | def loadWebpage(self): 30 | data = { 31 | "streak": self.days, 32 | } 33 | with open(f"{Path(__file__).parents[0]}/data.json", "w") as file: 34 | file.write(f"data = '{json.dumps(data)}';") 35 | 36 | sourceFile = f"{Path(__file__).parents[0]}/streak.html" 37 | self.dialog.webview.load(QtCore.QUrl.fromLocalFile(sourceFile)) -------------------------------------------------------------------------------- /src/streakAchievement/style.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | display: flex; 9 | justify-content: left; 10 | align-items: center; 11 | height: 100vh; 12 | font-family: "Arial", sans-serif; 13 | overflow: hidden; 14 | background-color: #686868; 15 | } 16 | 17 | p { 18 | margin: 0 0 3px; 19 | line-height: 1; 20 | letter-spacing: 1px; 21 | pointer-events: none; 22 | } 23 | 24 | .streak { 25 | padding: 20px; 26 | font-size: 35px; 27 | font-weight: bold; 28 | color: #2f9be3; 29 | } 30 | 31 | .calendar { 32 | position: relative; 33 | width: 152px; 34 | cursor: default; 35 | user-select: none; 36 | margin-left: 30px; 37 | } 38 | .calendar::before, .calendar::after { 39 | content: ""; 40 | position: absolute; 41 | top: -28px; 42 | left: 40px; 43 | width: 10px; 44 | height: 10px; 45 | border-radius: 5px; 46 | background: #ddd; 47 | z-index: 3; 48 | } 49 | .calendar::after { 50 | left: initial; 51 | right: 40px; 52 | } 53 | 54 | .pages { 55 | position: relative; 56 | text-align: center; 57 | background: #eee; 58 | box-shadow: 0 10px 0 0px #a5a4a4; 59 | } 60 | .pages::before { 61 | content: ""; 62 | position: absolute; 63 | width: 100%; 64 | height: 45px; 65 | background: #2f9be3; 66 | bottom: 100%; 67 | left: 0; 68 | z-index: 2; 69 | } 70 | 71 | .page { 72 | position: relative; 73 | padding: 20px 30px 15px; 74 | background: #eee; 75 | } 76 | .page::before { 77 | content: ""; 78 | position: absolute; 79 | bottom: 99%; 80 | left: 0; 81 | display: block; 82 | background: linear-gradient(-45deg, #eee 10px, #eee 10px, #eee 10px, transparent 0), linear-gradient(45deg, #eee 10px, transparent 0); 83 | background-position: left top; 84 | background-repeat: repeat-x; 85 | background-size: 10px 18px; 86 | height: 10px; 87 | width: 100%; 88 | } 89 | 90 | .month, 91 | .day-name { 92 | text-transform: uppercase; 93 | font-weight: 600; 94 | } 95 | 96 | .day { 97 | font-size: 60px; 98 | font-weight: 700; 99 | margin: 0 0 15px; 100 | } 101 | 102 | .year { 103 | font-size: 12px; 104 | } 105 | 106 | .tear { 107 | position: absolute; 108 | top: 0; 109 | left: 0; 110 | width: 100%; 111 | height: 100%; 112 | z-index: 1; 113 | transform-origin: top left; 114 | box-shadow: 0 0 10px -1px rgba(0, 0, 0, 0.5); 115 | pointer-events: none; 116 | animation: tear-animation 0.8s linear forwards; 117 | } 118 | 119 | 120 | @keyframes tear-animation { 121 | 0% { 122 | transform: rotate(0deg); 123 | top: 0; 124 | opacity: 1; 125 | } 126 | 20% { 127 | transform: rotate(9deg); 128 | top: 0; 129 | opacity: 1; 130 | } 131 | 70% { 132 | opacity: 1; 133 | } 134 | 100% { 135 | top: 300px; 136 | opacity: 0; 137 | } 138 | } -------------------------------------------------------------------------------- /src/userInfo.py: -------------------------------------------------------------------------------- 1 | from aqt.qt import QDialog, Qt, QIcon, QPixmap, qtmajor 2 | from aqt.utils import tooltip 3 | from aqt import mw 4 | import json 5 | from pathlib import Path 6 | 7 | if qtmajor > 5: 8 | from ..forms.pyqt6UI import user_info 9 | from PyQt6 import QtCore, QtWidgets 10 | else: 11 | from ..forms.pyqt5UI import user_info 12 | from PyQt5 import QtCore, QtWidgets 13 | from .reportUser import start_report 14 | from .config_manager import write_config 15 | from .api_connect import postRequest 16 | from .banUser import start_banUser 17 | 18 | class start_user_info(QDialog): 19 | def __init__(self, user_clicked, enabled, parent=None): 20 | self.parent = parent 21 | self.user_clicked = user_clicked.split(" |")[0] 22 | self.enabled = enabled 23 | QDialog.__init__(self, parent, Qt.WindowType.Window) 24 | self.dialog = user_info.Ui_Dialog() 25 | self.dialog.setupUi(self) 26 | self.setupUI() 27 | 28 | def setupUI(self): 29 | self.dialog.username_label.setText(self.user_clicked) 30 | root = Path(__file__).parents[1] 31 | 32 | icon = QIcon() 33 | icon.addPixmap(QPixmap(f"{root}/designer/icons/person.png"), QIcon.Mode.Normal, QIcon.State.Off) 34 | self.setWindowIcon(icon) 35 | 36 | if self.enabled == True: 37 | self.dialog.banUser.setEnabled(True) 38 | 39 | data = {"username": self.user_clicked} 40 | response = postRequest("getUserinfo/", data, 200) 41 | 42 | if response: 43 | response = response.json() 44 | if response[4]: 45 | self.dialog.status_message.setMarkdown(response[4]) 46 | else: 47 | pass 48 | 49 | if response[0] == "Country": 50 | self.dialog.country_label.setText("") 51 | else: 52 | self.dialog.country_label.setText(f"Country: {response[0]}") 53 | for i in response[1]: 54 | self.dialog.group_list.addItem(i) 55 | self.dialog.league_label.setText(f"League: {response[2]}") 56 | 57 | 58 | header = self.dialog.history.horizontalHeader() 59 | header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) 60 | header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch) 61 | header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Stretch) 62 | header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.Stretch) 63 | if response[3]: 64 | medals = "" 65 | history = json.loads(response[3]) 66 | results = history["results"] 67 | if history["gold"] > 0: 68 | medals = f"{medals} {history['gold'] if history['gold'] != 1 else ''}🥇" 69 | if history["silver"] > 0: 70 | medals = f"{medals} {history['silver'] if history['silver'] != 1 else ''}🥈" 71 | if history["bronze"] > 0: 72 | medals = f"{medals} {history['bronze'] if history['bronze'] != 1 else ''}🥉" 73 | self.dialog.medals_label.setText(f"Medals: {medals}") 74 | index = 0 75 | for i in results["leagues"]: 76 | rowPosition = self.dialog.history.rowCount() 77 | self.dialog.history.insertRow(rowPosition) 78 | 79 | self.dialog.history.setItem(rowPosition , 3, QtWidgets.QTableWidgetItem(str(i))) 80 | 81 | item = QtWidgets.QTableWidgetItem() 82 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(results["seasons"][index])) 83 | self.dialog.history.setItem(rowPosition, 0, item) 84 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 85 | 86 | item = QtWidgets.QTableWidgetItem() 87 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(results["xp"][index])) 88 | self.dialog.history.setItem(rowPosition, 2, item) 89 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 90 | 91 | item = QtWidgets.QTableWidgetItem() 92 | item.setData(QtCore.Qt.ItemDataRole.DisplayRole, int(results["rank"][index])) 93 | self.dialog.history.setItem(rowPosition, 1, item) 94 | item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignVCenter) 95 | 96 | index += 1 97 | 98 | self.dialog.hideUser.clicked.connect(self.hideUser) 99 | self.dialog.addFriend.clicked.connect(self.addFriend) 100 | self.dialog.banUser.clicked.connect(self.banUser) 101 | self.dialog.reportUser.clicked.connect(self.reportUser) 102 | self.dialog.history.sortItems(0, QtCore.Qt.SortOrder.DescendingOrder) 103 | 104 | def hideUser(self): 105 | config = mw.addonManager.getConfig(__name__) 106 | hidden = config["hidden_users"] 107 | hidden.append(self.user_clicked) 108 | write_config("hidden_users", hidden) 109 | tooltip(f"{self.user_clicked} will be hidden next time you open the leaderboard.") 110 | 111 | def addFriend(self): 112 | config = mw.addonManager.getConfig(__name__) 113 | friends = config['friends'] 114 | if self.user_clicked in friends: 115 | tooltip(f"{self.user_clicked} already is your friend.") 116 | else: 117 | friends.append(self.user_clicked) 118 | write_config("friends", friends) 119 | tooltip(f"{self.user_clicked} is now your friend.") 120 | 121 | def banUser(self): 122 | s = start_banUser(self.user_clicked) 123 | if s.exec(): 124 | pass 125 | 126 | def reportUser(self): 127 | s = start_report(self.user_clicked) 128 | if s.exec(): 129 | pass 130 | -------------------------------------------------------------------------------- /src/version.py: -------------------------------------------------------------------------------- 1 | version = "v3.0.2" 2 | 3 | about_text = f""" 4 |

Leaderboard {version}

5 |

6 | This add-on ranks all of its users by the number of cards reviewed today, time spend studying today, current streak, reviews in the past 31 days, 7 | and retention. You can also compete against friends, join groups, and join a country leaderboard. You'll only see users, that synced on the same day as you. 8 | In the league tab, you see everyone who synced at least once during the current season. There are four leagues (Alpha, Beta, Gamma, and Delta). 9 | A season lasts two weeks. You don't have to sync every day. The XP formula is: 10 |
XP = days studied percentage x ((6 x time) + (2 x reviews x retention)).
11 | You have to study at least 5 minutes per day. Otherwise, this day won't be counted as “studied” 12 | (See this issue for more info). 13 | At the end of each season, the top 20% will be promoted, and the last 20% will be relegated. 14 |

15 |

16 | The code for the add-on is available on GitHub. 17 | It is licensed under the MIT License. The privacy policy can 18 | found here. 19 | If you like this add-on, rate and review it on AnkiWeb. 20 | You can also check the leaderboard (past 24 hours) on this website. 21 |

22 | 23 |

This add-on includes the following third-party assets and code

24 |

25 | Crown icon made by Freepik from www.flaticon.com
26 | Person icon made by iconixar from www.flaticon.com
27 | Settings icon made by Setting icons created by Phoenix Group - Flaticon
28 | canvas-confetti by Kiril Vatev licensed under the ISC License
29 | Tear off calendar by Nikita Hlopov 30 |

31 | 32 | 33 |

Change Log:

34 | - fixed bug when trying to open the leaderboard without being logged in
35 | - fixed highlight bug
36 | You can check out the full change log on GitHub. 37 | 38 |

39 | © Thore Tyborski 2023
40 | With contributions from 41 | khonkhortisan, 42 | zjosua, 43 | SmallFluffyIPA, 44 | Atílio Antônio Dadalto and 45 | Rodrigo Lanes
46 | Also thank you to everyone who reported bugs and suggested new features!

47 | Contact: leaderboard_support@protonmail.com, Reddit or 48 | GitHub. 49 |

50 | """ -------------------------------------------------------------------------------- /tools/ankiaddon.py: -------------------------------------------------------------------------------- 1 | import zipfile 2 | import os 3 | from pathlib import Path 4 | import shutil 5 | 6 | root = Path(__file__).parents[1] 7 | 8 | # create output folder 9 | if not os.path.exists(f"{root}/releases"): 10 | os.makedirs(f"{root}/releases") 11 | 12 | # remove pycache 13 | shutil.rmtree(f"{root}/forms/pyqt5UI/__pycache__", ignore_errors=True) 14 | shutil.rmtree(f"{root}/forms/pyqt6UI/__pycache__", ignore_errors=True) 15 | shutil.rmtree(f"{root}/src/__pycache__", ignore_errors=True) 16 | shutil.rmtree(f"{root}/src/streakAchievement/__pycache__", ignore_errors=True) 17 | 18 | # create .ankiaddon 19 | 20 | data = [ 21 | "-c", 22 | f"{root}/releases/AnkiLeaderboard.ankiaddon", 23 | f"{root}/__init__.py", 24 | f"{root}/config.json", 25 | f"{root}/manifest.json", 26 | f"{root}/License.txt", 27 | f"{root}/designer", 28 | f"{root}/forms", 29 | f"{root}/src", 30 | ] 31 | 32 | zipfile.main(data) -------------------------------------------------------------------------------- /tools/build_ui.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | mkdir -p ../forms/pyqt5UI 6 | mkdir -p ../forms/pyqt6UI 7 | 8 | #pyqt5 9 | 10 | echo "Generating pyqt5 forms..." 11 | for i in ../designer/*.ui 12 | do 13 | base=$(basename $i .ui) 14 | py="../forms/pyqt5UI/${base}.py" 15 | if [ $i -nt $py ]; then 16 | echo " * "$py 17 | pyuic5 --from-imports $i -o $py 18 | fi 19 | done 20 | 21 | echo "Building resources.." 22 | 23 | #pyqt6 24 | 25 | echo "Generating pyqt6 forms..." 26 | for i in ../designer/*.ui 27 | do 28 | base=$(basename $i .ui) 29 | py="../forms//pyqt6UI/${base}.py" 30 | if [ $i -nt $py ]; then 31 | echo " * "$py 32 | pyuic6 $i -o $py 33 | fi 34 | done --------------------------------------------------------------------------------