diff --git a/src/adapters/leetcode/index.ts b/src/adapters/leetcode/index.ts
index e418764..f879039 100644
--- a/src/adapters/leetcode/index.ts
+++ b/src/adapters/leetcode/index.ts
@@ -7,10 +7,14 @@ import {
import { sendRuntimeMessage } from "../../shared/runtime";
import type { RuntimeMessageResponse } from "../../core/types/messages";
import type { ExtensionSettings } from "../../core/types/domain";
-import type { UploadJob } from "../../core/types/upload";
+import type { ProblemNoteRequest, UploadJob } from "../../core/types/upload";
import type { PlatformAdapter } from "../types";
import { burstConfetti } from "../confetti";
-import { uploadThroughBackground } from "../upload";
+import { openSyncedActionsModal } from "../problemActionsModal";
+import {
+ appendProblemNoteThroughBackground,
+ uploadThroughBackground,
+} from "../upload";
const SUBMIT_BUTTON_SELECTOR = '[data-e2e-locator="console-submit-button"]';
const SUBMISSION_RESULT_SELECTOR = '[data-e2e-locator="submission-result"]';
@@ -68,6 +72,12 @@ type GraphQLResponse = {
errors?: Array<{ message: string }>;
};
+type SyncedProblemContext = {
+ settings: ExtensionSettings;
+ job: UploadJob;
+ repositoryUrl: string;
+};
+
function isProblemPage(url: URL) {
return url.hostname.includes("leetcode.com") && url.pathname.includes("/problems/");
}
@@ -119,6 +129,28 @@ function escapePipe(value: string) {
return value.replace(/\|/g, "\\|");
}
+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(" ", "_")
+ .replaceAll(":", "-");
+}
+
+function createArchiveFileName(extension: string, uniqueSuffix: string) {
+ return `${formatArchiveStamp()}_${uniqueSuffix}${extension}`;
+}
+
function createProblemReadme(
details: LeetCodeSubmissionDetails,
submissionId: string
@@ -208,26 +240,29 @@ function renderStatusContent(marker: HTMLElement, text: string) {
marker.append(icon, label);
}
-function setStatusLink(marker: HTMLElement, url?: string) {
+function setStatusLink(
+ marker: HTMLElement,
+ action?: () => void,
+ title?: string
+) {
marker.onclick = null;
- if (!url) {
+ if (!action) {
marker.style.cursor = "default";
marker.removeAttribute("title");
return;
}
marker.style.cursor = "pointer";
- marker.title = "Open synced commit";
- marker.onclick = () => {
- window.open(url, "_blank", "noopener,noreferrer");
- };
+ marker.title = title ?? "Open synced actions";
+ marker.onclick = action;
}
function setInlineStatus(
text: string,
tone: "working" | "success" | "error",
- url?: string
+ action?: () => void,
+ actionTitle?: string
) {
const marker = ensureStatusMarker();
if (!marker) {
@@ -235,7 +270,7 @@ function setInlineStatus(
}
renderStatusContent(marker, text);
- setStatusLink(marker, tone === "success" ? url : undefined);
+ setStatusLink(marker, tone === "success" ? action : undefined, actionTitle);
if (tone === "working") {
marker.style.background = "#1f2937";
@@ -434,6 +469,11 @@ function buildUploadJob(
content: details.code,
});
+ job = addUploadFile(job, {
+ path: `archives/${createArchiveFileName(extension, submissionId)}`,
+ content: details.code,
+ });
+
if (settings.platforms.leetcode.createProblemReadme) {
job = addUploadFile(job, {
path: "README.md",
@@ -454,42 +494,44 @@ function buildUploadJob(
function createSubmissionHandler() {
const handledSubmissionIds = new Set();
let lastTriggerAt = 0;
+ let latestSyncContext: SyncedProblemContext | null = null;
return async function handleSubmissionTrigger() {
- const settings = await getSettings();
+ try {
+ const settings = await getSettings();
- if (!settings.platforms.leetcode.enabled || !settings.platforms.leetcode.autoUpload) {
- return;
- }
+ if (!settings.platforms.leetcode.enabled || !settings.platforms.leetcode.autoUpload) {
+ return;
+ }
- if (!(await isExtensionEnabled())) {
- return;
- }
+ if (!(await isExtensionEnabled())) {
+ return;
+ }
- const now = Date.now();
- if (now - lastTriggerAt < 1500) {
- return;
- }
- lastTriggerAt = now;
+ const now = Date.now();
+ if (now - lastTriggerAt < 1500) {
+ return;
+ }
+ lastTriggerAt = now;
- const submissionId = await waitForSubmissionId();
- if (!submissionId || handledSubmissionIds.has(submissionId)) {
- return;
- }
+ const submissionId = await waitForSubmissionId();
+ if (!submissionId || handledSubmissionIds.has(submissionId)) {
+ return;
+ }
- const accepted = await waitForAcceptedState();
- if (!accepted) {
- clearInlineStatus();
- return;
- }
+ const accepted = await waitForAcceptedState();
+ if (!accepted) {
+ clearInlineStatus();
+ return;
+ }
- handledSubmissionIds.add(submissionId);
- try {
+ handledSubmissionIds.add(submissionId);
if (!(await isExtensionEnabled())) {
clearInlineStatus();
return;
}
+ latestSyncContext = null;
setInlineStatus("Syncing...", "working");
const details = await getSubmissionDetails(submissionId);
@@ -501,14 +543,46 @@ function createSubmissionHandler() {
const record = await Promise.all([uploadThroughBackground(job), wait(700)]).then(
([uploadRecord]) => uploadRecord
);
+ latestSyncContext = {
+ settings,
+ job,
+ repositoryUrl: `https://github.com/${record.repository}/tree/${record.branch}/${encodeURI(
+ job.directory
+ )}`,
+ };
setInlineStatus(
"Synced",
"success",
- `https://github.com/${record.repository}/tree/${record.branch}/${encodeURI(
- record.filePaths[0]?.split("/").slice(0, -1).join("/") ?? ""
- )}`
+ () => {
+ 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"
);
} catch {
+ latestSyncContext = null;
setInlineStatus("Sync failed", "error");
}
};
diff --git a/src/adapters/problemActionsModal.ts b/src/adapters/problemActionsModal.ts
new file mode 100644
index 0000000..d59ef67
--- /dev/null
+++ b/src/adapters/problemActionsModal.ts
@@ -0,0 +1,447 @@
+import type { Locale, ThemeMode } from "../core/types/domain";
+
+type SyncedActionsModalOptions = {
+ locale: Locale;
+ themeMode: ThemeMode;
+ title: string;
+ onOpenRepository: () => void;
+ onSaveNote: (note: string) => Promise;
+};
+
+const MODAL_COPY = {
+ en: {
+ description: "Choose what to do with this solved problem.",
+ openRepository: "Open repository",
+ addNote: "Add note",
+ noteTitle: "Add note",
+ noteDescription: "Each non-empty line will be appended to NOTE.md as a bullet list.",
+ notePlaceholder: "Enter your note",
+ back: "Back",
+ save: "Save note",
+ saving: "Saving...",
+ emptyNote: "Write at least one line.",
+ close: "Close",
+ failed: "Failed to save the note.",
+ },
+ ko: {
+ description: "이 문제에 대해 이어서 할 작업을 선택하세요.",
+ openRepository: "저장소에서 보기",
+ addNote: "노트 추가",
+ noteTitle: "노트 추가",
+ noteDescription: "입력한 각 줄은 NOTE.md에 불릿 포인트로 누적 저장됩니다.",
+ notePlaceholder: "노트를 입력하세요",
+ back: "뒤로",
+ save: "노트 저장",
+ saving: "저장 중...",
+ emptyNote: "한 줄 이상 입력해 주세요.",
+ close: "닫기",
+ failed: "노트 저장에 실패했습니다.",
+ },
+} as const;
+
+type EditorMessage =
+ | { type: "ready" }
+ | { type: "value"; requestId: string; value: string };
+
+function resolveLocale(locale: Locale): Locale {
+ if (locale === "ko" || locale === "en") {
+ return locale;
+ }
+
+ const language =
+ document.documentElement.lang || window.navigator.language || "";
+ return language.toLowerCase().startsWith("ko") ? "ko" : "en";
+}
+
+function resolveTheme(themeMode: ThemeMode) {
+ if (themeMode === "light") {
+ return "light";
+ }
+
+ if (themeMode === "dark") {
+ return "dark";
+ }
+
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+}
+
+function createEditorDocument({
+ isDark,
+ placeholder,
+}: {
+ isDark: boolean;
+ placeholder: string;
+}) {
+ const panelText = isDark ? "#e2e8f0" : "#0f172a";
+ const border = isDark ? "#334155" : "#cbd5e1";
+ const inputBackground = isDark ? "#020617" : "#ffffff";
+ const placeholderColor = isDark ? "#64748b" : "#94a3b8";
+ const focusRing = isDark
+ ? "rgba(37, 99, 235, 0.28)"
+ : "rgba(37, 99, 235, 0.18)";
+
+ return `
+
+
+
+
+
+
+
+
+
+`;
+}
+
+export function openSyncedActionsModal(options: SyncedActionsModalOptions) {
+ const locale = resolveLocale(options.locale);
+ const copy = MODAL_COPY[locale];
+ const theme = resolveTheme(options.themeMode);
+ const isDark = theme === "dark";
+
+ const overlay = document.createElement("div");
+ overlay.style.position = "fixed";
+ overlay.style.inset = "0";
+ overlay.style.zIndex = "2147483647";
+ overlay.style.display = "flex";
+ overlay.style.alignItems = "center";
+ overlay.style.justifyContent = "center";
+ overlay.style.padding = "24px";
+ overlay.style.background = isDark ? "rgba(15, 23, 42, 0.58)" : "rgba(15, 23, 42, 0.28)";
+
+ const panel = document.createElement("div");
+ panel.style.width = "min(100%, 460px)";
+ panel.style.borderRadius = "16px";
+ panel.style.border = isDark
+ ? "1px solid rgba(148, 163, 184, 0.22)"
+ : "1px solid rgba(148, 163, 184, 0.28)";
+ panel.style.background = isDark ? "#0f172a" : "#ffffff";
+ panel.style.boxShadow = isDark
+ ? "0 24px 64px rgba(15, 23, 42, 0.36)"
+ : "0 24px 64px rgba(15, 23, 42, 0.16)";
+ panel.style.color = isDark ? "#e2e8f0" : "#0f172a";
+ panel.style.padding = "20px";
+ panel.style.fontFamily =
+ "Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
+ panel.style.position = "relative";
+
+ const modalTitle = document.createElement("p");
+ modalTitle.textContent = "AlgorithmHub";
+ modalTitle.style.margin = "0";
+ modalTitle.style.paddingRight = "32px";
+ modalTitle.style.fontSize = "16px";
+ modalTitle.style.fontWeight = "700";
+ modalTitle.style.color = isDark ? "#f8fafc" : "#0f172a";
+
+ const problemTitle = document.createElement("p");
+ problemTitle.textContent = options.title;
+ problemTitle.style.margin = "6px 0 0";
+ problemTitle.style.paddingRight = "32px";
+ problemTitle.style.fontSize = "13px";
+ problemTitle.style.fontWeight = "600";
+ problemTitle.style.color = isDark ? "#cbd5e1" : "#334155";
+
+ const description = document.createElement("p");
+ description.textContent = copy.description;
+ description.style.margin = "8px 0 0";
+ description.style.fontSize = "13px";
+ description.style.lineHeight = "1.5";
+ description.style.color = isDark ? "#94a3b8" : "#475569";
+
+ const content = document.createElement("div");
+ content.style.marginTop = "20px";
+
+ const close = () => {
+ window.removeEventListener("message", handleEditorMessage);
+ document.removeEventListener("keydown", handleEscape, true);
+ overlay.remove();
+ };
+
+ const closeButton = document.createElement("button");
+ closeButton.type = "button";
+ closeButton.setAttribute("aria-label", copy.close);
+ closeButton.textContent = "×";
+ closeButton.style.position = "absolute";
+ closeButton.style.top = "14px";
+ closeButton.style.right = "14px";
+ closeButton.style.width = "28px";
+ closeButton.style.height = "28px";
+ closeButton.style.border = "0";
+ closeButton.style.borderRadius = "8px";
+ closeButton.style.background = "transparent";
+ closeButton.style.color = isDark ? "#94a3b8" : "#64748b";
+ closeButton.style.fontSize = "22px";
+ closeButton.style.lineHeight = "1";
+ closeButton.style.cursor = "pointer";
+ closeButton.onclick = close;
+
+ const buttonClass = (button: HTMLButtonElement) => {
+ button.type = "button";
+ button.style.border = "1px solid transparent";
+ button.style.borderRadius = "10px";
+ button.style.padding = "11px 14px";
+ button.style.fontSize = "14px";
+ button.style.fontWeight = "600";
+ button.style.cursor = "pointer";
+ button.style.transition = "background-color 120ms ease, border-color 120ms ease";
+ };
+
+ let editorFrame: HTMLIFrameElement | null = null;
+ let pendingValueRequest:
+ | {
+ requestId: string;
+ resolve: (value: string) => void;
+ }
+ | null = null;
+
+ const postEditorMessage = (message: object) => {
+ editorFrame?.contentWindow?.postMessage(
+ {
+ source: "algorithmhub-note-editor-parent",
+ ...message,
+ },
+ "*"
+ );
+ };
+
+ const requestEditorValue = () =>
+ new Promise((resolve) => {
+ const requestId = crypto.randomUUID();
+ pendingValueRequest = { requestId, resolve };
+ postEditorMessage({ type: "requestValue", requestId });
+ });
+
+ const handleEditorMessage = (
+ event: MessageEvent
+ ) => {
+ if (!editorFrame || event.source !== editorFrame.contentWindow) {
+ return;
+ }
+
+ if (event.data?.source !== "algorithmhub-note-editor") {
+ return;
+ }
+
+ if (event.data.type === "ready") {
+ postEditorMessage({ type: "focus" });
+ return;
+ }
+
+ if (
+ event.data.type === "value" &&
+ pendingValueRequest?.requestId === event.data.requestId
+ ) {
+ pendingValueRequest.resolve(event.data.value);
+ pendingValueRequest = null;
+ }
+ };
+
+ const renderActions = () => {
+ editorFrame = null;
+ pendingValueRequest = null;
+ content.replaceChildren();
+
+ const stack = document.createElement("div");
+ stack.style.display = "grid";
+ stack.style.gap = "10px";
+
+ const openButton = document.createElement("button");
+ buttonClass(openButton);
+ openButton.textContent = copy.openRepository;
+ openButton.style.width = "100%";
+ openButton.style.background = "#2563eb";
+ openButton.style.color = "#eff6ff";
+ openButton.onclick = () => {
+ options.onOpenRepository();
+ close();
+ };
+
+ const noteButton = document.createElement("button");
+ buttonClass(noteButton);
+ noteButton.textContent = copy.addNote;
+ noteButton.style.width = "100%";
+ noteButton.style.background = isDark ? "#111827" : "#f8fafc";
+ noteButton.style.borderColor = isDark ? "#334155" : "#cbd5e1";
+ noteButton.style.color = isDark ? "#e2e8f0" : "#0f172a";
+ noteButton.onclick = renderNoteEditor;
+
+ stack.append(openButton, noteButton);
+ content.appendChild(stack);
+ };
+
+ const renderNoteEditor = () => {
+ content.replaceChildren();
+
+ const heading = document.createElement("p");
+ heading.textContent = copy.noteTitle;
+ heading.style.margin = "0";
+ heading.style.fontSize = "14px";
+ heading.style.fontWeight = "600";
+ heading.style.color = isDark ? "#f8fafc" : "#0f172a";
+
+ const hint = document.createElement("p");
+ hint.textContent = copy.noteDescription;
+ hint.style.margin = "8px 0 0";
+ hint.style.fontSize = "13px";
+ hint.style.lineHeight = "1.5";
+ hint.style.color = isDark ? "#94a3b8" : "#475569";
+
+ editorFrame = document.createElement("iframe");
+ editorFrame.title = copy.noteTitle;
+ editorFrame.style.display = "block";
+ editorFrame.style.width = "100%";
+ editorFrame.style.height = "182px";
+ editorFrame.style.marginTop = "14px";
+ editorFrame.style.border = "0";
+ editorFrame.style.borderRadius = "12px";
+ editorFrame.style.background = isDark ? "#020617" : "#ffffff";
+ editorFrame.style.colorScheme = isDark ? "dark" : "light";
+ editorFrame.setAttribute("sandbox", "allow-scripts");
+ editorFrame.srcdoc = createEditorDocument({
+ isDark,
+ placeholder: copy.notePlaceholder,
+ });
+
+ const error = document.createElement("p");
+ error.style.margin = "8px 0 0";
+ error.style.fontSize = "12px";
+ error.style.color = "#ef4444";
+ error.style.minHeight = "18px";
+
+ const actions = document.createElement("div");
+ actions.style.display = "flex";
+ actions.style.justifyContent = "flex-end";
+ actions.style.gap = "10px";
+ actions.style.marginTop = "14px";
+
+ const backButton = document.createElement("button");
+ buttonClass(backButton);
+ backButton.textContent = copy.back;
+ backButton.style.width = "auto";
+ backButton.style.background = "transparent";
+ backButton.style.borderColor = isDark ? "#334155" : "#cbd5e1";
+ backButton.style.color = isDark ? "#94a3b8" : "#475569";
+ backButton.onclick = renderActions;
+
+ const saveButton = document.createElement("button");
+ buttonClass(saveButton);
+ saveButton.textContent = copy.save;
+ saveButton.style.width = "auto";
+ saveButton.style.background = "#059669";
+ saveButton.style.color = "#ecfdf5";
+ saveButton.onclick = async () => {
+ const note = (await requestEditorValue()).trim();
+ if (!note) {
+ error.textContent = copy.emptyNote;
+ postEditorMessage({ type: "focus" });
+ return;
+ }
+
+ error.textContent = "";
+ postEditorMessage({ type: "setDisabled", disabled: true });
+ backButton.disabled = true;
+ saveButton.disabled = true;
+ saveButton.textContent = copy.saving;
+
+ try {
+ await options.onSaveNote(note);
+ close();
+ } catch (reason) {
+ error.textContent =
+ reason instanceof Error ? reason.message : copy.failed;
+ postEditorMessage({ type: "setDisabled", disabled: false });
+ postEditorMessage({ type: "focus" });
+ backButton.disabled = false;
+ saveButton.disabled = false;
+ saveButton.textContent = copy.save;
+ }
+ };
+
+ actions.append(backButton, saveButton);
+ content.append(heading, hint, editorFrame, error, actions);
+ };
+
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ close();
+ }
+ };
+
+ overlay.addEventListener("click", (event) => {
+ if (event.target === overlay) {
+ close();
+ }
+ });
+
+ window.addEventListener("message", handleEditorMessage);
+ document.addEventListener("keydown", handleEscape, true);
+
+ panel.append(closeButton, modalTitle, problemTitle, description, content);
+ overlay.appendChild(panel);
+ document.body.appendChild(overlay);
+ renderActions();
+}
diff --git a/src/adapters/programmers/index.ts b/src/adapters/programmers/index.ts
index f76ab06..1e39391 100644
--- a/src/adapters/programmers/index.ts
+++ b/src/adapters/programmers/index.ts
@@ -7,10 +7,14 @@ import {
import { sendRuntimeMessage } from "../../shared/runtime";
import type { ExtensionSettings } from "../../core/types/domain";
import type { RuntimeMessageResponse } from "../../core/types/messages";
-import type { UploadJob } from "../../core/types/upload";
+import type { ProblemNoteRequest, UploadJob } from "../../core/types/upload";
import type { PlatformAdapter } from "../types";
import { burstConfetti } from "../confetti";
-import { uploadThroughBackground } from "../upload";
+import { openSyncedActionsModal } from "../problemActionsModal";
+import {
+ appendProblemNoteThroughBackground,
+ uploadThroughBackground,
+} from "../upload";
const STATUS_MARKER_ID = "algorithmhub-programmers-status-marker";
@@ -29,6 +33,12 @@ type ProgrammersProblemData = {
link: string;
};
+type SyncedProblemContext = {
+ settings: ExtensionSettings;
+ job: UploadJob;
+ repositoryUrl: string;
+};
+
function isProgrammersProblemPage(url: URL) {
return (
url.hostname.includes("programmers.co.kr") &&
@@ -37,6 +47,28 @@ function isProgrammersProblemPage(url: URL) {
);
}
+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(" ", "_")
+ .replaceAll(":", "-");
+}
+
+function createArchiveFileName(extension: string) {
+ return `${formatArchiveStamp()}-${Date.now().toString().slice(-4)}.${extension}`;
+}
+
function isSubmitButtonElement(button: HTMLButtonElement | null) {
if (!button) {
return false;
@@ -160,20 +192,22 @@ function renderStatusContent(marker: HTMLElement, text: string) {
marker.append(icon, label);
}
-function setStatusLink(marker: HTMLElement, url?: string) {
+function setStatusLink(
+ marker: HTMLElement,
+ action?: () => void,
+ title?: string
+) {
marker.onclick = null;
- if (!url) {
+ if (!action) {
marker.style.cursor = "default";
marker.removeAttribute("title");
return;
}
marker.style.cursor = "pointer";
- marker.title = "Open synced commit";
- marker.onclick = () => {
- window.open(url, "_blank", "noopener,noreferrer");
- };
+ marker.title = title ?? "Open synced actions";
+ marker.onclick = action;
}
function positionFooterMarker(marker: HTMLElement) {
@@ -221,7 +255,8 @@ async function prepareFooterStatusSlot() {
function setInlineStatus(
text: string,
tone: "working" | "success" | "error",
- url?: string
+ action?: () => void,
+ actionTitle?: string
) {
const marker = ensureStatusMarker();
if (!marker) {
@@ -229,7 +264,7 @@ function setInlineStatus(
}
renderStatusContent(marker, text);
- setStatusLink(marker, tone === "success" ? url : undefined);
+ setStatusLink(marker, tone === "success" ? action : undefined, actionTitle);
if (marker.style.position === "absolute") {
scheduleFooterMarkerPosition(marker);
@@ -524,6 +559,11 @@ function buildUploadJob(data: ProgrammersProblemData, settings: ExtensionSetting
content: data.code,
});
+ job = addUploadFile(job, {
+ path: `archives/${createArchiveFileName(data.languageExtension)}`,
+ content: data.code,
+ });
+
if (settings.platforms.programmers.createProblemReadme) {
job = addUploadFile(job, {
path: "README.md",
@@ -552,40 +592,42 @@ function createSubmissionHandler() {
let lastUploadedKey = "";
let lastTriggeredAt = 0;
let lastPathname = window.location.pathname;
+ let latestSyncContext: SyncedProblemContext | null = null;
return async function handleSubmission() {
- if (window.location.pathname !== lastPathname) {
- lastPathname = window.location.pathname;
- lastUploadedKey = "";
- clearInlineStatus();
- }
+ try {
+ if (window.location.pathname !== lastPathname) {
+ lastPathname = window.location.pathname;
+ lastUploadedKey = "";
+ latestSyncContext = null;
+ clearInlineStatus();
+ }
- const settings = await getSettings();
+ const settings = await getSettings();
- if (
- !settings.platforms.programmers.enabled ||
- !settings.platforms.programmers.autoUpload
- ) {
- return;
- }
+ if (
+ !settings.platforms.programmers.enabled ||
+ !settings.platforms.programmers.autoUpload
+ ) {
+ return;
+ }
- if (!(await isExtensionEnabled())) {
- return;
- }
+ if (!(await isExtensionEnabled())) {
+ return;
+ }
- const now = Date.now();
- if (now - lastTriggeredAt < 1500) {
- return;
- }
- lastTriggeredAt = now;
+ const now = Date.now();
+ if (now - lastTriggeredAt < 1500) {
+ return;
+ }
+ lastTriggeredAt = now;
- const accepted = await waitForAcceptedResult();
- if (!accepted) {
- clearInlineStatus();
- return;
- }
+ const accepted = await waitForAcceptedResult();
+ if (!accepted) {
+ clearInlineStatus();
+ return;
+ }
- try {
if (!(await isExtensionEnabled())) {
clearInlineStatus();
return;
@@ -596,23 +638,88 @@ function createSubmissionHandler() {
const data = parseProgrammersProblemData();
const uploadKey = `${data.problemId}:${data.language}:${data.code.length}`;
if (uploadKey === lastUploadedKey) {
- setInlineStatus("Synced", "success");
+ setInlineStatus(
+ "Synced",
+ "success",
+ latestSyncContext
+ ? () => {
+ 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);
+ },
+ });
+ }
+ : undefined,
+ "Open synced actions"
+ );
return;
}
+ latestSyncContext = null;
const job = buildUploadJob(data, settings);
const record = await Promise.all([uploadThroughBackground(job), wait(700)]).then(
([uploadRecord]) => uploadRecord
);
lastUploadedKey = uploadKey;
+ latestSyncContext = {
+ settings,
+ job,
+ repositoryUrl: `https://github.com/${record.repository}/tree/${record.branch}/${encodeURI(
+ job.directory
+ )}`,
+ };
setInlineStatus(
"Synced",
"success",
- `https://github.com/${record.repository}/tree/${record.branch}/${encodeURI(
- record.filePaths[0]?.split("/").slice(0, -1).join("/") ?? ""
- )}`
+ () => {
+ 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"
);
} catch {
+ latestSyncContext = null;
setInlineStatus("Sync failed", "error");
}
};
diff --git a/src/adapters/upload.ts b/src/adapters/upload.ts
index 486cb46..17af838 100644
--- a/src/adapters/upload.ts
+++ b/src/adapters/upload.ts
@@ -1,4 +1,4 @@
-import type { UploadJob } from "../core/types/upload";
+import type { ProblemNoteRequest, UploadJob } from "../core/types/upload";
import { sendRuntimeMessage } from "../shared/runtime";
export async function uploadThroughBackground(job: UploadJob) {
@@ -21,3 +21,24 @@ export async function uploadThroughBackground(job: UploadJob) {
return response.record;
}
+
+export async function appendProblemNoteThroughBackground(payload: ProblemNoteRequest) {
+ const response = await sendRuntimeMessage({
+ type: "APPEND_PROBLEM_NOTE",
+ payload,
+ });
+
+ if (!response) {
+ throw new Error("Extension context invalidated.");
+ }
+
+ if (response.type !== "APPEND_PROBLEM_NOTE_RESULT") {
+ throw new Error("Unexpected note response.");
+ }
+
+ if (!response.ok) {
+ throw new Error(response.reason);
+ }
+
+ return response.record;
+}
diff --git a/src/app/options/Options.tsx b/src/app/options/Options.tsx
index 3994e91..2811c64 100644
--- a/src/app/options/Options.tsx
+++ b/src/app/options/Options.tsx
@@ -344,7 +344,7 @@ export default function Options() {
const [draggedSegment, setDraggedSegment] =
useState(null);
const [extensionEnabled, setExtensionEnabled] = useState(true);
- const [templatesOpen, setTemplatesOpen] = useState(false);
+ const [templatesOpen, setTemplatesOpen] = useState(true);
useEffect(() => {
void chrome.runtime
@@ -716,7 +716,7 @@ export default function Options() {
{copy.templatesTitle}
diff --git a/src/app/popup/Popup.tsx b/src/app/popup/Popup.tsx
index e385cc4..b143850 100644
--- a/src/app/popup/Popup.tsx
+++ b/src/app/popup/Popup.tsx
@@ -272,7 +272,7 @@ export default function Popup() {
diff --git a/src/app/welcome/Welcome.tsx b/src/app/welcome/Welcome.tsx
index 8c9bcdc..fbd85be 100644
--- a/src/app/welcome/Welcome.tsx
+++ b/src/app/welcome/Welcome.tsx
@@ -8,6 +8,19 @@ import { useResolvedTheme } from "../../shared/theme";
type RepoMode = "" | "new" | "link";
+const WELCOME_COPY = {
+ en: {
+ subtitle:
+ "Automatically sync your accepted LeetCode and Programmers solutions to GitHub.",
+ connectTitle: "Connect a repository to get started",
+ },
+ ko: {
+ subtitle:
+ "LeetCode와 프로그래머스 정답 제출을 GitHub에 자동으로 동기화하세요.",
+ connectTitle: "시작하려면 저장소를 연결하세요",
+ },
+} as const;
+
const emptySettings: ExtensionSettings = {
locale: "en",
themeMode: "system",
@@ -70,6 +83,7 @@ export default function Welcome() {
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
const resolvedTheme = useResolvedTheme(settings.themeMode);
+ const copy = WELCOME_COPY[settings.locale];
const loadRepositories = useCallback(async (token = settings.github.token) => {
if (!token.trim()) {
@@ -249,7 +263,7 @@ export default function Welcome() {
resolvedTheme === "dark" ? "text-stone-400" : "text-stone-700"
}`}
>
- Automatically sync your accepted LeetCode and Programmers solutions to GitHub.
+ {copy.subtitle}
@@ -259,7 +273,7 @@ export default function Welcome() {
resolvedTheme === "dark" ? "text-stone-50" : "text-stone-900"
}`}
>
- Connect a repository to get started
+ {copy.connectTitle}
diff --git a/src/core/github/client.ts b/src/core/github/client.ts
index 5591edf..3a7b5c1 100644
--- a/src/core/github/client.ts
+++ b/src/core/github/client.ts
@@ -40,6 +40,11 @@ type CreateCommitResponse = {
sha: string;
};
+type RepositoryContentResponse = {
+ content?: string;
+ encoding?: string;
+};
+
class GitHubApiError extends Error {
readonly status: number;
@@ -129,6 +134,30 @@ export function createGitHubClient(token: string) {
commit: { sha: string; message: string };
}>(response);
},
+ async getRepositoryFileContent(
+ fullName: string,
+ path: string,
+ branch: string
+ ): Promise {
+ const response = await fetch(
+ `https://api.github.com/repos/${fullName}/contents/${encodeURIComponent(path)}?ref=${encodeURIComponent(branch)}`,
+ {
+ method: "GET",
+ headers,
+ }
+ );
+
+ if (response.status === 404) {
+ return null;
+ }
+
+ const payload = await handleGitHubResponse(response);
+ if (!payload.content) {
+ return null;
+ }
+
+ return decodeURIComponent(escape(atob(payload.content.replace(/\n/g, ""))));
+ },
async updateRepository(
fullName: string,
payload: {
diff --git a/src/core/types/messages.ts b/src/core/types/messages.ts
index eb041f9..da4307e 100644
--- a/src/core/types/messages.ts
+++ b/src/core/types/messages.ts
@@ -3,7 +3,7 @@ import type {
ExtensionSettings,
RepositoryInfo,
} from "./domain";
-import type { UploadJob, UploadRecord } from "./upload";
+import type { ProblemNoteRequest, UploadJob, UploadRecord } from "./upload";
export type RuntimeMessage =
| { type: "PING" }
@@ -20,7 +20,8 @@ export type RuntimeMessage =
| { type: "CREATE_GITHUB_REPOSITORY"; name: string; private: boolean }
| { type: "LINK_GITHUB_REPOSITORY"; repository: string }
| { type: "DISCONNECT_GITHUB_REPOSITORY" }
- | { type: "UPLOAD_JOB"; job: UploadJob };
+ | { type: "UPLOAD_JOB"; job: UploadJob }
+ | { type: "APPEND_PROBLEM_NOTE"; payload: ProblemNoteRequest };
export type RuntimeMessageResponse =
| { type: "PONG"; timestamp: number }
@@ -36,3 +37,5 @@ export type RuntimeMessageResponse =
| { type: "GITHUB_REPOSITORIES"; ok: false; reason: string }
| { type: "GITHUB_REPOSITORY_UPDATE"; ok: true; settings: ExtensionSettings; repository: RepositoryInfo }
| { type: "GITHUB_REPOSITORY_UPDATE"; ok: false; reason: string }
+ | { type: "APPEND_PROBLEM_NOTE_RESULT"; ok: true; record: UploadRecord }
+ | { type: "APPEND_PROBLEM_NOTE_RESULT"; ok: false; reason: string; problemId: string };
diff --git a/src/core/types/upload.ts b/src/core/types/upload.ts
index a4b4197..efe6a90 100644
--- a/src/core/types/upload.ts
+++ b/src/core/types/upload.ts
@@ -27,3 +27,11 @@ export type UploadRecord = {
uploadedAt: number;
filePaths: string[];
};
+
+export type ProblemNoteRequest = {
+ platform: PlatformId;
+ problemId: string;
+ title: string;
+ directory: string;
+ note: string;
+};
diff --git a/src/scripts/background/index.ts b/src/scripts/background/index.ts
index ff099a0..b96a9cc 100644
--- a/src/scripts/background/index.ts
+++ b/src/scripts/background/index.ts
@@ -91,6 +91,37 @@ function withRootSummaryReadme(
};
}
+function formatNoteDate(date = new Date()) {
+ return new Intl.DateTimeFormat("sv-SE", {
+ timeZone: "Asia/Seoul",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).format(date);
+}
+
+function formatProblemNote(note: string) {
+ const lines = note
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ if (lines.length === 0) {
+ throw new Error("Add at least one line to save a note.");
+ }
+
+ return `## ${formatNoteDate()}\n\n${lines.map((line) => `- ${line}`).join("\n")}`;
+}
+
+function appendProblemNote(existingContent: string | null, note: string) {
+ const nextEntry = formatProblemNote(note);
+ if (!existingContent?.trim()) {
+ return `${nextEntry}\n`;
+ }
+
+ return `${existingContent.trimEnd()}\n\n${nextEntry}\n`;
+}
+
function openWelcomePage() {
const url = chrome.runtime.getURL("welcome.html");
void chrome.tabs.create({ url, active: true });
@@ -465,6 +496,65 @@ async function handleUploadJobMessage(
}
}
+async function handleAppendProblemNoteMessage(
+ message: Extract
+): Promise {
+ const settings = await getSettings();
+ const token = settings.github.token.trim();
+ const repository = settings.github.repository.trim();
+
+ if (!token || !repository) {
+ return {
+ type: "APPEND_PROBLEM_NOTE_RESULT",
+ ok: false,
+ problemId: message.payload.problemId,
+ reason: "GitHub token and repository must be configured.",
+ };
+ }
+
+ try {
+ const github = createGitHubClient(token);
+ const repo = await github.getRepository(repository);
+ const branch = settings.github.branch.trim() || repo.default_branch;
+ const notePath = `${message.payload.directory}/NOTE.md`;
+ const existingContent = await github.getRepositoryFileContent(
+ repository,
+ notePath,
+ branch
+ );
+ const job: UploadJob = {
+ id: `note:${message.payload.platform}:${message.payload.problemId}:${Date.now()}`,
+ platform: message.payload.platform,
+ problemId: message.payload.problemId,
+ title: message.payload.title,
+ directory: message.payload.directory,
+ commitMessage: `[${message.payload.platform}][Note] ${message.payload.title} - AlgorithmHub`,
+ files: [
+ {
+ path: "NOTE.md",
+ content: appendProblemNote(existingContent, message.payload.note),
+ },
+ ],
+ rootFiles: [],
+ };
+ const record = await executeUploadJob(job, settings);
+ await saveUploadRecord(record);
+
+ return {
+ type: "APPEND_PROBLEM_NOTE_RESULT",
+ ok: true,
+ record,
+ };
+ } catch (error) {
+ return {
+ type: "APPEND_PROBLEM_NOTE_RESULT",
+ ok: false,
+ problemId: message.payload.problemId,
+ reason: error instanceof Error ? error.message : "Failed to save note.",
+ };
+ }
+}
+
chrome.runtime.onInstalled.addListener(async () => {
const { settings } = await chrome.storage.local.get("settings");
@@ -513,6 +603,9 @@ chrome.runtime.onMessage.addListener(
case "UPLOAD_JOB":
sendResponse(await handleUploadJobMessage(message));
return;
+ case "APPEND_PROBLEM_NOTE":
+ sendResponse(await handleAppendProblemNoteMessage(message));
+ return;
}
})();
diff --git a/src/shared/runtime.ts b/src/shared/runtime.ts
index 144e8aa..b5ec5bb 100644
--- a/src/shared/runtime.ts
+++ b/src/shared/runtime.ts
@@ -3,12 +3,20 @@ import type { RuntimeMessage, RuntimeMessageResponse } from "../core/types/messa
export async function sendRuntimeMessage(
message: RuntimeMessage
): Promise {
+ const runtime = globalThis.chrome?.runtime;
+ if (!runtime?.sendMessage) {
+ return null;
+ }
+
try {
- return (await chrome.runtime.sendMessage(message)) as T;
+ return (await runtime.sendMessage(message)) as T;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
- if (reason.includes("Extension context invalidated")) {
+ if (
+ reason.includes("Extension context invalidated") ||
+ reason.includes("Cannot read properties of undefined")
+ ) {
return null;
}