From 3197d29a2851688804b7426bb4fb6b23dbffa4ad Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:01:58 -0400 Subject: [PATCH] Add course import/export: JSON bundle and direct Word docx upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export any course as a .json bundle (GET /api/admin/courses/:id/export) - Import a .json bundle as a new course (POST /api/admin/import-course) - Import one or more unit .docx files directly in the browser β€” server parses each file with mammoth, extracts Week N: headings as lesson items and the Unit Quiz section as a graded quiz with answer key (POST /api/admin/import-course-docx, multer memory storage) - Admin UI: Export button on course detail; Import card with JSON and Word docs tabs; multi-file picker for docx uploads - scripts/import-kitchen-course.js: one-time converter that produced the Kitchen, Tools & Electrical Skills course JSON bundle (already imported as course id 3: 8 units, 44 items) Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 131 ++++++++++++- package.json | 3 +- public/admin.html | 42 ++++ public/admin.js | 122 ++++++++++++ scripts/import-kitchen-course.js | 223 +++++++++++++++++++++ server.js | 319 +++++++++++++++++++++++++++++++ 6 files changed, 838 insertions(+), 2 deletions(-) create mode 100644 scripts/import-kitchen-course.js 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 @@ +
+

Import course

+ + +
+ + +
+ + +
+

Import a .json bundle previously exported from this app.

+ +
+ + + +
+
+ + + + +

+
@@ -95,6 +136,7 @@ + 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);