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 '
Choose the users who should belong to this role.
| Select | -Name | -Username | -Roles | +||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Select | +Username | +Name | +Roles | +
|---|---|---|---|
| + + | +
+
+
+ {{username}}
+ |
+ {{#if name}}{{name}}{{else}}-{{/if}} | +{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}} |
| - - | -{{name}} | -{{username}} | -{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}} | -