diff --git a/package-lock.json b/package-lock.json
index 7d70a31..f517cbb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"license": "ISC",
"dependencies": {
"express": "^5.1.0",
- "mammoth": "^1.12.0"
+ "mammoth": "^1.12.0",
+ "multer": "^2.2.0"
},
"engines": {
"node": ">=24"
@@ -38,6 +39,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -110,6 +117,23 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -148,6 +172,35 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/concat-stream/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -659,6 +712,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/multer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
+ "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/multer/node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -991,6 +1106,14 @@
"node": ">= 0.8"
}
},
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -1040,6 +1163,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
diff --git a/package.json b/package.json
index 2636bc7..f1dcb0d 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"license": "ISC",
"dependencies": {
"express": "^5.1.0",
- "mammoth": "^1.12.0"
+ "mammoth": "^1.12.0",
+ "multer": "^2.2.0"
},
"engines": {
"node": ">=24"
diff --git a/public/admin.html b/public/admin.html
index d0408e1..713b18b 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -77,6 +77,47 @@
Add course
+
+
Import course
+
+
+
+ π From JSON
+ π From Word docs
+
+
+
+
+
Import a .json bundle previously exported from this app.
+
+
+ π Choose fileβ¦
+
+ π₯ Import
+
+
+
+
+
+
Select all unit .docx files at once β one file per unit, sorted by filename.
+
+
+
+ π Choose .docx filesβ¦
+
+ π₯ Import
+
+
+
+
+
@@ -95,6 +136,7 @@
Archived
Save
Duplicate course
+ β¬ Export course
Delete course
diff --git a/public/admin.js b/public/admin.js
index cc38865..e68d575 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -206,6 +206,110 @@ $('#add-course-form').addEventListener('submit', async (e) => {
showPanel('course-detail');
});
+// ---------- import course β tab switching ----------
+function setImportTab(tab) {
+ $('#import-tab-json').hidden = tab !== 'json';
+ $('#import-tab-docx').hidden = tab !== 'docx';
+ document.querySelectorAll('.import-tab-btn').forEach((b) => {
+ b.classList.toggle('secondary', b.dataset.tab !== tab);
+ });
+}
+setImportTab('json');
+document.querySelectorAll('.import-tab-btn').forEach((b) =>
+ b.addEventListener('click', () => setImportTab(b.dataset.tab))
+);
+
+// ---------- import course β JSON ----------
+$('#import-course-browse').addEventListener('click', () => $('#import-course-file').click());
+$('#import-course-file').addEventListener('change', () => {
+ const f = $('#import-course-file').files[0];
+ $('#import-course-filename').textContent = f ? f.name : '';
+ $('#import-course-go').disabled = !f;
+ $('#import-course-status').textContent = '';
+});
+$('#import-course-go').addEventListener('click', async () => {
+ const f = $('#import-course-file').files[0];
+ if (!f) return;
+ $('#import-course-status').textContent = 'Importingβ¦';
+ let bundle;
+ try {
+ bundle = JSON.parse(await f.text());
+ } catch {
+ $('#import-course-status').textContent = 'Could not parse JSON file.';
+ return;
+ }
+ let res;
+ try {
+ res = await api('/api/admin/import-course', { method: 'POST', body: bundle });
+ } catch (err) {
+ $('#import-course-status').textContent = `Error: ${err.message}`;
+ return;
+ }
+ $('#import-course-file').value = '';
+ $('#import-course-filename').textContent = '';
+ $('#import-course-go').disabled = true;
+ $('#import-course-status').textContent = '';
+ currentCourseId = res.id;
+ msg('Course imported.');
+ showPanel('course-detail');
+});
+
+// ---------- import course β Word docx ----------
+$('#import-docx-browse').addEventListener('click', () => $('#import-docx-files').click());
+$('#import-docx-files').addEventListener('change', () => {
+ const files = [...$('#import-docx-files').files];
+ if (files.length) {
+ $('#import-docx-filenames').textContent =
+ files.length === 1 ? files[0].name : `${files.length} files selected`;
+ // Pre-fill course name from common filename stem if name field is empty
+ if (!$('#import-docx-name').value.trim() && files.length > 1) {
+ // e.g. "Unit_A_Kitchen_Safety.docx" β try to find a shared prefix word
+ }
+ } else {
+ $('#import-docx-filenames').textContent = '';
+ }
+ $('#import-docx-go').disabled = files.length === 0;
+ $('#import-course-status').textContent = '';
+});
+$('#import-docx-go').addEventListener('click', async () => {
+ const name = $('#import-docx-name').value.trim();
+ if (!name) { $('#import-course-status').textContent = 'Enter a course name first.'; return; }
+ const files = [...$('#import-docx-files').files];
+ if (!files.length) return;
+
+ $('#import-course-status').textContent = `Parsing ${files.length} file${files.length > 1 ? 's' : ''}β¦`;
+
+ const fd = new FormData();
+ fd.append('name', name);
+ fd.append('subject', $('#import-docx-subject').value.trim());
+ fd.append('color', $('#import-docx-color').value);
+ files.forEach((f) => fd.append('files', f));
+
+ let data;
+ try {
+ const res = await fetch('/api/admin/import-course-docx', {
+ method: 'POST',
+ headers: { 'x-pin': parentPin },
+ body: fd,
+ });
+ data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Import failed');
+ } catch (err) {
+ $('#import-course-status').textContent = `Error: ${err.message}`;
+ return;
+ }
+
+ $('#import-docx-files').value = '';
+ $('#import-docx-filenames').textContent = '';
+ $('#import-docx-go').disabled = true;
+ $('#import-docx-name').value = '';
+ $('#import-docx-subject').value = '';
+ $('#import-course-status').textContent = '';
+ currentCourseId = data.id;
+ msg('Course imported from Word documents.');
+ showPanel('course-detail');
+});
+
async function openCourseDetail(id) {
if (!id) return showPanel('courses');
@@ -335,6 +439,24 @@ $('#cd-duplicate').addEventListener('click', async () => {
msg('Course duplicated β update the name and details below.');
});
+$('#cd-export').addEventListener('click', async () => {
+ const res = await fetch(`/api/admin/courses/${currentCourseId}/export`, {
+ headers: { 'x-pin': parentPin },
+ });
+ if (!res.ok) { msg('Export failed.'); return; }
+ const blob = await res.blob();
+ const disposition = res.headers.get('content-disposition') || '';
+ const nameMatch = disposition.match(/filename="([^"]+)"/);
+ const filename = nameMatch ? nameMatch[1] : 'course.json';
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ msg('Course exported.');
+});
+
$('#cd-delete').addEventListener('click', async () => {
if (!confirm('Delete this whole course, including all units, items, and grades?')) return;
await api(`/api/courses/${currentCourseId}`, { method: 'DELETE' });
diff --git a/scripts/import-kitchen-course.js b/scripts/import-kitchen-course.js
new file mode 100644
index 0000000..c45515d
--- /dev/null
+++ b/scripts/import-kitchen-course.js
@@ -0,0 +1,223 @@
+#!/usr/bin/env node
+// Converts "Kitchen, Tools & Electrical Skills" docx files into a homeschool-lms-course JSON bundle.
+// Usage: node scripts/import-kitchen-course.js > kitchen-course.json
+// Then import the JSON via the admin "Import course" UI.
+
+import { execFileSync } from 'node:child_process';
+import { join } from 'node:path';
+
+const BASE = "/Users/nateemmert/Library/CloudStorage/SeaDrive-NateEmmert(seafile.necloud.us)/My Libraries/Homeschool/Kitchen, Tools, & Electrical Skills";
+
+const UNIT_FILES = [
+ { letter: 'A', file: 'Unit_A_Kitchen_Safety_Foundations.docx', title: 'Kitchen Safety & Foundations' },
+ { letter: 'B', file: 'Unit_B_Baking_Fundamentals.docx', title: 'Baking Fundamentals' },
+ { letter: 'C', file: 'Unit_C_Everyday_Cooking_Skills.docx', title: 'Everyday Cooking Skills' },
+ { letter: 'D', file: 'Unit_D_Cooking_Baking_Capstone.docx', title: 'Cooking & Baking Capstone' },
+ { letter: 'E', file: 'Unit_E_Shop_Safety_Hand_Tools.docx', title: 'Shop Safety & Hand Tools' },
+ { letter: 'F', file: 'Unit_F_Power_Tools_Framing.docx', title: 'Power Tools & Framing Basics' },
+ { letter: 'G', file: 'Unit_G_Electrical_Fundamentals.docx', title: 'Electrical Fundamentals' },
+ { letter: 'H', file: 'Unit_H_Wired_Wall_Capstone.docx', title: 'Wired Wall Capstone' },
+];
+
+function toMd(filePath) {
+ return execFileSync('pandoc', ['-t', 'markdown', filePath], { encoding: 'utf8' });
+}
+
+function clean(s) {
+ return s
+ .replace(/\n\s{4,}/g, ' ') // unwrap indented continuation lines
+ .replace(/\\'/g, "'")
+ .replace(/\\"/, '"')
+ .replace(/---/g, 'β')
+ .replace(/--/g, 'β')
+ .replace(/\\_/g, '_')
+ .replace(/\\-/g, '-')
+ .replace(/\\\./g, '.')
+ .replace(/\\\[/g, '[')
+ .replace(/\\\]/g, ']')
+ .replace(/\\\*/g, '*')
+ .replace(/\\!/g, '!')
+ .replace(/\\#/g, '#')
+ .replace(/\s{2,}/g, ' ')
+ .trim();
+}
+
+function stripBold(s) {
+ return s.replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').trim();
+}
+
+// Returns the text inside **...** if the entire block is a bold paragraph, else null.
+function getBoldContent(block) {
+ const m = block.match(/^\*\*(.+)\*\*$/s);
+ return m ? m[1].trim() : null;
+}
+
+const SECTION_LABELS = {
+ 'Objective': 'OBJECTIVE',
+ 'Materials Needed': 'MATERIALS NEEDED',
+ 'Lesson & Activity Steps': 'LESSON STEPS',
+ 'Safety Notes': 'SAFETY NOTES',
+ 'Wrap-Up Discussion': 'WRAP-UP DISCUSSION',
+};
+
+function formatBodyBlock(block) {
+ // Bold heading β section label
+ const bold = getBoldContent(block);
+ if (bold) {
+ const text = clean(stripBold(bold));
+ return SECTION_LABELS[text] ? `\n${SECTION_LABELS[text]}` : null;
+ }
+
+ // Bullet: "- text"
+ if (/^-\s+/.test(block)) {
+ return 'β’ ' + clean(block.replace(/^-\s+/, ''));
+ }
+
+ // Numbered list item: "1. text..."
+ const numMatch = block.match(/^(\d+)\.\s{1,2}(.+)$/s);
+ if (numMatch) {
+ return `${numMatch[1]}. ${clean(numMatch[2])}`;
+ }
+
+ // Regular paragraph
+ return clean(block.replace(/\n/g, ' '));
+}
+
+function parseUnit(letter, md) {
+ const blocks = md.split(/\n\n+/).map(b => b.trim()).filter(b => b.length > 0);
+
+ const weeks = [];
+ let current = null;
+ let inQuiz = false;
+ let inAnswers = false;
+ const quizQuestions = [];
+ const quizAnswers = [];
+
+ for (const block of blocks) {
+ const bold = getBoldContent(block);
+
+ // Week heading: **Week N: Title**
+ if (bold) {
+ const weekMatch = bold.match(/^Week (\d+):\s*(.+)$/i);
+ if (weekMatch) {
+ if (current) weeks.push(current);
+ inQuiz = false;
+ inAnswers = false;
+ current = { title: `Week ${weekMatch[1]}: ${clean(stripBold(weekMatch[2]))}`, bodyBlocks: [] };
+ continue;
+ }
+
+ // Worksheet heading: skip it and its table content
+ if (/^Week \d+ Worksheet:/i.test(bold)) { current = current ? { ...current, skipTable: true } : null; continue; }
+
+ // Quiz heading
+ if (/^Unit [A-H] Quiz:/i.test(bold)) {
+ if (current) { weeks.push(current); current = null; }
+ inQuiz = true; inAnswers = false;
+ continue;
+ }
+
+ // Answer key
+ if (/^Answer Key$/i.test(bold)) { inAnswers = true; inQuiz = false; continue; }
+ }
+
+ // Skip table rows (worksheet content)
+ if (block.startsWith('+') || block.startsWith('|')) continue;
+
+ // Skip horizontal rules
+ if (/^[\\-]{10,}$/.test(block)) continue;
+
+ // Skip "Name: ___ Date: ___" lines
+ if (/^Name:\s*\\?_/.test(block)) continue;
+
+ if (inAnswers) {
+ const m = block.match(/^(\d+)[\\.]+\s+(.+)$/s);
+ if (m) quizAnswers.push(clean(m[2]));
+ continue;
+ }
+
+ if (inQuiz) {
+ const m = block.match(/^(\d+)[\\.]+\s+(.+)$/s);
+ if (m) quizQuestions.push(clean(m[2]));
+ continue;
+ }
+
+ // Pre-week boilerplate (current === null) β skip
+ if (!current) continue;
+
+ // Skip worksheet heading we already consumed; reset flag after the table clears
+ if (current.skipTable) {
+ if (block.startsWith('+') || block.startsWith('|')) continue;
+ current.skipTable = false;
+ continue;
+ }
+
+ current.bodyBlocks.push(block);
+ }
+ if (current) weeks.push(current);
+
+ // Build lesson items
+ const items = weeks.map((w, i) => {
+ const lines = w.bodyBlocks.map(formatBodyBlock).filter(Boolean);
+ return {
+ type: 'lesson',
+ title: w.title,
+ body: lines.join('\n'),
+ points: 0,
+ sort: i,
+ allow_retakes: 0,
+ evidence_mode: 'none',
+ retake_policy: 'latest',
+ };
+ });
+
+ // Build quiz item
+ if (quizQuestions.length) {
+ const questions = quizQuestions.map((q, i) => ({
+ type: /^True or False/i.test(q) ? 'tf' : 'short',
+ prompt: q,
+ choices: [],
+ correct_answer: quizAnswers[i] || '',
+ points: 1,
+ sort: i,
+ }));
+ items.push({
+ type: 'quiz',
+ title: `Unit ${letter} Quiz`,
+ body: '',
+ points: questions.length,
+ sort: items.length,
+ allow_retakes: 1,
+ evidence_mode: 'none',
+ retake_policy: 'latest',
+ questions,
+ });
+ }
+
+ return items;
+}
+
+const bundle = {
+ format: 'homeschool-lms-course',
+ version: 1,
+ course: {
+ name: 'Kitchen, Tools & Electrical Skills',
+ subject: 'Life Skills',
+ color: '#e87c2e',
+ },
+ units: [],
+};
+
+for (const u of UNIT_FILES) {
+ const filePath = join(BASE, 'Units', u.file);
+ process.stderr.write(`Unit ${u.letter}: ${u.title}β¦\n`);
+ const md = toMd(filePath);
+ const items = parseUnit(u.letter, md);
+ const lessons = items.filter(i => i.type === 'lesson').length;
+ const quizzes = items.filter(i => i.type === 'quiz').length;
+ process.stderr.write(` β ${lessons} lessons, ${quizzes} quiz\n`);
+ bundle.units.push({ name: `Unit ${u.letter}: ${u.title}`, sort: bundle.units.length, items });
+}
+
+process.stdout.write(JSON.stringify(bundle, null, 2) + '\n');
+process.stderr.write('Done.\n');
diff --git a/server.js b/server.js
index b61fa1e..342ab7b 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node
import { readFile, writeFile } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import mammoth from 'mammoth';
+import multer from 'multer';
import { db, sha256 } from './db.js';
const ROOT = dirname(fileURLToPath(import.meta.url));
@@ -22,6 +23,134 @@ const app = express();
app.use(express.json({ limit: '15mb' })); // photos for page-scanning are base64 in the JSON body
app.use(express.static(join(ROOT, 'public')));
+const docxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
+
+// Strip HTML tags and decode entities to plain text
+function htmlToText(html) {
+ return html
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
+ .replace(/\s+/g, ' ').trim();
+}
+
+// Parse a unit docx buffer (from mammoth HTML) into an array of LMS items.
+// Handles: Week N: headings β lesson items, Unit X Quiz β quiz item with questions + answers.
+async function parseUnitDocxBuffer(buffer, filename) {
+ const { value: html } = await mammoth.convertToHtml({ buffer });
+
+ // Strip worksheet tables β they are fill-in-blank print sheets, not digital content
+ const stripped = html.replace(//gi, '');
+
+ // Walk the HTML and collect typed elements
+ const elements = [];
+ for (const m of stripped.matchAll(/<(p|ul|ol)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi)) {
+ const tag = m[0].match(/^<(\w+)/)[1].toLowerCase();
+ const inner = m[0].replace(/^<[^>]+>/, '').replace(/<\/\w+>\s*$/, '');
+
+ if (tag === 'p') {
+ const text = htmlToText(inner);
+ if (!text) continue;
+ // A paragraph whose only non-whitespace content is wrapped in is a heading
+ const isHeading = /^[^<]+<\/strong>\s*$/.test(inner.trim());
+ elements.push({ kind: isHeading ? 'heading' : 'para', text });
+ } else {
+ const items = [...inner.matchAll(/]*>([\s\S]*?)<\/li>/gi)]
+ .map((li) => htmlToText(li[1])).filter(Boolean);
+ if (items.length) elements.push({ kind: tag === 'ol' ? 'numbered' : 'bullets', items });
+ }
+ }
+
+ // Infer unit letter from filename (e.g. Unit_A_Kitchen.docx β 'A')
+ const lm = filename.match(/[Uu]nit[_\s-]([A-Ha-h])[_\s-]/);
+ const letter = lm ? lm[1].toUpperCase() : '';
+
+ // State machine over elements
+ const SECTIONS = {
+ 'Objective': 'OBJECTIVE',
+ 'Materials Needed': 'MATERIALS NEEDED',
+ 'Lesson & Activity Steps': 'LESSON STEPS',
+ 'Safety Notes': 'SAFETY NOTES',
+ 'Wrap-Up Discussion': 'WRAP-UP DISCUSSION',
+ };
+
+ const weeks = [];
+ let cur = null;
+ let inQuiz = false, inAnswers = false, skipWorksheet = false;
+ const qPrompts = [], qAnswers = [];
+
+ for (const el of elements) {
+ if (el.kind === 'heading') {
+ const t = el.text;
+ if (/^Week \d+:/i.test(t)) {
+ if (cur) weeks.push(cur);
+ inQuiz = inAnswers = skipWorksheet = false;
+ cur = { title: t, lines: [] };
+ } else if (/^Week \d+ Worksheet:/i.test(t)) {
+ skipWorksheet = true;
+ } else if (/^Unit [A-H] Quiz:/i.test(t)) {
+ if (cur) { weeks.push(cur); cur = null; }
+ inQuiz = true; inAnswers = false;
+ } else if (/^Answer Key$/i.test(t)) {
+ inAnswers = true; inQuiz = false;
+ } else if (cur && !skipWorksheet && SECTIONS[t]) {
+ cur.lines.push('\n' + SECTIONS[t]);
+ }
+ continue;
+ }
+
+ if (el.kind === 'para') {
+ const t = el.text;
+ if (/^Name:\s*_/.test(t) || /^Part \d+\b/i.test(t)) continue;
+ if (inAnswers) {
+ const m = t.match(/^\d+[\.\)]+\s*(.+)$/); if (m) qAnswers.push(m[1].trim());
+ } else if (inQuiz) {
+ const m = t.match(/^\d+[\.\)]+\s*(.+)$/); if (m) qPrompts.push(m[1].trim());
+ } else if (cur && !skipWorksheet) {
+ cur.lines.push(t);
+ }
+ continue;
+ }
+
+ if (el.kind === 'bullets') {
+ if (cur && !skipWorksheet) el.items.forEach((item) => cur.lines.push('β’ ' + item));
+ continue;
+ }
+
+ if (el.kind === 'numbered') {
+ if (inAnswers) { qAnswers.push(...el.items); }
+ else if (inQuiz) { qPrompts.push(...el.items); }
+ else if (cur && !skipWorksheet) {
+ el.items.forEach((item, i) => cur.lines.push(`${i + 1}. ${item}`));
+ }
+ }
+ }
+ if (cur) weeks.push(cur);
+
+ const items = weeks.map((w, i) => ({
+ type: 'lesson', title: w.title,
+ body: w.lines.join('\n').trim(),
+ points: 0, sort: i, allow_retakes: 0,
+ evidence_mode: 'none', retake_policy: 'latest',
+ }));
+
+ if (qPrompts.length) {
+ const questions = qPrompts.map((q, i) => ({
+ type: /^True or False/i.test(q) ? 'tf' : 'short',
+ prompt: q, choices: [], correct_answer: qAnswers[i] || '',
+ points: 1, sort: i,
+ }));
+ items.push({
+ type: 'quiz', title: `${letter ? `Unit ${letter} Quiz` : 'Unit Quiz'}`, body: '',
+ points: questions.length, sort: items.length,
+ allow_retakes: 1, evidence_mode: 'none', retake_policy: 'latest',
+ questions,
+ });
+ }
+
+ return { letter, items };
+}
+
// ---------- helpers ----------
// Days until next review after reaching each Leitner box
@@ -643,6 +772,196 @@ app.post('/api/courses/:id/duplicate', requirePin, (req, res) => {
res.json({ id: newCourseId });
});
+// ---------- course export / import ----------
+
+app.get('/api/admin/courses/:id/export', requirePin, (req, res) => {
+ const course = db.prepare(`SELECT name, subject, color FROM courses WHERE id = ?`).get(req.params.id);
+ if (!course) return res.status(404).json({ error: 'No such course' });
+
+ const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id);
+ const itemStmt = db.prepare(`SELECT id, type, title, body, points, sort, due_date, allow_retakes, evidence_mode, retake_policy FROM items WHERE unit_id = ? ORDER BY sort, id`);
+ const qStmt = db.prepare(`SELECT type, prompt, choices, correct_answer, points, sort FROM quiz_questions WHERE item_id = ? ORDER BY sort, id`);
+
+ const bundle = {
+ format: 'homeschool-lms-course',
+ version: 1,
+ exportedAt: new Date().toISOString(),
+ course,
+ units: units.map((u) => ({
+ name: u.name,
+ sort: u.sort,
+ items: itemStmt.all(u.id).map((it) => {
+ const out = {
+ type: it.type,
+ title: it.title,
+ body: it.body || '',
+ points: it.points,
+ sort: it.sort,
+ due_date: it.due_date || null,
+ allow_retakes: it.allow_retakes,
+ evidence_mode: it.evidence_mode || 'none',
+ retake_policy: it.retake_policy || 'latest',
+ };
+ if (it.type === 'quiz') {
+ out.questions = qStmt.all(it.id).map((q) => ({
+ type: q.type,
+ prompt: q.prompt,
+ choices: JSON.parse(q.choices || '[]'),
+ correct_answer: q.correct_answer,
+ points: q.points,
+ sort: q.sort,
+ }));
+ }
+ return out;
+ }),
+ })),
+ };
+
+ const safe = course.name.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '_');
+ res.setHeader('Content-Disposition', `attachment; filename="${safe}.json"`);
+ res.json(bundle);
+});
+
+app.post('/api/admin/import-course', requirePin, (req, res) => {
+ const { format, course, units } = req.body;
+ if (format !== 'homeschool-lms-course' || !String(course?.name || '').trim()) {
+ return res.status(400).json({ error: 'Invalid course bundle' });
+ }
+
+ const insItem = db.prepare(`
+ INSERT INTO items (unit_id, type, title, body, points, sort, due_date, allow_retakes, evidence_mode, retake_policy)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ const insQ = db.prepare(`
+ INSERT INTO quiz_questions (item_id, type, prompt, choices, correct_answer, points, sort)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `);
+
+ let courseId;
+ try {
+ db.exec('BEGIN');
+ courseId = db.prepare(`INSERT INTO courses (name, subject, color) VALUES (?, ?, ?)`)
+ .run(String(course.name).trim(), String(course.subject || '').trim(), course.color || '#4f86f7')
+ .lastInsertRowid;
+
+ for (const unit of (units || [])) {
+ const unitId = db.prepare(`INSERT INTO units (course_id, name, sort) VALUES (?, ?, ?)`)
+ .run(courseId, String(unit.name).trim(), unit.sort ?? 0)
+ .lastInsertRowid;
+
+ for (const item of (unit.items || [])) {
+ const itemId = insItem.run(
+ unitId,
+ item.type || 'lesson',
+ String(item.title || '').trim(),
+ String(item.body || ''),
+ item.points || 0,
+ item.sort ?? 0,
+ item.due_date || null,
+ item.allow_retakes ? 1 : 0,
+ item.evidence_mode || 'none',
+ item.retake_policy || 'latest',
+ ).lastInsertRowid;
+
+ if (item.type === 'quiz' && Array.isArray(item.questions) && item.questions.length) {
+ let totalPts = 0;
+ for (const q of item.questions) {
+ insQ.run(
+ itemId,
+ q.type || 'short',
+ String(q.prompt || ''),
+ JSON.stringify(Array.isArray(q.choices) ? q.choices : []),
+ String(q.correct_answer || ''),
+ q.points || 1,
+ q.sort ?? 0,
+ );
+ totalPts += q.points || 1;
+ }
+ db.prepare(`UPDATE items SET points = ? WHERE id = ?`).run(totalPts, itemId);
+ }
+ }
+ }
+ db.exec('COMMIT');
+ } catch (e) {
+ db.exec('ROLLBACK');
+ return res.status(500).json({ error: e.message });
+ }
+
+ res.json({ id: courseId });
+});
+
+// Multi-file docx β course import
+app.post('/api/admin/import-course-docx', requirePin, docxUpload.array('files'), async (req, res) => {
+ try {
+ if (!req.files?.length) return res.status(400).json({ error: 'No files uploaded' });
+ const courseName = String(req.body.name || '').trim();
+ if (!courseName) return res.status(400).json({ error: 'Course name is required' });
+
+ // Sort by filename so Unit_A_ comes before Unit_B_, etc.
+ const sorted = [...req.files].sort((a, b) => a.originalname.localeCompare(b.originalname));
+
+ // Parse each docx into unit items
+ const parsedUnits = [];
+ for (const file of sorted) {
+ const { letter, items } = await parseUnitDocxBuffer(file.buffer, file.originalname);
+ const unitName = letter
+ ? `Unit ${letter}`
+ : file.originalname.replace(/\.docx$/i, '').replace(/[_-]/g, ' ').trim();
+ parsedUnits.push({ name: unitName, items });
+ }
+
+ const insItem = db.prepare(`
+ INSERT INTO items (unit_id, type, title, body, points, sort, due_date, allow_retakes, evidence_mode, retake_policy)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ const insQ = db.prepare(`
+ INSERT INTO quiz_questions (item_id, type, prompt, choices, correct_answer, points, sort)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `);
+
+ let courseId;
+ db.exec('BEGIN');
+ try {
+ courseId = db.prepare(`INSERT INTO courses (name, subject, color) VALUES (?, ?, ?)`)
+ .run(courseName, String(req.body.subject || '').trim(), req.body.color || '#4f86f7')
+ .lastInsertRowid;
+
+ for (let ui = 0; ui < parsedUnits.length; ui++) {
+ const pu = parsedUnits[ui];
+ const unitId = db.prepare(`INSERT INTO units (course_id, name, sort) VALUES (?, ?, ?)`)
+ .run(courseId, pu.name, ui).lastInsertRowid;
+
+ for (const item of pu.items) {
+ const itemId = insItem.run(
+ unitId, item.type, item.title, item.body,
+ item.points, item.sort, null,
+ item.allow_retakes ? 1 : 0,
+ item.evidence_mode, item.retake_policy,
+ ).lastInsertRowid;
+
+ if (item.type === 'quiz' && item.questions?.length) {
+ let pts = 0;
+ for (const q of item.questions) {
+ insQ.run(itemId, q.type, q.prompt, JSON.stringify(q.choices || []),
+ q.correct_answer, q.points || 1, q.sort ?? 0);
+ pts += q.points || 1;
+ }
+ db.prepare(`UPDATE items SET points = ? WHERE id = ?`).run(pts, itemId);
+ }
+ }
+ }
+ db.exec('COMMIT');
+ } catch (e) {
+ db.exec('ROLLBACK');
+ throw e;
+ }
+
+ res.json({ id: courseId });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
app.post('/api/courses/:id/enroll', requirePin, (req, res) => {
db.prepare(`INSERT OR IGNORE INTO enrollments (student_id, course_id) VALUES (?, ?)`)
.run(req.body.studentId, req.params.id);