// 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); }