// 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, ">");
}
function schoolHeadHtml(profile = {}) {
const teachLine = [profile.teacherName, profile.className].filter(Boolean).join(" — ");
if (!profile.logo && !profile.schoolName && !teachLine) return "";
return `
${profile.logo ? `

` : ""}
${profile.schoolName ? `
${esc(profile.schoolName)}
` : ""}
${teachLine ? `
${esc(teachLine)}
` : ""}
`;
}
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 = `` + (q.options || []).map((o, j) => {
const isAns = teacher && j === q.correctIndex;
return `
${LETTERS[j]}. ${esc(o)}${isAns ? " ✓" : ""}
`;
}).join("") + `
`;
} else if (q.type === "true_false") {
body = `True / False${teacher ? ` ✓ ${q.correctAnswer ? "True" : "False"}` : ""}
`;
} else if (q.type === "short_answer") {
body = teacher ? "" : ``;
} 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 = `| ` +
(q.pairs || []).map((p, j) => ` ___ ${j + 1}. ${esc(p.left)} `).join("") +
` | ` + rights.map((r, j) => ` ${LETTERS[j]}. ${esc(r)} `).join("") +
` |
`;
}
let key = "";
if (teacher) {
const ans = answersLine(q);
key = `` +
(ans ? `
${esc(ans).replace(/\n/g, "
")}
` : "") +
(q.explanation ? `
Explanation: ${esc(q.explanation)}
` : "") +
(q.sourceRef ? `
Source: \u201C${esc(q.sourceRef)}\u201D
` : "") +
(q.verification?.status === "warn" ? `
⚠ Reviewer note: ${esc(q.verification.note || "flagged")}
` : "") +
`
`;
}
const prompt = q.type === "matching" ? (q.question || "Match each item on the left with the correct item on the right.") : q.question;
return `${n}. ${esc(prompt)} (${q.points} pt${q.points === 1 ? "" : "s"})
${body}${key}
`;
}).join("");
return `${schoolHeadHtml(profile)}${esc(assignment.title)}
${esc([assignment.subject, assignment.gradeLevel].filter(Boolean).join(" · "))} · ${totalPoints(qs)} points
${teacher ? `Teacher version — answer key
` : `Name: ____________________________________ Date: ________________
`}
${assignment.instructions ? `Instructions: ${esc(assignment.instructions)}
` : ""}
${assignment.caseStudy ? `${esc(assignment.caseStudy).replace(/\n/g, "
")}
` : ""}
${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
? `
`
: ``;
const body = opts.packet
? buildBodyHtml(assignment, { teacher: false, profile }) + sep + buildBodyHtml(assignment, { teacher: true, profile })
: buildBodyHtml(assignment, { teacher: opts.teacher, profile });
return `${esc(assignment.title)}
${body}
`;
}
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);
}