diff --git a/README.en.md b/README.en.md index 74380d9..42c67e8 100644 --- a/README.en.md +++ b/README.en.md @@ -10,7 +10,7 @@

A Chrome extension that automatically syncs accepted - LeetCode and Programmers submissions to GitHub. + LeetCode, Programmers, and HackerRank submissions to GitHub.

@@ -30,11 +30,12 @@ from: - LeetCode - Programmers +- HackerRank ## Highlights - Sync accepted submissions directly to GitHub -- Use one extension for both LeetCode and Programmers +- Use one extension for LeetCode, Programmers, and HackerRank - Create a new repository or connect an existing one - Customize repository path templates by platform - Keep a clean root README with platform-based summary @@ -42,20 +43,20 @@ from: ## How does AlgorithmHub work? 1. Connect your GitHub account and repository -2. Solve a problem on LeetCode or Programmers +2. Solve a problem on LeetCode, Programmers, or HackerRank 3. Submit an accepted solution 4. Let AlgorithmHub sync the solution files to GitHub automatically -| LeetCode Demo | Programmers Demo | -| --- | --- | -| ![LeetCode demo](./docs/leetcode-demo.gif) | ![Programmers demo](./docs/programmers-demo.gif) | +| LeetCode Demo | Programmers Demo | HackerRank Demo | +| --- | --- | --- | +| ![LeetCode demo](./docs/leetcode-demo.gif) | ![Programmers demo](./docs/programmers-demo.gif) | ![HackerRank demo](./docs/hackerrank-demo.gif) | ## Usage 1. Open the extension popup 2. Authenticate with GitHub 3. Create a repository or connect an existing one -4. Solve a problem on LeetCode or Programmers +4. Solve a problem on LeetCode, Programmers, or HackerRank 5. Submit an accepted solution 6. Let AlgorithmHub sync it to GitHub diff --git a/README.md b/README.md index 496fe59..5b4a74d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- LeetCode프로그래머스의 정답 제출 코드를 + LeetCode, 프로그래머스, HackerRank의 정답 제출 코드를 GitHub으로 자동 동기화하는 Chrome 확장 프로그램입니다.

@@ -28,11 +28,12 @@ GitHub 저장소를 연결해두면 아래 플랫폼의 정답 제출을 자동 - LeetCode - 프로그래머스 +- HackerRank ## 주요 특징 - 정답 제출 코드를 GitHub으로 자동 업로드 -- LeetCode와 프로그래머스를 하나의 확장에서 지원 +- LeetCode, 프로그래머스, HackerRank를 하나의 확장에서 지원 - 새 저장소 생성 또는 기존 저장소 연결 - 플랫폼별 저장 경로 템플릿 커스터마이징 - 플랫폼 기준 요약이 포함된 깔끔한 루트 README 유지 @@ -40,20 +41,20 @@ GitHub 저장소를 연결해두면 아래 플랫폼의 정답 제출을 자동 ## AlgorithmHub는 어떻게 동작하나요? 1. GitHub 계정과 저장소를 연결합니다 -2. LeetCode 또는 프로그래머스에서 문제를 풉니다 +2. LeetCode, 프로그래머스, HackerRank에서 문제를 풉니다 3. 정답 제출을 합니다 4. AlgorithmHub가 풀이 파일을 GitHub으로 자동 동기화합니다 -| LeetCode 데모 | 프로그래머스 데모 | -| --- | --- | -| ![LeetCode demo](./docs/leetcode-demo.gif) | ![Programmers demo](./docs/programmers-demo.gif) | +| LeetCode 데모 | 프로그래머스 데모 | HackerRank 데모 | +| --- | --- | --- | +| ![LeetCode demo](./docs/leetcode-demo.gif) | ![Programmers demo](./docs/programmers-demo.gif) | ![HackerRank demo](./docs/hackerrank-demo.gif) | ## 사용 방법 1. 확장 팝업을 연다 2. GitHub 인증을 진행한다 3. 새 저장소를 만들거나 기존 저장소를 연결한다 -4. LeetCode 또는 프로그래머스에서 문제를 푼다 +4. LeetCode, 프로그래머스, HackerRank에서 문제를 푼다 5. 정답 제출을 한다 6. AlgorithmHub가 결과를 GitHub으로 동기화한다 diff --git a/docs/hackerrank-demo.gif b/docs/hackerrank-demo.gif new file mode 100644 index 0000000..ced8106 Binary files /dev/null and b/docs/hackerrank-demo.gif differ diff --git a/manifest.json b/manifest.json index c83f5ad..d32c9ee 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AlgorithmHub", - "description": "Unified Chrome extension that uploads accepted LeetCode and Programmers solutions to GitHub.", + "description": "Unified Chrome extension that uploads accepted LeetCode, Programmers, and HackerRank solutions to GitHub.", "version": "1.0.0", "icons": { "16": "icons/icon-16.png", @@ -26,6 +26,7 @@ "host_permissions": [ "https://leetcode.com/*", "https://school.programmers.co.kr/*", + "https://www.hackerrank.com/*", "https://github.com/*", "https://api.github.com/*" ], @@ -34,9 +35,16 @@ "matches": [ "https://leetcode.com/*", "https://school.programmers.co.kr/*", + "https://www.hackerrank.com/*", "https://github.com/*" ], "js": ["content.js"] } + ], + "web_accessible_resources": [ + { + "resources": ["hackerrank-page-bridge.js"], + "matches": ["https://www.hackerrank.com/*"] + } ] } diff --git a/public/hackerrank-page-bridge.js b/public/hackerrank-page-bridge.js new file mode 100644 index 0000000..162b4e0 --- /dev/null +++ b/public/hackerrank-page-bridge.js @@ -0,0 +1,154 @@ +(function () { + if (window.__algorithmHubHackerrankBridgeInstalled) { + return; + } + + window.__algorithmHubHackerrankBridgeInstalled = true; + + var EVENT_NAME = "algorithmhub:hackerrank-submission"; + var SUBMISSION_PATH_PATTERN = + /^\/rest\/contests\/([^/]+)\/challenges\/([^/]+)\/submissions(?:\/(\d+))?\/?$/; + + function getRequestUrl(input) { + if (typeof input === "string") { + return input; + } + + if (input && typeof input.url === "string") { + return input.url; + } + + return ""; + } + + function getRequestMethod(input, init) { + var initMethod = init && typeof init.method === "string" ? init.method : ""; + if (initMethod) { + return initMethod.toUpperCase(); + } + + var inputMethod = input && typeof input.method === "string" ? input.method : ""; + return (inputMethod || "GET").toUpperCase(); + } + + function parseSubmissionRequest(url, method) { + try { + var parsedUrl = new URL(url, window.location.href); + var match = parsedUrl.pathname.match(SUBMISSION_PATH_PATTERN); + if (!match) { + return null; + } + + var submissionId = match[3] || ""; + if (method === "POST" && submissionId) { + return null; + } + + if (method === "GET" && !submissionId) { + return null; + } + + if (method !== "POST" && method !== "GET") { + return null; + } + + return { + contestSlug: match[1], + challengeSlug: match[2], + submissionId: submissionId, + }; + } catch (_error) { + return null; + } + } + + function publishSubmission(body, request) { + var model = body && body.model; + if (!model || !model.id) { + return; + } + + window.dispatchEvent( + new CustomEvent(EVENT_NAME, { + detail: JSON.stringify({ + submissionId: String(model.id || request.submissionId), + contestSlug: model.contest_slug || request.contestSlug, + challengeSlug: + model.challenge_slug || model.slug || request.challengeSlug, + submission: model, + }), + }) + ); + } + + function readJsonResponseText(text, request) { + if (!text) { + return; + } + + try { + publishSubmission(JSON.parse(text), request); + } catch (_error) { + // Ignore non-JSON responses. + } + } + + if (typeof window.fetch === "function") { + var originalFetch = window.fetch; + window.fetch = function (input, init) { + var request = parseSubmissionRequest( + getRequestUrl(input), + getRequestMethod(input, init) + ); + + return originalFetch.apply(this, arguments).then(function (response) { + if (request) { + response + .clone() + .json() + .then(function (body) { + publishSubmission(body, request); + }) + .catch(function () { + // The page still receives the original response. + }); + } + + return response; + }); + }; + } + + var originalOpen = XMLHttpRequest.prototype.open; + var originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this.__algorithmHubHackerrankRequest = parseSubmissionRequest( + String(url || ""), + String(method || "GET").toUpperCase() + ); + return originalOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function () { + if (this.__algorithmHubHackerrankRequest) { + this.addEventListener("load", function () { + var request = this.__algorithmHubHackerrankRequest; + if (!request) { + return; + } + + if (this.responseType === "json") { + publishSubmission(this.response, request); + return; + } + + if (!this.responseType || this.responseType === "text") { + readJsonResponseText(this.responseText, request); + } + }); + } + + return originalSend.apply(this, arguments); + }; +})(); diff --git a/src/adapters/hackerrank/index.ts b/src/adapters/hackerrank/index.ts new file mode 100644 index 0000000..e6a8a7e --- /dev/null +++ b/src/adapters/hackerrank/index.ts @@ -0,0 +1,874 @@ +import { addUploadFile, createUploadJob } from "../../core/upload/job"; +import { + buildRepositoryDirectory, + getPlatformRootLabel, + normalizePathSegment, +} from "../../core/path/template"; +import { sendRuntimeMessage } from "../../shared/runtime"; +import type { ExtensionSettings } from "../../core/types/domain"; +import type { RuntimeMessageResponse } from "../../core/types/messages"; +import type { ProblemNoteRequest, UploadJob } from "../../core/types/upload"; +import type { PlatformAdapter } from "../types"; +import { burstConfetti } from "../confetti"; +import { openSyncedActionsModal } from "../problemActionsModal"; +import { + appendProblemNoteThroughBackground, + uploadThroughBackground, +} from "../upload"; + +const HACKERRANK_SUBMISSION_EVENT = "algorithmhub:hackerrank-submission"; +const STATUS_MARKER_ID = "algorithmhub-hackerrank-status-marker"; +const BRIDGE_SCRIPT_ID = "algorithmhub-hackerrank-page-bridge"; +const BACKUP_STATUS_CHECK_DELAY_MS = 90000; + +const languageExtensions: Record = { + c: ".c", + clojure: ".clj", + cpp: ".cpp", + cpp14: ".cpp", + cpp20: ".cpp", + csharp: ".cs", + erlang: ".erl", + go: ".go", + haskell: ".hs", + java: ".java", + java8: ".java", + java15: ".java", + javascript: ".js", + julia: ".jl", + kotlin: ".kt", + lua: ".lua", + objectivec: ".m", + perl: ".pl", + php: ".php", + pypy: ".py", + pypy3: ".py", + python: ".py", + python3: ".py", + r: ".r", + ruby: ".rb", + rust: ".rs", + scala: ".scala", + swift: ".swift", + typescript: ".ts", +}; + +type HackerrankRoute = { + contestSlug: string; + challengeSlug: string; +}; + +type HackerrankSubmissionEvent = HackerrankRoute & { + submissionId: string; + submission?: HackerrankSubmissionModel; +}; + +type HackerrankTrack = { + name?: string | null; + slug?: string | null; + track_name?: string | null; + track_slug?: string | null; +}; + +type HackerrankSubmissionModel = { + id: number; + challenge_id: number; + language: string; + status: string; + language_status?: number | null; + status_code: number; + solved: number; + code: string; + name: string; + slug: string; + challenge_slug?: string | null; + contest_slug?: string | null; + score?: string | null; + display_score?: string | null; + compile_status?: number | null; + compile_message?: string | null; + testcase_status?: number[]; + testcase_message?: string[]; + stderr?: string | null; + codechecker_signal?: number[]; + codechecker_time?: number[]; + track?: HackerrankTrack | null; + created_at?: string | null; + updated_at?: string | null; +}; + +type HackerrankChallengeModel = { + id: number; + slug: string; + name: string; + preview?: string | null; + body_html?: string | null; + problem_statement?: string | null; + difficulty_name?: string | null; + max_score?: number | null; + track?: HackerrankTrack | null; +}; + +type HackerrankApiResponse = { + status: boolean; + model: T; +}; + +type HackerrankProblemData = { + submission: HackerrankSubmissionModel; + challenge: HackerrankChallengeModel; + route: HackerrankRoute; +}; + +type SyncedProblemContext = { + settings: ExtensionSettings; + job: UploadJob; + repositoryUrl: string; +}; + +class HackerrankRequestError extends Error { + readonly status: number; + + constructor(status: number) { + super(`HackerRank request failed: ${status}`); + this.name = "HackerrankRequestError"; + this.status = status; + } +} + +function isHackerrankProblemPage(url: URL) { + return Boolean( + url.hostname.includes("hackerrank.com") && + (url.pathname.match(/^\/challenges\/[^/]+\/problem\/?$/) || + url.pathname.match(/^\/contests\/[^/]+\/challenges\/[^/]+\/problem\/?$/)) + ); +} + +function parseHackerrankRoute(url = new URL(window.location.href)): HackerrankRoute | null { + const contestMatch = url.pathname.match( + /^\/contests\/([^/]+)\/challenges\/([^/]+)\/problem\/?$/ + ); + if (contestMatch?.[1] && contestMatch[2]) { + return { + contestSlug: contestMatch[1], + challengeSlug: contestMatch[2], + }; + } + + const challengeMatch = url.pathname.match(/^\/challenges\/([^/]+)\/problem\/?$/); + if (challengeMatch?.[1]) { + return { + contestSlug: "master", + challengeSlug: challengeMatch[1], + }; + } + + return null; +} + +function formatArchiveStamp(date = new Date()) { + const format = new Intl.DateTimeFormat("sv-SE", { + timeZone: "Asia/Seoul", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + + return format.format(date).replace(" ", "_"); +} + +function createArchiveFileName(extension: string) { + return `${formatArchiveStamp()}${extension}`; +} + +function wait(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function escapePipe(value: string) { + return value.replace(/\|/g, "\\|"); +} + +function getLanguageExtension(language: string) { + const normalized = language.trim().toLowerCase(); + const extension = languageExtensions[normalized]; + if (extension) { + return extension; + } + + const safeLanguage = normalizePathSegment(normalized).replace(/\s+/g, "-"); + return safeLanguage ? `.${safeLanguage}` : ".txt"; +} + +function getTrackRoot(data: HackerrankProblemData) { + return ( + data.challenge.track?.track_name?.trim() || + data.submission.track?.track_name?.trim() || + "Practice" + ); +} + +function getTrackName(data: HackerrankProblemData) { + return ( + data.challenge.track?.name?.trim() || + data.submission.track?.name?.trim() || + "General" + ); +} + +function getDifficulty(data: HackerrankProblemData) { + return data.challenge.difficulty_name?.trim() || "N/A"; +} + +function getProblemLink(data: HackerrankProblemData) { + const { contestSlug, challengeSlug } = data.route; + if (contestSlug && contestSlug !== "master") { + return `https://www.hackerrank.com/contests/${contestSlug}/challenges/${challengeSlug}/problem`; + } + + return `https://www.hackerrank.com/challenges/${challengeSlug}/problem`; +} + +function getProblemBody(data: HackerrankProblemData) { + return ( + data.challenge.problem_statement?.trim() || + data.challenge.body_html?.trim() || + data.challenge.preview?.trim() || + "Problem statement is available on HackerRank." + ); +} + +function formatTestSummary(submission: HackerrankSubmissionModel) { + const statuses = submission.testcase_status ?? []; + if (statuses.length === 0) { + return "N/A"; + } + + const passedCount = statuses.filter((status) => status === 1).length; + return `${passedCount}/${statuses.length} passed`; +} + +function createProblemReadme(data: HackerrankProblemData) { + const { submission, challenge } = data; + const trackRoot = getTrackRoot(data); + const trackName = getTrackName(data); + const difficulty = getDifficulty(data); + const maxScore = + typeof challenge.max_score === "number" ? String(challenge.max_score) : "N/A"; + const displayScore = submission.display_score?.trim() || "N/A"; + const problemLink = getProblemLink(data); + + return `# ${submission.name} + +> ${trackRoot} | ${trackName} | HackerRank + +## Problem Overview + +- Platform: HackerRank +- Domain: ${trackRoot} +- Track: ${trackName} +- Difficulty: ${difficulty} +- Problem ID: ${submission.challenge_id} +- Max Score: ${maxScore} +- Problem Link: [${problemLink}](${problemLink}) + +## Problem + +${getProblemBody(data)} + +## Submission + +| Item | Value | +| --- | --- | +| Status | Accepted | +| Language | ${escapePipe(submission.language)} | +| Score | ${escapePipe(displayScore)} | +| Testcases | ${escapePipe(formatTestSummary(submission))} | +| Submission ID | ${submission.id} | + +--- + +_Synced with AlgorithmHub_`; +} + +function ensureStatusMarker() { + let marker = document.getElementById(STATUS_MARKER_ID); + + if (marker) { + return marker; + } + + marker = document.createElement("span"); + marker.id = STATUS_MARKER_ID; + marker.style.position = "fixed"; + marker.style.right = "24px"; + marker.style.bottom = "24px"; + marker.style.zIndex = "2147483647"; + marker.style.display = "inline-flex"; + marker.style.alignItems = "center"; + marker.style.gap = "6px"; + marker.style.padding = "8px 12px"; + marker.style.borderRadius = "999px"; + marker.style.boxShadow = "0 14px 34px rgba(0, 0, 0, 0.22)"; + marker.style.fontSize = "12px"; + marker.style.fontWeight = "700"; + marker.style.fontFamily = + '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + + document.body.appendChild(marker); + return marker; +} + +function renderStatusContent(marker: HTMLElement, text: string) { + marker.replaceChildren(); + + const icon = document.createElement("span"); + icon.textContent = "✓"; + icon.style.fontWeight = "900"; + icon.style.lineHeight = "1"; + + const label = document.createElement("span"); + label.textContent = text; + + marker.append(icon, label); +} + +function setStatusLink( + marker: HTMLElement, + action?: () => void, + title?: string +) { + marker.onclick = null; + + if (!action) { + marker.style.cursor = "default"; + marker.removeAttribute("title"); + return; + } + + marker.style.cursor = "pointer"; + marker.title = title ?? "Open synced actions"; + marker.onclick = action; +} + +function setInlineStatus( + text: string, + tone: "working" | "success" | "error", + action?: () => void, + actionTitle?: string +) { + const marker = ensureStatusMarker(); + + renderStatusContent(marker, text); + setStatusLink(marker, tone === "success" ? action : undefined, actionTitle); + + if (tone === "working") { + marker.style.background = "#1e293b"; + marker.style.color = "#fde68a"; + return; + } + + if (tone === "success") { + const rect = marker.getBoundingClientRect(); + burstConfetti({ + x: rect.left + rect.width / 2, + y: Math.max(48, rect.top - 28), + }); + marker.style.background = "#052e16"; + marker.style.color = "#bbf7d0"; + return; + } + + marker.style.background = "#450a0a"; + marker.style.color = "#fecaca"; +} + +function clearInlineStatus() { + document.getElementById(STATUS_MARKER_ID)?.remove(); +} + +async function getSettings() { + const response = await sendRuntimeMessage({ + type: "GET_SETTINGS", + }); + + if (!response || response.type !== "SETTINGS_STATE") { + throw new Error("Failed to load extension settings."); + } + + return response.settings; +} + +async function isExtensionEnabled() { + const stored = await chrome.storage.local.get(["extensionEnabled"]); + return stored.extensionEnabled !== false; +} + +async function fetchHackerrankApi(path: string) { + const response = await fetch(`https://www.hackerrank.com${path}`, { + credentials: "include", + headers: { + accept: "application/json", + }, + }); + + if (!response.ok) { + throw new HackerrankRequestError(response.status); + } + + const body = (await response.json()) as HackerrankApiResponse; + if (!body.status || !body.model) { + throw new Error("HackerRank response did not include a model."); + } + + return body.model; +} + +async function getChallengeDetails(route: HackerrankRoute) { + return fetchHackerrankApi( + `/rest/contests/${route.contestSlug}/challenges/${route.challengeSlug}` + ); +} + +async function getChallengeDetailsWithFallback( + route: HackerrankRoute, + submission: HackerrankSubmissionModel +) { + try { + return await getChallengeDetails(route); + } catch (error) { + console.warn("[AlgorithmHub] HackerRank challenge metadata request failed.", error); + return { + id: submission.challenge_id, + slug: submission.challenge_slug?.trim() || submission.slug || route.challengeSlug, + name: submission.name, + preview: null, + body_html: null, + problem_statement: null, + difficulty_name: null, + max_score: null, + track: submission.track ?? null, + }; + } +} + +async function getSubmissionDetails(event: HackerrankSubmissionEvent) { + return fetchHackerrankApi( + `/rest/contests/${event.contestSlug}/challenges/${event.challengeSlug}/submissions/${event.submissionId}` + ); +} + +function isAcceptedSubmission(submission: HackerrankSubmissionModel) { + const normalizedStatus = submission.status?.trim().toLowerCase() ?? ""; + const testcaseStatuses = submission.testcase_status ?? []; + const hasSuccessfulTestcaseResults = testcaseStatuses.length > 0; + const hasFailedTestcase = testcaseStatuses.some((status) => status !== 1); + const testcaseMessages = submission.testcase_message ?? []; + const hasFailedTestcaseMessage = testcaseMessages.some( + (message) => !/success/i.test(message) + ); + const codecheckerSignals = submission.codechecker_signal ?? []; + const hasCodecheckerSignal = codecheckerSignals.some((signal) => signal !== 0); + const score = Number.parseFloat(submission.score ?? ""); + const displayScore = Number.parseFloat(submission.display_score ?? ""); + const hasPassingScore = score > 0 || displayScore > 0; + const hasCompileErrorMessage = /compil|error|failed/i.test( + submission.compile_message ?? "" + ); + const hasStderr = Boolean(submission.stderr?.trim()); + + return ( + normalizedStatus === "accepted" && + submission.solved === 1 && + submission.compile_status === 0 && + submission.language_status === 0 && + hasPassingScore && + hasSuccessfulTestcaseResults && + !hasCompileErrorMessage && + !hasStderr && + !hasFailedTestcase && + !hasFailedTestcaseMessage && + !hasCodecheckerSignal + ); +} + +function isProcessingSubmission(submission: HackerrankSubmissionModel) { + return ( + submission.status_code === 3 || + /processing|queued|running/i.test(submission.status) + ); +} + +function getCurrentRouteForEvent(event: HackerrankSubmissionEvent): HackerrankRoute { + const route = parseHackerrankRoute(); + if (route?.challengeSlug === event.challengeSlug) { + return route; + } + + return { + contestSlug: event.contestSlug || "master", + challengeSlug: event.challengeSlug, + }; +} + +function buildUploadJob(data: HackerrankProblemData, settings: ExtensionSettings) { + const { submission } = data; + const challengeSlug = + submission.challenge_slug?.trim() || submission.slug || data.route.challengeSlug; + const safeTitle = normalizePathSegment(submission.name); + const trackRoot = getTrackRoot(data); + const trackName = getTrackName(data); + const extension = getLanguageExtension(submission.language); + const directory = buildRepositoryDirectory(settings.repositoryTemplate.hackerrank, { + platform: getPlatformRootLabel("hackerrank"), + level: trackRoot, + id: String(submission.challenge_id), + title: safeTitle, + }); + const commitMessage = `[HackerRank][${trackRoot}][${trackName}] ${submission.name} - Score: ${ + submission.display_score?.trim() || "N/A" + } - AlgorithmHub`; + + let job: UploadJob = createUploadJob({ + id: `hackerrank:${submission.id}`, + platform: "hackerrank", + problemId: String(submission.challenge_id), + title: submission.name, + directory, + commitMessage, + metadata: { + submissionId: String(submission.id), + challengeSlug, + trackRoot, + trackName, + language: submission.language, + }, + }); + + job = addUploadFile(job, { + path: `${normalizePathSegment(challengeSlug) || "solution"}${extension}`, + content: submission.code, + }); + + job = addUploadFile(job, { + path: `archives/${createArchiveFileName(extension)}`, + content: submission.code, + }); + + if (settings.platforms.hackerrank.createProblemReadme) { + job = addUploadFile(job, { + path: "README.md", + content: createProblemReadme(data), + }); + } + + return job; +} + +function parseHackerrankSubmissionEvent(event: Event) { + if (!(event instanceof CustomEvent) || typeof event.detail !== "string") { + return null; + } + + try { + const detail = JSON.parse(event.detail) as Partial; + if (!detail.submissionId || !detail.challengeSlug) { + return null; + } + + return { + submissionId: detail.submissionId, + contestSlug: detail.contestSlug || "master", + challengeSlug: detail.challengeSlug, + submission: detail.submission, + }; + } catch { + return null; + } +} + +function getBridgeScriptUrl() { + if (typeof chrome === "undefined" || !chrome.runtime?.getURL) { + return null; + } + + return chrome.runtime.getURL("hackerrank-page-bridge.js"); +} + +function injectHackerrankBridge() { + if (document.getElementById(BRIDGE_SCRIPT_ID)) { + return; + } + + const bridgeScriptUrl = getBridgeScriptUrl(); + if (!bridgeScriptUrl) { + return; + } + + const script = document.createElement("script"); + script.id = BRIDGE_SCRIPT_ID; + script.src = bridgeScriptUrl; + script.async = false; + + (document.head || document.documentElement).appendChild(script); +} + +function createSubmissionController() { + const processingSubmissionIds = new Set(); + const syncingSubmissionIds = new Set(); + const syncedSubmissionIds = new Set(); + const backupStatusCheckTimeouts = new Map(); + let latestSyncContext: SyncedProblemContext | null = null; + + function clearBackupStatusChecks() { + backupStatusCheckTimeouts.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + backupStatusCheckTimeouts.clear(); + } + + function resetPageState() { + processingSubmissionIds.clear(); + syncingSubmissionIds.clear(); + clearBackupStatusChecks(); + latestSyncContext = null; + clearInlineStatus(); + } + + async function syncAcceptedSubmission( + submissionEvent: HackerrankSubmissionEvent, + submission: HackerrankSubmissionModel, + settings: ExtensionSettings + ) { + if (!isAcceptedSubmission(submission)) { + processingSubmissionIds.delete(submissionEvent.submissionId); + clearInlineStatus(); + return; + } + + if ( + syncedSubmissionIds.has(submissionEvent.submissionId) || + syncingSubmissionIds.has(submissionEvent.submissionId) + ) { + return; + } + + syncingSubmissionIds.add(submissionEvent.submissionId); + processingSubmissionIds.delete(submissionEvent.submissionId); + const backupTimeoutId = backupStatusCheckTimeouts.get(submissionEvent.submissionId); + if (backupTimeoutId) { + window.clearTimeout(backupTimeoutId); + backupStatusCheckTimeouts.delete(submissionEvent.submissionId); + } + latestSyncContext = null; + setInlineStatus("Syncing...", "working"); + + try { + if (!(await isExtensionEnabled())) { + clearInlineStatus(); + return; + } + + const route = getCurrentRouteForEvent(submissionEvent); + const challenge = await getChallengeDetailsWithFallback(route, submission); + const job = buildUploadJob({ submission, challenge, route }, settings); + const record = await Promise.all([uploadThroughBackground(job), wait(700)]).then( + ([uploadRecord]) => uploadRecord + ); + + syncedSubmissionIds.add(submissionEvent.submissionId); + latestSyncContext = { + settings, + job, + repositoryUrl: `https://github.com/${record.repository}/tree/${record.branch}/${encodeURI( + job.directory + )}`, + }; + setInlineStatus( + "Synced", + "success", + () => { + const context = latestSyncContext; + if (!context) { + return; + } + + openSyncedActionsModal({ + locale: context.settings.locale, + themeMode: context.settings.themeMode, + title: context.job.title, + onOpenRepository: () => { + window.open(context.repositoryUrl, "_blank", "noopener,noreferrer"); + }, + onSaveNote: async (note: string) => { + const payload: ProblemNoteRequest = { + platform: context.job.platform, + problemId: context.job.problemId, + title: context.job.title, + directory: context.job.directory, + note, + }; + + await appendProblemNoteThroughBackground(payload); + }, + }); + }, + "Open synced actions" + ); + } finally { + syncingSubmissionIds.delete(submissionEvent.submissionId); + } + } + + function scheduleBackupStatusCheck( + submissionEvent: HackerrankSubmissionEvent, + settings: ExtensionSettings + ) { + if ( + backupStatusCheckTimeouts.has(submissionEvent.submissionId) || + syncedSubmissionIds.has(submissionEvent.submissionId) + ) { + return; + } + + const timeoutId = window.setTimeout(() => { + backupStatusCheckTimeouts.delete(submissionEvent.submissionId); + + void (async () => { + if ( + syncedSubmissionIds.has(submissionEvent.submissionId) || + syncingSubmissionIds.has(submissionEvent.submissionId) + ) { + return; + } + + try { + const route = getCurrentRouteForEvent(submissionEvent); + const submission = await getSubmissionDetails({ + ...submissionEvent, + ...route, + }); + + if (isAcceptedSubmission(submission)) { + await syncAcceptedSubmission( + { + ...submissionEvent, + ...route, + }, + submission, + settings + ); + return; + } + + if (!isProcessingSubmission(submission)) { + processingSubmissionIds.delete(submissionEvent.submissionId); + clearInlineStatus(); + } + } catch (error) { + if (error instanceof HackerrankRequestError && error.status === 429) { + console.info( + "[AlgorithmHub] HackerRank backup status check was rate limited. Waiting for HackerRank page status updates." + ); + return; + } + + console.info( + "[AlgorithmHub] HackerRank backup status check could not read a result yet." + ); + } + })(); + }, BACKUP_STATUS_CHECK_DELAY_MS); + + backupStatusCheckTimeouts.set(submissionEvent.submissionId, timeoutId); + } + + return { + handleSubmissionEvent: async (event: Event) => { + const submissionEvent = parseHackerrankSubmissionEvent(event); + if (!submissionEvent || syncedSubmissionIds.has(submissionEvent.submissionId)) { + return; + } + + try { + const settings = await getSettings(); + + if ( + !settings.platforms.hackerrank.enabled || + !settings.platforms.hackerrank.autoUpload + ) { + return; + } + + if (!(await isExtensionEnabled())) { + return; + } + + if ( + submissionEvent.submission && + isAcceptedSubmission(submissionEvent.submission) + ) { + await syncAcceptedSubmission(submissionEvent, submissionEvent.submission, settings); + return; + } + + if (processingSubmissionIds.has(submissionEvent.submissionId)) { + return; + } + + if ( + submissionEvent.submission && + !isProcessingSubmission(submissionEvent.submission) + ) { + clearInlineStatus(); + return; + } + + processingSubmissionIds.add(submissionEvent.submissionId); + latestSyncContext = null; + clearInlineStatus(); + scheduleBackupStatusCheck(submissionEvent, settings); + } catch (error) { + console.error("[AlgorithmHub] HackerRank sync failed.", error); + if (submissionEvent) { + processingSubmissionIds.delete(submissionEvent.submissionId); + } + latestSyncContext = null; + setInlineStatus("Sync failed", "error"); + } + }, + resetPageState, + }; +} + +async function bindAutoUpload() { + injectHackerrankBridge(); + const controller = createSubmissionController(); + window.addEventListener( + HACKERRANK_SUBMISSION_EVENT, + controller.handleSubmissionEvent as EventListener + ); + return controller; +} + +let hackerrankController: Awaited> | null = null; + +export const hackerrankAdapter: PlatformAdapter = { + platform: "hackerrank", + canHandle(url) { + return isHackerrankProblemPage(url); + }, + async boot() { + console.info("[AlgorithmHub] HackerRank adapter booted."); + hackerrankController = await bindAutoUpload(); + }, + onUrlChange(url) { + if (!isHackerrankProblemPage(url)) { + hackerrankController?.resetPageState(); + } + }, +}; diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 7cfb23c..52210c3 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,8 +1,13 @@ import { leetCodeAdapter } from "./leetcode"; import { programmersAdapter } from "./programmers"; +import { hackerrankAdapter } from "./hackerrank"; import type { PlatformAdapter } from "./types"; -const adapters: PlatformAdapter[] = [leetCodeAdapter, programmersAdapter]; +const adapters: PlatformAdapter[] = [ + leetCodeAdapter, + programmersAdapter, + hackerrankAdapter, +]; export function getAdapterForUrl(url: URL): PlatformAdapter | null { return adapters.find((adapter) => adapter.canHandle(url)) ?? null; diff --git a/src/adapters/types.ts b/src/adapters/types.ts index a73b4fb..512b7dd 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -10,6 +10,7 @@ export type PlatformAdapter = { platform: PlatformId; canHandle(url: URL): boolean; boot(context: PlatformAdapterContext): void | Promise; + onUrlChange?(url: URL): void; }; export type SubmissionUploadDelegate = (job: UploadJob) => Promise; diff --git a/src/app/options/Options.tsx b/src/app/options/Options.tsx index 2811c64..03f3bb0 100644 --- a/src/app/options/Options.tsx +++ b/src/app/options/Options.tsx @@ -4,6 +4,8 @@ import { getPlatformRootLabel, TEMPLATE_SEGMENT_LABELS, } from "../../core/path/template"; +import { DEFAULT_SETTINGS } from "../../core/storage/settings"; +import { PLATFORM_DEFINITIONS, PLATFORM_IDS } from "../../core/platforms"; import type { ExtensionSettings, Locale, @@ -18,54 +20,6 @@ import { useResolvedTheme } from "../../shared/theme"; const ISSUE_URL = "https://github.com/dev-minsoo/AlgorithmHub/issues"; const REPOSITORY_URL = "https://github.com/dev-minsoo/AlgorithmHub"; -const emptySettings: ExtensionSettings = { - locale: "en", - themeMode: "system", - github: { - oauthClientId: "", - token: "", - username: "", - repository: "", - branch: "", - }, - platforms: { - leetcode: { - enabled: true, - autoUpload: true, - createProblemReadme: true, - attachNotes: false, - }, - programmers: { - enabled: true, - autoUpload: true, - createProblemReadme: true, - attachNotes: false, - }, - }, - repositoryTemplate: { - leetcode: { - order: ["platform", "level", "id", "title"], - enabled: { - platform: true, - level: true, - id: true, - title: true, - }, - combineIdTitle: true, - }, - programmers: { - order: ["platform", "level", "id", "title"], - enabled: { - platform: true, - level: true, - id: true, - title: true, - }, - combineIdTitle: true, - }, - }, -}; - const TEMPLATE_SEGMENTS: RepositoryTemplateSegment[] = [ "platform", "level", @@ -92,9 +46,8 @@ const OPTIONS_COPY = { templatesEyebrow: "Repository path templates", templatesTitle: "Configure each platform separately", templatesDescription: - "LeetCode and Programmers can use different path templates. When ID is placed directly before Title, AlgorithmHub can combine them into the default format: ID. Title.", - templateLeetCode: "LeetCode template", - templateProgrammers: "Programmers template", + "Each platform can use a different path template. When ID is placed directly before Title, AlgorithmHub can combine them into the default format: ID. Title.", + templateLabel: "template", templateHint: "Drag enabled segments to reorder them. Use the toggle to include or exclude each segment without changing its row position.", combine: "Combine ID + Title", @@ -120,9 +73,8 @@ const OPTIONS_COPY = { templatesEyebrow: "저장 경로 템플릿", templatesTitle: "플랫폼별로 따로 설정하세요", templatesDescription: - "LeetCode와 프로그래머스는 서로 다른 경로 템플릿을 사용할 수 있습니다. ID가 Title 바로 앞에 오면 기본 형식인 ID. Title로 합칠 수 있습니다.", - templateLeetCode: "LeetCode 템플릿", - templateProgrammers: "프로그래머스 템플릿", + "각 플랫폼은 서로 다른 경로 템플릿을 사용할 수 있습니다. ID가 Title 바로 앞에 오면 기본 형식인 ID. Title로 합칠 수 있습니다.", + templateLabel: "템플릿", templateHint: "활성화된 세그먼트를 드래그해서 순서를 바꾸고, 토글로 포함 여부를 제어하세요.", combine: "ID + Title 합치기", @@ -167,13 +119,14 @@ function PathTemplateCard({ resolvedTheme: "light" | "dark"; }) { const template = settings.repositoryTemplate[platform]; + const definition = PLATFORM_DEFINITIONS[platform]; const previewPath = buildRepositoryDirectory(template, { platform: getPlatformRootLabel(platform), - level: platform === "leetcode" ? "Easy" : "Lv. 2", - id: platform === "leetcode" ? "0001" : "12909", - title: platform === "leetcode" ? "Two Sum" : "올바른 괄호", + level: definition.pathPreview.level, + id: definition.pathPreview.id, + title: definition.pathPreview.title, }); - const fileName = platform === "leetcode" ? "solution.py" : "solution.js"; + const fileName = definition.pathPreview.fileName; return (

- {platform === "leetcode" ? copy.templateLeetCode : copy.templateProgrammers} + {definition.displayName} {copy.templateLabel}

(emptySettings); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [draggedSegment, setDraggedSegment] = useState(null); const [extensionEnabled, setExtensionEnabled] = useState(true); @@ -594,30 +547,25 @@ export default function Options() { > {copy.solving}

- - LeetCode - - - 프로그래머스 - + {PLATFORM_IDS.map((platform) => { + const definition = PLATFORM_DEFINITIONS[platform]; + + return ( + + {definition.displayName} + + ); + })}
@@ -736,29 +684,21 @@ export default function Options() { {templatesOpen ? ( -
- - +
+ {PLATFORM_IDS.map((platform) => ( + + ))}
) : null}
diff --git a/src/app/welcome/Welcome.tsx b/src/app/welcome/Welcome.tsx index fbd85be..165fce9 100644 --- a/src/app/welcome/Welcome.tsx +++ b/src/app/welcome/Welcome.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { consumeWelcomeMode } from "../../core/storage/auth"; import { getCachedGitHubRepositories } from "../../core/storage/repositories"; +import { DEFAULT_SETTINGS } from "../../core/storage/settings"; import type { ExtensionSettings, RepositoryInfo } from "../../core/types/domain"; import type { RuntimeMessageResponse } from "../../core/types/messages"; import { BrandWordmark } from "../../shared/components/BrandWordmark"; @@ -11,71 +12,23 @@ type RepoMode = "" | "new" | "link"; const WELCOME_COPY = { en: { subtitle: - "Automatically sync your accepted LeetCode and Programmers solutions to GitHub.", + "Automatically sync your accepted LeetCode, Programmers, and HackerRank solutions to GitHub.", connectTitle: "Connect a repository to get started", }, ko: { subtitle: - "LeetCode와 프로그래머스 정답 제출을 GitHub에 자동으로 동기화하세요.", + "LeetCode, 프로그래머스, HackerRank 정답 제출을 GitHub에 자동으로 동기화하세요.", connectTitle: "시작하려면 저장소를 연결하세요", }, } as const; -const emptySettings: ExtensionSettings = { - locale: "en", - themeMode: "system", - github: { - oauthClientId: "", - token: "", - username: "", - repository: "", - branch: "", - }, - platforms: { - leetcode: { - enabled: true, - autoUpload: true, - createProblemReadme: true, - attachNotes: false, - }, - programmers: { - enabled: true, - autoUpload: true, - createProblemReadme: true, - attachNotes: false, - }, - }, - repositoryTemplate: { - leetcode: { - order: ["platform", "level", "id", "title"], - enabled: { - platform: true, - level: true, - id: true, - title: true, - }, - combineIdTitle: true, - }, - programmers: { - order: ["platform", "level", "id", "title"], - enabled: { - platform: true, - level: true, - id: true, - title: true, - }, - combineIdTitle: true, - }, - }, -}; - function openOptions() { const url = chrome.runtime.getURL("options.html"); void chrome.tabs.create({ url }); } export default function Welcome() { - const [settings, setSettings] = useState(emptySettings); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [mode, setMode] = useState(""); const [repositoryName, setRepositoryName] = useState(""); const [availableRepositories, setAvailableRepositories] = useState([]); diff --git a/src/core/github/client.ts b/src/core/github/client.ts index 3a7b5c1..1b2e077 100644 --- a/src/core/github/client.ts +++ b/src/core/github/client.ts @@ -103,7 +103,7 @@ export function createGitHubClient(token: string) { private: isPrivate, auto_init: false, description: - "Archive of accepted LeetCode and Programmers solutions, synced by AlgorithmHub.", + "Archive of accepted coding challenge solutions, synced by AlgorithmHub.", }), }); diff --git a/src/core/path/template.ts b/src/core/path/template.ts index 4239d1f..b553089 100644 --- a/src/core/path/template.ts +++ b/src/core/path/template.ts @@ -2,6 +2,7 @@ import type { PlatformId, RepositoryTemplateSegment, } from "../types/domain"; +import { getPlatformDefinition } from "../platforms"; export type RepositoryPathParts = { platform: string; @@ -22,7 +23,7 @@ export function normalizePathSegment(value: string) { } export function getPlatformRootLabel(platform: PlatformId) { - return platform === "leetcode" ? "Leetcode" : "프로그래머스"; + return getPlatformDefinition(platform).rootLabel; } export function buildRepositoryDirectory( diff --git a/src/core/platforms.ts b/src/core/platforms.ts new file mode 100644 index 0000000..c024d86 --- /dev/null +++ b/src/core/platforms.ts @@ -0,0 +1,63 @@ +import type { PlatformId } from "./types/domain"; + +export type PlatformDefinition = { + id: PlatformId; + displayName: string; + rootLabel: string; + solveUrl: string; + pathPreview: { + level: string; + id: string; + title: string; + fileName: string; + }; +}; + +export const PLATFORM_IDS: PlatformId[] = [ + "leetcode", + "programmers", + "hackerrank", +]; + +export const PLATFORM_DEFINITIONS: Record = { + leetcode: { + id: "leetcode", + displayName: "LeetCode", + rootLabel: "Leetcode", + solveUrl: "https://leetcode.com/", + pathPreview: { + level: "Easy", + id: "0001", + title: "Two Sum", + fileName: "solution.py", + }, + }, + programmers: { + id: "programmers", + displayName: "프로그래머스", + rootLabel: "프로그래머스", + solveUrl: "https://school.programmers.co.kr/learn/challenges", + pathPreview: { + level: "Lv. 2", + id: "12909", + title: "올바른 괄호", + fileName: "solution.js", + }, + }, + hackerrank: { + id: "hackerrank", + displayName: "HackerRank", + rootLabel: "HackerRank", + solveUrl: "https://www.hackerrank.com/domains", + pathPreview: { + level: "Data Structures", + id: "13579", + title: "Arrays - DS", + fileName: "arrays-ds.kt", + }, + }, +}; + +export function getPlatformDefinition(platform: PlatformId) { + return PLATFORM_DEFINITIONS[platform]; +} diff --git a/src/core/storage/settings.ts b/src/core/storage/settings.ts index f82a4f3..2517e82 100644 --- a/src/core/storage/settings.ts +++ b/src/core/storage/settings.ts @@ -1,8 +1,11 @@ import type { DeepPartial, ExtensionSettings, + PlatformId, + PlatformSettings, RepositoryTemplateSegment, } from "../types/domain"; +import { PLATFORM_IDS } from "../platforms"; export const DEFAULT_GITHUB_OAUTH_CLIENT_ID = "Ov23liLxRpRqCrpLKjYy"; @@ -24,6 +27,15 @@ function createDefaultTemplateConfig() { }; } +function createDefaultPlatformSettings(): PlatformSettings { + return { + enabled: true, + autoUpload: true, + createProblemReadme: true, + attachNotes: false, + }; +} + export const DEFAULT_SETTINGS: ExtensionSettings = { locale: "en", themeMode: "system", @@ -35,25 +47,66 @@ export const DEFAULT_SETTINGS: ExtensionSettings = { branch: "", }, platforms: { - leetcode: { - enabled: true, - autoUpload: true, - createProblemReadme: true, - attachNotes: false, - }, - programmers: { - enabled: true, - autoUpload: true, - createProblemReadme: true, - attachNotes: false, - }, + leetcode: createDefaultPlatformSettings(), + programmers: createDefaultPlatformSettings(), + hackerrank: createDefaultPlatformSettings(), }, repositoryTemplate: { leetcode: createDefaultTemplateConfig(), programmers: createDefaultTemplateConfig(), + hackerrank: createDefaultTemplateConfig(), }, }; +function mergePlatformSettings( + current: ExtensionSettings["platforms"], + patch: DeepPartial["platforms"] +): ExtensionSettings["platforms"] { + return PLATFORM_IDS.reduce((platforms, platform) => { + platforms[platform] = { + ...current[platform], + ...(patch?.[platform] as Partial | undefined), + }; + return platforms; + }, {} as Record); +} + +function sanitizeTemplateOrder(order: unknown) { + if (!Array.isArray(order)) { + return null; + } + + return order.filter( + (segment): segment is RepositoryTemplateSegment => + segment === "platform" || + segment === "level" || + segment === "id" || + segment === "title" + ); +} + +function mergeRepositoryTemplate( + current: ExtensionSettings["repositoryTemplate"], + patch: DeepPartial["repositoryTemplate"] +): ExtensionSettings["repositoryTemplate"] { + return PLATFORM_IDS.reduce((repositoryTemplate, platform) => { + const patchTemplate = patch?.[platform]; + + repositoryTemplate[platform] = { + ...current[platform], + ...patchTemplate, + order: sanitizeTemplateOrder(patchTemplate?.order) ?? current[platform].order, + enabled: { + ...current[platform].enabled, + ...patchTemplate?.enabled, + }, + combineIdTitle: + patchTemplate?.combineIdTitle ?? current[platform].combineIdTitle, + }; + return repositoryTemplate; + }, {} as ExtensionSettings["repositoryTemplate"]); +} + function mergeSettings( current: ExtensionSettings, patch: DeepPartial @@ -65,50 +118,11 @@ function mergeSettings( ...current.github, ...patch.github, }, - platforms: { - leetcode: { - ...current.platforms.leetcode, - ...patch.platforms?.leetcode, - }, - programmers: { - ...current.platforms.programmers, - ...patch.platforms?.programmers, - }, - }, - repositoryTemplate: { - leetcode: { - ...current.repositoryTemplate.leetcode, - ...patch.repositoryTemplate?.leetcode, - order: - patch.repositoryTemplate?.leetcode?.order?.filter( - (segment): segment is "platform" | "level" | "id" | "title" => - Boolean(segment) - ) ?? current.repositoryTemplate.leetcode.order, - enabled: { - ...current.repositoryTemplate.leetcode.enabled, - ...patch.repositoryTemplate?.leetcode?.enabled, - }, - combineIdTitle: - patch.repositoryTemplate?.leetcode?.combineIdTitle ?? - current.repositoryTemplate.leetcode.combineIdTitle, - }, - programmers: { - ...current.repositoryTemplate.programmers, - ...patch.repositoryTemplate?.programmers, - order: - patch.repositoryTemplate?.programmers?.order?.filter( - (segment): segment is "platform" | "level" | "id" | "title" => - Boolean(segment) - ) ?? current.repositoryTemplate.programmers.order, - enabled: { - ...current.repositoryTemplate.programmers.enabled, - ...patch.repositoryTemplate?.programmers?.enabled, - }, - combineIdTitle: - patch.repositoryTemplate?.programmers?.combineIdTitle ?? - current.repositoryTemplate.programmers.combineIdTitle, - }, - }, + platforms: mergePlatformSettings(current.platforms, patch.platforms), + repositoryTemplate: mergeRepositoryTemplate( + current.repositoryTemplate, + patch.repositoryTemplate + ), }; } diff --git a/src/core/storage/summary.ts b/src/core/storage/summary.ts index ddfe4d6..31f422f 100644 --- a/src/core/storage/summary.ts +++ b/src/core/storage/summary.ts @@ -1,4 +1,5 @@ import type { PlatformId } from "../types/domain"; +import { PLATFORM_IDS } from "../platforms"; const SOLVED_SUMMARY_KEY = "solvedSummary"; @@ -8,10 +9,10 @@ export async function getSolvedSummary(): Promise { const stored = await chrome.storage.local.get(SOLVED_SUMMARY_KEY); const summary = stored[SOLVED_SUMMARY_KEY] as SolvedSummary | undefined; - return { - leetcode: summary?.leetcode ?? [], - programmers: summary?.programmers ?? [], - }; + return PLATFORM_IDS.reduce((nextSummary, platform) => { + nextSummary[platform] = summary?.[platform] ?? []; + return nextSummary; + }, {} as SolvedSummary); } export async function saveSolvedSummary(summary: SolvedSummary): Promise { diff --git a/src/core/types/domain.ts b/src/core/types/domain.ts index a39d772..878d0ae 100644 --- a/src/core/types/domain.ts +++ b/src/core/types/domain.ts @@ -1,4 +1,4 @@ -export type PlatformId = "leetcode" | "programmers"; +export type PlatformId = "leetcode" | "programmers" | "hackerrank"; export type RepositoryTemplateSegment = "platform" | "level" | "id" | "title"; export type Locale = "ko" | "en"; export type ThemeMode = "system" | "light" | "dark"; diff --git a/src/scripts/background/index.ts b/src/scripts/background/index.ts index 9e648ba..64d34dc 100644 --- a/src/scripts/background/index.ts +++ b/src/scripts/background/index.ts @@ -17,6 +17,7 @@ import { exchangeGitHubOAuthCode, } from "../../core/github/client"; import { executeUploadJob } from "../../core/upload/execute"; +import { PLATFORM_DEFINITIONS, PLATFORM_IDS } from "../../core/platforms"; import type { RuntimeMessage, RuntimeMessageResponse } from "../../core/types/messages"; import type { ProblemNoteRequest, UploadJob } from "../../core/types/upload"; @@ -25,23 +26,43 @@ const GITHUB_OAUTH_REDIRECT_URI = "https://github.com/"; const GITHUB_OAUTH_CLIENT_SECRET = "a223258ea8e316e47ee81988e85cada899cfb2d4"; const INITIAL_REPOSITORY_COMMIT_MESSAGE = "Initial commit - AlgorithmHub"; +function createPlatformSummaryRows(summary?: SolvedSummary) { + return PLATFORM_IDS.map((platform) => { + const definition = PLATFORM_DEFINITIONS[platform]; + const count = summary ? new Set(summary[platform]).size : 0; + return `| ${definition.displayName} | ${count} |`; + }).join("\n"); +} + +function createPlatformLinks() { + return PLATFORM_IDS.map((platform) => { + const definition = PLATFORM_DEFINITIONS[platform]; + return `- [${definition.displayName}](${encodeURI(`./${definition.rootLabel}`)})`; + }).join("\n"); +} + +function countSolvedProblems(summary: SolvedSummary) { + return PLATFORM_IDS.reduce( + (total, platform) => total + new Set(summary[platform]).size, + 0 + ); +} + function createRepositoryReadme(name: string) { return `# ${name} -Archive of accepted LeetCode and Programmers solutions, synced by [AlgorithmHub](https://github.com/dev-minsoo/AlgorithmHub). +Archive of accepted coding challenge solutions, synced by [AlgorithmHub](https://github.com/dev-minsoo/AlgorithmHub). ## Summary | Platform | Solved | | --- | ---: | -| LeetCode | 0 | -| 프로그래머스 | 0 | +${createPlatformSummaryRows()} | Total | 0 | ## Platforms -- [LeetCode](./Leetcode) -- [프로그래머스](./%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4) +${createPlatformLinks()} `; } @@ -49,26 +70,22 @@ function createRootSummaryReadme( repositoryName: string, summary: SolvedSummary ) { - const leetcodeCount = new Set(summary.leetcode).size; - const programmersCount = new Set(summary.programmers).size; - const totalCount = leetcodeCount + programmersCount; + const totalCount = countSolvedProblems(summary); return `# ${repositoryName} -Archive of accepted LeetCode and Programmers solutions, synced by [AlgorithmHub](https://github.com/dev-minsoo/AlgorithmHub). +Archive of accepted coding challenge solutions, synced by [AlgorithmHub](https://github.com/dev-minsoo/AlgorithmHub). ## Summary | Platform | Solved | | --- | ---: | -| LeetCode | ${leetcodeCount} | -| 프로그래머스 | ${programmersCount} | +${createPlatformSummaryRows(summary)} | Total | ${totalCount} | ## Platforms -- [LeetCode](./Leetcode) -- [프로그래머스](./%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4) +${createPlatformLinks()} `; } @@ -168,7 +185,7 @@ async function ensureRepositoryInitialized( ); await github.updateRepository(repositoryFullName, { description: - "Archive of accepted LeetCode and Programmers solutions, synced by AlgorithmHub.", + "Archive of accepted coding challenge solutions, synced by AlgorithmHub.", }); } diff --git a/src/scripts/content/index.ts b/src/scripts/content/index.ts index 3357345..1efa292 100644 --- a/src/scripts/content/index.ts +++ b/src/scripts/content/index.ts @@ -1,10 +1,60 @@ import { getAdapterForUrl } from "../../adapters"; +import type { PlatformAdapter } from "../../adapters/types"; import { handleGitHubOAuthCallback } from "./githubAuth"; void handleGitHubOAuthCallback(); -const adapter = getAdapterForUrl(new URL(window.location.href)); +const bootedAdapters = new Map(); +let lastUrl = window.location.href; -if (adapter) { - void adapter.boot({ platform: adapter.platform }); +function bootAdapterForCurrentUrl() { + const url = new URL(window.location.href); + const adapter = getAdapterForUrl(url); + + if (!adapter) { + return; + } + + if (!bootedAdapters.has(adapter.platform)) { + bootedAdapters.set(adapter.platform, adapter); + void adapter.boot({ platform: adapter.platform }); + } +} + +function notifyBootedAdapters(url: URL) { + bootedAdapters.forEach((adapter) => { + adapter.onUrlChange?.(url); + }); } + +function notifyUrlChange() { + const currentUrl = window.location.href; + if (currentUrl === lastUrl) { + return; + } + + lastUrl = currentUrl; + bootAdapterForCurrentUrl(); + notifyBootedAdapters(new URL(currentUrl)); +} + +function patchHistoryMethod(method: "pushState" | "replaceState") { + const originalMethod = window.history[method]; + window.history[method] = function patchedHistoryMethod(...args) { + const result = originalMethod.apply(this, args); + window.setTimeout(notifyUrlChange, 0); + return result; + }; +} + +bootAdapterForCurrentUrl(); +notifyBootedAdapters(new URL(window.location.href)); +patchHistoryMethod("pushState"); +patchHistoryMethod("replaceState"); +window.addEventListener("popstate", notifyUrlChange); +window.addEventListener("hashchange", notifyUrlChange); + +new MutationObserver(notifyUrlChange).observe(document.documentElement, { + childList: true, + subtree: true, +});