improve learning hub generation flow

This commit is contained in:
Daniel 2026-05-08 09:32:58 +02:00
parent d8e9ba149e
commit 6d13765fc4
13 changed files with 457 additions and 334 deletions

7
.mailmap Normal file
View file

@ -0,0 +1,7 @@
# Remap historical AI co-author trailers so repository contributors reflect human maintainers.
Daniel <danitex@danvics.com> vendor model Opus 4.6 <noreply@anthropic.com>
Daniel <danitex@danvics.com> vendor model Opus 4.6 (1M context) <noreply@anthropic.com>
Daniel <danitex@danvics.com> vendor model Opus 4.7 (1M context) <noreply@anthropic.com>
Daniel <danitex@danvics.com> vendor model Sonnet 4.5 <noreply@anthropic.com>
Daniel <danitex@danvics.com> vendor model Sonnet 4.6 <noreply@anthropic.com>
Daniel <danitex@danvics.com> vendor model Sonnet 4.6 (1M context) <noreply@anthropic.com>

View file

@ -21,10 +21,7 @@ test.describe('Unauthenticated auth screen', () => {
});
test('register link is present but currently disabled (display:none)', async ({ page }) => {
// Daniel's instance has invite-only registration — the "Create account"
// link is explicitly hidden via inline style, so the HTML is there but
// users can't reach the register form through the UI. Verify the hidden
// state so flipping the style to re-enable it fails loudly.
// Invite-only registration hides the link while keeping the form in the DOM.
await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 });
const display = await page.locator('#show-register').evaluate(el => el.style.display);

View file

@ -1,12 +1,4 @@
/**
* Soft-delete for personal_notes Daniel asked for "deleted notes go to
* trash" so a slip of the finger doesn't lose work. Adds a deleted_at
* timestamp; NULL means active. Trash listing filters by NOT NULL,
* regular listing filters by NULL.
*
* Restore = clear deleted_at. Empty Trash = real DELETE. No retention
* policy yet items stay in trash until the user empties it.
*/
// Adds soft-delete support for personal_notes via deleted_at.
exports.up = (pgm) => {
pgm.addColumn('personal_notes', {

View file

@ -214,14 +214,6 @@
<div class="settings-section card">
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
<div class="hipaa-info">
<p><strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.</p>
<ul>
<li>✅ All connections use HTTPS/TLS encryption</li>
<li>✅ Authentication with optional 2FA</li>
<li>✅ No patient data stored on server beyond session</li>
<li>✅ AWS Bedrock supports BAA for HIPAA compliance</li>
<li>✅ Azure OpenAI supports BAA for HIPAA compliance</li>
</ul>
<p><strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.</p>
</div>
</div>

View file

@ -1,6 +1,17 @@
// ============================================================
// LEARNING HUB — User content feed, viewer, quizzes + Admin CMS
// ============================================================
import {
clearQuestionBlocks,
destroyOptionEditor,
destroyQuestionBlockEditors,
getTpHTML,
makeTpEditor
} from './learningHub/tiptapEditor.js';
import { esc, sanitizeHtml } from './learningHub/sanitize.js';
import { deleteJson, getJson, sendJson } from './learningHub/api.js';
import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.js';
var loaded = false;
var cmsLoaded = false;
var currentContent = null;
@ -159,8 +170,7 @@
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
showFeed();
fetch('/api/learning/feed', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/learning/feed')
.then(function(data) {
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Failed to load</p>'; return; }
renderFeed(data.content, feedEl);
@ -169,17 +179,12 @@
}
function loadCategories() {
fetch('/api/learning/categories', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/learning/categories')
.then(function(data) {
if (!data.success) return;
var bar = document.getElementById('lh-categories');
if (!bar) return;
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
data.categories.forEach(function(c) {
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
});
bar.innerHTML = html;
bar.innerHTML = renderCategoryPills(data.categories);
});
}
@ -189,8 +194,7 @@
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
showFeed();
fetch('/api/learning/category/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/learning/category/' + encodeURIComponent(slug))
.then(function(data) {
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Category not found</p>'; return; }
renderFeed(data.content, feedEl);
@ -206,25 +210,13 @@
'<i class="fas fa-search"></i> Searching for <strong>"' + esc(q) + '"</strong>…' +
'</div>';
fetch('/api/learning/search?q=' + encodeURIComponent(q), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/learning/search?q=' + encodeURIComponent(q))
.then(function(data) {
if (!data.success) { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search failed</p>'; return; }
var header = '<div style="padding:8px 0 12px;display:flex;align-items:center;justify-content:space-between;">' +
'<span style="font-size:13px;color:var(--g600);">' +
(data.content.length > 0
? '<strong>' + data.content.length + '</strong> result' + (data.content.length !== 1 ? 's' : '') + ' for <strong>"' + esc(q) + '"</strong>'
: 'No results for <strong>"' + esc(q) + '"</strong>') +
'</span>' +
'<button id="lh-search-clear" style="background:none;border:none;font-size:12px;color:var(--blue);cursor:pointer;padding:0;">Clear search</button>' +
'</div>';
var header = renderSearchHeader(data.content.length, q);
if (data.content.length === 0) {
feedEl.innerHTML = header +
'<div style="text-align:center;padding:40px;color:var(--g400);">' +
'<i class="fas fa-search" style="font-size:32px;margin-bottom:12px;display:block;opacity:0.4;"></i>' +
'Try different keywords or browse by category' +
'</div>';
feedEl.innerHTML = header + renderEmptySearchMessage();
} else {
var tempEl = document.createElement('div');
renderFeed(data.content, tempEl);
@ -243,39 +235,8 @@
.catch(function() { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search error</p>'; });
}
function renderFeed(items, feedEl) {
if (!items || items.length === 0) {
feedEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-book-open" style="font-size:32px;margin-bottom:12px;display:block;"></i>No content yet. Check back soon!</div>';
return;
}
feedEl.innerHTML = items.map(function(item) {
var typeIcon = item.content_type === 'quiz' ? 'fa-clipboard-question' : item.content_type === 'pearl' ? 'fa-gem' : item.content_type === 'presentation' ? 'fa-presentation-screen' : 'fa-file-alt';
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : item.content_type === 'presentation' ? '#065f46' : 'var(--blue)';
var quizBadge = item.question_count > 0 ? '<span class="lh-badge lh-badge-quiz"><i class="fas fa-clipboard-question"></i> ' + item.question_count + ' Q</span>' : '';
var catBadge = item.category_name ? '<span class="lh-badge">' + esc(item.category_name) + '</span>' : '<span class="lh-badge" style="opacity:0.5;">Uncategorized</span>';
var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : '';
return '<div class="lh-feed-item card" data-slug="' + esc(item.slug) + '">' +
'<div class="lh-feed-item-header">' +
'<i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:16px;"></i>' +
'<div style="flex:1;min-width:0;">' +
'<div class="lh-feed-title">' + esc(item.title) + '</div>' +
'<div class="lh-feed-meta">' +
(item.subject ? '<span>' + esc(item.subject) + '</span>' : '') +
(item.author_name ? '<span>by ' + esc(item.author_name) + '</span>' : '') +
'<span>' + date + '</span>' +
'</div>' +
'</div>' +
'<div class="lh-feed-badges">' + catBadge + quizBadge + '</div>' +
'</div>' +
'</div>';
}).join('');
}
function loadContent(slug) {
fetch('/api/learning/content/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/learning/content/' + encodeURIComponent(slug))
.then(function(data) {
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
currentContent = data.content;
@ -446,12 +407,7 @@
showLoading('Submitting quiz...');
fetch('/api/learning/submit-quiz', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ contentId: currentContent.id, answers: answers })
})
.then(function(r) { return r.json(); })
sendJson('/api/learning/submit-quiz', 'POST', { contentId: currentContent.id, answers: answers })
.then(function(data) {
hideLoading();
if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
@ -621,8 +577,7 @@
updateAiOptions(editorType);
// Hide WebDAV tab if Nextcloud not configured
fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/auth/me')
.then(function(d) {
var tab = document.getElementById('lh-ai-tab-webdav');
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
@ -715,8 +670,7 @@
if (list) list.innerHTML = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
if (pathLabel) pathLabel.textContent = _aiWebdavCurrentPath;
fetch('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath))
.then(function(data) {
if (!data.success) { if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(data.error) + '</div>'; return; }
if (pathLabel) pathLabel.textContent = data.path;
@ -849,9 +803,10 @@
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
// Presentation returns marpMarkdown directly; others return content{}
var payload = data.contentType === 'presentation' ? data : data.content;
applyAiContent(payload, contentType);
closeAiPanel();
showToast('Content generated! Review and save.', 'success');
return applyAiContent(payload, contentType).then(function() {
closeAiPanel();
showToast('Content generated! Review and save.', 'success');
});
})
.catch(function(err) {
hideBusy();
@ -881,7 +836,7 @@
content.questions.forEach(function(q) { addQuestionBlock(q); });
}
}
return;
return applyAiCategory(content.category_name || content.category);
}
// Fill title
@ -903,6 +858,44 @@
content.questions.forEach(function(q) { addQuestionBlock(q); });
}
}
return applyAiCategory(content.category_name || content.category);
}
function applyAiCategory(categoryName) {
categoryName = (categoryName || '').trim();
if (!categoryName) return Promise.resolve();
var sel = document.getElementById('lh-cms-edit-category');
if (!sel) return Promise.resolve();
var existing = findCategoryOption(sel, categoryName);
if (existing) {
sel.value = existing.value;
return Promise.resolve();
}
return sendJson('/api/admin/learning/categories', 'POST', { name: categoryName })
.then(function(data) {
if (!data.success || !data.id) throw new Error(data.error || 'Category creation failed');
var opt = document.createElement('option');
opt.value = data.id;
opt.textContent = categoryName;
sel.appendChild(opt);
sel.value = String(data.id);
loadCmsCategories();
loadCategories();
})
.catch(function(err) {
showToast('Generated category was not created: ' + err.message, 'error');
});
}
function findCategoryOption(sel, categoryName) {
var wanted = categoryName.toLowerCase();
for (var i = 0; i < sel.options.length; i++) {
if ((sel.options[i].textContent || '').trim().toLowerCase() === wanted) return sel.options[i];
}
return null;
}
function toggleRefineBar(forceShow) {
@ -926,12 +919,7 @@
toggleRefineBar(false);
showBusy('Refining content...');
fetch('/api/admin/learning/ai-refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ content: currentBody, instructions: instructions, model: model })
})
.then(function(r) { return r.json(); })
sendJson('/api/admin/learning/ai-refine', 'POST', { content: currentBody, instructions: instructions, model: model })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
@ -942,8 +930,7 @@
}
function loadCmsStats() {
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/admin/learning/stats')
.then(function(data) {
if (!data.success) return;
var s = data.stats;
@ -957,8 +944,7 @@
}
function loadCmsCategories() {
fetch('/api/admin/learning/categories', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/admin/learning/categories')
.then(function(data) {
if (!data.success) return;
var container = document.getElementById('lh-cms-categories');
@ -998,12 +984,7 @@
var input = document.getElementById('lh-cms-cat-name');
if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; }
fetch('/api/admin/learning/categories', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name: input.value.trim() })
})
.then(function(r) { return r.json(); })
sendJson('/api/admin/learning/categories', 'POST', { name: input.value.trim() })
.then(function(data) {
if (data.success) {
input.value = '';
@ -1023,8 +1004,7 @@
return;
}
window.__confirmDeleteCat = null;
fetch('/api/admin/learning/categories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
deleteJson('/api/admin/learning/categories/' + id)
.then(function(data) {
if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); }
else showToast(data.error || 'Failed', 'error');
@ -1036,8 +1016,7 @@
if (!container) return;
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">Loading...</p>';
fetch('/api/admin/learning/content', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/admin/learning/content')
.then(function(data) {
if (!data.success) return;
if (data.content.length === 0) {
@ -1101,168 +1080,6 @@
renderCmsContentList(filtered);
}
// ── Tiptap editor helpers ──────────────────────────────────
var T = window.Tiptap || {};
function buildTpToolbar(mini, isOption) {
var btns;
if (isOption) {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
} else if (mini) {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="bulletList" title="List"><i class="fas fa-list-ul"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
} else {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>' +
'<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet List"><i class="fas fa-list-ul"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered List"><i class="fas fa-list-ol"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="blockquote" title="Blockquote"><i class="fas fa-quote-left"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
}
return '<div class="tp-toolbar' + (mini ? ' tp-toolbar-mini' : '') + '">' + btns + '</div>' +
'<div class="tp-link-bar" style="display:none;">' +
'<input type="url" class="tp-link-input" placeholder="https://">' +
'<button type="button" class="tp-link-apply">Apply</button>' +
'<button type="button" class="tp-link-remove">Remove</button>' +
'<button type="button" class="tp-link-cancel">✕</button>' +
'</div>';
}
function wireTpToolbar(wrap, editor) {
var linkBar = wrap.querySelector('.tp-link-bar');
var linkInput = wrap.querySelector('.tp-link-input');
wrap.querySelector('.tp-toolbar').addEventListener('mousedown', function(e) {
var btn = e.target.closest('.tp-btn[data-cmd]');
if (!btn) return;
e.preventDefault();
var cmd = btn.dataset.cmd;
switch (cmd) {
case 'bold': editor.chain().focus().toggleBold().run(); break;
case 'italic': editor.chain().focus().toggleItalic().run(); break;
case 'underline': editor.chain().focus().toggleUnderline().run(); break;
case 'strike': editor.chain().focus().toggleStrike().run(); break;
case 'h2': editor.chain().focus().toggleHeading({ level: 2 }).run(); break;
case 'h3': editor.chain().focus().toggleHeading({ level: 3 }).run(); break;
case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
case 'orderedList': editor.chain().focus().toggleOrderedList().run(); break;
case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
case 'codeBlock': editor.chain().focus().toggleCodeBlock().run(); break;
case 'clear': editor.chain().focus().unsetAllMarks().clearNodes().run(); break;
case 'link':
if (linkBar.style.display === 'none') {
linkInput.value = editor.getAttributes('link').href || '';
linkBar.style.display = 'flex';
setTimeout(function() { linkInput.focus(); }, 0);
} else {
linkBar.style.display = 'none';
}
break;
}
updateTpState(wrap, editor);
});
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
e.preventDefault();
var url = linkInput.value.trim();
if (url) editor.chain().focus().setLink({ href: url }).run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
e.preventDefault();
editor.chain().focus().unsetLink().run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
e.preventDefault();
linkBar.style.display = 'none';
});
linkInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
if (e.key === 'Escape') linkBar.style.display = 'none';
});
}
function updateTpState(wrap, editor) {
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
var a = false;
switch (btn.dataset.cmd) {
case 'bold': a = editor.isActive('bold'); break;
case 'italic': a = editor.isActive('italic'); break;
case 'underline': a = editor.isActive('underline'); break;
case 'strike': a = editor.isActive('strike'); break;
case 'h2': a = editor.isActive('heading', { level: 2 }); break;
case 'h3': a = editor.isActive('heading', { level: 3 }); break;
case 'bulletList': a = editor.isActive('bulletList'); break;
case 'orderedList': a = editor.isActive('orderedList'); break;
case 'blockquote': a = editor.isActive('blockquote'); break;
case 'codeBlock': a = editor.isActive('codeBlock'); break;
case 'link': a = editor.isActive('link'); break;
}
btn.classList.toggle('active', a);
});
}
function makeTpEditor(wrap, existingHtml, mini, isOption) {
wrap.innerHTML = buildTpToolbar(mini, isOption) + '<div class="tp-content"></div>';
var ed = new T.Editor({
element: wrap.querySelector('.tp-content'),
extensions: [
T.StarterKit,
T.Link.configure({ openOnClick: false, autolink: true }),
T.Underline
],
content: existingHtml || '',
onUpdate: function() { updateTpState(wrap, ed); },
onSelectionUpdate: function() { updateTpState(wrap, ed); }
});
wireTpToolbar(wrap, ed);
return ed;
}
function getTpHTML(editor) {
if (!editor) return '';
var html = editor.getHTML();
return (html === '<p></p>' || html === '') ? '' : html;
}
function destroyTpEditor(editor) {
if (editor && typeof editor.destroy === 'function') editor.destroy();
}
function destroyOptionEditor(row) {
if (!row || !row._optEditor) return;
destroyTpEditor(row._optEditor);
row._optEditor = null;
}
function destroyQuestionBlockEditors(block) {
if (!block) return;
destroyTpEditor(block._questionEditor);
block._questionEditor = null;
block.querySelectorAll('.lh-option-row').forEach(destroyOptionEditor);
}
function clearQuestionBlocks(container) {
if (!container) return;
container.querySelectorAll('.lh-question-block').forEach(destroyQuestionBlockEditors);
container.innerHTML = '';
}
// ── Body editor ────────────────────────────────────────────
function initBodyEditor() {
var wrap = document.getElementById('lh-body-editor');
@ -1375,8 +1192,7 @@
if (!contentId) return;
// Load existing content
fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/admin/learning/content/' + contentId)
.then(function(data) {
if (!data.success) { showToast('Failed to load', 'error'); return; }
var c = data.content;
@ -1487,8 +1303,7 @@
showLoading('Saving...');
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(payload) })
.then(function(r) { return r.json(); })
sendJson(url, method, payload)
.then(function(data) {
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
var contentId = id || data.id;
@ -1521,17 +1336,12 @@
url = '/api/admin/learning/content/' + contentId + '/questions';
}
fetch(url, {
method: method,
headers: getAuthHeaders(),
body: JSON.stringify({
question_text: q.question_text,
question_type: q.question_type,
explanation: q.explanation,
options: q.options
})
sendJson(url, method, {
question_text: q.question_text,
question_type: q.question_type,
explanation: q.explanation,
options: q.options
})
.then(function(r) { return r.json(); })
.then(function() {
saveQuestions(contentId, questions, index + 1, callback);
})
@ -1570,8 +1380,7 @@
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
hideDeleteConfirm();
fetch('/api/admin/learning/content/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
deleteJson('/api/admin/learning/content/' + id)
.then(function(data) {
if (data.success) {
showToast('Content deleted', 'info');
@ -1596,8 +1405,7 @@
// Open slide modal for a published presentation in the user-facing viewer
function openSlidesFromSlug(slug) {
showBusy('Loading slides...');
fetch('/api/learning/content/' + encodeURIComponent(slug) + '/slides', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
getJson('/api/learning/content/' + encodeURIComponent(slug) + '/slides')
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Could not load slides', 'error'); return; }
@ -1612,11 +1420,7 @@
showBusy('Rendering slides...');
fetch('/api/admin/learning/preview-slides', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ markdown: md })
})
.then(function(r) { return r.json(); })
sendJson('/api/admin/learning/preview-slides', 'POST', { markdown: md })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Preview failed', 'error'); return; }
@ -1799,30 +1603,3 @@
// Set option text value after insertion (can't use value="" on nested input via innerHTML)
row.querySelector('.lh-opt-text').value = opt ? opt.option_text || '' : '';
}
// ── Helpers ──────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function sanitizeHtml(html) {
// DOMPurify is loaded from cdnjs via <script> in index.html. If it somehow
// fails to load, refuse to render unsafe content rather than falling back
// to a regex sanitizer (historical bypass risk).
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
console.warn('[learningHub] DOMPurify unavailable — rendering as plain text.');
var d = document.createElement('div');
d.textContent = String(html == null ? '' : html);
return d.innerHTML;
}
return window.DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
'hr','div','span','sub','sup','dl','dt','dd'],
ALLOWED_ATTR: ['href','colspan','rowspan','class','target','rel'],
ADD_ATTR: ['target'],
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false
});
}

View file

@ -0,0 +1,15 @@
export function getJson(url) {
return fetch(url, { headers: getAuthHeaders() }).then(function(r) { return r.json(); });
}
export function sendJson(url, method, payload) {
return fetch(url, {
method: method,
headers: getAuthHeaders(),
body: JSON.stringify(payload || {})
}).then(function(r) { return r.json(); });
}
export function deleteJson(url) {
return fetch(url, { method: 'DELETE', headers: getAuthHeaders() }).then(function(r) { return r.json(); });
}

View file

@ -0,0 +1,60 @@
import { esc } from './sanitize.js';
export function renderCategoryPills(categories) {
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
(categories || []).forEach(function(c) {
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
});
return html;
}
export function renderFeed(items, feedEl) {
if (!feedEl) return;
if (!items || items.length === 0) {
feedEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-book-open" style="font-size:32px;margin-bottom:12px;display:block;"></i>No content yet. Check back soon!</div>';
return;
}
feedEl.innerHTML = items.map(renderFeedItem).join('');
}
export function renderSearchHeader(count, query) {
return '<div style="padding:8px 0 12px;display:flex;align-items:center;justify-content:space-between;">' +
'<span style="font-size:13px;color:var(--g600);">' +
(count > 0
? '<strong>' + count + '</strong> result' + (count !== 1 ? 's' : '') + ' for <strong>"' + esc(query) + '"</strong>'
: 'No results for <strong>"' + esc(query) + '"</strong>') +
'</span>' +
'<button id="lh-search-clear" style="background:none;border:none;font-size:12px;color:var(--blue);cursor:pointer;padding:0;">Clear search</button>' +
'</div>';
}
export function renderEmptySearchMessage() {
return '<div style="text-align:center;padding:40px;color:var(--g400);">' +
'<i class="fas fa-search" style="font-size:32px;margin-bottom:12px;display:block;opacity:0.4;"></i>' +
'Try different keywords or browse by category' +
'</div>';
}
function renderFeedItem(item) {
var typeIcon = item.content_type === 'quiz' ? 'fa-clipboard-question' : item.content_type === 'pearl' ? 'fa-gem' : item.content_type === 'presentation' ? 'fa-presentation-screen' : 'fa-file-alt';
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : item.content_type === 'presentation' ? '#065f46' : 'var(--blue)';
var quizBadge = item.question_count > 0 ? '<span class="lh-badge lh-badge-quiz"><i class="fas fa-clipboard-question"></i> ' + item.question_count + ' Q</span>' : '';
var catBadge = item.category_name ? '<span class="lh-badge">' + esc(item.category_name) + '</span>' : '<span class="lh-badge" style="opacity:0.5;">Uncategorized</span>';
var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : '';
return '<div class="lh-feed-item card" data-slug="' + esc(item.slug) + '">' +
'<div class="lh-feed-item-header">' +
'<i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:16px;"></i>' +
'<div style="flex:1;min-width:0;">' +
'<div class="lh-feed-title">' + esc(item.title) + '</div>' +
'<div class="lh-feed-meta">' +
(item.subject ? '<span>' + esc(item.subject) + '</span>' : '') +
(item.author_name ? '<span>by ' + esc(item.author_name) + '</span>' : '') +
'<span>' + date + '</span>' +
'</div>' +
'</div>' +
'<div class="lh-feed-badges">' + catBadge + quizBadge + '</div>' +
'</div>' +
'</div>';
}

View file

@ -0,0 +1,24 @@
export function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function sanitizeHtml(html) {
// DOMPurify is loaded globally from index.html. If it is unavailable, render
// as plain text rather than attempting partial sanitization.
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
console.warn('[learningHub] DOMPurify unavailable - rendering as plain text.');
var d = document.createElement('div');
d.textContent = String(html == null ? '' : html);
return d.innerHTML;
}
return window.DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
'hr','div','span','sub','sup','dl','dt','dd'],
ALLOWED_ATTR: ['href','colspan','rowspan','class','target','rel'],
ADD_ATTR: ['target'],
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false
});
}

View file

@ -0,0 +1,163 @@
function getTiptap() {
return window.Tiptap || {};
}
function buildTpToolbar(mini, isOption) {
var btns;
if (isOption) {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
} else if (mini) {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="bulletList" title="List"><i class="fas fa-list-ul"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
} else {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>' +
'<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet List"><i class="fas fa-list-ul"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered List"><i class="fas fa-list-ol"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="blockquote" title="Blockquote"><i class="fas fa-quote-left"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
}
return '<div class="tp-toolbar' + (mini ? ' tp-toolbar-mini' : '') + '">' + btns + '</div>' +
'<div class="tp-link-bar" style="display:none;">' +
'<input type="url" class="tp-link-input" placeholder="https://">' +
'<button type="button" class="tp-link-apply">Apply</button>' +
'<button type="button" class="tp-link-remove">Remove</button>' +
'<button type="button" class="tp-link-cancel">&times;</button>' +
'</div>';
}
function wireTpToolbar(wrap, editor) {
var linkBar = wrap.querySelector('.tp-link-bar');
var linkInput = wrap.querySelector('.tp-link-input');
wrap.querySelector('.tp-toolbar').addEventListener('mousedown', function(e) {
var btn = e.target.closest('.tp-btn[data-cmd]');
if (!btn) return;
e.preventDefault();
var cmd = btn.dataset.cmd;
switch (cmd) {
case 'bold': editor.chain().focus().toggleBold().run(); break;
case 'italic': editor.chain().focus().toggleItalic().run(); break;
case 'underline': editor.chain().focus().toggleUnderline().run(); break;
case 'strike': editor.chain().focus().toggleStrike().run(); break;
case 'h2': editor.chain().focus().toggleHeading({ level: 2 }).run(); break;
case 'h3': editor.chain().focus().toggleHeading({ level: 3 }).run(); break;
case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
case 'orderedList': editor.chain().focus().toggleOrderedList().run(); break;
case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
case 'codeBlock': editor.chain().focus().toggleCodeBlock().run(); break;
case 'clear': editor.chain().focus().unsetAllMarks().clearNodes().run(); break;
case 'link':
if (linkBar.style.display === 'none') {
linkInput.value = editor.getAttributes('link').href || '';
linkBar.style.display = 'flex';
setTimeout(function() { linkInput.focus(); }, 0);
} else {
linkBar.style.display = 'none';
}
break;
}
updateTpState(wrap, editor);
});
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
e.preventDefault();
var url = linkInput.value.trim();
if (url) editor.chain().focus().setLink({ href: url }).run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
e.preventDefault();
editor.chain().focus().unsetLink().run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
e.preventDefault();
linkBar.style.display = 'none';
});
linkInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
if (e.key === 'Escape') linkBar.style.display = 'none';
});
}
function updateTpState(wrap, editor) {
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
var a = false;
switch (btn.dataset.cmd) {
case 'bold': a = editor.isActive('bold'); break;
case 'italic': a = editor.isActive('italic'); break;
case 'underline': a = editor.isActive('underline'); break;
case 'strike': a = editor.isActive('strike'); break;
case 'h2': a = editor.isActive('heading', { level: 2 }); break;
case 'h3': a = editor.isActive('heading', { level: 3 }); break;
case 'bulletList': a = editor.isActive('bulletList'); break;
case 'orderedList': a = editor.isActive('orderedList'); break;
case 'blockquote': a = editor.isActive('blockquote'); break;
case 'codeBlock': a = editor.isActive('codeBlock'); break;
case 'link': a = editor.isActive('link'); break;
}
btn.classList.toggle('active', a);
});
}
export function makeTpEditor(wrap, existingHtml, mini, isOption) {
var T = getTiptap();
wrap.innerHTML = buildTpToolbar(mini, isOption) + '<div class="tp-content"></div>';
var ed = new T.Editor({
element: wrap.querySelector('.tp-content'),
extensions: [
T.StarterKit,
T.Link.configure({ openOnClick: false, autolink: true }),
T.Underline
],
content: existingHtml || '',
onUpdate: function() { updateTpState(wrap, ed); },
onSelectionUpdate: function() { updateTpState(wrap, ed); }
});
wireTpToolbar(wrap, ed);
return ed;
}
export function getTpHTML(editor) {
if (!editor) return '';
var html = editor.getHTML();
return (html === '<p></p>' || html === '') ? '' : html;
}
export function destroyTpEditor(editor) {
if (editor && typeof editor.destroy === 'function') editor.destroy();
}
export function destroyOptionEditor(row) {
if (!row || !row._optEditor) return;
destroyTpEditor(row._optEditor);
row._optEditor = null;
}
export function destroyQuestionBlockEditors(block) {
if (!block) return;
destroyTpEditor(block._questionEditor);
block._questionEditor = null;
block.querySelectorAll('.lh-option-row').forEach(destroyOptionEditor);
}
export function clearQuestionBlocks(container) {
if (!container) return;
container.querySelectorAll('.lh-question-block').forEach(destroyQuestionBlockEditors);
container.innerHTML = '';
}

View file

@ -135,13 +135,14 @@ async function extractText(buffer, mimetype, filename) {
// ── Build AI prompt ──────────────────────────────────────────
function buildGeneratePrompt(opts) {
var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount } = opts;
var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories } = 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 : '';
var categoryInstr = buildCategoryInstruction(existingCategories);
// ── Presentation ──
if (contentType === 'presentation') {
@ -171,11 +172,12 @@ Then each slide separated by ---. Guidelines:
// 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 + `
'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": [
{
@ -210,11 +212,12 @@ Marp guidelines inside marpMarkdown:
typeInstr = 'This is an article. Write a comprehensive, well-structured body.' + wordHint + ' ' + qInstr;
}
return source + '\n' + typeInstr + refineInstr + `
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": [
@ -231,11 +234,20 @@ Return ONLY a valid JSON object (no markdown, no code fences) with this exact st
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
@ -298,7 +310,8 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res)
return res.status(400).json({ error: 'Provide a topic or upload a file.' });
}
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount });
var existingCategories = await db.all('SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC', []);
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories });
var result = await callAI(
[
@ -327,7 +340,7 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res)
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 });
return res.json({ success: true, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, category_name: parsedPres.category_name || '', questions: parsedPres.questions || [], model: result.model });
}
}
// Plain Marp markdown (no questions requested, or parse failed)

View file

@ -53,6 +53,9 @@ router.post('/categories', async function(req, res) {
var { name, description, sort_order } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
var existing = await db.get('SELECT id, slug FROM learning_categories WHERE LOWER(name) = LOWER(?)', [name.trim()]);
if (existing) return res.json({ success: true, id: existing.id, slug: existing.slug, existing: true });
var slug = await uniqueSlug('learning_categories', name.trim());
var result = await db.run(

View file

@ -0,0 +1,37 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function read(relativePath) {
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
}
const frontend = read('public/js/learningHub.js');
const learningAI = read('src/routes/learningAI.js');
const learningAdmin = read('src/routes/learningAdmin.js');
test('Learning AI prompts request category_name using existing categories', () => {
assert.match(learningAI, /function buildCategoryInstruction\(existingCategories\)/);
assert.match(learningAI, /SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC/);
assert.match(learningAI, /"category_name": "string \(best existing category name/);
assert.match(learningAI, /Prefer an existing category_name when it fits/);
});
test('Learning AI presentation JSON preserves generated category_name', () => {
assert.match(learningAI, /category_name: parsedPres\.category_name \|\| ''/);
});
test('Learning Hub applies AI-generated category names to the editor', () => {
assert.match(frontend, /return applyAiContent\(payload, contentType\)\.then/);
assert.match(frontend, /function applyAiCategory\(categoryName\)/);
assert.match(frontend, /sendJson\('\/api\/admin\/learning\/categories', 'POST', \{ name: categoryName \}\)/);
assert.match(frontend, /findCategoryOption\(sel, categoryName\)/);
assert.match(frontend, /loadCmsCategories\(\);/);
assert.match(frontend, /loadCategories\(\);/);
});
test('Learning category creation is idempotent by name', () => {
assert.match(learningAdmin, /WHERE LOWER\(name\) = LOWER\(\?\)/);
assert.match(learningAdmin, /existing: true/);
});

View file

@ -3,14 +3,57 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'learningHub.js'), 'utf8');
function read(relativePath) {
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
}
const source = read('public/js/learningHub.js');
const apiModule = read('public/js/learningHub/api.js');
const editorModule = read('public/js/learningHub/tiptapEditor.js');
const feedRendererModule = read('public/js/learningHub/feedRenderer.js');
const sanitizeModule = read('public/js/learningHub/sanitize.js');
test('Learning Hub destroys embedded Tiptap editors before removing CMS rows', () => {
assert.match(source, /function destroyTpEditor\(editor\)/);
assert.match(source, /function destroyQuestionBlockEditors\(block\)/);
assert.match(source, /function clearQuestionBlocks\(container\)/);
assert.match(source, /from '\.\/learningHub\/tiptapEditor\.js'/);
assert.match(editorModule, /export function destroyTpEditor\(editor\)/);
assert.match(editorModule, /export function destroyQuestionBlockEditors\(block\)/);
assert.match(editorModule, /export function clearQuestionBlocks\(container\)/);
assert.match(source, /destroyQuestionBlockEditors\(qb\); qb\.remove\(\)/);
assert.match(source, /destroyOptionEditor\(row\); row\.remove\(\)/);
assert.doesNotMatch(source, /document\.getElementById\('lh-cms-questions'\)\.innerHTML = ''/);
assert.doesNotMatch(source, /qContainer\.innerHTML = ''/);
});
test('Learning Hub sanitization helpers are isolated for renderer reuse', () => {
assert.match(source, /from '\.\/learningHub\/sanitize\.js'/);
assert.match(sanitizeModule, /export function esc\(str\)/);
assert.match(sanitizeModule, /export function sanitizeHtml\(html\)/);
assert.match(sanitizeModule, /DOMPurify\.sanitize/);
assert.doesNotMatch(sanitizeModule, /ai-generate|ai-refine|FormData|\/api\/admin\/learning/);
assert.doesNotMatch(source, /function sanitizeHtml\(html\)/);
});
test('Learning Hub API helpers centralize auth JSON fetches', () => {
assert.match(source, /from '\.\/learningHub\/api\.js'/);
assert.match(apiModule, /export function getJson\(url\)/);
assert.match(apiModule, /getAuthHeaders\(\)/);
assert.match(apiModule, /export function sendJson\(url, method, payload\)/);
assert.match(apiModule, /export function deleteJson\(url\)/);
});
test('Learning Hub Tiptap helpers are isolated from content generation flow', () => {
assert.match(editorModule, /export function makeTpEditor/);
assert.match(editorModule, /window\.Tiptap/);
assert.doesNotMatch(editorModule, /ai-generate|ai-refine|FormData|\/api\/admin\/learning/);
});
test('Learning Hub feed renderers are isolated from API and editor flows', () => {
assert.match(source, /from '\.\/learningHub\/feedRenderer\.js'/);
assert.match(feedRendererModule, /export function renderCategoryPills\(categories\)/);
assert.match(feedRendererModule, /export function renderFeed\(items, feedEl\)/);
assert.match(feedRendererModule, /export function renderSearchHeader\(count, query\)/);
assert.match(feedRendererModule, /export function renderEmptySearchMessage\(\)/);
assert.match(feedRendererModule, /fa-presentation-screen/);
assert.doesNotMatch(feedRendererModule, /fetch\(|getJson|sendJson|deleteJson|ai-generate|FormData|window\.Tiptap/);
assert.doesNotMatch(source, /function renderFeed\(items, feedEl\)/);
});