From 2eb0a3ffa771e4c2dce071bae091339dcb58b47d Mon Sep 17 00:00:00 2001 From: Krasimir Nedelchev <19822240+kaykayehnn@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:24:28 +0300 Subject: [PATCH] Add import/export functionality --- backend/src/app.ts | 292 ++++++++++++++++++++++++++++++++++++---- frontend/src/main.tsx | 87 ++++++++++-- frontend/src/styles.css | 9 ++ 3 files changed, 351 insertions(+), 37 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index dfc4dc3..bf676c1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -51,32 +51,8 @@ const checkSchema = z.object({ message: z.string().trim().min(1, "Add feedback text for this check.") }); -const taskSchema = z.object({ - title: z.string().trim().min(1, "Add a task title."), - descriptionMarkdown: z.string().default(""), - groupId: z.string().trim().min(1, "Choose a day group for this task."), - order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0), - visible: z.boolean().default(true), - starterHtml: z.string().default(""), - starterCss: z.string().default(""), - starterJs: z.string().default(""), - referenceHtml: z.string().default(""), - referenceCss: z.string().default(""), - referenceJs: z.string().default(""), - visualThreshold: z - .coerce.number({ invalid_type_error: "Enter a valid visual threshold." }) - .min(0, "Visual threshold must be between 0 and 1.") - .max(1, "Visual threshold must be between 0 and 1.") - .default(0.85), - passingScore: z - .coerce.number({ invalid_type_error: "Enter a valid passing score." }) - .min(0, "Passing score must be between 0 and 1.") - .max(1, "Passing score must be between 0 and 1.") - .default(0.8), - checks: z.array(checkSchema).default([]), - regenerateExpected: z.boolean().default(true) -}).superRefine((task, context) => { - task.checks.forEach((check, index) => { +function validateChecks(checks: z.infer[], context: z.RefinementCtx) { + checks.forEach((check, index) => { const label = `Check ${index + 1}`; const needsSelector = check.type !== "visual-match"; const needsValue = ["text-contains", "attribute-equals", "css-property"].includes(check.type); @@ -129,6 +105,79 @@ const taskSchema = z.object({ }); } }); +} + +const taskSchema = z.object({ + title: z.string().trim().min(1, "Add a task title."), + descriptionMarkdown: z.string().default(""), + groupId: z.string().trim().min(1, "Choose a day group for this task."), + order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0), + visible: z.boolean().default(true), + starterHtml: z.string().default(""), + starterCss: z.string().default(""), + starterJs: z.string().default(""), + referenceHtml: z.string().default(""), + referenceCss: z.string().default(""), + referenceJs: z.string().default(""), + visualThreshold: z + .coerce.number({ invalid_type_error: "Enter a valid visual threshold." }) + .min(0, "Visual threshold must be between 0 and 1.") + .max(1, "Visual threshold must be between 0 and 1.") + .default(0.85), + passingScore: z + .coerce.number({ invalid_type_error: "Enter a valid passing score." }) + .min(0, "Passing score must be between 0 and 1.") + .max(1, "Passing score must be between 0 and 1.") + .default(0.8), + checks: z.array(checkSchema).default([]), + regenerateExpected: z.boolean().default(true) +}).superRefine((task, context) => { + validateChecks(task.checks, context); +}); + +const importTaskSchema = z.object({ + id: z.string().trim().optional(), + title: z.string().trim().min(1, "Add a task title."), + descriptionMarkdown: z.string().default(""), + order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0), + visible: z.boolean().default(true), + starterHtml: z.string().default(""), + starterCss: z.string().default(""), + starterJs: z.string().default(""), + referenceHtml: z.string().default(""), + referenceCss: z.string().default(""), + referenceJs: z.string().default(""), + visualThreshold: z + .coerce.number({ invalid_type_error: "Enter a valid visual threshold." }) + .min(0, "Visual threshold must be between 0 and 1.") + .max(1, "Visual threshold must be between 0 and 1.") + .default(0.85), + passingScore: z + .coerce.number({ invalid_type_error: "Enter a valid passing score." }) + .min(0, "Passing score must be between 0 and 1.") + .max(1, "Passing score must be between 0 and 1.") + .default(0.8), + checks: z.array(checkSchema).default([]) +}).superRefine((task, context) => { + validateChecks(task.checks, context); +}); + +const importAssetSchema = z.object({ + path: z.string().trim().min(1), + contentType: z.string().trim().optional(), + dataBase64: z.string().min(1) +}); + +const importExportSchema = z.object({ + format: z.literal("frontend-judge-task-export").optional(), + version: z.number().optional(), + groups: z.array(z.object({ + id: z.string().trim().optional(), + title: z.string().trim().min(1, "Add a group title."), + order: z.coerce.number().int().default(0), + tasks: z.array(importTaskSchema).default([]) + })).default([]), + assets: z.array(importAssetSchema).default([]) }); const submissionSchema = z.object({ @@ -221,6 +270,80 @@ function imageExtension(contentType: string) { return null; } +function contentTypeForAssetPath(assetPath: string) { + const extension = path.extname(assetPath).toLocaleLowerCase("en-US"); + if (extension === ".png") return "image/png"; + if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg"; + if (extension === ".webp") return "image/webp"; + if (extension === ".gif") return "image/gif"; + return "application/octet-stream"; +} + +function normalizeExportAssetPath(assetPath: string) { + const normalized = assetPath.replace(/^\/+/, ""); + return /^generated\/assets\/[A-Za-z0-9._-]+\.(png|jpe?g|webp|gif)$/i.test(normalized) ? normalized : null; +} + +function markdownAssetPaths(markdown: string) { + const paths = new Set(); + const assetPattern = /(?:^|[("'`\s])\/?(generated\/assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif))/gi; + for (const match of markdown.matchAll(assetPattern)) { + const assetPath = normalizeExportAssetPath(match[1]); + if (assetPath) { + paths.add(assetPath); + } + } + return paths; +} + +function uniqueImportId(id: string | undefined, used: Set) { + if (!id || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || used.has(id)) { + return undefined; + } + + used.add(id); + return id; +} + +async function exportMarkdownAssets(markdowns: string[]) { + const paths = new Set(); + for (const markdown of markdowns) { + for (const assetPath of markdownAssetPaths(markdown)) { + paths.add(assetPath); + } + } + + const assets = []; + for (const assetPath of [...paths].sort()) { + try { + const absolutePath = path.join(config.dataDir, assetPath); + const data = await fs.readFile(absolutePath); + assets.push({ + path: assetPath, + contentType: contentTypeForAssetPath(assetPath), + dataBase64: data.toString("base64") + }); + } catch { + // Missing markdown images should not make the entire task export unusable. + } + } + + return assets; +} + +async function importMarkdownAssets(assets: z.infer[]) { + for (const asset of assets) { + const assetPath = normalizeExportAssetPath(asset.path); + if (!assetPath) { + throw new Error(`Unsupported asset path: ${asset.path}`); + } + + const absolutePath = path.join(config.dataDir, assetPath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, Buffer.from(asset.dataBase64, "base64")); + } +} + async function saveAsset(buffer: Buffer, contentType: string) { const extension = imageExtension(contentType); if (!extension) { @@ -323,7 +446,7 @@ export async function createApp() { crossOriginEmbedderPolicy: false }) ); - app.use(express.json({ limit: "2mb" })); + app.use(express.json({ limit: "25mb" })); app.use(cookieParser()); app.use("/generated", express.static(config.generatedDir)); @@ -482,6 +605,121 @@ export async function createApp() { res.json(groups.map((group) => ({ ...group, tasks: group.tasks.map(taskWithChecks) }))); }); + app.get("/api/admin/export", requireAdmin, async (_req, res) => { + const groups = await prisma.dayGroup.findMany({ + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + include: { tasks: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] } } + }); + const markdowns = groups.flatMap((group) => group.tasks.map((task) => task.descriptionMarkdown)); + const exportData = { + format: "frontend-judge-task-export", + version: 1, + exportedAt: new Date().toISOString(), + groups: groups.map((group) => ({ + id: group.id, + title: group.title, + order: group.order, + tasks: group.tasks.map((task) => ({ + id: task.id, + title: task.title, + descriptionMarkdown: task.descriptionMarkdown, + order: task.order, + visible: task.visible, + starterHtml: task.starterHtml, + starterCss: task.starterCss, + starterJs: task.starterJs, + referenceHtml: task.referenceHtml, + referenceCss: task.referenceCss, + referenceJs: task.referenceJs, + visualThreshold: task.visualThreshold, + passingScore: task.passingScore, + checks: parseChecks(task.checksJson) + })) + })), + assets: await exportMarkdownAssets(markdowns) + }; + + res.setHeader("Content-Disposition", `attachment; filename="frontend-judge-tasks-${new Date().toISOString().slice(0, 10)}.json"`); + res.json(exportData); + }); + + app.post("/api/admin/import", requireAdmin, async (req, res) => { + const parsed = importExportSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json(validationError(parsed.error, "Please choose a valid task export file.")); + return; + } + + const warnings: string[] = []; + try { + await importMarkdownAssets(parsed.data.assets); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : "Could not import markdown assets." }); + return; + } + + const groupIds = new Set(); + const taskIds = new Set(); + const createdTasks = await prisma.$transaction(async (tx) => { + await tx.dayGroup.deleteMany(); + const tasks = []; + + for (const group of parsed.data.groups) { + const createdGroup = await tx.dayGroup.create({ + data: { + ...(uniqueImportId(group.id, groupIds) ? { id: group.id } : {}), + title: group.title, + order: group.order + } + }); + + for (const task of group.tasks) { + const taskId = uniqueImportId(task.id, taskIds); + const createdTask = await tx.task.create({ + data: { + ...(taskId ? { id: taskId } : {}), + title: task.title, + descriptionMarkdown: task.descriptionMarkdown, + groupId: createdGroup.id, + order: task.order, + visible: task.visible, + starterHtml: task.starterHtml, + starterCss: task.starterCss, + starterJs: task.starterJs, + referenceHtml: task.referenceHtml, + referenceCss: task.referenceCss, + referenceJs: task.referenceJs, + visualThreshold: task.visualThreshold, + passingScore: task.passingScore, + checksJson: JSON.stringify(task.checks) + } + }); + tasks.push(createdTask); + } + } + + return tasks; + }); + + for (const task of createdTasks) { + try { + const expectedScreenshotPath = await maybeGenerateExpected(task); + if (expectedScreenshotPath) { + await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } }); + } + } catch { + warnings.push(`Could not regenerate the expected screenshot for "${task.title}".`); + } + } + + res.json({ + ok: true, + groups: parsed.data.groups.length, + tasks: createdTasks.length, + warnings + }); + }); + app.post("/api/admin/groups", requireAdmin, async (req, res) => { const parsed = z.object({ title: z.string().min(1), order: z.coerce.number().int().default(0) }).safeParse(req.body); if (!parsed.success) { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1e771c3..084ca8e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { javascript } from "@codemirror/lang-javascript"; import ReactMarkdown from "react-markdown"; import { CheckCircle2, + Download, Eye, FileText, History, @@ -698,6 +699,8 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) { if (!selectedTask.groupId && groupData[0]) { setSelectedTask((task) => ({ ...task, groupId: groupData[0].id })); } + + return { groupData, submissionData }; } useEffect(() => { @@ -802,6 +805,53 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) { }); } + async function exportTasks() { + setError(""); + setMessage(""); + const response = await fetch("/api/admin/export"); + if (!response.ok) { + const payload = await response.json().catch(() => ({ error: response.statusText })); + throw new ApiError(typeof payload.error === "string" ? payload.error : "Could not export tasks.", collectErrorDetails(payload)); + } + + const blob = await response.blob(); + const disposition = response.headers.get("content-disposition") ?? ""; + const fileNameMatch = disposition.match(/filename="([^"]+)"/); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileNameMatch?.[1] ?? `frontend-judge-tasks-${new Date().toISOString().slice(0, 10)}.json`; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + setMessage("Task export downloaded."); + } + + async function importTasks(file: File) { + setError(""); + setMessage(""); + const shouldImport = window.confirm("Importing will replace all current groups, tasks, and submissions. Continue?"); + if (!shouldImport) { + return; + } + + const parsed = JSON.parse(await file.text()) as unknown; + const result = await api<{ groups: number; tasks: number; warnings: string[] }>("/api/admin/import", { + method: "POST", + body: JSON.stringify(parsed) + }); + const { groupData } = await loadAdminData(); + setSelectedTask({ ...emptyTask, groupId: groupData[0]?.id ?? "" }); + setSelectedSubmission(null); + setMessage( + [ + `Imported ${result.groups} group(s) and ${result.tasks} task(s).`, + ...(result.warnings ?? []) + ].join("\n") + ); + } + return (
@@ -809,16 +859,33 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {

Admin dashboard

Create day groups, tasks, checks, and reference screenshots.

- +
+ + + +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5ff06e3..d3dc253 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -102,6 +102,14 @@ p { color: #5b6b79; } +.topbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; + flex-wrap: wrap; +} + .student-layout, .admin-layout { display: grid; @@ -645,6 +653,7 @@ p { .success { color: #167142; font-weight: 750; + white-space: pre-line; } .cm-editor {