diff --git a/public/js/learningHub.js b/public/js/learningHub.js
index 0466070..b6b27b7 100644
--- a/public/js/learningHub.js
+++ b/public/js/learningHub.js
@@ -19,6 +19,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
import { createQuizController } from './learningHub/quizController.js';
import { createSlideController } from './learningHub/slideController.js';
import { buildViewerMeta, renderPresentationCard, renderProgressList } from './learningHub/viewerRenderer.js';
+import { createWebdavController } from './learningHub/webdavController.js';
var loaded = false;
var cmsLoaded = false;
@@ -42,6 +43,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
hideLoading: function() { hideLoading(); },
showToast: function(message, type) { showToast(message, type); }
});
+ var webdav = createWebdavController({});
// ── Load when tab activated ────────────────────────────────
document.addEventListener('tabChanged', function(e) {
@@ -121,8 +123,8 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
if (e.target.closest('#btn-lh-ai-refine-body')) { toggleRefineBar(); return; }
if (e.target.closest('#btn-lh-refine-submit')) { submitRefineBody(); return; }
if (e.target.closest('#btn-lh-refine-cancel')) { toggleRefineBar(false); return; }
- if (e.target.closest('#btn-lh-webdav-refresh')) { browseWebdav(_aiWebdavCurrentPath); return; }
- if (e.target.closest('#btn-lh-webdav-deselect')) { deselectWebdavFile(); return; }
+ if (e.target.closest('#btn-lh-webdav-refresh')) { webdav.browse(webdav.getCurrentPath()); return; }
+ if (e.target.closest('#btn-lh-webdav-deselect')) { webdav.deselectFile(); return; }
// AI tabs
var aiTab = e.target.closest('.lh-ai-tab');
@@ -131,11 +133,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
// 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);
- }
+ webdav.openItem(wdItem);
return;
}
@@ -440,10 +438,6 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
// AI GENERATE PANEL
// ============================================================
- var _aiWebdavCurrentPath = '/';
- var _aiWebdavSelectedPath = '';
- var _aiWebdavSelectedName = '';
-
function openAiPanel() {
var panel = document.getElementById('lh-ai-panel');
if (!panel) return;
@@ -461,7 +455,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
.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 || '/';
+ if (d.user && d.user.webdav_learning_path) webdav.setCurrentPath(d.user.webdav_learning_path || '/');
});
// Wire ctype change once (use a flag so it doesn't stack on re-opens)
@@ -538,93 +532,10 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
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.browse(webdav.getCurrentPath());
}
}
- // ── 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;
-
- getJson('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath))
- .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 browser = document.getElementById('lh-ai-webdav-browser');
- var selDiv = document.getElementById('lh-ai-webdav-selected');
- var selName = document.getElementById('lh-ai-webdav-selected-name');
- // Hide browser, show selected indicator (use style.display — class="hidden" is overridden by inline styles)
- if (browser) browser.style.display = 'none';
- if (selDiv) selDiv.style.display = 'flex';
- if (selName) selName.textContent = name;
- }
-
- function deselectWebdavFile() {
- _aiWebdavSelectedPath = '';
- _aiWebdavSelectedName = '';
- var browser = document.getElementById('lh-ai-webdav-browser');
- var selDiv = document.getElementById('lh-ai-webdav-selected');
- if (selDiv) selDiv.style.display = 'none';
- if (browser) browser.style.display = '';
- }
-
// ── Generate ────────────────────────────────────────────────
function runAiGenerate() {
var activeTab = document.querySelector('.lh-ai-tab.active');
@@ -659,8 +570,9 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
var uploadCtx = document.getElementById('lh-ai-upload-context');
if (uploadCtx && uploadCtx.value.trim()) formData.append('topic', uploadCtx.value.trim());
} else if (tabName === 'webdav') {
- if (!_aiWebdavSelectedPath) { showToast('Select a file from Nextcloud', 'error'); return; }
- formData.append('webdavPath', _aiWebdavSelectedPath);
+ var webdavPath = webdav.getSelectedPath();
+ if (!webdavPath) { showToast('Select a file from Nextcloud', 'error'); return; }
+ formData.append('webdavPath', webdavPath);
var webdavCtx = document.getElementById('lh-ai-webdav-context');
if (webdavCtx && webdavCtx.value.trim()) formData.append('topic', webdavCtx.value.trim());
}
diff --git a/public/js/learningHub/webdavController.js b/public/js/learningHub/webdavController.js
new file mode 100644
index 0000000..61dc9ff
--- /dev/null
+++ b/public/js/learningHub/webdavController.js
@@ -0,0 +1,116 @@
+import { getJson } from './api.js';
+import { esc } from './sanitize.js';
+
+export function createWebdavController(deps) {
+ var currentPath = '/';
+ var selectedPath = '';
+ var selectedName = '';
+
+ function browse(path) {
+ currentPath = 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 = currentPath;
+
+ getJson('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(currentPath))
+ .then(function(data) {
+ if (!data.success) {
+ if (list) list.innerHTML = '' + esc(data.error) + '
';
+ return;
+ }
+ if (pathLabel) pathLabel.textContent = data.path;
+ renderList(data);
+ })
+ .catch(function(err) {
+ if (list) list.innerHTML = '' + esc(err.message) + '
';
+ });
+ }
+
+ function renderList(data) {
+ var list = document.getElementById('lh-ai-webdav-list');
+ if (!list) return;
+ list.innerHTML = renderWebdavListHtml(data);
+ }
+
+ function openItem(el) {
+ if (!el) return;
+ if (el.dataset.isdir === '1') browse(el.dataset.path);
+ else selectFile(el.dataset.path, el.dataset.name);
+ }
+
+ function selectFile(path, name) {
+ selectedPath = path;
+ selectedName = name;
+ var browser = document.getElementById('lh-ai-webdav-browser');
+ var selDiv = document.getElementById('lh-ai-webdav-selected');
+ var selName = document.getElementById('lh-ai-webdav-selected-name');
+ if (browser) browser.style.display = 'none';
+ if (selDiv) selDiv.style.display = 'flex';
+ if (selName) selName.textContent = name;
+ }
+
+ function deselectFile() {
+ selectedPath = '';
+ selectedName = '';
+ var browser = document.getElementById('lh-ai-webdav-browser');
+ var selDiv = document.getElementById('lh-ai-webdav-selected');
+ if (selDiv) selDiv.style.display = 'none';
+ if (browser) browser.style.display = '';
+ }
+
+ return {
+ browse: browse,
+ deselectFile: deselectFile,
+ getCurrentPath: function() { return currentPath; },
+ getSelectedName: function() { return selectedName; },
+ getSelectedPath: function() { return selectedPath; },
+ openItem: openItem,
+ selectFile: selectFile,
+ setCurrentPath: function(path) { currentPath = path || '/'; }
+ };
+}
+
+export function renderWebdavListHtml(data) {
+ var html = '';
+
+ if (data.path !== '/' && data.path !== data.parentPath) {
+ html += '' +
+ ' ..
';
+ }
+
+ if (data.items.length === 0 && html === '') {
+ return '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 +
+ '
';
+ });
+
+ return html;
+}
+
+export 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';
+}
+
+export 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';
+}
diff --git a/test/learning-hub-editor.test.js b/test/learning-hub-editor.test.js
index bf344aa..5ac1152 100644
--- a/test/learning-hub-editor.test.js
+++ b/test/learning-hub-editor.test.js
@@ -18,6 +18,7 @@ const quizRendererModule = read('public/js/learningHub/quizRenderer.js');
const sanitizeModule = read('public/js/learningHub/sanitize.js');
const slideControllerModule = read('public/js/learningHub/slideController.js');
const viewerRendererModule = read('public/js/learningHub/viewerRenderer.js');
+const webdavControllerModule = read('public/js/learningHub/webdavController.js');
test('Learning Hub destroys embedded Tiptap editors before removing CMS rows', () => {
assert.match(source, /from '\.\/learningHub\/tiptapEditor\.js'/);
@@ -149,6 +150,20 @@ test('Learning Hub quiz controller owns submission and answer tracking', () => {
assert.doesNotMatch(source, /function submitQuiz\(\)|function showQuizResults\(|function renderQuiz\(questions\)/);
});
+test('Learning Hub WebDAV controller owns browser state and rendering', () => {
+ assert.match(source, /from '\.\/learningHub\/webdavController\.js'/);
+ assert.match(source, /var webdav = createWebdavController\(/);
+ assert.match(source, /webdav\.browse\(webdav\.getCurrentPath\(\)\)/);
+ assert.match(source, /webdav\.openItem\(wdItem\)/);
+ assert.match(source, /var webdavPath = webdav\.getSelectedPath\(\)/);
+ assert.match(webdavControllerModule, /export function createWebdavController\(deps\)/);
+ assert.match(webdavControllerModule, /export function renderWebdavListHtml\(data\)/);
+ assert.match(webdavControllerModule, /export function getFileIcon\(mime, name\)/);
+ assert.match(webdavControllerModule, /export function formatBytes\(bytes\)/);
+ assert.doesNotMatch(source, /var _aiWebdav|function browseWebdav\(|function renderWebdavList\(|function selectWebdavFile\(/);
+ assert.doesNotMatch(webdavControllerModule, /window\.Tiptap|ai-generate|FormData/);
+});
+
test('Learning Hub viewer renderers are isolated from API and slide loading', () => {
assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/);
assert.match(viewerRendererModule, /export function buildViewerMeta\(item\)/);
diff --git a/test/learning-hub-webdav-controller.test.js b/test/learning-hub-webdav-controller.test.js
new file mode 100644
index 0000000..d09b9b2
--- /dev/null
+++ b/test/learning-hub-webdav-controller.test.js
@@ -0,0 +1,72 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+
+async function loadWebdavController() {
+ return import(pathToFileURL(path.join(__dirname, '..', 'public/js/learningHub/webdavController.js')).href);
+}
+
+test('Learning Hub WebDAV helpers preserve icons and byte labels', async () => {
+ const { formatBytes, getFileIcon } = await loadWebdavController();
+
+ assert.equal(getFileIcon('application/pdf', 'scan.bin'), 'fa-file-pdf');
+ assert.equal(getFileIcon('', 'note.md'), 'fa-file-lines');
+ assert.equal(getFileIcon('', 'page.html'), 'fa-file-code');
+ assert.equal(getFileIcon('', 'letter.docx'), 'fa-file-word');
+ assert.equal(getFileIcon('', 'archive.zip'), 'fa-file');
+ assert.equal(formatBytes(512), '512 B');
+ assert.equal(formatBytes(2048), '2 KB');
+ assert.equal(formatBytes(1572864), '1.5 MB');
+});
+
+test('Learning Hub WebDAV list rendering escapes paths and names', async () => {
+ const { renderWebdavListHtml } = await loadWebdavController();
+ const html = renderWebdavListHtml({
+ path: '/Learning/Sub',
+ parentPath: '/Learning?',
+ items: [
+ { isDir: true, path: '/Learning/