From de1469c29df68ffe10e0ac397b2cfe9cf0f0dd7c Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 26 Jul 2026 22:03:36 +0100 Subject: [PATCH] Release 1.5.16 --- CHANGELOG.md | 16 ++ package-lock.json | 4 +- package.json | 2 +- src/data/utils.js | 73 ++++++- src/web.js | 185 ++---------------- src/web/lib/background-task-handlers.js | 93 +++++++++ src/web/lib/background-task-queue.js | 22 ++- src/web/lib/background-task-scheduling.js | 146 ++++++++++++++ src/web/lib/background-task-setup.js | 32 +++ src/web/lib/rbac-data.js | 10 +- src/web/public/css/theme-custom.css | 9 +- .../public/js/playlists/playlist-schedule.js | 35 +++- src/web/public/js/toast.js | 122 +++++++++++- src/web/routes/admin/content.js | 28 +++ src/web/routes/admin/rbac.js | 18 +- .../routes/settings/background-tasks-page.js | 6 +- src/web/routes/settings/rbac/edit.js | 4 +- src/web/routes/signage/playlists/edit.js | 22 +++ src/web/views/settings/rbac/edit.hbs | 71 +++---- src/web/views/shared/layout.hbs | 23 ++- 20 files changed, 679 insertions(+), 242 deletions(-) create mode 100644 src/web/lib/background-task-handlers.js create mode 100644 src/web/lib/background-task-scheduling.js create mode 100644 src/web/lib/background-task-setup.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e319948..4bc07eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to this project will be documented in this file. - No unreleased changes recorded yet. +## 1.5.16 - 2026-07-26 + +### Added + +- Background task handling was split into dedicated handler and scheduling modules, with startup data-source refreshes and unused-upload cleanup wired through the shared setup flow. + +### Changed + +- The admin shell, shared theme, and toast presentation were refreshed to match the newer layout and notification styling. +- Playlist scheduling now handles video-duration toggles and already-assigned slides more clearly in the picker and editor UI. +- RBAC and settings views were updated to align with the revised admin layout and shared table behavior. + +### Fixed + +- Template and slide thumbnail refreshes now run through the background task queue so template-wide thumbnail regeneration stays consistent. + ## 1.5.15 - 2026-07-26 ### Changed diff --git a/package-lock.json b/package-lock.json index 0b0d8de..c20c91d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "1.5.15", + "version": "1.5.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "1.5.15", + "version": "1.5.16", "dependencies": { "@sparticuz/chromium": "^137.0.0", "bootstrap-icons": "1.11.3", diff --git a/package.json b/package.json index 949b86b..8b48f9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.5.15", + "version": "1.5.16", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/data/utils.js b/src/data/utils.js index 056265f..00647d6 100644 --- a/src/data/utils.js +++ b/src/data/utils.js @@ -130,6 +130,75 @@ function findTopLevelOrderByIndex(sql) { return lastOrderByIndex; } +function findTopLevelWhereIndex(sql) { + const text = String(sql || ''); + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + let lastWhereIndex = -1; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + const previousCharacter = index > 0 ? text[index - 1] : ''; + + if (inSingleQuote) { + if (character === '\'' && previousCharacter !== '\\') { + inSingleQuote = false; + } + continue; + } + + if (inDoubleQuote) { + if (character === '"' && previousCharacter !== '\\') { + inDoubleQuote = false; + } + continue; + } + + if (inBacktick) { + if (character === '`') { + inBacktick = false; + } + continue; + } + + if (character === '\'') { + inSingleQuote = true; + continue; + } + + if (character === '"') { + inDoubleQuote = true; + continue; + } + + if (character === '`') { + inBacktick = true; + continue; + } + + if (character === '(') { + depth += 1; + continue; + } + + if (character === ')' && depth > 0) { + depth -= 1; + continue; + } + + if (depth === 0 && /[wW]/.test(character)) { + const remaining = text.slice(index); + if (/^where\b/i.test(remaining)) { + lastWhereIndex = index; + } + } + } + + return lastWhereIndex; +} + function buildSearchFilter(searchColumns, searchTerm) { const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) { return String(column || '').trim(); @@ -175,7 +244,9 @@ async function fetchPagedRows(pool, options) { orderBySql = selectSql.slice(orderByIndex).trim(); } - const filteredSelectSql = `${baseSelectSql}${searchFilter.clause}`; + const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0; + const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : ''; + const filteredSelectSql = `${baseSelectSql}${searchClause}`; const countQuery = searchFilter.clause ? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows` : countSql; diff --git a/src/web.js b/src/web.js index 2ee0f55..d3e59b7 100644 --- a/src/web.js +++ b/src/web.js @@ -16,7 +16,8 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-comm const registerAdminContentRoutes = require('./web/routes/admin/content'); const registerAdminDataSourceRoutes = require('./web/routes/data-sources'); const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks'); -const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue'); +const { createBackgroundTaskQueue } = require('./web/lib/background-task-queue'); +const { createBackgroundTaskSetup } = require('./web/lib/background-task-setup'); const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails'); const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh'); const { createWebBootstrap } = require('./web/bootstrap'); @@ -105,6 +106,21 @@ async function start() { const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer; const runMediaSyncTask = webBootstrap.runMediaSyncTask; + const backgroundTaskSetup = createBackgroundTaskSetup({ + pool: pool, + common: common, + backgroundTaskQueue: backgroundTaskQueue, + collectUploadPathsFromDirectory: collectUploadPathsFromDirectory, + removeUnusedUploadFiles: removeUnusedUploadFiles, + captureSlideThumbnail: captureSlideThumbnail, + refreshApiSource: refreshApiSource, + refreshRssFeed: refreshRssFeed, + runMediaSyncTask: runMediaSyncTask, + mediaDir: MEDIA_DIR, + uploadsDir: UPLOADS_DIR, + playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, + dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS + }); const broadcastDashboardState = webBootstrap.broadcastDashboardState; const sessionService = createSessionService({ sessionCookieName: SESSION_COOKIE_NAME, @@ -119,51 +135,6 @@ async function start() { const createUserSession = sessionService.createUserSession; const requireAuth = sessionService.requireAuth; - backgroundTaskQueue.setTaskHandler('media-sync', function (task) { - return runMediaSyncTask(task && task.payload ? task.payload : task); - }); - - backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) { - const payload = task && task.payload ? task.payload : {}; - const sourceType = String(payload.sourceType || '').trim(); - const sourceId = Number(payload.sourceId || 0); - - if (sourceType === 'api-source') { - const apiSource = await common.fetchApiSourceById(pool, sourceId); - if (!apiSource) { - throw new Error('API source not found.'); - } - return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null); - } - - if (sourceType === 'rss-feed') { - const rssFeed = await common.fetchRssFeedById(pool, sourceId); - if (!rssFeed) { - throw new Error('RSS feed not found.'); - } - return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null); - } - - throw new Error('Unsupported data source refresh task.'); - }); - - backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) { - const payload = task && task.payload ? task.payload : {}; - const slideId = Number(payload.slideId || 0); - if (!Number.isFinite(slideId) || slideId <= 0) { - throw new Error('Slide id is required.'); - } - - return captureSlideThumbnail({ - pool: pool, - common: common, - mediaDir: MEDIA_DIR, - baseUrl: PLAYER_INTERNAL_BASE_URL, - slideId: slideId, - previousThumbnailPath: payload.previousThumbnailPath || null - }); - }); - app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use('/assets', express.static(ASSET_DIR)); @@ -374,127 +345,7 @@ async function start() { // Ensure schema and mirror media before the web service starts handling traffic. await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); await backgroundTaskQueue.initialize(); - - async function syncRecurringRefreshes() { - const apiSourcesData = await common.fetchApiSourcesData(pool); - (apiSourcesData.apiSources || []).forEach(function (apiSource) { - backgroundTaskQueue.registerRecurringTask({ - key: 'api-source-refresh:' + Number(apiSource.id), - title: 'API source refresh', - category: 'data-source', - intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit), - metadata: { - sourceType: 'api-source', - sourceId: Number(apiSource.id), - sourceName: apiSource.name - }, - run: function () { - return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); - } - }); - }); - - const rssFeedsData = await common.fetchRssFeedsData(pool); - (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { - backgroundTaskQueue.registerRecurringTask({ - key: 'rss-feed-refresh:' + Number(rssFeed.id), - title: 'RSS feed refresh', - category: 'data-source', - intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit), - metadata: { - sourceType: 'rss-feed', - sourceId: Number(rssFeed.id), - sourceName: rssFeed.name - }, - run: function () { - return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); - } - }); - }); - } - - async function registerRecurringMaintenanceTasks() { - async function runUnusedUploadSweep() { - const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR); - if (!uploadPaths.length) { - return; - } - - await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths); - } - - backgroundTaskQueue.registerRecurringTask({ - key: 'unused-upload-sweep', - title: 'Unused upload sweep', - category: 'media-sync', - intervalMs: 24 * 60 * 60 * 1000, - metadata: { - uploadDir: UPLOADS_DIR - }, - run: runUnusedUploadSweep - }); - } - - async function scheduleInitialDataSourceRefreshes() { - const apiSourcesData = await common.fetchApiSourcesData(pool); - const rssFeedsData = await common.fetchRssFeedsData(pool); - const startupSources = []; - - (apiSourcesData.apiSources || []).forEach(function (apiSource) { - startupSources.push({ - type: 'api-source', - id: Number(apiSource.id), - name: apiSource.name, - run: function () { - return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); - } - }); - }); - - (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { - startupSources.push({ - type: 'rss-feed', - id: Number(rssFeed.id), - name: rssFeed.name, - run: function () { - return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); - } - }); - }); - - startupSources.forEach(function (source, index) { - const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS; - - setTimeout(function () { - backgroundTaskQueue.enqueueTask({ - key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(), - title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh', - category: 'data-source', - taskType: 'data-source-refresh', - metadata: { - sourceType: source.type, - sourceId: source.id, - sourceName: source.name, - startupRefresh: true - }, - payload: { - sourceType: source.type, - sourceId: source.id, - sourceName: source.name, - startupRefresh: true - } - }).catch(function (error) { - console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error); - }); - }, startupDelayMs); - }); - } - - await syncRecurringRefreshes(); - await registerRecurringMaintenanceTasks(); - scheduleInitialDataSourceRefreshes().catch(function (error) { - console.warn('Unable to schedule startup data source refreshes:', error); - }); + await backgroundTaskSetup.initialize(); fs.mkdirSync(MEDIA_DIR, { recursive: true }); await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) { diff --git a/src/web/lib/background-task-handlers.js b/src/web/lib/background-task-handlers.js new file mode 100644 index 0000000..3c9b228 --- /dev/null +++ b/src/web/lib/background-task-handlers.js @@ -0,0 +1,93 @@ +function registerBackgroundTaskHandlers(options) { + const pool = options && options.pool; + const common = options && options.common; + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const captureSlideThumbnail = options && options.captureSlideThumbnail; + const refreshApiSource = options && options.refreshApiSource; + const refreshRssFeed = options && options.refreshRssFeed; + const runMediaSyncTask = options && options.runMediaSyncTask; + const mediaDir = String(options && options.mediaDir || '').trim(); + const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim(); + + if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) { + throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.'); + } + + backgroundTaskQueue.setTaskHandler('media-sync', function (task) { + return runMediaSyncTask(task && task.payload ? task.payload : task); + }); + + backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) { + const payload = task && task.payload ? task.payload : {}; + const sourceType = String(payload.sourceType || '').trim(); + const sourceId = Number(payload.sourceId || 0); + + if (sourceType === 'api-source') { + const apiSource = await common.fetchApiSourceById(pool, sourceId); + if (!apiSource) { + throw new Error('API source not found.'); + } + return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null); + } + + if (sourceType === 'rss-feed') { + const rssFeed = await common.fetchRssFeedById(pool, sourceId); + if (!rssFeed) { + throw new Error('RSS feed not found.'); + } + return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null); + } + + throw new Error('Unsupported data source refresh task.'); + }); + + backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) { + const payload = task && task.payload ? task.payload : {}; + const slideId = Number(payload.slideId || 0); + if (!Number.isFinite(slideId) || slideId <= 0) { + throw new Error('Slide id is required.'); + } + + return captureSlideThumbnail({ + pool: pool, + common: common, + mediaDir: mediaDir, + baseUrl: playerInternalBaseUrl, + slideId: slideId, + previousThumbnailPath: payload.previousThumbnailPath || null + }); + }); + + backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) { + const payload = task && task.payload ? task.payload : {}; + const templateId = Number(payload.templateId || 0); + if (!Number.isFinite(templateId) || templateId <= 0) { + throw new Error('Template id is required.'); + } + + const [slides] = await pool.query( + 'SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC', + [templateId] + ); + + for (const slide of slides || []) { + const slideId = Number(slide && slide.id || 0); + if (!Number.isFinite(slideId) || slideId <= 0) { + continue; + } + + await captureSlideThumbnail({ + pool: pool, + common: common, + mediaDir: mediaDir, + baseUrl: playerInternalBaseUrl, + slideId: slideId, + previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null + }); + } + + return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 }; + }); +} + +module.exports = { registerBackgroundTaskHandlers }; \ No newline at end of file diff --git a/src/web/lib/background-task-queue.js b/src/web/lib/background-task-queue.js index 425a2d4..100aa4d 100644 --- a/src/web/lib/background-task-queue.js +++ b/src/web/lib/background-task-queue.js @@ -628,14 +628,24 @@ function createBackgroundTaskQueue(options) { return Array.from(tasksById.values()) .slice() .sort(function (left, right) { - const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN; - const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN; - if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) { - return rightQueuedTime - leftQueuedTime; + const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN; + const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN; + if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) { + return rightStartedTime - leftStartedTime; } - if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) { - return Number.isFinite(leftQueuedTime) ? -1 : 1; + if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) { + return Number.isFinite(leftStartedTime) ? -1 : 1; + } + + const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN; + const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN; + if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) { + return rightCreatedTime - leftCreatedTime; + } + + if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) { + return Number.isFinite(leftCreatedTime) ? -1 : 1; } return right.id - left.id; diff --git a/src/web/lib/background-task-scheduling.js b/src/web/lib/background-task-scheduling.js new file mode 100644 index 0000000..5bcb8a3 --- /dev/null +++ b/src/web/lib/background-task-scheduling.js @@ -0,0 +1,146 @@ +const { normalizeIntervalMs } = require('./background-task-queue'); + +function registerBackgroundTaskScheduling(options) { + const pool = options && options.pool; + const common = options && options.common; + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory; + const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles; + const refreshApiSource = options && options.refreshApiSource; + const refreshRssFeed = options && options.refreshRssFeed; + const uploadsDir = String(options && options.uploadsDir || '').trim(); + const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250)); + + if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) { + throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.'); + } + + async function syncRecurringRefreshes() { + const apiSourcesData = await common.fetchApiSourcesData(pool); + (apiSourcesData.apiSources || []).forEach(function (apiSource) { + backgroundTaskQueue.registerRecurringTask({ + key: 'api-source-refresh:' + Number(apiSource.id), + title: 'API source refresh', + category: 'data-source', + intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit), + metadata: { + sourceType: 'api-source', + sourceId: Number(apiSource.id), + sourceName: apiSource.name + }, + run: function () { + return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); + } + }); + }); + + const rssFeedsData = await common.fetchRssFeedsData(pool); + (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { + backgroundTaskQueue.registerRecurringTask({ + key: 'rss-feed-refresh:' + Number(rssFeed.id), + title: 'RSS feed refresh', + category: 'data-source', + intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit), + metadata: { + sourceType: 'rss-feed', + sourceId: Number(rssFeed.id), + sourceName: rssFeed.name + }, + run: function () { + return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); + } + }); + }); + } + + async function registerRecurringMaintenanceTasks() { + async function runUnusedUploadSweep() { + const uploadPaths = await collectUploadPathsFromDirectory(uploadsDir); + if (!uploadPaths.length) { + return; + } + + await removeUnusedUploadFiles(pool, uploadsDir, uploadPaths); + } + + backgroundTaskQueue.registerRecurringTask({ + key: 'unused-upload-sweep', + title: 'Unused upload sweep', + category: 'media-sync', + intervalMs: 24 * 60 * 60 * 1000, + metadata: { + uploadDir: uploadsDir + }, + run: runUnusedUploadSweep + }); + } + + async function scheduleInitialDataSourceRefreshes() { + const apiSourcesData = await common.fetchApiSourcesData(pool); + const rssFeedsData = await common.fetchRssFeedsData(pool); + const startupSources = []; + + (apiSourcesData.apiSources || []).forEach(function (apiSource) { + startupSources.push({ + type: 'api-source', + id: Number(apiSource.id), + name: apiSource.name, + run: function () { + return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); + } + }); + }); + + (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { + startupSources.push({ + type: 'rss-feed', + id: Number(rssFeed.id), + name: rssFeed.name, + run: function () { + return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); + } + }); + }); + + startupSources.forEach(function (source, index) { + const startupDelayMs = index * dataSourceStartupRefreshStaggerMs; + + setTimeout(function () { + backgroundTaskQueue.enqueueTask({ + key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(), + title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh', + category: 'data-source', + taskType: 'data-source-refresh', + metadata: { + sourceType: source.type, + sourceId: source.id, + sourceName: source.name, + startupRefresh: true + }, + payload: { + sourceType: source.type, + sourceId: source.id, + sourceName: source.name, + startupRefresh: true + } + }).catch(function (error) { + console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error); + }); + }, startupDelayMs); + }); + } + + async function initialize() { + await syncRecurringRefreshes(); + await registerRecurringMaintenanceTasks(); + scheduleInitialDataSourceRefreshes().catch(function (error) { + console.warn('Unable to schedule startup data source refreshes:', error); + }); + } + + return { + initialize: initialize + }; +} + +module.exports = { registerBackgroundTaskScheduling }; \ No newline at end of file diff --git a/src/web/lib/background-task-setup.js b/src/web/lib/background-task-setup.js new file mode 100644 index 0000000..4bada3c --- /dev/null +++ b/src/web/lib/background-task-setup.js @@ -0,0 +1,32 @@ +const { registerBackgroundTaskHandlers } = require('./background-task-handlers'); +const { registerBackgroundTaskScheduling } = require('./background-task-scheduling'); + +function createBackgroundTaskSetup(options) { + const pool = options && options.pool; + const common = options && options.common; + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory; + const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles; + const refreshApiSource = options && options.refreshApiSource; + const refreshRssFeed = options && options.refreshRssFeed; + const uploadsDir = String(options && options.uploadsDir || '').trim(); + const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250)); + + if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) { + throw new Error('createBackgroundTaskSetup requires the background task dependencies.'); + } + + async function initialize() { + registerBackgroundTaskHandlers(options); + const backgroundTaskScheduling = registerBackgroundTaskScheduling(options); + if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') { + await backgroundTaskScheduling.initialize(); + } + } + + return { + initialize: initialize + }; +} + +module.exports = { createBackgroundTaskSetup }; \ No newline at end of file diff --git a/src/web/lib/rbac-data.js b/src/web/lib/rbac-data.js index 5528aa3..83b937e 100644 --- a/src/web/lib/rbac-data.js +++ b/src/web/lib/rbac-data.js @@ -134,7 +134,11 @@ async function fetchUsersWithRoles(pool) { }); } -async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { +async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection, options) { + const excludedUserId = Number(options && options.excludeUserId); + const hasExcludedUserId = Number.isInteger(excludedUserId) && excludedUserId > 0; + const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : ''; + const queryArgs = hasExcludedUserId ? [excludedUserId] : []; const paged = await fetchPagedRows(pool, { selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, COALESCE(role_data.role_names, '') AS role_names, @@ -148,8 +152,10 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey JOIN roles r ON r.id = ur.role_id GROUP BY ur.user_id ) role_data ON role_data.user_id = u.id + ${whereSql} ORDER BY u.id ASC`, - countSql: 'SELECT COUNT(*) AS count FROM users', + countSql: `SELECT COUNT(*) AS count FROM users u ${whereSql}`, + params: queryArgs, searchColumns: ['u.name', 'u.username', 'role_data.role_names'], searchTerm: searchTerm, sortColumns: { diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index e1b597e..45b8c9c 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -1552,6 +1552,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { z-index: 1085; } +.app-toast-container { + top: calc(40px + 1rem); +} + .is-hidden { display: none !important; } @@ -1747,9 +1751,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { background: transparent; max-width: none; overflow: hidden; - width: min(calc(100vw - 1rem), 42rem); - max-height: calc(100vh - 1rem); - height: auto; + width: min(calc(100vw - 2rem), 56rem); + max-height: calc(100vh - 0.25rem); margin: 0; } diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js index 3d7b371..751b985 100644 --- a/src/web/public/js/playlists/playlist-schedule.js +++ b/src/web/public/js/playlists/playlist-schedule.js @@ -353,6 +353,25 @@ var rowKeyInput = form.querySelector('[name="row_key"]'); var dayCheckboxes = form.querySelectorAll('[name="schedule_days"]'); + function getDayButton(checkbox) { + return checkbox && checkbox.id ? form.querySelector('label[for="' + checkbox.id + '"]') : null; + } + + function syncDayButtonState(checkbox) { + var button = getDayButton(checkbox); + + if (!button) { + return; + } + + button.classList.toggle('active', Boolean(checkbox.checked)); + button.setAttribute('aria-pressed', checkbox.checked ? 'true' : 'false'); + } + + function syncAllDayButtonStates() { + Array.prototype.forEach.call(dayCheckboxes, syncDayButtonState); + } + function clearScheduleValidity() { [startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) { if (input) { @@ -412,7 +431,20 @@ } }); Array.prototype.forEach.call(dayCheckboxes, function (checkbox) { - checkbox.addEventListener('change', clearScheduleValidity); + checkbox.addEventListener('change', function () { + syncDayButtonState(checkbox); + clearScheduleValidity(); + }); + + var button = getDayButton(checkbox); + if (button) { + button.addEventListener('click', function (event) { + event.preventDefault(); + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + checkbox.focus(); + }); + } }); select.addEventListener('change', clearScheduleValidity); @@ -436,6 +468,7 @@ select.addEventListener('change', updateVisibility); updateVisibility(); + syncAllDayButtonStates(); form.addEventListener('submit', function (event) { event.preventDefault(); diff --git a/src/web/public/js/toast.js b/src/web/public/js/toast.js index 7c91a43..c81645f 100644 --- a/src/web/public/js/toast.js +++ b/src/web/public/js/toast.js @@ -1,4 +1,110 @@ (function () { + function getToastTitle(variant) { + var textVariant = String(variant || '').trim().toLowerCase(); + if (textVariant === 'danger') { + return 'Error'; + } + if (textVariant === 'warning') { + return 'Warning'; + } + if (textVariant === 'success') { + return 'Success'; + } + return 'Pulse'; + } + + function getToastTimeLabel(createdAt) { + var timestamp = Number(createdAt); + if (!timestamp || Number.isNaN(timestamp)) { + return 'just now'; + } + + var elapsed = Date.now() - timestamp; + if (elapsed < 60 * 1000) { + return 'just now'; + } + + var minutes = Math.floor(elapsed / (60 * 1000)); + if (minutes < 60) { + return minutes + ' min' + (minutes === 1 ? '' : 's') + ' ago'; + } + + var hours = Math.floor(minutes / 60); + if (hours < 24) { + return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago'; + } + + var days = Math.floor(hours / 24); + if (days < 7) { + return days + ' day' + (days === 1 ? '' : 's') + ' ago'; + } + + return new Date(timestamp).toLocaleString(); + } + + function getToastButtonClass(variant) { + var textVariant = String(variant || '').trim().toLowerCase(); + return textVariant === 'light' || textVariant === 'warning' ? 'btn-close' : 'btn-close-white'; + } + + function getToastIconClass(variant) { + var textVariant = String(variant || '').trim().toLowerCase(); + if (textVariant === 'success') { + return 'bi-check-circle-fill'; + } + if (textVariant === 'warning') { + return 'bi-exclamation-triangle-fill'; + } + if (textVariant === 'danger') { + return 'bi-x-circle-fill'; + } + if (textVariant === 'primary') { + return 'bi-bell-fill'; + } + return 'bi-info-circle-fill'; + } + + function buildToastMarkup(title, timeLabel, buttonClass, iconClass) { + return '
' + + '' + + '' + title + '' + + '' + timeLabel + '' + + '' + + '
' + + '
'; + } + + function setToastHeader(toast, variant, createdAt) { + if (!toast) { + return; + } + + var header = toast.querySelector('.toast-header'); + var icon = toast.querySelector('.toast-header i'); + var title = toast.querySelector('.toast-header .me-auto'); + var time = toast.querySelector('.toast-time'); + var closeButton = toast.querySelector('.toast-header .btn-close'); + var nextVariant = getMessageVariant('', variant); + + if (!header) { + return; + } + + header.className = 'toast-header border-0 text-bg-' + nextVariant; + if (icon) { + icon.className = 'bi ' + getToastIconClass(nextVariant) + ' me-2'; + } + if (title) { + title.textContent = getToastTitle(nextVariant); + } + if (time) { + time.textContent = getToastTimeLabel(createdAt); + } + if (closeButton) { + closeButton.className = 'btn-close ' + getToastButtonClass(nextVariant) + ' ms-2'; + } + } + function getBootstrapToast(toast) { if (!toast || !window.bootstrap || !window.bootstrap.Toast) { return null; @@ -30,12 +136,7 @@ return; } - var nextVariant = String(variant || 'info').trim().toLowerCase(); - var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']; - variants.forEach(function (value) { - toast.classList.remove('text-bg-' + value); - }); - toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant)); + setToastHeader(toast, variant, toast.getAttribute('data-toast-created-at') || Date.now()); } function getMessageVariant(message, fallbackVariant) { @@ -68,7 +169,9 @@ } var nextVariant = getMessageVariant(text, variant); existingToast.setAttribute('data-toast-variant', nextVariant); + existingToast.setAttribute('data-toast-created-at', String(Date.now())); setToastVariant(existingToast, nextVariant); + setToastHeader(existingToast, nextVariant, Date.now()); var existingInstance = getBootstrapToast(existingToast); if (existingInstance) { existingInstance.show(); @@ -77,18 +180,19 @@ } var toast = document.createElement('div'); - toast.className = 'toast align-items-center border-0'; + toast.className = 'toast shadow-lg border-0'; toast.id = 'app-toast'; toast.setAttribute('role', 'status'); toast.setAttribute('aria-live', 'polite'); toast.setAttribute('aria-atomic', 'true'); toast.setAttribute('data-bs-autohide', 'true'); toast.setAttribute('data-bs-delay', '4000'); - toast.innerHTML = '
'; + toast.setAttribute('data-toast-created-at', String(Date.now())); var toastVariant = getMessageVariant(text, variant); toast.setAttribute('data-toast-variant', toastVariant); - setToastVariant(toast, toastVariant); + toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant)); toast.querySelector('.toast-body').textContent = text; + setToastVariant(toast, toastVariant); toast.addEventListener('hidden.bs.toast', function () { removeToast(toast); diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 602dead..d67e278 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -135,6 +135,11 @@ module.exports = function registerAdminContentRoutes(app, deps) { return Array.from(usage); } + async function fetchSlidesByTemplateId(templateId) { + const [slides] = await pool.query('SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC', [templateId]); + return slides; + } + function getRemovedTemplateRegionKeys(existingRegions, nextRegions) { const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean)); return (Array.isArray(existingRegions) ? existingRegions : []) @@ -171,6 +176,23 @@ module.exports = function registerAdminContentRoutes(app, deps) { }); } + function queueTemplateSlideThumbnailRefresh(templateId) { + if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') { + return Promise.resolve(null); + } + + return backgroundTaskQueue.enqueueTask({ + key: 'template-slide-thumbnail:' + Number(templateId), + title: 'Refresh slide thumbnails', + category: 'slides', + taskType: 'template-slide-thumbnail-refresh', + payload: { + templateId: Number(templateId) + }, + persist: true + }); + } + async function canvasSizeExists(width, height, ignoreId) { const params = [width, height]; let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?'; @@ -524,6 +546,12 @@ module.exports = function registerAdminContentRoutes(app, deps) { previousUploadRefs: existingUploadRefs, nextUploadRefs: nextUploadRefs }); + const templateSlides = await fetchSlidesByTemplateId(template.id); + if (templateSlides.length > 0) { + queueTemplateSlideThumbnailRefresh(template.id).catch(function (error) { + console.warn('Unable to queue template slide thumbnail refresh:', error); + }); + } await notifyPlayerScreens(affectedScreens, 'refresh'); redirectAfterSave(req, res, '/templates/' + template.id + '/edit', { closeUrl: '/templates', diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index 7b0fd64..2a557eb 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -147,7 +147,6 @@ module.exports = function registerAdminRbacRoutes(app, deps) { const permissionRows = await rbacData.fetchPermissions(pool); const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id); const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id); - const users = await rbacData.fetchUsersWithRoles(pool); return { role: Object.assign({}, role, { permissionKeys: selectedPermissionKeys, @@ -155,7 +154,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) { userCount: Number(role.user_count) || 0 }), permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)), - users: mapUsersForView(users, selectedUserIds) + selectedUserIds: selectedUserIds }; } @@ -251,14 +250,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) { return res.status(404).send('Role not found.'); } + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; - const users = Array.isArray(viewModel.users) - ? viewModel.users.filter(function (user) { - return Number(user && user.id) !== currentUserId; - }) - : []; + const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, { + excludeUserId: currentUserId + }); + const users = mapUsersForView(data.users, viewModel.selectedUserIds); - res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users)); + res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages'))); } catch (error) { next(error); } diff --git a/src/web/routes/settings/background-tasks-page.js b/src/web/routes/settings/background-tasks-page.js index 1939b20..c075b79 100644 --- a/src/web/routes/settings/background-tasks-page.js +++ b/src/web/routes/settings/background-tasks-page.js @@ -91,8 +91,8 @@ function buildQueuePageViewModel(data, message, currentUser) { const tasks = (data && data.tasks) || []; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']); - const sort = String(data && data.sort || '').trim(); - const direction = normalizeSortDirection(data && data.direction); + const sort = String(data && data.sort || '').trim() || 'started'; + const direction = normalizeSortDirection(data && data.direction || 'desc'); const queryState = { page: parsePageNumber(data && data.page), sourceType: '', @@ -130,7 +130,7 @@ function buildQueuePageViewModel(data, message, currentUser) { return task && task.createdAt; } if (sort === 'started') { - return task && task.startedAt; + return task && (task.startedAt || '0001-01-01T00:00:00.000Z'); } if (sort === 'finished') { return task && task.finishedAt; diff --git a/src/web/routes/settings/rbac/edit.js b/src/web/routes/settings/rbac/edit.js index 5b6067b..575be8a 100644 --- a/src/web/routes/settings/rbac/edit.js +++ b/src/web/routes/settings/rbac/edit.js @@ -1,6 +1,6 @@ const { renderView } = require('../../../view'); -module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) { +module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) { return renderView('settings/rbac/edit', { title: 'Edit role', active: 'rbac', @@ -9,6 +9,8 @@ module.exports = function renderRbacEditPage(role, message, currentUser, permiss role: role, permissionGroups: permissionGroups || [], users: users || [], + pagination: pagination || null, + roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit', scripts: ['js/rbac-permissions.js'] }); }; \ No newline at end of file diff --git a/src/web/routes/signage/playlists/edit.js b/src/web/routes/signage/playlists/edit.js index dada636..53ef612 100644 --- a/src/web/routes/signage/playlists/edit.js +++ b/src/web/routes/signage/playlists/edit.js @@ -36,6 +36,27 @@ function formatDays(daysJson) { return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', '); } +function normalizeScheduleDaysJson(daysJson) { + if (Array.isArray(daysJson)) { + return JSON.stringify(daysJson.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)); + } + + if (!daysJson) { + return '[]'; + } + + try { + const parsed = JSON.parse(daysJson); + if (Array.isArray(parsed)) { + return JSON.stringify(parsed.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)); + } + } catch (_error) { + return JSON.stringify(String(daysJson).split(',').map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)); + } + + return '[]'; +} + function scheduleSummary(item) { const mode = String(item.schedule_mode || 'always'); if (mode === 'dates') { @@ -160,6 +181,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren videoDurationSeconds: getVideoDurationSeconds(item), useVideoDuration: Boolean(item.use_video_duration), durationSeconds: getVideoDurationValue(item), + schedule_days_json: normalizeScheduleDaysJson(item.schedule_days_json), scheduleSummary: scheduleSummary(item) })); const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id)); diff --git a/src/web/views/settings/rbac/edit.hbs b/src/web/views/settings/rbac/edit.hbs index 6f49999..a4cb291 100644 --- a/src/web/views/settings/rbac/edit.hbs +++ b/src/web/views/settings/rbac/edit.hbs @@ -25,7 +25,7 @@ - -
-
+
+

Users

+
+ + +
-
+

Choose the users who should belong to this role.

- {{#if users.length}} -
- - - - - - - +
SelectNameUsernameRoles
+ + + + + + + + + + {{#if users.length}} + {{#each users}} + + + + + - - - {{#each users}} - - - - - - - {{/each}} - -
SelectUsernameNameRoles
+ + +
+
{{username}}
+
+
{{#if name}}{{name}}{{else}}-{{/if}}{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}
- - {{name}}{{username}}{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}
-
- {{else}} -
-
Create at least one user before assigning this role.
-
- {{/if}} + {{/each}} + {{else}} + No users found. + {{/if}} + +
+ {{> table-pagination pagination=pagination basePath=roleEditBasePath}}
diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs index 045874b..89516ce 100644 --- a/src/web/views/shared/layout.hbs +++ b/src/web/views/shared/layout.hbs @@ -33,13 +33,26 @@ {{else}} -
+
{{#if message}} -
-
-
{{message}}
- +
+
+ {{#if (eq messageVariant 'success')}} + + {{else if (eq messageVariant 'warning')}} + + {{else if (eq messageVariant 'danger')}} + + {{else if (eq messageVariant 'primary')}} + + {{else}} + + {{/if}} + Pulse + just now +
+
{{message}}
{{/if}}