Add course import/export: JSON bundle and direct Word docx upload

- 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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-03 10:02:10 -04:00
1 parent a44cf659b5
commit 3197d29a28
6 files changed
+838 -2

No files matched your search

+223
View File
@@ -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');