├── .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 |
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 | Username
30 | XP
31 | Time
32 | Reviews
33 | Retention
34 | Days studied
35 |
36 | {% for i in data %}
37 |
38 | {{i.place}}
39 | {{i.username}}
40 | {{i.xp}}
41 | {{i.time}}
42 | {{i.reviews}}
43 | {{i.retention}}%
44 | {{i.days_learned}}%
45 |
46 | {% endfor %}
47 |
48 |
49 | {% endblock %}
--------------------------------------------------------------------------------
/server/templates/messages.html:
--------------------------------------------------------------------------------
1 | {% if messages %}
2 |
3 | {% for message in messages %}
4 | {{ message }}
5 | {% endfor %}
6 |
7 | {% endif %}
--------------------------------------------------------------------------------
/server/templates/newPassword.html:
--------------------------------------------------------------------------------
1 | {% extends "header+footer.html" %}
2 |
3 | {% block nav %}
4 |
21 | {% endblock %}
22 |
23 | {% block table %}
24 |
25 |
36 |
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 | Username
29 | Retention %
30 |
31 | {% for i in data %}
32 |
33 | {{i.place}}
34 | {{i.username}}
35 | {{i.value}}
36 |
37 | {% endfor %}
38 |
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 | Username
29 | Reviews
30 |
31 | {% for i in data %}
32 |
33 | {{i.place}}
34 | {{i.username}}
35 | {{i.value}}
36 |
37 | {% endfor %}
38 |
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 | Username
29 | Streak
30 |
31 | {% for i in data %}
32 |
33 | {{i.place}}
34 | {{i.username}}
35 | {{i.value}}
36 |
37 | {% endfor %}
38 |
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 | Username
29 | Minutes
30 |
31 | {% for i in data %}
32 |
33 | {{i.place}}
34 | {{i.username}}
35 | {{i.value}}
36 |
37 | {% endfor %}
38 |
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 |
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 | Reviews
29 | {{i.cards}}
30 |
31 |
32 | Streak
33 | {{i.streak}}
34 |
35 |
36 | Minutes
37 | {{i.time}}
38 |
39 |
40 | Retention
41 | {{i.retention}}%
42 |
43 |
44 | Reviews past 31 days
45 | {{i.month}}
46 |
47 |
48 | Country
49 | {{i.country}}
50 |
51 |
52 | Group
53 | {{i.subject}}
54 |
55 |
56 | League
57 | {{i.league}}
58 |
59 |
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 | Username
203 | Reviews
204 | Minutes
205 | Streak
206 | Past month
207 | Retention
208 |
209 | """
210 | tableContent = ""
211 | for i in result:
212 | tableContent += f"""
213 |
214 | {i[0]}
215 | {i[1]}
216 | {i[2]}
217 | {i[3]}
218 | {i[4]}
219 | {i[5]}
220 | {i[6]}%
221 |
222 | """
223 | if config["tab"] == 4:
224 | tableHeader = """
225 |
226 |
227 |
228 | #
229 | Username
230 | XP
231 | Minutes
232 | Reviews
233 | Retention
234 | Days learned
235 |
236 | """
237 | tableContent = ""
238 | for i in result:
239 | tableContent += f"""
240 |
241 | {i[0]}
242 | {i[1]}
243 | {i[2]}
244 | {i[3]}
245 | {i[4]}
246 | {i[5]}%
247 | {i[6]}%
248 |
249 | """
250 |
251 | content.stats += title + tableStyle + tableHeader + tableContent + "
"
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 |
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
--------------------------------------------------------------------------------