extract learning hub webdav controller
This commit is contained in:
parent
dc62cc880d
commit
92351e1ab5
4 changed files with 213 additions and 98 deletions
|
|
@ -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 = '<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;
|
||||
|
||||
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;
|
||||
renderWebdavList(data);
|
||||
})
|
||||
.catch(function(err) { if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(err.message) + '</div>'; });
|
||||
}
|
||||
|
||||
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 += '<div class="lh-webdav-item lh-webdav-dir" data-path="' + esc(data.parentPath) + '" data-isdir="1">' +
|
||||
'<i class="fas fa-arrow-left" style="color:var(--g400);"></i> <span>..</span></div>';
|
||||
}
|
||||
|
||||
if (data.items.length === 0 && html === '') {
|
||||
html = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Empty folder</div>';
|
||||
}
|
||||
|
||||
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) ? ' <span style="color:var(--g400);font-size:11px;">(' + formatBytes(item.size) + ')</span>' : '';
|
||||
html += '<div class="lh-webdav-item' + (item.isDir ? ' lh-webdav-dir' : ' lh-webdav-file') + '"' +
|
||||
' data-path="' + esc(item.path) + '" data-name="' + esc(item.name) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' +
|
||||
'<i class="fas ' + icon + '" style="color:' + iconColor + ';width:16px;flex-shrink:0;"></i>' +
|
||||
'<span class="lh-webdav-name">' + esc(item.name) + '</span>' + sizeStr +
|
||||
'</div>';
|
||||
});
|
||||
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());
|
||||
}
|
||||
|
|
|
|||
116
public/js/learningHub/webdavController.js
Normal file
116
public/js/learningHub/webdavController.js
Normal file
|
|
@ -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 = '<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 = currentPath;
|
||||
|
||||
getJson('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(currentPath))
|
||||
.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;
|
||||
renderList(data);
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
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 += '<div class="lh-webdav-item lh-webdav-dir" data-path="' + esc(data.parentPath) + '" data-isdir="1">' +
|
||||
'<i class="fas fa-arrow-left" style="color:var(--g400);"></i> <span>..</span></div>';
|
||||
}
|
||||
|
||||
if (data.items.length === 0 && html === '') {
|
||||
return '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Empty folder</div>';
|
||||
}
|
||||
|
||||
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)
|
||||
? ' <span style="color:var(--g400);font-size:11px;">(' + formatBytes(item.size) + ')</span>'
|
||||
: '';
|
||||
html += '<div class="lh-webdav-item' + (item.isDir ? ' lh-webdav-dir' : ' lh-webdav-file') + '"' +
|
||||
' data-path="' + esc(item.path) + '" data-name="' + esc(item.name) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' +
|
||||
'<i class="fas ' + icon + '" style="color:' + iconColor + ';width:16px;flex-shrink:0;"></i>' +
|
||||
'<span class="lh-webdav-name">' + esc(item.name) + '</span>' + sizeStr +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
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';
|
||||
}
|
||||
|
|
@ -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\)/);
|
||||
|
|
|
|||
72
test/learning-hub-webdav-controller.test.js
Normal file
72
test/learning-hub-webdav-controller.test.js
Normal file
|
|
@ -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?<bad>',
|
||||
items: [
|
||||
{ isDir: true, path: '/Learning/<script>', name: 'Peds <Cases>', contentType: '', size: 0 },
|
||||
{ isDir: false, path: '/Learning/a.pdf', name: 'A&B.pdf', contentType: 'application/pdf', size: 2048 }
|
||||
]
|
||||
});
|
||||
|
||||
assert.match(html, /lh-webdav-dir/);
|
||||
assert.match(html, /data-path="\/Learning\?<bad>"/);
|
||||
assert.match(html, /Peds <Cases>/);
|
||||
assert.match(html, /A&B\.pdf/);
|
||||
assert.match(html, /\(2 KB\)/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
});
|
||||
|
||||
test('Learning Hub WebDAV controller tracks current and selected paths', async () => {
|
||||
const previousDocument = global.document;
|
||||
const elements = {
|
||||
'lh-ai-webdav-browser': { style: {} },
|
||||
'lh-ai-webdav-selected': { style: {} },
|
||||
'lh-ai-webdav-selected-name': { textContent: '' }
|
||||
};
|
||||
global.document = { getElementById: function(id) { return elements[id] || null; } };
|
||||
|
||||
try {
|
||||
const { createWebdavController } = await loadWebdavController();
|
||||
const webdav = createWebdavController({});
|
||||
webdav.setCurrentPath('/Learning');
|
||||
webdav.selectFile('/Learning/case.pdf', 'case.pdf');
|
||||
|
||||
assert.equal(webdav.getCurrentPath(), '/Learning');
|
||||
assert.equal(webdav.getSelectedPath(), '/Learning/case.pdf');
|
||||
assert.equal(webdav.getSelectedName(), 'case.pdf');
|
||||
assert.equal(elements['lh-ai-webdav-browser'].style.display, 'none');
|
||||
assert.equal(elements['lh-ai-webdav-selected'].style.display, 'flex');
|
||||
assert.equal(elements['lh-ai-webdav-selected-name'].textContent, 'case.pdf');
|
||||
|
||||
webdav.deselectFile();
|
||||
assert.equal(webdav.getSelectedPath(), '');
|
||||
assert.equal(webdav.getSelectedName(), '');
|
||||
assert.equal(elements['lh-ai-webdav-selected'].style.display, 'none');
|
||||
assert.equal(elements['lh-ai-webdav-browser'].style.display, '');
|
||||
} finally {
|
||||
global.document = previousDocument;
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue