Sync Canvas LMS export feature + refreshed Settings screenshot

Brings the share build up to date with the main project: QTI .zip export
and optional direct Canvas API push, plus the Canvas settings card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:43:35 -04:00
co-authored by Claude Opus 4.8
parent 3c4b80a6d8
commit 4036ad5839
15 changed files with 1125 additions and 1 deletions
+86
View File
@@ -0,0 +1,86 @@
// lib/canvas/api.js — render the neutral Canvas IR (map.js) into Canvas *Classic Quizzes*
// REST payloads. Same IR the .zip path uses, so question mapping lives in exactly one place.
// Used by the server route app/api/canvas/route.js (browser→Canvas is blocked by CORS).
export function normalizeBaseUrl(url) {
let u = String(url || "").trim();
if (!u) return "";
if (!/^https?:\/\//i.test(u)) u = "https://" + u;
return u.replace(/\/+$/, "");
}
// Strip tags + decode the few entities we emit, for the plain answer_text fallback.
function stripTags(html) {
return String(html || "")
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"');
}
// IR.quiz -> body for POST /api/v1/courses/:id/quizzes
export function quizPayload(ir) {
const q = ir.quiz;
const quiz = {
title: q.title,
description: q.description || "",
quiz_type: q.quizType,
shuffle_answers: !!q.shuffleAnswers,
allowed_attempts: q.allowedAttempts,
scoring_policy: q.scoringPolicy,
show_correct_answers: q.showCorrectAnswers !== false,
one_question_at_a_time: !!q.oneQuestionAtATime,
cant_go_back: !!(q.oneQuestionAtATime && q.cantGoBack),
published: false, // import unpublished so the teacher reviews before students see it
};
if (q.timeLimit !== "" && q.timeLimit != null) quiz.time_limit = q.timeLimit;
if (q.accessCode) quiz.access_code = q.accessCode;
if (q.dueAt) quiz.due_at = q.dueAt;
if (q.unlockAt) quiz.unlock_at = q.unlockAt;
if (q.lockAt) quiz.lock_at = q.lockAt;
return { quiz };
}
// IR.items -> array of bodies for POST /api/v1/courses/:id/quizzes/:qid/questions
export function questionPayloads(ir) {
return ir.items.map((it, i) => ({ question: questionBody(it, i + 1) }));
}
function questionBody(it, position) {
const body = {
question_name: it.title || `Question ${position}`,
question_text: it.promptHtml || "",
question_type: it.canvasType,
points_possible: it.points || 0,
position,
};
if (it.feedback?.neutralHtml) body.neutral_comments_html = it.feedback.neutralHtml;
switch (it.canvasType) {
case "multiple_choice_question":
case "true_false_question":
body.answers = it.choices.map((c) => ({
answer_html: c.html, answer_text: stripTags(c.html), answer_weight: c.correct ? 100 : 0,
}));
break;
case "fill_in_multiple_blanks_question":
body.answers = it.blanks.flatMap((b) =>
b.answers.map((a) => ({ answer_text: a.textPlain, answer_weight: 100, blank_id: b.name })));
break;
case "matching_question": {
const rightText = (ident) => (it.rights.find((r) => r.ident === ident)?.textPlain) || "";
body.answers = it.lefts.map((l) => ({
answer_match_left: l.textPlain,
answer_match_right: rightText(l.correctRightIdent),
answer_weight: 100,
}));
const used = new Set(it.lefts.map((l) => l.correctRightIdent));
const distractors = it.rights.filter((r) => !used.has(r.ident)).map((r) => r.textPlain);
if (distractors.length) body.matching_answer_incorrect_matches = distractors.join("\n");
break;
}
case "essay_question":
case "text_only_question":
default:
body.answers = [];
}
return body;
}
+49
View File
@@ -0,0 +1,49 @@
// lib/canvas/export.js — assemble a Canvas QTI .zip from an assignment and download it.
// Client-side only (like lib/exporter.js): no data leaves the browser.
import { mapAssignmentToCanvas } from "./map";
import { renderAssessmentXml } from "./qti";
import { renderManifest, renderAssessmentMeta } from "./manifest";
import { storeZip } from "./zip";
// A QTI identifier must be a valid NCName-ish token (starts with a letter, no spaces).
function slugIdent(seed) {
const clean = String(seed || "").replace(/[^a-zA-Z0-9]/g, "");
return "g" + (clean || "quiz");
}
function safeName(title) {
const base = String(title || "quiz")
.replace(/[^\w\- ]+/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "quiz";
return `${base}-canvas.zip`;
}
// Build the package in memory. Returns { bytes: Uint8Array, ir, files } — pure, testable.
export function buildCanvasPackage(assignment, opts = {}) {
const ir = mapAssignmentToCanvas(assignment, opts);
const assessmentIdent = slugIdent(assignment?.id || assignment?.title);
const manifestIdent = "m" + assessmentIdent;
const files = [
{ name: "imsmanifest.xml", data: renderManifest(assessmentIdent, manifestIdent) },
{ name: `${assessmentIdent}/${assessmentIdent}.xml`, data: renderAssessmentXml(ir, assessmentIdent) },
{ name: `${assessmentIdent}/assessment_meta.xml`, data: renderAssessmentMeta(ir, assessmentIdent) },
];
return { bytes: storeZip(files), ir, files };
}
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);
}
// Build and trigger the browser download. Returns the IR (so callers can show a summary).
export function exportCanvasZip(assignment, opts = {}) {
const { bytes, ir } = buildCanvasPackage(assignment, opts);
// Copy into a fresh ArrayBuffer so Blob gets a clean, correctly-sized buffer.
const blob = new Blob([bytes.slice()], { type: "application/zip" });
download(safeName(assignment?.title), blob);
return ir;
}
+55
View File
@@ -0,0 +1,55 @@
// lib/canvas/manifest.js — the two wrapper files that make a folder of QTI XML into a
// Canvas-importable package: imsmanifest.xml (IMS Content Packaging) and the Canvas
// proprietary assessment_meta.xml (quiz-level settings).
import { xmlEsc } from "./qti";
// Canvas quiz-level settings. `title` is the only thing Canvas truly needs; everything
// else has a sensible Canvas default if omitted. We emit the fields the user can set.
export function renderAssessmentMeta(ir, assessmentIdent) {
const q = ir.quiz;
const lines = [];
const tag = (name, val) => lines.push(`<${name}>${xmlEsc(String(val))}</${name}>`);
const bool = (name, val) => tag(name, val ? "true" : "false");
const opt = (name, val) => { if (val !== "" && val != null) tag(name, val); };
tag("title", q.title);
tag("description", q.description || "");
tag("quiz_type", q.quizType);
tag("points_possible", q.pointsPossible);
opt("time_limit", q.timeLimit);
tag("allowed_attempts", q.allowedAttempts);
tag("scoring_policy", q.scoringPolicy);
bool("shuffle_answers", q.shuffleAnswers);
bool("show_correct_answers", q.showCorrectAnswers);
bool("one_question_at_a_time", q.oneQuestionAtATime);
bool("cant_go_back", q.cantGoBack);
opt("access_code", q.accessCode);
opt("due_at", q.dueAt);
opt("unlock_at", q.unlockAt);
opt("lock_at", q.lockAt);
return `<?xml version="1.0" encoding="UTF-8"?>
<quiz identifier="${xmlEsc(assessmentIdent)}" xmlns="http://canvas.instructure.com/xsd/cccv1p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd">
${lines.join("\n")}
</quiz>`;
}
export function renderManifest(assessmentIdent, manifestIdent) {
const assessmentHref = `${assessmentIdent}/${assessmentIdent}.xml`;
const metaHref = `${assessmentIdent}/assessment_meta.xml`;
const depIdent = `${assessmentIdent}_dependency`;
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="${xmlEsc(manifestIdent)}" xmlns="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1" xmlns:lom="http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd">
<metadata><schema>IMS Content</schema><schemaversion>1.1.3</schemaversion></metadata>
<organizations/>
<resources>
<resource identifier="${xmlEsc(assessmentIdent)}" type="imsqti_xmlv1p2/imscc_xmlv1p1/assessment" href="${xmlEsc(assessmentHref)}">
<file href="${xmlEsc(assessmentHref)}"/>
<dependency identifierref="${xmlEsc(depIdent)}"/>
</resource>
<resource identifier="${xmlEsc(depIdent)}" type="associatedcontent/imscc_xmlv1p1/learning-application-resource" href="${xmlEsc(metaHref)}">
<file href="${xmlEsc(metaHref)}"/>
</resource>
</resources>
</manifest>`;
}
+189
View File
@@ -0,0 +1,189 @@
// lib/canvas/map.js — turn one assignment into a neutral "Canvas IR" that both the
// QTI .zip renderer (qti.js) and the future REST push (api.js) consume. This is where
// every app question type is mapped to a Canvas question type, exactly once.
//
// Mapping decisions (see project memory canvas-export-feature):
// multiple_choice -> multiple_choice_question
// true_false -> true_false_question
// essay -> essay_question (manual; rubric -> neutral feedback)
// short_answer -> essay_question (open-ended; sample/key points -> feedback)
// fill_blank -> fill_in_multiple_blanks_question (______ becomes [blankN] tokens)
// matching -> matching_question
// discussion -> skipped (no Canvas quiz equivalent)
// caseStudy -> text_only_question (intro passage at the top)
const plain = (s) => String(s ?? "").trim();
// Escape text so that, after Canvas decodes the XML layer, the result is valid HTML.
const escHtmlText = (s) =>
String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Plain text -> HTML with paragraphs and line breaks (content escaped, tags raw).
function paragraphsToHtml(text) {
const t = plain(text);
if (!t) return "";
return t
.split(/\n{2,}/)
.map((para) => "<p>" + para.split(/\n/).map(escHtmlText).join("<br>") + "</p>")
.join("");
}
function shortAnswerGuide(q) {
const out = [];
if (plain(q.sampleAnswer)) out.push("Sample answer: " + plain(q.sampleAnswer));
const kp = (q.keyPoints || []).map(plain).filter(Boolean);
if (kp.length) out.push("Must include: " + kp.join("; "));
return out.join("\n");
}
function essayGuide(q) {
const out = [];
if (plain(q.sampleResponse)) out.push("Sample response: " + plain(q.sampleResponse));
const rubric = (q.rubric || []).filter((r) => plain(r?.criterion));
if (rubric.length) {
out.push("Rubric:");
for (const r of rubric) {
out.push(`${plain(r.criterion)} (${Number(r.points) || 0} pts)` +
(plain(r.description) ? " — " + plain(r.description) : ""));
}
}
return out.join("\n");
}
// Convert the app's ______ blanks into Canvas named tokens [blank1], [blank2], ...
function buildBlanks(q, nextId) {
let n = 0;
const text = String(q.question || "").replace(/_{2,}/g, () => `[blank${++n}]`);
if (n === 0) return null;
const answers = q.answers || [];
const list = [];
for (let i = 0; i < n; i++) {
const ans = plain(answers[i]);
list.push({
name: `blank${i + 1}`,
respIdent: `response_blank${i + 1}`,
answers: [{ ident: nextId(), textPlain: ans || "" }],
});
}
return { list, promptHtml: paragraphsToHtml(text) };
}
function buildMatching(q, nextId) {
const pairs = (q.pairs || []).filter((p) => plain(p?.left) && plain(p?.right));
if (pairs.length < 2) return null;
const rights = [];
const identByText = new Map();
for (const p of pairs) {
const text = plain(p.right);
if (!identByText.has(text)) {
const ident = nextId();
identByText.set(text, ident);
rights.push({ ident, textPlain: text });
}
}
const lefts = pairs.map((p, i) => ({
respIdent: `question_${i + 1}`,
textPlain: plain(p.left),
correctRightIdent: identByText.get(plain(p.right)),
}));
return { lefts, rights };
}
export function mapAssignmentToCanvas(assignment, opts = {}) {
const includeExplanations = opts.includeExplanations !== false;
let counter = 1000;
const nextId = () => String(++counter);
const items = [];
const skipped = [];
const caseStudy = plain(assignment?.caseStudy);
if (caseStudy) {
items.push({
ident: "intro_passage",
title: "Read the following",
canvasType: "text_only_question",
points: 0,
promptHtml: paragraphsToHtml(caseStudy),
});
}
(assignment?.questions || []).forEach((q, i) => {
const ident = "q_" + (i + 1);
const points = Number(q.points) || 0;
const neutral = includeExplanations ? plain(q.explanation) : "";
const neutralHtml = (extra) => {
const merged = [neutral, extra].filter(Boolean).join("\n\n");
return merged ? paragraphsToHtml(merged) : "";
};
const title = `Question ${i + 1}`;
switch (q.type) {
case "multiple_choice": {
const choices = (q.options || []).map((opt, j) => ({
ident: nextId(), html: escHtmlText(plain(opt)), correct: j === q.correctIndex,
}));
if (choices.length < 2) { skipped.push({ type: q.type, question: q.question, reason: "needs 2+ options" }); break; }
items.push({ ident, title, canvasType: "multiple_choice_question", points,
promptHtml: paragraphsToHtml(q.question), choices, feedback: { neutralHtml: neutralHtml() } });
break;
}
case "true_false": {
items.push({ ident, title, canvasType: "true_false_question", points,
promptHtml: paragraphsToHtml(q.question),
choices: [
{ ident: nextId(), html: "True", correct: q.correctAnswer === true },
{ ident: nextId(), html: "False", correct: q.correctAnswer !== true },
],
feedback: { neutralHtml: neutralHtml() } });
break;
}
case "essay":
case "short_answer": {
const guide = q.type === "short_answer" ? shortAnswerGuide(q) : essayGuide(q);
items.push({ ident, title, canvasType: "essay_question", points,
promptHtml: paragraphsToHtml(q.question), feedback: { neutralHtml: neutralHtml(guide) } });
break;
}
case "fill_blank": {
const b = buildBlanks(q, nextId);
if (!b) { skipped.push({ type: q.type, question: q.question, reason: "no blanks found" }); break; }
items.push({ ident, title, canvasType: "fill_in_multiple_blanks_question", points,
promptHtml: b.promptHtml, blanks: b.list, feedback: { neutralHtml: neutralHtml() } });
break;
}
case "matching": {
const m = buildMatching(q, nextId);
if (!m) { skipped.push({ type: q.type, question: q.question, reason: "needs 2+ pairs" }); break; }
items.push({ ident, title, canvasType: "matching_question", points,
promptHtml: paragraphsToHtml(q.question || "Match each item on the left with the correct item on the right."),
lefts: m.lefts, rights: m.rights, feedback: { neutralHtml: neutralHtml() } });
break;
}
default:
skipped.push({ type: q.type, question: q.question, reason: "not supported in Canvas quizzes" });
}
});
const gradedCount = items.filter((it) => it.canvasType !== "text_only_question").length;
const quiz = {
title: plain(assignment?.title) || "Untitled quiz",
description: paragraphsToHtml(plain(assignment?.instructions)),
pointsPossible: items.reduce((s, it) => s + (it.points || 0), 0),
quizType: opts.quizType || "assignment",
timeLimit: opts.timeLimit ?? "", // minutes; "" = no limit
allowedAttempts: opts.allowedAttempts ?? 1, // -1 = unlimited
scoringPolicy: opts.scoringPolicy || "keep_highest",
shuffleAnswers: !!opts.shuffleAnswers,
showCorrectAnswers: opts.showCorrectAnswers !== false,
oneQuestionAtATime: !!opts.oneQuestionAtATime,
cantGoBack: !!opts.cantGoBack,
dueAt: opts.dueAt || "",
unlockAt: opts.unlockAt || "",
lockAt: opts.lockAt || "",
accessCode: opts.accessCode || "",
};
return { quiz, items, skipped, gradedCount };
}
+139
View File
@@ -0,0 +1,139 @@
// lib/canvas/qti.js — render the neutral Canvas IR (map.js) into a Canvas QTI 1.2
// assessment XML document. Structure mirrors Canvas's own QTI export / parser fixtures
// (instructure/qti) so the package imports cleanly into Classic Quizzes (and, via the
// "import as New Quizzes" checkbox, New Quizzes too).
const xmlEsc = (s) =>
String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const matHtml = (html) =>
`<material><mattext texttype="text/html">${xmlEsc(html)}</mattext></material>`;
const matPlain = (text) =>
`<material><mattext texttype="text/plain">${xmlEsc(text)}</mattext></material>`;
const OUTCOMES =
`<outcomes><decvar maxvalue="100" minvalue="0" varname="SCORE" vartype="Decimal"/></outcomes>`;
const metaField = (label, entry) =>
`<qtimetadatafield><fieldlabel>${label}</fieldlabel><fieldentry>${xmlEsc(entry)}</fieldentry></qtimetadatafield>`;
// Even split of 100 points across n blanks/pairs, last absorbs the rounding remainder.
function addValues(n) {
const base = Math.floor((10000 / n)) / 100;
const vals = Array(n).fill(base);
vals[n - 1] = Math.round((100 - base * (n - 1)) * 100) / 100;
return vals.map((v) => v.toFixed(2));
}
function generalFeedback(it) {
const html = it.feedback?.neutralHtml;
if (!html) return { block: "", cond: "" };
return {
block: `<itemfeedback ident="general_fb"><flow_mat>${matHtml(html)}</flow_mat></itemfeedback>`,
cond: `<respcondition continue="Yes"><conditionvar><other/></conditionvar>` +
`<displayfeedback feedbacktype="Response" linkrefid="general_fb"/></respcondition>`,
};
}
function wrapItem(it, presentationInner, conditions, extraMeta = "") {
const fb = generalFeedback(it);
const meta =
metaField("question_type", it.canvasType) +
metaField("points_possible", String(it.points ?? 0)) +
extraMeta;
return `<item ident="${xmlEsc(it.ident)}" title="${xmlEsc(it.title || "Question")}">
<itemmetadata><qtimetadata>${meta}</qtimetadata></itemmetadata>
<presentation>${matHtml(it.promptHtml || "")}${presentationInner}</presentation>
<resprocessing>${OUTCOMES}${conditions}${fb.cond}</resprocessing>${fb.block}</item>`;
}
function renderChoice(it, cardinality) {
const labels = it.choices
.map((c) => `<response_label ident="${c.ident}">${matHtml(c.html)}</response_label>`)
.join("");
const present =
`<response_lid ident="response1" rcardinality="${cardinality}"><render_choice>${labels}</render_choice></response_lid>`;
const correct = it.choices.filter((c) => c.correct);
const pick = (correct[0] || it.choices[0]);
const cond =
`<respcondition continue="No"><conditionvar><varequal respident="response1">${pick.ident}</varequal></conditionvar>` +
`<setvar action="Set" varname="SCORE">100</setvar></respcondition>`;
const extraMeta = metaField("original_answer_ids", it.choices.map((c) => c.ident).join(","));
return wrapItem(it, present, cond, extraMeta);
}
function renderEssay(it) {
const present =
`<response_str ident="response1" rcardinality="Single"><render_fib><response_label ident="answer1" rshuffle="No"/></render_fib></response_str>`;
const cond = `<respcondition continue="No"><conditionvar><other/></conditionvar></respcondition>`;
return wrapItem(it, present, cond);
}
function renderFillBlanks(it) {
const present = it.blanks.map((b) => {
const labels = b.answers
.map((a) => `<response_label ident="${a.ident}">${matPlain(a.textPlain)}</response_label>`)
.join("");
return `<response_lid ident="${b.respIdent}">${matPlain(b.name)}<render_choice>${labels}</render_choice></response_lid>`;
}).join("");
const add = addValues(it.blanks.length);
const conds = it.blanks.map((b, i) =>
`<respcondition><conditionvar><varequal respident="${b.respIdent}">${b.answers[0].ident}</varequal></conditionvar>` +
`<setvar varname="SCORE" action="Add">${add[i]}</setvar></respcondition>`
).join("");
return wrapItem(it, present, conds);
}
function renderMatching(it) {
const rights = it.rights
.map((r) => `<response_label ident="${r.ident}">${matPlain(r.textPlain)}</response_label>`)
.join("");
const present = it.lefts.map((l) =>
`<response_lid ident="${l.respIdent}" rcardinality="Single">${matPlain(l.textPlain)}` +
`<render_choice>${rights}</render_choice></response_lid>`
).join("");
const add = addValues(it.lefts.length);
const conds = it.lefts.map((l, i) =>
`<respcondition><conditionvar><varequal respident="${l.respIdent}">${l.correctRightIdent}</varequal></conditionvar>` +
`<setvar varname="SCORE" action="Add">${add[i]}</setvar></respcondition>`
).join("");
return wrapItem(it, present, conds);
}
function renderTextOnly(it) {
const meta = metaField("question_type", "text_only_question") + metaField("points_possible", "0");
return `<item ident="${xmlEsc(it.ident)}" title="${xmlEsc(it.title || "Text")}">
<itemmetadata><qtimetadata>${meta}</qtimetadata></itemmetadata>
<presentation>${matHtml(it.promptHtml || "")}</presentation>
<resprocessing>${OUTCOMES}<respcondition continue="No"><conditionvar><other/></conditionvar></respcondition></resprocessing></item>`;
}
function renderItem(it) {
switch (it.canvasType) {
case "multiple_choice_question": return renderChoice(it, "Single");
case "true_false_question": return renderChoice(it, "Single");
case "essay_question": return renderEssay(it);
case "fill_in_multiple_blanks_question": return renderFillBlanks(it);
case "matching_question": return renderMatching(it);
case "text_only_question": return renderTextOnly(it);
default: return "";
}
}
export function renderAssessmentXml(ir, assessmentIdent) {
const items = ir.items.map(renderItem).filter(Boolean).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/ims_qtiasiv1p2 http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd">
<assessment ident="${xmlEsc(assessmentIdent)}" title="${xmlEsc(ir.quiz.title)}">
<qtimetadata>
<qtimetadatafield><fieldlabel>cc_maxattempts</fieldlabel><fieldentry>1</fieldentry></qtimetadatafield>
</qtimetadata>
<section ident="root_section">
${items}
</section>
</assessment>
</questestinterop>`;
}
export { xmlEsc };
+99
View File
@@ -0,0 +1,99 @@
// lib/canvas/zip.js — minimal, zero-dependency ZIP writer (STORE method, no compression).
// Canvas accepts uncompressed QTI packages, so we skip DEFLATE entirely and avoid any
// dependency. Output is a standard .zip as a Uint8Array. Deterministic (fixed timestamps).
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
// CRC-32 (IEEE 802.3). crc32 of "123456789" === 0xCBF43926 (unit-test anchor).
export function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
const enc = new TextEncoder();
const toBytes = (data) => (typeof data === "string" ? enc.encode(data) : data);
// files: [{ name: string, data: string|Uint8Array }] -> Uint8Array (.zip bytes)
export function storeZip(files) {
const DOS_TIME = 0;
const DOS_DATE = 0x0021; // 1980-01-01, deterministic
const chunks = [];
const central = [];
let offset = 0;
for (const f of files) {
const nameBytes = enc.encode(f.name);
const data = toBytes(f.data);
const crc = crc32(data);
const local = new Uint8Array(30 + nameBytes.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true); // local file header signature
lv.setUint16(4, 20, true); // version needed to extract
lv.setUint16(6, 0x0800, true); // flags: bit 11 = UTF-8 filenames
lv.setUint16(8, 0, true); // compression method 0 = store
lv.setUint16(10, DOS_TIME, true);
lv.setUint16(12, DOS_DATE, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true); // compressed size (== uncompressed for store)
lv.setUint32(22, data.length, true); // uncompressed size
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true); // extra field length
local.set(nameBytes, 30);
chunks.push(local, data);
const cd = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(cd.buffer);
cv.setUint32(0, 0x02014b50, true); // central directory header signature
cv.setUint16(4, 20, true); // version made by
cv.setUint16(6, 20, true); // version needed
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true); // method
cv.setUint16(12, DOS_TIME, true);
cv.setUint16(14, DOS_DATE, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint16(30, 0, true); // extra length
cv.setUint16(32, 0, true); // comment length
cv.setUint16(34, 0, true); // disk number start
cv.setUint16(36, 0, true); // internal attributes
cv.setUint32(38, 0, true); // external attributes
cv.setUint32(42, offset, true); // relative offset of local header
cd.set(nameBytes, 46);
central.push(cd);
offset += local.length + data.length;
}
const centralStart = offset;
let centralSize = 0;
for (const cd of central) { chunks.push(cd); centralSize += cd.length; }
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true); // end of central directory signature
ev.setUint16(8, central.length, true); // entries on this disk
ev.setUint16(10, central.length, true); // total entries
ev.setUint32(12, centralSize, true);
ev.setUint32(16, centralStart, true);
ev.setUint16(20, 0, true); // comment length
chunks.push(eocd);
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let p = 0;
for (const c of chunks) { out.set(c, p); p += c.length; }
return out;
}