From eb724167bcb0809a2ba6e6f9d6faa1e281764942 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 17 Oct 2026 19:57:03 +0100 Subject: [PATCH] Add media filename renaming and preserve library filters --- CHANGELOG.md | 2 + src/rbac.js | 1 + src/web/public/js/settings/media-library.js | 96 ++++++++++- src/web/routes/settings/media-library.js | 52 +++++- src/web/views/settings/media-library/list.hbs | 20 ++- test/media-library.test.js | 159 ++++++++++++++++++ 6 files changed, 310 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d98801b..03827ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - Added an All timetable display mode that includes past items while respecting date filters and the item limit. +- Added filename renaming in Media Library image and video previews without changing stored media paths or slide references. ### Changed @@ -19,6 +20,7 @@ All notable changes to this project will be documented in this file. - Region inputs no longer submit the template form when Enter is pressed. - Region width and height can be cleared and retyped without minimum-size clamping until the field is committed. - Restored remove controls when selecting image or video media from the Media Library. +- Preserved Media Library search, filters, and sort order after individual or bulk deletion. ## 2.15.4 - 2026-10-16 diff --git a/src/rbac.js b/src/rbac.js index e66bb21..305a2c7 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -88,6 +88,7 @@ const PERMISSION_SECTIONS = [ permissions: [ { key: 'read', name: 'Read', description: 'View shared media assets.' }, { key: 'create', name: 'Create', description: 'Upload shared media assets.' }, + { key: 'update', name: 'Update', description: 'Rename shared media assets.' }, { key: 'delete', name: 'Delete', description: 'Delete unused shared media assets.' } ] }, diff --git a/src/web/public/js/settings/media-library.js b/src/web/public/js/settings/media-library.js index 35c09e4..54d6faf 100644 --- a/src/web/public/js/settings/media-library.js +++ b/src/web/public/js/settings/media-library.js @@ -6,6 +6,15 @@ const search = gallery && gallery.querySelector('[data-media-gallery-search]'); const typeFilter = gallery && gallery.querySelector('[data-media-gallery-type-filter]'); const usageFilter = gallery && gallery.querySelector('[data-media-gallery-usage-filter]'); const sort = gallery && gallery.querySelector('[data-media-gallery-sort]'); +const initialGalleryQuery = new URLSearchParams(window.location.search); +if (search && initialGalleryQuery.has('search')) search.value = initialGalleryQuery.get('search'); +[ ['type', typeFilter], ['usage', usageFilter], ['sort', sort] ].forEach(function (entry) { + const control = entry[1]; + const value = initialGalleryQuery.get(entry[0]); + if (control && Array.from(control.options).some(function (option) { return option.value === value; })) { + control.value = value; + } +}); const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]'); const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]'); const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]'); @@ -369,14 +378,19 @@ if (uploadForm) { }); } -function getGalleryQuery(offset, limit) { +function getGalleryFilters() { const params = new URLSearchParams(); - params.set('offset', String(offset)); - params.set('limit', String(limit)); params.set('search', String(search && search.value || '').trim()); params.set('type', typeFilter ? typeFilter.value : 'all'); params.set('usage', usageFilter ? usageFilter.value : 'all'); params.set('sort', sort ? sort.value : 'date-desc'); + return params; +} + +function getGalleryQuery(offset, limit) { + const params = getGalleryFilters(); + params.set('offset', String(offset)); + params.set('limit', String(limit)); return params.toString(); } @@ -439,7 +453,7 @@ function submitBulkDelete() { } const form = document.createElement('form'); form.method = 'post'; - form.action = '/media-library/delete'; + form.action = '/media-library/delete?' + getGalleryFilters().toString(); selectedAssetIds.forEach(function (id) { const input = document.createElement('input'); input.type = 'hidden'; @@ -484,6 +498,12 @@ if (typeFilter) typeFilter.addEventListener('change', resetVisibleItemLimit); if (usageFilter) usageFilter.addEventListener('change', resetVisibleItemLimit); if (sort) sort.addEventListener('change', resetVisibleItemLimit); if (gallery) { + gallery.addEventListener('submit', function (event) { + const form = event.target; + const action = form && form.getAttribute('action'); + if (!/^\/media-library\/[^/?]+\/delete(?:\?|$)/.test(action || '')) return; + form.action = action.split('?')[0] + '?' + getGalleryFilters().toString(); + }, true); gallery.addEventListener('click', function (event) { if (!selectionMode) return; const column = event.target && event.target.closest ? event.target.closest('[data-media-gallery-column]') : null; @@ -534,6 +554,62 @@ function showPreviewModal() { } if (modal && body) { + const renameForm = modal.querySelector('[data-media-gallery-rename-form]'); + const filenameInput = modal.querySelector('[data-media-gallery-filename]'); + const renameSave = modal.querySelector('[data-media-gallery-rename-save]'); + const renameStatus = modal.querySelector('[data-media-gallery-rename-status]'); + let activePreview = null; + + if (renameForm && filenameInput && renameSave && renameStatus) { + renameForm.addEventListener('submit', async function (event) { + event.preventDefault(); + if (renameSave.disabled || !activePreview) return; + const preview = activePreview; + const action = renameForm.action; + renameSave.disabled = true; + filenameInput.disabled = true; + renameStatus.hidden = true; + try { + const response = await fetch(action, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ name: filenameInput.value }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Unable to rename media.'); + preview.dataset.mediaName = result.name; + preview.setAttribute('aria-label', 'Preview ' + result.name); + const column = preview.closest('[data-media-gallery-column]'); + const label = column && column.querySelector('.media-gallery-name'); + if (label) { + label.textContent = result.name; + label.title = result.name; + } + if (activePreview === preview) { + filenameInput.value = result.name; + if (title) title.textContent = result.name; + const image = body.querySelector('img'); + if (image) image.alt = result.name; + renameStatus.textContent = 'Filename saved.'; + renameStatus.classList.remove('text-danger'); + renameStatus.hidden = false; + } + await refreshGallery(); + } catch (error) { + if (activePreview === preview) { + renameStatus.textContent = error.message || 'Unable to rename media.'; + renameStatus.classList.add('text-danger'); + renameStatus.hidden = false; + } + } finally { + if (activePreview === preview) { + renameSave.disabled = false; + filenameInput.disabled = false; + } + } + }); + } + document.addEventListener('click', function (event) { const preview = event.target && event.target.closest ? event.target.closest('[data-media-gallery-preview]') : null; if (!preview) { @@ -543,6 +619,17 @@ if (modal && body) { const path = preview.getAttribute('data-media-path') || ''; const type = preview.getAttribute('data-media-type') || 'image'; const name = preview.getAttribute('data-media-name') || 'Media preview'; + activePreview = preview; + if (renameForm && filenameInput && renameSave && renameStatus) { + const column = preview.closest('[data-media-gallery-column]'); + const assetId = column && column.dataset.mediaId; + renameForm.hidden = !assetId; + renameForm.action = '/media-library/' + encodeURIComponent(assetId || '') + '/rename'; + filenameInput.value = name; + filenameInput.disabled = false; + renameSave.disabled = false; + renameStatus.hidden = true; + } body.replaceChildren(); if (title) { title.textContent = name; @@ -560,6 +647,7 @@ if (modal && body) { }); modal.addEventListener('hidden.bs.modal', function () { + activePreview = null; body.replaceChildren(); }); } \ No newline at end of file diff --git a/src/web/routes/settings/media-library.js b/src/web/routes/settings/media-library.js index 83b1d19..339b18b 100644 --- a/src/web/routes/settings/media-library.js +++ b/src/web/routes/settings/media-library.js @@ -14,7 +14,7 @@ function requireMediaAccess(setAuthMessageCookie) { } return res.redirect('/login'); } - if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.delete'])) { + if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.update', 'media-library.delete'])) { return next(); } const error = new Error(PERMISSION_DENIED_MESSAGE); @@ -71,7 +71,7 @@ function formatFileSize(bytes) { } function getMediaFormat(asset) { - const name = String(asset && (asset.original_name || asset.media_path) || '').trim(); + const name = String(asset && (asset.media_path || asset.original_name) || '').trim(); const match = name.match(/\.([a-z0-9]+)$/i); return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase(); } @@ -115,6 +115,13 @@ function filterAndSortAssets(assets, query) { return filtered; } +function getDeleteRedirect(req, message) { + const params = new URLSearchParams(getAssetQuery(req.query)); + params.set('search', String(req.query && req.query.search || '').trim()); + params.set('message', message); + return '/media-library?' + params.toString(); +} + module.exports = function registerMediaLibraryRoutes(app, deps) { const requireAccess = requireMediaAccess(deps.setAuthMessageCookie); const recordRequestAuditEvent = deps.recordRequestAuditEvent; @@ -219,6 +226,37 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { } }); + app.post('/media-library/:id/rename', requireAccess, deps.requirePermission('media-library.update'), async function (req, res, next) { + try { + const name = typeof (req.body && req.body.name) === 'string' ? req.body.name.trim() : ''; + if (!name || name.length > 255 || /[<>:"/\\|?*\u0000-\u001f\u007f]/.test(name) || name === '.' || name === '..' || /[. ]$/.test(name)) { + return res.status(400).json({ error: 'Enter a valid filename of up to 255 characters.' }); + } + const [rows] = await deps.pool.query('SELECT id, original_name, media_path FROM c_media_assets WHERE id = ?', [Number(req.params.id)]); + const asset = rows && rows[0]; + if (!asset) { + return res.status(404).json({ error: 'Media asset not found.' }); + } + if (asset.original_name !== name) { + await deps.pool.query('UPDATE c_media_assets SET original_name = ?, modified_by = ? WHERE id = ?', [name, deps.getAuditUserId(req), asset.id]); + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(deps.pool, req, { + category: 'media-assets', + eventType: 'media.renamed', + actorUserId: deps.getAuditUserId(req), + targetType: 'media-asset', + targetId: asset.id, + targetLabel: name, + details: { mediaPath: asset.media_path, previousName: asset.original_name, name: name } + }); + } + } + res.json({ id: asset.id, name: name }); + } catch (error) { + next(error); + } + }); + async function deleteMediaAsset(assetId, req) { const [rows] = await deps.pool.query('SELECT id, media_path, original_name, media_type, file_size FROM c_media_assets WHERE id = ?', [Number(assetId)]); const asset = rows && rows[0]; @@ -269,7 +307,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { return Number.isInteger(value) && value > 0; }))); if (!assetIds.length) { - return res.redirect('/media-library?message=' + encodeURIComponent('Select at least one media asset.')); + return res.redirect(getDeleteRedirect(req, 'Select at least one media asset.')); } let deletedCount = 0; let inUseCount = 0; @@ -279,7 +317,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { if (result === 'in-use') inUseCount += 1; } const message = deletedCount + ' media asset' + (deletedCount === 1 ? '' : 's') + ' deleted.' + (inUseCount ? ' ' + inUseCount + ' in-use asset' + (inUseCount === 1 ? '' : 's') + ' skipped.' : ''); - res.redirect('/media-library?message=' + encodeURIComponent(message)); + res.redirect(getDeleteRedirect(req, message)); } catch (error) { next(error); } @@ -289,12 +327,12 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { try { const result = await deleteMediaAsset(req.params.id, req); if (result === 'missing') { - return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.')); + return res.redirect(getDeleteRedirect(req, 'Media asset not found.')); } if (result === 'in-use') { - return res.redirect('/media-library?message=' + encodeURIComponent('This media is still used and cannot be deleted.')); + return res.redirect(getDeleteRedirect(req, 'This media is still used and cannot be deleted.')); } - res.redirect('/media-library?message=' + encodeURIComponent('Media deleted.')); + res.redirect(getDeleteRedirect(req, 'Media deleted.')); } catch (error) { next(error); } diff --git a/src/web/views/settings/media-library/list.hbs b/src/web/views/settings/media-library/list.hbs index 53915d1..c79cf4a 100644 --- a/src/web/views/settings/media-library/list.hbs +++ b/src/web/views/settings/media-library/list.hbs @@ -12,7 +12,6 @@ {{#if (hasPermission currentUser 'media-library.create')}} {{/if}} - {{#if assets.length}} - {{else}} - - {{/if}} - {{#if assets.length}} - - {{else}} - - {{/if}} + {{#> modal-shell modalId="media-gallery-preview-modal" modalLabelId="media-gallery-preview-modal-label" modalDialogClass="modal-dialog-centered modal-fullscreen-md-down modal-xl"}} @@ -103,6 +95,16 @@ + {{#if (hasPermission currentUser 'media-library.update')}} + + {{/if}} {{/modal-shell}} {{#if (hasPermission currentUser 'media-library.create')}} diff --git a/test/media-library.test.js b/test/media-library.test.js index 90a0d1a..2ae1158 100644 --- a/test/media-library.test.js +++ b/test/media-library.test.js @@ -4,6 +4,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); +const vm = require('node:vm'); const sharp = require('sharp'); require('../src/common'); const mediaLibrary = require('../src/web/lib/media/library'); @@ -212,3 +213,161 @@ test('media library upload and delete routes audit successful asset changes', as await fs.promises.rm(root, { recursive: true, force: true }); } }); + +test('media library delete redirects preserve filters for individual and bulk outcomes', async () => { + const originalCountReferences = mediaLibrary.countMediaAssetReferences; + const originalRemoveVariants = mediaLibrary.removeImageVariants; + const handlers = {}; + let outcome = 'deleted'; + const query = { search: 'Photo & Video', type: 'image', usage: 'unused', sort: 'name-desc' }; + mediaLibrary.countMediaAssetReferences = async function () { return outcome === 'in-use' ? 1 : 0; }; + mediaLibrary.removeImageVariants = async function () {}; + try { + registerMediaLibraryRoutes({ + get() {}, + post(route, ...middleware) { handlers[route] = middleware.at(-1); } + }, { + pool: { + async query(sql) { + if (sql.startsWith('SELECT')) { + return [outcome === 'missing' ? [] : [{ id: 9, media_path: '/media/uploads/missing-test.png' }]]; + } + return [[]]; + } + }, + pages: {}, + upload: { single() { return function () {}; } }, + uploadDir: path.join(os.tmpdir(), 'pulse-media-filter-test', 'uploads'), + requirePermission() { return function () {}; } + }); + for (const route of ['/media-library/:id/delete', '/media-library/delete']) { + for (outcome of ['deleted', 'in-use', 'missing', 'empty']) { + let location; + await handlers[route]({ query, params: { id: '9' }, body: { 'assetIds[]': outcome === 'empty' ? [] : ['9'] } }, { + redirect(value) { location = value; } + }, function (error) { throw error; }); + const redirect = new URL(location, 'http://localhost'); + assert.equal(redirect.pathname, '/media-library'); + for (const [key, value] of Object.entries(query)) { + assert.equal(redirect.searchParams.get(key), value, route + ': ' + outcome + ': ' + key); + } + assert.ok(redirect.searchParams.get('message')); + } + } + } finally { + mediaLibrary.countMediaAssetReferences = originalCountReferences; + mediaLibrary.removeImageVariants = originalRemoveVariants; + } +}); + +test('media gallery restores controls and submits current filters with both delete actions', () => { + const listeners = {}; + const controls = {}; + for (const [name, values] of Object.entries({ + search: [''], 'type-filter': ['all', 'image', 'video'], + 'usage-filter': ['all', 'used', 'unused'], sort: ['date-desc', 'name-desc', 'size-asc'] + })) { + controls['[data-media-gallery-' + name + ']'] = { + value: values[0], options: values.map(value => ({ value })), addEventListener() {} + }; + } + const gallery = { + dataset: {}, querySelector(selector) { return controls[selector] || null; }, + querySelectorAll() { return []; }, + addEventListener(name, callback) { listeners[name] = callback; } + }; + let submittedForm; + const context = vm.createContext({ + URLSearchParams, + window: { location: { search: '?search=Photo+%26+Video&type=image&usage=unused&sort=name-desc' }, innerWidth: 1200, confirm() { return true; } }, + document: { + getElementById() { return null; }, + querySelector(selector) { return selector === '.media-gallery' ? gallery : null; }, + createElement() { return { appendChild() {}, submit() { submittedForm = this; } }; }, + body: { appendChild() {} } + } + }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context); + assert.equal(controls['[data-media-gallery-search]'].value, 'Photo & Video'); + assert.equal(controls['[data-media-gallery-type-filter]'].value, 'image'); + assert.equal(controls['[data-media-gallery-usage-filter]'].value, 'unused'); + assert.equal(controls['[data-media-gallery-sort]'].value, 'name-desc'); + controls['[data-media-gallery-search]'].value = 'Updated & current'; + controls['[data-media-gallery-sort]'].value = 'size-asc'; + const form = { getAttribute() { return '/media-library/9/delete'; } }; + listeners.submit({ target: form }); + vm.runInContext("selectedAssetIds.add('9'); submitBulkDelete();", context); + for (const target of [form, submittedForm]) { + const url = new URL(target.action, 'http://localhost'); + assert.equal(url.searchParams.get('search'), 'Updated & current'); + assert.equal(url.searchParams.get('type'), 'image'); + assert.equal(url.searchParams.get('usage'), 'unused'); + assert.equal(url.searchParams.get('sort'), 'size-asc'); + assert.equal(url.searchParams.has('offset'), false); + } +}); + +test('empty media gallery retains filters and its refreshable results container', () => { + const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list'); + const html = renderMediaLibraryPage({ assets: [], assetTotal: 0 }, '', { permissions: ['media-library.read', 'media-library.delete'] }); + assert.match(html, /data-media-gallery-search/); + assert.match(html, /data-media-gallery-type-filter/); + assert.match(html, /data-media-gallery-usage-filter/); + assert.match(html, /data-media-gallery-sort/); + assert.match(html, /class="media-gallery-scroll"/); + assert.match(html, /data-media-gallery-no-results>/); +}); + +test('media rename validates filenames and changes only metadata with update permission and auditing', async () => { + const handlers = {}; + const queries = []; + const events = []; + const permissions = []; + let exists = true; + registerMediaLibraryRoutes({ + get() {}, post(route, ...middleware) { handlers[route] = middleware.at(-1); } + }, { + pool: { + async query(sql, params) { + queries.push({ sql, params }); + return [sql.startsWith('SELECT') && exists ? [{ id: 9, original_name: 'old.png', media_path: '/media/uploads/uuid.png' }] : []]; + } + }, pages: {}, upload: { single() { return function () {}; } }, uploadDir: os.tmpdir(), + getAuditUserId() { return 4; }, + requirePermission(permission) { permissions.push(permission); return function () {}; }, + recordRequestAuditEvent(_pool, _req, event) { events.push(event); } + }); + assert.ok(permissions.includes('media-library.update')); + const rename = handlers['/media-library/:id/rename']; + const response = { statusCode: 200, status(code) { this.statusCode = code; return this; }, json(data) { this.data = data; } }; + const next = function (error) { throw error; }; + for (const name of ['', ' ', '../photo.png', 'a\\b.png', 'a\n.png', 'a?b.png', 'a'.repeat(256), ['photo.png'], '..']) { + await rename({ params: { id: '9' }, body: { name } }, response, next); + assert.equal(response.statusCode, 400); + } + assert.equal(queries.length, 0); + response.statusCode = 200; + await rename({ params: { id: '9' }, body: { name: ' New photo.png ' } }, response, next); + assert.deepEqual(response.data, { id: 9, name: 'New photo.png' }); + assert.deepEqual(queries[1], { + sql: 'UPDATE c_media_assets SET original_name = ?, modified_by = ? WHERE id = ?', params: ['New photo.png', 4, 9] + }); + assert.equal(events[0].eventType, 'media.renamed'); + assert.deepEqual(events[0].details, { mediaPath: '/media/uploads/uuid.png', previousName: 'old.png', name: 'New photo.png' }); + await rename({ params: { id: '9' }, body: { name: 'old.png' } }, response, next); + assert.equal(queries.filter(query => query.sql.startsWith('UPDATE')).length, 1); + exists = false; + await rename({ params: { id: '9' }, body: { name: 'missing.png' } }, response, next); + assert.equal(response.statusCode, 404); +}); + +test('media preview offers filename editing only with media update permission', () => { + const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list'); + const data = { assets: [], assetTotal: 0 }; + const readOnly = renderMediaLibraryPage(data, '', { permissions: ['media-library.read'] }); + assert.doesNotMatch(readOnly, /data-media-gallery-rename-form/); + const editable = renderMediaLibraryPage(data, '', { permissions: ['media-library.read', 'media-library.update'] }); + assert.match(editable, /data-media-gallery-rename-form/); + assert.match(editable, /maxlength="255" required data-media-gallery-filename/); + assert.match(editable, /data-media-gallery-rename-save/); +});