Mr. Drew's Assignment Creator — Docker share build
Self-contained Dockerized build for end users. Run via docker compose; see README.md for setup. Source-only, no sample data or build artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+241
@@ -0,0 +1,241 @@
|
||||
// lib/exporter.js — zero-dependency exports, client-side only.
|
||||
// Student version and teacher version (with red-pen answer key), as:
|
||||
// plain text, Word (.doc via HTML), clipboard, and print (for PDF).
|
||||
import { totalPoints } from "@/lib/schema";
|
||||
|
||||
const LETTERS = "ABCDEFGHIJ";
|
||||
|
||||
function answersLine(q) {
|
||||
switch (q.type) {
|
||||
case "multiple_choice":
|
||||
return `Answer: ${LETTERS[q.correctIndex] || "?"} — ${q.options?.[q.correctIndex] || ""}`;
|
||||
case "true_false":
|
||||
return `Answer: ${q.correctAnswer ? "True" : "False"}`;
|
||||
case "short_answer":
|
||||
return `Sample answer: ${q.sampleAnswer || ""}` + (q.keyPoints?.length ? `\nMust include: ${q.keyPoints.join("; ")}` : "");
|
||||
case "essay":
|
||||
return (q.sampleResponse ? `Sample response: ${q.sampleResponse}` : "") +
|
||||
(q.rubric?.length ? `\nRubric:\n${q.rubric.map((r) => ` • ${r.criterion} (${r.points} pts)${r.description ? " — " + r.description : ""}`).join("\n")}` : "");
|
||||
case "fill_blank":
|
||||
return `Answers: ${(q.answers || []).map((a, i) => `(${i + 1}) ${a}`).join(" ")}`;
|
||||
case "matching":
|
||||
return `Answer key:\n${(q.pairs || []).map((p) => ` ${p.left} → ${p.right}`).join("\n")}`;
|
||||
case "discussion":
|
||||
return (q.talkingPoints?.length ? `Key talking points:\n${q.talkingPoints.map((t) => ` • ${t}`).join("\n")}` : "") +
|
||||
(q.followUps?.length ? `\nFollow-ups:\n${q.followUps.map((t) => ` • ${t}`).join("\n")}` : "") +
|
||||
(q.sampleResponse ? `\nA strong contribution: ${q.sampleResponse}` : "");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle the right column of a matching question deterministically (by text)
|
||||
// so the student version isn't pre-matched but exports are stable.
|
||||
function shuffledRight(pairs) {
|
||||
return [...pairs.map((p) => p.right)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function questionStudentText(q, n) {
|
||||
const head = `${n}. ${q.type === "matching" ? (q.question || "Match each item on the left with the correct item on the right.") : q.question} (${q.points} pt${q.points === 1 ? "" : "s"})`;
|
||||
let body = "";
|
||||
if (q.type === "multiple_choice") {
|
||||
body = (q.options || []).map((o, i) => ` ${LETTERS[i]}. ${o}`).join("\n");
|
||||
} else if (q.type === "true_false") {
|
||||
body = " True / False";
|
||||
} else if (q.type === "short_answer") {
|
||||
body = " ________________________________________________\n ________________________________________________";
|
||||
} else if (q.type === "essay" || q.type === "discussion") {
|
||||
body = "";
|
||||
} else if (q.type === "matching") {
|
||||
const rights = shuffledRight(q.pairs || []);
|
||||
body = (q.pairs || []).map((p, i) => ` ___ ${i + 1}. ${p.left}`).join("\n") +
|
||||
"\n\n" + rights.map((r, i) => ` ${LETTERS[i]}. ${r}`).join("\n");
|
||||
}
|
||||
return body ? head + "\n" + body : head;
|
||||
}
|
||||
|
||||
export function buildText(assignment, { teacher, profile = {} }) {
|
||||
const lines = [];
|
||||
const teachLine = [profile.teacherName, profile.className].filter(Boolean).join(" — ");
|
||||
if (profile.schoolName) lines.push(profile.schoolName);
|
||||
if (teachLine) lines.push(teachLine);
|
||||
if (profile.schoolName || teachLine) lines.push("");
|
||||
lines.push(assignment.title || "Untitled assignment");
|
||||
const meta = [assignment.subject, assignment.gradeLevel, totalPoints(assignment.questions) + " points"].filter(Boolean).join(" · ");
|
||||
lines.push(meta);
|
||||
if (teacher) lines.push("TEACHER VERSION — ANSWER KEY INCLUDED");
|
||||
lines.push("");
|
||||
if (!teacher) lines.push("Name: ______________________________ Date: ______________", "");
|
||||
if (assignment.instructions) lines.push("Instructions: " + assignment.instructions, "");
|
||||
if (assignment.caseStudy) lines.push("— Read the following —", "", assignment.caseStudy, "");
|
||||
|
||||
assignment.questions.forEach((q, i) => {
|
||||
lines.push(questionStudentText(q, i + 1));
|
||||
if (teacher) {
|
||||
const ans = answersLine(q);
|
||||
if (ans) lines.push(" ✎ " + ans.replace(/\n/g, "\n "));
|
||||
if (q.explanation) lines.push(" ✎ Explanation: " + q.explanation);
|
||||
if (q.sourceRef) lines.push(" ✎ Source: \u201C" + q.sourceRef + "\u201D");
|
||||
if (q.verification?.status === "warn") lines.push(" ⚠ Reviewer note: " + (q.verification.note || "flagged — double-check"));
|
||||
}
|
||||
lines.push("");
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function schoolHeadHtml(profile = {}) {
|
||||
const teachLine = [profile.teacherName, profile.className].filter(Boolean).join(" — ");
|
||||
if (!profile.logo && !profile.schoolName && !teachLine) return "";
|
||||
return `<div class="schoolhead">
|
||||
${profile.logo ? `<img class="schoollogo" src="${profile.logo}" alt="">` : ""}
|
||||
<div class="schoolinfo">
|
||||
${profile.schoolName ? `<div class="schoolname">${esc(profile.schoolName)}</div>` : ""}
|
||||
${teachLine ? `<div class="teachline">${esc(teachLine)}</div>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildBodyHtml(assignment, { teacher, profile }) {
|
||||
const qs = assignment.questions || [];
|
||||
const qHtml = qs.map((q, i) => {
|
||||
const n = i + 1;
|
||||
let body = "";
|
||||
if (q.type === "multiple_choice") {
|
||||
body = `<div class="opts">` + (q.options || []).map((o, j) => {
|
||||
const isAns = teacher && j === q.correctIndex;
|
||||
return `<div${isAns ? ' class="ans"' : ""}>${LETTERS[j]}. ${esc(o)}${isAns ? " ✓" : ""}</div>`;
|
||||
}).join("") + `</div>`;
|
||||
} else if (q.type === "true_false") {
|
||||
body = `<div class="opts">True / False${teacher ? ` <span class="ans">✓ ${q.correctAnswer ? "True" : "False"}</span>` : ""}</div>`;
|
||||
} else if (q.type === "short_answer") {
|
||||
body = teacher ? "" : `<div class="writelines"></div><div class="writelines"></div>`;
|
||||
} else if (q.type === "essay" || q.type === "discussion") {
|
||||
body = "";
|
||||
} else if (q.type === "fill_blank") {
|
||||
body = "";
|
||||
} else if (q.type === "matching") {
|
||||
const rights = shuffledRight(q.pairs || []);
|
||||
body = `<table class="match"><tr><td>` +
|
||||
(q.pairs || []).map((p, j) => `<div>___ ${j + 1}. ${esc(p.left)}</div>`).join("") +
|
||||
`</td><td>` + rights.map((r, j) => `<div>${LETTERS[j]}. ${esc(r)}</div>`).join("") +
|
||||
`</td></tr></table>`;
|
||||
}
|
||||
|
||||
let key = "";
|
||||
if (teacher) {
|
||||
const ans = answersLine(q);
|
||||
key = `<div class="key">` +
|
||||
(ans ? `<div>${esc(ans).replace(/\n/g, "<br>")}</div>` : "") +
|
||||
(q.explanation ? `<div><b>Explanation:</b> ${esc(q.explanation)}</div>` : "") +
|
||||
(q.sourceRef ? `<div><b>Source:</b> \u201C${esc(q.sourceRef)}\u201D</div>` : "") +
|
||||
(q.verification?.status === "warn" ? `<div class="warnnote">⚠ Reviewer note: ${esc(q.verification.note || "flagged")}</div>` : "") +
|
||||
`</div>`;
|
||||
}
|
||||
const prompt = q.type === "matching" ? (q.question || "Match each item on the left with the correct item on the right.") : q.question;
|
||||
return `<div class="q"><p class="qp"><b>${n}.</b> ${esc(prompt)} <span class="pts">(${q.points} pt${q.points === 1 ? "" : "s"})</span></p>${body}${key}</div>`;
|
||||
}).join("");
|
||||
|
||||
return `${schoolHeadHtml(profile)}<h1>${esc(assignment.title)}</h1>
|
||||
<div class="meta">${esc([assignment.subject, assignment.gradeLevel].filter(Boolean).join(" · "))} · ${totalPoints(qs)} points</div>
|
||||
${teacher ? `<div class="teacherbar">Teacher version — answer key</div>` : `<div class="nameline">Name: ____________________________________ Date: ________________</div>`}
|
||||
${assignment.instructions ? `<p class="instr"><b>Instructions:</b> ${esc(assignment.instructions)}</p>` : ""}
|
||||
${assignment.caseStudy ? `<div class="case">${esc(assignment.caseStudy).replace(/\n/g, "<br>")}</div>` : ""}
|
||||
<hr>
|
||||
${qHtml}`;
|
||||
}
|
||||
|
||||
const PRINT_STYLES = `
|
||||
body { font-family: Georgia, "Times New Roman", serif; color: #1a1a1a; max-width: 7.2in; margin: 0 auto; padding: 24px; font-size: 12.5pt; line-height: 1.5; }
|
||||
h1 { font-size: 17pt; margin: 0 0 2px; }
|
||||
.meta { color: #555; font-size: 10.5pt; margin-bottom: 4px; }
|
||||
.teacherbar { color: #b8412f; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.06em; text-transform: uppercase; border: 1.5pt solid #b8412f; display: inline-block; padding: 2px 8px; margin: 4px 0 10px; }
|
||||
.nameline { margin: 10px 0 14px; }
|
||||
.instr { margin: 0 0 14px; }
|
||||
.case { border: 1pt solid #999; padding: 12px 14px; margin: 0 0 16px; background: #fafafa; }
|
||||
.q { margin: 0 0 16px; page-break-inside: avoid; }
|
||||
.qp { margin: 0 0 4px; }
|
||||
.pts { color: #666; font-size: 10pt; }
|
||||
.opts { margin-left: 22px; }
|
||||
.opts div { margin: 2px 0; }
|
||||
.writelines { border-bottom: 1pt solid #888; height: 22px; margin: 8px 0 0 22px; }
|
||||
.match td { vertical-align: top; padding-right: 36px; }
|
||||
.match div { margin: 3px 0; }
|
||||
.ans { color: #b8412f; font-weight: bold; }
|
||||
.key { border-left: 2.5pt solid #b8412f; background: #fdf1ee; color: #7c2c1e; padding: 7px 11px; margin: 7px 0 0 22px; font-size: 11pt; }
|
||||
.key div { margin: 2px 0; }
|
||||
.warnnote { color: #8a6414; }
|
||||
hr { border: 0; border-top: 1pt solid #ccc; margin: 14px 0; }
|
||||
.schoolhead { display: flex; align-items: center; gap: 14px; border-bottom: 2pt solid #1a1a1a; padding-bottom: 9px; margin-bottom: 14px; }
|
||||
.schoollogo { height: 46pt; max-width: 130pt; object-fit: contain; flex: none; }
|
||||
.schoolname { font-size: 14pt; font-weight: bold; letter-spacing: 0.02em; }
|
||||
.teachline { font-size: 10.5pt; color: #444; }
|
||||
.pagebreak { page-break-after: always; break-after: page; }
|
||||
@media print { body { padding: 0; } }
|
||||
`;
|
||||
|
||||
// opts: { teacher } for a single version, or { packet: true } for the student
|
||||
// version followed by the answer key in one document (page break between).
|
||||
// { word: true } switches the page-break markup to the form Word understands.
|
||||
export function buildHtml(assignment, opts) {
|
||||
const profile = opts.profile || {};
|
||||
const sep = opts.word
|
||||
? `<br clear="all" style="page-break-before:always">`
|
||||
: `<div class="pagebreak"></div>`;
|
||||
const body = opts.packet
|
||||
? buildBodyHtml(assignment, { teacher: false, profile }) + sep + buildBodyHtml(assignment, { teacher: true, profile })
|
||||
: buildBodyHtml(assignment, { teacher: opts.teacher, profile });
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(assignment.title)}</title>
|
||||
<style>${PRINT_STYLES}</style></head><body>
|
||||
${body}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function download(filename, blob) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800);
|
||||
}
|
||||
|
||||
function safeName(title, suffix, ext) {
|
||||
const base = String(title || "assignment").replace(/[^\w\- ]+/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "assignment";
|
||||
return `${base}-${suffix}.${ext}`;
|
||||
}
|
||||
|
||||
function variantSuffix(opts) {
|
||||
return opts.packet ? "packet" : opts.teacher ? "answer-key" : "student";
|
||||
}
|
||||
|
||||
export function exportTxt(assignment, opts) {
|
||||
const text = buildText(assignment, opts);
|
||||
download(safeName(assignment.title, variantSuffix(opts), "txt"), new Blob([text], { type: "text/plain;charset=utf-8" }));
|
||||
}
|
||||
|
||||
export function exportDoc(assignment, opts) {
|
||||
const html = buildHtml(assignment, { ...opts, word: true });
|
||||
download(
|
||||
safeName(assignment.title, variantSuffix(opts), "doc"),
|
||||
new Blob(["\ufeff" + html], { type: "application/msword" })
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportClipboard(assignment, opts) {
|
||||
await navigator.clipboard.writeText(buildText(assignment, opts));
|
||||
}
|
||||
|
||||
export function exportPrint(assignment, opts) {
|
||||
const html = buildHtml(assignment, opts);
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) throw new Error("Your browser blocked the print window. Allow pop-ups for this site and try again.");
|
||||
w.document.open();
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
setTimeout(() => w.print(), 400);
|
||||
}
|
||||
Reference in New Issue
Block a user