- Add Cloudflare Turnstile to login, register, and password reset forms - Switch AI provider to LiteLLM, transcription to OpenAI Whisper - Change domain to scribe.pedshub.com - Fix PPTX export: add tables, bold/italic, numbered lists, code blocks, blockquotes - Fix announcement banner close button (CSP was blocking inline onclick) - Fix auth middleware: empty Bearer token now falls through to cookie auth - Fix audio backups: only save on transcription failure, stop auto-deleting on success - Soften AI correction injection to prevent model hallucination from correction history - Fix LiteLLM TTS model name handling (no incorrect openai/ prefix) - Expand AI instructions textarea in Learning Hub CMS - Update README for v6 with all features and providers - Add comprehensive docs/: architecture, API reference, database schema, authentication, AI providers, speech, learning hub, configuration, deployment
735 lines
30 KiB
JavaScript
735 lines
30 KiB
JavaScript
// ============================================================
|
|
// LEARNING AI ROUTES — AI-assisted content generation for Learning Hub
|
|
// ============================================================
|
|
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
var multer = require('multer');
|
|
var axios = require('axios');
|
|
var path = require('path');
|
|
var { callAI } = require('../utils/ai');
|
|
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
|
|
var db = require('../db/database');
|
|
|
|
router.use(authMiddleware);
|
|
router.use(moderatorMiddleware);
|
|
|
|
var upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: {
|
|
fileSize: 100 * 1024 * 1024, // 100 MB per file (large PDFs supported)
|
|
files: 10 // max 10 files at once
|
|
},
|
|
fileFilter: function(req, file, cb) {
|
|
// Whitelist allowed file types
|
|
var allowed = [
|
|
'application/pdf',
|
|
'text/plain',
|
|
'text/markdown',
|
|
'text/html',
|
|
'text/csv',
|
|
'application/json'
|
|
];
|
|
if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json)$/i)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('File type not allowed. Only PDF, TXT, MD, HTML, CSV, JSON are supported.'));
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Text extraction helpers ──────────────────────────────────
|
|
|
|
async function extractText(buffer, mimetype, filename) {
|
|
var ext = (filename || '').split('.').pop().toLowerCase();
|
|
|
|
// PDF
|
|
if (mimetype === 'application/pdf' || ext === 'pdf') {
|
|
try {
|
|
var pdfParse = require('pdf-parse');
|
|
var data = await pdfParse(buffer);
|
|
return data.text || '';
|
|
} catch (e) {
|
|
throw new Error('Could not parse PDF: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Plain text / markdown / HTML
|
|
if (mimetype.startsWith('text/') || ['txt', 'md', 'html', 'htm', 'csv'].includes(ext)) {
|
|
return buffer.toString('utf8');
|
|
}
|
|
|
|
// JSON
|
|
if (mimetype === 'application/json' || ext === 'json') {
|
|
return buffer.toString('utf8');
|
|
}
|
|
|
|
// Fallback — try utf8
|
|
return buffer.toString('utf8');
|
|
}
|
|
|
|
// ── Build AI prompt ──────────────────────────────────────────
|
|
|
|
function buildGeneratePrompt(opts) {
|
|
var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount } = opts;
|
|
|
|
var source = docText
|
|
? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 50000) + '\n"""\n'
|
|
: 'Generate educational content on the following topic for a medical professional audience (pediatrics / primary care).\n\nTOPIC: ' + topic + '\n';
|
|
|
|
var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
|
|
|
|
// ── Presentation ──
|
|
if (contentType === 'presentation') {
|
|
var slideHint = slideCount ? slideCount + ' slides' : '8-12 slides';
|
|
var hasQuestions = parseInt(questionCount) > 0;
|
|
|
|
if (!hasQuestions) {
|
|
// No questions — return raw Marp markdown only
|
|
return source + '\n' +
|
|
'Create a professional Marp presentation (' + slideHint + ') suitable for medical education. ' +
|
|
'Each slide should be focused and readable.' + refineInstr + `
|
|
|
|
Return ONLY valid Marp markdown (no JSON, no code fences). Start with frontmatter:
|
|
---
|
|
marp: true
|
|
theme: default
|
|
---
|
|
|
|
Then each slide separated by ---. Guidelines:
|
|
- First slide: title slide with presentation name and brief subtitle
|
|
- Use # for slide titles
|
|
- Use bullet points (- ) for lists, keep them concise (max 5 bullets per slide)
|
|
- Include a summary/key takeaways slide at the end
|
|
- Do NOT include HTML tags or inline styles`;
|
|
}
|
|
|
|
// With questions — return JSON containing both Marp markdown and questions
|
|
return source + '\n' +
|
|
'Create a professional Marp presentation (' + slideHint + ') suitable for medical education, ' +
|
|
'AND ' + questionCount + ' quiz questions based on the content.' + refineInstr + `
|
|
|
|
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
|
|
{
|
|
"title": "string",
|
|
"marpMarkdown": "string (the complete Marp markdown starting with frontmatter ---\\nmarp: true\\ntheme: default\\n---)",
|
|
"questions": [
|
|
{
|
|
"question_text": "string",
|
|
"question_type": "mcq",
|
|
"explanation": "string",
|
|
"options": [
|
|
{ "option_text": "string", "is_correct": false, "explanation": "string" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Marp guidelines inside marpMarkdown:
|
|
- Start with: ---\\nmarp: true\\ntheme: default\\n---
|
|
- Separate slides with \\n---\\n
|
|
- First slide: title + subtitle. Use # for titles, bullets for content.
|
|
- Each MCQ must have exactly 4 options, exactly 1 marked is_correct: true`;
|
|
}
|
|
|
|
var wordHint = wordCount ? ' Target approximately ' + wordCount + ' words for the body.' : '';
|
|
var qInstr = parseInt(questionCount) > 0
|
|
? 'then generate ' + questionCount + ' quiz questions.'
|
|
: 'Do NOT include quiz questions (questions array should be empty []).';
|
|
|
|
var typeInstr = '';
|
|
if (contentType === 'quiz') {
|
|
typeInstr = 'This is a quiz-only resource. Write a brief introductory body (1-2 paragraphs),' + wordHint + ' ' + qInstr;
|
|
} else if (contentType === 'pearl') {
|
|
typeInstr = 'This is a clinical pearl. Write a concise, high-impact body (2-4 paragraphs focusing on key takeaways).' + wordHint + ' ' + qInstr;
|
|
} else {
|
|
typeInstr = 'This is an article. Write a comprehensive, well-structured body.' + wordHint + ' ' + qInstr;
|
|
}
|
|
|
|
return source + '\n' + typeInstr + refineInstr + `
|
|
|
|
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
|
|
{
|
|
"title": "string",
|
|
"subject": "string (1-3 word sub-topic label)",
|
|
"body": "string (valid HTML using only: p, h2, h3, ul, ol, li, strong, em, blockquote, code — no inline styles)",
|
|
"questions": [
|
|
{
|
|
"question_text": "string",
|
|
"question_type": "mcq",
|
|
"explanation": "string (general explanation shown after answering)",
|
|
"options": [
|
|
{ "option_text": "string", "is_correct": false, "explanation": "string (shown if this wrong option chosen)" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Rules:
|
|
- Each MCQ must have exactly 4 options, exactly 1 marked is_correct: true
|
|
- question_type must be "mcq" or "true_false" (true_false has exactly 2 options: "True" and "False")
|
|
- body must be clean HTML, no raw markdown
|
|
- Do not include any text outside the JSON object`;
|
|
}
|
|
|
|
// ── POST /api/admin/learning/ai-generate ────────────────────
|
|
// Accepts: multipart/form-data OR application/json
|
|
|
|
router.post('/ai-generate', upload.array('files', 10), async function(req, res) {
|
|
try {
|
|
var topic = req.body.topic || '';
|
|
var contentType = req.body.contentType || 'article';
|
|
var questionCount = Math.min(parseInt(req.body.questionCount) || 0, 20);
|
|
var model = req.body.model || null;
|
|
var refinement = req.body.refinement || '';
|
|
var webdavPath = req.body.webdavPath || '';
|
|
var wordCount = parseInt(req.body.wordCount) || 0;
|
|
var slideCount = parseInt(req.body.slideCount) || 0;
|
|
|
|
var docText = '';
|
|
var fileCount = 0;
|
|
|
|
// 1 — Uploaded files (multiple)
|
|
if (req.files && req.files.length > 0) {
|
|
var allTexts = [];
|
|
for (var i = 0; i < req.files.length; i++) {
|
|
var file = req.files[i];
|
|
try {
|
|
var text = await extractText(file.buffer, file.mimetype, file.originalname);
|
|
allTexts.push('### Source File: ' + file.originalname + '\n\n' + text);
|
|
fileCount++;
|
|
} catch (e) {
|
|
console.error('[LearningAI] Failed to extract text from ' + file.originalname + ':', e.message);
|
|
// Continue with other files even if one fails
|
|
}
|
|
}
|
|
docText = allTexts.join('\n\n---\n\n');
|
|
}
|
|
|
|
// 2 — Nextcloud WebDAV path
|
|
else if (webdavPath) {
|
|
var user = await db.get(
|
|
'SELECT nextcloud_url, nextcloud_user, nextcloud_token FROM users WHERE id = ?',
|
|
[req.user.id]
|
|
);
|
|
if (!user || !user.nextcloud_url) {
|
|
return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings first.' });
|
|
}
|
|
var fileUrl = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + webdavPath;
|
|
var response = await axios.get(fileUrl, {
|
|
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
|
|
responseType: 'arraybuffer',
|
|
timeout: 30000
|
|
});
|
|
var mimeType = response.headers['content-type'] || 'text/plain';
|
|
var fname = path.basename(webdavPath);
|
|
docText = await extractText(Buffer.from(response.data), mimeType, fname);
|
|
}
|
|
|
|
// 3 — Topic only (no file)
|
|
else if (!topic.trim()) {
|
|
return res.status(400).json({ error: 'Provide a topic or upload a file.' });
|
|
}
|
|
|
|
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount });
|
|
|
|
var result = await callAI(
|
|
[
|
|
{ role: 'system', content: 'You are a medical education content generator. Return ONLY the requested JSON or Marp markdown — no preamble, no commentary, no code fences, no thinking. Start your response with { or --- as appropriate.' },
|
|
{ role: 'user', content: prompt }
|
|
],
|
|
{ model: model, temperature: 0.4, maxTokens: 8000 }
|
|
);
|
|
|
|
var raw = result.content.trim();
|
|
// Strip any leading text before the first { or --- (models sometimes add preamble)
|
|
if (contentType !== 'presentation' || parseInt(questionCount) > 0) {
|
|
var jsonStart = raw.indexOf('{');
|
|
if (jsonStart > 0) raw = raw.substring(jsonStart);
|
|
}
|
|
|
|
// ── Presentation ──
|
|
if (contentType === 'presentation') {
|
|
if (questionCount > 0) {
|
|
// JSON response with marpMarkdown + questions
|
|
var cleanRaw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
var parsedPres;
|
|
try { parsedPres = JSON.parse(cleanRaw); }
|
|
catch(e) {
|
|
var m = cleanRaw.match(/\{[\s\S]*\}/);
|
|
try { parsedPres = m ? JSON.parse(m[0]) : null; } catch(e2) { parsedPres = null; }
|
|
}
|
|
if (parsedPres && parsedPres.marpMarkdown) {
|
|
return res.json({ success: true, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, questions: parsedPres.questions || [], model: result.model });
|
|
}
|
|
}
|
|
// Plain Marp markdown (no questions requested, or parse failed)
|
|
var marpMd = raw.replace(/^```(?:markdown|marp)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
return res.json({ success: true, contentType: 'presentation', marpMarkdown: marpMd, questions: [], model: result.model, docLength: docText.length });
|
|
}
|
|
|
|
// Strip code fences and any trailing text after JSON
|
|
raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
// Trim trailing non-JSON text (e.g. "Here is the JSON..." after closing brace)
|
|
var lastBrace = raw.lastIndexOf('}');
|
|
if (lastBrace !== -1 && lastBrace < raw.length - 1) raw = raw.substring(0, lastBrace + 1);
|
|
|
|
// Fix unescaped control characters inside JSON string values.
|
|
// Models sometimes output literal newlines/tabs inside strings instead of \n \t.
|
|
function sanitizeJsonString(s) {
|
|
var inStr = false, escaped = false, out = '';
|
|
for (var i = 0; i < s.length; i++) {
|
|
var c = s[i];
|
|
if (escaped) { out += c; escaped = false; continue; }
|
|
if (c === '\\' && inStr) { out += c; escaped = true; continue; }
|
|
if (c === '"') { inStr = !inStr; out += c; continue; }
|
|
if (inStr && c === '\n') { out += '\\n'; continue; }
|
|
if (inStr && c === '\r') { continue; }
|
|
if (inStr && c === '\t') { out += '\\t'; continue; }
|
|
out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
var parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (e) {
|
|
console.error('[LearningAI] Direct parse failed:', e.message, '| Pos:', e.message.match(/position (\d+)/)?.[1], '| Char context:', raw.substring(parseInt(e.message.match(/position (\d+)/)?.[1] || 0) - 40, parseInt(e.message.match(/position (\d+)/)?.[1] || 0) + 40));
|
|
// Attempt 2: sanitize control chars and retry
|
|
try { parsed = JSON.parse(sanitizeJsonString(raw)); }
|
|
catch (e2) {
|
|
// Attempt 3: extract first { to last }
|
|
var firstBrace = raw.indexOf('{');
|
|
var extractedJson = firstBrace >= 0 ? raw.substring(firstBrace, lastBrace + 1) : null;
|
|
if (extractedJson) {
|
|
try { parsed = JSON.parse(sanitizeJsonString(extractedJson)); }
|
|
catch (e3) {
|
|
console.error('[LearningAI] All parse attempts failed:', e3.message);
|
|
return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' });
|
|
}
|
|
} else {
|
|
console.error('[LearningAI] No JSON braces found');
|
|
return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' });
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
content: parsed,
|
|
model: result.model,
|
|
docLength: docText.length,
|
|
fileCount: fileCount || (webdavPath ? 1 : 0)
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('[LearningAI]', err.message);
|
|
res.status(500).json({ error: err.message || 'Generation failed' });
|
|
}
|
|
});
|
|
|
|
// ── POST /api/admin/learning/ai-refine ───────────────────────
|
|
// Refine an existing body or specific question
|
|
|
|
router.post('/ai-refine', async function(req, res) {
|
|
try {
|
|
var { content, instructions, model } = req.body;
|
|
if (!content || !instructions) return res.status(400).json({ error: 'content and instructions required' });
|
|
|
|
var prompt = `You are editing educational medical content. Refine the following HTML body according to the instructions below.
|
|
|
|
INSTRUCTIONS: ${instructions}
|
|
|
|
CURRENT CONTENT:
|
|
${content.substring(0, 8000)}
|
|
|
|
Return ONLY the refined HTML body (same structure, no JSON wrapper, no markdown fences). Keep all HTML tags intact.`;
|
|
|
|
var result = await callAI(
|
|
[{ role: 'user', content: prompt }],
|
|
{ model: model, temperature: 0.3, maxTokens: 4000 }
|
|
);
|
|
|
|
var refined = result.content.trim().replace(/^```(?:html)?\s*/i, '').replace(/\s*```\s*$/, '');
|
|
res.json({ success: true, refined, model: result.model });
|
|
} catch (err) {
|
|
console.error('[LearningAI]', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── GET /api/admin/learning/webdav-browse ────────────────────
|
|
// Browse user's Nextcloud folder
|
|
|
|
router.get('/webdav-browse', async function(req, res) {
|
|
try {
|
|
var user = await db.get(
|
|
'SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder, webdav_learning_path FROM users WHERE id = ?',
|
|
[req.user.id]
|
|
);
|
|
if (!user || !user.nextcloud_url) {
|
|
return res.status(400).json({ error: 'Nextcloud not connected' });
|
|
}
|
|
|
|
var browsePath = req.query.path || user.webdav_learning_path || user.nextcloud_folder || '/';
|
|
// Ensure it starts with /
|
|
if (!browsePath.startsWith('/')) browsePath = '/' + browsePath;
|
|
|
|
var davUrl = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + browsePath;
|
|
|
|
var davResponse = await axios({
|
|
method: 'PROPFIND',
|
|
url: davUrl,
|
|
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
|
|
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
|
data: `<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/></d:prop></d:propfind>`,
|
|
timeout: 15000
|
|
});
|
|
|
|
// Parse WebDAV XML response
|
|
var xml = davResponse.data;
|
|
var items = [];
|
|
var responseRegex = /<d:response>([\s\S]*?)<\/d:response>/g;
|
|
var match;
|
|
while ((match = responseRegex.exec(xml)) !== null) {
|
|
var block = match[1];
|
|
var hrefMatch = block.match(/<d:href>([^<]+)<\/d:href>/);
|
|
var nameMatch = block.match(/<d:displayname>([^<]*)<\/d:displayname>/);
|
|
var typeMatch = block.match(/<d:getcontenttype>([^<]*)<\/d:getcontenttype>/);
|
|
var sizeMatch = block.match(/<d:getcontentlength>([^<]*)<\/d:getcontentlength>/);
|
|
var isCollMatch = block.includes('<d:collection');
|
|
|
|
if (!hrefMatch) continue;
|
|
|
|
var href = decodeURIComponent(hrefMatch[1]);
|
|
// Strip the /remote.php/dav/files/username prefix to get the relative path
|
|
var relPath = href.replace(/^.*\/remote\.php\/dav\/files\/[^/]+/, '') || '/';
|
|
|
|
items.push({
|
|
path: relPath,
|
|
name: nameMatch ? nameMatch[1] : path.basename(relPath) || relPath,
|
|
isDir: !!isCollMatch,
|
|
contentType: typeMatch ? typeMatch[1] : '',
|
|
size: sizeMatch ? parseInt(sizeMatch[1]) : 0
|
|
});
|
|
}
|
|
|
|
// Filter: skip the parent directory entry (same path as requested)
|
|
var normBrowse = browsePath.replace(/\/$/, '');
|
|
items = items.filter(function(item) {
|
|
var normPath = item.path.replace(/\/$/, '');
|
|
return normPath !== normBrowse;
|
|
});
|
|
|
|
// Sort: directories first, then files
|
|
items.sort(function(a, b) {
|
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
// Compute parent path
|
|
var parent = browsePath.replace(/\/$/, '');
|
|
var parentPath = parent.includes('/') ? parent.substring(0, parent.lastIndexOf('/')) || '/' : '/';
|
|
|
|
res.json({ success: true, path: browsePath, parentPath, items });
|
|
} catch (err) {
|
|
console.error('[WebDAV]', err.message);
|
|
res.status(500).json({ error: 'WebDAV browse failed: ' + err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /api/admin/learning/webdav-path ─────────────────────
|
|
// Save user's preferred WebDAV learning path
|
|
|
|
router.post('/webdav-path', async function(req, res) {
|
|
try {
|
|
var { path: wPath } = req.body;
|
|
await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [wPath || null, req.user.id]);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /api/admin/learning/generate-pptx ──────────────────
|
|
// Convert Marp markdown to PPTX using pptxgenjs (no browser needed)
|
|
|
|
router.post('/generate-pptx', async function(req, res) {
|
|
try {
|
|
var { markdown, title } = req.body;
|
|
if (!markdown) return res.status(400).json({ error: 'markdown required' });
|
|
|
|
var PptxGenJS = require('pptxgenjs');
|
|
var pptx = new PptxGenJS();
|
|
|
|
pptx.layout = 'LAYOUT_WIDE'; // 16:9
|
|
|
|
// ── Helpers ────────────────────────────────────────────────
|
|
|
|
// Parse inline markdown (**bold**, *italic*, `code`, ***both***) into pptxgenjs text objects
|
|
function parseInline(text, defaults) {
|
|
var parts = [];
|
|
var re = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
|
var last = 0;
|
|
var m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
if (m.index > last) parts.push({ text: text.slice(last, m.index), options: Object.assign({}, defaults) });
|
|
if (m[2]) parts.push({ text: m[2], options: Object.assign({}, defaults, { bold: true, italic: true }) });
|
|
else if (m[3]) parts.push({ text: m[3], options: Object.assign({}, defaults, { bold: true }) });
|
|
else if (m[4]) parts.push({ text: m[4], options: Object.assign({}, defaults, { italic: true }) });
|
|
else if (m[5]) parts.push({ text: m[5], options: Object.assign({}, defaults, { fontFace: 'Courier New', color: '7c3aed' }) });
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (last < text.length) parts.push({ text: text.slice(last), options: Object.assign({}, defaults) });
|
|
return parts.length ? parts : [{ text: text, options: Object.assign({}, defaults) }];
|
|
}
|
|
|
|
// Parse a markdown table block into { headers: string[], rows: string[][] }
|
|
function parseTable(lines) {
|
|
var headers = lines[0].split('|').map(function(c) { return c.trim(); }).filter(Boolean);
|
|
var rows = [];
|
|
for (var i = 2; i < lines.length; i++) {
|
|
var cells = lines[i].split('|').map(function(c) { return c.trim(); }).filter(Boolean);
|
|
if (cells.length) rows.push(cells);
|
|
}
|
|
return { headers: headers, rows: rows };
|
|
}
|
|
|
|
// Classify a line by type
|
|
function classifyLine(line) {
|
|
if (/^\|.+\|/.test(line)) return 'table';
|
|
if (/^\|[\s:-]+\|/.test(line)) return 'table-sep';
|
|
if (/^>\s+/.test(line)) return 'blockquote';
|
|
if (/^```/.test(line)) return 'code-fence';
|
|
if (/^\d+\.\s+/.test(line)) return 'ordered';
|
|
if (/^[-*]\s+/.test(line)) return 'bullet';
|
|
if (/^#{1,3}\s+/.test(line)) return 'heading';
|
|
if (line.trim() === '') return 'blank';
|
|
return 'paragraph';
|
|
}
|
|
|
|
// ── Parse slides ───────────────────────────────────────────
|
|
|
|
var md = markdown.replace(/^---[\s\S]*?---\n?/, '').trim();
|
|
var rawSlides = md.split(/\n---\n/);
|
|
|
|
rawSlides.forEach(function(slideText, slideIdx) {
|
|
slideText = slideText.trim();
|
|
if (!slideText) return;
|
|
|
|
var slide = pptx.addSlide();
|
|
slide.background = { color: 'FFFFFF' };
|
|
|
|
// Extract slide title (first # heading)
|
|
var titleMatch = slideText.match(/^#{1,3}\s+(.+)$/m);
|
|
var slideTitle = titleMatch ? titleMatch[1].trim() : '';
|
|
var body = slideText.replace(/^#{1,3}\s+.+$/m, '').trim();
|
|
|
|
if (slideTitle) {
|
|
slide.addText(parseInline(slideTitle, { fontSize: 28, bold: true, color: '1e40af', fontFace: 'Calibri' }), {
|
|
x: 0.5, y: 0.3, w: '90%', h: 0.8, valign: 'middle'
|
|
});
|
|
}
|
|
|
|
if (!body) {
|
|
slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' });
|
|
return;
|
|
}
|
|
|
|
var contentY = slideTitle ? 1.3 : 0.6;
|
|
var maxH = 5.2 - contentY;
|
|
var lines = body.split('\n');
|
|
var i = 0;
|
|
var inCodeBlock = false;
|
|
var codeLines = [];
|
|
|
|
while (i < lines.length) {
|
|
var line = lines[i];
|
|
var type = classifyLine(line);
|
|
|
|
// ── Code block ──
|
|
if (type === 'code-fence' || inCodeBlock) {
|
|
if (type === 'code-fence' && !inCodeBlock) {
|
|
inCodeBlock = true; codeLines = []; i++; continue;
|
|
}
|
|
if (type === 'code-fence' && inCodeBlock) {
|
|
inCodeBlock = false;
|
|
if (codeLines.length) {
|
|
var codeText = codeLines.join('\n');
|
|
var codeH = Math.min(Math.max(codeLines.length * 0.28, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
|
|
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: codeH, fill: { color: 'f3f4f6' }, rectRadius: 0.08 });
|
|
slide.addText(codeText, {
|
|
x: 0.65, y: contentY + 0.08, w: '86%', h: codeH - 0.16,
|
|
fontSize: 13, fontFace: 'Courier New', color: '1f2937', valign: 'top', wrap: true
|
|
});
|
|
contentY += codeH + 0.2;
|
|
codeLines = [];
|
|
}
|
|
i++; continue;
|
|
}
|
|
codeLines.push(line);
|
|
i++; continue;
|
|
}
|
|
|
|
// ── Table ──
|
|
if (type === 'table') {
|
|
var tableLines = [];
|
|
while (i < lines.length && /^\|/.test(lines[i])) { tableLines.push(lines[i]); i++; }
|
|
var tbl = parseTable(tableLines);
|
|
if (tbl.headers.length) {
|
|
var colW = (12 / tbl.headers.length);
|
|
var tblRows = [];
|
|
// Header row
|
|
tblRows.push(tbl.headers.map(function(h) {
|
|
return { text: h, options: { bold: true, fontSize: 13, color: 'ffffff', fill: { color: '1e40af' }, fontFace: 'Calibri', align: 'center', valign: 'middle' } };
|
|
}));
|
|
// Data rows
|
|
tbl.rows.forEach(function(row, ri) {
|
|
tblRows.push(row.map(function(cell) {
|
|
return { text: cell, options: { fontSize: 12, color: '374151', fill: { color: ri % 2 === 0 ? 'f9fafb' : 'ffffff' }, fontFace: 'Calibri', valign: 'middle' } };
|
|
}));
|
|
});
|
|
var tblH = Math.min((tblRows.length * 0.38) + 0.1, maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
|
|
slide.addTable(tblRows, {
|
|
x: 0.5, y: contentY, w: 12,
|
|
colW: Array(tbl.headers.length).fill(colW),
|
|
border: { type: 'solid', pt: 0.5, color: 'dee2e6' },
|
|
rowH: 0.36,
|
|
autoPage: false
|
|
});
|
|
contentY += tblH + 0.15;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// ── Blockquote ──
|
|
if (type === 'blockquote') {
|
|
var quoteText = line.replace(/^>\s*/, '').trim();
|
|
i++;
|
|
while (i < lines.length && /^>\s*/.test(lines[i])) { quoteText += '\n' + lines[i].replace(/^>\s*/, '').trim(); i++; }
|
|
var qH = Math.max(0.5, Math.ceil(quoteText.length / 100) * 0.35);
|
|
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: qH, fill: { color: 'eff6ff' }, rectRadius: 0.06 });
|
|
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: 0.06, h: qH, fill: { color: '3b82f6' } });
|
|
slide.addText(parseInline(quoteText, { fontSize: 16, italic: true, color: '1e40af', fontFace: 'Calibri' }), {
|
|
x: 0.8, y: contentY + 0.06, w: '85%', h: qH - 0.12, valign: 'middle', wrap: true
|
|
});
|
|
contentY += qH + 0.15;
|
|
continue;
|
|
}
|
|
|
|
// ── Bullets (unordered) ──
|
|
if (type === 'bullet') {
|
|
var bulletItems = [];
|
|
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
bulletItems.push(lines[i].replace(/^[-*]\s+/, '').trim());
|
|
i++;
|
|
}
|
|
var bulletObjs = [];
|
|
bulletItems.forEach(function(b) {
|
|
var inlineParts = parseInline(b, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
|
|
inlineParts[0].options.bullet = { type: 'bullet' };
|
|
inlineParts[0].options.paraSpaceBefore = 4;
|
|
bulletObjs.push(inlineParts);
|
|
});
|
|
var flat = []; bulletObjs.forEach(function(arr) { arr.forEach(function(p) { flat.push(p); }); });
|
|
var bH = Math.min(Math.max(bulletItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
|
|
slide.addText(flat, { x: 0.6, y: contentY, w: '88%', h: bH, fontFace: 'Calibri', valign: 'top' });
|
|
contentY += bH + 0.1;
|
|
continue;
|
|
}
|
|
|
|
// ── Numbered list ──
|
|
if (type === 'ordered') {
|
|
var orderedItems = [];
|
|
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
orderedItems.push(lines[i].replace(/^\d+\.\s+/, '').trim());
|
|
i++;
|
|
}
|
|
var orderedObjs = [];
|
|
orderedItems.forEach(function(item, idx) {
|
|
var inlineParts = parseInline(item, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
|
|
inlineParts[0].options.bullet = { type: 'number', numberStartAt: idx === 0 ? 1 : undefined };
|
|
inlineParts[0].options.paraSpaceBefore = 4;
|
|
orderedObjs.push(inlineParts);
|
|
});
|
|
var flatOrd = []; orderedObjs.forEach(function(arr) { arr.forEach(function(p) { flatOrd.push(p); }); });
|
|
var oH = Math.min(Math.max(orderedItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
|
|
slide.addText(flatOrd, { x: 0.6, y: contentY, w: '88%', h: oH, fontFace: 'Calibri', valign: 'top' });
|
|
contentY += oH + 0.1;
|
|
continue;
|
|
}
|
|
|
|
// ── Sub-heading (## or ### inside body) ──
|
|
if (type === 'heading') {
|
|
var hText = line.replace(/^#{1,3}\s+/, '').trim();
|
|
slide.addText(parseInline(hText, { fontSize: 22, bold: true, color: '1e3a5f', fontFace: 'Calibri' }), {
|
|
x: 0.5, y: contentY, w: '90%', h: 0.45, valign: 'bottom'
|
|
});
|
|
contentY += 0.5;
|
|
i++; continue;
|
|
}
|
|
|
|
// ── Paragraph ──
|
|
if (type === 'paragraph') {
|
|
var paraText = line.trim();
|
|
i++;
|
|
// Merge consecutive paragraph lines
|
|
while (i < lines.length && classifyLine(lines[i]) === 'paragraph') { paraText += ' ' + lines[i].trim(); i++; }
|
|
var pParts = parseInline(paraText, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
|
|
var pH = Math.max(0.4, Math.ceil(paraText.length / 110) * 0.3);
|
|
slide.addText(pParts, { x: 0.5, y: contentY, w: '90%', h: pH, valign: 'top', wrap: true });
|
|
contentY += pH + 0.1;
|
|
continue;
|
|
}
|
|
|
|
// ── Blank / skip ──
|
|
i++;
|
|
}
|
|
|
|
// Slide number
|
|
slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' });
|
|
});
|
|
|
|
// Write to buffer and send
|
|
var pptxBuffer = await pptx.write({ outputType: 'nodebuffer' });
|
|
var safeTitle = (title || 'presentation').replace(/[^a-zA-Z0-9-_\s]/g, '').replace(/\s+/g, '-').toLowerCase();
|
|
|
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.presentationml.presentation');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="' + safeTitle + '.pptx"');
|
|
res.send(pptxBuffer);
|
|
|
|
} catch (err) {
|
|
console.error('[PPTX]', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── POST /api/admin/learning/preview-slides ──────────────────
|
|
// Render Marp markdown to HTML for in-browser preview
|
|
|
|
router.post('/preview-slides', async function(req, res) {
|
|
try {
|
|
var { markdown } = req.body;
|
|
if (!markdown) return res.status(400).json({ error: 'markdown required' });
|
|
|
|
var { Marp } = require('@marp-team/marp-core');
|
|
var marp = new Marp({ html: false });
|
|
var { html, css } = marp.render(markdown);
|
|
|
|
// Extract individual <section> elements for page-by-page navigation
|
|
var slides = [];
|
|
var sectionReg = /<section[^>]*>[\s\S]*?<\/section>/g;
|
|
var match;
|
|
while ((match = sectionReg.exec(html)) !== null) {
|
|
slides.push(match[0]);
|
|
}
|
|
if (slides.length === 0) slides.push(html); // fallback
|
|
|
|
res.json({ success: true, css: css, slides: slides });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|