Files
homeschool/scripts/import-kitchen-course.js
T
nmemmertandClaude Sonnet 4.6 3197d29a28 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>
2026-08-03 10:02:10 -04:00

224 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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');