bizzle 5a51a0f112 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>
2026-06-21 19:58:36 -04:00

242 lines
12 KiB
JavaScript

// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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&nbsp;&nbsp;/&nbsp;&nbsp;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: ____________________________________&nbsp;&nbsp;&nbsp;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);
}