Replace docx text extraction with file attachment upload

Instead of parsing .docx content via mammoth and inserting raw text/HTML,
the import flow now saves the file to data/attachments/ (UUID filename) and
inserts an <a> link into the item body so students can open/download the doc.

- Added GET /api/attachments/:filename (no PIN — students access directly)
- Added POST /api/admin/upload-attachment (PIN required)
- Removed mammoth dependency from server
- Updated admin.js import handler to POST to new endpoint and set link HTML

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-30 08:56:12 -04:00
1 parent 6b72031672
commit 5de4cfa6cf
2 files changed
+33 -32

No files matched your search

+10 -13
View File
@@ -607,7 +607,7 @@ $('#import-go-btn').addEventListener('click', async () => {
if (!file) return; if (!file) return;
const type = $('#ie-type').value; const type = $('#ie-type').value;
$('#import-go-btn').disabled = true; $('#import-go-btn').disabled = true;
$('#import-status').textContent = '📄 Extracting text…'; $('#import-status').textContent = '📤 Uploading…';
try { try {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer); const bytes = new Uint8Array(arrayBuffer);
@@ -616,20 +616,17 @@ $('#import-go-btn').addEventListener('click', async () => {
binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + 8192, bytes.length))); binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + 8192, bytes.length)));
} }
const base64 = btoa(binary); const base64 = btoa(binary);
const result = await api('/api/admin/import-docx', { method: 'POST', body: { base64 } }); const result = await api('/api/admin/upload-attachment', {
if (!$('#ie-title').value && result.title) $('#ie-title').value = result.title; method: 'POST',
if (result.body) { body: { base64, originalName: file.name },
if (type === 'assignment') { });
const tmp = document.createElement('div'); const link = `<a href="${result.url}" target="_blank">📄 ${result.originalName}</a>`;
tmp.innerHTML = result.body; if (type === 'assignment') {
$('#ie-body-assignment').value = tmp.textContent || tmp.innerText || ''; $('#ie-body-assignment').value = link;
} else if (type === 'lesson') {
$('#ie-body-lesson').innerHTML = result.body;
}
$('#import-status').textContent = '✅ Text extracted. Review and edit before saving.';
} else { } else {
$('#import-status').textContent = '⚠️ File imported but no content was found.'; $('#ie-body-lesson').innerHTML = link;
} }
$('#import-status').textContent = '✅ File uploaded. Link inserted into body.';
} catch (err) { } catch (err) {
$('#import-status').textContent = `❌ ${err.message}`; $('#import-status').textContent = `❌ ${err.message}`;
} finally { } finally {
+23 -19
View File
@@ -1,17 +1,18 @@
import express from 'express'; import express from 'express';
import { join, dirname } from 'node:path'; import { join, dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import mammoth from 'mammoth';
import { db, sha256 } from './db.js'; import { db, sha256 } from './db.js';
const ROOT = dirname(fileURLToPath(import.meta.url)); const ROOT = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = process.env.DATA_DIR || join(ROOT, 'data'); const DATA_DIR = process.env.DATA_DIR || join(ROOT, 'data');
const TTS_CACHE = join(DATA_DIR, 'tts-cache'); const TTS_CACHE = join(DATA_DIR, 'tts-cache');
mkdirSync(TTS_CACHE, { recursive: true }); mkdirSync(TTS_CACHE, { recursive: true });
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
const PIPER_BIN = process.env.PIPER_BIN || 'piper'; const PIPER_BIN = process.env.PIPER_BIN || 'piper';
const PIPER_MODEL = process.env.PIPER_MODEL || join(ROOT, 'voices', 'en_US-lessac-medium.onnx'); const PIPER_MODEL = process.env.PIPER_MODEL || join(ROOT, 'voices', 'en_US-lessac-medium.onnx');
const PIPER_OK = existsSync(PIPER_MODEL); const PIPER_OK = existsSync(PIPER_MODEL);
@@ -1519,30 +1520,33 @@ app.get('/api/admin/homeschool-files', requirePin, (req, res) => {
}); });
app.post('/api/admin/import-docx', requirePin, async (req, res) => { // Public: students need to open attachments without a PIN
const { base64: b64 } = req.body; app.get('/api/attachments/:filename', (req, res) => {
if (!b64) return res.status(400).json({ error: 'base64 required' }); const filename = req.params.filename;
// Only allow safe filenames (UUID + extension)
if (!/^[\w-]+\.\w+$/.test(filename)) return res.status(400).json({ error: 'Invalid filename' });
const filePath = join(ATTACHMENTS_DIR, filename);
if (!existsSync(filePath)) return res.status(404).json({ error: 'Not found' });
res.sendFile(filePath);
});
app.post('/api/admin/upload-attachment', requirePin, async (req, res) => {
const { base64: b64, originalName } = req.body;
if (!b64 || !originalName) return res.status(400).json({ error: 'base64 and originalName required' });
let buffer; let buffer;
try { buffer = Buffer.from(b64, 'base64'); } try { buffer = Buffer.from(b64, 'base64'); }
catch (err) { return res.status(400).json({ error: 'Invalid base64' }); } catch (err) { return res.status(400).json({ error: 'Invalid base64' }); }
let html, rawText; const ext = extname(originalName).toLowerCase() || '.docx';
const filename = `${randomUUID()}${ext}`;
try { try {
const [htmlResult, textResult] = await Promise.all([ await writeFile(join(ATTACHMENTS_DIR, filename), buffer);
mammoth.convertToHtml({ buffer }),
mammoth.extractRawText({ buffer }),
]);
html = htmlResult.value.trim();
rawText = textResult.value.trim();
} catch (err) { } catch (err) {
return res.status(500).json({ error: `Could not parse docx: ${err.message}` }); return res.status(500).json({ error: `Could not save file: ${err.message}` });
} }
if (!rawText) return res.status(400).json({ error: 'Document appears to be empty' });
// Use first non-empty line of raw text as the title return res.json({ url: `/api/attachments/${filename}`, originalName });
const firstLine = rawText.split('\n').map(l => l.trim()).find(Boolean) || '';
return res.json({ title: firstLine, body: html });
}); });
app.get('/health', (req, res) => res.json({ ok: true })); app.get('/health', (req, res) => res.json({ ok: true }));