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

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