Add media filename renaming and preserve library filters
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 41s

This commit is contained in:
lzstealth committed 2026-10-17 19:57:03 +01:00
1 parent 2fd059d35a
commit eb724167bc
6 files changed
+310 -20

No files matched your search

+2
View File
@@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Added an All timetable display mode that includes past items while respecting date filters and the item limit. - 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 ### 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 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. - 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. - 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 ## 2.15.4 - 2026-10-16
+1
View File
@@ -88,6 +88,7 @@ const PERMISSION_SECTIONS = [
permissions: [ permissions: [
{ key: 'read', name: 'Read', description: 'View shared media assets.' }, { key: 'read', name: 'Read', description: 'View shared media assets.' },
{ key: 'create', name: 'Create', description: 'Upload 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.' } { key: 'delete', name: 'Delete', description: 'Delete unused shared media assets.' }
] ]
}, },
+92 -4
View File
@@ -6,6 +6,15 @@ const search = gallery && gallery.querySelector('[data-media-gallery-search]');
const typeFilter = gallery && gallery.querySelector('[data-media-gallery-type-filter]'); const typeFilter = gallery && gallery.querySelector('[data-media-gallery-type-filter]');
const usageFilter = gallery && gallery.querySelector('[data-media-gallery-usage-filter]'); const usageFilter = gallery && gallery.querySelector('[data-media-gallery-usage-filter]');
const sort = gallery && gallery.querySelector('[data-media-gallery-sort]'); 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 galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]'); const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]'); 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(); const params = new URLSearchParams();
params.set('offset', String(offset));
params.set('limit', String(limit));
params.set('search', String(search && search.value || '').trim()); params.set('search', String(search && search.value || '').trim());
params.set('type', typeFilter ? typeFilter.value : 'all'); params.set('type', typeFilter ? typeFilter.value : 'all');
params.set('usage', usageFilter ? usageFilter.value : 'all'); params.set('usage', usageFilter ? usageFilter.value : 'all');
params.set('sort', sort ? sort.value : 'date-desc'); 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(); return params.toString();
} }
@@ -439,7 +453,7 @@ function submitBulkDelete() {
} }
const form = document.createElement('form'); const form = document.createElement('form');
form.method = 'post'; form.method = 'post';
form.action = '/media-library/delete'; form.action = '/media-library/delete?' + getGalleryFilters().toString();
selectedAssetIds.forEach(function (id) { selectedAssetIds.forEach(function (id) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'hidden'; input.type = 'hidden';
@@ -484,6 +498,12 @@ if (typeFilter) typeFilter.addEventListener('change', resetVisibleItemLimit);
if (usageFilter) usageFilter.addEventListener('change', resetVisibleItemLimit); if (usageFilter) usageFilter.addEventListener('change', resetVisibleItemLimit);
if (sort) sort.addEventListener('change', resetVisibleItemLimit); if (sort) sort.addEventListener('change', resetVisibleItemLimit);
if (gallery) { 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) { gallery.addEventListener('click', function (event) {
if (!selectionMode) return; if (!selectionMode) return;
const column = event.target && event.target.closest ? event.target.closest('[data-media-gallery-column]') : null; const column = event.target && event.target.closest ? event.target.closest('[data-media-gallery-column]') : null;
@@ -534,6 +554,62 @@ function showPreviewModal() {
} }
if (modal && body) { 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) { document.addEventListener('click', function (event) {
const preview = event.target && event.target.closest ? event.target.closest('[data-media-gallery-preview]') : null; const preview = event.target && event.target.closest ? event.target.closest('[data-media-gallery-preview]') : null;
if (!preview) { if (!preview) {
@@ -543,6 +619,17 @@ if (modal && body) {
const path = preview.getAttribute('data-media-path') || ''; const path = preview.getAttribute('data-media-path') || '';
const type = preview.getAttribute('data-media-type') || 'image'; const type = preview.getAttribute('data-media-type') || 'image';
const name = preview.getAttribute('data-media-name') || 'Media preview'; 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(); body.replaceChildren();
if (title) { if (title) {
title.textContent = name; title.textContent = name;
@@ -560,6 +647,7 @@ if (modal && body) {
}); });
modal.addEventListener('hidden.bs.modal', function () { modal.addEventListener('hidden.bs.modal', function () {
activePreview = null;
body.replaceChildren(); body.replaceChildren();
}); });
} }
+45 -7
View File
@@ -14,7 +14,7 @@ function requireMediaAccess(setAuthMessageCookie) {
} }
return res.redirect('/login'); 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(); return next();
} }
const error = new Error(PERMISSION_DENIED_MESSAGE); const error = new Error(PERMISSION_DENIED_MESSAGE);
@@ -71,7 +71,7 @@ function formatFileSize(bytes) {
} }
function getMediaFormat(asset) { 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); const match = name.match(/\.([a-z0-9]+)$/i);
return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase(); return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase();
} }
@@ -115,6 +115,13 @@ function filterAndSortAssets(assets, query) {
return filtered; 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) { module.exports = function registerMediaLibraryRoutes(app, deps) {
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie); const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
const recordRequestAuditEvent = deps.recordRequestAuditEvent; 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) { 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 [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]; const asset = rows && rows[0];
@@ -269,7 +307,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
return Number.isInteger(value) && value > 0; return Number.isInteger(value) && value > 0;
}))); })));
if (!assetIds.length) { 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 deletedCount = 0;
let inUseCount = 0; let inUseCount = 0;
@@ -279,7 +317,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
if (result === 'in-use') inUseCount += 1; if (result === 'in-use') inUseCount += 1;
} }
const message = deletedCount + ' media asset' + (deletedCount === 1 ? '' : 's') + ' deleted.' + (inUseCount ? ' ' + inUseCount + ' in-use asset' + (inUseCount === 1 ? '' : 's') + ' skipped.' : ''); 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) { } catch (error) {
next(error); next(error);
} }
@@ -289,12 +327,12 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
try { try {
const result = await deleteMediaAsset(req.params.id, req); const result = await deleteMediaAsset(req.params.id, req);
if (result === 'missing') { 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') { 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) { } catch (error) {
next(error); next(error);
} }
+11 -9
View File
@@ -12,7 +12,6 @@
{{#if (hasPermission currentUser 'media-library.create')}} {{#if (hasPermission currentUser 'media-library.create')}}
<button type="button" class="btn btn-primary btn-sm text-nowrap" data-media-library-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add media</button> <button type="button" class="btn btn-primary btn-sm text-nowrap" data-media-library-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add media</button>
{{/if}} {{/if}}
{{#if assets.length}}
<div class="media-gallery-controls"> <div class="media-gallery-controls">
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p> <p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
<div class="media-gallery-search d-flex flex-wrap gap-2"> <div class="media-gallery-search d-flex flex-wrap gap-2">
@@ -48,12 +47,8 @@
{{/if}} {{/if}}
</div> </div>
</div> </div>
{{else}}
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
{{/if}}
</div> </div>
{{#if assets.length}}
<div class="media-gallery-scroll"> <div class="media-gallery-scroll">
<div class="row g-3 g-xl-4"> <div class="row g-3 g-xl-4">
{{#each assets}} {{#each assets}}
@@ -91,10 +86,7 @@
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button> <button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button>
</div> </div>
</div> </div>
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results hidden>No media matches your search.</div> <div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div>
{{else}}
<div class="media-gallery-empty text-body-secondary">No media yet.</div>
{{/if}}
</section> </section>
{{#> modal-shell modalId="media-gallery-preview-modal" modalLabelId="media-gallery-preview-modal-label" modalDialogClass="modal-dialog-centered modal-fullscreen-md-down modal-xl"}} {{#> 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 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div> <div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div>
{{#if (hasPermission currentUser 'media-library.update')}}
<form class="modal-footer d-block" data-media-gallery-rename-form>
<label for="media-gallery-filename" class="form-label">Filename</label>
<div class="input-group">
<input id="media-gallery-filename" name="name" type="text" class="form-control" maxlength="255" required data-media-gallery-filename />
<button type="submit" class="btn btn-primary" title="Save filename" data-media-gallery-rename-save><i class="bi bi-floppy" aria-hidden="true"></i><span class="visually-hidden">Save filename</span></button>
</div>
<p class="small mb-0 mt-2" role="status" data-media-gallery-rename-status hidden></p>
</form>
{{/if}}
{{/modal-shell}} {{/modal-shell}}
{{#if (hasPermission currentUser 'media-library.create')}} {{#if (hasPermission currentUser 'media-library.create')}}
+159
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const vm = require('node:vm');
const sharp = require('sharp'); const sharp = require('sharp');
require('../src/common'); require('../src/common');
const mediaLibrary = require('../src/web/lib/media/library'); 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 }); 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/);
});