Release v2.15.4
This commit is contained in:
1 parent
64aeb1b47e
commit
05c9341b08
25 files changed
+595
-31
No files matched your search
@@ -7,3 +7,4 @@
|
|||||||
!src/**
|
!src/**
|
||||||
!scripts/
|
!scripts/
|
||||||
!scripts/**
|
!scripts/**
|
||||||
|
/local-examples/
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
/local-examples/
|
||||||
media/
|
media/
|
||||||
!src/web/lib/media/
|
!src/web/lib/media/
|
||||||
!src/web/lib/media/**
|
!src/web/lib/media/**
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.15.4 - 2026-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added configurable audit events for media-library assets, managed fonts, and weather icon sets.
|
||||||
|
- Added All / Between Dates / Before Date / After Date filtering to timetable regions, with optional inclusive start-time boundaries shared by previews and playback.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevented deletion of media referenced from rich-text slide content.
|
||||||
|
|
||||||
## 2.15.3 - 2026-10-15
|
## 2.15.3 - 2026-10-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.15.3",
|
"version": "2.15.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.15.3",
|
"version": "2.15.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.15.3",
|
"version": "2.15.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.15.3",
|
"version": "2.15.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^149.0.0",
|
"@sparticuz/chromium": "^149.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.15.3",
|
"version": "2.15.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const AUDIT_EVENT_CATEGORIES = Object.freeze({
|
|||||||
RSS_FEEDS: 'rss-feeds',
|
RSS_FEEDS: 'rss-feeds',
|
||||||
TIMETABLES: 'timetables',
|
TIMETABLES: 'timetables',
|
||||||
WEATHER: 'weather',
|
WEATHER: 'weather',
|
||||||
|
MEDIA_ASSETS: 'media-assets',
|
||||||
SCREEN_CONTROLS: 'screen-controls'
|
SCREEN_CONTROLS: 'screen-controls'
|
||||||
});
|
});
|
||||||
const AUDIT_CATEGORY_KEYS = Object.freeze(Object.values(AUDIT_EVENT_CATEGORIES));
|
const AUDIT_CATEGORY_KEYS = Object.freeze(Object.values(AUDIT_EVENT_CATEGORIES));
|
||||||
@@ -37,6 +38,7 @@ const AUDIT_CATEGORY_LABELS = Object.freeze({
|
|||||||
'rss-feeds': 'RSS Feeds',
|
'rss-feeds': 'RSS Feeds',
|
||||||
timetables: 'Timetables',
|
timetables: 'Timetables',
|
||||||
weather: 'Weather',
|
weather: 'Weather',
|
||||||
|
'media-assets': 'Media Assets',
|
||||||
'screen-controls': 'Screen Controls'
|
'screen-controls': 'Screen Controls'
|
||||||
});
|
});
|
||||||
const SCREEN_CONTROL_COMMAND_KEYS = Object.freeze(['pause', 'blackout', 'reload', 'navigation', 'moveclient', 'setclientname']);
|
const SCREEN_CONTROL_COMMAND_KEYS = Object.freeze(['pause', 'blackout', 'reload', 'navigation', 'moveclient', 'setclientname']);
|
||||||
|
|||||||
@@ -417,6 +417,24 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
|||||||
delete generic.timetable_display_mode;
|
delete generic.timetable_display_mode;
|
||||||
delete generic.timetable_max_items;
|
delete generic.timetable_max_items;
|
||||||
|
|
||||||
|
generic.date_filter = ['between', 'before', 'after'].includes(generic.date_filter) ? generic.date_filter : 'all';
|
||||||
|
for (const field of ['date_from', 'date_to']) {
|
||||||
|
const value = String(generic[field] || '').trim();
|
||||||
|
const date = value ? new Date(value) : null;
|
||||||
|
const active = generic.date_filter === 'between' || generic.date_filter === (field === 'date_from' ? 'after' : 'before');
|
||||||
|
if (active && date && Number.isNaN(date.getTime())) {
|
||||||
|
const error = new Error('Timetable date range must contain valid date-times.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
generic[field] = date && !Number.isNaN(date.getTime()) ? date.toISOString() : '';
|
||||||
|
}
|
||||||
|
if (generic.date_filter === 'between' && generic.date_from && generic.date_to && generic.date_from > generic.date_to) {
|
||||||
|
const error = new Error('Timetable date range must end on or after its start.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
generic.text = normalizedText;
|
generic.text = normalizedText;
|
||||||
generic.value = normalizedText;
|
generic.value = normalizedText;
|
||||||
generic.type = region.region_type;
|
generic.type = region.region_type;
|
||||||
|
|||||||
@@ -63,9 +63,16 @@ function isLive(entry, now) {
|
|||||||
return Boolean(start && end && now >= start && now < end);
|
return Boolean(start && end && now >= start && now < end);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
function getVisibleEntries(groupId, displayMode, maxItems, groups, config) {
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
|
var range = config || {};
|
||||||
|
var from = toDate(range.date_filter === 'between' || range.date_filter === 'after' ? range.date_from : '');
|
||||||
|
var to = toDate(range.date_filter === 'between' || range.date_filter === 'before' ? range.date_to : '');
|
||||||
|
var candidates = getEntries(groupId, groups).filter(function (entry) {
|
||||||
|
var start = toDate(entry && entry.start_datetime);
|
||||||
|
return (!from && !to) || Boolean(start && (!from || start >= from) && (!to || start <= to));
|
||||||
|
});
|
||||||
|
var entries = candidates.slice().sort(function (left, right) {
|
||||||
var leftStart = toDate(left && left.start_datetime);
|
var leftStart = toDate(left && left.start_datetime);
|
||||||
var rightStart = toDate(right && right.start_datetime);
|
var rightStart = toDate(right && right.start_datetime);
|
||||||
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||||
@@ -83,7 +90,7 @@ function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!entries.length && mode === 'upcoming') {
|
if (!entries.length && mode === 'upcoming') {
|
||||||
entries = getEntries(groupId, groups).filter(function (entry) {
|
entries = candidates.filter(function (entry) {
|
||||||
return isLive(entry, now);
|
return isLive(entry, now);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -135,7 +142,7 @@ function renderRegion(region, regionContent) {
|
|||||||
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
||||||
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
||||||
var group = getGroupById(groupId, groups);
|
var group = getGroupById(groupId, groups);
|
||||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
|
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups, regionContent);
|
||||||
var timeZone = group && (group.timezone || group.time_zone) ? String(group.timezone || group.time_zone) : '';
|
var timeZone = group && (group.timezone || group.time_zone) ? String(group.timezone || group.time_zone) : '';
|
||||||
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
|
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
|
||||||
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
|
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ async function start() {
|
|||||||
// Middleware and route registration.
|
// Middleware and route registration.
|
||||||
registerMiddleware(app, {
|
registerMiddleware(app, {
|
||||||
pool: pool,
|
pool: pool,
|
||||||
|
pages: pages,
|
||||||
loadCurrentUser: loadCurrentUser,
|
loadCurrentUser: loadCurrentUser,
|
||||||
requireAuth: requireAuth,
|
requireAuth: requireAuth,
|
||||||
MEDIA_DIR: webConfig.mediaDir,
|
MEDIA_DIR: webConfig.mediaDir,
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ async function countMediaAssetReferences(pool, mediaPath) {
|
|||||||
const [slideRows] = await pool.query(
|
const [slideRows] = await pool.query(
|
||||||
`SELECT COUNT(*) AS ref_count
|
`SELECT COUNT(*) AS ref_count
|
||||||
FROM c_slides
|
FROM c_slides
|
||||||
WHERE LOCATE(?, COALESCE(content_json, '')) > 0`,
|
WHERE LOCATE(?, CAST(COALESCE(content_json, '') AS CHAR)) > 0`,
|
||||||
[normalizedPath]
|
[normalizedPath]
|
||||||
);
|
);
|
||||||
const [templateRows] = await pool.query(
|
const [templateRows] = await pool.query(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const registerErrorPageMiddleware = require('#src/web/middleware/error-page');
|
||||||
|
|
||||||
module.exports = function registerMiddleware(app, deps) {
|
module.exports = function registerMiddleware(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -28,6 +29,8 @@ module.exports = function registerMiddleware(app, deps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerErrorPageMiddleware(app, deps.pages);
|
||||||
|
|
||||||
app.use(function (req, res, next) {
|
app.use(function (req, res, next) {
|
||||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
function wantsHtmlErrorPage(req, res) {
|
||||||
|
const isXhr = String(req.get && req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest';
|
||||||
|
const requestPath = String(req.originalUrl || req.path || '');
|
||||||
|
const acceptsHtml = !req.accepts || req.accepts('html');
|
||||||
|
const contentType = String(res.get && res.get('Content-Type') || '').toLowerCase();
|
||||||
|
|
||||||
|
return !isXhr && requestPath.indexOf('/api/') !== 0 && acceptsHtml && (!contentType || contentType.indexOf('text/html') === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorPage(pages, req, statusCode, message) {
|
||||||
|
const isServerError = statusCode >= 500;
|
||||||
|
return pages.renderErrorPage({
|
||||||
|
statusCode: statusCode,
|
||||||
|
message: isServerError ? '' : String(message || ''),
|
||||||
|
detail: isServerError ? 'The server could not complete the request.' : '',
|
||||||
|
backUrl: req.currentUser ? '/dashboard' : '/login',
|
||||||
|
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
|
||||||
|
}, req.currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function registerErrorPageMiddleware(app, pages) {
|
||||||
|
app.use(function (req, res, next) {
|
||||||
|
const send = res.send;
|
||||||
|
const sendStatus = res.sendStatus;
|
||||||
|
|
||||||
|
res.send = function (body) {
|
||||||
|
const statusCode = Number(res.statusCode) || 200;
|
||||||
|
const isHtml = typeof body === 'string' && /^\s*</.test(body);
|
||||||
|
|
||||||
|
if (statusCode >= 400 && statusCode < 600 && !isHtml && wantsHtmlErrorPage(req, res)) {
|
||||||
|
return send.call(res, createErrorPage(pages, req, statusCode, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
return send.apply(res, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
res.sendStatus = function (statusCode) {
|
||||||
|
if (statusCode >= 400 && statusCode < 600 && wantsHtmlErrorPage(req, res)) {
|
||||||
|
res.statusCode = statusCode;
|
||||||
|
return res.send(createErrorPage(pages, req, statusCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendStatus.call(res, statusCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -98,15 +98,40 @@
|
|||||||
return Boolean(start && now < start);
|
return Boolean(start && now < start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toLocalInput(value) {
|
||||||
|
var date = toDate(value);
|
||||||
|
if (!date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function pad(part) { return String(part).padStart(2, '0'); }
|
||||||
|
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateRangeConfig(regionId, card, current) {
|
||||||
|
var config = {};
|
||||||
|
['date_filter', 'date_from', 'date_to'].forEach(function (field) {
|
||||||
|
var input = card && card.querySelector ? card.querySelector('[name="region_' + field + '_' + regionId + '"]') : null;
|
||||||
|
config[field] = input ? input.value : current[field] || (field === 'date_filter' ? 'all' : '');
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
function isLive(entry, now) {
|
function isLive(entry, now) {
|
||||||
var start = toDate(entry && entry.start_datetime);
|
var start = toDate(entry && entry.start_datetime);
|
||||||
var end = toDate(entry && entry.end_datetime);
|
var end = toDate(entry && entry.end_datetime);
|
||||||
return Boolean(start && end && now >= start && now < end);
|
return Boolean(start && end && now >= start && now < end);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
function getVisibleEntries(groupId, displayMode, maxItems, groups, config) {
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
|
var range = config || {};
|
||||||
|
var from = toDate(range.date_filter === 'between' || range.date_filter === 'after' ? range.date_from : '');
|
||||||
|
var to = toDate(range.date_filter === 'between' || range.date_filter === 'before' ? range.date_to : '');
|
||||||
|
var candidates = getEntries(groupId, groups).filter(function (entry) {
|
||||||
|
var start = toDate(entry && entry.start_datetime);
|
||||||
|
return (!from && !to) || Boolean(start && (!from || start >= from) && (!to || start <= to));
|
||||||
|
});
|
||||||
|
var entries = candidates.slice().sort(function (left, right) {
|
||||||
var leftStart = toDate(left && left.start_datetime);
|
var leftStart = toDate(left && left.start_datetime);
|
||||||
var rightStart = toDate(right && right.start_datetime);
|
var rightStart = toDate(right && right.start_datetime);
|
||||||
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||||
@@ -124,7 +149,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!entries.length && mode === 'upcoming') {
|
if (!entries.length && mode === 'upcoming') {
|
||||||
entries = getEntries(groupId, groups).filter(function (entry) {
|
entries = candidates.filter(function (entry) {
|
||||||
return isLive(entry, now);
|
return isLive(entry, now);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -167,6 +192,9 @@
|
|||||||
display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
|
display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
|
||||||
value: String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''),
|
value: String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''),
|
||||||
max_items: Math.max(1, Number(current.max_items || current.timetable_max_items || 5)),
|
max_items: Math.max(1, Number(current.max_items || current.timetable_max_items || 5)),
|
||||||
|
date_filter: ['between', 'before', 'after'].includes(current.date_filter) ? current.date_filter : 'all',
|
||||||
|
date_from: current.date_from || '',
|
||||||
|
date_to: current.date_to || '',
|
||||||
font_family: current.font_family || region.font_family || getDefaultStyle().font_family,
|
font_family: current.font_family || region.font_family || getDefaultStyle().font_family,
|
||||||
font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
|
font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
|
||||||
font_color: current.font_color || region.font_color || getDefaultStyle().font_color
|
font_color: current.font_color || region.font_color || getDefaultStyle().font_color
|
||||||
@@ -230,8 +258,9 @@
|
|||||||
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
|
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
|
||||||
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5));
|
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5));
|
||||||
var group = getGroupById(groupId, timetableGroups);
|
var group = getGroupById(groupId, timetableGroups);
|
||||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
|
var range = getDateRangeConfig(regionId, card, current);
|
||||||
if (!entries.length) {
|
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups, range);
|
||||||
|
if (!entries.length && range.date_filter === 'all') {
|
||||||
entries = getEntries(groupId, timetableGroups).slice(0, maxItems);
|
entries = getEntries(groupId, timetableGroups).slice(0, maxItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +296,10 @@
|
|||||||
var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
|
var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
|
||||||
var currentDisplayMode = String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
|
var currentDisplayMode = String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
|
||||||
var currentMaxItems = Math.max(1, Number(current.max_items || current.timetable_max_items || 5));
|
var currentMaxItems = Math.max(1, Number(current.max_items || current.timetable_max_items || 5));
|
||||||
|
var dateFilter = ['between', 'before', 'after'].includes(current.date_filter) ? current.date_filter : 'all';
|
||||||
var currentGroup = getGroupById(currentGroupId, timetableGroups);
|
var currentGroup = getGroupById(currentGroupId, timetableGroups);
|
||||||
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
|
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups, current);
|
||||||
if (!currentEntries.length) {
|
if (!currentEntries.length && dateFilter === 'all') {
|
||||||
currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
|
currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
|
||||||
}
|
}
|
||||||
var groupOptions = timetableGroups.map(function (group) {
|
var groupOptions = timetableGroups.map(function (group) {
|
||||||
@@ -295,7 +325,7 @@
|
|||||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(currentValue) + '" />' +
|
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(currentValue) + '" />' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="row g-3">' +
|
'<div class="row g-3">' +
|
||||||
'<div class="col-12 col-lg-6">' +
|
'<div class="col-12 col-lg-4">' +
|
||||||
'<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' +
|
'<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' +
|
||||||
'<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_group_id_' + region.id + '" class="form-select">' +
|
'<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_group_id_' + region.id + '" class="form-select">' +
|
||||||
'<option value="">Select a group</option>' +
|
'<option value="">Select a group</option>' +
|
||||||
@@ -310,12 +340,34 @@
|
|||||||
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
|
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
|
||||||
'</select>' +
|
'</select>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="col-6 col-lg-3">' +
|
'<div class="col-6 col-lg-2">' +
|
||||||
'<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' +
|
'<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' +
|
||||||
'<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
|
'<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="col-12 col-lg-3">' +
|
||||||
|
'<label class="form-label" for="region_date_filter_' + region.id + '">Date filter</label>' +
|
||||||
|
'<select id="region_date_filter_' + region.id + '" name="region_date_filter_' + region.id + '" class="form-select">' +
|
||||||
|
'<option value="all"' + (dateFilter === 'all' ? ' selected' : '') + '>All</option>' +
|
||||||
|
'<option value="between"' + (dateFilter === 'between' ? ' selected' : '') + '>Between Dates</option>' +
|
||||||
|
'<option value="before"' + (dateFilter === 'before' ? ' selected' : '') + '>Before Date</option>' +
|
||||||
|
'<option value="after"' + (dateFilter === 'after' ? ' selected' : '') + '>After Date</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div data-timetable-date-range' + (dateFilter === 'all' ? ' hidden' : '') + '>' +
|
||||||
|
'<div class="row g-3">' +
|
||||||
|
['from', 'to'].map(function (boundary) {
|
||||||
|
var field = 'date_' + boundary;
|
||||||
|
var enabled = dateFilter === 'between' || dateFilter === (boundary === 'from' ? 'after' : 'before');
|
||||||
|
var label = boundary === 'from' ? 'Starts on or after (local time)' : 'Starts on or before (local time)';
|
||||||
|
return '<div class="col-12 col-md-6" data-timetable-boundary' + (!enabled ? ' hidden' : '') + '>' +
|
||||||
|
'<label class="form-label" for="region_' + field + '_' + region.id + '">' + label + '</label>' +
|
||||||
|
'<input id="region_' + field + '_' + region.id + '" type="datetime-local" step="1" class="form-control" data-timetable-date="' + field + '" value="' + escapeHtml(toLocalInput(current[field])) + '"' + (!enabled ? ' disabled' : '') + ' />' +
|
||||||
|
'<input type="hidden" name="region_' + field + '_' + region.id + '" value="' + escapeHtml(current[field] || '') + '" />' +
|
||||||
|
'</div>';
|
||||||
|
}).join('') +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
|
|
||||||
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
|
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
|
||||||
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
|
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
|
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
|
||||||
@@ -350,24 +402,50 @@
|
|||||||
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
|
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDateRange(card) {
|
||||||
|
var regionId = card.getAttribute('data-region-id');
|
||||||
|
var config = getDateRangeConfig(regionId, card, {});
|
||||||
|
var panel = card.querySelector('[data-timetable-date-range]');
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel.hidden = config.date_filter === 'all';
|
||||||
|
panel.querySelectorAll('[data-timetable-date]').forEach(function (input) {
|
||||||
|
var field = input.getAttribute('data-timetable-date');
|
||||||
|
var enabled = config.date_filter === 'between' || config.date_filter === (field === 'date_from' ? 'after' : 'before');
|
||||||
|
input.closest('[data-timetable-boundary]').hidden = !enabled;
|
||||||
|
input.disabled = !enabled;
|
||||||
|
var date = toDate(input.value);
|
||||||
|
card.querySelector('[name="region_' + input.getAttribute('data-timetable-date') + '_' + regionId + '"]').value = date ? date.toISOString() : '';
|
||||||
|
});
|
||||||
|
var fromInput = panel.querySelector('[data-timetable-date="date_from"]');
|
||||||
|
var toInput = panel.querySelector('[data-timetable-date="date_to"]');
|
||||||
|
var from = toDate(fromInput.value);
|
||||||
|
var to = toDate(toInput.value);
|
||||||
|
toInput.setCustomValidity(config.date_filter === 'between' && from && to && from > to ? 'The end of the date range must be on or after its start.' : '');
|
||||||
|
}
|
||||||
|
|
||||||
if (!window.__scheduleRegionPlaceholderRefreshInstalled) {
|
if (!window.__scheduleRegionPlaceholderRefreshInstalled) {
|
||||||
window.__scheduleRegionPlaceholderRefreshInstalled = true;
|
window.__scheduleRegionPlaceholderRefreshInstalled = true;
|
||||||
|
|
||||||
document.addEventListener('change', function (event) {
|
function handleTimetableInput(event) {
|
||||||
var target = event.target;
|
var target = event.target;
|
||||||
if (!target || !target.closest) {
|
if (!target || !target.closest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) {
|
if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"], select[name^="region_date_filter_"], [data-timetable-date]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var card = target.closest('[data-region-id]');
|
var card = target.closest('[data-region-id]');
|
||||||
if (card) {
|
if (card) {
|
||||||
|
updateDateRange(card);
|
||||||
updateSchedulePlaceholderPanel(card);
|
updateSchedulePlaceholderPanel(card);
|
||||||
}
|
}
|
||||||
}, true);
|
}
|
||||||
|
document.addEventListener('input', handleTimetableInput, true);
|
||||||
|
document.addEventListener('change', handleTimetableInput, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEditorCardContext(context) {
|
function buildEditorCardContext(context) {
|
||||||
@@ -410,6 +488,7 @@
|
|||||||
tz_long: timezoneValues.tz_long,
|
tz_long: timezoneValues.tz_long,
|
||||||
timeZone: timezoneValues.tz_long,
|
timeZone: timezoneValues.tz_long,
|
||||||
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
|
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
|
||||||
|
...getDateRangeConfig(region.id, card, current),
|
||||||
existingContent: existingContent || {},
|
existingContent: existingContent || {},
|
||||||
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
|
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
|
||||||
};
|
};
|
||||||
@@ -423,7 +502,7 @@
|
|||||||
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
||||||
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
||||||
var group = getGroupById(groupId, groups);
|
var group = getGroupById(groupId, groups);
|
||||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
|
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups, regionContent);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
|
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ function registerSettingsAndContentRoutes(app, deps) {
|
|||||||
uploadDir: deps.uploadDir,
|
uploadDir: deps.uploadDir,
|
||||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,6 +345,7 @@ function registerSettingsAndContentRoutes(app, deps) {
|
|||||||
uploadDir: deps.uploadDir,
|
uploadDir: deps.uploadDir,
|
||||||
getAuditUserId: deps.getAuditUserId,
|
getAuditUserId: deps.getAuditUserId,
|
||||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent,
|
||||||
requirePermission: deps.requirePermission,
|
requirePermission: deps.requirePermission,
|
||||||
syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange
|
syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ module.exports = function registerFontRoutes(app, deps) {
|
|||||||
const uploadDir = deps.uploadDir;
|
const uploadDir = deps.uploadDir;
|
||||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
|
|
||||||
const FONT_FAMILY_MAX_LENGTH = 255;
|
const FONT_FAMILY_MAX_LENGTH = 255;
|
||||||
|
|
||||||
@@ -146,6 +147,13 @@ module.exports = function registerFontRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim().slice(0, FONT_FAMILY_MAX_LENGTH));
|
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim().slice(0, FONT_FAMILY_MAX_LENGTH));
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'media-assets', eventType: 'font.uploaded', actorUserId: req.currentUser && req.currentUser.id,
|
||||||
|
targetType: 'font', targetId: result.font.id, targetLabel: result.font.family,
|
||||||
|
details: { fileName: result.font.fileName, format: result.font.format }
|
||||||
|
});
|
||||||
|
}
|
||||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||||
await backgroundTaskQueue.enqueueTask({
|
await backgroundTaskQueue.enqueueTask({
|
||||||
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`,
|
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`,
|
||||||
@@ -186,6 +194,14 @@ module.exports = function registerFontRoutes(app, deps) {
|
|||||||
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
|
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'media-assets', eventType: enabled ? 'font.enabled' : 'font.disabled', actorUserId: req.currentUser && req.currentUser.id,
|
||||||
|
targetType: 'font', targetId: updated.id, targetLabel: updated.family || updated.name,
|
||||||
|
details: { enabled: Boolean(enabled) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||||
await backgroundTaskQueue.enqueueTask({
|
await backgroundTaskQueue.enqueueTask({
|
||||||
key: `font-sync:toggle:${updated && updated.id ? updated.id : Date.now()}`,
|
key: `font-sync:toggle:${updated && updated.id ? updated.id : Date.now()}`,
|
||||||
@@ -222,6 +238,14 @@ module.exports = function registerFontRoutes(app, deps) {
|
|||||||
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
|
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'media-assets', eventType: 'font.deleted', actorUserId: req.currentUser && req.currentUser.id,
|
||||||
|
targetType: 'font', targetId: removed.id, targetLabel: removed.family || removed.name,
|
||||||
|
details: { fileName: removed.fileName, format: removed.format }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||||
await backgroundTaskQueue.enqueueTask({
|
await backgroundTaskQueue.enqueueTask({
|
||||||
key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`,
|
key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function filterAndSortAssets(assets, query) {
|
|||||||
|
|
||||||
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 pageSize = 24;
|
const pageSize = 24;
|
||||||
if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) {
|
if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) {
|
||||||
throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and uploadDir.');
|
throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and uploadDir.');
|
||||||
@@ -196,7 +197,19 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
|
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
|
||||||
}
|
}
|
||||||
await mediaLibrary.createImageVariants(req.file);
|
await mediaLibrary.createImageVariants(req.file);
|
||||||
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), '/media/uploads/' + req.file.filename, deps.getAuditUserId(req));
|
const mediaPath = '/media/uploads/' + req.file.filename;
|
||||||
|
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req));
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(deps.pool, req, {
|
||||||
|
category: 'media-assets',
|
||||||
|
eventType: 'media.uploaded',
|
||||||
|
actorUserId: deps.getAuditUserId(req),
|
||||||
|
targetType: 'media-asset',
|
||||||
|
targetId: mediaPath,
|
||||||
|
targetLabel: req.file.originalname || req.file.filename,
|
||||||
|
details: { mediaPath: mediaPath, mediaType: mediaType, mimeType: mimeType, fileSize: Number(req.file.size) || 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
|
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (req.file && req.file.path) {
|
if (req.file && req.file.path) {
|
||||||
@@ -206,8 +219,8 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function deleteMediaAsset(assetId) {
|
async function deleteMediaAsset(assetId, req) {
|
||||||
const [rows] = await deps.pool.query('SELECT id, media_path 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];
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return 'missing';
|
return 'missing';
|
||||||
@@ -218,6 +231,17 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
const filePath = path.join(path.dirname(deps.uploadDir), asset.media_path.replace(/^\/media\//, ''));
|
const filePath = path.join(path.dirname(deps.uploadDir), asset.media_path.replace(/^\/media\//, ''));
|
||||||
await deps.pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
|
await deps.pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(deps.pool, req, {
|
||||||
|
category: 'media-assets',
|
||||||
|
eventType: 'media.deleted',
|
||||||
|
actorUserId: deps.getAuditUserId(req),
|
||||||
|
targetType: 'media-asset',
|
||||||
|
targetId: asset.id,
|
||||||
|
targetLabel: asset.original_name || asset.media_path,
|
||||||
|
details: { mediaPath: asset.media_path, mediaType: asset.media_type || null, fileSize: Number(asset.file_size) || 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
await fs.promises.unlink(filePath).catch(function (error) {
|
await fs.promises.unlink(filePath).catch(function (error) {
|
||||||
if (error && error.code !== 'ENOENT') {
|
if (error && error.code !== 'ENOENT') {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -250,7 +274,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
let inUseCount = 0;
|
let inUseCount = 0;
|
||||||
for (let index = 0; index < assetIds.length; index += 1) {
|
for (let index = 0; index < assetIds.length; index += 1) {
|
||||||
const result = await deleteMediaAsset(assetIds[index]);
|
const result = await deleteMediaAsset(assetIds[index], req);
|
||||||
if (result === 'deleted') deletedCount += 1;
|
if (result === 'deleted') deletedCount += 1;
|
||||||
if (result === 'in-use') inUseCount += 1;
|
if (result === 'in-use') inUseCount += 1;
|
||||||
}
|
}
|
||||||
@@ -263,7 +287,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
|
|
||||||
app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
|
app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const result = await deleteMediaAsset(req.params.id);
|
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('/media-library?message=' + encodeURIComponent('Media asset not found.'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { buildPagination } = require('#src/web/lib/pagination');
|
|||||||
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
||||||
|
|
||||||
module.exports = function registerWeatherIconRoutes(app, deps) {
|
module.exports = function registerWeatherIconRoutes(app, deps) {
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } });
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } });
|
||||||
const fields = library.CONDITIONS.flatMap(function (condition) {
|
const fields = library.CONDITIONS.flatMap(function (condition) {
|
||||||
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }];
|
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }];
|
||||||
@@ -57,8 +58,17 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
|
|||||||
async function save(req, res, next) {
|
async function save(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const previousSets = library.readWeatherIconSets(deps.mediaDir);
|
const previousSets = library.readWeatherIconSets(deps.mediaDir);
|
||||||
await library.saveWeatherIconSet(deps.mediaDir, req.body.name, Object.values(req.files || {}).flat(), req.params.id);
|
const uploadedFiles = Object.values(req.files || {}).flat();
|
||||||
|
const savedSet = await library.saveWeatherIconSet(deps.mediaDir, req.body.name, uploadedFiles, req.params.id);
|
||||||
req.weatherIconRemovalOperations = library.collectWeatherIconRemovalOperations(previousSets, library.readWeatherIconSets(deps.mediaDir));
|
req.weatherIconRemovalOperations = library.collectWeatherIconRemovalOperations(previousSets, library.readWeatherIconSets(deps.mediaDir));
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(deps.pool, req, {
|
||||||
|
category: 'media-assets', eventType: req.params.id ? 'weather-icon-set.updated' : 'weather-icon-set.created',
|
||||||
|
actorUserId: req.currentUser && req.currentUser.id, targetType: 'weather-icon-set',
|
||||||
|
targetId: savedSet.id, targetLabel: savedSet.name,
|
||||||
|
details: { uploadedFileCount: uploadedFiles.length, iconCount: Object.keys(savedSet.icons || {}).length, nightIconCount: Object.keys(savedSet.nightIcons || {}).length }
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { return await page(req, res, error.message, 400); } catch (pageError) { return next(pageError); }
|
try { return await page(req, res, error.message, 400); } catch (pageError) { return next(pageError); }
|
||||||
}
|
}
|
||||||
@@ -74,6 +84,13 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
|
|||||||
const enabled = String(req.body && req.body.enabled || '').trim() !== '0';
|
const enabled = String(req.body && req.body.enabled || '').trim() !== '0';
|
||||||
const updated = library.setWeatherIconSetEnabled(deps.mediaDir, req.params.id, enabled);
|
const updated = library.setWeatherIconSetEnabled(deps.mediaDir, req.params.id, enabled);
|
||||||
if (!updated) return res.redirect('/settings/weather-icons?message=' + encodeURIComponent('That weather icon set was not found.'));
|
if (!updated) return res.redirect('/settings/weather-icons?message=' + encodeURIComponent('That weather icon set was not found.'));
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(deps.pool, req, {
|
||||||
|
category: 'media-assets', eventType: enabled ? 'weather-icon-set.enabled' : 'weather-icon-set.disabled',
|
||||||
|
actorUserId: req.currentUser && req.currentUser.id, targetType: 'weather-icon-set',
|
||||||
|
targetId: updated.id, targetLabel: updated.name, details: { enabled: Boolean(enabled) }
|
||||||
|
});
|
||||||
|
}
|
||||||
await sync();
|
await sync();
|
||||||
res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.'));
|
res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.'));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
@@ -89,6 +106,13 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
if (used) return await page(req, res, 'That icon set is still used by one or more slides.', 400);
|
if (used) return await page(req, res, 'That icon set is still used by one or more slides.', 400);
|
||||||
const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id);
|
const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(deps.pool, req, {
|
||||||
|
category: 'media-assets', eventType: 'weather-icon-set.deleted', actorUserId: req.currentUser && req.currentUser.id,
|
||||||
|
targetType: 'weather-icon-set', targetId: removed.id, targetLabel: removed.name,
|
||||||
|
details: { iconCount: Object.keys(removed.icons || {}).length, nightIconCount: Object.keys(removed.nightIcons || {}).length }
|
||||||
|
});
|
||||||
|
}
|
||||||
await sync(library.collectWeatherIconRemovalOperations([removed], []));
|
await sync(library.collectWeatherIconRemovalOperations([removed], []));
|
||||||
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.'));
|
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.'));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const request = {
|
|||||||
test('audit categories include weather data sources', () => {
|
test('audit categories include weather data sources', () => {
|
||||||
assert.ok(AUDIT_CATEGORY_KEYS.includes('weather'));
|
assert.ok(AUDIT_CATEGORY_KEYS.includes('weather'));
|
||||||
assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather');
|
assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather');
|
||||||
|
assert.ok(AUDIT_CATEGORY_KEYS.includes('media-assets'));
|
||||||
|
assert.equal(AUDIT_CATEGORY_LABELS['media-assets'], 'Media Assets');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screen control auditing is disabled by default', () => {
|
test('screen control auditing is disabled by default', () => {
|
||||||
|
|||||||
@@ -226,4 +226,52 @@ test('fonts page renders the upload card above the table card', () => {
|
|||||||
assert.match(html, /data-table-sort-key="file"/);
|
assert.match(html, /data-table-sort-key="file"/);
|
||||||
assert.match(html, /data-table-sort-key="format"/);
|
assert.match(html, /data-table-sort-key="format"/);
|
||||||
assert.match(html, /data-table-sort-key="status"/);
|
assert.match(html, /data-table-sort-key="status"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('font upload, status changes, and deletion are audited', async () => {
|
||||||
|
const originals = {
|
||||||
|
loadFontLibrary: fontLibrary.loadFontLibrary,
|
||||||
|
addFontFile: fontLibrary.addFontFile,
|
||||||
|
setFontEnabled: fontLibrary.setFontEnabled,
|
||||||
|
deleteFontFile: fontLibrary.deleteFontFile
|
||||||
|
};
|
||||||
|
const { app, handlers } = createHandlers();
|
||||||
|
const events = [];
|
||||||
|
const font = { id: 'alpha.woff2', family: 'Alpha Sans', name: 'Alpha Sans', fileName: 'alpha.woff2', format: 'woff2', enabled: true };
|
||||||
|
fontLibrary.loadFontLibrary = function () { return { fonts: [font], stylesheetHref: '' }; };
|
||||||
|
fontLibrary.addFontFile = async function () { return { font: font, fonts: [font] }; };
|
||||||
|
fontLibrary.setFontEnabled = async function (_dir, _id, enabled) { return Object.assign({}, font, { enabled: enabled }); };
|
||||||
|
fontLibrary.deleteFontFile = async function () { return font; };
|
||||||
|
|
||||||
|
try {
|
||||||
|
registerFontRoutes(app, {
|
||||||
|
pool: { async query() { return [[{ match_count: 0 }]]; } },
|
||||||
|
pages: { renderFontsPage() { return ''; } },
|
||||||
|
upload: { single() { return function (_req, _res, next) { next(); }; } },
|
||||||
|
uploadDir: 'unused',
|
||||||
|
backgroundTaskQueue: null,
|
||||||
|
recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
|
||||||
|
});
|
||||||
|
const response = { redirect() { return this; } };
|
||||||
|
const next = function (error) { if (error) throw error; };
|
||||||
|
const currentUser = { id: 12, permissionKeys: ['fonts.create', 'fonts.update', 'fonts.delete'] };
|
||||||
|
|
||||||
|
await handlers['POST /settings/fonts'].at(-1)(
|
||||||
|
{ currentUser: currentUser, body: { font_family: 'Alpha Sans' }, file: { path: 'unused' } }, response, next
|
||||||
|
);
|
||||||
|
await handlers['POST /settings/fonts/:id/toggle'].at(-1)(
|
||||||
|
{ currentUser: currentUser, params: { id: font.id }, body: { enabled: '0' } }, response, next
|
||||||
|
);
|
||||||
|
await handlers['POST /settings/fonts/:id/delete'].at(-1)(
|
||||||
|
{ currentUser: currentUser, params: { id: font.id } }, response, next
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(events.map(function (event) { return event.eventType; }), ['font.uploaded', 'font.disabled', 'font.deleted']);
|
||||||
|
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 12; }));
|
||||||
|
} finally {
|
||||||
|
fontLibrary.loadFontLibrary = originals.loadFontLibrary;
|
||||||
|
fontLibrary.addFontFile = originals.addFontFile;
|
||||||
|
fontLibrary.setFontEnabled = originals.setFontEnabled;
|
||||||
|
fontLibrary.deleteFontFile = originals.deleteFontFile;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,9 @@ const os = require('os');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
require('../src/common');
|
||||||
|
const mediaLibrary = require('../src/web/lib/media/library');
|
||||||
|
const registerMediaLibraryRoutes = require('../src/web/routes/settings/media-library');
|
||||||
const {
|
const {
|
||||||
normalizeMediaPath,
|
normalizeMediaPath,
|
||||||
createImageVariants,
|
createImageVariants,
|
||||||
@@ -140,4 +143,72 @@ test('media library counts slide and template references', async () => {
|
|||||||
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
|
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
|
||||||
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
|
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
|
||||||
assert.match(queries[0].sql, /LOCATE/);
|
assert.match(queries[0].sql, /LOCATE/);
|
||||||
|
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media library upload and delete routes audit successful asset changes', async () => {
|
||||||
|
const originalCreateVariants = mediaLibrary.createImageVariants;
|
||||||
|
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
|
||||||
|
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
||||||
|
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
||||||
|
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-audit-'));
|
||||||
|
const uploadDir = path.join(root, 'media', 'uploads');
|
||||||
|
const filePath = path.join(uploadDir, 'photo.png');
|
||||||
|
await fs.promises.mkdir(uploadDir, { recursive: true });
|
||||||
|
await fs.promises.writeFile(filePath, 'image');
|
||||||
|
const routeHandlers = {};
|
||||||
|
const events = [];
|
||||||
|
const app = {
|
||||||
|
get(route, ...handlers) { routeHandlers['GET ' + route] = handlers; },
|
||||||
|
post(route, ...handlers) { routeHandlers['POST ' + route] = handlers; }
|
||||||
|
};
|
||||||
|
const pool = {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM o_app_settings')) return [[]];
|
||||||
|
if (sql.includes('FROM c_media_assets')) {
|
||||||
|
return [[{ id: 9, media_path: '/media/uploads/photo.png', original_name: 'Photo.png', media_type: 'image', file_size: 5 }]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaLibrary.createImageVariants = async function () {};
|
||||||
|
mediaLibrary.registerMediaAsset = async function () {};
|
||||||
|
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
||||||
|
mediaLibrary.removeImageVariants = async function () {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
registerMediaLibraryRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
pages: {},
|
||||||
|
upload: { single() { return function (_req, _res, next) { next(); }; } },
|
||||||
|
uploadDir: uploadDir,
|
||||||
|
getAuditUserId(req) { return req.currentUser.id; },
|
||||||
|
recordRequestAuditEvent(_pool, _req, event) { events.push(event); },
|
||||||
|
requirePermission() { return function (_req, _res, next) { next(); }; }
|
||||||
|
});
|
||||||
|
const response = { redirect(value) { this.location = value; return this; } };
|
||||||
|
const next = function (error) { if (error) throw error; };
|
||||||
|
const request = {
|
||||||
|
currentUser: { id: 4 },
|
||||||
|
body: {},
|
||||||
|
file: { path: filePath, filename: 'photo.png', originalname: 'Photo.png', mimetype: 'image/png', size: 5 }
|
||||||
|
};
|
||||||
|
|
||||||
|
await routeHandlers['POST /media-library'].at(-1)(request, response, next);
|
||||||
|
await routeHandlers['POST /media-library/:id/delete'].at(-1)(
|
||||||
|
{ currentUser: { id: 4 }, params: { id: '9' } }, response, next
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(events.map(function (event) { return event.eventType; }), ['media.uploaded', 'media.deleted']);
|
||||||
|
assert.equal(events[0].targetLabel, 'Photo.png');
|
||||||
|
assert.equal(events[1].targetId, 9);
|
||||||
|
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 4; }));
|
||||||
|
} finally {
|
||||||
|
mediaLibrary.createImageVariants = originalCreateVariants;
|
||||||
|
mediaLibrary.registerMediaAsset = originalRegisterAsset;
|
||||||
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
||||||
|
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
||||||
|
await fs.promises.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -83,6 +83,91 @@ function loadScheduleModule(overrides) {
|
|||||||
return registry.get('timetable');
|
return registry.get('timetable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('timetable date ranges agree in preview and player, before limiting items', () => {
|
||||||
|
const previewModule = loadScheduleModule();
|
||||||
|
const groups = [{ id: 1, entries: [
|
||||||
|
{ id: 1, title: 'Early', start_datetime: '2099-08-20T09:00:00Z' },
|
||||||
|
{ id: 2, title: 'Middle', start_datetime: '2099-08-20T10:00:00Z' },
|
||||||
|
{ id: 3, title: 'Late', start_datetime: '2099-08-20T11:00:00Z' }
|
||||||
|
] }];
|
||||||
|
let playerModule;
|
||||||
|
const sandbox = {
|
||||||
|
window: {
|
||||||
|
pulsePlayerRegionTypes: { register(type, module) { playerModule = module; } },
|
||||||
|
placeholderUtils: {
|
||||||
|
resolvePlaceholderExpression(entry, expression) { return entry[expression]; },
|
||||||
|
formatPlaceholderValue(value) { return value; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialData: { timetableGroups: groups },
|
||||||
|
escapeHtml: String,
|
||||||
|
sanitizeRichText: String
|
||||||
|
};
|
||||||
|
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8'), sandbox);
|
||||||
|
const cases = [
|
||||||
|
[{}, ['Early', 'Middle', 'Late']],
|
||||||
|
[{ date_filter: 'all', date_from: '2099-08-20T10:00:00Z' }, ['Early', 'Middle', 'Late']],
|
||||||
|
[{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z' }, ['Middle', 'Late']],
|
||||||
|
[{ date_filter: 'between', date_to: '2099-08-20T10:00:00Z' }, ['Early', 'Middle']],
|
||||||
|
[{ date_filter: 'between', date_from: '2099-08-20T12:00:00+02:00', date_to: '2099-08-20T10:00:00Z' }, ['Middle']],
|
||||||
|
[{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z', max_items: 1 }, ['Middle']],
|
||||||
|
[{ date_filter: 'between', date_from: '2099-08-21T00:00:00Z' }, []],
|
||||||
|
[{ date_filter: 'after', date_from: '2099-08-20T10:00:00Z', date_to: '2099-08-20T09:00:00Z' }, ['Middle', 'Late']],
|
||||||
|
[{ date_filter: 'before', date_to: '2099-08-20T10:00:00Z', date_from: '2099-08-20T11:00:00Z' }, ['Early', 'Middle']]
|
||||||
|
];
|
||||||
|
for (const [range, titles] of cases) {
|
||||||
|
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'both', max_items: 5, ...range };
|
||||||
|
const region = { id: 47, baseStyle: '' };
|
||||||
|
const preview = previewModule.renderPreview(region, config, { timetableGroups: groups });
|
||||||
|
const player = playerModule.renderRegion(region, config);
|
||||||
|
for (const title of ['Early', 'Middle', 'Late']) {
|
||||||
|
assert.equal(preview.includes(title), titles.includes(title), 'preview ' + JSON.stringify(range));
|
||||||
|
assert.equal(player.includes(title), titles.includes(title), 'player ' + JSON.stringify(range));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[0].entries = [{ id: 4, title: 'Live', start_datetime: new Date(Date.now() - 60000).toISOString(), end_datetime: new Date(Date.now() + 60000).toISOString() }];
|
||||||
|
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' };
|
||||||
|
assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/);
|
||||||
|
assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timetable date controls default to all and pass unsaved date ranges to preview', () => {
|
||||||
|
const module = loadScheduleModule();
|
||||||
|
const region = { id: 47, region_key: 'timetable', label: 'Timetable' };
|
||||||
|
const html = module.renderEditorCard({ region, current: {}, timetableGroups: [] });
|
||||||
|
assert.match(html, /<option value="all" selected>All<\/option>/);
|
||||||
|
assert.match(html, /data-timetable-date-range hidden/);
|
||||||
|
assert.match(html, /data-timetable-date="date_from"[^>]* disabled/);
|
||||||
|
const saved = { date_filter: 'between', date_from: '2099-08-20T10:00:00.000Z', date_to: '' };
|
||||||
|
const enabled = module.renderEditorCard({ region, current: saved, timetableGroups: [] });
|
||||||
|
assert.match(enabled, /<option value="between" selected>Between Dates<\/option>/);
|
||||||
|
assert.doesNotMatch(enabled, /data-timetable-date-range hidden/);
|
||||||
|
assert.doesNotMatch(enabled, /data-timetable-date="date_from"[^>]* disabled/);
|
||||||
|
const config = module.getCurrentConfig(region, { timetable: saved });
|
||||||
|
assert.equal(config.date_from, saved.date_from);
|
||||||
|
const fields = {
|
||||||
|
'[name="region_date_filter_47"]': { value: 'between' },
|
||||||
|
'[name="region_date_from_47"]': { value: '' },
|
||||||
|
'[name="region_date_to_47"]': { value: '2099-08-21T10:00:00.000Z' }
|
||||||
|
};
|
||||||
|
const context = module.buildPreviewRenderContext(region, { querySelector(selector) { return fields[selector] || null; } }, { timetable: saved }, []);
|
||||||
|
assert.equal(context.date_filter, 'between');
|
||||||
|
assert.equal(context.date_from, '');
|
||||||
|
assert.equal(context.date_to, fields['[name="region_date_to_47"]'].value);
|
||||||
|
assert.doesNotMatch(html, /Use the selected group and display mode/);
|
||||||
|
for (const mode of ['before', 'after']) {
|
||||||
|
const current = { ...saved, date_filter: mode };
|
||||||
|
const markup = module.renderEditorCard({ region, current, timetableGroups: [] });
|
||||||
|
assert.match(markup, new RegExp('<option value="' + mode + '" selected>'));
|
||||||
|
assert.equal(module.getCurrentConfig(region, { timetable: current }).date_filter, mode);
|
||||||
|
const activeField = mode === 'after' ? 'date_from' : 'date_to';
|
||||||
|
const inactiveField = mode === 'after' ? 'date_to' : 'date_from';
|
||||||
|
assert.doesNotMatch(markup, new RegExp('data-timetable-date="' + activeField + '"[^>]* disabled'));
|
||||||
|
assert.match(markup, new RegExp('data-timetable-date="' + inactiveField + '"[^>]* disabled'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('timetable region editor lists timezone transforms in helper text', () => {
|
test('timetable region editor lists timezone transforms in helper text', () => {
|
||||||
const module = loadScheduleModule();
|
const module = loadScheduleModule();
|
||||||
const html = module.renderEditorCard({
|
const html = module.renderEditorCard({
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
|
|||||||
region_text_47: '<p>{{title}}</p>',
|
region_text_47: '<p>{{title}}</p>',
|
||||||
region_timetable_group_id_47: '1',
|
region_timetable_group_id_47: '1',
|
||||||
region_timetable_display_mode_47: 'current',
|
region_timetable_display_mode_47: 'current',
|
||||||
region_timetable_max_items_47: '7'
|
region_timetable_max_items_47: '7',
|
||||||
|
region_date_filter_47: 'between',
|
||||||
|
region_date_from_47: '2099-08-20T12:00:00+02:00',
|
||||||
|
region_date_to_47: ''
|
||||||
},
|
},
|
||||||
files: []
|
files: []
|
||||||
}, null);
|
}, null);
|
||||||
@@ -37,10 +40,50 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
|
|||||||
assert.equal(content.timetable.timetable_group_id, '1');
|
assert.equal(content.timetable.timetable_group_id, '1');
|
||||||
assert.equal(content.timetable.display_mode, 'current');
|
assert.equal(content.timetable.display_mode, 'current');
|
||||||
assert.equal(content.timetable.max_items, '7');
|
assert.equal(content.timetable.max_items, '7');
|
||||||
|
assert.equal(content.timetable.date_filter, 'between');
|
||||||
|
assert.equal(content.timetable.date_from, '2099-08-20T10:00:00.000Z');
|
||||||
|
assert.equal(content.timetable.date_to, '');
|
||||||
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
|
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
|
||||||
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
|
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('timetable date settings persist, clear, default to all, and reject invalid ranges', async () => {
|
||||||
|
const pool = {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM c_templates st')) {
|
||||||
|
return [[{ id: 9, name: 'Template 9' }]];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM c_template_regions')) {
|
||||||
|
return [[{ id: 47, region_key: 'timetable', region_type: 'timetable' }]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const existing = { content_json: JSON.stringify({ timetable: { type: 'timetable', date_filter: 'between', date_from: '2099-08-20T10:00:00.000Z', date_to: '2099-08-20T11:00:00.000Z' } }) };
|
||||||
|
async function save(fields, slide) {
|
||||||
|
const payload = await buildSlidePayload(pool, { body: { title: 'Timetable', template_id: '9', ...fields }, files: [] }, slide);
|
||||||
|
return JSON.parse(payload.contentJson).timetable;
|
||||||
|
}
|
||||||
|
assert.equal((await save({})).date_filter, 'all');
|
||||||
|
assert.equal((await save({}, existing)).date_from, '2099-08-20T10:00:00.000Z');
|
||||||
|
assert.equal((await save({ region_date_from_47: '' }, existing)).date_from, '');
|
||||||
|
const all = await save({ region_date_filter_47: 'all' }, existing);
|
||||||
|
assert.equal(all.date_filter, 'all');
|
||||||
|
assert.equal(all.date_to, '2099-08-20T11:00:00.000Z');
|
||||||
|
for (const mode of ['before', 'after']) {
|
||||||
|
const saved = await save({ region_date_filter_47: mode }, existing);
|
||||||
|
assert.equal(saved.date_filter, mode);
|
||||||
|
assert.equal(saved.date_from, '2099-08-20T10:00:00.000Z');
|
||||||
|
assert.equal(saved.date_to, '2099-08-20T11:00:00.000Z');
|
||||||
|
}
|
||||||
|
assert.equal((await save({ region_date_filter_47: 'after', region_date_to_47: '2099-08-19T00:00:00Z' }, existing)).date_filter, 'after');
|
||||||
|
assert.equal((await save({ region_date_filter_47: 'before', region_date_from_47: '2099-08-21T00:00:00Z' }, existing)).date_filter, 'before');
|
||||||
|
await assert.rejects(save({ region_date_filter_47: 'before', region_date_to_47: 'invalid' }), { statusCode: 400 });
|
||||||
|
await assert.rejects(save({ region_date_filter_47: 'after', region_date_from_47: 'invalid' }), { statusCode: 400 });
|
||||||
|
await assert.rejects(save({ region_date_filter_47: 'between', region_date_from_47: 'invalid' }), { statusCode: 400 });
|
||||||
|
await assert.rejects(save({ region_date_from_47: '2099-08-21T00:00:00Z' }, existing), { statusCode: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
test('buildSlidePayload preserves safe image markup in text regions', async () => {
|
test('buildSlidePayload preserves safe image markup in text regions', async () => {
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql) {
|
async query(sql) {
|
||||||
|
|||||||
@@ -206,4 +206,74 @@ test('weather icon usage recognizes one set reference', function () {
|
|||||||
};
|
};
|
||||||
assert.equal(isUsed('set'), true);
|
assert.equal(isUsed('set'), true);
|
||||||
assert.equal(isUsed('other'), false);
|
assert.equal(isUsed('other'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weather icon set create, update, toggle, and delete are audited', async function () {
|
||||||
|
const originals = {
|
||||||
|
readWeatherIconSets: library.readWeatherIconSets,
|
||||||
|
saveWeatherIconSet: library.saveWeatherIconSet,
|
||||||
|
collectWeatherIconRemovalOperations: library.collectWeatherIconRemovalOperations,
|
||||||
|
setWeatherIconSetEnabled: library.setWeatherIconSetEnabled,
|
||||||
|
deleteWeatherIconSet: library.deleteWeatherIconSet
|
||||||
|
};
|
||||||
|
const handlers = {};
|
||||||
|
const events = [];
|
||||||
|
let sets = [];
|
||||||
|
const app = {
|
||||||
|
get(route, ...callbacks) { handlers['GET ' + route] = callbacks; },
|
||||||
|
post(route, ...callbacks) { handlers['POST ' + route] = callbacks; }
|
||||||
|
};
|
||||||
|
const iconSet = { id: 'clear-set', name: 'Clear skies', icons: { clear: '/media/assets/weather-icons/clear-set/clear.png' }, nightIcons: {} };
|
||||||
|
library.readWeatherIconSets = function () { return sets.slice(); };
|
||||||
|
library.saveWeatherIconSet = async function (_root, name, _files, id) {
|
||||||
|
const saved = Object.assign({}, iconSet, { id: id || iconSet.id, name: name });
|
||||||
|
sets = id ? sets.map(function (set) { return set.id === id ? saved : set; }) : sets.concat(saved);
|
||||||
|
return saved;
|
||||||
|
};
|
||||||
|
library.collectWeatherIconRemovalOperations = function () { return []; };
|
||||||
|
library.setWeatherIconSetEnabled = function (_root, id, enabled) {
|
||||||
|
const updated = Object.assign({}, sets.find(function (set) { return set.id === id; }), { enabled: enabled });
|
||||||
|
sets = sets.map(function (set) { return set.id === id ? updated : set; });
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
library.deleteWeatherIconSet = function (_root, id) {
|
||||||
|
const removed = sets.find(function (set) { return set.id === id; });
|
||||||
|
sets = sets.filter(function (set) { return set.id !== id; });
|
||||||
|
return removed;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
require('../src/web/routes/settings/weather-icons') (app, {
|
||||||
|
mediaDir: 'unused',
|
||||||
|
pool: { async query() { return [[]]; } },
|
||||||
|
requirePermission() { return function (_req, _res, next) { next(); }; },
|
||||||
|
recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
|
||||||
|
});
|
||||||
|
const response = { redirect() { return this; }, status() { return this; }, send() { return this; } };
|
||||||
|
const next = function (error) { if (error) throw error; };
|
||||||
|
const currentUser = { id: 21 };
|
||||||
|
const createRequest = { currentUser: currentUser, body: { name: 'Clear skies' }, files: {}, params: {} };
|
||||||
|
|
||||||
|
await handlers['POST /settings/weather-icons'].at(-1)(createRequest, response, next);
|
||||||
|
await handlers['POST /settings/weather-icons/:id'].at(-1)(
|
||||||
|
{ currentUser: currentUser, body: { name: 'Updated skies' }, files: {}, params: { id: iconSet.id } }, response, next
|
||||||
|
);
|
||||||
|
await handlers['POST /settings/weather-icons/:id/toggle'].at(-1)(
|
||||||
|
{ currentUser: currentUser, body: { enabled: '0' }, params: { id: iconSet.id } }, response, next
|
||||||
|
);
|
||||||
|
await handlers['POST /settings/weather-icons/:id/delete'].at(-1)(
|
||||||
|
{ currentUser: currentUser, params: { id: iconSet.id } }, response, next
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(events.map(function (event) { return event.eventType; }), [
|
||||||
|
'weather-icon-set.created', 'weather-icon-set.updated', 'weather-icon-set.disabled', 'weather-icon-set.deleted'
|
||||||
|
]);
|
||||||
|
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 21; }));
|
||||||
|
} finally {
|
||||||
|
library.readWeatherIconSets = originals.readWeatherIconSets;
|
||||||
|
library.saveWeatherIconSet = originals.saveWeatherIconSet;
|
||||||
|
library.collectWeatherIconRemovalOperations = originals.collectWeatherIconRemovalOperations;
|
||||||
|
library.setWeatherIconSetEnabled = originals.setWeatherIconSetEnabled;
|
||||||
|
library.deleteWeatherIconSet = originals.deleteWeatherIconSet;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user