Add import/export functionality
This commit is contained in:
+265
-27
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user