1088 lines
47 KiB
JavaScript
1088 lines
47 KiB
JavaScript
import express from 'express';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { db, sha256 } from './db.js';
|
|
|
|
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
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')));
|
|
|
|
// ---------- helpers ----------
|
|
|
|
// Days until next review after reaching each Leitner box
|
|
const INTERVALS = { 1: 1, 2: 2, 3: 4, 4: 7, 5: 14 };
|
|
const MASTERED_BOX = 5;
|
|
|
|
const normalize = (s) => String(s).trim().toLowerCase();
|
|
const isDateStr = (s) => /^\d{4}-\d{2}-\d{2}$/.test(String(s));
|
|
const today = () => new Date().toISOString().slice(0, 10);
|
|
|
|
const shuffle = (arr) => {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
};
|
|
|
|
// Apply a result to a student's Leitner state. Words and flashcards each
|
|
// have their own progress table but share the same scheduling rules.
|
|
function leitnerUpdater(table, idCol) {
|
|
const select = db.prepare(`SELECT box FROM ${table} WHERE student_id = ? AND ${idCol} = ?`);
|
|
const upsert = db.prepare(`
|
|
INSERT INTO ${table} (student_id, ${idCol}, box, due, attempts, correct)
|
|
VALUES (?, ?, ?, date('now', ?), 1, ?)
|
|
ON CONFLICT (student_id, ${idCol}) DO UPDATE SET
|
|
box = excluded.box,
|
|
due = excluded.due,
|
|
attempts = attempts + 1,
|
|
correct = correct + excluded.correct
|
|
`);
|
|
return (studentId, itemId, correct) => {
|
|
const row = select.get(studentId, itemId);
|
|
const box = correct ? Math.min((row ? row.box : 0) + 1, MASTERED_BOX) : 1;
|
|
const days = correct ? INTERVALS[box] : 0;
|
|
upsert.run(studentId, itemId, box, `+${days} days`, correct ? 1 : 0);
|
|
return box;
|
|
};
|
|
}
|
|
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
|
|
function requirePin(req, res, next) {
|
|
const stored = db.prepare(`SELECT value FROM settings WHERE key = 'pin'`).get().value;
|
|
if (sha256(req.get('x-pin') || '') !== stored) {
|
|
return res.status(401).json({ error: 'Wrong PIN' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Mark a scheduled agenda entry for this student/item/date as done, if one exists
|
|
const markScheduleDone = db.prepare(`
|
|
UPDATE schedule SET done = 1, done_at = datetime('now')
|
|
WHERE student_id = ? AND item_id = ? AND date = ?
|
|
`);
|
|
|
|
const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'spelling_practice', 'spelling_test', 'flashcards'];
|
|
const GRADABLE_TYPES = ['assignment', 'quiz', 'spelling_test'];
|
|
|
|
// ---------- kid picker ----------
|
|
|
|
app.get('/api/students', (req, res) => {
|
|
res.json(db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all());
|
|
});
|
|
|
|
// ---------- spelling module ----------
|
|
|
|
// Everything the standalone spelling home screen needs
|
|
app.get('/api/state/:studentId', (req, res) => {
|
|
const id = req.params.studentId;
|
|
const student = db.prepare(`SELECT id, name, emoji FROM students WHERE id = ?`).get(id);
|
|
if (!student) return res.status(404).json({ error: 'No such student' });
|
|
|
|
const assignment = db.prepare(`
|
|
SELECT l.id, l.name FROM assignments a JOIN lists l ON l.id = a.list_id
|
|
WHERE a.student_id = ?
|
|
`).get(id);
|
|
|
|
let listProgress = null;
|
|
if (assignment) {
|
|
listProgress = db.prepare(`
|
|
SELECT COUNT(*) AS total,
|
|
SUM(CASE WHEN COALESCE(p.box, 0) >= ${MASTERED_BOX} THEN 1 ELSE 0 END) AS mastered
|
|
FROM words w
|
|
LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ?
|
|
WHERE w.list_id = ?
|
|
`).get(id, assignment.id);
|
|
}
|
|
|
|
// only words outside this week's list count as "old reviews"
|
|
const dueReviews = db.prepare(`
|
|
SELECT COUNT(*) AS n FROM progress p JOIN words w ON w.id = p.word_id
|
|
WHERE p.student_id = ? AND w.list_id <> ? AND p.box < ${MASTERED_BOX}
|
|
AND date(p.due) <= date('now')
|
|
`).get(id, assignment ? assignment.id : -1).n;
|
|
|
|
res.json({ student, assignment, listProgress, dueReviews });
|
|
});
|
|
|
|
// Build a practice session: the target list's unmastered words + due reviews.
|
|
// listId overrides the student's pinned weekly assignment (used when launched from a course item).
|
|
app.get('/api/session/:studentId', (req, res) => {
|
|
const id = req.params.studentId;
|
|
let listId = req.query.listId ? Number(req.query.listId) : null;
|
|
if (!listId) {
|
|
const assignment = db.prepare(`SELECT list_id FROM assignments WHERE student_id = ?`).get(id);
|
|
listId = assignment ? assignment.list_id : -1;
|
|
}
|
|
|
|
const weekWords = db.prepare(`
|
|
SELECT w.id, w.word, w.sentence, COALESCE(p.box, 0) AS box
|
|
FROM words w
|
|
LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ?
|
|
WHERE w.list_id = ? AND COALESCE(p.box, 0) < ${MASTERED_BOX}
|
|
ORDER BY box, RANDOM()
|
|
LIMIT 10
|
|
`).all(id, listId);
|
|
|
|
const reviews = db.prepare(`
|
|
SELECT w.id, w.word, w.sentence, p.box
|
|
FROM progress p JOIN words w ON w.id = p.word_id
|
|
WHERE p.student_id = ? AND w.list_id <> ? AND p.box < ${MASTERED_BOX}
|
|
AND date(p.due) <= date('now')
|
|
ORDER BY p.due
|
|
LIMIT 5
|
|
`).all(id, listId);
|
|
|
|
res.json({ words: shuffle([...weekWords, ...reviews]) });
|
|
});
|
|
|
|
// Grade one practice answer. firstTry attempts move the Leitner box;
|
|
// look-cover-write-check retypes are recorded but don't.
|
|
app.post('/api/attempt', (req, res) => {
|
|
const { studentId, wordId, typed, firstTry } = req.body;
|
|
const word = db.prepare(`SELECT word FROM words WHERE id = ?`).get(wordId);
|
|
if (!word) return res.status(404).json({ error: 'No such word' });
|
|
|
|
const correct = normalize(typed) === normalize(word.word);
|
|
db.prepare(`INSERT INTO attempts (student_id, word_id, typed, correct, mode) VALUES (?, ?, ?, ?, ?)`)
|
|
.run(studentId, wordId, String(typed), correct ? 1 : 0, firstTry ? 'practice' : 'retype');
|
|
|
|
let box = null;
|
|
if (firstTry) box = updateProgress(studentId, wordId, correct);
|
|
res.json({ correct, word: word.word, box });
|
|
});
|
|
|
|
// ---------- spelling test (Friday test, or a course spelling_test item) ----------
|
|
|
|
app.get('/api/test/:studentId', (req, res) => {
|
|
let list;
|
|
if (req.query.listId) {
|
|
list = db.prepare(`SELECT id, name FROM lists WHERE id = ?`).get(req.query.listId);
|
|
} else {
|
|
list = db.prepare(`
|
|
SELECT l.id, l.name FROM assignments a JOIN lists l ON l.id = a.list_id
|
|
WHERE a.student_id = ?
|
|
`).get(req.params.studentId);
|
|
}
|
|
if (!list) return res.status(404).json({ error: 'No list assigned' });
|
|
|
|
const words = db.prepare(`SELECT id, word, sentence FROM words WHERE list_id = ?`).all(list.id);
|
|
res.json({ list, words: shuffle(words) });
|
|
});
|
|
|
|
app.post('/api/test/:studentId', (req, res) => {
|
|
const studentId = req.params.studentId;
|
|
const { listId, answers, itemId, date } = req.body; // answers: [{ wordId, typed }]
|
|
if (!Array.isArray(answers) || answers.length === 0) {
|
|
return res.status(400).json({ error: 'No answers' });
|
|
}
|
|
|
|
const getWord = db.prepare(`SELECT word FROM words WHERE id = ?`);
|
|
const graded = answers.map((a) => {
|
|
const w = getWord.get(a.wordId);
|
|
return { ...a, word: w.word, correct: normalize(a.typed) === normalize(w.word) };
|
|
});
|
|
const score = graded.filter((g) => g.correct).length;
|
|
|
|
const testId = db.prepare(`INSERT INTO tests (student_id, list_id, score, total) VALUES (?, ?, ?, ?)`)
|
|
.run(studentId, listId, score, graded.length).lastInsertRowid;
|
|
const insAnswer = db.prepare(`INSERT INTO test_answers (test_id, word_id, typed, correct) VALUES (?, ?, ?, ?)`);
|
|
for (const g of graded) {
|
|
insAnswer.run(testId, g.wordId, String(g.typed), g.correct ? 1 : 0);
|
|
updateProgress(studentId, g.wordId, g.correct); // missed test words come back in practice
|
|
}
|
|
|
|
// if this test was launched from a course item, record it in the gradebook too
|
|
if (itemId) {
|
|
db.prepare(`
|
|
INSERT INTO submissions (student_id, item_id, status, score, points_possible, completed_at, graded_at)
|
|
VALUES (?, ?, 'graded', ?, ?, datetime('now'), datetime('now'))
|
|
ON CONFLICT (student_id, item_id) DO UPDATE SET
|
|
status = 'graded', score = excluded.score, points_possible = excluded.points_possible,
|
|
completed_at = datetime('now'), graded_at = datetime('now')
|
|
`).run(studentId, itemId, score, graded.length);
|
|
markScheduleDone.run(studentId, itemId, isDateStr(date) ? date : today());
|
|
}
|
|
|
|
res.json({ testId, score, total: graded.length, graded });
|
|
});
|
|
|
|
// Full detail for the printable report
|
|
app.get('/api/test-report/:testId', (req, res) => {
|
|
const test = db.prepare(`
|
|
SELECT t.id, t.score, t.total, t.at, s.name AS student, l.name AS list
|
|
FROM tests t JOIN students s ON s.id = t.student_id JOIN lists l ON l.id = t.list_id
|
|
WHERE t.id = ?
|
|
`).get(req.params.testId);
|
|
if (!test) return res.status(404).json({ error: 'No such test' });
|
|
|
|
test.answers = db.prepare(`
|
|
SELECT w.word, ta.typed, ta.correct
|
|
FROM test_answers ta JOIN words w ON w.id = ta.word_id
|
|
WHERE ta.test_id = ?
|
|
ORDER BY w.word
|
|
`).all(req.params.testId);
|
|
res.json(test);
|
|
});
|
|
|
|
// ---------- word lists (read is public, kids never write) ----------
|
|
|
|
app.get('/api/lists', (req, res) => {
|
|
res.json(db.prepare(`
|
|
SELECT l.id, l.name, l.builtin, COUNT(w.id) AS wordCount
|
|
FROM lists l LEFT JOIN words w ON w.list_id = l.id
|
|
GROUP BY l.id ORDER BY l.builtin, l.name
|
|
`).all());
|
|
});
|
|
|
|
app.get('/api/lists/:id', (req, res) => {
|
|
const list = db.prepare(`SELECT id, name, builtin FROM lists WHERE id = ?`).get(req.params.id);
|
|
if (!list) return res.status(404).json({ error: 'No such list' });
|
|
list.words = db.prepare(`SELECT id, word, sentence FROM words WHERE list_id = ? ORDER BY id`).all(req.params.id);
|
|
res.json(list);
|
|
});
|
|
|
|
app.post('/api/lists', requirePin, (req, res) => {
|
|
const { name, words } = req.body;
|
|
if (!name || !Array.isArray(words) || words.length === 0) {
|
|
return res.status(400).json({ error: 'Name and at least one word required' });
|
|
}
|
|
const id = db.prepare(`INSERT INTO lists (name, builtin) VALUES (?, 0)`).run(String(name).trim()).lastInsertRowid;
|
|
const ins = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`);
|
|
for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim());
|
|
res.json({ id });
|
|
});
|
|
|
|
app.put('/api/lists/:id', requirePin, (req, res) => {
|
|
const { name, words } = req.body;
|
|
const id = req.params.id;
|
|
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 = ? WHERE id = ?`).run(String(name).trim(), id);
|
|
db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id);
|
|
const ins = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`);
|
|
for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim());
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/lists/:id', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM lists WHERE id = ?`).run(req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ---------- flashcards module ----------
|
|
|
|
app.get('/api/decks', (req, res) => {
|
|
res.json(db.prepare(`
|
|
SELECT d.id, d.name, d.builtin, COUNT(c.id) AS cardCount
|
|
FROM decks d LEFT JOIN cards c ON c.deck_id = d.id
|
|
GROUP BY d.id ORDER BY d.builtin, d.name
|
|
`).all());
|
|
});
|
|
|
|
app.get('/api/decks/:id', (req, res) => {
|
|
const deck = db.prepare(`SELECT id, name, builtin FROM decks WHERE id = ?`).get(req.params.id);
|
|
if (!deck) return res.status(404).json({ error: 'No such deck' });
|
|
deck.cards = db.prepare(`SELECT id, front, back FROM cards WHERE deck_id = ? ORDER BY id`).all(req.params.id);
|
|
res.json(deck);
|
|
});
|
|
|
|
app.post('/api/decks', requirePin, (req, res) => {
|
|
const { name, cards } = req.body;
|
|
if (!name || !Array.isArray(cards) || cards.length === 0) {
|
|
return res.status(400).json({ error: 'Name and at least one card required' });
|
|
}
|
|
const id = db.prepare(`INSERT INTO decks (name, builtin) VALUES (?, 0)`).run(String(name).trim()).lastInsertRowid;
|
|
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());
|
|
res.json({ id });
|
|
});
|
|
|
|
app.put('/api/decks/:id', requirePin, (req, res) => {
|
|
const { name, cards } = req.body;
|
|
const id = req.params.id;
|
|
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 = ? WHERE id = ?`).run(String(name).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());
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/decks/:id', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM decks WHERE id = ?`).run(req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Due + new cards from one deck, capped per session
|
|
app.get('/api/flashcards/session/:studentId', (req, res) => {
|
|
const cards = db.prepare(`
|
|
SELECT c.id, c.front, c.back, COALESCE(p.box, 0) AS box
|
|
FROM cards c
|
|
LEFT JOIN card_progress p ON p.card_id = c.id AND p.student_id = ?
|
|
WHERE c.deck_id = ? AND COALESCE(p.box, 0) < ${MASTERED_BOX}
|
|
AND (p.due IS NULL OR date(p.due) <= date('now'))
|
|
ORDER BY box, RANDOM()
|
|
LIMIT 20
|
|
`).all(req.params.studentId, req.query.deckId);
|
|
res.json({ cards: shuffle(cards) });
|
|
});
|
|
|
|
app.post('/api/flashcards/grade', (req, res) => {
|
|
const { studentId, cardId, gotIt } = req.body;
|
|
if (!db.prepare(`SELECT id FROM cards WHERE id = ?`).get(cardId)) {
|
|
return res.status(404).json({ error: 'No such card' });
|
|
}
|
|
const box = updateCardProgress(studentId, cardId, !!gotIt);
|
|
res.json({ ok: true, box });
|
|
});
|
|
|
|
// ---------- parent account API ----------
|
|
|
|
app.post('/api/parent/verify', (req, res) => {
|
|
const stored = db.prepare(`SELECT value FROM settings WHERE key = 'pin'`).get().value;
|
|
res.json({ ok: sha256(req.body.pin || '') === stored });
|
|
});
|
|
|
|
app.post('/api/parent/pin', requirePin, (req, res) => {
|
|
const pin = String(req.body.newPin || '');
|
|
if (pin.length < 4) return res.status(400).json({ error: 'PIN must be at least 4 digits' });
|
|
db.prepare(`UPDATE settings SET value = ? WHERE key = 'pin'`).run(sha256(pin));
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/students', requirePin, (req, res) => {
|
|
const name = String(req.body.name || '').trim();
|
|
if (!name) return res.status(400).json({ error: 'Name required' });
|
|
const id = db.prepare(`INSERT INTO students (name, emoji) VALUES (?, ?)`)
|
|
.run(name, req.body.emoji || '🙂').lastInsertRowid;
|
|
res.json({ id });
|
|
});
|
|
|
|
app.delete('/api/students/:id', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM students WHERE id = ?`).run(req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/assign', requirePin, (req, res) => {
|
|
const { studentId, listId } = req.body;
|
|
db.prepare(`
|
|
INSERT INTO assignments (student_id, list_id) VALUES (?, ?)
|
|
ON CONFLICT (student_id) DO UPDATE SET list_id = excluded.list_id, assigned_at = datetime('now')
|
|
`).run(studentId, listId);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Legacy spelling-only dashboard summary
|
|
app.get('/api/overview', requirePin, (req, res) => {
|
|
const students = db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all();
|
|
for (const s of students) {
|
|
s.assignment = db.prepare(`
|
|
SELECT l.id, l.name FROM assignments a JOIN lists l ON l.id = a.list_id WHERE a.student_id = ?
|
|
`).get(s.id) || null;
|
|
s.mastered = db.prepare(
|
|
`SELECT COUNT(*) AS n FROM progress WHERE student_id = ? AND box >= ${MASTERED_BOX}`
|
|
).get(s.id).n;
|
|
s.tests = db.prepare(`
|
|
SELECT t.id, t.score, t.total, t.at, l.name AS list
|
|
FROM tests t JOIN lists l ON l.id = t.list_id
|
|
WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20
|
|
`).all(s.id);
|
|
}
|
|
res.json({ students });
|
|
});
|
|
|
|
// ============================================================
|
|
// LMS layer: courses, units, items, submissions, gradebook
|
|
// ============================================================
|
|
|
|
// ---------- courses (admin) ----------
|
|
|
|
app.get('/api/admin/courses', requirePin, (req, res) => {
|
|
res.json(db.prepare(`
|
|
SELECT c.id, c.name, c.subject, c.color, c.archived,
|
|
(SELECT COUNT(*) FROM units u WHERE u.course_id = c.id) AS unitCount,
|
|
(SELECT COUNT(*) FROM items i JOIN units u ON u.id = i.unit_id WHERE u.course_id = c.id) AS itemCount
|
|
FROM courses c ORDER BY c.archived, c.name
|
|
`).all());
|
|
});
|
|
|
|
app.post('/api/courses', requirePin, (req, res) => {
|
|
const { name, subject, color } = req.body;
|
|
if (!String(name || '').trim()) return res.status(400).json({ error: 'Name required' });
|
|
const id = db.prepare(`INSERT INTO courses (name, subject, color) VALUES (?, ?, ?)`)
|
|
.run(String(name).trim(), String(subject || '').trim(), color || '#4f86f7').lastInsertRowid;
|
|
res.json({ id });
|
|
});
|
|
|
|
app.put('/api/courses/:id', requirePin, (req, res) => {
|
|
const { name, subject, color, archived } = req.body;
|
|
if (!db.prepare(`SELECT id FROM courses WHERE id = ?`).get(req.params.id)) {
|
|
return res.status(404).json({ error: 'No such course' });
|
|
}
|
|
db.prepare(`UPDATE courses SET name = ?, subject = ?, color = ?, archived = ? WHERE id = ?`)
|
|
.run(String(name).trim(), String(subject || '').trim(), color || '#4f86f7', archived ? 1 : 0, req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/courses/:id', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM courses WHERE id = ?`).run(req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
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);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/courses/:id/enroll/:studentId', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM enrollments WHERE student_id = ? AND course_id = ?`)
|
|
.run(req.params.studentId, req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Full course structure for the admin course editor (no student-specific filtering)
|
|
app.get('/api/admin/courses/:id', requirePin, (req, res) => {
|
|
const course = db.prepare(`SELECT id, name, subject, color, archived 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, points, ref_id, sort FROM items WHERE unit_id = ? ORDER BY sort, id`);
|
|
for (const u of units) u.items = itemStmt.all(u.id);
|
|
res.json({ ...course, units });
|
|
});
|
|
|
|
// Full item detail for the admin item editor (quiz correct answers always included)
|
|
app.get('/api/admin/items/:id', requirePin, (req, res) => {
|
|
const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id FROM items WHERE id = ?`).get(req.params.id);
|
|
if (!item) return res.status(404).json({ error: 'No such item' });
|
|
if (item.type === 'quiz') {
|
|
item.questions = db.prepare(`
|
|
SELECT id, type, prompt, choices, correct_answer, points FROM quiz_questions WHERE item_id = ? ORDER BY sort, id
|
|
`).all(req.params.id).map((q) => ({ ...q, choices: JSON.parse(q.choices) }));
|
|
}
|
|
res.json(item);
|
|
});
|
|
|
|
app.get('/api/courses/:id/roster', requirePin, (req, res) => {
|
|
res.json(db.prepare(`
|
|
SELECT s.id, s.name, s.emoji, (e.student_id IS NOT NULL) AS enrolled
|
|
FROM students s LEFT JOIN enrollments e ON e.student_id = s.id AND e.course_id = ?
|
|
ORDER BY s.name
|
|
`).all(req.params.id));
|
|
});
|
|
|
|
// ---------- units (admin) ----------
|
|
|
|
app.post('/api/units', requirePin, (req, res) => {
|
|
const { courseId, name } = req.body;
|
|
if (!String(name || '').trim()) return res.status(400).json({ error: 'Name required' });
|
|
const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM units WHERE course_id = ?`).get(courseId).n;
|
|
const id = db.prepare(`INSERT INTO units (course_id, name, sort) VALUES (?, ?, ?)`)
|
|
.run(courseId, String(name).trim(), sort).lastInsertRowid;
|
|
res.json({ id });
|
|
});
|
|
|
|
app.put('/api/units/:id', requirePin, (req, res) => {
|
|
const { name, sort } = req.body;
|
|
db.prepare(`UPDATE units SET name = COALESCE(?, name), sort = COALESCE(?, sort) WHERE id = ?`)
|
|
.run(name ? String(name).trim() : null, Number.isInteger(sort) ? sort : null, req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/units/:id', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM units WHERE id = ?`).run(req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ---------- items (admin) ----------
|
|
|
|
function validateItemBody(body) {
|
|
const { type, title, refId } = body;
|
|
if (!ITEM_TYPES.includes(type)) return 'Invalid item type';
|
|
if (!String(title || '').trim()) return 'Title required';
|
|
if (type === 'spelling_practice' || type === 'spelling_test') {
|
|
if (!db.prepare(`SELECT id FROM lists WHERE id = ?`).get(refId)) return 'Pick a word list';
|
|
}
|
|
if (type === 'flashcards' && !db.prepare(`SELECT id FROM decks WHERE id = ?`).get(refId)) {
|
|
return 'Pick a deck';
|
|
}
|
|
if (type === 'quiz' && (!Array.isArray(body.questions) || body.questions.length === 0)) {
|
|
return 'Quiz needs at least one question';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function saveQuizQuestions(itemId, questions) {
|
|
db.prepare(`DELETE FROM quiz_questions WHERE item_id = ?`).run(itemId);
|
|
const ins = db.prepare(`
|
|
INSERT INTO quiz_questions (item_id, type, prompt, choices, correct_answer, points, sort)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
questions.forEach((q, i) => {
|
|
ins.run(
|
|
itemId,
|
|
q.type,
|
|
String(q.prompt).trim(),
|
|
JSON.stringify(q.type === 'mc' ? (q.choices || []) : []),
|
|
String(q.correctAnswer).trim(),
|
|
Number(q.points) || 1,
|
|
i
|
|
);
|
|
});
|
|
}
|
|
|
|
app.post('/api/items', requirePin, (req, res) => {
|
|
const err = validateItemBody(req.body);
|
|
if (err) return res.status(400).json({ error: err });
|
|
const { unitId, type, title, body, points, refId } = req.body;
|
|
const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM items WHERE unit_id = ?`).get(unitId).n;
|
|
const id = db.prepare(`
|
|
INSERT INTO items (unit_id, type, title, body, points, ref_id, sort) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(unitId, type, String(title).trim(), String(body || ''), Number(points) || 0,
|
|
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
|
sort).lastInsertRowid;
|
|
if (type === 'quiz') {
|
|
saveQuizQuestions(id, req.body.questions);
|
|
const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0);
|
|
db.prepare(`UPDATE items SET points = ? WHERE id = ?`).run(totalPoints, id);
|
|
}
|
|
res.json({ id });
|
|
});
|
|
|
|
app.put('/api/items/:id', requirePin, (req, res) => {
|
|
const err = validateItemBody(req.body);
|
|
if (err) return res.status(400).json({ error: err });
|
|
if (!db.prepare(`SELECT id FROM items WHERE id = ?`).get(req.params.id)) {
|
|
return res.status(404).json({ error: 'No such item' });
|
|
}
|
|
const { type, title, body, points, refId } = req.body;
|
|
const finalPoints = type === 'quiz'
|
|
? req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0)
|
|
: (Number(points) || 0);
|
|
db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ? WHERE id = ?`)
|
|
.run(type, String(title).trim(), String(body || ''), finalPoints,
|
|
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
|
req.params.id);
|
|
if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/items/:id', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM items WHERE id = ?`).run(req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ---------- kid course views ----------
|
|
|
|
// Enrolled courses with rough completion progress
|
|
app.get('/api/courses/mine/:studentId', (req, res) => {
|
|
const studentId = req.params.studentId;
|
|
const courses = db.prepare(`
|
|
SELECT c.id, c.name, c.subject, c.color
|
|
FROM courses c JOIN enrollments e ON e.course_id = c.id
|
|
WHERE e.student_id = ? AND c.archived = 0
|
|
ORDER BY c.name
|
|
`).all(studentId);
|
|
for (const c of courses) {
|
|
const totals = db.prepare(`
|
|
SELECT COUNT(*) AS total,
|
|
SUM(CASE WHEN s.id IS NOT NULL THEN 1 ELSE 0 END) AS done
|
|
FROM items i
|
|
JOIN units u ON u.id = i.unit_id
|
|
LEFT JOIN submissions s ON s.item_id = i.id AND s.student_id = ?
|
|
WHERE u.course_id = ?
|
|
`).get(studentId, c.id);
|
|
c.total = totals.total;
|
|
c.done = totals.done || 0;
|
|
}
|
|
res.json(courses);
|
|
});
|
|
|
|
// Full course outline with per-item status for this student
|
|
app.get('/api/courses/:id/detail', (req, res) => {
|
|
const studentId = req.query.studentId;
|
|
const course = db.prepare(`SELECT id, 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 i.id, i.type, i.title, i.points, i.sort,
|
|
s.status, s.score, s.points_possible
|
|
FROM items i
|
|
LEFT JOIN submissions s ON s.item_id = i.id AND s.student_id = ?
|
|
WHERE i.unit_id = ? ORDER BY i.sort, i.id
|
|
`);
|
|
for (const u of units) {
|
|
u.items = itemStmt.all(studentId, u.id).map((it) => ({ ...it, status: it.status || 'not_started' }));
|
|
}
|
|
res.json({ ...course, units });
|
|
});
|
|
|
|
// Single item detail for the kid to work on
|
|
app.get('/api/items/:id', (req, res) => {
|
|
const studentId = req.query.studentId;
|
|
const item = db.prepare(`
|
|
SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, u.name AS unit_name, u.course_id, c.name AS course_name
|
|
FROM items i JOIN units u ON u.id = i.unit_id JOIN courses c ON c.id = u.course_id
|
|
WHERE i.id = ?
|
|
`).get(req.params.id);
|
|
if (!item) return res.status(404).json({ error: 'No such item' });
|
|
|
|
item.submission = db.prepare(`
|
|
SELECT status, score, points_possible, answers, completed_at, graded_at
|
|
FROM submissions WHERE student_id = ? AND item_id = ?
|
|
`).get(studentId, req.params.id) || null;
|
|
|
|
if (item.type === 'quiz') {
|
|
const graded = item.submission && item.submission.status === 'graded';
|
|
const savedAnswers = graded && item.submission.answers ? JSON.parse(item.submission.answers) : null;
|
|
item.questions = db.prepare(`
|
|
SELECT id, type, prompt, choices, points ${graded ? ', correct_answer' : ''}
|
|
FROM quiz_questions WHERE item_id = ? ORDER BY sort, id
|
|
`).all(req.params.id).map((q) => ({
|
|
...q,
|
|
choices: JSON.parse(q.choices),
|
|
given: savedAnswers ? savedAnswers[q.id] : undefined,
|
|
}));
|
|
} else if (item.type === 'flashcards') {
|
|
item.deck = db.prepare(`SELECT id, name FROM decks WHERE id = ?`).get(item.ref_id);
|
|
} else if (item.type === 'spelling_practice' || item.type === 'spelling_test') {
|
|
item.list = db.prepare(`SELECT id, name FROM lists WHERE id = ?`).get(item.ref_id);
|
|
}
|
|
res.json(item);
|
|
});
|
|
|
|
// Mark a lesson viewed, an assignment done, or a practice/flashcards session finished.
|
|
// Never downgrades an already-graded submission (quizzes, spelling tests).
|
|
app.post('/api/items/:id/complete', (req, res) => {
|
|
const { studentId, date } = req.body;
|
|
const item = db.prepare(`SELECT id, points FROM items WHERE id = ?`).get(req.params.id);
|
|
if (!item) return res.status(404).json({ error: 'No such item' });
|
|
|
|
db.prepare(`
|
|
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at)
|
|
VALUES (?, ?, 'done', ?, datetime('now'))
|
|
ON CONFLICT (student_id, item_id) DO UPDATE SET completed_at = datetime('now')
|
|
WHERE submissions.status != 'graded'
|
|
`).run(studentId, req.params.id, item.points || null);
|
|
|
|
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Auto-graded quiz submission
|
|
app.post('/api/items/:id/quiz-submit', (req, res) => {
|
|
const { studentId, answers, date } = req.body; // answers: { questionId: value }
|
|
const questions = db.prepare(`SELECT * FROM quiz_questions WHERE item_id = ?`).all(req.params.id);
|
|
if (questions.length === 0) return res.status(404).json({ error: 'No such quiz' });
|
|
|
|
let earned = 0, possible = 0;
|
|
const record = {};
|
|
for (const q of questions) {
|
|
possible += q.points;
|
|
const given = answers ? answers[q.id] : undefined;
|
|
const correct = given !== undefined && normalize(given) === normalize(q.correct_answer);
|
|
if (correct) earned += q.points;
|
|
record[q.id] = given ?? '';
|
|
}
|
|
|
|
db.prepare(`
|
|
INSERT INTO submissions (student_id, item_id, status, score, points_possible, answers, completed_at, graded_at)
|
|
VALUES (?, ?, 'graded', ?, ?, ?, datetime('now'), datetime('now'))
|
|
ON CONFLICT (student_id, item_id) DO UPDATE SET
|
|
status = 'graded', score = excluded.score, points_possible = excluded.points_possible,
|
|
answers = excluded.answers, completed_at = datetime('now'), graded_at = datetime('now')
|
|
`).run(studentId, req.params.id, earned, possible, JSON.stringify(record));
|
|
|
|
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
|
|
res.json({ score: earned, total: possible });
|
|
});
|
|
|
|
// ---------- grading (parent) ----------
|
|
|
|
app.get('/api/grading-queue', requirePin, (req, res) => {
|
|
res.json(db.prepare(`
|
|
SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible,
|
|
s.id AS studentId, s.name AS studentName, s.emoji,
|
|
i.id AS itemId, i.title AS itemTitle,
|
|
c.name AS courseName, u.name AS unitName
|
|
FROM submissions sub
|
|
JOIN students s ON s.id = sub.student_id
|
|
JOIN items i ON i.id = sub.item_id
|
|
JOIN units u ON u.id = i.unit_id
|
|
JOIN courses c ON c.id = u.course_id
|
|
WHERE sub.status = 'done' AND i.type = 'assignment'
|
|
ORDER BY sub.completed_at
|
|
`).all());
|
|
});
|
|
|
|
app.put('/api/submissions/:id/grade', requirePin, (req, res) => {
|
|
const score = Number(req.body.score);
|
|
if (Number.isNaN(score)) return res.status(400).json({ error: 'Score must be a number' });
|
|
db.prepare(`UPDATE submissions SET score = ?, status = 'graded', graded_at = datetime('now') WHERE id = ?`)
|
|
.run(score, req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ---------- gradebook (parent) ----------
|
|
|
|
app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
|
|
const course = db.prepare(`SELECT id, name FROM courses WHERE id = ?`).get(req.params.courseId);
|
|
if (!course) return res.status(404).json({ error: 'No such course' });
|
|
|
|
const gradableItems = db.prepare(`
|
|
SELECT i.id, i.title, i.type, i.points
|
|
FROM items i JOIN units u ON u.id = i.unit_id
|
|
WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')})
|
|
ORDER BY u.sort, i.sort, i.id
|
|
`).all(req.params.courseId, ...GRADABLE_TYPES);
|
|
|
|
const students = db.prepare(`
|
|
SELECT s.id, s.name, s.emoji FROM students s
|
|
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 FROM submissions WHERE student_id = ? AND item_id = ?`);
|
|
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);
|
|
s.scores[item.id] = row || null;
|
|
if (row && row.status === 'graded' && row.points_possible) {
|
|
earned += row.score;
|
|
possible += row.points_possible;
|
|
}
|
|
}
|
|
s.percent = possible > 0 ? Math.round((earned / possible) * 100) : null;
|
|
}
|
|
|
|
res.json({ course, gradableItems, students });
|
|
});
|
|
|
|
// ---------- daily agenda / scheduling ----------
|
|
|
|
// Kid's checklist for one day
|
|
app.get('/api/schedule/:studentId', (req, res) => {
|
|
const date = isDateStr(req.query.date) ? req.query.date : null;
|
|
if (!date) return res.status(400).json({ error: 'date=YYYY-MM-DD required' });
|
|
const tasks = db.prepare(`
|
|
SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done,
|
|
i.id AS itemId, i.type, i.title AS itemTitle,
|
|
c.name AS courseName, c.color AS courseColor,
|
|
sub.status AS subStatus, sub.score, sub.points_possible
|
|
FROM schedule sc
|
|
LEFT JOIN items i ON i.id = sc.item_id
|
|
LEFT JOIN units u ON u.id = i.unit_id
|
|
LEFT JOIN courses c ON c.id = u.course_id
|
|
LEFT JOIN submissions sub ON sub.item_id = sc.item_id AND sub.student_id = sc.student_id
|
|
WHERE sc.student_id = ? AND sc.date = ?
|
|
ORDER BY sc.sort, sc.id
|
|
`).all(req.params.studentId, date);
|
|
res.json({ tasks });
|
|
});
|
|
|
|
// Kid toggles a standalone offline task
|
|
app.post('/api/schedule/:id/done', (req, res) => {
|
|
const row = db.prepare(`SELECT item_id FROM schedule WHERE id = ?`).get(req.params.id);
|
|
if (!row) return res.status(404).json({ error: 'No such task' });
|
|
if (row.item_id) return res.status(400).json({ error: 'Course items complete themselves — open the item instead' });
|
|
const done = req.body.done ? 1 : 0;
|
|
db.prepare(`
|
|
UPDATE schedule SET done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE id = ?
|
|
`).run(done, done, req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/schedule', requirePin, (req, res) => {
|
|
const { studentId, date, itemId, title } = req.body;
|
|
if (!isDateStr(date)) return res.status(400).json({ error: 'date=YYYY-MM-DD required' });
|
|
if (!itemId && !String(title || '').trim()) return res.status(400).json({ error: 'Pick an item or write a task' });
|
|
if (itemId && !db.prepare(`SELECT id FROM items WHERE id = ?`).get(itemId)) {
|
|
return res.status(400).json({ error: 'No such item' });
|
|
}
|
|
const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM schedule WHERE student_id = ? AND date = ?`)
|
|
.get(studentId, date).n;
|
|
const id = db.prepare(`
|
|
INSERT INTO schedule (student_id, date, item_id, title, sort) VALUES (?, ?, ?, ?, ?)
|
|
`).run(studentId, date, itemId || null, itemId ? '' : String(title).trim(), sort).lastInsertRowid;
|
|
res.json({ id });
|
|
});
|
|
|
|
app.delete('/api/schedule/:id', requirePin, (req, res) => {
|
|
db.prepare(`DELETE FROM schedule WHERE id = ?`).run(req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// One week of plan for the parent grid (start = Monday)
|
|
app.get('/api/schedule-week/:studentId', requirePin, (req, res) => {
|
|
const start = isDateStr(req.query.start) ? req.query.start : null;
|
|
if (!start) return res.status(400).json({ error: 'start=YYYY-MM-DD required' });
|
|
const tasks = db.prepare(`
|
|
SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done,
|
|
i.id AS itemId, i.type, i.title AS itemTitle, c.name AS courseName
|
|
FROM schedule sc
|
|
LEFT JOIN items i ON i.id = sc.item_id
|
|
LEFT JOIN units u ON u.id = i.unit_id
|
|
LEFT JOIN courses c ON c.id = u.course_id
|
|
WHERE sc.student_id = ? AND sc.date BETWEEN ? AND date(?, '+4 days')
|
|
ORDER BY sc.date, sc.sort, sc.id
|
|
`).all(req.params.studentId, start, start);
|
|
res.json({ tasks });
|
|
});
|
|
|
|
// Copy one week's plan to another week
|
|
app.post('/api/schedule/copy', requirePin, (req, res) => {
|
|
const { studentId, from, to } = req.body;
|
|
if (!isDateStr(from) || !isDateStr(to)) return res.status(400).json({ error: 'from/to dates required' });
|
|
const offsetDays = Math.round((new Date(to) - new Date(from)) / 86400000);
|
|
const rows = db.prepare(`
|
|
SELECT date, item_id, title, sort FROM schedule WHERE student_id = ? AND date BETWEEN ? AND date(?, '+4 days')
|
|
`).all(studentId, from, from);
|
|
const ins = db.prepare(`
|
|
INSERT INTO schedule (student_id, date, item_id, title, sort) VALUES (?, date(?, ?), ?, ?, ?)
|
|
`);
|
|
for (const r of rows) ins.run(studentId, r.date, `+${offsetDays} days`, r.item_id, r.title, r.sort);
|
|
res.json({ copied: rows.length });
|
|
});
|
|
|
|
// Printable weekly report: schedule completion + any graded work that week
|
|
app.get('/api/week-report/:studentId', requirePin, (req, res) => {
|
|
const start = isDateStr(req.query.start) ? req.query.start : null;
|
|
if (!start) return res.status(400).json({ error: 'start=YYYY-MM-DD required' });
|
|
const student = db.prepare(`SELECT name FROM students WHERE id = ?`).get(req.params.studentId);
|
|
if (!student) return res.status(404).json({ error: 'No such student' });
|
|
|
|
const tasks = db.prepare(`
|
|
SELECT sc.date, sc.title AS offlineTitle, sc.done, i.title AS itemTitle, i.type, c.name AS courseName
|
|
FROM schedule sc
|
|
LEFT JOIN items i ON i.id = sc.item_id
|
|
LEFT JOIN units u ON u.id = i.unit_id
|
|
LEFT JOIN courses c ON c.id = u.course_id
|
|
WHERE sc.student_id = ? AND sc.date BETWEEN ? AND date(?, '+4 days')
|
|
ORDER BY sc.date, sc.sort, sc.id
|
|
`).all(req.params.studentId, start, start);
|
|
|
|
const graded = db.prepare(`
|
|
SELECT sub.score, sub.points_possible, sub.graded_at, i.title AS itemTitle, c.name AS courseName
|
|
FROM submissions sub
|
|
JOIN items i ON i.id = sub.item_id
|
|
JOIN units u ON u.id = i.unit_id
|
|
JOIN courses c ON c.id = u.course_id
|
|
WHERE sub.student_id = ? AND sub.status = 'graded' AND date(sub.graded_at) BETWEEN ? AND date(?, '+4 days')
|
|
ORDER BY sub.graded_at
|
|
`).all(req.params.studentId, start, start);
|
|
|
|
const spellingTests = db.prepare(`
|
|
SELECT t.score, t.total, t.at, l.name AS list
|
|
FROM tests t JOIN lists l ON l.id = t.list_id
|
|
WHERE t.student_id = ? AND date(t.at) BETWEEN ? AND date(?, '+4 days')
|
|
ORDER BY t.at
|
|
`).all(req.params.studentId, start, start);
|
|
|
|
res.json({ student: student.name, start, tasks, graded, spellingTests });
|
|
});
|
|
|
|
// ---------- page scanning: photo -> lesson/quiz content ----------
|
|
|
|
const getSetting = (key) => db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key)?.value;
|
|
|
|
const SCAN_PROMPTS = {
|
|
lesson: `You are an OCR engine. Your only job is to copy the printed text from this image exactly as it appears — word for word, letter for letter. Do NOT summarize, paraphrase, rewrite, or shorten anything. Do NOT add explanations or commentary.
|
|
|
|
Rules:
|
|
- Copy every word of the body text exactly as printed.
|
|
- Preserve paragraph breaks (use a blank line between paragraphs).
|
|
- If there is a heading or title printed on the page, put it in "title". Otherwise use a very short descriptive label.
|
|
- Skip page numbers, running headers/footers, and captions under illustrations — copy body text only.
|
|
- If a word is hard to read, give your best exact guess — never skip it or replace it with a summary.
|
|
|
|
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
|
|
{"title": "the heading printed on the page", "body": "the complete word-for-word text of the page, paragraphs separated by a blank line"}`,
|
|
|
|
quiz: `You are an OCR engine helping digitize a quiz or test page. Copy every question and every answer choice exactly as printed — word for word. Do NOT summarize, rewrite, or skip any question.
|
|
|
|
For each question extract:
|
|
- "type": "mc" if it has lettered/numbered answer choices, "tf" if it is true/false, "short" if it expects a written answer
|
|
- "prompt": the exact question text as printed
|
|
- "choices": for "mc", an array of the exact answer choice texts as printed (empty array for "tf" or "short")
|
|
- "correctAnswer": copy the marked answer ONLY if one is clearly indicated (circled, checked, starred, bolded, or in an answer key on the page). For "tf" use exactly "true" or "false". If no answer is marked, use "".
|
|
- "points": 1 unless the page prints a different value
|
|
|
|
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
|
|
{"title": "the quiz title as printed, or a short label if none", "questions": [{"type": "mc", "prompt": "...", "choices": ["...", "..."], "correctAnswer": "...", "points": 1}]}`,
|
|
};
|
|
|
|
function parseVisionJson(text) {
|
|
try { return JSON.parse(text); } catch { /* fall through */ }
|
|
const match = String(text).match(/\{[\s\S]*\}/);
|
|
if (!match) throw new Error('Model didn\'t return usable JSON. Try again or try a different model.');
|
|
return JSON.parse(match[0]);
|
|
}
|
|
|
|
// Local Ollama provider
|
|
async function callOllama(imageBase64, mode) {
|
|
const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, '');
|
|
const model = getSetting('ollama_model') || 'llava';
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 180_000);
|
|
let res;
|
|
try {
|
|
res = await fetch(`${host}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model, prompt: SCAN_PROMPTS[mode], images: [imageBase64], stream: false, format: 'json' }),
|
|
signal: controller.signal,
|
|
});
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') throw new Error('Ollama timed out (3 min). Try a smaller photo or a faster model.');
|
|
throw new Error(`Couldn't reach Ollama at ${host} — is it running? (${err.message})`);
|
|
} finally { clearTimeout(timer); }
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
if (res.status === 404) throw new Error(`Model "${model}" not found on Ollama. Run: ollama pull ${model}`);
|
|
throw new Error(`Ollama error (${res.status}): ${text.slice(0, 200)}`);
|
|
}
|
|
const data = await res.json();
|
|
return parseVisionJson(data.response);
|
|
}
|
|
|
|
// Ollama Cloud provider — routes through local Ollama with :cloud model tag.
|
|
// Cloud models are pulled via local Ollama (e.g. `ollama pull gemma4:cloud`) and
|
|
// automatically offloaded to Ollama's cloud infrastructure.
|
|
async function callOllamaCloud(imageBase64, mode) {
|
|
const apiKey = getSetting('ollama_cloud_api_key') || '';
|
|
const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, '');
|
|
// Ensure model has :cloud suffix so Ollama offloads it
|
|
const baseModel = (getSetting('ollama_cloud_model') || 'gemma4').replace(/:cloud$/, '');
|
|
const model = `${baseModel}:cloud`;
|
|
if (!apiKey) throw new Error('Ollama Cloud API key not set — add it in Settings.');
|
|
|
|
// Ollama reads OLLAMA_API_KEY from env; we inject it per-request via Authorization header
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 180_000);
|
|
let res;
|
|
try {
|
|
// Auth is handled by Ollama via OLLAMA_API_KEY env var — do not send it as a header
|
|
res = await fetch(`${host}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: [{ role: 'user', content: SCAN_PROMPTS[mode], images: [imageBase64] }],
|
|
stream: false,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') throw new Error('Ollama Cloud timed out (3 min).');
|
|
throw new Error(`Couldn't reach local Ollama at ${host} — is it running? (${err.message})`);
|
|
} finally { clearTimeout(timer); }
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
if (res.status === 404) throw new Error(`Cloud model "${model}" not found. Run: ollama pull ${model}`);
|
|
throw new Error(`Ollama Cloud error (${res.status}): ${text.slice(0, 300)}`);
|
|
}
|
|
const data = await res.json();
|
|
const content = data.message?.content;
|
|
if (!content) throw new Error('Ollama Cloud returned an empty response.');
|
|
return parseVisionJson(content);
|
|
}
|
|
|
|
// Try primary provider, fall back to the other
|
|
app.post('/api/admin/scan', requirePin, async (req, res) => {
|
|
const { image, mode } = req.body;
|
|
if (!image) return res.status(400).json({ error: 'No image provided' });
|
|
if (!SCAN_PROMPTS[mode]) return res.status(400).json({ error: 'mode must be "lesson" or "quiz"' });
|
|
|
|
const primary = getSetting('scan_primary') || 'local';
|
|
const providerFns = { local: callOllama, ollama_cloud: callOllamaCloud };
|
|
const order = primary === 'ollama_cloud'
|
|
? ['ollama_cloud', 'local']
|
|
: ['local', 'ollama_cloud'];
|
|
const isConfigured = {
|
|
local: true,
|
|
ollama_cloud: !!getSetting('ollama_cloud_api_key'),
|
|
};
|
|
|
|
const errors = [];
|
|
for (const provider of order) {
|
|
if (!isConfigured[provider]) continue;
|
|
try {
|
|
const result = await providerFns[provider](image, mode);
|
|
return res.json({ ...result, _provider: provider, _fallback: provider !== primary, _primaryError: errors[0] || null });
|
|
} catch (err) {
|
|
errors.push(`${provider}: ${err.message}`);
|
|
}
|
|
}
|
|
return res.status(502).json({ error: errors.join(' | ') || 'No scan providers configured.' });
|
|
});
|
|
|
|
app.get('/api/admin/scan-config', requirePin, (req, res) => {
|
|
res.json({
|
|
primary: getSetting('scan_primary') || 'local',
|
|
ollama_host: getSetting('ollama_host'),
|
|
ollama_model: getSetting('ollama_model'),
|
|
ollama_cloud_model: getSetting('ollama_cloud_model') || '',
|
|
ollama_cloud_has_key: !!(getSetting('ollama_cloud_api_key')),
|
|
});
|
|
});
|
|
|
|
app.post('/api/admin/scan-config', requirePin, (req, res) => {
|
|
const { primary, ollama_host, ollama_model, ollama_cloud_model, ollama_cloud_api_key } = req.body;
|
|
const set = (k, v) => {
|
|
if (v === undefined) return;
|
|
const s = String(v).trim();
|
|
if (s) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(k, s);
|
|
};
|
|
set('scan_primary', primary);
|
|
set('ollama_host', ollama_host);
|
|
set('ollama_model', ollama_model);
|
|
set('ollama_cloud_model', ollama_cloud_model);
|
|
if (ollama_cloud_api_key !== undefined)
|
|
db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_cloud_api_key', String(ollama_cloud_api_key).trim());
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Keep backward-compat routes for existing ollama-config/test calls
|
|
app.get('/api/admin/ollama-config', requirePin, (req, res) => {
|
|
res.json({ host: getSetting('ollama_host'), model: getSetting('ollama_model') });
|
|
});
|
|
app.post('/api/admin/ollama-config', requirePin, (req, res) => {
|
|
const { host, model } = req.body;
|
|
if (String(host || '').trim()) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_host', String(host).trim());
|
|
if (String(model || '').trim()) db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('ollama_model', String(model).trim());
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/admin/ollama-test', requirePin, async (req, res) => {
|
|
const host = (getSetting('ollama_host') || '').replace(/\/+$/, '');
|
|
const model = getSetting('ollama_model');
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
try {
|
|
const r = await fetch(`${host}/api/tags`, { signal: controller.signal });
|
|
if (!r.ok) throw new Error(`Ollama responded with HTTP ${r.status}`);
|
|
const { models } = await r.json();
|
|
const names = (models || []).map((m) => m.name);
|
|
const hasModel = names.some((n) => n === model || n.startsWith(`${model}:`));
|
|
res.json({ reachable: true, hasModel, availableModels: names });
|
|
} catch (err) {
|
|
res.json({ reachable: false, error: err.name === 'AbortError' ? 'Timed out reaching Ollama' : err.message });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
});
|
|
|
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
app.listen(PORT, () => console.log(`Spelling v2 running on http://localhost:${PORT}`));
|