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 @@
+ +
+
+ + + + + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+ + +
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 @@
+ 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('