Add import/export functionality

This commit is contained in:
2026-07-03 08:24:28 +03:00
parent 250c3b0156
commit 2eb0a3ffa7
3 changed files with 351 additions and 37 deletions
+265 -27
View File
@@ -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<typeof checkSchema>[], 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<string>();
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<string>) {
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<string>();
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<typeof importAssetSchema>[]) {
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<string>();
const taskIds = new Set<string>();
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) {
+67
View File
@@ -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 (
<main className="app-shell">
<header className="topbar">
@@ -809,6 +859,22 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
<h1>Admin dashboard</h1>
<p>Create day groups, tasks, checks, and reference screenshots.</p>
</div>
<div className="topbar-actions">
<button className="secondary" onClick={() => exportTasks().catch((err) => setError(errorMessage(err, "Could not export tasks.")))}>
<Download size={17} /> Export
</button>
<label className="upload-button">
<Upload size={17} /> Import
<input
type="file"
accept="application/json,.json"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) importTasks(file).catch((err) => setError(errorMessage(err, "Could not import tasks.")));
}}
/>
</label>
<button
className="ghost"
onClick={async () => {
@@ -819,6 +885,7 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
>
<LogOut size={18} />
</button>
</div>
</header>
<div className="admin-layout">
+9
View File
@@ -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 {