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
Generated
+130
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -77,6 +77,47 @@
|
||||
<button type="submit">Add course</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-top:1rem">
|
||||
<h2 style="margin:0 0 0.75rem">Import course</h2>
|
||||
|
||||
<!-- mode tabs -->
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
|
||||
<button type="button" class="import-tab-btn secondary" data-tab="json" style="flex:1">📄 From JSON</button>
|
||||
<button type="button" class="import-tab-btn secondary" data-tab="docx" style="flex:1">📝 From Word docs</button>
|
||||
</div>
|
||||
|
||||
<!-- JSON import -->
|
||||
<div id="import-tab-json">
|
||||
<p class="hint" style="margin:0 0 0.75rem">Import a <code>.json</code> bundle previously exported from this app.</p>
|
||||
<input type="file" id="import-course-file" accept=".json" hidden>
|
||||
<div class="inline-form" style="align-items:center;gap:0.5rem">
|
||||
<button type="button" id="import-course-browse" class="secondary">📂 Choose file…</button>
|
||||
<span id="import-course-filename" class="hint" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button type="button" id="import-course-go" class="secondary" disabled>📥 Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word docx import -->
|
||||
<div id="import-tab-docx" hidden>
|
||||
<p class="hint" style="margin:0 0 0.75rem">Select all unit <code>.docx</code> files at once — one file per unit, sorted by filename.</p>
|
||||
<div class="inline-form" style="flex-direction:column;gap:0.5rem">
|
||||
<input id="import-docx-name" placeholder="Course name (required)" aria-label="Course name" style="width:100%">
|
||||
<input id="import-docx-subject" placeholder="Subject (optional)" aria-label="Subject" style="width:100%">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||
<label style="font-size:0.85rem">Color</label>
|
||||
<input id="import-docx-color" type="color" value="#4f86f7" aria-label="Color">
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="import-docx-files" accept=".docx" multiple hidden>
|
||||
<div class="inline-form" style="align-items:center;gap:0.5rem;margin-top:0.5rem">
|
||||
<button type="button" id="import-docx-browse" class="secondary">📂 Choose .docx files…</button>
|
||||
<span id="import-docx-filenames" class="hint" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button type="button" id="import-docx-go" class="secondary" disabled>📥 Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="import-course-status" class="hint" style="margin:0.5rem 0 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -95,6 +136,7 @@
|
||||
<label class="checkbox-label"><input type="checkbox" id="cd-archived"> Archived</label>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="cd-duplicate" class="secondary">Duplicate course</button>
|
||||
<button type="button" id="cd-export" class="secondary">⬇ Export course</button>
|
||||
<button type="button" id="cd-delete" class="danger">Delete course</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+122
@@ -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' });
|
||||
|
||||
@@ -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');
|
||||
@@ -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(/<table[\s\S]*?<\/table>/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 <strong> is a heading
|
||||
const isHeading = /^<strong>[^<]+<\/strong>\s*$/.test(inner.trim());
|
||||
elements.push({ kind: isHeading ? 'heading' : 'para', text });
|
||||
} else {
|
||||
const items = [...inner.matchAll(/<li[^>]*>([\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);
|
||||
|
||||
Reference in new issue
Block a user