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() {

-

+

{copy.subtitle}

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; }