diff --git a/package-lock.json b/package-lock.json
index 1e1b2b0..d7f234b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"openai": "^4.73.0",
+ "pdf-parse": "^2.4.5",
"pg": "^8.13.0",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0"
@@ -769,6 +770,190 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
+ "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
+ "license": "MIT",
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-x64": "0.1.80",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.80",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.80",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.80"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
+ "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
+ "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
+ "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
+ "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
+ "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
+ "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
+ "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
+ "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
+ "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
+ "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
@@ -3315,6 +3500,38 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
+ "node_modules/pdf-parse": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
+ "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@napi-rs/canvas": "0.1.80",
+ "pdfjs-dist": "5.4.296"
+ },
+ "bin": {
+ "pdf-parse": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": ">=20.16.0 <21 || >=22.3.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/mehmet-kozan"
+ }
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.296",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
+ "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.80"
+ }
+ },
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
diff --git a/package.json b/package.json
index 96189cb..81c1547 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"openai": "^4.73.0",
+ "pdf-parse": "^2.4.5",
"pg": "^8.13.0",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0"
diff --git a/public/components/cms.html b/public/components/cms.html
index 740cfd0..a071d98 100644
--- a/public/components/cms.html
+++ b/public/components/cms.html
@@ -76,6 +76,7 @@
-
+
+
+
+
+
+
+ By Topic
+ Upload File
+ Nextcloud
+
+
+
+
+
+
+
+
+
+ Drop a file here or click to browse
+ PDF, TXT, MD, HTML — max 20 MB
+
+
+
+
+
+
+
+
+ /
+
+
+
+
Click to browse your Nextcloud files
+
+
+
+
+ ✕
+
+
+
+
+
+
+
+
+ Questions to generate
+
+
+
+ Content type
+
+ Article
+ Quiz
+ Clinical Pearl
+
+
+
+ Model
+
+
+
+
+ Special instructions (optional)
+
+
+
+
+
+
+ Generate Content
+
+
+ Refine Current Body
+
+
+
+
+
Body
diff --git a/public/components/settings.html b/public/components/settings.html
index 29e2066..26ca016 100644
--- a/public/components/settings.html
+++ b/public/components/settings.html
@@ -43,6 +43,14 @@
Connect
Disconnect
+
+
Learning Hub — Default Browse Path
+
Folder opened first when picking files for AI content generation (e.g. /Medical-Resources)
+
+
+ Save Path
+
+
diff --git a/public/css/styles.css b/public/css/styles.css
index db15ac8..506dfbc 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -709,3 +709,26 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
/* Multi-select quiz options */
.lh-quiz-option input[type="checkbox"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
.lh-quiz-multi-hint{font-size:12px;color:var(--g500);margin-bottom:10px;font-style:italic;}
+
+/* ── AI Generate panel ────────────────────────────────────── */
+.lh-ai-panel{border:2px solid transparent;background:linear-gradient(white,white) padding-box,linear-gradient(135deg,#7c3aed,#2563eb) border-box;border-radius:12px;padding:20px;margin-bottom:16px;}
+.lh-ai-panel-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;}
+.lh-ai-tabs{display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--g200);padding-bottom:10px;}
+.lh-ai-tab{padding:6px 14px;border:1px solid var(--g200);border-radius:8px;background:white;cursor:pointer;font-size:13px;font-weight:500;color:var(--g600);display:flex;align-items:center;gap:6px;transition:all 0.15s;}
+.lh-ai-tab:hover{border-color:var(--blue);color:var(--blue);}
+.lh-ai-tab.active{background:var(--blue-light);border-color:var(--blue);color:var(--blue);font-weight:600;}
+.lh-ai-tabpanel{margin-bottom:14px;}
+.lh-ai-dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px;border:2px dashed var(--g300);border-radius:10px;cursor:pointer;text-align:center;font-size:13px;color:var(--g600);transition:border-color 0.2s;background:var(--g50);}
+.lh-ai-dropzone:hover{border-color:var(--blue);background:var(--blue-light);}
+.lh-ai-options{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;padding:14px;background:var(--g50);border-radius:8px;}
+.lh-ai-opt-row{display:flex;gap:10px;flex-wrap:wrap;}
+.lh-ai-opt-field{display:flex;flex-direction:column;gap:3px;}
+.lh-ai-opt-field label{font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;}
+
+/* WebDAV browser */
+.lh-webdav-item{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:13px;cursor:pointer;border-bottom:1px solid var(--g100);transition:background 0.1s;}
+.lh-webdav-item:last-child{border-bottom:none;}
+.lh-webdav-item:hover{background:var(--g50);}
+.lh-webdav-dir{color:var(--g700);font-weight:500;}
+.lh-webdav-file{color:var(--g600);}
+.lh-webdav-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
diff --git a/public/js/learningHub.js b/public/js/learningHub.js
index dce89e0..0c87aff 100644
--- a/public/js/learningHub.js
+++ b/public/js/learningHub.js
@@ -17,7 +17,7 @@
}
// Load CMS when Content Manager tab is opened
if (e.detail && e.detail.tab === 'cms') {
- if (!cmsLoaded) { loadCms(); cmsLoaded = true; }
+ if (!cmsLoaded) { loadCms(); cmsLoaded = true; wireCmsFileInput(); }
}
});
@@ -65,6 +65,29 @@
if (e.target.closest('#btn-lh-delete-content')) { deleteContent(); return; }
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; }
+ // AI panel
+ if (e.target.closest('#btn-lh-ai-open')) { openAiPanel(); return; }
+ if (e.target.closest('#btn-lh-ai-close')) { closeAiPanel(); return; }
+ if (e.target.closest('#btn-lh-ai-generate')) { runAiGenerate(); return; }
+ if (e.target.closest('#btn-lh-ai-refine-body')) { runAiRefineBody(); return; }
+ if (e.target.closest('#btn-lh-webdav-refresh')) { browseWebdav(_aiWebdavCurrentPath); return; }
+ if (e.target.closest('#btn-lh-webdav-deselect')) { deselectWebdavFile(); return; }
+
+ // AI tabs
+ var aiTab = e.target.closest('.lh-ai-tab');
+ if (aiTab && aiTab.dataset.aitab) { switchAiTab(aiTab.dataset.aitab); return; }
+
+ // WebDAV list items
+ var wdItem = e.target.closest('.lh-webdav-item');
+ if (wdItem) {
+ if (wdItem.dataset.isdir === '1') {
+ browseWebdav(wdItem.dataset.path);
+ } else {
+ selectWebdavFile(wdItem.dataset.path, wdItem.dataset.name);
+ }
+ return;
+ }
+
// Rich text toggle for question or option
var rtBtn = e.target.closest('.lh-richtext-btn');
if (rtBtn) { toggleRichText(rtBtn); return; }
@@ -421,12 +444,279 @@
// ADMIN CMS
// ============================================================
+ function wireCmsFileInput() {
+ var fileInput = document.getElementById('lh-ai-file');
+ var fileLabel = document.getElementById('lh-ai-file-label');
+ var dropzone = document.getElementById('lh-ai-dropzone');
+ if (fileInput) {
+ fileInput.addEventListener('change', function() {
+ if (fileInput.files[0] && fileLabel) fileLabel.textContent = fileInput.files[0].name;
+ });
+ }
+ if (dropzone) {
+ dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.style.borderColor = 'var(--blue)'; });
+ dropzone.addEventListener('dragleave', function() { dropzone.style.borderColor = ''; });
+ dropzone.addEventListener('drop', function(e) {
+ e.preventDefault();
+ dropzone.style.borderColor = '';
+ var files = e.dataTransfer.files;
+ if (files[0] && fileInput && fileLabel) {
+ // Transfer to file input via DataTransfer
+ try {
+ var dt = new DataTransfer();
+ dt.items.add(files[0]);
+ fileInput.files = dt.files;
+ } catch(ex) {}
+ fileLabel.textContent = files[0].name;
+ }
+ });
+ }
+ }
+
function loadCms() {
loadCmsCategories();
loadCmsContent();
loadCmsStats();
}
+ // ============================================================
+ // AI GENERATE PANEL
+ // ============================================================
+
+ var _aiWebdavCurrentPath = '/';
+ var _aiWebdavSelectedPath = '';
+ var _aiWebdavSelectedName = '';
+
+ function openAiPanel() {
+ var panel = document.getElementById('lh-ai-panel');
+ if (!panel) return;
+ panel.classList.remove('hidden');
+ // Populate model selector
+ populateAiModelSelect();
+ // Hide WebDAV tab if Nextcloud not configured
+ fetch('/api/auth/me', { headers: getAuthHeaders() })
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ var tab = document.getElementById('lh-ai-tab-webdav');
+ if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
+ if (d.user && d.user.webdav_learning_path) {
+ _aiWebdavCurrentPath = d.user.webdav_learning_path || '/';
+ }
+ });
+ panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+
+ function closeAiPanel() {
+ var panel = document.getElementById('lh-ai-panel');
+ if (panel) panel.classList.add('hidden');
+ }
+
+ function populateAiModelSelect() {
+ var sel = document.getElementById('lh-ai-model');
+ if (!sel || sel.options.length > 1) return;
+ // Copy options from global model select
+ var globalSel = document.getElementById('global-model-select');
+ if (!globalSel) return;
+ sel.innerHTML = globalSel.innerHTML;
+ sel.value = globalSel.value;
+ }
+
+ function switchAiTab(tabName) {
+ document.querySelectorAll('.lh-ai-tab').forEach(function(t) { t.classList.toggle('active', t.dataset.aitab === tabName); });
+ document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); });
+ var tp = document.getElementById('lh-ai-tp-' + tabName);
+ if (tp) tp.classList.remove('hidden');
+ if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) {
+ browseWebdav(_aiWebdavCurrentPath);
+ }
+ }
+
+ // ── WebDAV browser ──────────────────────────────────────────
+ function browseWebdav(path) {
+ _aiWebdavCurrentPath = path || '/';
+ var list = document.getElementById('lh-ai-webdav-list');
+ var pathLabel = document.getElementById('lh-ai-webdav-path-label');
+ if (list) list.innerHTML = ' Loading...
';
+ if (pathLabel) pathLabel.textContent = _aiWebdavCurrentPath;
+
+ fetch('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath), { headers: getAuthHeaders() })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (!data.success) { if (list) list.innerHTML = '' + esc(data.error) + '
'; return; }
+ if (pathLabel) pathLabel.textContent = data.path;
+ renderWebdavList(data);
+ })
+ .catch(function(err) { if (list) list.innerHTML = '' + esc(err.message) + '
'; });
+ }
+
+ function renderWebdavList(data) {
+ var list = document.getElementById('lh-ai-webdav-list');
+ if (!list) return;
+
+ var html = '';
+
+ // Parent nav
+ if (data.path !== '/' && data.path !== data.parentPath) {
+ html += '' +
+ ' ..
';
+ }
+
+ if (data.items.length === 0 && html === '') {
+ html = 'Empty folder
';
+ }
+
+ data.items.forEach(function(item) {
+ var icon = item.isDir ? 'fa-folder' : getFileIcon(item.contentType, item.name);
+ var iconColor = item.isDir ? 'var(--amber)' : 'var(--blue)';
+ var sizeStr = (!item.isDir && item.size) ? ' (' + formatBytes(item.size) + ') ' : '';
+ html += '' +
+ ' ' +
+ '' + esc(item.name) + ' ' + sizeStr +
+ '
';
+ });
+ list.innerHTML = html;
+ }
+
+ function getFileIcon(mime, name) {
+ if (!name) name = '';
+ var ext = name.split('.').pop().toLowerCase();
+ if (mime && mime.includes('pdf') || ext === 'pdf') return 'fa-file-pdf';
+ if (['doc','docx'].includes(ext)) return 'fa-file-word';
+ if (['txt','md'].includes(ext)) return 'fa-file-lines';
+ if (['html','htm'].includes(ext)) return 'fa-file-code';
+ return 'fa-file';
+ }
+
+ function formatBytes(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024*1024) return (bytes/1024).toFixed(0) + ' KB';
+ return (bytes/1024/1024).toFixed(1) + ' MB';
+ }
+
+ function selectWebdavFile(path, name) {
+ _aiWebdavSelectedPath = path;
+ _aiWebdavSelectedName = name;
+ var selDiv = document.getElementById('lh-ai-webdav-selected');
+ var selName = document.getElementById('lh-ai-webdav-selected-name');
+ if (selDiv) selDiv.classList.remove('hidden');
+ if (selName) selName.textContent = name;
+ }
+
+ function deselectWebdavFile() {
+ _aiWebdavSelectedPath = '';
+ _aiWebdavSelectedName = '';
+ var selDiv = document.getElementById('lh-ai-webdav-selected');
+ if (selDiv) selDiv.classList.add('hidden');
+ }
+
+ // ── Generate ────────────────────────────────────────────────
+ function runAiGenerate() {
+ var activeTab = document.querySelector('.lh-ai-tab.active');
+ var tabName = activeTab ? activeTab.dataset.aitab : 'topic';
+ var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
+ var questionCount = document.getElementById('lh-ai-q-count') ? document.getElementById('lh-ai-q-count').value : 5;
+ var contentType = document.getElementById('lh-ai-ctype') ? document.getElementById('lh-ai-ctype').value : 'article';
+ var refinement = document.getElementById('lh-ai-refinement') ? document.getElementById('lh-ai-refinement').value.trim() : '';
+
+ var formData = new FormData();
+ formData.append('model', model);
+ formData.append('questionCount', questionCount);
+ formData.append('contentType', contentType);
+ if (refinement) formData.append('refinement', refinement);
+
+ if (tabName === 'topic') {
+ var topic = document.getElementById('lh-ai-topic') ? document.getElementById('lh-ai-topic').value.trim() : '';
+ if (!topic) { showToast('Enter a topic', 'error'); return; }
+ formData.append('topic', topic);
+ } else if (tabName === 'upload') {
+ var fileInput = document.getElementById('lh-ai-file');
+ if (!fileInput || !fileInput.files[0]) { showToast('Select a file to upload', 'error'); return; }
+ formData.append('file', fileInput.files[0]);
+ } else if (tabName === 'webdav') {
+ if (!_aiWebdavSelectedPath) { showToast('Select a file from Nextcloud', 'error'); return; }
+ formData.append('webdavPath', _aiWebdavSelectedPath);
+ }
+
+ var btn = document.getElementById('btn-lh-ai-generate');
+ if (btn) { btn.disabled = true; btn.innerHTML = ' Generating...'; }
+ showLoading('AI is generating content…');
+
+ // For FormData, do NOT set Content-Type (browser sets it with boundary)
+ var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
+ fetch('/api/admin/learning/ai-generate', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + token },
+ body: formData
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ hideLoading();
+ if (btn) { btn.disabled = false; btn.innerHTML = ' Generate Content'; }
+ if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
+ applyAiContent(data.content, contentType);
+ closeAiPanel();
+ showToast('Content generated! Review and save.', 'success');
+ })
+ .catch(function(err) {
+ hideLoading();
+ if (btn) { btn.disabled = false; btn.innerHTML = ' Generate Content'; }
+ showToast(err.message, 'error');
+ });
+ }
+
+ function applyAiContent(content, contentType) {
+ // Fill title
+ var titleEl = document.getElementById('lh-cms-edit-title');
+ if (titleEl && content.title) titleEl.value = content.title;
+
+ // Fill subject
+ var subjectEl = document.getElementById('lh-cms-edit-subject');
+ if (subjectEl && content.subject) subjectEl.value = content.subject;
+
+ // Set content type
+ var typeEl = document.getElementById('lh-cms-edit-type');
+ if (typeEl && contentType) typeEl.value = contentType;
+
+ // Fill body via Tiptap
+ if (_bodyEditor && content.body) _bodyEditor.commands.setContent(content.body);
+
+ // Replace questions
+ var qContainer = document.getElementById('lh-cms-questions');
+ if (qContainer) {
+ qContainer.innerHTML = '';
+ if (content.questions && Array.isArray(content.questions)) {
+ content.questions.forEach(function(q) { addQuestionBlock(q); });
+ }
+ }
+ }
+
+ function runAiRefineBody() {
+ var instructions = prompt('How should the body be refined?\n(e.g., "Make it more concise", "Add a clinical case", "Focus on management")');
+ if (!instructions || !instructions.trim()) return;
+
+ var currentBody = _bodyEditor ? getTpHTML(_bodyEditor) : '';
+ if (!currentBody) { showToast('No body content to refine', 'error'); return; }
+
+ var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
+
+ showLoading('Refining content…');
+
+ fetch('/api/admin/learning/ai-refine', {
+ method: 'POST',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ content: currentBody, instructions: instructions.trim(), model: model })
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ hideLoading();
+ if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
+ if (_bodyEditor) _bodyEditor.commands.setContent(data.refined);
+ showToast('Body refined!', 'success');
+ })
+ .catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
+ }
+
function loadCmsStats() {
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
diff --git a/public/js/nextcloud.js b/public/js/nextcloud.js
index 0bdd089..9451be0 100644
--- a/public/js/nextcloud.js
+++ b/public/js/nextcloud.js
@@ -7,14 +7,21 @@
fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
+ var pathSection = document.getElementById('nc-webdav-path-section');
if (data.user && data.user.nextcloud_url) {
document.getElementById('nc-status').innerHTML = '✅ Connected to ' + data.user.nextcloud_url + ' as ' + data.user.nextcloud_user;
document.getElementById('nc-url').value = data.user.nextcloud_url;
document.getElementById('nc-user').value = data.user.nextcloud_user;
document.getElementById('btn-nc-disconnect').classList.remove('hidden');
+ if (pathSection) {
+ pathSection.classList.remove('hidden');
+ var pathEl = document.getElementById('nc-webdav-path');
+ if (pathEl) pathEl.value = data.user.webdav_learning_path || '';
+ }
} else {
document.getElementById('nc-status').textContent = 'Not connected';
document.getElementById('btn-nc-disconnect').classList.add('hidden');
+ if (pathSection) pathSection.classList.add('hidden');
}
});
}
@@ -54,6 +61,15 @@
.then(function() { showToast('Disconnected', 'info'); loadNextcloudStatus(); });
});
+ document.getElementById('btn-nc-save-path').addEventListener('click', function() {
+ var p = (document.getElementById('nc-webdav-path').value || '').trim();
+ fetch('/api/admin/learning/webdav-path', {
+ method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ path: p })
+ }).then(function(r) { return r.json(); })
+ .then(function(d) { d.success ? showToast('Path saved', 'success') : showToast(d.error || 'Failed', 'error'); })
+ .catch(function() { showToast('Failed to save', 'error'); });
+ });
+
console.log('✅ Nextcloud module loaded');
});
})();
diff --git a/server.js b/server.js
index e28f59d..a042a4f 100644
--- a/server.js
+++ b/server.js
@@ -154,6 +154,7 @@ app.use('/api', require('./src/routes/encounters'));
app.use('/api', require('./src/routes/memories'));
app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
+app.use('/api/admin/learning', require('./src/routes/learningAI'));
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
diff --git a/src/db/database.js b/src/db/database.js
index 4705af9..ee328b3 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -210,6 +210,8 @@ async function initDatabase() {
// Add updated_by column to app_settings if upgrading
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
+ // Add webdav_learning_path to users for Learning Hub file browser
+ try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
var defaults = [
diff --git a/src/routes/learningAI.js b/src/routes/learningAI.js
new file mode 100644
index 0000000..42f8edf
--- /dev/null
+++ b/src/routes/learningAI.js
@@ -0,0 +1,304 @@
+// ============================================================
+// 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: 20 * 1024 * 1024 } // 20 MB
+});
+
+// ── 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 } = opts;
+
+ var source = docText
+ ? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 12000) + '\n"""\n'
+ : 'Generate educational content on the following topic for a medical professional audience (pediatrics / primary care).\n\nTOPIC: ' + topic + '\n';
+
+ var typeInstr = '';
+ if (contentType === 'quiz') {
+ typeInstr = 'This is a quiz-only resource. Write a brief introductory body (1-2 paragraphs), then generate ' + questionCount + ' quiz questions.';
+ } else if (contentType === 'pearl') {
+ typeInstr = 'This is a clinical pearl. Write a concise, high-impact body (2-4 paragraphs focusing on key takeaways), then generate ' + questionCount + ' quiz question(s).';
+ } else {
+ typeInstr = 'This is an article. Write a comprehensive body (4-8 paragraphs), then generate ' + questionCount + ' quiz questions.';
+ }
+
+ var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
+
+ 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.single('file'), async function(req, res) {
+ try {
+ var topic = req.body.topic || '';
+ var contentType = req.body.contentType || 'article';
+ var questionCount = Math.min(parseInt(req.body.questionCount) || 5, 20);
+ var model = req.body.model || null;
+ var refinement = req.body.refinement || '';
+ var webdavPath = req.body.webdavPath || '';
+
+ var docText = '';
+
+ // 1 — Uploaded file
+ if (req.file) {
+ docText = await extractText(req.file.buffer, req.file.mimetype, req.file.originalname);
+ }
+
+ // 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 });
+
+ var result = await callAI(
+ [{ role: 'user', content: prompt }],
+ { model: model, temperature: 0.4, maxTokens: 6000 }
+ );
+
+ // Parse JSON from AI response
+ var raw = result.content.trim();
+ // Strip code fences if model added them anyway
+ raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
+
+ var parsed;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (e) {
+ // Try to extract JSON object from surrounding text
+ var match = raw.match(/\{[\s\S]*\}/);
+ if (match) {
+ try { parsed = JSON.parse(match[0]); }
+ catch (e2) { return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' }); }
+ } else {
+ return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' });
+ }
+ }
+
+ res.json({
+ success: true,
+ content: parsed,
+ model: result.model,
+ docLength: docText.length
+ });
+
+ } 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: ` `,
+ timeout: 15000
+ });
+
+ // Parse WebDAV XML response
+ var xml = davResponse.data;
+ var items = [];
+ var responseRegex = /([\s\S]*?)<\/d:response>/g;
+ var match;
+ while ((match = responseRegex.exec(xml)) !== null) {
+ var block = match[1];
+ var hrefMatch = block.match(/([^<]+)<\/d:href>/);
+ var nameMatch = block.match(/([^<]*)<\/d:displayname>/);
+ var typeMatch = block.match(/([^<]*)<\/d:getcontenttype>/);
+ var sizeMatch = block.match(/([^<]*)<\/d:getcontentlength>/);
+ var isCollMatch = block.includes('