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:
1 parent
a44cf659b5
commit
3197d29a28
6 files changed
+838
-2
No files matched your search
@@ -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');
|
||||
Reference in new issue
Block a user