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 | Programmers Demo | HackerRank Demo |
+| --- | --- | --- |
+|  |  |  |
## 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 데모 | 프로그래머스 데모 | HackerRank 데모 |
+| --- | --- | --- |
+|  |  |  |
## 사용 방법
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,
+});