pediatric-ai-scribe-v3/src/routes/learningAI.js
Daniel 4f5687982d
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
feat: Learning resources can be grounded in the clinical corpus
Learning generated everything from the model alone. A deck on bronchiolitis was
whatever the model remembered about bronchiolitis, with no connection to the
documents this institution actually indexed — while the assistant had been
searching that corpus all along.

Same collection, deliberately. mcp_bge_m3_1024 is already embedded with
openrouter-bge-m3 at 1024 dimensions; a second index over the same documents
with the same embedder would be a copy that drifts. What differs is the budget:
a chat answer wants a few tight excerpts because the reader is waiting, a
teaching resource synthesises a whole topic. So learning.search_limit and
learning.context_chars default to 30 and 2500 against the assistant's 8 and
1400, and are separate keys so tuning one cannot move the other.

Not unbounded, though. "No limit" only moves the ceiling from a setting to the
model's context window, where overflow truncates the middle of the prompt
silently — the worst place to lose source material. 60 results and 8000
characters per excerpt are the caps.

Opt in per generation: a resource on something the library does not cover is
better written without it than padded with the nearest unrelated excerpts.
Retrieval never fails a generation — the resource is then written from the model
alone, which is what happened before this existed — and every response reports
what it was grounded on, so a caller can say "24 excerpts" or "the library had
nothing on this" rather than quietly serving ungrounded material.

Verified against the live corpus: bronchiolitis, neonatal jaundice and febrile
seizure each returned 12 excerpts and ~23k characters from Nelson, Rudolph and
the Pediatric Clinical Practice Guidelines. A deck generated through the full
chain came back with textbook specificity that is not general recall —
bronchiolar diameter, birth-weight thresholds, the full pathogen list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 14:03:05 +02:00

689 lines
31 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 imageTool = require('../utils/imageTool');
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
var db = require('../db/database');
var cryptoUtil = require('../utils/crypto');
var { assertSafeHttpsUrl } = require('../utils/urlSafety');
var learningRetrieval = require('../utils/learningRetrieval');
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',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/epub+zip'
];
if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json|docx|pptx|odt|epub)$/i)) {
cb(null, true);
} else {
cb(new Error('File type not allowed. Supported: PDF, DOCX, PPTX, ODT, EPUB, TXT, MD, HTML, CSV, JSON.'));
}
}
});
// ── 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');
}
// DOCX
if (mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || ext === 'docx') {
try {
var mammoth = require('mammoth');
var result = await mammoth.extractRawText({ buffer: buffer });
return result.value || '';
} catch (e) {
throw new Error('Could not parse DOCX: ' + e.message);
}
}
// PPTX — extract text from slide XML inside the zip
if (mimetype === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || ext === 'pptx') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var slides = Object.keys(zip.files).filter(function(f) { return /^ppt\/slides\/slide\d+\.xml$/.test(f); }).sort();
var texts = [];
for (var i = 0; i < slides.length; i++) {
var xml = await zip.files[slides[i]].async('string');
var slideText = xml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (slideText) texts.push('--- Slide ' + (i + 1) + ' ---\n' + slideText);
}
return texts.join('\n\n');
} catch (e) {
throw new Error('Could not parse PPTX: ' + e.message);
}
}
// ODT — extract text from content.xml inside the zip
if (mimetype === 'application/vnd.oasis.opendocument.text' || ext === 'odt') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var contentXml = await zip.files['content.xml'].async('string');
return contentXml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
} catch (e) {
throw new Error('Could not parse ODT: ' + e.message);
}
}
// EPUB — extract text from XHTML chapters inside the zip
if (mimetype === 'application/epub+zip' || ext === 'epub') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var chapters = Object.keys(zip.files).filter(function(f) { return /\.(xhtml|html|htm)$/i.test(f) && !zip.files[f].dir; }).sort();
var texts = [];
for (var i = 0; i < chapters.length; i++) {
var html = await zip.files[chapters[i]].async('string');
var chapterText = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (chapterText) texts.push(chapterText);
}
return texts.join('\n\n');
} catch (e) {
throw new Error('Could not parse EPUB: ' + e.message);
}
}
// Fallback — try utf8
return buffer.toString('utf8');
}
// ── Build AI prompt ──────────────────────────────────────────
function buildGeneratePrompt(opts) {
var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories, corpusContext } = 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';
// Material from this institution's own indexed documents, when the author
// asked for it. It goes before the instructions so the model reads it as the
// ground to work from, and it is explicitly preferred over recall: the point
// of grounding is that local guidance wins where the two disagree.
if (corpusContext) {
source += '\nThe following excerpts come from this institution\'s indexed clinical library. ' +
'Prefer them over your own recall wherever they disagree, and do not contradict them. ' +
'They are reference material, not a template: write the resource in your own words.\n\n' +
'LIBRARY EXCERPTS:\n"""\n' + corpusContext + '\n"""\n';
}
var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
var categoryInstr = buildCategoryInstruction(existingCategories);
// ── 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)
- A slide that contains a table must contain ONLY that table and its heading.
Anything after a table starts a new, untitled slide when the deck is built.
- Leave a blank line before and after every table, or it is not read as a table
at all and appears as literal pipe characters on the slide.
- Do not nest lists more than one level deep, and do not put an ordered list
inside a bullet: it overfills the slide.
- Prefer more slides with less on each. A slide should hold one idea.
- A slide heading is the slide's subject. Do not number it or prefix it with
"Slide 3:" — the deck numbers itself.
- 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.' + categoryInstr + refineInstr + `
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
{
"title": "string",
"category_name": "string (best existing category name, or a concise new category if none fit)",
"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 + categoryInstr + refineInstr + `
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
{
"title": "string",
"category_name": "string (best existing category name, or a concise new category if none fit)",
"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
- Prefer an existing category_name when it fits; create a short broad category_name only when no existing category fits
- 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`;
}
function buildCategoryInstruction(existingCategories) {
var names = (existingCategories || []).map(function(c) { return c.name; }).filter(Boolean);
if (!names.length) {
return '\nAssign a short, broad category_name for this content.';
}
return '\nChoose the best category_name from this existing list when appropriate: ' + names.join(', ') + '. If none fit, create one short broad category_name.';
}
// ── 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;
// Opt in. Grounding is the right default for clinical teaching, but a
// resource on something the library does not cover is better written
// without it than padded with the nearest unrelated excerpts.
var useCorpus = String(req.body.useCorpus) === 'true' || req.body.useCorpus === true;
if (typeof topic !== 'string' || typeof refinement !== 'string') return res.status(400).json({ error: 'topic and refinement must be text' });
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 uploaded file:', e.message);
// Continue with other files even if one fails
}
}
docText = allTexts.join('\n\n---\n\n');
}
// 2 — Nextcloud WebDAV path
else if (webdavPath) {
if (!await require('../utils/policy').isFeatureEnabled('nextcloud')) return res.status(403).json({ error: 'Feature disabled' });
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.' });
}
await assertSafeHttpsUrl(user.nextcloud_url, 'Nextcloud URL');
var ncPassword;
try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
if (!webdavPath.startsWith('/')) webdavPath = '/' + webdavPath;
var fileUrl = user.nextcloud_url + '/remote.php/dav/files/' + encodeURIComponent(user.nextcloud_user) + webdavPath;
var response = await axios.get(fileUrl, {
auth: { username: user.nextcloud_user, password: ncPassword },
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 0
});
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 existingCategories = await db.all('SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC', []);
// Retrieval never fails a generation: without it the resource is written
// from the model alone, which is exactly what happened before this
// existed. The reason is surfaced so the author is told rather than
// quietly handed ungrounded material.
var corpus = { sources: [], context: '', reason: 'not requested' };
if (useCorpus) {
corpus = await learningRetrieval.retrieve(topic || docText || '', db.getSetting);
if (corpus.reason) console.warn('[learning] corpus not used:', corpus.reason);
else console.info('[learning] grounded on', corpus.sources.length, 'excerpts');
}
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories, corpusContext: corpus.context });
var aiMessages = [
{ 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 }
];
var aiOptions = { model: model, temperature: 0.4, maxTokens: 8000, tools: imageTool.tools };
var result = await callAI(aiMessages, aiOptions);
result = await imageTool.dispatch(result, { owner: req.user.id, workflow: 'learning_hub', body: { ...req.body, docText }, imageContext: require('../utils/generatedImages').imageContext(
[topic, refinement].filter(Boolean).join('\n\n') || 'Generate educational content from the supplied document.', docText ? [{ role: 'user', content: docText }] : []), messages: aiMessages, options: aiOptions, callAI });
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, grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null }, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, category_name: parsedPres.category_name || '', questions: parsedPres.questions || [], imageJobs: result.imageJobs || [], 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, grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null }, contentType: 'presentation', marpMarkdown: marpMd, questions: [], imageJobs: result.imageJobs || [], 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]);
// 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,
grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null },
content: parsed,
imageJobs: result.imageJobs || [], model: result.model,
docLength: docText.length,
fileCount: fileCount || (webdavPath ? 1 : 0)
});
} catch (err) {
console.error('[LearningAI]', err.message);
res.status(err.statusCode || 500).json({ error: err.statusCode ? 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 (typeof content !== 'string' || typeof instructions !== 'string' || !content || !instructions.trim()) 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}
For a text-only refinement, return ONLY the refined HTML body (same structure, no JSON wrapper, no markdown fences). Keep all HTML tags intact. If the instruction calls for an image, invoke generate_image instead; the existing body will be preserved regardless of any accompanying text. Do not combine image insertion with a body rewrite.`;
var aiMessages = [{ role: 'user', content: prompt }];
var aiOptions = { model: model, temperature: 0.3, maxTokens: 4000, tools: imageTool.tools };
var result = await callAI(aiMessages, aiOptions);
// A tool call never authorizes a text rewrite, even if the model also emits HTML.
var imageOnly = Boolean(result.toolCalls?.length);
if (imageOnly) result = { ...result, content };
result = await imageTool.dispatch(result, { owner: req.user.id, workflow: 'learning_hub', body: req.body, imageContext: require('../utils/generatedImages').imageContext(instructions, [{ role: 'user', content }]), messages: aiMessages, options: aiOptions, callAI });
var refined = imageOnly ? content : result.content.trim().replace(/^```(?:html)?\s*/i, '').replace(/\s*```\s*$/, '');
res.json({ success: true, refined, bodyPreserved: imageOnly, imageJobs: result.imageJobs || [], model: result.model });
} catch (err) {
console.error('[LearningAI]', err.message);
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Request failed' });
}
});
// ── GET /api/admin/learning/webdav-browse ────────────────────
// Browse user's Nextcloud folder
router.get('/webdav-browse', require('../utils/policy').requireFeature('nextcloud'), 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' });
}
await assertSafeHttpsUrl(user.nextcloud_url, 'Nextcloud URL');
var ncPassword;
try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
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/' + encodeURIComponent(user.nextcloud_user) + browsePath;
var davResponse = await axios({
method: 'PROPFIND',
url: davUrl,
auth: { username: user.nextcloud_user, password: ncPassword },
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,
maxRedirects: 0
});
// 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' });
}
});
// ── POST /api/admin/learning/webdav-path ─────────────────────
// Save user's preferred WebDAV learning path
router.post('/webdav-path', require('../utils/policy').requireFeature('nextcloud'), 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: 'Request failed' });
}
});
// ── POST /api/admin/learning/generate-pptx ──────────────────
// pandoc measures images itself and emits an extent with the right shape, so
// nothing here needs to size them.
router.post('/generate-pptx', async function(req, res) {
var workdir = null;
try {
var { markdown, title } = req.body;
if (!markdown) return res.status(400).json({ error: 'markdown required' });
var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
var { execFile } = require('child_process');
workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'pptx-'));
// Only images this user owns, written beside the markdown under names we
// choose. Everything else is stripped: pandoc resolves an image link
// against the filesystem, so a link naming any local path would embed that
// file into the deck. The allow-list is the set we just fetched by id.
var allowed = Object.create(null);
var refs = require('../utils/generatedImageLinks').references(markdown);
for (var r = 0; r < refs.length; r++) {
var id = refs[r];
var image = await require('../utils/generatedImages').service().asset(id, req.user);
var ext = String(image.mime || '').indexOf('png') !== -1 ? 'png' : 'jpg';
var file = 'img-' + r + '.' + ext;
await fsp.writeFile(pathMod.join(workdir, file), image.bytes);
allowed['/api/generated-images/' + id] = file;
}
var source = String(markdown).replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (whole, alt, src) {
var local = allowed[String(src).trim()];
// A link we did not resolve is dropped rather than passed through, so a
// deck can never be made to read a path off this host.
return local ? '![' + alt + '](' + local + ')' : '';
});
await fsp.writeFile(pathMod.join(workdir, 'deck.md'), source, 'utf8');
// The reference deck carries the fonts, palette and slide layouts. Design
// lives there, not here: restyling means editing that file in PowerPoint.
var reference = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
await new Promise(function (resolve, reject) {
execFile('pandoc', ['deck.md', '--reference-doc=' + reference, '-o', 'deck.pptx'],
{ cwd: workdir, timeout: 60000, maxBuffer: 1024 * 1024 },
function (err, stdout, stderr) {
if (err) return reject(new Error(String(stderr || err.message).slice(0, 400)));
resolve();
});
});
var pptxBuffer = await fsp.readFile(pathMod.join(workdir, 'deck.pptx'));
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: 'Request failed' });
} finally {
if (workdir) {
try { await require('fs/promises').rm(workdir, { recursive: true, force: true }); }
catch (e) { console.warn('[PPTX] could not clean', workdir, e.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: 'Request failed' });
}
});
module.exports = router;