diff --git a/public/admin.js b/public/admin.js
index e68d575..2532ae9 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -261,10 +261,6 @@ $('#import-docx-files').addEventListener('change', () => {
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 = '';
}
@@ -1224,10 +1220,10 @@ function toggleAddTaskForm(date) {
const courseSelect = box.querySelector('.task-course-select');
(async () => {
- for (const c of plannerCoursesCache) {
- const detail = await api(`/api/admin/courses/${c.id}`);
+ const details = await Promise.all(plannerCoursesCache.map((c) => api(`/api/admin/courses/${c.id}`)));
+ for (const detail of details) {
const group = document.createElement('optgroup');
- group.label = c.name;
+ group.label = detail.name;
for (const u of detail.units) {
for (const it of u.items) {
const opt = document.createElement('option');
@@ -1274,12 +1270,12 @@ function updatePlannerSaveBtn() {
$('#planner-save-all').addEventListener('click', async () => {
const count = plannerQueue.length;
if (!count) return;
- for (const item of plannerQueue) {
- await api('/api/schedule', {
+ await Promise.all(plannerQueue.map((item) =>
+ api('/api/schedule', {
method: 'POST',
body: { studentId: plannerStudentId, date: item.date, itemId: item.itemId, title: item.title },
- });
- }
+ })
+ ));
plannerQueue = [];
updatePlannerSaveBtn();
msg(`${count} item${count === 1 ? '' : 's'} added to planner.`);
@@ -1292,7 +1288,12 @@ function renderPrintTaskBlock(t) {
const status = t.done ? `✓ done` : '';
const typeTag = t.type ? `${TYPE_LABEL[t.type] || t.type}` : '';
let body = '';
- if (t.body) body = `
${t.body.split(/\n\n+/).map((p) => `
${esc(p.trim())}
`).join('')}
`;
+ if (t.body) {
+ const isHtml = t.body.includes('<');
+ body = isHtml
+ ? `${t.body}
`
+ : `${t.body.split(/\n\n+/).map((p) => `
${esc(p.trim()).replace(/\n/g, '
')}
`).join('')}
`;
+ }
return `
${body}
@@ -1427,7 +1428,12 @@ async function printCourse(courseId) {
function renderItemBody(it) {
let html = '';
if (it.body) {
- html += `
${it.body.split(/\n\n+/).map((p) => `
${esc(p.trim())}
`).join('')}
`;
+ const isHtml = it.body.includes('<');
+ html += `
${
+ isHtml
+ ? it.body
+ : it.body.split(/\n\n+/).map((p) => `
${esc(p.trim()).replace(/\n/g, '
')}
`).join('')
+ }
`;
}
if (it.type === 'quiz' && it.questions?.length) {
html += `
${it.questions.map((q, i) => {
diff --git a/server.js b/server.js
index 598f182..6980c6a 100644
--- a/server.js
+++ b/server.js
@@ -194,8 +194,9 @@ const updateProgress = leitnerUpdater('progress', 'word_id');
const updateCardProgress = leitnerUpdater('card_progress', 'card_id');
// Parent-only routes must send the PIN in the x-pin header
+const getStoredPin = db.prepare(`SELECT value FROM settings WHERE key = 'pin'`);
function requirePin(req, res, next) {
- const stored = db.prepare(`SELECT value FROM settings WHERE key = 'pin'`).get().value;
+ const stored = getStoredPin.get().value;
if (sha256(req.get('x-pin') || '') !== stored) {
return res.status(401).json({ error: 'Wrong PIN' });
}
@@ -546,11 +547,18 @@ app.put('/api/lists/:id', requirePin, (req, res) => {
if (!db.prepare(`SELECT id FROM lists WHERE id = ?`).get(id)) {
return res.status(404).json({ error: 'No such list' });
}
- db.prepare(`UPDATE lists SET name = ?, group_name = ? WHERE id = ?`)
- .run(String(name).trim(), String(groupName || '').trim(), id);
- db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id);
const ins = db.prepare(`INSERT INTO words (list_id, word, sentence, definition) VALUES (?, ?, ?, ?)`);
- for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim());
+ db.exec('BEGIN');
+ try {
+ db.prepare(`UPDATE lists SET name = ?, group_name = ? WHERE id = ?`)
+ .run(String(name).trim(), String(groupName || '').trim(), id);
+ db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id);
+ for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim());
+ db.exec('COMMIT');
+ } catch (e) {
+ db.exec('ROLLBACK');
+ throw e;
+ }
res.json({ ok: true });
});
@@ -594,11 +602,18 @@ app.put('/api/decks/:id', requirePin, (req, res) => {
if (!db.prepare(`SELECT id FROM decks WHERE id = ?`).get(id)) {
return res.status(404).json({ error: 'No such deck' });
}
- db.prepare(`UPDATE decks SET name = ?, group_name = ? WHERE id = ?`)
- .run(String(name).trim(), String(groupName || '').trim(), id);
- db.prepare(`DELETE FROM cards WHERE deck_id = ?`).run(id);
const ins = db.prepare(`INSERT INTO cards (deck_id, front, back) VALUES (?, ?, ?)`);
- for (const c of cards) ins.run(id, String(c.front).trim(), String(c.back).trim());
+ db.exec('BEGIN');
+ try {
+ db.prepare(`UPDATE decks SET name = ?, group_name = ? WHERE id = ?`)
+ .run(String(name).trim(), String(groupName || '').trim(), id);
+ db.prepare(`DELETE FROM cards WHERE deck_id = ?`).run(id);
+ for (const c of cards) ins.run(id, String(c.front).trim(), String(c.back).trim());
+ db.exec('COMMIT');
+ } catch (e) {
+ db.exec('ROLLBACK');
+ throw e;
+ }
res.json({ ok: true });
});
@@ -1354,7 +1369,6 @@ app.post('/api/items/:id/crossword-submit', (req, res) => {
try { cw = JSON.parse(item.body); } catch { return res.status(400).json({ error: 'Invalid crossword data' }); }
const allWords = [...(cw.across || []), ...(cw.down || [])];
- const isDown = (w) => cw.down.some(d => d.num === w.num && d.row === w.row && d.col === w.col);
let earned = 0;
const possible = allWords.length;
@@ -1444,23 +1458,46 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
JOIN enrollments e ON e.student_id = s.id WHERE e.course_id = ? ORDER BY s.name
`).all(req.params.courseId);
- const scoreStmt = db.prepare(`SELECT score, points_possible, status, parent_comment FROM submissions WHERE student_id = ? AND item_id = ?`);
- const histBest = db.prepare(`SELECT MAX(score * 1.0 / points_possible) AS ratio, MAX(score) AS score, points_possible FROM submission_history WHERE student_id = ? AND item_id = ? AND points_possible > 0`);
- const histAvg = db.prepare(`SELECT AVG(score) AS score, points_possible, COUNT(*) AS cnt FROM submission_history WHERE student_id = ? AND item_id = ? AND points_possible > 0`);
+ const studentIds = students.map((s) => s.id);
+ const itemIds = gradableItems.map((i) => i.id);
+ const ph = (arr) => arr.map(() => '?').join(',');
+
+ const allSubs = studentIds.length && itemIds.length
+ ? db.prepare(`
+ SELECT student_id, item_id, score, points_possible, status, parent_comment
+ FROM submissions
+ WHERE student_id IN (${ph(studentIds)}) AND item_id IN (${ph(itemIds)})
+ `).all(...studentIds, ...itemIds)
+ : [];
+ const allHist = studentIds.length && itemIds.length
+ ? db.prepare(`
+ SELECT student_id, item_id,
+ MAX(score) AS best_score, AVG(score) AS avg_score, COUNT(*) AS cnt
+ FROM submission_history
+ WHERE student_id IN (${ph(studentIds)}) AND item_id IN (${ph(itemIds)})
+ AND points_possible > 0
+ GROUP BY student_id, item_id
+ `).all(...studentIds, ...itemIds)
+ : [];
+
+ const subMap = new Map(allSubs.map((r) => [`${r.student_id}:${r.item_id}`, r]));
+ const histMap = new Map(allHist.map((r) => [`${r.student_id}:${r.item_id}`, r]));
+
const now = today();
for (const s of students) {
s.scores = {};
let earned = 0, possible = 0;
for (const item of gradableItems) {
- const row = scoreStmt.get(s.id, item.id);
+ const row = subMap.get(`${s.id}:${item.id}`) || null;
const overdue = !!(item.due_date && item.due_date < now && (!row || row.status !== 'graded'));
let effectiveScore = row?.score;
- if (row?.status === 'graded' && item.retake_policy === 'highest') {
- const best = histBest.get(s.id, item.id);
- if (best?.score !== null) effectiveScore = best.score;
- } else if (row?.status === 'graded' && item.retake_policy === 'average') {
- const avg = histAvg.get(s.id, item.id);
- if (avg?.cnt > 0) effectiveScore = Math.round(avg.score * 10) / 10;
+ if (row?.status === 'graded') {
+ const hist = histMap.get(`${s.id}:${item.id}`);
+ if (item.retake_policy === 'highest' && hist?.best_score != null) {
+ effectiveScore = hist.best_score;
+ } else if (item.retake_policy === 'average' && hist?.cnt > 0) {
+ effectiveScore = Math.round(hist.avg_score * 10) / 10;
+ }
}
s.scores[item.id] = row ? { ...row, score: effectiveScore, overdue } : (overdue ? { overdue: true } : null);
if (row && row.status === 'graded' && row.points_possible) {
@@ -2042,7 +2079,7 @@ app.get('/api/tts', (req, res) => {
// ---------- Homeschool folder import ----------
-const HOMESCHOOL_DIR = "/Users/nateemmert/Library/CloudStorage/SeaDrive-NateEmmert(seafile.necloud.us)/My Libraries/Homeschool";
+const HOMESCHOOL_DIR = process.env.HOMESCHOOL_DIR || "/Users/nateemmert/Library/CloudStorage/SeaDrive-NateEmmert(seafile.necloud.us)/My Libraries/Homeschool";
function listDocxFiles(dir, base = '') {
const results = [];