├── .gitignore
├── README.md
├── assets
├── fonts
│ └── Poppins
│ │ ├── Poppins-Black.ttf
│ │ ├── Poppins-BlackItalic.ttf
│ │ ├── Poppins-Bold.ttf
│ │ ├── Poppins-BoldItalic.ttf
│ │ ├── Poppins-ExtraBold.ttf
│ │ ├── Poppins-ExtraBoldItalic.ttf
│ │ ├── Poppins-ExtraLight.ttf
│ │ ├── Poppins-ExtraLightItalic.ttf
│ │ ├── Poppins-Italic.ttf
│ │ ├── Poppins-Light.ttf
│ │ ├── Poppins-LightItalic.ttf
│ │ ├── Poppins-Medium.ttf
│ │ ├── Poppins-MediumItalic.ttf
│ │ ├── Poppins-Regular.ttf
│ │ ├── Poppins-SemiBold.ttf
│ │ ├── Poppins-SemiBoldItalic.ttf
│ │ ├── Poppins-Thin.ttf
│ │ └── Poppins-ThinItalic.ttf
├── icons
│ └── github
│ │ ├── github-mark-white.png
│ │ ├── github-mark-white.svg
│ │ ├── github-mark.png
│ │ └── github-mark.svg
└── logo-leetcode-tracker
│ ├── Leetcode-tracker-128.png
│ └── Leetcode-tracker-500.png
├── background.js
├── css
└── popup.css
├── manifest.json
├── popup.html
├── popup.js
└── scripts
├── authorize.js
├── constants
├── dom-elements.js
└── languages.js
├── leetcode.js
├── loader.js
├── models
└── problem.js
├── services
├── configuration-service.js
├── github-service.js
├── leetcode-service.js
├── route-service.js
└── sync-service.js
└── utils
├── dom-utils.js
└── language-utils.js
/.gitignore:
--------------------------------------------------------------------------------
1 | environment.js
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Leetcode Tracker - Automatically sync your leetcode submission to GitHub.
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | ## What is Leetcode Tracker?
12 |
13 | A chrome extension that automatically pushes your code to GitHub when you pass all tests on a Leetcode problem .
14 |
15 | ## Why Leetcode Tracker?
16 |
17 | 1. Recruiters want to see your contributions to the Open Source community, be it through side projects, solving algorithms/data-structures, or contributing to existing OS projects.
18 | As of now, GitHub is developers' #1 portfolio. Leetcode Tracker just makes it much easier (autonomous) to keep track of progress and contributions on the largest network of engineering community, GitHub.
19 |
20 | 2. There's no easy way of accessing your leetcode problems in one place!
21 | Moreover, pushing code manually to GitHub from Leetcode is very time consuming. So, why not just automate it entirely without spending a SINGLE additional second on it?
22 |
23 | ## How does Leetcode Tracker work?
24 |
25 |
26 | After installation, launch leetcode tracker.
27 | Click on "Authenticate" button to automatically link your github account with the extension.
28 | Setup an existing repository.
29 | Begin Leetcoding! To view your progress, simply click on the extension!
30 |
31 |
32 | ## Why did I build Leetcode Tracker?
33 |
34 |
35 | The coding interview is arguably the most important part of your interview process, given you get the interview first. As someone who's received multiple internship offers from Fortune 100 companies, getting the interview in the first place is not easy!
36 | And that's what Leetcode Tracker is supposed to do: indirectly improving your coding skills while improving your portfolio to ACE that interview at big tech companies!
37 | There were many Chrome extensions to automatically synchronize LeetCode code with GitHub, but none of them was up-to-date to work with the new LeetCode interface.
38 |
39 |
40 | # How to set up Leetcode Tracker for local development?
41 |
42 |
43 | Fork this repo and clone to your local machine
44 | Create new file environment.js in cloned folder
45 | Copy/paste the following code in the new file and patch CLIENT_SECRET and CLIENT_ID keys.
46 |
47 | export const ENV = {
48 | URL: "https://github.com/login/oauth/authorize",
49 | ACCESS_TOKEN_URL: "https://github.com/login/oauth/access_token",
50 | REDIRECT_URL: " https://github.com/",
51 | REPOSITORY_URL: "https://api.github.com/repos/",
52 | USER_INFO_URL: "https://api.github.com/user",
53 | CLIENT_SECRET: "YOUR_CLIENT_SECRET_KEY",
54 | CLIENT_ID: "YOUR_CLIENT_ID",
55 | SCOPES: ["repo"],
56 | HEADER: {
57 | Accept: "application/json",
58 | "Content-Type": "application/json",
59 | },
60 | };
61 |
62 | Go to chrome://extensions
63 | Enable Developer mode by toggling the switch on top right corner
64 | Click 'Load unpacked'
65 | Select the entire Leetcode Tracker folder
66 |
67 |
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-Black.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-Black.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-BlackItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-BlackItalic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-Bold.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-BoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-BoldItalic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-ExtraBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-ExtraBold.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-ExtraLight.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-ExtraLight.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-ExtraLightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-ExtraLightItalic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-Italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-Italic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-Light.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-LightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-LightItalic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-Medium.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-MediumItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-MediumItalic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-Regular.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-SemiBold.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-Thin.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-Thin.ttf
--------------------------------------------------------------------------------
/assets/fonts/Poppins/Poppins-ThinItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/fonts/Poppins/Poppins-ThinItalic.ttf
--------------------------------------------------------------------------------
/assets/icons/github/github-mark-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/icons/github/github-mark-white.png
--------------------------------------------------------------------------------
/assets/icons/github/github-mark-white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/github/github-mark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/icons/github/github-mark.png
--------------------------------------------------------------------------------
/assets/icons/github/github-mark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/logo-leetcode-tracker/Leetcode-tracker-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/logo-leetcode-tracker/Leetcode-tracker-128.png
--------------------------------------------------------------------------------
/assets/logo-leetcode-tracker/Leetcode-tracker-500.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JeffreyGbeho/leetcode-tracker/768b90e7ec9816796d3580a993e408c4ddb0f981/assets/logo-leetcode-tracker/Leetcode-tracker-500.png
--------------------------------------------------------------------------------
/background.js:
--------------------------------------------------------------------------------
1 | import { ENV } from "./environment.js";
2 | import LeetCodeService from "./scripts/services/leetcode-service.js";
3 | import SyncService from "./scripts/services/sync-service.js";
4 |
5 | /**
6 | * Manages LeetCode problem statistics and synchronization state.
7 | * Centralized state management for difficulty counters and sync progress tracking.
8 | */
9 | class LeetCodeStateManager {
10 | /**
11 | * Initialize the state manager with default values.
12 | * Sets up difficulty counters and tracking flags.
13 | */
14 | constructor() {
15 | this.state = {
16 | counter: {
17 | easy: 0,
18 | medium: 0,
19 | hard: 0,
20 | },
21 | isCountingComplete: false,
22 | lastUpdate: null,
23 | loading: true,
24 | };
25 | }
26 |
27 | /**
28 | * Increment the counter for a specific difficulty level.
29 | * Used for real-time updates when new problems are solved.
30 | *
31 | * @param {string} difficulty - The difficulty level to increment (Easy, Medium, Hard)
32 | * @returns {boolean} True if increment was successful, false if invalid difficulty
33 | */
34 | incrementCounter(difficulty) {
35 | if (!difficulty) return;
36 | const normalizedDifficulty = difficulty.toLowerCase();
37 | if (normalizedDifficulty in this.state.counter) {
38 | this.state.counter[normalizedDifficulty] += 1;
39 | this.state.lastUpdate = new Date();
40 | this.broadcastState();
41 | return true;
42 | }
43 | return false;
44 | }
45 |
46 | /**
47 | * Update statistics with a complete set of difficulty data.
48 | * Used for bulk updates during synchronization or initialization.
49 | *
50 | * Algorithm:
51 | * 1. Reset all counters to zero
52 | * 2. Process each difficulty value and increment appropriate counter
53 | * 3. Update completion status and loading state
54 | * 4. Broadcast the updated state to all listeners
55 | *
56 | * @param {Array} difficulties - Array of difficulty strings to count
57 | */
58 | updateStats(difficulties) {
59 | this.state.counter = { easy: 0, medium: 0, hard: 0 };
60 |
61 | difficulties.forEach((difficulty) => {
62 | if (difficulty) {
63 | const normalizedDifficulty = difficulty.toLowerCase();
64 | if (normalizedDifficulty in this.state.counter) {
65 | this.state.counter[normalizedDifficulty] += 1;
66 | }
67 | }
68 | });
69 |
70 | this.state.lastUpdate = new Date();
71 | this.state.loading = false;
72 | this.state.isCountingComplete = true;
73 |
74 | this.broadcastState();
75 | }
76 |
77 | /**
78 | * Get the current statistics state.
79 | * Returns a copy of the current state for external consumption.
80 | *
81 | * @returns {Object} Current statistics with counters and metadata
82 | */
83 | getStats() {
84 | return {
85 | ...this.state.counter,
86 | isCountingComplete: this.state.isCountingComplete,
87 | lastUpdate: this.state.lastUpdate,
88 | loading: this.state.loading,
89 | };
90 | }
91 |
92 | /**
93 | * Reset all counters and state flags to initial values.
94 | * Used when starting fresh counting or handling errors.
95 | */
96 | reset() {
97 | this.state.counter = { easy: 0, medium: 0, hard: 0 };
98 | this.state.isCountingComplete = false;
99 | this.state.lastUpdate = null;
100 | this.state.loading = true;
101 | }
102 |
103 | /**
104 | * Broadcast current state to all connected UI components.
105 | * Uses Chrome messaging API to update popup and other interfaces.
106 | * Handles messaging errors gracefully to prevent state corruption.
107 | */
108 | broadcastState() {
109 | chrome.runtime
110 | .sendMessage({
111 | type: "statsUpdate",
112 | data: this.getStats(),
113 | })
114 | .catch(() => {
115 | // Silently handle messaging errors (e.g., when popup is closed)
116 | });
117 | }
118 | }
119 |
120 | /**
121 | * Service for interacting with GitHub repositories to fetch problem data.
122 | * Handles repository communication and data transformation for statistics.
123 | */
124 | class GitHubService {
125 | /**
126 | * Initialize GitHub service with environment configuration.
127 | *
128 | * @param {Object} env - Environment configuration with API endpoints
129 | */
130 | constructor(env) {
131 | this.env = env;
132 | }
133 |
134 | /**
135 | * Build the base GitHub API URL for the connected repository.
136 | * Constructs URL from stored user credentials and repository name.
137 | *
138 | * @returns {Promise} Complete GitHub API URL for repository contents
139 | */
140 | async buildBasicGithubUrl() {
141 | const result = await chrome.storage.local.get([
142 | "leetcode_tracker_username",
143 | "leetcode_tracker_repo",
144 | ]);
145 | return `${this.env.REPOSITORY_URL}${result.leetcode_tracker_username}/${result.leetcode_tracker_repo}/contents/`;
146 | }
147 |
148 | /**
149 | * Fetch all LeetCode problems from the connected GitHub repository.
150 | * Filters repository contents to identify valid problem files.
151 | *
152 | * Algorithm:
153 | * 1. Build GitHub API URL for repository contents
154 | * 2. Fetch repository file list via GitHub API
155 | * 3. Filter files matching LeetCode naming pattern (e.g., "1-TwoSum")
156 | * 4. Extract problem IDs and convert to LeetCode format
157 | * 5. Return structured problem data for statistics calculation
158 | *
159 | * @returns {Promise>} Array of problem objects with IDs
160 | */
161 | async getAllLeetCodeProblems() {
162 | try {
163 | const url = await this.buildBasicGithubUrl();
164 | const response = await fetch(url);
165 | const data = await response.json();
166 |
167 | return data
168 | .filter((problem) => /^\d+-[A-Z]/.test(problem.name))
169 | .map((problem) => ({
170 | originalName: problem.name,
171 | questionId: this.convertGithubToLeetCodeSlug(problem.name),
172 | }));
173 | } catch (error) {
174 | return [];
175 | }
176 | }
177 |
178 | /**
179 | * Convert GitHub filename to LeetCode problem ID.
180 | * Extracts the numeric problem ID from the filename format.
181 | *
182 | * @param {string} githubFileName - GitHub file name (e.g., "1-TwoSum.js")
183 | * @returns {string} LeetCode problem ID (e.g., "1")
184 | */
185 | convertGithubToLeetCodeSlug(githubFileName) {
186 | const [number] = githubFileName.split("-");
187 | return number;
188 | }
189 | }
190 |
191 | /**
192 | * Main controller for the LeetCode Tracker background script.
193 | * Orchestrates all background services and handles Chrome extension messaging.
194 | */
195 | class LeetCodeTrackerController {
196 | /**
197 | * Initialize the controller with all required services and configuration.
198 | * Sets up state management, GitHub integration, and Chrome storage defaults.
199 | */
200 | constructor() {
201 | this.stateManager = new LeetCodeStateManager();
202 | this.githubService = new GitHubService(ENV);
203 | this.leetCodeService = new LeetCodeService();
204 | this.syncService = new SyncService();
205 |
206 | // Store environment configuration for other components
207 | chrome.storage.local.set({ leetcode_tracker_data_config: ENV });
208 |
209 | // Initialize sync status tracking
210 | chrome.storage.local.set({
211 | leetcode_tracker_last_sync_status: "",
212 | leetcode_tracker_sync_in_progress: false,
213 | leetcode_tracker_last_sync_message: "No synchronization performed yet",
214 | leetcode_tracker_last_sync_date: null,
215 | });
216 |
217 | this.initializeMessageListeners();
218 | }
219 |
220 | /**
221 | * Set up Chrome extension message listeners for UI communication.
222 | * Handles all message types from popup, content scripts, and other components.
223 | *
224 | * Message Types:
225 | * - updateDifficultyStats: Real-time counter updates when problems are solved
226 | * - getDataConfig: Environment configuration requests
227 | * - saveUserInfos: Authentication data storage
228 | * - syncSolvedProblems: Manual synchronization triggers
229 | * - requestInitialStats: Statistics data requests (triggers recalculation)
230 | */
231 | initializeMessageListeners() {
232 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
233 | const handlers = {
234 | updateDifficultyStats: () => {
235 | const success = this.stateManager.incrementCounter(
236 | request.difficulty
237 | );
238 | sendResponse({ success });
239 | },
240 | getDataConfig: () => {
241 | sendResponse(ENV);
242 | },
243 | saveUserInfos: () => {
244 | this.saveUserInfos(request);
245 | sendResponse({ success: true });
246 | },
247 | syncSolvedProblems: () => {
248 | this.startSync();
249 | sendResponse({ status: "started" });
250 | },
251 | requestInitialStats: () => {
252 | // Always recalculate counter when popup requests stats
253 | this.initCounter();
254 | sendResponse(null); // Send null initially, updated stats will be broadcast
255 | },
256 | };
257 |
258 | if (handlers[request.type]) {
259 | handlers[request.type]();
260 | }
261 |
262 | return true; // Keep message channel open for async responses
263 | });
264 | }
265 |
266 | /**
267 | * Save user authentication information to Chrome storage.
268 | * Stores GitHub username and access token for API authentication.
269 | *
270 | * @param {Object} request - Request object containing username and token
271 | */
272 | saveUserInfos(request) {
273 | chrome.storage.local.set({
274 | leetcode_tracker_username: request.username,
275 | leetcode_tracker_token: request.token,
276 | });
277 | }
278 |
279 | /**
280 | * Start the synchronization process and track progress.
281 | * Coordinates with SyncService and updates storage with results.
282 | *
283 | * Algorithm:
284 | * 1. Delegate synchronization to SyncService
285 | * 2. Monitor sync progress and handle results
286 | * 3. Update Chrome storage with sync status and messages
287 | * 4. Trigger counter recalculation on successful sync
288 | * 5. Handle errors gracefully and update status accordingly
289 | *
290 | * @returns {Promise} Sync result with success status and message
291 | */
292 | async startSync() {
293 | try {
294 | const result = await this.syncService.startSync();
295 |
296 | await chrome.storage.local.set({
297 | leetcode_tracker_last_sync_status: result.success
298 | ? "success"
299 | : "failed",
300 | leetcode_tracker_sync_in_progress: false,
301 | leetcode_tracker_last_sync_message: result.message,
302 | leetcode_tracker_last_sync_date: new Date().toISOString(),
303 | });
304 |
305 | if (result.success) {
306 | this.initCounter();
307 | }
308 |
309 | return result;
310 | } catch (error) {
311 | await chrome.storage.local.set({
312 | leetcode_tracker_last_sync_status: "failed",
313 | leetcode_tracker_sync_in_progress: false,
314 | leetcode_tracker_last_sync_message: error.message,
315 | leetcode_tracker_last_sync_date: new Date().toISOString(),
316 | });
317 |
318 | return {
319 | success: false,
320 | message: "Error during synchronization: " + error.message,
321 | };
322 | }
323 | }
324 |
325 | /**
326 | * Initialize or recalculate problem counters by fetching current repository state.
327 | * Combines GitHub repository data with LeetCode difficulty information.
328 | *
329 | * Algorithm:
330 | * 1. Validate user authentication and repository configuration
331 | * 2. Reset state manager to loading state
332 | * 3. Fetch problem list from GitHub repository in parallel with LeetCode difficulty data
333 | * 4. Create difficulty mapping from LeetCode API data
334 | * 5. Map repository problems to their difficulty levels
335 | * 6. Update state manager with calculated statistics
336 | * 7. Handle errors gracefully and ensure UI remains responsive
337 | *
338 | * This method is called:
339 | * - On extension startup if user is fully configured
340 | * - When repository configuration changes
341 | * - When popup requests initial statistics (ensures fresh data)
342 | * - After successful synchronization
343 | */
344 | async initCounter() {
345 | try {
346 | const {
347 | leetcode_tracker_token,
348 | leetcode_tracker_username,
349 | leetcode_tracker_repo,
350 | } = await chrome.storage.local.get([
351 | "leetcode_tracker_token",
352 | "leetcode_tracker_username",
353 | "leetcode_tracker_repo",
354 | ]);
355 |
356 | // Exit early if not fully configured
357 | if (
358 | !leetcode_tracker_token ||
359 | !leetcode_tracker_username ||
360 | !leetcode_tracker_repo
361 | ) {
362 | this.stateManager.state.loading = false;
363 | this.stateManager.state.isCountingComplete = true;
364 | this.stateManager.broadcastState();
365 | return;
366 | }
367 |
368 | this.stateManager.reset();
369 |
370 | // Fetch data in parallel for better performance
371 | const [problems, allQuestions] = await Promise.all([
372 | this.githubService.getAllLeetCodeProblems(),
373 | this.leetCodeService.fetchAllQuestionsDifficulty(),
374 | ]);
375 |
376 | // Create efficient lookup map for difficulty information
377 | const difficultyMap = new Map(
378 | allQuestions.map((q) => [q.questionId, q.difficulty])
379 | );
380 |
381 | // Map problems to their difficulties
382 | const difficulties = problems.map((problem) =>
383 | difficultyMap.get(problem.questionId)
384 | );
385 |
386 | this.stateManager.updateStats(difficulties);
387 | } catch (error) {
388 | // Ensure UI shows completed state even on error
389 | this.stateManager.state.loading = false;
390 | this.stateManager.state.isCountingComplete = true;
391 | this.stateManager.broadcastState();
392 | }
393 | }
394 | }
395 |
396 | /**
397 | * Extension installation and update handler.
398 | * Sets up default settings for new installations and updates.
399 | */
400 | chrome.runtime.onInstalled.addListener((details) => {
401 | if (details.reason === "install" || details.reason === "update") {
402 | // Initialize default settings only if they don't exist
403 | chrome.storage.local.get("leetcode_tracker_code_submit", (result) => {
404 | if (result.leetcode_tracker_code_submit === undefined) {
405 | chrome.storage.local.set({
406 | leetcode_tracker_code_submit: true,
407 | });
408 | }
409 | });
410 |
411 | chrome.storage.local.get(
412 | "leetcode_tracker_sync_multiple_submission",
413 | (result) => {
414 | if (result.leetcode_tracker_sync_multiple_submission === undefined) {
415 | chrome.storage.local.set({
416 | leetcode_tracker_sync_multiple_submission: false,
417 | });
418 | }
419 | }
420 | );
421 |
422 | chrome.storage.local.get(
423 | "leetcode_tracker_comment_submission",
424 | (result) => {
425 | if (result.leetcode_tracker_comment_submission === undefined) {
426 | chrome.storage.local.set({
427 | leetcode_tracker_comment_submission: false,
428 | });
429 | }
430 | }
431 | );
432 |
433 | chrome.storage.local.get("leetcode_tracker_auto_sync", (result) => {
434 | if (result.leetcode_tracker_auto_sync === undefined) {
435 | chrome.storage.local.set({
436 | leetcode_tracker_auto_sync: true,
437 | });
438 | }
439 | });
440 | }
441 | });
442 |
443 | // Initialize the main controller
444 | const controller = new LeetCodeTrackerController();
445 |
446 | /**
447 | * Initialize counter on startup if user is fully configured.
448 | * Ensures statistics are available immediately when extension starts.
449 | */
450 | (async function () {
451 | try {
452 | const {
453 | leetcode_tracker_token,
454 | leetcode_tracker_username,
455 | leetcode_tracker_repo,
456 | leetcode_tracker_mode,
457 | } = await chrome.storage.local.get([
458 | "leetcode_tracker_token",
459 | "leetcode_tracker_username",
460 | "leetcode_tracker_repo",
461 | "leetcode_tracker_mode",
462 | ]);
463 |
464 | if (
465 | leetcode_tracker_token &&
466 | leetcode_tracker_username &&
467 | leetcode_tracker_repo &&
468 | leetcode_tracker_mode
469 | ) {
470 | controller.initCounter();
471 | }
472 | } catch (error) {
473 | // Handle initialization errors gracefully
474 | }
475 | })();
476 |
477 | /**
478 | * Listen for storage changes and recalculate counters when repository configuration changes.
479 | * Ensures statistics stay synchronized with repository changes.
480 | */
481 | chrome.storage.onChanged.addListener((changes, area) => {
482 | if (area === "local") {
483 | if (changes.leetcode_tracker_repo || changes.leetcode_tracker_mode) {
484 | controller.initCounter();
485 | }
486 | }
487 | });
488 |
--------------------------------------------------------------------------------
/css/popup.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Poppins-Bold";
3 | src: url(../assets/fonts/Poppins/Poppins-Bold.ttf);
4 | }
5 | @font-face {
6 | font-family: "Poppins-Regular";
7 | src: url(../assets/fonts/Poppins/Poppins-Regular.ttf);
8 | }
9 | @font-face {
10 | font-family: "Poppins-Medium";
11 | src: url(../assets/fonts/Poppins/Poppins-Medium.ttf);
12 | }
13 | @font-face {
14 | font-family: "Poppins-SemiBold";
15 | src: url(../assets/fonts/Poppins/Poppins-SemiBold.ttf);
16 | }
17 |
18 | :root {
19 | --primary: #FFA116;
20 | --contrast-primary: #FFF1DF;
21 | --gray: #F4F4F4;
22 | --black: #0F0F0F;
23 | --background: #FFF;
24 | --title: #696969;
25 | --error: #F44336;
26 | }
27 |
28 | body {
29 | width: 350px;
30 | background-color: var(--background) !important;
31 | text-align: center;
32 | transition: background-color 0.75s ease;
33 | font-size: 13px;
34 |
35 | font-family: "Poppins-Regular", "Helvetica Neue", "Lucida Grande", sans-serif;
36 | }
37 |
38 | .content-container {
39 | padding: 15px;
40 | }
41 |
42 | #authenticate, #authenticated, #hook-repo {
43 | display: none;
44 | }
45 |
46 | #contribute-container {
47 | background-color: var(--contrast-primary);
48 | padding: 10px;
49 | font-size: 12px;
50 | margin-top: 15px;
51 | }
52 |
53 | .button-auth-container {
54 | margin-top: 20px;
55 | display: flex;
56 | justify-content: center;
57 | }
58 |
59 | button.button-auth {
60 | background-color: var(--black);
61 | color: white;
62 | border: none;
63 | padding: 10px 32px;
64 | border-radius: 300px;
65 | cursor: pointer;
66 | font-family: 'Poppins-Regular';
67 | font-size: 14px;
68 | display: flex;
69 | align-items: center;
70 | justify-content: center;
71 | gap: 5px;
72 | }
73 |
74 | #repo-name-error {
75 | font-family: "Poppins-Regular";
76 | font-size: 12px;
77 | color: var(--error);
78 | padding: 0 16px;
79 | }
80 |
81 | a, a:hover, a:visited {
82 | color: var(--primary);
83 | font-size: 12px;
84 | text-decoration: none !important;
85 | font-family: 'Poppins-Medium';
86 | display: flex;
87 | align-items: center;
88 | gap: 7px;
89 | }
90 |
91 | div.input-group-text.btn-suffix {
92 | cursor: pointer;
93 | font-family: 'Poppins-Regular';
94 | font-size: 14px;
95 | color: white;
96 | background-color: var(--black);
97 | }
98 |
99 | /* input.form-control:focus {
100 | border-color: var(--black);
101 | box-shadow: none;
102 | } */
103 |
104 | p {
105 | font-size: 14px;
106 | text-align: center;
107 | color: var(--title);
108 | font-family: 'Poppins-Regular';
109 | }
110 |
111 | h2.auth-title {
112 | font-family: "Poppins-Bold";
113 | font-size: 20px;
114 | color: var(--black);
115 | margin-top: 20px;
116 | text-align: center;
117 | }
118 |
119 | #github-authenticate-container {
120 | display: flex;
121 | flex-direction: column;
122 | align-items: center;
123 | justify-content: center;
124 | }
125 |
126 | #github-mark {
127 | width: 25px;
128 | margin-right: 5px;
129 | }
130 |
131 | .user-infos {
132 | margin-top: 20px;
133 | }
134 |
135 | .user-infos-content {
136 | display: flex;
137 | flex-direction: column;
138 | gap: 10px;
139 |
140 | }
141 |
142 | .user-infos-item {
143 | display: flex;
144 | justify-content: space-between;
145 | align-items: center;
146 | gap: 20px;
147 | }
148 |
149 | .user-infos-item-label {
150 | font-family: "Poppins-Bold";
151 | font-size: 12px;
152 | color: var(--black);
153 | }
154 |
155 | .user-infos-item-value {
156 | font-family: "Poppins-Regular";
157 | font-size: 12px;
158 | color: var(--black);
159 | }
160 |
161 | button.primary-button, button.primary-button:visited {
162 | background-color: transparent;
163 | color: var(--primary);
164 | border: 2px solid var(--primary);
165 | padding: 5px 15px;
166 | border-radius: 300px;
167 | cursor: pointer;
168 | font-family: 'Poppins-Regular';
169 | font-size: 12px;
170 | }
171 |
172 | button.primary-button:hover {
173 | background-color: var(--primary);
174 | color: var(--contrast-primary);
175 | }
176 |
177 | h3.subtitle {
178 | font-family: "Poppins-Regular";
179 | font-size: 16px;
180 | color: var(--title);
181 | }
182 |
183 | h1 {
184 | font-family: "Poppins-SemiBold";
185 | font-size: 20px !important;
186 | margin: 0;
187 | padding: 0;
188 | color: var(--black);
189 | margin-bottom: 0px !important;
190 | }
191 |
192 | h1 span {
193 | font-family: "Poppins-Bold";
194 | color: var(--primary);
195 | }
196 |
197 | #stats-container {
198 | margin-top: 20px;
199 | background-color: var(--gray);
200 | border-radius: 10px;
201 | padding: 12px;
202 | }
203 |
204 | #stats-content {
205 | position: relative;
206 | overflow: hidden;
207 | }
208 |
209 | #loading-container {
210 | position: absolute;
211 | top: 0;
212 | left: 0;
213 | width: 100%;
214 | height: 100%;
215 | display: flex;
216 | justify-content: center;
217 | align-items: center;
218 | backdrop-filter: blur(4px);
219 | z-index: 10;
220 | }
221 |
222 | .modern-loader {
223 | position: relative;
224 | width: 60px;
225 | height: 30px;
226 | display: flex;
227 | justify-content: space-between;
228 | align-items: center;
229 | }
230 |
231 | .modern-loader .dot {
232 | width: 10px;
233 | height: 10px;
234 | border-radius: 50%;
235 | background: var(--primary);
236 | animation: bounce 1.4s infinite ease-in-out both;
237 | box-shadow: 0 4px 10px var(--shadow);
238 | }
239 |
240 | .modern-loader .dot:nth-child(1) {
241 | animation-delay: -0.32s;
242 | }
243 |
244 | .modern-loader .dot:nth-child(2) {
245 | animation-delay: -0.16s;
246 | }
247 |
248 | @keyframes bounce {
249 | 0%, 80%, 100% {
250 | transform: scale(0);
251 | opacity: 0.5;
252 | }
253 | 40% {
254 | transform: scale(1);
255 | opacity: 1;
256 | }
257 | }
258 |
259 | .loading .value {
260 | opacity: 0;
261 | filter: blur(2px);
262 | }
263 |
264 | #stats {
265 | display: grid;
266 | grid-template-columns: repeat(3, 1fr);
267 |
268 | }
269 |
270 | #easy, #medium, #hard {
271 | text-align: center;
272 | font-family: 'Poppins-Bold';
273 | font-size: 32px;
274 | }
275 |
276 | .stats-label {
277 | font-family: "Poppins-Regular";
278 | font-size: 12px;
279 | color: var(--black);
280 | text-align: center;
281 | }
282 |
283 | #total-problems-solved {
284 | font-size: 14px;
285 | font-family: "Poppins-Bold";
286 | color: #000;
287 | text-align: center;
288 | margin-top: 10px;
289 | }
290 |
291 | #unlink-repository-container {
292 | margin-top: 10px;
293 | }
294 |
295 | #unlink-repo {
296 | cursor: pointer;
297 | }
298 |
299 | /* Slide toggle button */
300 | .switch {
301 | position: relative;
302 | display: inline-block;
303 | width: 40px; /* Reduced from 60px */
304 | height: 22px; /* Reduced from 34px */
305 | }
306 |
307 | .switch input {
308 | opacity: 0;
309 | width: 0;
310 | height: 0;
311 | }
312 |
313 | .slider {
314 | position: absolute;
315 | cursor: pointer;
316 | top: 0;
317 | left: 0;
318 | right: 0;
319 | bottom: 0;
320 | background-color: #ccc;
321 | -webkit-transition: .4s;
322 | transition: .4s;
323 | }
324 |
325 | .slider:before {
326 | position: absolute;
327 | content: "";
328 | height: 16px; /* Reduced from 26px */
329 | width: 16px; /* Reduced from 26px */
330 | left: 3px; /* Reduced from 4px */
331 | bottom: 3px; /* Reduced from 4px */
332 | background-color: white;
333 | -webkit-transition: .4s;
334 | transition: .4s;
335 | }
336 |
337 | input:checked + .slider {
338 | background-color: var(--primary);
339 | }
340 |
341 | input:focus + .slider {
342 | box-shadow: 0 0 1px var(--primary);
343 | }
344 |
345 | input:checked + .slider:before {
346 | -webkit-transform: translateX(18px); /* Reduced from 26px */
347 | -ms-transform: translateX(18px); /* Reduced from 26px */
348 | transform: translateX(18px); /* Reduced from 26px */
349 | }
350 |
351 | /* Rounded sliders */
352 | .slider.round {
353 | border-radius: 22px; /* Adjusted to match new height */
354 | }
355 |
356 | .slider.round:before {
357 | border-radius: 50%;
358 | }
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "LeetCode Tracker",
3 | "description": "Sync LeetCode's submissions with a GitHub repository",
4 | "version": "1.7.0",
5 | "manifest_version": 3,
6 | "author": "Jeffrey Gbeho",
7 | "action": {
8 | "default_popup": "popup.html"
9 | },
10 | "icons": {
11 | "16": "assets/logo-leetcode-tracker/Leetcode-tracker-500.png",
12 | "48": "assets/logo-leetcode-tracker/Leetcode-tracker-500.png",
13 | "128": "assets/logo-leetcode-tracker/Leetcode-tracker-500.png"
14 | },
15 | "background": {
16 | "service_worker": "background.js",
17 | "type": "module"
18 | },
19 | "permissions": ["unlimitedStorage", "storage"],
20 | "content_scripts": [
21 | {
22 | "matches": ["https://leetcode.com/*", "https://github.com/*"],
23 | "js": ["scripts/authorize.js", "scripts/loader.js"],
24 | "run_at": "document_idle"
25 | }
26 | ],
27 | "web_accessible_resources": [
28 | {
29 | "resources": [
30 | "scripts/leetcode.js",
31 | "scripts/models/*",
32 | "scripts/utils/*",
33 | "scripts/services/*",
34 | "scripts/constants/*"
35 | ],
36 | "matches": ["https://leetcode.com/*", "https://github.com/*"]
37 | }
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 | Leetcode Tracker
14 |
15 |
16 |
17 |
18 |
LeetcodeTracker.
19 |
20 |
21 |
22 |
Welcome !
23 |
24 | Authenticate with your Github account to track your progress on
25 | Leetcode.
26 |
27 |
28 |
33 |
38 | Authenticate
39 |
40 |
41 |
42 |
43 |
44 |
Hook your repository
45 |
46 | Create a github repository and enter the name to link it for tracking
47 | your progress.
48 |
49 |
60 |
61 |
62 |
63 |
64 | Change github account
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
79 |
80 |
81 |
Easy
82 |
Medium
83 |
Hard
84 |
-
85 |
-
86 |
-
87 |
88 |
89 |
90 |
117 |
118 |
119 |
120 |
Settings
121 |
122 |
123 |
124 |
125 |
126 | Submit only new solutions
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 | Sync multiple submissions for the same problem
141 |
142 |
143 |
144 |
145 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 | Add comments to your submissions
159 |
160 |
161 |
162 |
163 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 | Sync previously solved problems
177 |
178 |
179 |
180 |
186 |
193 |
196 |
200 |
201 | Sync
202 |
203 |
204 |
205 |
206 |
207 |
208 |
Repository
209 |
210 | repo name
211 |
212 |
213 |
214 |
215 | Unlink
216 |
217 |
218 |
219 |
220 |
221 |
222 |
Username
223 |
224 | username
225 |
226 |
227 |
228 |
229 | Logout
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
247 |
248 |
265 |
266 |
267 |
268 |
269 |
--------------------------------------------------------------------------------
/popup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * DOM element references for the extension popup interface.
3 | * Centralizes all DOM queries for better maintainability and performance.
4 | */
5 | const DOM = {
6 | authenticate: document.getElementById("authenticate"),
7 | authenticateButton: document.getElementById("github-authenticate-button"),
8 | hookRepo: document.getElementById("hook-repo"),
9 | authenticated: document.getElementById("authenticated"),
10 | repoName: document.getElementById("repo-name"),
11 | repoNameError: document.getElementById("repo-name-error"),
12 | hookButton: document.getElementById("hook-button"),
13 | unlinkButton: document.getElementById("unlink-button"),
14 | repositoryName: document.getElementById("repository-name"),
15 | repositoryLink: document.getElementById("repository-link"),
16 | githubUsername: document.getElementById("github-username"),
17 | logoutButton: document.getElementById("logout-button"),
18 | changeAccountButton: document.getElementById("change-account-button"),
19 | checkboxCodeSubmitSetting: document.getElementById("submit-code-checkbox"),
20 | checkboxSyncMultipleSubmissions: document.getElementById(
21 | "multiple-submission-checkbox"
22 | ),
23 | checkboxCommentSubmission: document.getElementById(
24 | "comment-submission-checkbox"
25 | ),
26 | syncButton: document.getElementById("sync-button"),
27 | syncStatus: document.getElementById("sync-status"),
28 | syncTime: document.getElementById("sync-time"),
29 | stats: {
30 | easy: document.getElementById("easy"),
31 | medium: document.getElementById("medium"),
32 | hard: document.getElementById("hard"),
33 | },
34 | };
35 |
36 | /**
37 | * Main controller class for the browser extension popup interface.
38 | * Manages authentication flow, repository linking, settings, and synchronization status.
39 | */
40 | class PopupManager {
41 | /**
42 | * Initialize the popup manager with all required components.
43 | * Sets up statistics display, event listeners, settings synchronization,
44 | * and starts background sync status monitoring.
45 | */
46 | constructor() {
47 | this.initializeStats();
48 | this.initializeEventListeners();
49 | this.initializeSetting();
50 |
51 | this.updateSyncStatus();
52 | this.syncStatusInterval = setInterval(() => this.updateSyncStatus(), 2000);
53 | }
54 |
55 | /**
56 | * Load and synchronize all user settings from Chrome storage to UI controls.
57 | * Ensures the popup displays current setting states correctly.
58 | */
59 | initializeSetting() {
60 | chrome.storage.local.get("leetcode_tracker_code_submit", (result) => {
61 | const codeSubmit = result.leetcode_tracker_code_submit;
62 | DOM.checkboxCodeSubmitSetting.checked = codeSubmit;
63 | });
64 |
65 | chrome.storage.local.get(
66 | "leetcode_tracker_sync_multiple_submission",
67 | (result) => {
68 | const isSync = result.leetcode_tracker_sync_multiple_submission;
69 | DOM.checkboxSyncMultipleSubmissions.checked = isSync;
70 | }
71 | );
72 |
73 | chrome.storage.local.get(
74 | "leetcode_tracker_comment_submission",
75 | (result) => {
76 | const isCommentEnabled = result.leetcode_tracker_comment_submission;
77 | DOM.checkboxCommentSubmission.checked = isCommentEnabled;
78 | }
79 | );
80 | }
81 |
82 | /**
83 | * Toggle the setting for syncing old problems that were solved before extension installation.
84 | * Provides backward compatibility for existing LeetCode solutions.
85 | */
86 | toggleSyncOldProblemsSetting() {
87 | chrome.storage.local.get("leetcode_tracker_sync_old_problems", (result) => {
88 | const syncOldProblems =
89 | result.leetcode_tracker_sync_old_problems !== undefined
90 | ? result.leetcode_tracker_sync_old_problems
91 | : false;
92 |
93 | chrome.storage.local.set({
94 | leetcode_tracker_sync_old_problems: !syncOldProblems,
95 | });
96 |
97 | this.initializeSetting();
98 | });
99 | }
100 |
101 | /**
102 | * Toggle the code submission setting with dependent setting management.
103 | * When disabled, automatically disables multiple submissions and comments
104 | * to maintain logical consistency.
105 | *
106 | * Algorithm:
107 | * 1. Get current code submit setting state
108 | * 2. Invert the setting value
109 | * 3. If disabling code submit, also disable dependent features
110 | * 4. Update storage and refresh UI
111 | */
112 | toggleCodeSubmitSetting() {
113 | chrome.storage.local.get("leetcode_tracker_code_submit", (result) => {
114 | const codeSubmit = result.leetcode_tracker_code_submit;
115 | chrome.storage.local.set({
116 | leetcode_tracker_code_submit: !codeSubmit,
117 | });
118 |
119 | // Disable dependent settings when code submit is disabled
120 | if (!codeSubmit) {
121 | chrome.storage.local.set({
122 | leetcode_tracker_sync_multiple_submission: false,
123 | leetcode_tracker_comment_submission: false,
124 | });
125 | }
126 |
127 | this.initializeSetting();
128 | });
129 | }
130 |
131 | /**
132 | * Toggle multiple submission synchronization with dependency management.
133 | * Handles complex interdependencies between settings to prevent invalid states.
134 | *
135 | * Settings Dependencies:
136 | * - When enabling: Disables code submit to prevent conflicts
137 | * - When disabling: Disables comment submission (requires multiple submissions)
138 | */
139 | toggleSyncMultipleSubmissionSetting() {
140 | chrome.storage.local.get(
141 | "leetcode_tracker_sync_multiple_submission",
142 | (result) => {
143 | const isSync = result.leetcode_tracker_sync_multiple_submission;
144 | chrome.storage.local.set({
145 | leetcode_tracker_sync_multiple_submission: !isSync,
146 | });
147 |
148 | if (!isSync) {
149 | // Enabling multiple submissions - disable conflicting settings
150 | chrome.storage.local.set({
151 | leetcode_tracker_code_submit: false,
152 | });
153 | } else {
154 | // Disabling multiple submissions - disable dependent settings
155 | chrome.storage.local.set({
156 | leetcode_tracker_comment_submission: false,
157 | });
158 | }
159 |
160 | this.initializeSetting();
161 | }
162 | );
163 | }
164 |
165 | /**
166 | * Toggle comment submission setting with prerequisite validation.
167 | * Comments require multiple submission mode, so enabling comments
168 | * automatically configures the required dependencies.
169 | */
170 | toggleCommentSubmissionSetting() {
171 | chrome.storage.local.get(
172 | "leetcode_tracker_comment_submission",
173 | (result) => {
174 | const isCommentEnabled = result.leetcode_tracker_comment_submission;
175 | chrome.storage.local.set({
176 | leetcode_tracker_comment_submission: !isCommentEnabled,
177 | });
178 |
179 | if (!isCommentEnabled) {
180 | // Enabling comments requires multiple submissions
181 | chrome.storage.local.set({
182 | leetcode_tracker_code_submit: false,
183 | leetcode_tracker_sync_multiple_submission: true,
184 | });
185 | }
186 |
187 | this.initializeSetting();
188 | }
189 | );
190 | }
191 |
192 | /**
193 | * Set up all event listeners for the popup interface.
194 | * Includes DOM event handlers and Chrome extension message listeners.
195 | */
196 | initializeEventListeners() {
197 | document.addEventListener("DOMContentLoaded", this.setupLinks.bind(this));
198 | DOM.authenticateButton.addEventListener(
199 | "click",
200 | this.handleAuthentication.bind(this)
201 | );
202 | DOM.hookButton.addEventListener("click", this.handleHookRepo.bind(this));
203 | DOM.unlinkButton.addEventListener("click", this.unlinkRepo.bind(this));
204 | DOM.logoutButton.addEventListener("click", this.logout.bind(this));
205 | DOM.changeAccountButton.addEventListener("click", this.logout.bind(this));
206 | DOM.checkboxCodeSubmitSetting.addEventListener(
207 | "click",
208 | this.toggleCodeSubmitSetting.bind(this)
209 | );
210 | DOM.checkboxSyncMultipleSubmissions.addEventListener(
211 | "click",
212 | this.toggleSyncMultipleSubmissionSetting.bind(this)
213 | );
214 | DOM.checkboxCommentSubmission.addEventListener(
215 | "click",
216 | this.toggleCommentSubmissionSetting.bind(this)
217 | );
218 | DOM.syncButton.addEventListener("click", this.startManualSync.bind(this));
219 |
220 | // Listen for statistics updates from background script
221 | chrome.runtime.onMessage.addListener((message) => {
222 | if (message.type === "statsUpdate") {
223 | this.updateStatsDisplay(message.data);
224 | }
225 | });
226 | }
227 |
228 | /**
229 | * Initiate manual synchronization of all solved problems.
230 | * Updates UI to show progress and sends sync command to background script.
231 | *
232 | * Algorithm:
233 | * 1. Disable sync button to prevent multiple concurrent syncs
234 | * 2. Replace button content with animated loading indicator
235 | * 3. Inject CSS animation for loading spinner
236 | * 4. Send sync message to background script
237 | * 5. Update sync status display
238 | */
239 | startManualSync() {
240 | DOM.syncButton.disabled = true;
241 | DOM.syncButton.innerHTML =
242 | 'Syncing... ';
243 |
244 | // Inject CSS animation for loading spinner
245 | const style = document.createElement("style");
246 | style.textContent = `
247 | .spin {
248 | animation: spin 1.5s linear infinite;
249 | }
250 | @keyframes spin {
251 | 0% { transform: rotate(0deg); }
252 | 100% { transform: rotate(360deg); }
253 | }
254 | `;
255 | document.head.appendChild(style);
256 |
257 | chrome.runtime.sendMessage({ type: "syncSolvedProblems" });
258 |
259 | this.updateSyncStatus();
260 | }
261 |
262 | /**
263 | * Update the synchronization status display with current progress and results.
264 | * Monitors background sync process and updates UI accordingly.
265 | *
266 | * Algorithm:
267 | * 1. Fetch sync status data from Chrome storage
268 | * 2. Update button state based on sync progress
269 | * 3. Display appropriate status message and styling
270 | * 4. Show formatted timestamp of last sync operation
271 | * 5. Handle error cases and edge states gracefully
272 | */
273 | async updateSyncStatus() {
274 | try {
275 | const result = await chrome.storage.local.get([
276 | "leetcode_tracker_sync_in_progress",
277 | "leetcode_tracker_last_sync_status",
278 | "leetcode_tracker_last_sync_message",
279 | "leetcode_tracker_last_sync_date",
280 | ]);
281 |
282 | const inProgress = result.leetcode_tracker_sync_in_progress || false;
283 | const lastStatus = result.leetcode_tracker_last_sync_status || "";
284 | const lastMessage = result.leetcode_tracker_last_sync_message || "";
285 | const lastDate = result.leetcode_tracker_last_sync_date
286 | ? new Date(result.leetcode_tracker_last_sync_date)
287 | : null;
288 |
289 | if (inProgress) {
290 | DOM.syncButton.disabled = true;
291 | DOM.syncButton.innerHTML =
292 | 'Syncing... ';
293 | DOM.syncStatus.textContent = "Synchronization in progress...";
294 | } else {
295 | DOM.syncButton.disabled = false;
296 | DOM.syncButton.innerHTML =
297 | 'Sync ';
298 |
299 | // Update status message based on last sync result
300 | if (lastStatus === "success") {
301 | DOM.syncStatus.textContent = "Last sync: Successful";
302 | DOM.syncStatus.className = "text-success";
303 | } else if (lastStatus === "failed") {
304 | DOM.syncStatus.textContent = "Last sync: Failed";
305 | DOM.syncStatus.className = "text-danger";
306 |
307 | if (lastMessage) {
308 | DOM.syncStatus.textContent = `Last sync: Failed - ${lastMessage}`;
309 | }
310 | } else if (!lastStatus) {
311 | DOM.syncStatus.textContent = "No synchronization performed yet";
312 | DOM.syncStatus.className = "text-muted";
313 | }
314 | }
315 |
316 | // Display formatted timestamp
317 | if (lastDate) {
318 | const formattedDate = this.formatDate(lastDate);
319 | DOM.syncTime.textContent = `${formattedDate}`;
320 | DOM.syncTime.className = "text-muted";
321 | } else {
322 | DOM.syncTime.textContent = "";
323 | }
324 | } catch (error) {
325 | // Handle errors silently to prevent popup disruption
326 | }
327 | }
328 |
329 | /**
330 | * Format a date object into a human-readable relative time string.
331 | * Provides intuitive time descriptions (e.g., "2 minutes ago", "Just now").
332 | *
333 | * @param {Date} date - The date to format
334 | * @returns {string} Human-readable relative time string
335 | */
336 | formatDate(date) {
337 | if (!date) return "";
338 |
339 | const now = new Date();
340 | const diffMs = now - date;
341 | const diffSeconds = Math.floor(diffMs / 1000);
342 |
343 | if (diffSeconds < 60) {
344 | return "Just now";
345 | } else if (diffSeconds < 3600) {
346 | const minutes = Math.floor(diffSeconds / 60);
347 | return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
348 | } else if (diffSeconds < 86400) {
349 | const hours = Math.floor(diffSeconds / 3600);
350 | return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
351 | } else {
352 | return date.toLocaleString();
353 | }
354 | }
355 |
356 | /**
357 | * Configure external links to open in new tabs.
358 | * Prevents navigation away from the popup interface.
359 | */
360 | setupLinks() {
361 | document.querySelectorAll("a.link").forEach((link) => {
362 | link.onclick = () => chrome.tabs.create({ active: true, url: link.href });
363 | });
364 | }
365 |
366 | /**
367 | * Check the current authentication status and display appropriate UI state.
368 | * Determines which section of the popup should be visible based on user progress.
369 | *
370 | * State Machine:
371 | * - No token/username → Show authentication section
372 | * - Has token but no repo → Show repository setup section
373 | * - Fully configured → Show main authenticated interface
374 | */
375 | async checkAuthStatus() {
376 | const result = await chrome.storage.local.get([
377 | "leetcode_tracker_token",
378 | "leetcode_tracker_username",
379 | "leetcode_tracker_mode",
380 | "leetcode_tracker_repo",
381 | ]);
382 |
383 | if (!result.leetcode_tracker_token || !result.leetcode_tracker_username) {
384 | DOM.authenticate.style.display = "block";
385 | } else if (!result.leetcode_tracker_repo || !result.leetcode_tracker_mode) {
386 | DOM.hookRepo.style.display = "block";
387 | } else {
388 | DOM.authenticated.style.display = "block";
389 | }
390 |
391 | this.updateUserInfos();
392 | }
393 |
394 | /**
395 | * Log out the user by clearing all stored data and resetting UI state.
396 | * Provides complete cleanup for account switching or privacy.
397 | */
398 | async logout() {
399 | try {
400 | await chrome.storage.local.clear();
401 |
402 | DOM.authenticate.style.display = "block";
403 | DOM.hookRepo.style.display = "none";
404 | DOM.authenticated.style.display = "none";
405 | } catch (error) {
406 | // Handle logout errors gracefully
407 | }
408 | }
409 |
410 | /**
411 | * Update the user information display with current GitHub username and repository.
412 | * Constructs the repository link for easy access to the GitHub repository.
413 | */
414 | async updateUserInfos() {
415 | const { leetcode_tracker_repo, leetcode_tracker_username } =
416 | await chrome.storage.local.get([
417 | "leetcode_tracker_repo",
418 | "leetcode_tracker_username",
419 | ]);
420 | if (leetcode_tracker_repo) {
421 | DOM.repositoryName.textContent = leetcode_tracker_repo;
422 | }
423 | if (leetcode_tracker_username) {
424 | DOM.githubUsername.textContent = leetcode_tracker_username;
425 | }
426 | if (leetcode_tracker_username && leetcode_tracker_repo) {
427 | DOM.repositoryLink.href = `https://github.com/${leetcode_tracker_username}/${leetcode_tracker_repo}`;
428 | }
429 | }
430 |
431 | /**
432 | * Initialize the statistics display by requesting current data from background script.
433 | * Shows loading state while fetching and updates display when data arrives.
434 | */
435 | async initializeStats() {
436 | try {
437 | this.startLoading();
438 |
439 | const initialStats = await this.getInitialStats();
440 | if (initialStats) {
441 | this.updateStatsDisplay(initialStats);
442 | }
443 | } catch (error) {
444 | // Handle stats loading errors gracefully
445 | }
446 | }
447 |
448 | /**
449 | * Request initial statistics data from the background script.
450 | * Uses Chrome messaging API to communicate with background processes.
451 | *
452 | * @returns {Promise} Promise resolving to statistics object
453 | */
454 | getInitialStats() {
455 | return new Promise((resolve) => {
456 | chrome.runtime.sendMessage(
457 | { type: "requestInitialStats" },
458 | (response) => {
459 | resolve(response);
460 | }
461 | );
462 | });
463 | }
464 |
465 | /**
466 | * Update the statistics display with new data from background script.
467 | * Handles both initial load and real-time updates during synchronization.
468 | *
469 | * @param {Object} stats - Statistics object with easy, medium, hard counts
470 | */
471 | updateStatsDisplay(stats) {
472 | if (!stats) return;
473 |
474 | this.stopLoading();
475 |
476 | Object.keys(DOM.stats).forEach((key) => {
477 | if (DOM.stats[key]) {
478 | DOM.stats[key].textContent = stats[key] || 0;
479 | }
480 | });
481 | }
482 |
483 | /**
484 | * Show loading animation for statistics section.
485 | * Provides visual feedback during data fetching.
486 | */
487 | startLoading() {
488 | document.getElementById("loading-container").style.display = "flex";
489 | document.getElementById("stats").classList.add("loading");
490 | }
491 |
492 | /**
493 | * Hide loading animation and show statistics content.
494 | * Called when data loading completes successfully.
495 | */
496 | stopLoading() {
497 | document.getElementById("loading-container").style.display = "none";
498 | document.getElementById("stats").classList.remove("loading");
499 | }
500 |
501 | /**
502 | * Handle GitHub authentication by opening OAuth flow in new tab.
503 | * Constructs proper OAuth URL with required parameters and scopes.
504 | */
505 | async handleAuthentication() {
506 | try {
507 | const data = await chrome.runtime.sendMessage({ type: "getDataConfig" });
508 | const url = `${data.URL}?client_id=${data.CLIENT_ID}&redirect_uri${
509 | data.REDIRECT_URL
510 | }&scope=${data.SCOPES.join(" ")}`;
511 | chrome.tabs.create({ url, active: true });
512 | } catch (error) {
513 | // Handle authentication errors gracefully
514 | }
515 | }
516 |
517 | /**
518 | * Handle repository setup and validation process.
519 | * Validates user input and attempts to link the specified repository.
520 | */
521 | async handleHookRepo() {
522 | const repositoryName = DOM.repoName.value;
523 | DOM.repoNameError.textContent = "";
524 |
525 | if (!repositoryName) {
526 | DOM.repoNameError.textContent = "Please enter a repository name";
527 | return;
528 | }
529 |
530 | try {
531 | const result = await chrome.storage.local.get([
532 | "leetcode_tracker_token",
533 | "leetcode_tracker_username",
534 | ]);
535 |
536 | if (result) {
537 | await this.linkRepo(result, repositoryName);
538 | }
539 | } catch (error) {
540 | DOM.repoNameError.textContent =
541 | "An error occurred while linking the repository";
542 | }
543 | }
544 |
545 | /**
546 | * Link and validate a GitHub repository for synchronization.
547 | * Verifies repository exists and user has appropriate access permissions.
548 | *
549 | * Algorithm:
550 | * 1. Extract authentication data and repository name
551 | * 2. Get API configuration from background script
552 | * 3. Make authenticated request to GitHub API to verify repository
553 | * 4. Handle authentication errors by logging out user
554 | * 5. Store repository configuration on successful validation
555 | * 6. Update UI to show authenticated state
556 | *
557 | * @param {Object} githubAuthData - Authentication data with token and username
558 | * @param {string} repositoryName - Name of repository to link
559 | */
560 | async linkRepo(githubAuthData, repositoryName) {
561 | const { leetcode_tracker_token, leetcode_tracker_username } =
562 | githubAuthData;
563 | const dataConfig = await chrome.runtime.sendMessage({
564 | type: "getDataConfig",
565 | });
566 |
567 | try {
568 | const response = await fetch(
569 | `${dataConfig.REPOSITORY_URL}${leetcode_tracker_username}/${repositoryName}`,
570 | {
571 | method: "GET",
572 | headers: {
573 | ...dataConfig.HEADERS,
574 | Authorization: `token ${leetcode_tracker_token}`,
575 | },
576 | }
577 | );
578 |
579 | const result = await response.json();
580 |
581 | if (!response.ok) {
582 | if (response.status === 401) {
583 | this.logout();
584 | }
585 |
586 | throw new Error(result.message);
587 | }
588 |
589 | await chrome.storage.local.set({
590 | leetcode_tracker_mode: "commit",
591 | leetcode_tracker_repo: repositoryName,
592 | });
593 |
594 | DOM.hookRepo.style.display = "none";
595 | DOM.authenticated.style.display = "block";
596 | } catch (error) {
597 | DOM.repoNameError.textContent = error.message;
598 | }
599 | }
600 |
601 | /**
602 | * Unlink the current repository and return to repository setup state.
603 | * Allows users to change repositories without full logout.
604 | */
605 | async unlinkRepo() {
606 | try {
607 | await chrome.storage.local.remove([
608 | "leetcode_tracker_mode",
609 | "leetcode_tracker_repo",
610 | ]);
611 | DOM.authenticated.style.display = "none";
612 | DOM.hookRepo.style.display = "block";
613 | } catch (error) {
614 | // Handle unlink errors gracefully
615 | }
616 | }
617 | }
618 |
619 | // Initialize the popup manager and check authentication status
620 | const popupManager = new PopupManager();
621 | popupManager.checkAuthStatus();
622 |
--------------------------------------------------------------------------------
/scripts/authorize.js:
--------------------------------------------------------------------------------
1 | let dataConfig = {};
2 |
3 | /**
4 | * Get github's access token.
5 | * @param {*} code
6 | */
7 | async function getAccessToken(code) {
8 | const response = await fetch(dataConfig.ACCESS_TOKEN_URL, {
9 | method: "POST",
10 | headers: dataConfig.HEADER,
11 | body: JSON.stringify({
12 | client_id: dataConfig.CLIENT_ID,
13 | client_secret: dataConfig.CLIENT_SECRET,
14 | code: code,
15 | }),
16 | });
17 |
18 | const data = await response.json();
19 |
20 | getUserInfo(data.access_token);
21 | }
22 |
23 | /**
24 | * Get github's user informations with the access token and save them in the local storage.
25 | * @param {*} accessToken
26 | */
27 | async function getUserInfo(accessToken) {
28 | const response = await fetch(dataConfig.USER_INFO_URL, {
29 | method: "GET",
30 | headers: {
31 | ...dataConfig.HEADER,
32 | Authorization: `token ${accessToken}`,
33 | },
34 | });
35 |
36 | const data = await response.json();
37 |
38 | chrome.runtime.sendMessage({
39 | type: "saveUserInfos",
40 | token: accessToken,
41 | username: data.login,
42 | });
43 | }
44 |
45 | /**
46 | * Retrieve config file from the background script.
47 | */
48 | if (window.location.host === "github.com") {
49 | const code = window.location.search.split("=")[1];
50 |
51 | chrome.runtime.sendMessage({ type: "getDataConfig" }).then((response) => {
52 | dataConfig = response;
53 |
54 | getAccessToken(code);
55 | });
56 | }
57 |
--------------------------------------------------------------------------------
/scripts/constants/dom-elements.js:
--------------------------------------------------------------------------------
1 | export const domElements = {
2 | submitButton: 'button[data-e2e-locator="console-submit-button"]',
3 | submissionResult: 'span[data-e2e-locator="submission-result"]',
4 | };
5 |
--------------------------------------------------------------------------------
/scripts/constants/languages.js:
--------------------------------------------------------------------------------
1 | export const baseLanguages = {
2 | cpp: { langName: "cpp", extension: ".cpp" },
3 | java: { langName: "java", extension: ".java" },
4 | python: { langName: "python", extension: ".py" },
5 | python3: { langName: "python", extension: ".py" },
6 | c: { langName: "c", extension: ".c" },
7 | csharp: { langName: "csharp", extension: ".cs" },
8 | javascript: { langName: "javascript", extension: ".js" },
9 | typescript: { langName: "typescript", extension: ".ts" },
10 | php: { langName: "php", extension: ".php" },
11 | swift: { langName: "swift", extension: ".swift" },
12 | kotlin: { langName: "kotlin", extension: ".kt" },
13 | dart: { langName: "dart", extension: ".dart" },
14 | golang: { langName: "golang", extension: ".go" },
15 | ruby: { langName: "ruby", extension: ".rb" },
16 | scala: { langName: "scala", extension: ".scala" },
17 | rust: { langName: "rust", extension: ".rs" },
18 | racket: { langName: "racket", extension: ".rkt" },
19 | erlang: { langName: "erlang", extension: ".erl" },
20 | elixir: { langName: "elixir", extension: ".ex" },
21 | };
22 |
23 | export const alternativeNames = {
24 | "c++": "cpp",
25 | "c#": "csharp",
26 | go: "golang",
27 | };
28 |
--------------------------------------------------------------------------------
/scripts/leetcode.js:
--------------------------------------------------------------------------------
1 | import Problem from "/scripts/models/problem.js";
2 | import RouteService from "/scripts/services/route-service.js";
3 | import DOMUtils from "/scripts/utils/dom-utils.js";
4 | import GithubService from "/scripts/services/github-service.js";
5 | import { domElements } from "/scripts/constants/dom-elements.js";
6 |
7 | /**
8 | * Main controller class for the LeetCode Tracker extension.
9 | * Orchestrates the interaction between LeetCode's interface and GitHub synchronization.
10 | */
11 | export default class LeetcodeTracker {
12 | /**
13 | * Initialize the LeetCode Tracker with required services and route monitoring.
14 | * Sets up problem model, GitHub service, and route change detection.
15 | */
16 | constructor() {
17 | this.problem = new Problem();
18 | this.githubService = new GithubService();
19 | this.route = new RouteService(() => this.init());
20 | this.init();
21 | }
22 |
23 | /**
24 | * Initialize or reinitialize the tracker for the current problem page.
25 | * Loads problem data from DOM and sets up submission monitoring.
26 | *
27 | * Algorithm:
28 | * 1. Extract problem metadata from current page DOM
29 | * 2. Wait for submit button to be available and interactive
30 | * 3. Attach click handler to monitor submission attempts
31 | */
32 | async init() {
33 | this.problem.loadProblemFromDOM();
34 | await DOMUtils.waitForElement(
35 | `${domElements.submitButton}:not([data-state="closed"])`
36 | );
37 | this.setupSubmitButton();
38 | }
39 |
40 | /**
41 | * Set up event listener on the LeetCode submit button to monitor submissions.
42 | * Ensures clean handler attachment by removing any existing listeners first.
43 | *
44 | * Algorithm:
45 | * 1. Remove any previously attached click handlers to prevent duplicates
46 | * 2. Create new click handler that clears old results and triggers submission handling
47 | * 3. Attach the handler to the submit button element
48 | * 4. Track handler attachment state to prevent memory leaks
49 | */
50 | setupSubmitButton() {
51 | const submitButton = document.querySelector(domElements.submitButton);
52 |
53 | if (this.clickHandlerAttached) {
54 | submitButton.removeEventListener("click", this.submitClickHandler);
55 | this.clickHandlerAttached = false;
56 | }
57 |
58 | this.submitClickHandler = () => {
59 | const existingResult = document.querySelector(
60 | domElements.submissionResult
61 | );
62 | if (existingResult) {
63 | existingResult.remove();
64 | }
65 |
66 | this.handleSubmission();
67 | };
68 |
69 | submitButton.addEventListener("click", this.submitClickHandler);
70 | this.clickHandlerAttached = true;
71 | }
72 |
73 | /**
74 | * Handle the submission process after user clicks submit button.
75 | * Waits for submission result and processes accepted solutions.
76 | *
77 | * Algorithm:
78 | * 1. Wait for submission result element to appear in DOM
79 | * 2. Check if submission was accepted (status === "Accepted")
80 | * 3. Check user settings for comment functionality
81 | * 4. Show comment popup if enabled, collect user input
82 | * 5. Extract language and code information from current DOM state
83 | * 6. Submit complete solution data to GitHub via GithubService
84 | */
85 | async handleSubmission() {
86 | await DOMUtils.waitForElement(domElements.submissionResult);
87 | const accepted = document.querySelector(domElements.submissionResult);
88 | if (accepted && accepted.textContent === "Accepted") {
89 | const result = await chrome.storage.local.get(
90 | "leetcode_tracker_comment_submission"
91 | );
92 | const isCommentEnabled =
93 | result.leetcode_tracker_comment_submission || false;
94 |
95 | const userComment = isCommentEnabled ? await this.showCommentPopup() : "";
96 | this.problem.extractLanguageFromDOM();
97 | this.problem.extractCodeFromDOM();
98 | this.githubService.submitToGitHub(this.problem, userComment);
99 | }
100 | }
101 |
102 | /**
103 | * Display a modal popup for users to add comments about their solution.
104 | * Provides a rich UI experience with proper styling and interaction handling.
105 | *
106 | * Algorithm:
107 | * 1. Create modal overlay with dark background
108 | * 2. Build popup content with header, instruction text, and textarea
109 | * 3. Style components with inline CSS for consistency across sites
110 | * 4. Add interactive buttons (Skip/Save) with hover effects
111 | * 5. Handle user interactions: save comment, skip, or click outside to close
112 | * 6. Clean up DOM elements and resolve promise with user input
113 | *
114 | * UI Components:
115 | * - Modal overlay with semi-transparent background
116 | * - Centered popup with professional styling
117 | * - Branded header with LeetcodeTracker name
118 | * - Instructional text explaining the purpose
119 | * - Large textarea for multi-line comments
120 | * - Action buttons with hover states and proper spacing
121 | *
122 | * @returns {Promise} User's comment text or empty string if skipped
123 | */
124 | showCommentPopup() {
125 | return new Promise((resolve) => {
126 | // Create modal overlay element
127 | const popup = document.createElement("div");
128 | popup.className = "leetcode-tracker-comment-popup";
129 | popup.style.cssText = `
130 | position: fixed;
131 | top: 0;
132 | left: 0;
133 | width: 100%;
134 | height: 100%;
135 | background-color: rgba(0, 0, 0, 0.7);
136 | display: flex;
137 | justify-content: center;
138 | align-items: center;
139 | z-index: 10000;
140 | `;
141 |
142 | // Create main popup content container
143 | const popupContent = document.createElement("div");
144 | popupContent.style.cssText = `
145 | background-color: white;
146 | padding: 24px;
147 | border-radius: 12px;
148 | width: 90%;
149 | max-width: 500px;
150 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
151 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
152 | `;
153 |
154 | // Create header section with branding
155 | const header = document.createElement("div");
156 | header.style.cssText = `
157 | display: flex;
158 | align-items: center;
159 | margin-bottom: 16px;
160 | `;
161 |
162 | const title = document.createElement("h3");
163 | title.innerHTML = "LeetcodeTracker ";
164 | title.style.cssText = `
165 | margin: 0;
166 | font-size: 20px;
167 | font-weight: 700;
168 | color: #262626;
169 | `;
170 |
171 | header.appendChild(title);
172 |
173 | // Create instruction text
174 | const instruction = document.createElement("p");
175 | instruction.textContent =
176 | "Add notes about your solution approach, time complexity, etc.";
177 | instruction.style.cssText = `
178 | color: #525252;
179 | font-size: 14px;
180 | margin-bottom: 16px;
181 | line-height: 1.5;
182 | `;
183 |
184 | // Create comment input textarea
185 | const textarea = document.createElement("textarea");
186 | textarea.style.cssText = `
187 | width: 100%;
188 | height: 150px;
189 | padding: 12px;
190 | box-sizing: border-box;
191 | border: 1px solid #E0E0E0;
192 | border-radius: 8px;
193 | color: rgb(0, 0, 0);
194 | font-family: inherit;
195 | font-size: 14px;
196 | margin-bottom: 20px;
197 | resize: vertical;
198 | background-color: #F5F5F5;
199 | `;
200 | textarea.placeholder =
201 | "Example: This solution uses a stack to keep track of...";
202 |
203 | // Create visual separator
204 | const separator = document.createElement("div");
205 | separator.style.cssText = `
206 | height: 1px;
207 | background-color: #E0E0E0;
208 | margin: 16px 0;
209 | `;
210 |
211 | // Create button container
212 | const buttonContainer = document.createElement("div");
213 | buttonContainer.style.cssText = `
214 | display: flex;
215 | justify-content: flex-end;
216 | gap: 12px;
217 | margin-top: 16px;
218 | `;
219 |
220 | // Create Skip button
221 | const skipButton = document.createElement("button");
222 | skipButton.textContent = "Skip";
223 | skipButton.style.cssText = `
224 | padding: 8px 16px;
225 | background-color: white;
226 | color: #525252;
227 | border: 1px solid #E0E0E0;
228 | border-radius: 20px;
229 | cursor: pointer;
230 | font-size: 14px;
231 | font-weight: 500;
232 | transition: background-color 0.2s;
233 | `;
234 |
235 | // Add hover effects for Skip button
236 | skipButton.onmouseover = () => {
237 | skipButton.style.backgroundColor = "#F5F5F5";
238 | };
239 | skipButton.onmouseout = () => {
240 | skipButton.style.backgroundColor = "white";
241 | };
242 |
243 | // Create Save button
244 | const saveButton = document.createElement("button");
245 | saveButton.textContent = "Save Comment";
246 | saveButton.style.cssText = `
247 | padding: 8px 16px;
248 | background-color: #FFA116;
249 | color: white;
250 | border: none;
251 | border-radius: 20px;
252 | cursor: pointer;
253 | font-size: 14px;
254 | font-weight: 500;
255 | transition: opacity 0.2s;
256 | `;
257 |
258 | // Add hover effects for Save button
259 | saveButton.onmouseover = () => {
260 | saveButton.style.opacity = "0.9";
261 | };
262 | saveButton.onmouseout = () => {
263 | saveButton.style.opacity = "1";
264 | };
265 |
266 | // Assemble DOM structure
267 | buttonContainer.appendChild(skipButton);
268 | buttonContainer.appendChild(saveButton);
269 |
270 | popupContent.appendChild(header);
271 | popupContent.appendChild(instruction);
272 | popupContent.appendChild(textarea);
273 | popupContent.appendChild(separator);
274 | popupContent.appendChild(buttonContainer);
275 |
276 | popup.appendChild(popupContent);
277 | document.body.appendChild(popup);
278 |
279 | // Focus on textarea for immediate typing
280 | setTimeout(() => textarea.focus(), 100);
281 |
282 | // Handle Skip button click
283 | skipButton.addEventListener("click", () => {
284 | document.body.removeChild(popup);
285 | resolve(""); // Resolve with empty string if user skips
286 | });
287 |
288 | // Handle Save button click
289 | saveButton.addEventListener("click", () => {
290 | const comment = textarea.value.trim();
291 | document.body.removeChild(popup);
292 | resolve(comment); // Resolve with user's comment
293 | });
294 |
295 | // Handle click outside popup to close
296 | popup.addEventListener("click", (e) => {
297 | if (e.target === popup) {
298 | document.body.removeChild(popup);
299 | resolve("");
300 | }
301 | });
302 | });
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/scripts/loader.js:
--------------------------------------------------------------------------------
1 | (async () => {
2 | try {
3 | const src = chrome.runtime.getURL("scripts/leetcode.js");
4 | const mainModule = await import(src);
5 |
6 | // Initialize LeetcodeTracker
7 | new mainModule.default();
8 | } catch (error) {
9 | console.error("Error loading LeetCode Tracker modules:", error);
10 | }
11 | })();
12 |
--------------------------------------------------------------------------------
/scripts/models/problem.js:
--------------------------------------------------------------------------------
1 | import LanguageUtils from "/scripts/utils/language-utils.js";
2 |
3 | export default class Problem {
4 | constructor() {
5 | this.slug = "";
6 | this.difficulty = "";
7 | this.description = "";
8 | this.problemUrl = "";
9 | this.code = "";
10 | this.language = {};
11 | }
12 |
13 | loadProblemFromDOM() {
14 | const url = this.getDescriptionUrl();
15 |
16 | if (url) {
17 | this.extractProblemInfos(url);
18 | }
19 | }
20 |
21 | getDescriptionUrl() {
22 | const url = window.location.href;
23 |
24 | if (url.includes("leetcode.com/problems/")) {
25 | const problemName = url
26 | .replace("https://leetcode.com/problems/", "")
27 | .split("/")[0];
28 |
29 | this.problemUrl = `/problems/${problemName}/`;
30 | return `https://leetcode.com/problems/${problemName}/description/`;
31 | }
32 |
33 | return "";
34 | }
35 |
36 | extractLanguageFromDOM() {
37 | const language =
38 | JSON.parse(window.localStorage.getItem("global_lang")) ||
39 | document.querySelector("#headlessui-popover-button-\\:r1s\\: button")
40 | ?.textContent;
41 |
42 | this.language = LanguageUtils.getLanguageInfo(language);
43 | }
44 |
45 | extractCodeFromDOM() {
46 | const codeElements = document.querySelectorAll(
47 | `code.language-${this.language.langName}`
48 | );
49 |
50 | this.code = codeElements[codeElements.length - 1].textContent;
51 | }
52 |
53 | extractProblemInfos(url) {
54 | const iframe = document.createElement("iframe");
55 |
56 | // Invisible iframe
57 | iframe.style.position = "absolute";
58 | iframe.style.width = "0";
59 | iframe.style.height = "0";
60 | iframe.style.border = "none";
61 | iframe.style.opacity = "0";
62 | iframe.style.pointerEvents = "none";
63 |
64 | iframe.src = url;
65 |
66 | // Observer to retrieve data from the iframe
67 | iframe.onload = () => {
68 | const iframeDocument =
69 | iframe.contentDocument || iframe.contentWindow.document;
70 |
71 | const observer = new MutationObserver((mutations, obs) => {
72 | // Extract data from the iframe
73 | this.extractDifficultyFromDOM(iframeDocument);
74 | this.extractDescriptionFromDOM(iframeDocument);
75 | this.extractSlugFromDOM(iframeDocument);
76 |
77 | // If all data is extracted, stop the observer
78 | if (this.difficulty && this.description && this.slug) {
79 | obs.disconnect();
80 | document.body.removeChild(iframe);
81 | }
82 | });
83 |
84 | observer.observe(document.body, {
85 | childList: true,
86 | subtree: true,
87 | });
88 |
89 | // Stop the observer after 3 seconds and remove the iframe
90 | setTimeout(() => {
91 | observer.disconnect();
92 | if (document.body.contains(iframe)) {
93 | document.body.removeChild(iframe);
94 | }
95 | }, 3000);
96 | };
97 |
98 | document.body.appendChild(iframe);
99 | }
100 |
101 | async extractSlugFromDOM(iframeContent) {
102 | const problemNameSelector = iframeContent.querySelector(
103 | `a[href='${this.problemUrl}']`
104 | );
105 |
106 | if (problemNameSelector) {
107 | this.slug = this.formatProblemName(problemNameSelector.textContent);
108 | }
109 | }
110 |
111 | async extractDifficultyFromDOM(iframeDocument) {
112 | const easy = iframeDocument.querySelector("div.text-difficulty-easy");
113 | const medium = iframeDocument.querySelector("div.text-difficulty-medium");
114 | const hard = iframeDocument.querySelector("div.text-difficulty-hard");
115 |
116 | if (easy) {
117 | this.difficulty = "easy";
118 | } else if (medium) {
119 | this.difficulty = "medium";
120 | } else if (hard) {
121 | this.difficulty = "hard";
122 | } else {
123 | this.difficulty = "";
124 | }
125 | }
126 |
127 | async extractDescriptionFromDOM(iframeDocument) {
128 | const problemDescription = iframeDocument.querySelector(
129 | 'div[data-track-load="description_content"]'
130 | );
131 | if (problemDescription) {
132 | this.description = problemDescription.textContent;
133 | }
134 | }
135 |
136 | formatProblemName(problemName) {
137 | return problemName.replace(".", "-").split(" ").join("");
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/scripts/services/configuration-service.js:
--------------------------------------------------------------------------------
1 | export default class ConfigurationService {
2 | constructor() {}
3 |
4 | /**
5 | * Retrieves configuration from Chrome local storage
6 | * @param {Array} properties - The configuration properties to retrieve
7 | * @returns {Promise} Object containing configuration values
8 | */
9 | async getChromeStorageConfig(properties) {
10 | return new Promise((resolve) => {
11 | chrome.storage.local.get(properties, resolve);
12 | });
13 | }
14 |
15 | /**
16 | * Retrieves data configuration from the background script
17 | * @returns {Promise} Data configuration object
18 | */
19 | async getDataConfig() {
20 | return new Promise((resolve) => {
21 | chrome.storage.local.get("leetcode_tracker_data_config", (result) => {
22 | resolve(result.leetcode_tracker_data_config);
23 | });
24 | });
25 | }
26 |
27 | /**
28 | * Validates that all required configuration fields are present
29 | * @param {Object} config - The configuration object to validate
30 | * @param {Array} [requiredFields] - List of field names that must be present and non-empty
31 | * @returns {boolean} True if all required fields are present and non-empty
32 | */
33 | isConfigValid(
34 | config,
35 | requiredFields = [
36 | "leetcode_tracker_repo",
37 | "leetcode_tracker_username",
38 | "leetcode_tracker_token",
39 | ]
40 | ) {
41 | if (!config) return false;
42 |
43 | return requiredFields.every((field) => {
44 | // Check if the property exists and has a truthy value
45 | return (
46 | config[field] !== undefined &&
47 | config[field] !== null &&
48 | config[field] !== ""
49 | );
50 | });
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/scripts/services/github-service.js:
--------------------------------------------------------------------------------
1 | import ConfigurationService from "/scripts/services/configuration-service.js";
2 |
3 | /**
4 | * Service for managing GitHub repository operations for LeetCode problem synchronization.
5 | * Handles file creation, updates, and repository management with proper authentication.
6 | */
7 | export default class GithubService {
8 | /**
9 | * Initialize GitHub service with independent instance isolation.
10 | * Each instance gets a unique ID to prevent concurrent operation conflicts.
11 | */
12 | constructor() {
13 | this.configurationService = new ConfigurationService();
14 |
15 | this.submissionInProgress = false;
16 | this.problem = null;
17 | this.comment = "";
18 |
19 | // Make each instance independent to prevent race conditions
20 | this.instanceId = Math.random().toString(36).substr(2, 9);
21 | }
22 |
23 | /**
24 | * Initialize the service by loading configuration settings from Chrome storage.
25 | * Must be called before performing any GitHub operations.
26 | *
27 | * @throws {Error} If configuration loading fails
28 | */
29 | async init() {
30 | try {
31 | this.userConfig = await this.configurationService.getChromeStorageConfig([
32 | "leetcode_tracker_repo",
33 | "leetcode_tracker_username",
34 | "leetcode_tracker_token",
35 | ]);
36 | this.dataConfig = await this.configurationService.getDataConfig();
37 | const result = await chrome.storage.local.get(
38 | "leetcode_tracker_sync_multiple_submission"
39 | );
40 | this.syncMultipleSubmissionsSettingEnabled =
41 | result.leetcode_tracker_sync_multiple_submission || false;
42 | } catch (error) {
43 | throw error;
44 | }
45 | }
46 |
47 | /**
48 | * Submit a LeetCode problem solution to GitHub repository.
49 | * Orchestrates the complete submission workflow with duplicate detection.
50 | *
51 | * Algorithm:
52 | * 1. Initialize configuration and validate settings
53 | * 2. Check if submission is already in progress (prevent duplicates)
54 | * 3. Verify file existence in repository
55 | * 4. Compare content if file exists and handle based on user settings
56 | * 5. Create new file or update existing one accordingly
57 | *
58 | * @param {Object} problem - Problem object containing code, metadata, and language info
59 | * @param {string} comment - Optional comment to include in the submission
60 | */
61 | async submitToGitHub(problem, comment = "") {
62 | await this.init();
63 | this.problem = problem;
64 | this.comment = comment;
65 |
66 | if (
67 | this.submissionInProgress ||
68 | !this.configurationService.isConfigValid(this.userConfig)
69 | ) {
70 | return;
71 | }
72 |
73 | this.submissionInProgress = true;
74 |
75 | try {
76 | const fileExists = await this.checkFileExistence();
77 |
78 | if (fileExists && !this.syncMultipleSubmissionsSettingEnabled) {
79 | const currentContent = atob(fileExists.content);
80 | const newContent = this.getFormattedCode();
81 | const result = await this.configurationService.getChromeStorageConfig([
82 | "leetcode_tracker_code_submit",
83 | ]);
84 | const skipDuplicates = result.leetcode_tracker_code_submit;
85 | const contentIsSame = !(await this.contentsDiffer(
86 | currentContent,
87 | newContent
88 | ));
89 |
90 | // Skip update if setting is enabled and content hasn't changed
91 | if (skipDuplicates && contentIsSame) {
92 | return;
93 | }
94 |
95 | await this.updateFile(fileExists);
96 | } else {
97 | await this.createFile();
98 | }
99 | } finally {
100 | this.submissionInProgress = false;
101 | }
102 | }
103 |
104 | /**
105 | * Update an existing file in the GitHub repository.
106 | *
107 | * @param {Object} existingFile - File object from GitHub API containing SHA and metadata
108 | * @returns {Promise} GitHub API response object
109 | * @throws {Error} If the update operation fails
110 | */
111 | async updateFile(existingFile) {
112 | const url = this.buildGitHubUrl(false);
113 | const currentDate = new Date().toLocaleString();
114 |
115 | const body = {
116 | message: `File updated at ${currentDate}`,
117 | content: btoa(this.getFormattedCode()),
118 | sha: existingFile.sha, // Required for updates to prevent conflicts
119 | };
120 |
121 | const response = await this.fetchWithAuth(url, "PUT", body);
122 |
123 | if (!response.ok) {
124 | const errorData = await response.json().catch(() => ({}));
125 | throw new Error(
126 | `Failed to update file: ${response.status} - ${
127 | errorData.message || "Unknown error"
128 | }`
129 | );
130 | }
131 |
132 | return response;
133 | }
134 |
135 | /**
136 | * Create a new file in the GitHub repository with optional README generation.
137 | *
138 | * Algorithm:
139 | * 1. Validate problem object exists
140 | * 2. Build appropriate GitHub URL for the target file
141 | * 3. Create the main solution file with formatted code
142 | * 4. Handle file conflicts gracefully (422 status for existing files)
143 | * 5. Create README file if problem description is available
144 | * 6. Update difficulty statistics if not in sync mode
145 | * 7. Log all operations with instance ID for debugging
146 | *
147 | * @param {boolean} isSyncing - Whether this is part of a bulk sync operation
148 | * @returns {Promise} GitHub API response object
149 | * @throws {Error} If file creation fails or problem object is invalid
150 | */
151 | async createFile(isSyncing = false) {
152 | if (!this.problem) {
153 | throw new Error("No problem set for file creation");
154 | }
155 |
156 | const codeUrl = this.buildGitHubUrl(isSyncing);
157 | const readmeUrl = this.buildGitHubUrl(isSyncing, "README.md");
158 |
159 | const codeBody = {
160 | message: `Create ${this.problem.slug}`,
161 | content: btoa(this.getFormattedCode()),
162 | };
163 |
164 | try {
165 | const result = await this.fetchWithAuth(codeUrl, "PUT", codeBody);
166 |
167 | if (!result.ok) {
168 | const errorData = await result.json().catch(() => ({}));
169 |
170 | // Handle file already exists scenario gracefully
171 | if (
172 | result.status === 422 &&
173 | errorData.message?.includes("already exists")
174 | ) {
175 | return result;
176 | }
177 |
178 | throw new Error(
179 | `Failed to create file: ${result.status} - ${
180 | errorData.message || "Unknown error"
181 | }`
182 | );
183 | }
184 |
185 | const resultJson = await result.json();
186 |
187 | if (result.status === 201) {
188 | try {
189 | // Create README only if description exists and is meaningful
190 | if (this.problem.description && this.problem.description.trim()) {
191 | const readmeBody = {
192 | message: `Add README for ${this.problem.slug}`,
193 | content: this.utf8ToBase64(this.problem.description),
194 | };
195 |
196 | const readmeResult = await this.fetchWithAuth(
197 | readmeUrl,
198 | "PUT",
199 | readmeBody
200 | );
201 |
202 | // Don't fail main operation if README creation fails
203 | if (!readmeResult.ok) {
204 | // README creation failed but continue with main operation
205 | }
206 | }
207 | } catch (readmeError) {
208 | // Don't fail main file creation due to README issues
209 | } finally {
210 | // Update statistics only in normal mode (not during bulk sync)
211 | if (!isSyncing) {
212 | try {
213 | chrome.runtime.sendMessage({
214 | type: "updateDifficultyStats",
215 | difficulty: this.problem.difficulty,
216 | });
217 | } catch (messageError) {
218 | // Statistics update failed but don't fail the main operation
219 | }
220 | }
221 | }
222 | }
223 |
224 | return result;
225 | } catch (error) {
226 | throw error;
227 | }
228 | }
229 |
230 | /**
231 | * Compare two content strings to determine if they differ significantly.
232 | * Normalizes whitespace and line endings for accurate comparison.
233 | *
234 | * @param {string} currentContent - Existing file content
235 | * @param {string} newContent - New content to compare
236 | * @returns {Promise} True if contents differ, false if they're the same
237 | */
238 | async contentsDiffer(currentContent, newContent) {
239 | const normalize = (content) =>
240 | content.trim().replace(/\r\n/g, "\n").replace(/\s+/g, " ");
241 | return normalize(currentContent) !== normalize(newContent);
242 | }
243 |
244 | /**
245 | * Check if a file already exists in the GitHub repository.
246 | *
247 | * @param {boolean} isSyncing - Whether this is part of a bulk sync operation
248 | * @returns {Promise} File object if exists, null if not found
249 | * @throws {Error} If problem object is missing or API request fails
250 | */
251 | async checkFileExistence(isSyncing = false) {
252 | if (!this.problem) {
253 | throw new Error("No problem set for file existence check");
254 | }
255 |
256 | const url = this.buildGitHubUrl(isSyncing);
257 |
258 | try {
259 | const response = await this.fetchWithAuth(url, "GET");
260 |
261 | if (response.ok) {
262 | return await response.json();
263 | } else if (response.status === 404) {
264 | // File doesn't exist, which is expected for new files
265 | return null;
266 | } else {
267 | const errorData = await response.json().catch(() => ({}));
268 | throw new Error(
269 | `Failed to check file existence: ${response.status} - ${
270 | errorData.message || "Unknown error"
271 | }`
272 | );
273 | }
274 | } catch (error) {
275 | throw error;
276 | }
277 | }
278 |
279 | /**
280 | * Format the problem code with appropriate headers and comments.
281 | * Generates language-specific comment formats and includes metadata.
282 | *
283 | * Algorithm:
284 | * 1. Validate problem and code availability
285 | * 2. Get appropriate comment format for the programming language
286 | * 3. Create header with last updated timestamp
287 | * 4. Add user comment if provided (handles both single and multi-line)
288 | * 5. Append the actual solution code
289 | *
290 | * @returns {string} Formatted code string with headers and comments
291 | * @throws {Error} If problem or code is not available
292 | */
293 | getFormattedCode() {
294 | if (!this.problem || !this.problem.code) {
295 | throw new Error("No problem or code available for formatting");
296 | }
297 |
298 | const currentDate = new Date().toLocaleString();
299 |
300 | // Get appropriate comment format for the programming language
301 | const commentFormat = this.getCommentFormat(
302 | this.problem.language.extension
303 | );
304 |
305 | // Create header with timestamp
306 | let header = `${commentFormat.line} Last updated: ${currentDate}\n`;
307 |
308 | // Add user comment if provided
309 | if (this.comment && this.comment.trim()) {
310 | // Handle multi-line comments with proper formatting
311 | if (this.comment.includes("\n")) {
312 | header += `${commentFormat.start}\n`;
313 |
314 | // Format each line of the comment
315 | this.comment.split("\n").forEach((line) => {
316 | header += `${commentFormat.linePrefix}${line}\n`;
317 | });
318 |
319 | header += `${commentFormat.end}\n\n`;
320 | } else {
321 | // Single line comment
322 | header += `${commentFormat.line} ${this.comment}\n`;
323 | }
324 | }
325 |
326 | // Combine header with actual code
327 | return header + this.problem.code;
328 | }
329 |
330 | /**
331 | * Get the appropriate comment format for different programming languages.
332 | * Supports both single-line and multi-line comment styles.
333 | *
334 | * @param {string} extension - File extension (e.g., ".js", ".py", ".java")
335 | * @returns {Object} Object containing comment format strings for the language
336 | */
337 | getCommentFormat(extension) {
338 | switch (extension) {
339 | case ".py":
340 | return {
341 | line: "#",
342 | start: "'''",
343 | end: "'''",
344 | linePrefix: "",
345 | };
346 | case ".rb":
347 | return {
348 | line: "#",
349 | start: "=begin",
350 | end: "=end",
351 | linePrefix: "",
352 | };
353 | case ".php":
354 | return {
355 | line: "//",
356 | start: "/*",
357 | end: "*/",
358 | linePrefix: " * ",
359 | };
360 | case ".js":
361 | case ".ts":
362 | case ".kt":
363 | case ".java":
364 | case ".c":
365 | case ".cpp":
366 | case ".cs":
367 | case ".swift":
368 | case ".scala":
369 | case ".dart":
370 | case ".go":
371 | return {
372 | line: "//",
373 | start: "/*",
374 | end: "*/",
375 | linePrefix: " * ",
376 | };
377 | case ".rs":
378 | return {
379 | line: "//",
380 | start: "/*",
381 | end: "*/",
382 | linePrefix: " * ",
383 | };
384 | case ".ex":
385 | return {
386 | line: "#",
387 | start: '@doc """',
388 | end: '"""',
389 | linePrefix: "",
390 | };
391 | case ".erl":
392 | return {
393 | line: "%",
394 | start: "%% ---",
395 | end: "%% ---",
396 | linePrefix: "%% ",
397 | };
398 | case ".rkt":
399 | return {
400 | line: ";",
401 | start: "#|",
402 | end: "|#",
403 | linePrefix: " ",
404 | };
405 | default:
406 | // Default to C-style comments for unknown languages
407 | return {
408 | line: "//",
409 | start: "/*",
410 | end: "*/",
411 | linePrefix: " * ",
412 | };
413 | }
414 | }
415 |
416 | /**
417 | * Convert UTF-8 string to Base64 encoding for GitHub API.
418 | * Handles Unicode characters properly for international content.
419 | *
420 | * @param {string} str - UTF-8 string to encode
421 | * @returns {string} Base64 encoded string
422 | */
423 | utf8ToBase64(str) {
424 | return btoa(
425 | encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
426 | String.fromCharCode("0x" + p1)
427 | )
428 | );
429 | }
430 |
431 | /**
432 | * Build the GitHub API URL for file operations.
433 | * Constructs repository path with optional versioning support.
434 | *
435 | * Algorithm:
436 | * 1. Validate problem object exists
437 | * 2. Add timestamp suffix if multiple submissions are enabled
438 | * 3. Create version path for language-specific organization
439 | * 4. Combine all components into complete GitHub API URL
440 | *
441 | * @param {boolean} isSyncing - Whether this is part of bulk sync (affects versioning)
442 | * @param {string} file - Optional specific filename override
443 | * @returns {string} Complete GitHub API URL for file operations
444 | * @throws {Error} If problem object is not set
445 | */
446 | buildGitHubUrl(isSyncing, file = "") {
447 | if (!this.problem) {
448 | throw new Error("No problem set for URL building");
449 | }
450 |
451 | const dateTime =
452 | this.syncMultipleSubmissionsSettingEnabled && !isSyncing
453 | ? `_${this.getLocalTimeString()}`
454 | : "";
455 |
456 | const fileName =
457 | file ||
458 | `${this.problem.slug}${dateTime}${this.problem.language.extension}`;
459 | const versionPath =
460 | this.syncMultipleSubmissionsSettingEnabled && !isSyncing
461 | ? `/version/${this.problem.language.langName}`
462 | : "";
463 |
464 | return `${this.dataConfig.REPOSITORY_URL}${this.userConfig.leetcode_tracker_username}/${this.userConfig.leetcode_tracker_repo}/contents/${this.problem.slug}${versionPath}/${fileName}`;
465 | }
466 |
467 | /**
468 | * Execute authenticated HTTP requests to GitHub API.
469 | * Handles authentication headers and request configuration.
470 | *
471 | * @param {string} url - GitHub API endpoint URL
472 | * @param {string} method - HTTP method (GET, PUT, POST, etc.)
473 | * @param {Object} body - Optional request body for PUT/POST requests
474 | * @returns {Promise} Fetch API response object
475 | * @throws {Error} If network request fails
476 | */
477 | async fetchWithAuth(url, method, body = null) {
478 | const options = {
479 | method,
480 | headers: {
481 | ...this.dataConfig.HEADERS,
482 | Authorization: `token ${this.userConfig.leetcode_tracker_token}`,
483 | },
484 | };
485 | if (body) options.body = JSON.stringify(body);
486 |
487 | try {
488 | const response = await fetch(url, options);
489 | return response;
490 | } catch (error) {
491 | throw error;
492 | }
493 | }
494 |
495 | /**
496 | * Generate a timestamp string for versioning multiple submissions.
497 | * Creates a sortable datetime string in YYYYMMDD_HHMMSS format.
498 | *
499 | * @returns {string} Formatted timestamp string for file versioning
500 | */
501 | getLocalTimeString() {
502 | const now = new Date();
503 |
504 | // Format: YYYYMMDD_HHMMSS
505 | return (
506 | now.getFullYear() +
507 | ("0" + (now.getMonth() + 1)).slice(-2) +
508 | ("0" + now.getDate()).slice(-2) +
509 | "_" +
510 | ("0" + now.getHours()).slice(-2) +
511 | ("0" + now.getMinutes()).slice(-2) +
512 | ("0" + now.getSeconds()).slice(-2)
513 | );
514 | }
515 | }
516 |
--------------------------------------------------------------------------------
/scripts/services/leetcode-service.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Service for interacting with LeetCode's API to fetch problem data and submissions.
3 | * Handles authentication, rate limiting, and data transformation for synchronization.
4 | */
5 | export default class LeetCodeService {
6 | /**
7 | * Initialize the LeetCode service with caching capability.
8 | */
9 | constructor() {
10 | this.cachedProblems = null;
11 | }
12 |
13 | /**
14 | * Fetches all questions from LeetCode GraphQL API with their difficulty levels.
15 | * Used to get comprehensive problem metadata.
16 | *
17 | * @returns {Promise} Array of question objects with questionId and difficulty
18 | */
19 | async fetchAllQuestionsDifficulty() {
20 | const graphqlQuery = {
21 | query: `
22 | query allQuestions {
23 | allQuestions {
24 | questionId
25 | difficulty
26 | }
27 | }
28 | `,
29 | };
30 |
31 | try {
32 | const response = await fetch("https://leetcode.com/graphql", {
33 | method: "POST",
34 | headers: { "Content-Type": "application/json" },
35 | body: JSON.stringify(graphqlQuery),
36 | });
37 |
38 | const data = await response.json();
39 | return data.data.allQuestions;
40 | } catch (error) {
41 | return [];
42 | }
43 | }
44 |
45 | /**
46 | * Retrieves the list of solved problems for the authenticated user.
47 | * Uses caching to avoid repeated API calls during the same session.
48 | *
49 | * Algorithm:
50 | * 1. Check if problems are already cached
51 | * 2. If not cached, fetch from LeetCode API with credentials
52 | * 3. Verify user is logged in by checking username
53 | * 4. Cache the results and filter for accepted solutions only
54 | *
55 | * @returns {Promise} Array of solved problem objects (status === "ac")
56 | * @throws {Error} If user is not logged in or API request fails
57 | */
58 | async getSolvedProblems() {
59 | try {
60 | if (this.cachedProblems) {
61 | return this.cachedProblems.filter((problem) => problem.status === "ac");
62 | }
63 |
64 | const response = await fetch("https://leetcode.com/api/problems/all/", {
65 | method: "GET",
66 | credentials: "include", // Include cookies for authentication
67 | });
68 |
69 | if (!response.ok) {
70 | throw new Error(`API error: ${response.status}`);
71 | }
72 |
73 | const data = await response.json();
74 |
75 | // Verify user authentication by checking username presence
76 | if (!data.user_name || data.user_name.trim() === "") {
77 | throw new Error(
78 | "You have not logged in to LeetCode. Please log in for syncing problems."
79 | );
80 | }
81 |
82 | this.cachedProblems = data.stat_status_pairs;
83 |
84 | return this.cachedProblems.filter((problem) => problem.status === "ac");
85 | } catch (error) {
86 | throw error;
87 | }
88 | }
89 |
90 | /**
91 | * Retrieves submissions for a specific problem organized by programming language.
92 | * Implements comprehensive error handling and rate limiting.
93 | *
94 | * Algorithm:
95 | * 1. Add delay to respect API rate limits
96 | * 2. Fetch submission list for the problem (accepted only)
97 | * 3. Group submissions by language, keeping the most recent per language
98 | * 4. For each language, fetch detailed submission code
99 | * 5. Handle various error conditions (HTTP errors, GraphQL errors, rate limits)
100 | * 6. Return structured data with code and metadata
101 | *
102 | * Error Handling Strategy:
103 | * - HTTP 429/5xx errors: Set needsPause flag for retry with delay
104 | * - GraphQL errors: Set needsPause flag as likely rate limit
105 | * - Missing data: Set needsPause flag as API overload indicator
106 | * - Missing code: Skip submission but continue processing others
107 | *
108 | * @param {string} titleSlug - The problem's URL slug identifier
109 | * @returns {Promise} Object mapping language codes to submission data
110 | * @throws {Error} With needsPause property for rate limit scenarios
111 | */
112 | async getSubmissionsByLanguage(titleSlug) {
113 | try {
114 | // Delay to respect API rate limits
115 | await this.sleep(500);
116 |
117 | const submissionsResponse = await fetch("https://leetcode.com/graphql", {
118 | method: "POST",
119 | headers: { "Content-Type": "application/json" },
120 | credentials: "include",
121 | body: JSON.stringify({
122 | query: `
123 | query submissionList($offset: Int!, $limit: Int!, $lastKey: String, $questionSlug: String!, $lang: Int, $status: Int) {
124 | questionSubmissionList(
125 | offset: $offset
126 | limit: $limit
127 | lastKey: $lastKey
128 | questionSlug: $questionSlug
129 | lang: $lang
130 | status: $status
131 | ) {
132 | lastKey
133 | hasNext
134 | submissions {
135 | id
136 | title
137 | titleSlug
138 | status
139 | statusDisplay
140 | lang
141 | langName
142 | runtime
143 | timestamp
144 | url
145 | isPending
146 | memory
147 | hasNotes
148 | notes
149 | flagType
150 | frontendId
151 | topicTags {
152 | id
153 | }
154 | }
155 | }
156 | }
157 | `,
158 | variables: {
159 | questionSlug: titleSlug,
160 | offset: 0,
161 | limit: 20, // Reduced limit to avoid timeouts
162 | lastKey: "null",
163 | status: 10, // Only accepted submissions
164 | },
165 | }),
166 | });
167 |
168 | // Check HTTP status for rate limiting indicators
169 | if (!submissionsResponse.ok) {
170 | const error = new Error(`HTTP error: ${submissionsResponse.status}`);
171 | error.needsPause =
172 | submissionsResponse.status === 429 ||
173 | submissionsResponse.status >= 500;
174 | throw error;
175 | }
176 |
177 | const submissionsData = await submissionsResponse.json();
178 |
179 | // Check for GraphQL errors which often indicate rate limiting
180 | if (submissionsData.errors) {
181 | const error = new Error(
182 | `GraphQL errors: ${submissionsData.errors
183 | .map((e) => e.message)
184 | .join(", ")}`
185 | );
186 | error.needsPause = true;
187 | throw error;
188 | }
189 |
190 | // Validate response structure
191 | if (
192 | !submissionsData.data ||
193 | !submissionsData.data.questionSubmissionList
194 | ) {
195 | const error = new Error(
196 | "Invalid submission list response - API rate limit likely reached"
197 | );
198 | error.needsPause = true;
199 | throw error;
200 | }
201 |
202 | const submissions =
203 | submissionsData.data.questionSubmissionList.submissions;
204 |
205 | // Filter for accepted submissions only
206 | const acceptedSubmissions = submissions.filter(
207 | (sub) => sub.status === 10
208 | );
209 |
210 | if (acceptedSubmissions.length === 0) {
211 | return {};
212 | }
213 |
214 | const submissionsByLang = {};
215 |
216 | // Find the most recent submission per language
217 | for (const submission of acceptedSubmissions) {
218 | const lang = submission.lang;
219 | const timestamp = parseInt(submission.timestamp);
220 |
221 | if (
222 | !submissionsByLang[lang] ||
223 | timestamp > submissionsByLang[lang].timestamp
224 | ) {
225 | submissionsByLang[lang] = {
226 | id: submission.id,
227 | title: submission.title,
228 | timestamp: timestamp,
229 | lang: lang,
230 | };
231 | }
232 | }
233 |
234 | const result = {};
235 |
236 | // Fetch detailed code for each language submission
237 | for (const lang in submissionsByLang) {
238 | // Longer delay between detail requests to be more respectful
239 | await this.sleep(800);
240 |
241 | const submissionId = submissionsByLang[lang].id;
242 |
243 | const detailsResponse = await fetch("https://leetcode.com/graphql", {
244 | method: "POST",
245 | headers: { "Content-Type": "application/json" },
246 | credentials: "include",
247 | body: JSON.stringify({
248 | query: `
249 | query submissionDetails($submissionId: Int!) {
250 | submissionDetails(submissionId: $submissionId) {
251 | runtime
252 | runtimeDisplay
253 | runtimePercentile
254 | runtimeDistribution
255 | memory
256 | memoryDisplay
257 | memoryPercentile
258 | memoryDistribution
259 | code
260 | timestamp
261 | statusCode
262 | user {
263 | username
264 | profile {
265 | realName
266 | userAvatar
267 | }
268 | }
269 | lang {
270 | name
271 | verboseName
272 | }
273 | question {
274 | questionId
275 | titleSlug
276 | hasFrontendPreview
277 | }
278 | notes
279 | flagType
280 | topicTags {
281 | tagId
282 | slug
283 | name
284 | }
285 | runtimeError
286 | compileError
287 | lastTestcase
288 | codeOutput
289 | expectedOutput
290 | totalCorrect
291 | totalTestcases
292 | fullCodeOutput
293 | testDescriptions
294 | testBodies
295 | testInfo
296 | stdOutput
297 | }
298 | }
299 | `,
300 | variables: {
301 | submissionId: submissionId,
302 | },
303 | }),
304 | });
305 |
306 | // Check HTTP status for rate limiting
307 | if (!detailsResponse.ok) {
308 | const error = new Error(`HTTP error: ${detailsResponse.status}`);
309 | error.needsPause =
310 | detailsResponse.status === 429 || detailsResponse.status >= 500;
311 | throw error;
312 | }
313 |
314 | const detailsData = await detailsResponse.json();
315 |
316 | // Check for GraphQL errors
317 | if (detailsData.errors) {
318 | const error = new Error(
319 | `GraphQL errors: ${detailsData.errors
320 | .map((e) => e.message)
321 | .join(", ")}`
322 | );
323 | error.needsPause = true;
324 | throw error;
325 | }
326 |
327 | // Validate response data structure
328 | if (!detailsData.data) {
329 | const error = new Error(
330 | "Invalid details response - API rate limit likely reached"
331 | );
332 | error.needsPause = true;
333 | throw error;
334 | }
335 |
336 | const details = detailsData.data.submissionDetails;
337 |
338 | // Check for null details (common rate limit indicator)
339 | if (!details) {
340 | const error = new Error(
341 | "API rate limit reached - null submission details received"
342 | );
343 | error.needsPause = true;
344 | throw error;
345 | }
346 |
347 | // Skip submissions without code (shouldn't happen for accepted submissions)
348 | if (!details.code) {
349 | continue;
350 | }
351 |
352 | result[lang] = {
353 | questionId: details.question.questionId,
354 | title: this.kebabToPascalCase(details.question.titleSlug),
355 | titleSlug: details.question.titleSlug,
356 | status_display: "Accepted",
357 | code: details.code,
358 | timestamp: details.timestamp,
359 | lang: lang,
360 | };
361 | }
362 |
363 | // Ensure we have at least one valid submission
364 | if (Object.keys(result).length === 0) {
365 | return {};
366 | }
367 |
368 | return result;
369 | } catch (error) {
370 | throw error;
371 | }
372 | }
373 |
374 | /**
375 | * Utility function to pause execution for rate limiting.
376 | *
377 | * @param {number} ms - Milliseconds to sleep
378 | * @returns {Promise} Promise that resolves after the specified delay
379 | */
380 | sleep(ms) {
381 | return new Promise((resolve) => setTimeout(resolve, ms));
382 | }
383 |
384 | /**
385 | * Converts kebab-case strings to PascalCase for consistent naming.
386 | * Used to transform LeetCode problem slugs into proper class/file names.
387 | *
388 | * Example: "two-sum" -> "TwoSum"
389 | *
390 | * @param {string} str - Kebab-case string to convert
391 | * @returns {string} PascalCase converted string
392 | */
393 | kebabToPascalCase(str) {
394 | const words = str.split("-");
395 |
396 | const capitalizedWords = words.map(
397 | (word) => word.charAt(0).toUpperCase() + word.slice(1)
398 | );
399 |
400 | return capitalizedWords.join("");
401 | }
402 | }
403 |
--------------------------------------------------------------------------------
/scripts/services/route-service.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Service class that monitors URL changes on LeetCode pages
3 | * Detects when a user navigates between different problem pages or tabs
4 | */
5 | export default class RouteService {
6 | constructor(onRouteChange) {
7 | this.problemSlug = this.extractProblemSlugFromUrl(location.pathname);
8 | this.onRouteChange = onRouteChange;
9 | this.observeUrlChanges();
10 | }
11 |
12 | observeUrlChanges() {
13 | const observer = new MutationObserver(() => {
14 | if (
15 | this.problemSlug !== this.extractProblemSlugFromUrl(location.pathname)
16 | ) {
17 | observer.disconnect();
18 | this.problemSlug = this.extractProblemSlugFromUrl(location.pathname);
19 |
20 | setTimeout(() => {
21 | this.onRouteChange();
22 | }, 1000);
23 | }
24 | });
25 |
26 | observer.observe(document.body, { subtree: true, childList: true });
27 | }
28 |
29 | extractProblemSlugFromUrl(pathname) {
30 | const match = pathname.match(/\/problems\/([^/]+)/);
31 | return match ? match[1] : null;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/scripts/services/sync-service.js:
--------------------------------------------------------------------------------
1 | import LeetCodeService from "/scripts/services/leetcode-service.js";
2 | import GithubService from "/scripts/services/github-service.js";
3 | import Problem from "/scripts/models/problem.js";
4 | import LanguageUtils from "/scripts/utils/language-utils.js";
5 |
6 | /**
7 | * Service responsible for synchronizing LeetCode solutions with GitHub repository.
8 | * Handles parallel processing, rate limiting, retries, and progress tracking.
9 | */
10 | export default class SyncService {
11 | /**
12 | * Initialize the SyncService with default configuration and stats tracking.
13 | */
14 | constructor() {
15 | this.leetcodeService = new LeetCodeService();
16 |
17 | this.isSyncing = false;
18 | this.stats = {
19 | total: 0,
20 | synced: 0,
21 | failed: 0,
22 | current: 0,
23 | processed: 0,
24 | skipped: 0,
25 | };
26 |
27 | this.pauseDuration = 30000; // 30 seconds pause for rate limiting
28 | this.failedProblems = [];
29 | this.maxRetries = 3;
30 | this.retryCount = 0;
31 |
32 | // GitHub operations queue to prevent concurrent conflicts
33 | this.githubQueue = [];
34 | this.githubProcessing = false;
35 | }
36 |
37 | /**
38 | * Start the synchronization process between LeetCode and GitHub.
39 | * Prevents multiple simultaneous syncs and manages the complete workflow.
40 | *
41 | * @returns {Promise} Result object with success status, message, and stats
42 | */
43 | async startSync() {
44 | const { leetcode_tracker_sync_in_progress } =
45 | await chrome.storage.local.get("leetcode_tracker_sync_in_progress");
46 |
47 | if (leetcode_tracker_sync_in_progress) {
48 | return {
49 | success: false,
50 | message: "Synchronization already in progress",
51 | };
52 | }
53 |
54 | await chrome.storage.local.set({
55 | leetcode_tracker_sync_in_progress: true,
56 | leetcode_tracker_last_sync_status: "in_progress",
57 | leetcode_tracker_last_sync_message: "Synchronization started...",
58 | leetcode_tracker_last_sync_date: new Date().toISOString(),
59 | });
60 |
61 | this.isSyncing = true;
62 |
63 | // Reset all counters and queues for fresh sync
64 | this.stats = {
65 | total: 0,
66 | synced: 0,
67 | failed: 0,
68 | current: 0,
69 | processed: 0,
70 | skipped: 0,
71 | };
72 | this.activePromises = new Map();
73 | this.failedProblems = [];
74 | this.retryCount = 0;
75 | this.githubQueue = [];
76 | this.githubProcessing = false;
77 |
78 | try {
79 | const solvedProblems = await this.leetcodeService.getSolvedProblems();
80 | this.stats.total = solvedProblems.length;
81 |
82 | const maxParallel = 5;
83 | await this.processProblemsQueue(solvedProblems, maxParallel);
84 |
85 | await this.waitForGithubQueueCompletion();
86 |
87 | const allProcessed = this.stats.processed === this.stats.total;
88 | const successMessage = `Synchronization completed. Total: ${this.stats.total}, New files: ${this.stats.synced}, Already existed: ${this.stats.skipped}, Failed: ${this.stats.failed}, Processed: ${this.stats.processed}`;
89 |
90 | try {
91 | await chrome.storage.local.set({
92 | leetcode_tracker_sync_in_progress: false,
93 | leetcode_tracker_last_sync_status: allProcessed
94 | ? "success"
95 | : "partial",
96 | leetcode_tracker_last_sync_date: new Date().toISOString(),
97 | leetcode_tracker_last_sync_message: successMessage,
98 | });
99 | } catch (error) {
100 | console.error(
101 | "Error when updating sync status in local storage: ",
102 | error
103 | );
104 | }
105 |
106 | this.isSyncing = false;
107 |
108 | return {
109 | success: allProcessed,
110 | message: successMessage,
111 | stats: this.stats,
112 | };
113 | } catch (error) {
114 | try {
115 | await chrome.storage.local.set({
116 | leetcode_tracker_sync_in_progress: false,
117 | leetcode_tracker_last_sync_status: "failed",
118 | leetcode_tracker_last_sync_date: new Date().toISOString(),
119 | leetcode_tracker_last_sync_message: error.message,
120 | });
121 | } catch (storageError) {
122 | console.error(
123 | "Error when updating sync status in local storage: ",
124 | storageError
125 | );
126 | }
127 |
128 | this.isSyncing = false;
129 |
130 | return {
131 | success: false,
132 | message: "Error fetching solved problems: " + error.message,
133 | };
134 | }
135 | }
136 |
137 | /**
138 | * Add a GitHub operation to the sequential processing queue.
139 | * This prevents concurrent GitHub API calls that could cause conflicts.
140 | *
141 | * @param {Function} operation - Async function to execute for GitHub operation
142 | * @returns {Promise} Promise that resolves when the operation completes
143 | */
144 | async processGithubOperation(operation) {
145 | return new Promise((resolve, reject) => {
146 | this.githubQueue.push({
147 | operation,
148 | resolve,
149 | reject,
150 | });
151 |
152 | this.processGithubQueue();
153 | });
154 | }
155 |
156 | /**
157 | * Process the GitHub operations queue sequentially.
158 | * Ensures only one GitHub operation runs at a time to prevent conflicts.
159 | */
160 | async processGithubQueue() {
161 | if (this.githubProcessing || this.githubQueue.length === 0) {
162 | return;
163 | }
164 |
165 | this.githubProcessing = true;
166 |
167 | while (this.githubQueue.length > 0) {
168 | const { operation, resolve, reject } = this.githubQueue.shift();
169 |
170 | try {
171 | const result = await operation();
172 | resolve(result);
173 | } catch (error) {
174 | reject(error);
175 | }
176 |
177 | // Small delay between GitHub operations to be respectful to API
178 | await this.sleep(100);
179 | }
180 |
181 | this.githubProcessing = false;
182 | }
183 |
184 | /**
185 | * Wait for all GitHub operations in the queue to complete.
186 | * Used to ensure synchronization doesn't finish before all files are created.
187 | */
188 | async waitForGithubQueueCompletion() {
189 | while (this.githubQueue.length > 0 || this.githubProcessing) {
190 | await this.sleep(100);
191 | }
192 | }
193 |
194 | /**
195 | * Utility function to pause execution for a specified duration.
196 | *
197 | * @param {number} ms - Milliseconds to sleep
198 | * @returns {Promise} Promise that resolves after the specified delay
199 | */
200 | sleep(ms) {
201 | return new Promise((resolve) => setTimeout(resolve, ms));
202 | }
203 |
204 | /**
205 | * Process a queue of problems with controlled parallelism and retry logic.
206 | *
207 | * Algorithm:
208 | * 1. Process up to maxParallel problems simultaneously
209 | * 2. When API rate limit is hit, pause all processing for 60 seconds
210 | * 3. Failed problems are collected for retry attempts
211 | * 4. After processing all problems, retry failed ones up to maxRetries times
212 | * 5. Each retry cycle also includes a pause to respect rate limits
213 | *
214 | * @param {Array} problems - Array of LeetCode problems to process
215 | * @param {number} maxParallel - Maximum number of concurrent problem processors
216 | * @returns {Promise} Promise that resolves when all problems are processed
217 | */
218 | async processProblemsQueue(problems, maxParallel) {
219 | return new Promise((resolve) => {
220 | let nextIndex = 0;
221 | let isPaused = false;
222 | let problemsToProcess = [...problems];
223 | let pauseTimer = null;
224 |
225 | /**
226 | * Resume processing after a pause period.
227 | * Fills available slots with new problem processors.
228 | */
229 | const resumeSync = () => {
230 | isPaused = false;
231 | pauseTimer = null;
232 |
233 | const activeCount = this.activePromises.size;
234 | const slotsToFill = Math.min(
235 | maxParallel - activeCount,
236 | problemsToProcess.length - nextIndex
237 | );
238 |
239 | for (let i = 0; i < slotsToFill; i++) {
240 | startNextProblem();
241 | }
242 | };
243 |
244 | /**
245 | * Pause all processing due to API rate limits.
246 | * Sets a timer to automatically resume after pauseDuration.
247 | */
248 | const pauseAndScheduleResume = () => {
249 | if (!isPaused) {
250 | isPaused = true;
251 |
252 | if (pauseTimer) {
253 | clearTimeout(pauseTimer);
254 | }
255 |
256 | pauseTimer = setTimeout(resumeSync, this.pauseDuration);
257 | }
258 | };
259 |
260 | /**
261 | * Start processing the next problem in the queue.
262 | * Handles completion logic and retry cycles.
263 | */
264 | const startNextProblem = async () => {
265 | if (isPaused) {
266 | return;
267 | }
268 |
269 | if (nextIndex >= problemsToProcess.length) {
270 | if (this.activePromises.size === 0) {
271 | // All problems processed, check for retries
272 | if (
273 | this.failedProblems.length > 0 &&
274 | this.retryCount < this.maxRetries
275 | ) {
276 | this.retryCount++;
277 | problemsToProcess = [...this.failedProblems];
278 | this.failedProblems = [];
279 | nextIndex = 0;
280 |
281 | // Pause before retry to respect rate limits
282 | isPaused = true;
283 | if (pauseTimer) {
284 | clearTimeout(pauseTimer);
285 | }
286 |
287 | pauseTimer = setTimeout(() => {
288 | isPaused = false;
289 | pauseTimer = null;
290 |
291 | for (
292 | let i = 0;
293 | i < Math.min(maxParallel, problemsToProcess.length);
294 | i++
295 | ) {
296 | startNextProblem();
297 | }
298 | }, this.pauseDuration);
299 |
300 | return;
301 | } else if (this.failedProblems.length > 0) {
302 | // Max retries reached, count remaining failures
303 | this.stats.failed += this.failedProblems.length;
304 | this.stats.processed += this.failedProblems.length;
305 | }
306 |
307 | this.isSyncing = false;
308 | resolve();
309 | }
310 | return;
311 | }
312 |
313 | const problem = problemsToProcess[nextIndex];
314 | const currentIndex = nextIndex++;
315 |
316 | const promise = this.processProblem(problem, currentIndex).catch(
317 | (error) => {
318 | if (error.needsPause) {
319 | this.failedProblems.push(problem);
320 | pauseAndScheduleResume();
321 | }
322 | throw error;
323 | }
324 | );
325 |
326 | this.activePromises.set(currentIndex, promise);
327 |
328 | promise
329 | .then(() => {
330 | this.activePromises.delete(currentIndex);
331 |
332 | if (!isPaused) {
333 | startNextProblem();
334 | }
335 | })
336 | .catch((error) => {
337 | this.activePromises.delete(currentIndex);
338 |
339 | if (!isPaused) {
340 | startNextProblem();
341 | }
342 | });
343 | };
344 |
345 | // Start initial batch of problems
346 | for (
347 | let i = 0;
348 | i < Math.min(maxParallel, problemsToProcess.length);
349 | i++
350 | ) {
351 | startNextProblem();
352 | }
353 | });
354 | }
355 |
356 | /**
357 | * Process a single LeetCode problem by fetching submissions and creating GitHub files.
358 | *
359 | * Algorithm:
360 | * 1. Add random delay to avoid request patterns
361 | * 2. Fetch all submissions for the problem by language
362 | * 3. For each language submission, create a Problem object
363 | * 4. Queue GitHub operations to check/create files
364 | * 5. Track statistics based on whether files were created or already existed
365 | *
366 | * @param {Object} problem - LeetCode problem object with metadata
367 | * @param {number} index - Current processing index for progress tracking
368 | * @returns {Promise} True if processing succeeded
369 | */
370 | async processProblem(problem, index) {
371 | this.stats.current = index + 1;
372 |
373 | try {
374 | const titleSlug = problem.stat.question__title_slug;
375 |
376 | // Random delay to avoid predictable request patterns
377 | await this.sleep(Math.random() * 300 + 200);
378 |
379 | const submissionsByLanguage =
380 | await this.leetcodeService.getSubmissionsByLanguage(titleSlug);
381 |
382 | let newFilesCreated = 0;
383 | let totalFilesForProblem = 0;
384 |
385 | for (const lang in submissionsByLanguage) {
386 | const submission = submissionsByLanguage[lang];
387 |
388 | const problemObj = new Problem();
389 | problemObj.slug = `${submission.questionId}-${submission.title}`;
390 | problemObj.difficulty = this.difficultyLevelToString(
391 | problem.difficulty.level
392 | );
393 | problemObj.language = LanguageUtils.getLanguageInfo(submission.lang);
394 | problemObj.code = submission.code;
395 |
396 | totalFilesForProblem++;
397 |
398 | // Use GitHub queue to prevent concurrent file operations
399 | const fileCreated = await this.processGithubOperation(async () => {
400 | // Create new GithubService instance for each operation to avoid conflicts
401 | const githubService = new GithubService();
402 | githubService.problem = problemObj;
403 |
404 | await githubService.init();
405 | const fileExists = await githubService.checkFileExistence(true);
406 |
407 | if (!fileExists) {
408 | await githubService.createFile(true);
409 | return true;
410 | }
411 | return false;
412 | });
413 |
414 | if (fileCreated) {
415 | newFilesCreated++;
416 | }
417 | }
418 |
419 | // Update statistics based on processing results
420 | if (newFilesCreated > 0) {
421 | this.stats.synced++;
422 | } else {
423 | this.stats.skipped++;
424 | }
425 |
426 | this.stats.processed++;
427 |
428 | return true;
429 | } catch (error) {
430 | if (error.needsPause) {
431 | // Don't increment failed/processed for rate limit errors as they'll be retried
432 | throw error;
433 | }
434 |
435 | // For non-rate-limit errors, count as permanent failure
436 | this.stats.failed++;
437 | this.stats.processed++;
438 |
439 | throw error;
440 | }
441 | }
442 |
443 | /**
444 | * Convert numeric difficulty level to human-readable string.
445 | *
446 | * @param {number} level - Difficulty level (1=Easy, 2=Medium, 3=Hard)
447 | * @returns {string} Human-readable difficulty string
448 | */
449 | difficultyLevelToString(level) {
450 | switch (level) {
451 | case 1:
452 | return "Easy";
453 | case 2:
454 | return "Medium";
455 | case 3:
456 | return "Hard";
457 | default:
458 | return "Unknown";
459 | }
460 | }
461 | }
462 |
--------------------------------------------------------------------------------
/scripts/utils/dom-utils.js:
--------------------------------------------------------------------------------
1 | export default class DOMUtils {
2 | /**
3 | * Waits for an element matching the selector to appear in the DOM
4 | * @param {string} selector - CSS selector of the element to wait for
5 | * @returns {Promise} - A promise that resolves with the found element
6 | */
7 | static async waitForElement(selector) {
8 | const existingElement = document.querySelector(selector);
9 | if (existingElement) {
10 | return existingElement;
11 | }
12 |
13 | return new Promise((resolve) => {
14 | const observer = new MutationObserver(() => {
15 | const element = document.querySelector(selector);
16 | if (element) {
17 | observer.disconnect();
18 | resolve(element);
19 | }
20 | });
21 |
22 | observer.observe(document.body, {
23 | childList: true,
24 | subtree: true,
25 | });
26 | });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/scripts/utils/language-utils.js:
--------------------------------------------------------------------------------
1 | import {
2 | baseLanguages,
3 | alternativeNames,
4 | } from "/scripts/constants/languages.js";
5 |
6 | export default class LanguageUtils {
7 | /**
8 | * Retrieves language information based on its key
9 | * @param {string} key - The language key
10 | * @returns {object|null} - Language information or null if not found
11 | */
12 | static getLanguageInfo(key) {
13 | if (!key) {
14 | return null;
15 | }
16 |
17 | const normalizedKey = key.toLowerCase();
18 | const mappedKey = alternativeNames[normalizedKey] || normalizedKey;
19 | return baseLanguages[mappedKey] || null;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------