Release v2.4.2

This commit is contained in:
2026-08-03 12:45:27 +01:00
parent 2b9cabdab2
commit e95928f31c
141 changed files with 7694 additions and 5078 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ module.exports = function registerAccountRoutes(app, deps) {
const setSessionCookie = deps.setSessionCookie;
app.get('/account', function (req, res) {
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard'));
});
app.post('/account/name', async function (req, res, next) {
@@ -32,7 +32,7 @@ module.exports = function registerAccountRoutes(app, deps) {
return res.status(404).send('User not found.');
}
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.'));
} catch (error) {
next(error);
}
@@ -68,7 +68,7 @@ module.exports = function registerAccountRoutes(app, deps) {
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
res.redirect('/dashboard?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
+99
View File
@@ -1,12 +1,16 @@
// Admin client command routes for connected screens.
const { commitDeviceBinding } = require('#src/player/onboarding');
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const requirePermission = deps.requirePermission;
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
@@ -138,6 +142,101 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
});
}
if (command === 'moveclient') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
if (!targetScreenSlug) {
return res.status(400).json({ error: 'Target screen is required' });
}
const [currentRows] = await pool.query(
`SELECT d.client_name, s.slug AS current_screen_slug
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?
LIMIT 1`,
[deviceId]
);
const onboardingRow = currentRows[0] || null;
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
if (!resolvedClientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: resolvedClientName,
targetScreenSlug: targetScreenSlug,
ok: true,
unchanged: true
});
}
if (typeof commitDeviceBinding !== 'function') {
return res.status(500).json({ error: 'Client binding is unavailable.' });
}
let liveConnections = [];
try {
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
const screenSlug = String(row && row.slug ? row.slug : '').trim();
if (!screenSlug || typeof getScreenConnections !== 'function') {
return [];
}
try {
const liveResponse = await getScreenConnections(screenSlug);
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
} catch (_error) {
return [];
}
}));
liveConnections = liveResults.flat();
} catch (_error) {
liveConnections = [];
}
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
: null;
const targetBaseUrl = String(targetPlayerRecord && targetPlayerRecord.public_base_url || playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
await forwardPlayerCommand(slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: status ? status.client_name : resolvedClientName,
targetScreenSlug: targetScreenSlug,
playerUrl: targetPlayerUrl,
ok: true
});
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: { command: command };
+38 -44
View File
@@ -2,7 +2,8 @@
const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('../../lib/media/font-library');
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
@@ -27,12 +28,39 @@ module.exports = function registerContentRoutes(app, deps) {
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
const requirePermission = deps.requirePermission;
const hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const LIST_PAGE_SIZE = 25;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
async function fetchSlideFormData() {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
return Object.assign(data, rssData, apiData, timetableData, {
rssFeeds: rssFeeds,
apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [],
fontLibrary: loadFontLibrary(deps.uploadDir)
});
}
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
throw new Error('registerContentRoutes requires the content route dependencies.');
}
@@ -68,7 +96,7 @@ module.exports = function registerContentRoutes(app, deps) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -271,25 +299,8 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
const data = await fetchSlideFormData();
res.send(pages.renderSlideAddPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -302,25 +313,8 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(404).send('Slide not found');
}
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
const data = await fetchSlideFormData();
res.send(pages.renderSlideEditPage(data, slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -484,7 +478,7 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
} catch (error) {
next(error);
}
@@ -536,7 +530,7 @@ module.exports = function registerContentRoutes(app, deps) {
template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -642,7 +636,7 @@ module.exports = function registerContentRoutes(app, deps) {
});
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
+7 -826
View File
@@ -1,172 +1,10 @@
// Admin data-source route registration for API and RSS sources.
// Root data-source redirect only.
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerDataSourceRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const setAuthMessageCookie = deps.setAuthMessageCookie;
const { hasAnyPermission } = require('#src/rbac');
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const requirePermission = deps.requirePermission;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const { buildPagination } = require('#src/web/lib/pagination');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const LIST_PAGE_SIZE = 25;
function toIsoTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
function slideUsesDataSourceField(content, fieldName, targetId) {
const normalizedTargetId = Number(targetId);
if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') {
return false;
}
if (Array.isArray(content)) {
return content.some(function (item) {
return slideUsesDataSourceField(item, fieldName, normalizedTargetId);
});
}
if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) {
return true;
}
return Object.keys(content).some(function (key) {
return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId);
});
}
async function getDataSourceDeleteBlockMessage(fieldName, dataSourceId, label) {
const isInUse = await getDataSourceInUse(fieldName, dataSourceId);
if (isInUse) {
return 'This ' + label + ' is still used by one or more slides.';
}
return '';
}
async function getDataSourceInUse(fieldName, dataSourceId) {
const [slides] = await pool.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (slideUsesDataSourceField(content, fieldName, dataSourceId)) {
return true;
}
}
return false;
}
async function getDataSourceUsageMaps() {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const apiSourceIds = new Set();
const rssFeedIds = new Set();
const scheduleGroupIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
const sourceId = Number(value.source_id);
if (Number.isFinite(sourceId)) {
apiSourceIds.add(sourceId);
}
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
const feedId = Number(value.feed_id);
if (Number.isFinite(feedId)) {
rssFeedIds.add(feedId);
}
}
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
const scheduleGroupId = Number(value.schedule_group_id);
if (Number.isFinite(scheduleGroupId)) {
scheduleGroupIds.add(scheduleGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return {
apiSourceIds: apiSourceIds,
rssFeedIds: rssFeedIds,
scheduleGroupIds: scheduleGroupIds
};
}
function readScheduleArrayField(body, key) {
if (!body || !Object.prototype.hasOwnProperty.call(body, key)) {
return [];
}
const value = body[key];
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
}
const dataSourceTasks = createDataSourceTaskService({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue
});
async function sendRefreshTaskState(req, res, sourceType, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
app.get('/data-sources', function (req, res, next) {
if (!req.currentUser) {
@@ -177,9 +15,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['schedules.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['schedules.read'])) {
return res.redirect('/data-sources/schedules');
if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['timetables.read'])) {
return res.redirect('/data-sources/timetables');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
return res.redirect('/data-sources/rss-feeds');
@@ -187,666 +25,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/data-sources/api-sources');
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
});
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
try {
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 data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
inUse: usageMaps.apiSourceIds.has(Number(apiSource.id))
});
});
res.send(pages.renderApiSourcesPage({
apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildApiSourcePayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + result.insertId,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'API source created. Refresh is running in the background.';
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const inUse = await getDataSourceInUse('source_id', apiSource.id);
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
apiUrl: apiSource.api_url,
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
lastPullError: apiSource.last_pull_error || '',
lastResponseStatus: apiSource.last_response_status,
lastResponseContentType: apiSource.last_response_content_type,
lastResponseJson: apiSource.last_response_json || '',
inUse: inUse
}), {
lastResponseJson: apiSource.last_response_json || '',
lastPullError: apiSource.last_pull_error || ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id)).catch(next);
});
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const payload = common.buildApiSourcePayload(req, apiSource);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
});
const message = 'API source updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + apiSource.id,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('source_id', apiSource.id, 'API source');
if (blockMessage) {
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try {
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 data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
return Object.assign({}, rssFeed, {
inUse: usageMaps.rssFeedIds.has(Number(rssFeed.id))
});
});
res.send(pages.renderRssFeedsPage({
rssFeeds: rssFeeds,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + result.insertId,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'RSS feed created. Refresh is running in the background.';
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const inUse = await getDataSourceInUse('feed_id', rssFeed.id);
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
: [];
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit,
inUse: inUse
}), {
pulledItems: pulledItems,
pullError: ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id)).catch(next);
});
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const payload = common.buildRssFeedPayload(req, rssFeed);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
});
const message = 'RSS feed updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + rssFeed.id,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('feed_id', rssFeed.id, 'RSS feed');
if (blockMessage) {
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules', requirePermission('schedules.read'), async function (req, res, next) {
try {
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 data = await common.fetchScheduleGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
inUse: usageMaps.scheduleGroupIds.has(Number(scheduleGroup.id))
});
});
res.send(pages.renderScheduleGroupsPage({
scheduleGroups: scheduleGroups,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Schedules', 'Schedule groups')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules/new', requirePermission('schedules.create'), function (req, res) {
res.send(pages.renderScheduleGroupFormPage(null, [], 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
function readScheduleArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null) {
return [value];
}
}
return [];
}
function buildScheduleEntryRows(req) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
error.statusCode = 400;
throw error;
}
const rows = [];
for (let index = 0; index < titles.length; index += 1) {
const idValue = Number(ids[index]);
const title = String(titles[index] || '').trim();
const shortDescription = String(descriptions[index] || '').trim();
const startDatetime = parseDateTimeLocal(starts[index]);
const endDatetime = parseDateTimeLocal(ends[index]);
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
if (!hasContent) {
continue;
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
rows.push({
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
title: title,
shortDescription: shortDescription,
startDatetime: startDatetime,
endDatetime: endDatetime
});
}
return rows;
}
app.post('/data-sources/schedules', requirePermission('schedules.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildScheduleGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
return res.redirect('/data-sources/schedules/new?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
result.insertId,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/schedules/' + result.insertId + '/edit', {
closeUrl: '/data-sources/schedules',
newUrl: '/data-sources/schedules/new',
message: 'Schedule group created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/schedules/:id/edit', requirePermission('schedules.update'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const inUse = await getDataSourceInUse('schedule_group_id', scheduleGroup.id);
const scheduleEntries = typeof common.fetchScheduleEntriesByGroupId === 'function'
? await common.fetchScheduleEntriesByGroupId(pool, scheduleGroup.id)
: [];
res.send(pages.renderScheduleGroupFormPage(Object.assign({}, scheduleGroup, {
shortDescription: scheduleGroup.short_description || '',
inUse: inUse
}), scheduleEntries, 'edit', req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/data-sources/schedules/:id', requirePermission('schedules.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const payload = common.buildScheduleGroupPayload(req, scheduleGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, scheduleGroup.id)) {
return res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, scheduleGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [scheduleGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
scheduleGroup.id,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
const message = 'Schedule group updated.';
res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent(message));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/schedules/:id/delete', requirePermission('schedules.delete'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('schedule_group_id', scheduleGroup.id, 'schedule group');
if (blockMessage) {
return res.redirect('/data-sources/schedules?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [scheduleGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/schedules?message=' + encodeURIComponent('Schedule group deleted.'));
} catch (error) {
next(error);
}
});
};
+1 -466
View File
@@ -1,24 +1,15 @@
// Admin manage routes for screens, playlists, slides, and templates.
// Admin manage routes for screens and commands.
module.exports = function registerManageRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature;
const getCanvasSignature = deps.getCanvasSignature;
const normalizeScheduleMode = deps.normalizeScheduleMode;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const parseTimeLocal = deps.parseTimeLocal;
const readArrayField = deps.readArrayField;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const getScreenConnections = deps.getScreenConnections;
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
const requirePermission = deps.requirePermission;
@@ -83,462 +74,6 @@ module.exports = function registerManageRoutes(app, deps) {
}
});
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
return res.status(400).send('A playlist with that name already exists.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
});
} catch (error) {
next(error);
}
});
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']);
const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']);
const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']);
if (durations.length && durations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
return res.status(400).send('Playlist schedule data is invalid.');
}
const normalizedSlides = [];
const seenSlideIds = new Set();
for (let i = 0; i < slideIds.length; i += 1) {
const slideId = Number(slideIds[i]);
if (!Number.isInteger(slideId) || slideId <= 0) {
return res.status(400).send('Invalid slide selection.');
}
if (seenSlideIds.has(slideId)) {
return res.status(400).send('A slide can only be added to a playlist once.');
}
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]);
scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
return res.status(400).send('Start and end datetimes are required for date scheduling.');
}
if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]);
scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]);
if (!scheduleStartTime || !scheduleEndTime) {
return res.status(400).send('Start and end times are required for time scheduling.');
}
let scheduleDays = [];
try {
const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]'));
scheduleDays = Array.isArray(parsedDays) ? parsedDays : [];
} catch (_error) {
scheduleDays = [];
}
scheduleDays = scheduleDays
.map(function (value) { return Number(value); })
.filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; });
if (!scheduleDays.length) {
return res.status(400).send('Select at least one day for time scheduling.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
normalizedSlides.push({
slideId,
position: i,
durationSeconds,
useVideoDuration,
scheduleMode,
scheduleStartDatetime,
scheduleEndDatetime,
scheduleStartTime,
scheduleEndTime,
scheduleDaysJson
});
}
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_slides sl
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE sl.id IN (?)`,
[normalizedSlides.map(function (item) { return item.slideId; })]
);
if (slides.length !== normalizedSlides.length) {
return res.status(400).send('One or more selected slides no longer exist.');
}
const signatures = Array.from(new Set(
slides
.map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); })
.filter(Boolean)
));
if (signatures.length > 1) {
return res.status(400).send('All playlist slides must share the same canvas size.');
}
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
playlist.id,
item.slideId,
item.position,
item.durationSeconds,
item.useVideoDuration,
item.scheduleMode,
item.scheduleStartDatetime,
item.scheduleEndDatetime,
item.scheduleStartTime,
item.scheduleEndTime,
item.scheduleDaysJson,
actorId,
actorId
]
);
}
await connection.commit();
await notifyPlayerScreens(affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
if (blockMessage) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const slideId = Number(req.body.slide_id);
if (!slideId) {
return res.status(400).send('Slide is required.');
}
const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id);
if (playlistCanvasSignature === 'mismatch') {
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
return res.status(404).send('Slide not found');
}
const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height);
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const direction = String(req.body.direction || '').toLowerCase();
if (direction !== 'up' && direction !== 'down') {
return res.status(400).send('Invalid move direction.');
}
await connection.beginTransaction();
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
const currentIndex = orderedSlides.findIndex(function (item) {
return Number(item.id) === playlistSlideId;
});
if (currentIndex === -1) {
await connection.rollback();
return res.status(404).send('Playlist slide not found');
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
let playlistSlide = (data.playlistSlides || []).find(function (item) {
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
});
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
return res.status(404).send('Playlist slide not found');
}
const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always');
const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null;
const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null;
const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null;
const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null;
const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]';
const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || [];
playlistSlide = Object.assign({}, playlistSlide || {}, {
id: playlistSlide ? playlistSlide.id : 0,
schedule_mode: scheduleMode,
schedule_days_json: scheduleDaysJson,
schedule_days: scheduleDays,
schedule_start_datetime: scheduleStartDatetime,
schedule_end_datetime: scheduleEndDatetime,
schedule_start_time: scheduleStartTime,
schedule_end_time: scheduleEndTime
});
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const rowKey = String(req.body.row_key || '').trim();
if (rowKey) {
return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.');
}
const playlistSlideId = Number(req.params.playlistSlideId);
let scheduleMode = normalizeScheduleMode(req.body.schedule_mode);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime);
const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time);
const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length);
if (scheduleMode === 'dates' && !hasDateRange) {
scheduleMode = 'always';
} else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) {
scheduleMode = 'always';
}
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime);
scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
scheduleMode = 'always';
scheduleStartDatetime = null;
scheduleEndDatetime = null;
} else if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(req.body.schedule_start_time);
scheduleEndTime = parseTimeLocal(req.body.schedule_end_time);
const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value >= 0 && value <= 6;
});
if (!scheduleStartTime || !scheduleEndTime) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else if (!scheduleDays.length) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else {
if (scheduleEndTime < scheduleStartTime) {
return res.status(400).send('End time must be after start time.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
try {
const data = await common.fetchScreenEditData(pool);
+642
View File
@@ -0,0 +1,642 @@
// Playlist admin routes and playlist-slide management.
module.exports = function registerPlaylistRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId;
const readArrayField = deps.readArrayField;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const requirePermission = deps.requirePermission;
function readRawFieldArray(reqBody, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
if (!reqBody || !Object.prototype.hasOwnProperty.call(reqBody, key)) {
continue;
}
const value = reqBody[key];
if (Array.isArray(value)) {
return value.map(function (item) {
return item === undefined || item === null ? '' : String(item);
});
}
return [value === undefined || value === null ? '' : String(value)];
}
return [];
}
function normalizeScheduleRuleValuesFromBody(reqBody, index) {
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
const startDatetime = String(startDatetimeValues[index] || '').trim() || null;
const endDatetime = String(endDatetimeValues[index] || '').trim() || null;
const startTime = String(startTimeValues[index] || '').trim() || null;
const endTime = String(endTimeValues[index] || '').trim() || null;
const daysCsv = String(daysCsvValues[index] || '').trim();
const days = daysCsv
? Array.from(new Set(daysCsv.split(',').map(function (value) { return Number(value); }).filter(function (day) {
return Number.isInteger(day) && day >= 0 && day <= 6;
}))).sort(function (left, right) { return left - right; })
: [];
return {
start_datetime: startDatetime,
end_datetime: endDatetime,
start_time: startTime,
end_time: endTime,
schedule_days_json: days.length ? JSON.stringify(days) : null
};
}
function buildScheduleRulesFromRequestBody(reqBody) {
const rowKeys = readRawFieldArray(reqBody, ['schedule_rule_row_key[]', 'schedule_rule_row_key']);
const positions = readRawFieldArray(reqBody, ['schedule_rule_position[]', 'schedule_rule_position']);
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
const lengths = [rowKeys.length, positions.length, startDatetimeValues.length, endDatetimeValues.length, startTimeValues.length, endTimeValues.length, daysCsvValues.length]
.filter(function (value) { return value > 0; });
if (lengths.length && lengths.some(function (length) { return length !== lengths[0]; })) {
return null;
}
return rowKeys.map(function (rowKey, index) {
const rule = normalizeScheduleRuleValuesFromBody(reqBody, index);
if (!rule.start_datetime && !rule.end_datetime && !rule.start_time && !rule.end_time && !rule.schedule_days_json) {
return null;
}
return {
rowKey: String(rowKey || '').trim(),
position: Number(positions[index] || index) || 0,
startDatetime: rule.start_datetime,
endDatetime: rule.end_datetime,
startTime: rule.start_time,
endTime: rule.end_time,
daysJson: rule.schedule_days_json
};
}).filter(Boolean);
}
async function replacePlaylistSlideScheduleRules(connection, playlistSlideId, scheduleRules, actorId) {
await connection.query('DELETE FROM c_playlist_slide_schedule_rules WHERE playlist_slide_id = ?', [playlistSlideId]);
for (let index = 0; index < scheduleRules.length; index += 1) {
const rule = scheduleRules[index];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlistSlideId, rule.position || index, rule.startDatetime, rule.endDatetime, rule.startTime, rule.endTime, rule.daysJson, actorId, actorId]
);
}
}
function createValidationError(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
async function savePlaylistItems(connection, playlist, reqBody, actorId, options) {
const shouldUpdatePlaylist = !options || options.updatePlaylist !== false;
const name = String(reqBody.name || '').trim();
const fadeBetweenSlides = reqBody.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = reqBody.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(reqBody.canvas_size_id);
let requestedCanvasId = null;
if (!name) {
throw createValidationError('Playlist name is required.');
}
if (shouldUpdatePlaylist && reqBody.canvas_size_id !== undefined && reqBody.canvas_size_id !== null && String(reqBody.canvas_size_id).trim() !== '') {
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
throw createValidationError('Canvas size is invalid.');
}
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
if (!canvasSize) {
throw createValidationError('Canvas size not found.');
}
requestedCanvasId = Number(canvasSize.id);
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
throw new Error('Canvas size cannot be changed after the playlist is created.');
}
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
const slideIds = readArrayField(reqBody, ['slide_id[]', 'slide_id']);
const rowKeys = readRawFieldArray(reqBody, ['row_key[]', 'row_key']);
const durations = readArrayField(reqBody, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(reqBody, ['use_video_duration[]', 'use_video_duration']);
const disableAudios = readArrayField(reqBody, ['disable_audio[]', 'disable_audio']);
const scheduleRules = buildScheduleRulesFromRequestBody(reqBody);
if (durations.length && durations.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (disableAudios.length && disableAudios.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (rowKeys.length && rowKeys.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
const scheduleRulesByRowKey = new Map();
if (scheduleRules === null) {
throw createValidationError('Playlist schedule data is invalid.');
}
scheduleRules.forEach(function (rule) {
const rowKey = String(rule.rowKey || '').trim();
if (!scheduleRulesByRowKey.has(rowKey)) {
scheduleRulesByRowKey.set(rowKey, []);
}
scheduleRulesByRowKey.get(rowKey).push(rule);
});
const normalizedSlides = [];
const seenSlideIds = new Set();
for (let i = 0; i < slideIds.length; i += 1) {
const slideId = Number(slideIds[i]);
if (!Number.isInteger(slideId) || slideId <= 0) {
throw createValidationError('Invalid slide selection.');
}
if (seenSlideIds.has(slideId)) {
throw createValidationError('A slide can only be added to a playlist once.');
}
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const disableAudio = String(disableAudios[i] || '') !== '0' ? 1 : 0;
const rowKey = String(rowKeys[i] || '').trim();
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
for (const rule of playlistScheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
throw createValidationError('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
throw createValidationError('End time must be after start time.');
}
}
normalizedSlides.push({
slideId,
rowKey,
position: i,
durationSeconds,
useVideoDuration,
disableAudio,
scheduleRules: playlistScheduleRules
});
}
let selectedCanvasId = null;
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, st.canvas_size_id AS canvas_size_id
FROM c_slides sl
LEFT JOIN c_templates st ON st.id = sl.template_id
WHERE sl.id IN (?)`,
[normalizedSlides.map(function (item) { return item.slideId; })]
);
if (slides.length !== normalizedSlides.length) {
throw createValidationError('One or more selected slides no longer exist.');
}
const signatures = Array.from(new Set(
slides
.map(function (slide) { return Number(slide.canvas_size_id); })
.filter(function (value) { return Number.isInteger(value) && value > 0; })
));
selectedCanvasId = signatures.length === 1 ? signatures[0] : null;
if (signatures.length > 1) {
throw createValidationError('All playlist slides must share the same canvas size.');
}
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && selectedCanvasId && Number(playlist.canvas_id) !== selectedCanvasId) {
throw createValidationError('All playlist slides must match the playlist canvas size.');
}
}
const nextCanvasId = Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0
? Number(playlist.canvas_id)
: requestedCanvasId || selectedCanvasId || null;
if (shouldUpdatePlaylist) {
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]);
}
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
const [insertResult] = await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, actorId, actorId]
);
const insertedPlaylistSlideId = Number(insertResult.insertId);
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
const rule = itemRules[ruleIndex];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId]
);
}
}
return {
affectedScreens: affectedScreens,
nextCanvasId: nextCanvasId,
selectedCanvasId: selectedCanvasId
};
}
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(req.body.canvas_size_id);
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
return res.status(400).send('Canvas size is required.');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
return res.status(400).send('A playlist with that name already exists.');
}
const canvasSize = await common.fetchCanvasSizeById(pool, canvasSizeId);
if (!canvasSize) {
return res.status(400).send('Canvas size not found.');
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, canvasSize.id, actorId, actorId]);
const playlist = {
id: Number(result.insertId),
canvas_id: Number(canvasSize.id)
};
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
await connection.commit();
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
if (error && error.statusCode === 400) {
return res.status(400).send(error.message || 'Playlist data is invalid.');
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(req.body.canvas_size_id);
const actorId = getAuditUserId(req);
let requestedCanvasId = null;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
if (req.body.canvas_size_id !== undefined && req.body.canvas_size_id !== null && String(req.body.canvas_size_id).trim() !== '') {
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
return res.status(400).send('Canvas size is invalid.');
}
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
if (!canvasSize) {
return res.status(400).send('Canvas size not found.');
}
requestedCanvasId = Number(canvasSize.id);
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
return res.status(400).send('Canvas size cannot be changed after the playlist is created.');
}
}
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
if (error && error.statusCode === 400) {
return res.status(400).send(error.message || 'Playlist data is invalid.');
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
if (blockMessage) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const slideId = Number(req.body.slide_id);
if (!slideId) {
return res.status(400).send('Slide is required.');
}
const playlistCanvasId = await fetchPlaylistCanvasId(pool, playlist.id);
if (playlistCanvasId === 'mismatch') {
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
return res.status(404).send('Slide not found');
}
const slideCanvasId = Number(slide.canvas_size_id);
if (playlistCanvasId && slideCanvasId !== playlistCanvasId) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, 1, actorId, actorId]);
if (!Number.isInteger(Number(playlist.canvas_id)) || Number(playlist.canvas_id) <= 0) {
await pool.query('UPDATE c_playlists SET canvas_id = ?, modified_by = ? WHERE id = ?', [slideCanvasId, actorId, playlist.id]);
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [currentRows] = await pool.query('SELECT disable_audio FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [playlistSlideId, playlist.id]);
if (!currentRows.length) {
return res.status(404).send('Playlist slide not found');
}
const disableAudio = req.body.disable_audio === undefined ? (Number(currentRows[0].disable_audio) ? 1 : 0) : (String(req.body.disable_audio || '') === '0' ? 0 : 1);
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET duration_seconds = ?, disable_audio = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, disableAudio, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide config updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const direction = String(req.body.direction || '').toLowerCase();
if (direction !== 'up' && direction !== 'down') {
return res.status(400).send('Invalid move direction.');
}
await connection.beginTransaction();
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
const currentIndex = orderedSlides.findIndex(function (item) {
return Number(item.id) === playlistSlideId;
});
if (currentIndex === -1) {
await connection.rollback();
return res.status(404).send('Playlist slide not found');
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
app.get('/playlists/new/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
return String(key || '').indexOf('schedule_rule_') === 0;
});
const scheduleRules = hasQueryScheduleRules
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
let days = [];
try {
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
} catch (_error) {
days = [];
}
return {
start_datetime: rule.startDatetime,
end_datetime: rule.endDatetime,
start_time: rule.startTime,
end_time: rule.endTime,
days: Array.isArray(days) ? days : []
};
})
: [];
return res.send(pages.renderPlaylistSlideConfigPage({ id: null }, {
id: 0,
scheduleRules: scheduleRules
}, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlistId = Number(req.params.id);
if (!Number.isInteger(playlistId) || playlistId <= 0) {
return res.status(404).send('Playlist not found');
}
const playlist = await common.fetchPlaylistById(pool, playlistId);
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
let playlistSlide = (data.playlistSlides || []).find(function (item) {
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
});
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
return res.status(404).send('Playlist slide not found');
}
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
return String(key || '').indexOf('schedule_rule_') === 0;
});
const scheduleRules = hasQueryScheduleRules
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
let days = [];
try {
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
} catch (_error) {
days = [];
}
return {
start_datetime: rule.startDatetime,
end_datetime: rule.endDatetime,
start_time: rule.startTime,
end_time: rule.endTime,
days: Array.isArray(days) ? days : []
};
})
: (Array.isArray(playlistSlide && playlistSlide.scheduleRules) ? playlistSlide.scheduleRules : []);
playlistSlide = Object.assign({}, playlistSlide || {}, {
id: playlistSlide ? playlistSlide.id : 0,
scheduleRules: scheduleRules
});
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const actorId = getAuditUserId(req);
const scheduleRules = buildScheduleRulesFromRequestBody(req.body);
if (scheduleRules === null) {
return res.status(400).send('Schedule rules are invalid.');
}
for (const rule of scheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
return res.status(400).send('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
return res.status(400).send('End time must be after start time.');
}
}
const [result] = await pool.query(
'UPDATE c_playlist_slides SET modified_by = ? WHERE id = ? AND playlist_id = ?',
[actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await replacePlaylistSlideScheduleRules(pool, playlistSlideId, scheduleRules, actorId);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
};
+38 -8
View File
@@ -129,14 +129,19 @@
return groups;
}
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId) {
const permissionRows = await rbacData.fetchPermissions(pool);
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, {
excludeUserId: currentUserId
});
return {
formValues: {
name: String(formValues && formValues.name || '').trim(),
description: String(formValues && formValues.description || '').trim()
},
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(data.users, selectedUserIds),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')
};
}
@@ -177,11 +182,16 @@
});
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
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;
buildRoleCreateViewModel({
name: String(req.query.name || '').trim(),
description: String(req.query.description || '').trim()
}, []).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
}, [], [], page, search, sort, direction, currentUserId).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
}).catch(function (error) {
next(error);
});
@@ -191,6 +201,10 @@
try {
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
@@ -199,18 +213,33 @@
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
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 createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
if (await common.fetchDuplicateName(pool, 'a_roles', name)) {
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
if (selectedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const availableUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (normalizedUserIds.some(function (userId) {
return !availableUserIds.has(userId);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected users are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
const roleKey = await createUniqueRoleKey(name);
@@ -227,6 +256,7 @@
if (selectedPermissionKeys.length) {
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
}
await rbacData.syncRoleUsers(connection, insertedRoleId, normalizedUserIds);
await connection.commit();
} catch (error) {
await connection.rollback();
+61 -14
View File
@@ -123,7 +123,7 @@
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
const username = String(req.body.username || user.username || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
@@ -171,7 +171,7 @@
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
res.redirect('/users?message=' + encodeURIComponent('User created.'));
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
} catch (error) {
try {
await connection.rollback();
@@ -213,46 +213,93 @@
});
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const userId = Number(req.params.id);
const name = String(req.body.name || '').trim();
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const shouldUpdatePassword = Boolean(password || confirmPassword);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (!name) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [userRows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!userRows.length) {
const user = await rbacData.fetchUserWithRoles(pool, userId);
if (!user) {
return res.status(404).send('User not found.');
}
const username = String(req.body.username || userRows[0].username || '').trim();
if (!username) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
const username = String(req.body.username || user.username || '').trim();
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
const roleCheck = await validateRoleIds(selectedRoleIds);
async function renderValidationError(message) {
const roles = await fetchRoleOptions();
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
name: name || user.name,
username: username || user.username,
roleIds: selectedRoleIds,
inUse: !canDelete
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
}
if (!name) {
return renderValidationError('Name is required.');
}
if (!username) {
return renderValidationError('Username is required.');
}
if (shouldUpdatePassword && (!password || password.length < 8)) {
return renderValidationError('Password must be at least 8 characters.');
}
if (shouldUpdatePassword && password !== confirmPassword) {
return renderValidationError('Passwords do not match.');
}
if (!roleCheck.ok) {
return renderValidationError(roleCheck.message);
}
if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
return renderValidationError('That name already exists.');
}
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
if (existingRows.length) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
return renderValidationError('That username already exists.');
}
const [result] = await pool.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
await connection.beginTransaction();
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
await connection.rollback();
return res.status(404).send('User not found.');
}
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
if (shouldUpdatePassword) {
const passwordRecord = hashPassword(password);
await connection.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
await connection.commit();
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
+2 -1
View File
@@ -7,6 +7,7 @@ module.exports = function renderLoginPage(message) {
title: 'Sign in',
authShell: true,
bodyClass: 'login-page-body',
message: message || ''
message: message || '',
messageVariant: 'warning'
});
};
+4 -37
View File
@@ -1,41 +1,8 @@
// API source add page renderer and form defaults.
// API source add page renderer.
const { renderView } = require('../../../view');
const { buildApiSourceFormViewModel } = require('./form-view-model');
function buildDefaultApiSource() {
return {
id: null,
name: '',
apiUrl: '',
authMethod: 'none',
authUsername: '',
authPassword: '',
authBearerToken: '',
authHeaderName: 'X-API-Key',
authHeaderValue: '',
itemsPath: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
lastPulledAt: null,
lastPullError: '',
lastResponseStatus: null,
lastResponseContentType: '',
lastResponseJson: ''
};
}
module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', {
title: isEdit ? 'Edit API source' : 'Add API source',
active: 'api-sources',
message: message,
currentUser: currentUser || null,
apiSource: viewApiSource,
inUse: Boolean(viewApiSource.inUse),
lastResponseJson: viewApiSource.lastResponseJson || '',
lastPullError: viewApiSource.lastPullError || ''
});
module.exports = function renderApiSourceAddPage(apiSource, message, currentUser, options) {
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, options, false));
};
@@ -1,6 +1,7 @@
// API source edit page renderer that reuses the add form.
// API source edit page renderer.
const renderApiSourceFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildApiSourceFormViewModel } = require('./form-view-model');
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
const viewData = Object.assign({
@@ -8,7 +9,7 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
lastPullError: ''
}, data || {});
return renderApiSourceFormPage(Object.assign({}, apiSource, {
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(Object.assign({}, apiSource, {
lastResponseJson: viewData.lastResponseJson,
lastPullError: viewData.lastPullError,
authMethod: apiSource.auth_method || 'none',
@@ -18,5 +19,5 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || ''
}), 'edit', message, currentUser);
}), message, currentUser, viewData, true));
};
@@ -0,0 +1,50 @@
// Shared API source form view-model builder.
function buildDefaultApiSource() {
return {
id: null,
name: '',
apiUrl: '',
authMethod: 'none',
authUsername: '',
authPassword: '',
authBearerToken: '',
authHeaderName: 'X-API-Key',
authHeaderValue: '',
itemsPath: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
lastPulledAt: null,
lastPullError: '',
lastResponseStatus: null,
lastResponseContentType: '',
lastResponseJson: ''
};
}
function buildApiSourceFormViewModel(apiSource, message, currentUser, options, isEdit) {
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
const viewOptions = options || {};
return {
title: isEdit ? 'Edit API source' : 'Add API source',
active: 'api-sources',
message: message,
currentUser: currentUser || null,
apiSource: viewApiSource,
inUse: Boolean(viewApiSource.inUse),
lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '',
lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '',
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
formAttrs: isEdit && viewApiSource.id ? 'data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/' + viewApiSource.id + '/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' : 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/api-sources/new"',
cancelUrl: '/data-sources/api-sources',
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
};
}
module.exports = {
buildApiSourceFormViewModel: buildApiSourceFormViewModel
};
@@ -0,0 +1,326 @@
// API source route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderApiSourcesPage = require('./list');
const renderApiSourceAddPage = require('./add');
const renderApiSourceEditPage = require('./edit');
function toIsoTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const apiSourceIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
const sourceId = Number(value.source_id);
if (Number.isFinite(sourceId)) {
apiSourceIds.add(sourceId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return apiSourceIds;
}
async function isApiSourceInUse(pool, common, apiSourceId) {
const usageIds = await getDataSourceUsageMaps(pool, common);
return usageIds.has(Number(apiSourceId));
}
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = 'api-source-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
module.exports = function registerApiSourceRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const dataSourceTasks = deps.dataSourceTasks;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
try {
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 data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageIds = await getDataSourceUsageMaps(pool, common);
const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
inUse: usageIds.has(Number(apiSource.id))
});
});
res.send(renderApiSourcesPage({
apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
res.send(renderApiSourceAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const inUse = (slides || []).some(function (row) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
if (!content || typeof content !== 'object') {
return false;
}
const stack = [content];
while (stack.length) {
const value = stack.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (Array.isArray(value)) {
value.forEach(function (item) { stack.push(item); });
continue;
}
if (Object.prototype.hasOwnProperty.call(value, 'source_id') && Number(value.source_id) === Number(apiSource.id)) {
return true;
}
Object.keys(value).forEach(function (key) {
stack.push(value[key]);
});
}
return false;
});
res.send(renderApiSourceEditPage(Object.assign({}, apiSource, {
apiUrl: apiSource.api_url,
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
lastPullError: apiSource.last_pull_error || '',
lastResponseStatus: apiSource.last_response_status,
lastResponseContentType: apiSource.last_response_content_type,
lastResponseJson: apiSource.last_response_json || '',
inUse: inUse
}), {
lastResponseJson: apiSource.last_response_json || '',
lastPullError: apiSource.last_pull_error || ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
});
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildApiSourcePayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + result.insertId,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'API source created. Refresh is running in the background.';
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const payload = common.buildApiSourcePayload(req, apiSource);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
});
const message = 'API source updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + apiSource.id,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name
}
});
redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
closeUrl: '/data-sources/api-sources',
newUrl: '/data-sources/api-sources/new',
message: message
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
if (await isApiSourceInUse(pool, common, apiSource.id)) {
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('This API source is still used by one or more slides.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
} catch (error) {
next(error);
}
});
};
-4
View File
@@ -1,4 +0,0 @@
// Data-source route entry point that re-exports the admin handlers.
module.exports = require('../admin/data-sources');
+4 -26
View File
@@ -1,30 +1,8 @@
// RSS feed add page renderer and form defaults.
// RSS feed add page renderer.
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
function buildDefaultRssFeed() {
return {
id: null,
name: '',
feedUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
itemLimit: 1
};
}
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeed: viewRssFeed,
inUse: Boolean(viewRssFeed.inUse),
pulledItems: viewRssFeed.pulledItems || [],
pullError: viewRssFeed.pullError || ''
});
module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) {
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false));
};
@@ -1,6 +1,7 @@
// RSS feed edit page renderer that reuses the add form.
// RSS feed edit page renderer.
const renderRssFeedFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
const viewData = Object.assign({
@@ -8,8 +9,8 @@ module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentU
pullError: ''
}, data || {});
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(Object.assign({}, rssFeed, {
pulledItems: viewData.pulledItems,
pullError: viewData.pullError
}), 'edit', message, currentUser);
}), message, currentUser, viewData, true));
};
@@ -0,0 +1,39 @@
// Shared RSS feed form view-model builder.
function buildDefaultRssFeed() {
return {
id: null,
name: '',
feedUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
itemLimit: 1
};
}
function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdit) {
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
const viewOptions = options || {};
return {
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeed: viewRssFeed,
inUse: Boolean(viewRssFeed.inUse),
pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [],
pullError: viewOptions.pullError || viewRssFeed.pullError || '',
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(viewRssFeed.inUse),
formAction: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id : '/data-sources/rss-feeds',
formAttrs: isEdit && viewRssFeed.id ? 'data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/' + viewRssFeed.id + '/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new"' : 'data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/rss-feeds/new"',
cancelUrl: '/data-sources/rss-feeds',
deleteUrl: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id + '/delete' : ''
};
}
module.exports = {
buildRssFeedFormViewModel: buildRssFeedFormViewModel
};
@@ -0,0 +1,296 @@
// RSS feed route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderRssFeedsPage = require('./list');
const renderRssFeedAddPage = require('./add');
const renderRssFeedEditPage = require('./edit');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const rssFeedIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
const feedId = Number(value.feed_id);
if (Number.isFinite(feedId)) {
rssFeedIds.add(feedId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return rssFeedIds;
}
async function isRssFeedInUse(pool, common, rssFeedId) {
const usageIds = await getDataSourceUsageMaps(pool, common);
return usageIds.has(Number(rssFeedId));
}
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = 'rss-feed-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
module.exports = function registerRssFeedRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const dataSourceTasks = deps.dataSourceTasks;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try {
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 data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageIds = await getDataSourceUsageMaps(pool, common);
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
return Object.assign({}, rssFeed, {
inUse: usageIds.has(Number(rssFeed.id))
});
});
res.send(renderRssFeedsPage({
rssFeeds: rssFeeds,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const inUse = (slides || []).some(function (row) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
if (!content || typeof content !== 'object') {
return false;
}
const stack = [content];
while (stack.length) {
const value = stack.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (Array.isArray(value)) {
value.forEach(function (item) { stack.push(item); });
continue;
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id') && Number(value.feed_id) === Number(rssFeed.id)) {
return true;
}
Object.keys(value).forEach(function (key) {
stack.push(value[key]);
});
}
return false;
});
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
: [];
res.send(renderRssFeedEditPage(Object.assign({}, rssFeed, {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit,
inUse: inUse
}), {
pulledItems: pulledItems,
pullError: ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
});
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + result.insertId,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'RSS feed created. Refresh is running in the background.';
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const payload = common.buildRssFeedPayload(req, rssFeed);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
});
const message = 'RSS feed updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + rssFeed.id,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name
}
});
redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
closeUrl: '/data-sources/rss-feeds',
newUrl: '/data-sources/rss-feeds/new',
message: message
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
if (await isRssFeedInUse(pool, common, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by one or more slides.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -1,54 +0,0 @@
// Schedule group add/edit renderer and defaults.
const { renderView } = require('../../../view');
function buildDefaultScheduleGroup() {
return {
id: null,
name: '',
shortDescription: ''
};
}
function formatDateTimeLocalValue(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = String(date.getFullYear()).padStart(4, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
module.exports = function renderScheduleGroupFormPage(scheduleGroup, scheduleEntries, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewScheduleGroup = Object.assign(buildDefaultScheduleGroup(), scheduleGroup || {});
const viewScheduleEntries = Array.isArray(scheduleEntries) && scheduleEntries.length
? scheduleEntries.map(function (entry) {
return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime),
endValue: formatDateTimeLocalValue(entry.end_datetime)
});
})
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
return renderView('data-sources/schedules/form', {
title: isEdit ? 'Edit schedule group' : 'Add schedule group',
active: 'schedules',
message: message,
currentUser: currentUser || null,
isEdit: isEdit,
scheduleGroup: viewScheduleGroup,
scheduleEntries: viewScheduleEntries,
inUse: Boolean(viewScheduleGroup.inUse),
assetVersion: Date.now().toString(36)
});
};
@@ -1,32 +0,0 @@
// Schedule group list page renderer.
const { renderView } = require('../../../view');
function formatNextStartLabel(value, formatDashboardDate) {
if (!value) {
return 'No entries';
}
const label = typeof formatDashboardDate === 'function'
? formatDashboardDate(value)
: String(value);
return label || 'No entries';
}
module.exports = function renderScheduleGroupsPage(data, message, currentUser, formatDashboardDate) {
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
nextStartLabel: formatNextStartLabel(scheduleGroup.next_start_datetime, formatDashboardDate),
nextStartValue: scheduleGroup.next_start_datetime ? new Date(scheduleGroup.next_start_datetime).toISOString() : ''
});
});
return renderView('data-sources/schedules/list', {
title: 'Schedules',
active: 'schedules',
message: message,
currentUser: currentUser || null,
scheduleGroups: scheduleGroups,
pagination: data.pagination || null
});
};
@@ -0,0 +1,8 @@
// Timetable group add page renderer.
const { renderView } = require('../../../view');
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser) {
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false));
};
@@ -0,0 +1,8 @@
// Timetable group edit page renderer.
const { renderView } = require('../../../view');
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
module.exports = function renderTimetableGroupEditPage(timetableGroup, timetableEntries, message, currentUser) {
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, true));
};
@@ -0,0 +1,61 @@
// Shared timetable group form view-model builder.
function buildDefaultTimetableGroup() {
return {
id: null,
name: '',
shortDescription: ''
};
}
function formatDateTimeLocalValue(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = String(date.getFullYear()).padStart(4, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit) {
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
? timetableEntries.map(function (entry) {
return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime),
endValue: formatDateTimeLocalValue(entry.end_datetime)
});
})
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
return {
title: isEdit ? 'Edit timetable group' : 'Add timetable group',
active: 'timetables',
message: message,
currentUser: currentUser || null,
isEdit: Boolean(isEdit),
timetableGroup: viewTimetableGroup,
timetableEntries: viewTimetableEntries,
inUse: Boolean(viewTimetableGroup.inUse),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(viewTimetableGroup.inUse),
formAction: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id : '/data-sources/timetables',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/data-sources/timetables"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/data-sources/timetables/new"',
cancelUrl: '/data-sources/timetables',
deleteUrl: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id + '/delete' : '',
assetVersion: Date.now().toString(36)
};
}
module.exports = {
buildTimetableGroupFormViewModel: buildTimetableGroupFormViewModel
};
@@ -0,0 +1,32 @@
// Timetable group list page renderer.
const { renderView } = require('../../../view');
function formatNextStartLabel(value, formatDashboardDate) {
if (!value) {
return 'No entries';
}
const label = typeof formatDashboardDate === 'function'
? formatDashboardDate(value)
: String(value);
return label || 'No entries';
}
module.exports = function renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) {
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
return Object.assign({}, timetableGroup, {
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate),
nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : ''
});
});
return renderView('data-sources/timetables/list', {
title: 'Timetables',
active: 'timetables',
message: message,
currentUser: currentUser || null,
timetableGroups: timetableGroups,
pagination: data.pagination || null
});
};
@@ -0,0 +1,319 @@
// Timetable route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderTimetableGroupsPage = require('./list');
const renderTimetableGroupAddPage = require('./add');
const renderTimetableGroupEditPage = require('./edit');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const timetableGroupIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
const timetableGroupId = Number(value.schedule_group_id);
if (Number.isFinite(timetableGroupId)) {
timetableGroupIds.add(timetableGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return timetableGroupIds;
}
function readScheduleArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null) {
return [value];
}
}
return [];
}
function buildScheduleEntryRows(req, parseDateTimeLocal) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
error.statusCode = 400;
throw error;
}
const rows = [];
for (let index = 0; index < titles.length; index += 1) {
const idValue = Number(ids[index]);
const title = String(titles[index] || '').trim();
const shortDescription = String(descriptions[index] || '').trim();
const startDatetime = parseDateTimeLocal(starts[index]);
const endDatetime = parseDateTimeLocal(ends[index]);
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
if (!hasContent) {
continue;
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
rows.push({
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
title: title,
shortDescription: shortDescription,
startDatetime: startDatetime,
endDatetime: endDatetime
});
}
return rows;
}
module.exports = function registerTimetableRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
app.get('/data-sources/timetables', requirePermission('timetables.read'), async function (req, res, next) {
try {
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 data = await common.fetchTimetableGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageIds = await getDataSourceUsageMaps(pool, common);
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
return Object.assign({}, timetableGroup, {
inUse: usageIds.has(Number(timetableGroup.id))
});
});
res.send(renderTimetableGroupsPage({
timetableGroups: timetableGroups,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Timetables', 'Timetable groups')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/timetables/new', requirePermission('timetables.create'), function (req, res) {
res.send(renderTimetableGroupAddPage(null, [], req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/data-sources/timetables/:id/edit', requirePermission('timetables.update'), async function (req, res, next) {
try {
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
if (!timetableGroup) {
return res.status(404).send('Timetable group not found');
}
const usageIds = await getDataSourceUsageMaps(pool, common);
const timetableEntries = typeof common.fetchTimetableEntriesByGroupId === 'function'
? await common.fetchTimetableEntriesByGroupId(pool, timetableGroup.id)
: [];
res.send(renderTimetableGroupEditPage(Object.assign({}, timetableGroup, {
shortDescription: timetableGroup.short_description || '',
inUse: usageIds.has(Number(timetableGroup.id))
}), timetableEntries, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/data-sources/timetables', requirePermission('timetables.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildTimetableGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
result.insertId,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/timetables/' + result.insertId + '/edit', {
closeUrl: '/data-sources/timetables',
newUrl: '/data-sources/timetables/new',
message: 'Timetable group created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/timetables/:id', requirePermission('timetables.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
if (!timetableGroup) {
return res.status(404).send('Timetable group not found');
}
const payload = common.buildTimetableGroupPayload(req, timetableGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) {
return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, timetableGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
timetableGroup.id,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/timetables/' + timetableGroup.id + '/edit', {
closeUrl: '/data-sources/timetables',
newUrl: '/data-sources/timetables/new',
message: 'Timetable group updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/timetables/:id/delete', requirePermission('timetables.delete'), async function (req, res, next) {
try {
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
if (!timetableGroup) {
return res.status(404).send('Timetable group not found');
}
const usageIds = await getDataSourceUsageMaps(pool, common);
if (usageIds.has(Number(timetableGroup.id))) {
return res.redirect('/data-sources/timetables?message=' + encodeURIComponent('This timetable group is still used by one or more slides.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/timetables?message=' + encodeURIComponent('Timetable group deleted.'));
} catch (error) {
next(error);
}
});
};
+4 -3
View File
@@ -1,6 +1,7 @@
// Error page route helpers and error copy selection.
const { renderView } = require('../view');
const { renderView } = require('#src/web/view');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
function getErrorCopy(statusCode, message) {
const normalizedStatusCode = Number(statusCode) || 500;
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
return {
title: 'Access denied',
errorTitle: 'Access denied',
errorMessage: message || 'You do not have permission to access this area.',
errorMessage: message || PERMISSION_DENIED_MESSAGE,
backLabel: 'Back to dashboard'
};
}
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
return renderView('error/error', {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
messageVariant: 'primary',
messageVariant: statusCode === 403 ? 'warning' : 'primary',
currentUser: currentUser || null,
statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
-43
View File
@@ -1,43 +0,0 @@
// Aggregate the primary web route registrations.
module.exports = {
renderLoginPage: require('./auth/login'),
renderAccountPage: require('./account/password'),
renderUsersPage: require('./users/list'),
renderUsersAddPage: require('./users/add'),
renderUsersEditPage: require('./users/edit'),
renderDashboardPage: require('./dashboard/index'),
renderConnectedClientsPage: require('./clients/list'),
renderPlaylistsPage: require('./playlists/list'),
renderPlaylistFormPage: require('./playlists/add'),
renderPlaylistEditPage: require('./playlists/edit'),
renderPlaylistSlideConfigPage: require('./playlists/slide-config'),
renderApiSourcesPage: require('./data-sources/api-sources/list'),
renderApiSourceFormPage: require('./data-sources/api-sources/add'),
renderApiSourceEditPage: require('./data-sources/api-sources/edit'),
renderScheduleGroupsPage: require('./data-sources/schedules/list'),
renderScheduleGroupFormPage: require('./data-sources/schedules/form'),
renderRssFeedsPage: require('./data-sources/rss-feeds/list'),
renderRssFeedFormPage: require('./data-sources/rss-feeds/add'),
renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'),
renderScreensPage: require('./screens/list'),
renderScreenFormPage: require('./screens/add'),
renderScreenEditPage: require('./screens/edit'),
renderAnnouncementsPage: require('./announcements/list'),
renderAnnouncementFormPage: require('./announcements/add'),
renderAnnouncementEditPage: require('./announcements/edit'),
renderSlidesPage: require('./slides/list'),
renderSlideFormPage: require('./slides/form'),
renderTemplatesPage: require('./templates/list'),
renderTemplateFormPage: require('./templates/add'),
renderTemplateEditPage: require('./templates/edit'),
renderCanvasSizesPage: require('./canvas-sizes/list'),
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
renderRbacPage: require('./rbac/list'),
renderRbacAddPage: require('./rbac/add'),
renderRbacEditPage: require('./rbac/edit')
};
+65 -9
View File
@@ -4,11 +4,15 @@ const registerAuthRoutes = require('./auth');
const registerPagesRoutes = require('./admin/pages');
const registerAccountRoutes = require('./admin/account');
const registerUsersRoutes = require('./admin/users');
const registerPlaylistRoutes = require('./admin/playlists');
const registerManageRoutes = require('./admin/manage');
const registerScreenCommandRoutes = require('./admin/client-commands');
const registerAnnouncementRoutes = require('./signage/announcements/routes');
const registerContentRoutes = require('./admin/content');
const registerDataSourceRoutes = require('./admin/data-sources');
const registerRootDataSourceRoutes = require('./admin/data-sources');
const registerApiSourceRoutes = require('./data-sources/api-sources/routes');
const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
@@ -74,25 +78,32 @@ function registerAuthAndAccountRoutes(app, deps) {
function registerSignageRoutes(app, deps) {
// Screen, playlist, and command routes share the player-facing wiring.
registerManageRoutes(app, {
registerPlaylistRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
fetchOrderedPlaylistSlides: deps.fetchOrderedPlaylistSlides,
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
fetchPlaylistCanvasSignature: deps.fetchPlaylistCanvasSignature,
getCanvasSignature: deps.getCanvasSignature,
normalizeScheduleMode: deps.normalizeScheduleMode,
parseDateTimeLocal: deps.parseDateTimeLocal,
parseTimeLocal: deps.parseTimeLocal,
fetchPlaylistCanvasId: deps.fetchPlaylistCanvasId,
readArrayField: deps.readArrayField,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
notifyPlayerScreens: deps.notifyPlayerScreens,
broadcastDashboardState: deps.broadcastDashboardState,
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
requirePermission: deps.requirePermission
});
registerManageRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
notifyPlayerScreens: deps.notifyPlayerScreens,
broadcastDashboardState: deps.broadcastDashboardState,
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections,
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
playerPublicBaseUrl: deps.playerPublicBaseUrl,
requirePermission: deps.requirePermission
@@ -110,11 +121,13 @@ function registerSignageRoutes(app, deps) {
registerScreenCommandRoutes(app, {
pool: deps.pool,
common: deps.common,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
getScreenConnections: deps.playerActionService.getScreenConnections,
isClientNameAvailable: deps.isClientNameAvailable,
withClientNameReservation: deps.withClientNameReservation,
broadcastDashboardState: deps.broadcastDashboardState,
playerPublicBaseUrl: deps.playerPublicBaseUrl,
requirePermission: deps.requirePermission
});
}
@@ -161,7 +174,50 @@ function registerSettingsAndContentRoutes(app, deps) {
requirePermission: deps.requirePermission
});
registerDataSourceRoutes(app, {
registerRootDataSourceRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission,
section: 'root'
});
registerApiSourceRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission
});
registerRssFeedRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission
});
registerTimetableRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
@@ -1,9 +1,9 @@
// Background tasks settings page renderer.
const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac');
const { buildPagination, buildQueryString } = require('../../lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
const { renderView } = require('#src/web/view');
const { hasAnyPermission } = require('#src/rbac');
const { buildPagination, buildQueryString } = require('#src/web/lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('#src/web/lib/list-query');
const TASKS_PER_PAGE = 25;
const QUEUE_PAGE_URL = '/settings/tasks-background';
+5 -5
View File
@@ -1,6 +1,6 @@
// Settings route registration for the background tasks page.
const { hasAnyPermission } = require('../../../rbac');
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
function redirectToLogin(res, setAuthMessageCookie) {
if (typeof setAuthMessageCookie === 'function') {
@@ -21,7 +21,7 @@ function requireSettingsAccess(setAuthMessageCookie) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -38,7 +38,7 @@ function requireScheduledSettingsAccess(setAuthMessageCookie) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -86,7 +86,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -99,7 +99,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
}
if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) {
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
return next(error);
+3 -3
View File
@@ -1,8 +1,8 @@
// Settings route registration for managed fonts.
const fs = require('fs');
const { hasAnyPermission } = require('../../../rbac');
const fontLibrary = require('../../lib/media/font-library');
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const fontLibrary = require('#src/web/lib/media/font-library');
function normalizeFontFamilyName(value) {
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
@@ -62,7 +62,7 @@ function requireFontsAccess(setAuthMessageCookie) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
+3 -11
View File
@@ -1,16 +1,8 @@
// RBAC role add page renderer.
const { renderView } = require('../../../view');
const { buildRbacAddViewModel } = require('./form-view-model');
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
return renderView('settings/rbac/add', {
title: 'Create role',
active: 'rbac',
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
formValues: formValues || {},
permissionGroups: permissionGroups || [],
scripts: ['js/rbac-permissions.js']
});
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
return renderView('settings/rbac/form', buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant));
};
+2 -13
View File
@@ -1,19 +1,8 @@
// RBAC role edit page renderer.
const { renderView } = require('../../../view');
const { buildRbacEditViewModel } = require('./form-view-model');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
return renderView('settings/rbac/edit', {
title: 'Edit role',
active: 'rbac',
message: message,
currentUser: currentUser || null,
role: role,
inUse: Boolean(role && role.inUse),
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
scripts: ['js/rbac-permissions.js']
});
return renderView('settings/rbac/form', buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination));
};
@@ -0,0 +1,59 @@
// Shared RBAC form view-model helpers.
function buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
return {
title: 'Create role',
active: 'rbac',
isEdit: false,
usersPresent: true,
formId: 'role-create-form',
formAction: '/rbac',
footerSaveUrl: '/rbac',
footerCancelUrl: '/rbac',
footerDeleteUrl: '',
footerDeleteDisabled: true,
footerDeleteTitle: 'Delete is available after the role has been created.',
footerShowDelete: true,
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
formValues: formValues || {},
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/new',
scripts: ['js/rbac-permissions.js'],
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url"'
};
}
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
return {
title: 'Edit role',
active: 'rbac',
isEdit: true,
usersPresent: true,
formId: 'role-edit-form',
formAction: '/rbac/' + Number(role && role.id),
footerSaveUrl: '/rbac/' + Number(role && role.id),
footerCancelUrl: '/rbac',
footerDeleteUrl: '/rbac/' + Number(role && role.id) + '/delete',
footerDeleteDisabled: Boolean(role && role.inUse),
footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '',
footerShowDelete: true,
message: message,
currentUser: currentUser || null,
role: role,
inUse: Boolean(role && role.inUse),
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
scripts: ['js/rbac-permissions.js']
};
}
module.exports = {
buildRbacAddViewModel: buildRbacAddViewModel,
buildRbacEditViewModel: buildRbacEditViewModel
};
+2 -9
View File
@@ -1,15 +1,8 @@
// User add page renderer.
const { renderView } = require('../../../view');
const { buildUsersAddViewModel } = require('./form-view-model');
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
return renderView('settings/users/add', {
title: 'Add user',
active: 'users',
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
roles: roles || [],
formValues: formValues || {}
});
return renderView('settings/users/form', buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant));
};
+2 -9
View File
@@ -1,15 +1,8 @@
// User edit page renderer.
const { renderView } = require('../../../view');
const { buildUsersEditViewModel } = require('./form-view-model');
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
return renderView('settings/users/edit', {
title: 'Edit user',
active: 'users',
message: message,
currentUser: currentUser || null,
user: user,
inUse: Boolean(user && user.inUse),
roles: roles || []
});
return renderView('settings/users/form', buildUsersEditViewModel(user, message, currentUser, roles));
};
@@ -0,0 +1,69 @@
// Shared user form view-model helpers.
function normalizeSelectedRoleIds(selectedRoleIds) {
return new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
}));
}
function mapRolesForForm(roles, selectedRoleIds) {
const selectedIds = normalizeSelectedRoleIds(selectedRoleIds);
return (Array.isArray(roles) ? roles : []).map(function (role) {
return Object.assign({}, role, {
isSelected: selectedIds.has(Number(role.id))
});
});
}
function buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant) {
return {
title: 'Add user',
active: 'users',
isEdit: false,
formId: 'user-form',
formAction: '/users',
footerSaveUrl: '/users',
footerCancelUrl: '/users',
footerDeleteUrl: '',
footerDeleteDisabled: true,
footerDeleteTitle: 'Delete is available after the user has been created.',
footerShowDelete: true,
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
roles: mapRolesForForm(roles, []),
formValues: formValues || {},
formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/users/new"'
};
}
function buildUsersEditViewModel(user, message, currentUser, roles) {
return {
title: 'Edit user',
active: 'users',
isEdit: true,
formId: 'user-profile-form',
formAction: '/users/' + user.id + '/username',
formAttrs: 'data-async-save data-async-save-close-url="/users"',
footerSaveUrl: '/users/' + user.id + '/username',
footerCancelUrl: '/users',
footerDeleteUrl: '/users/' + user.id + '/delete',
footerDeleteDisabled: Boolean(user && user.inUse),
footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '',
footerShowDelete: true,
message: message,
currentUser: currentUser || null,
user: user,
inUse: Boolean(user && user.inUse),
roles: mapRolesForForm(roles, user && user.roleIds)
};
}
module.exports = {
mapRolesForForm: mapRolesForForm,
buildUsersAddViewModel: buildUsersAddViewModel,
buildUsersEditViewModel: buildUsersEditViewModel
};
+4 -37
View File
@@ -1,41 +1,8 @@
// Announcement add page renderer and form defaults.
// Announcement add page renderer.
const { renderView } = require('../../../view');
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
function buildDefaultAnnouncement() {
return {
id: null,
message: '',
short_label: '',
announcement_type: 'lower-third',
color_key: 'primary',
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
durationMode: 'duration',
duration_seconds: null,
durationValue: 5,
durationUnit: 'minutes',
durationLabel: 'Until disabled',
screen_ids: []
};
}
module.exports = function renderAnnouncementFormPage(announcement, mode, message, currentUser, options) {
const isEdit = mode === 'edit';
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
return renderView(isEdit ? 'signage/announcements/edit' : 'signage/announcements/add', {
title: isEdit ? 'Edit announcement' : 'Add announcement',
active: 'announcements',
message: message,
currentUser: currentUser || null,
announcement: Object.assign({}, buildDefaultAnnouncement(), announcement || {}),
announcementTypes: announcementTypes,
announcementColors: announcementColors,
announcementIcons: announcementIcons,
announcementScreens: announcementScreens
});
module.exports = function renderAnnouncementAddPage(announcement, message, currentUser, options) {
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Announcement edit page renderer that reuses the add form.
// Announcement edit page renderer.
const renderAnnouncementFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) {
return renderAnnouncementFormPage(announcement, 'edit', message, currentUser, options);
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, true));
};
@@ -0,0 +1,54 @@
// Shared announcement form view-model builder.
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
function buildDefaultAnnouncement() {
return {
id: null,
message: '',
short_label: '',
announcement_type: 'lower-third',
color_key: 'primary',
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
durationMode: 'duration',
duration_seconds: null,
durationValue: 5,
durationUnit: 'minutes',
durationLabel: 'Until disabled',
screen_ids: []
};
}
function buildAnnouncementFormViewModel(announcement, message, currentUser, options, isEdit) {
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {});
const canDelete = Boolean(currentUser && Array.isArray(currentUser.permissionKeys) && currentUser.permissionKeys.indexOf('announcements.delete') !== -1);
return {
title: isEdit ? 'Edit announcement' : 'Add announcement',
active: 'announcements',
message: message,
currentUser: currentUser || null,
announcement: viewAnnouncement,
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
showDeleteAction: Boolean(isEdit && canDelete),
deleteDisabled: !isEdit || !Boolean(viewAnnouncement && viewAnnouncement.canDelete),
formAction: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id : '/announcements',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/announcements"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/announcements/new"',
cancelUrl: '/announcements',
deleteUrl: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id + '/delete' : '',
actionFormId: 'announcement-action-form',
announcementTypes: announcementTypes,
announcementColors: announcementColors,
announcementIcons: announcementIcons,
announcementScreens: announcementScreens
};
}
module.exports = {
buildAnnouncementFormViewModel: buildAnnouncementFormViewModel
};
@@ -261,7 +261,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) {
try {
const screens = await fetchAnnouncementScreens();
res.send(pages.renderAnnouncementFormPage(buildAnnouncementFormData({
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData({
message: '',
short_label: '',
announcement_type: 'lower-third',
@@ -272,7 +272,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
durationValue: 5,
durationUnit: 'minutes',
screen_ids: []
}), 'add', req.query.message ? String(req.query.message) : '', req.currentUser, {
}), req.query.message ? String(req.query.message) : '', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
+4 -20
View File
@@ -1,24 +1,8 @@
// Canvas size add page renderer and form defaults.
// Canvas size add page renderer.
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
function buildDefaultCanvasSize() {
return {
id: null,
name: '',
width: 1920,
height: 1080
};
}
module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, currentUser) {
const isEdit = mode === 'edit';
return renderView(isEdit ? 'canvas-sizes/edit' : 'canvas-sizes/add', {
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: canvasSize || buildDefaultCanvasSize(),
inUse: Boolean(canvasSize && canvasSize.inUse)
});
module.exports = function renderCanvasSizeAddPage(canvasSize, message, currentUser) {
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Canvas size edit page renderer that reuses the add form.
// Canvas size edit page renderer.
const renderCanvasSizeFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
return renderCanvasSizeFormPage(canvasSize, 'edit', message, currentUser);
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, true));
};
@@ -0,0 +1,34 @@
// Shared canvas size form view-model builder.
function buildDefaultCanvasSize() {
return {
id: null,
name: '',
width: 1920,
height: 1080
};
}
function buildCanvasSizeFormViewModel(canvasSize, message, currentUser, isEdit) {
const viewCanvasSize = Object.assign(buildDefaultCanvasSize(), canvasSize || {});
return {
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: viewCanvasSize,
inUse: Boolean(viewCanvasSize && viewCanvasSize.inUse),
isEdit: Boolean(isEdit),
formAction: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id : '/canvas-sizes',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/canvas-sizes"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/canvas-sizes/new"',
cancelUrl: '/canvas-sizes',
deleteUrl: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id + '/delete' : '',
deleteConfirmMessage: 'Delete this canvas size?',
deleteDisabled: !isEdit || Boolean(viewCanvasSize && viewCanvasSize.inUse)
};
}
module.exports = {
buildCanvasSizeFormViewModel: buildCanvasSizeFormViewModel
};
+1
View File
@@ -9,6 +9,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser)
message: message,
currentUser: currentUser || null,
scripts: ['js/dashboard/dashboard-page.js'],
screens: data.screens || [],
clients: data.clients || [],
pagination: data.pagination || null
});
+1
View File
@@ -60,6 +60,7 @@ module.exports = function registerClientsRoutes(app, deps) {
const startIndex = (page - 1) * LIST_PAGE_SIZE;
const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
res.send(pages.renderConnectedClientsPage({
screens: data.screens || [],
clients: clients,
pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
+4 -12
View File
@@ -1,17 +1,9 @@
// Playlist add page renderer.
const { renderView } = require('../../../view');
const renderPlaylistEditorPage = require('./form-page');
module.exports = function renderPlaylistFormPage(message, currentUser) {
return renderView('playlists/add', {
title: 'Create playlist',
active: 'playlists',
playlist: {
fade_between_slides: false,
skip_unavailable_rtmp: false
},
message: message,
currentUser: currentUser || null
});
module.exports = async function renderPlaylistFormPage(message, currentUser, data, pool) {
const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] });
return renderPlaylistEditorPage(null, adminData, message, currentUser, { isEdit: false });
};
+2 -221
View File
@@ -1,226 +1,7 @@
// Playlist edit page renderer.
const { renderView } = require('../../../view');
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function formatDays(daysJson) {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let days = [];
if (Array.isArray(daysJson)) {
days = daysJson;
} else if (daysJson) {
try {
const parsed = JSON.parse(daysJson);
days = Array.isArray(parsed) ? parsed : [];
} catch (_error) {
days = [];
}
}
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') {
const start = formatDateTime(item.schedule_start_datetime);
const end = formatDateTime(item.schedule_end_datetime);
return start && end ? `Dates: ${start} to ${end}` : 'Dates: not set';
}
if (mode === 'times') {
const days = formatDays(item.schedule_days_json);
const start = formatTime(item.schedule_start_time);
const end = formatTime(item.schedule_end_time);
return days && start && end ? `Times: ${days} ${start}-${end}` : 'Times: not set';
}
return 'Always visible';
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const contentJson = slide.content_json;
if (!contentJson) {
return '';
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return '';
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : '';
} catch (_error) {
return '';
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
const renderPlaylistEditorPage = require('./form-page');
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
const playlistSlides = (data.playlistSlides || [])
.filter((item) => item.playlist_id === playlist.id)
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id - b.id;
})
.map((item, index, items) => Object.assign({}, item, {
displayOrder: index + 1,
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
showVideoDurationButton: hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
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));
const playlistCanvasSignatures = Array.from(new Set(playlistSlides.map((item) => getCanvasSignature(item.canvas_width, item.canvas_height)).filter(Boolean)));
const playlistCanvasSignature = playlistCanvasSignatures.length === 1 ? playlistCanvasSignatures[0] : null;
const playlistCanvasMismatch = playlistCanvasSignatures.length > 1;
const selectableSlides = (data.slides || []).filter((slide) => {
if (playlistCanvasMismatch) {
return false;
}
if (!playlistCanvasSignature) {
return true;
}
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
return renderView('playlists/edit', {
title: 'Edit playlist',
active: 'playlists',
message: message,
currentUser: currentUser || null,
playlist: playlist,
inUse: Boolean(playlist.inUse),
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
playlistSlides: playlistSlides,
slides: data.slides || [],
hasSlides: (data.slides || []).length > 0,
hasSelectableSlides: selectableSlides.length > 0,
hasAddableSlides: hasAddableSlides,
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
});
return renderPlaylistEditorPage(playlist, data || {}, message, currentUser, { isEdit: true });
};
@@ -0,0 +1,8 @@
// Shared playlist form page renderer.
const { renderView } = require('../../../view');
const { buildPlaylistFormViewModel } = require('./form-view-model');
module.exports = function renderPlaylistFormPage(playlist, data, message, currentUser, options) {
return renderView('playlists/form', buildPlaylistFormViewModel(playlist || null, data || {}, message, currentUser, options || {}));
};
@@ -0,0 +1,289 @@
// Shared playlist form view-model builder.
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function scheduleSummary(item) {
return formatScheduleRulesSummary(item && item.scheduleRules ? item.scheduleRules : []);
}
function formatScheduleRuleForView(rule, index) {
const input = rule && typeof rule === 'object' ? rule : {};
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const selectedDays = new Set(normalizeDayValues(input.days || input.schedule_days_json));
const normalizedDays = Array.from(selectedDays);
return {
index: index + 1,
position: index,
startDateTimeValue: formatDateTime(input.start_datetime),
endDateTimeValue: formatDateTime(input.end_datetime),
startTimeValue: formatTime(input.start_time),
endTimeValue: formatTime(input.end_time),
daysCsv: normalizedDays.join(','),
ruleSummary: formatScheduleRuleSummary(input),
dayOptions: dayNames.map((label, value) => ({
value: value,
label: label,
checked: selectedDays.has(value),
inputId: 'schedule-rule-' + (index + 1) + '-day-' + value
}))
};
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function hasPlayableMediaRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
const regionType = String(region && region.type || '').trim().toLowerCase();
return region && typeof region === 'object' && (regionType === 'video' || regionType === 'rtmp');
}));
} catch (_error) {
return false;
}
}
function getDisableAudioValue(slide) {
if (!slide || slide.disable_audio === undefined || slide.disable_audio === null) {
return true;
}
return Boolean(slide.disable_audio);
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const contentJson = slide.content_json;
if (!contentJson) {
return '';
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return '';
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : '';
} catch (_error) {
return '';
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
function formatCanvasSizeLabel(size) {
const width = Number(size && size.width);
const height = Number(size && size.height);
const dimensions = Number.isFinite(width) && Number.isFinite(height) ? width + 'x' + height : '';
const name = String(size && size.name ? size.name : '').trim();
if (!dimensions) {
return name;
}
return name ? dimensions + ' - ' + name : dimensions;
}
function buildPlaylistFormViewModel(playlist, data, message, currentUser, options) {
const isEdit = Boolean(options && options.isEdit);
const viewPlaylist = Object.assign({
id: null,
name: '',
fade_between_slides: false,
skip_unavailable_rtmp: false
}, playlist || {});
const playlistSlides = (data.playlistSlides || [])
.filter((item) => Number(item.playlist_id) === Number(viewPlaylist.id))
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id - b.id;
})
.map((item, index, items) => Object.assign({}, item, {
row_key: String(item.row_key || item.id || ''),
displayOrder: index + 1,
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSizeId: Number(item.canvas_size_id) || null,
showVideoDurationButton: hasVideoRegion(item.content_json),
showMuteButton: hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
disableAudio: getDisableAudioValue(item),
durationSeconds: getVideoDurationValue(item),
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
const storedCanvasId = Number(viewPlaylist.canvas_id) || null;
const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0)));
const computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null;
const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId;
const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null;
const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, {
label: formatCanvasSizeLabel(size),
canvasId: Number(size.id) || null,
selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id))
}));
const playlistCanvasMismatch = Boolean(
(storedCanvasId && playlistSlides.some((item) => Number(item.canvasSizeId || item.canvas_size_id) > 0 && Number(item.canvasSizeId || item.canvas_size_id) !== storedCanvasId)) ||
(!storedCanvasId && playlistSlideCanvasIds.length > 1)
);
const selectableSlides = (data.slides || []).map((slide) => Object.assign({}, slide, {
canvasSizeId: Number(slide.canvas_size_id) || null,
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: hasVideoRegion(slide.content_json),
showMuteButton: hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const playlistSlideTemplate = {
id: '',
row_key: '',
slide_id: '',
title: '',
thumbnail_path: '',
canvas_width: '',
canvas_height: '',
canvasSizeId: '',
showVideoDurationButton: false,
showMuteButton: false,
videoSourcePath: '',
videoDurationSeconds: '',
useVideoDuration: false,
disableAudio: true,
durationSeconds: 10,
scheduleRules: [],
scheduleSummary: 'Always visible',
displayOrder: ''
};
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned && (!playlistCanvasId || slide.canvasSizeId === playlistCanvasId));
const isCreate = !isEdit;
return {
title: 'Playlist editor',
active: 'playlists',
isEdit: isEdit,
playlist: viewPlaylist,
formAction: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id : '/playlists',
formAttrs: 'data-async-save data-async-save-refresh-target="#playlist-items-body" data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new" data-async-save-new-redirect="response-url"',
cancelUrl: '/playlists',
deleteUrl: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id + '/delete' : '',
deleteDisabled: isCreate ? true : Boolean(viewPlaylist.inUse),
showSaveSecondaryActions: true,
playlistFadeBetweenSlides: Boolean(viewPlaylist.fade_between_slides),
playlistSkipUnavailableRtmp: Boolean(viewPlaylist.skip_unavailable_rtmp),
canvasSizes: canvasSizes,
playlistCanvasSizeId: selectedCanvasSize ? Number(selectedCanvasSize.id) : '',
playlistCanvasSizeLabel: selectedCanvasSize ? formatCanvasSizeLabel(selectedCanvasSize) : '',
playlistCanvasEditable: Boolean(!isEdit || !playlistCanvasId),
playlistScheduleConfigUrl: '/playlists/new/slides/0/config',
hasSlides: (data.slides || []).length > 0,
hasSelectableSlides: selectableSlides.length > 0,
hasAddableSlides: hasAddableSlides,
playlistSlides: playlistSlides,
playlistSlideTemplate: playlistSlideTemplate,
slides: data.slides || [],
selectableSlides: selectableSlides,
playlistCanvasId: playlistCanvasId,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260803.1'],
message: message,
currentUser: currentUser || null,
inUse: Boolean(viewPlaylist.inUse)
};
}
module.exports = {
buildPlaylistFormViewModel: buildPlaylistFormViewModel
};
+6 -1
View File
@@ -43,7 +43,12 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
});
app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) {
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
(async function () {
const data = await common.fetchAdminData(pool);
res.send(await pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser, data, pool));
})().catch(function (error) {
res.status(500).send(error.message || 'Unable to load page.');
});
});
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
@@ -1,6 +1,7 @@
// Shared playlist slide configuration rendering helpers.
const { renderFragment } = require('../../../view');
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
const DAY_OPTIONS = [
{ value: 0, label: 'Sun' },
@@ -41,17 +42,34 @@ function formatTimeInput(value) {
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
const slide = playlistSlide || {
id: 0,
schedule_mode: 'always',
schedule_days: [],
schedule_start_datetime: null,
schedule_end_datetime: null,
schedule_start_time: null,
schedule_end_time: null
scheduleRules: []
};
const scheduleMode = String(slide.schedule_mode || 'always');
const selectedDays = new Set(Array.isArray(slide.schedule_days) ? slide.schedule_days.map(Number) : []);
const startTimeValue = formatTimeInput(slide.schedule_start_time) || (scheduleMode === 'times' ? DEFAULT_START_TIME : '');
const endTimeValue = formatTimeInput(slide.schedule_end_time) || (scheduleMode === 'times' ? DEFAULT_END_TIME : '');
const scheduleRules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
const renderedRules = scheduleRules.map((rule, index) => {
const ruleNumber = index + 1;
const selectedDays = new Set(normalizeDayValues(rule.days || rule.schedule_days_json));
return {
id: 'schedule-rule-' + ruleNumber,
index: ruleNumber,
startDateTimeValue: formatDateTimeInput(rule.start_datetime),
endDateTimeValue: formatDateTimeInput(rule.end_datetime),
startTimeValue: formatTimeInput(rule.start_time),
endTimeValue: formatTimeInput(rule.end_time),
ruleSummary: formatScheduleRuleSummary(rule),
dayOptions: DAY_OPTIONS.map((day) => ({
...day,
checked: selectedDays.has(day.value),
inputId: 'schedule-rule-' + ruleNumber + '-day-' + day.value
}))
};
});
const templateDayOptions = DAY_OPTIONS.map((day) => ({
...day,
checked: false,
inputId: 'schedule-rule-template-day-' + day.value
}));
return renderFragment('playlists/slide-config', {
title: 'Slide schedule',
@@ -63,19 +81,10 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
currentUser: currentUser || null,
playlist: playlist,
playlistSlide: slide,
scheduleMode: scheduleMode,
isAlwaysMode: scheduleMode === 'always',
isDatesMode: scheduleMode === 'dates',
isTimesMode: scheduleMode === 'times',
rowKey: String(rowKey || ''),
startDateTimeValue: formatDateTimeInput(slide.schedule_start_datetime),
endDateTimeValue: formatDateTimeInput(slide.schedule_end_datetime),
startTimeValue: startTimeValue,
endTimeValue: endTimeValue,
dayOptions: DAY_OPTIONS.map((day) => ({
...day,
checked: selectedDays.has(day.value)
})),
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js']
scheduleRules: renderedRules,
templateDayOptions: templateDayOptions,
scheduleRulesSummary: formatScheduleRulesSummary(scheduleRules),
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js?v=20260802.8']
});
};
+2 -7
View File
@@ -1,14 +1,9 @@
// Screen add page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
module.exports = function renderScreenFormPage(data, message, currentUser) {
return renderView('screens/add', {
title: 'Add screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
playlists: data.playlists || []
});
return renderView('screens/form', buildScreenFormViewModel(null, data || {}, message, currentUser, false));
};
+2 -9
View File
@@ -1,16 +1,9 @@
// Screen edit page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
return renderView('screens/edit', {
title: 'Edit screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
screen: screen,
inUse: Boolean(screen.inUse),
playlists: data.playlists || []
});
return renderView('screens/form', buildScreenFormViewModel(screen, data || {}, message, currentUser, true));
};
@@ -0,0 +1,32 @@
// Shared screen form view-model builder.
function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
const viewScreen = Object.assign({
name: '',
slug: '',
playlist_id: null
}, screen || {});
return {
title: isEdit ? 'Edit screen' : 'Add screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
playlists: (data && data.playlists) || [],
screen: viewScreen,
inUse: Boolean(viewScreen.inUse),
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
formAction: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id : '/screens',
formAttrs: Boolean(isEdit)
? 'data-async-save data-async-save-close-url="/screens" data-async-save-new-url="/screens/new"'
: 'data-async-save data-async-save-new-redirect="response-url" data-async-save-new-url="/screens/new"',
cancelUrl: '/screens',
deleteUrl: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id + '/delete' : ''
};
}
module.exports = {
buildScreenFormViewModel: buildScreenFormViewModel
};
+8
View File
@@ -0,0 +1,8 @@
// Slide add page renderer.
const { renderView } = require('../../../view');
const { buildSlideFormViewModel } = require('./form-view-model');
module.exports = function renderSlideAddPage(data, message, currentUser) {
return renderView('slides/form', buildSlideFormViewModel(data || {}, null, message, currentUser, false));
};
+8
View File
@@ -0,0 +1,8 @@
// Slide edit page renderer.
const { renderView } = require('../../../view');
const { buildSlideFormViewModel } = require('./form-view-model');
module.exports = function renderSlideEditPage(data, slide, message, currentUser) {
return renderView('slides/form', buildSlideFormViewModel(data || {}, slide || null, message, currentUser, true));
};
@@ -0,0 +1,63 @@
// Shared slide form view-model builder.
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
function buildDefaultSlide() {
return {
title: '',
template_id: null,
content: {}
};
}
function buildTemplates(templates, templateRegions) {
const regions = Array.isArray(templateRegions) ? templateRegions : [];
return (Array.isArray(templates) ? templates : []).map((template) => Object.assign({}, template, {
canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height,
regions: regions.filter((region) => Number(region.template_id) === Number(template.id))
}));
}
function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
const assetVersion = Date.now().toString(36);
const templateRegions = data && data.templateRegions ? data.templateRegions : [];
const rssFeeds = data && data.rssFeeds ? data.rssFeeds : [];
const apiSources = data && data.apiSources ? data.apiSources : [];
const timetableGroups = data && data.timetableGroups ? data.timetableGroups : [];
const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null;
const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions);
const viewSlide = slide || buildDefaultSlide();
return {
title: isEdit ? 'Edit slide' : 'Create slide',
active: 'slides',
message: message,
isEdit: Boolean(isEdit),
inUse: Boolean(viewSlide && viewSlide.inUse),
action: isEdit && viewSlide.id ? '/slides/' + viewSlide.id : '/slides',
formAttrs: isEdit
? 'data-async-save data-async-save-close-url="/slides" data-async-save-new-url="/slides/new"'
: 'data-async-save data-async-save-close-url="/slides" data-async-save-new-redirect="response-url" data-async-save-new-url="/slides/new"',
slide: viewSlide,
templates: templates,
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
slideEditorData: {
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
timetableGroups: timetableGroups,
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}
},
assetVersion: assetVersion,
slideEditorScripts: getRegionEditorScripts(assetVersion),
currentUser: currentUser || null
};
}
module.exports = {
buildSlideFormViewModel: buildSlideFormViewModel
};
-45
View File
@@ -1,45 +0,0 @@
// Slide form page renderer for add and edit flows.
const { renderView } = require('../../../view');
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) {
const assetVersion = Date.now().toString(36);
const templateRegions = data.templateRegions || [];
const rssFeeds = data.rssFeeds || [];
const apiSources = data.apiSources || [];
const scheduleGroups = data.scheduleGroups || [];
const fontLibrary = data.fontLibrary || null;
const templates = (data.templates || []).map((template) => ({
...template,
canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height,
regions: templateRegions.filter((region) => Number(region.template_id) === Number(template.id))
}));
return renderView('slides/form', {
title: mode === 'edit' ? 'Edit slide' : 'Create slide',
active: 'slides',
message: message,
isEdit: mode === 'edit',
inUse: Boolean(slide && slide.inUse),
saveLabel: 'Save',
action: mode === 'edit' ? `/slides/${slide.id}` : '/slides',
slide: slide || { title: '', template_id: null, content: {} },
templates: templates,
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
slideEditorData: {
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
scheduleGroups: scheduleGroups,
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
existingContent: slide && slide.content ? slide.content : {}
},
assetVersion: assetVersion,
slideEditorScripts: getRegionEditorScripts(assetVersion),
currentUser: currentUser || null
});
};
+4 -57
View File
@@ -1,61 +1,8 @@
// Template add page renderer and form defaults.
// Template add page renderer.
const { renderView } = require('../../../view');
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
const { buildTemplateFormViewModel } = require('./form-view-model');
function buildDefaultTemplate() {
return {
id: null,
name: '',
canvas_size_id: null,
canvas_size_width: 1920,
canvas_size_height: 1080,
background_color: '#111111',
background_image_path: '',
region_usage: [],
regions: []
};
}
function resolveTemplateCanvasSize(template, canvasSizes) {
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
if (matched) {
return Object.assign({}, template, {
canvas_size_id: matched.id,
canvas_size_label: matched.name,
canvas_size_width: matched.width,
canvas_size_height: matched.height,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
return Object.assign({}, template, {
canvas_size_id: '',
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
canvas_size_width: currentWidth,
canvas_size_height: currentHeight,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
module.exports = function renderTemplateFormPage(template, mode, message, canvasSizes, currentUser) {
const isEdit = mode === 'edit';
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
const assetVersion = Date.now().toString(36);
return renderView(isEdit ? 'templates/edit' : 'templates/add', {
title: isEdit ? 'Edit template' : 'Create Template',
active: 'templates',
message: message,
currentUser: currentUser || null,
template: current,
inUse: Boolean(current.inUse),
canvasSizes: canvasSizes || [],
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
});
module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Template edit page renderer that reuses the add form.
// Template edit page renderer.
const renderTemplateFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildTemplateFormViewModel } = require('./form-view-model');
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
return renderTemplateFormPage(template, 'edit', message, data && data.canvasSizes ? data.canvasSizes : [], currentUser);
return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true));
};
@@ -0,0 +1,70 @@
// Shared template form view-model builder.
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
function buildDefaultTemplate() {
return {
id: null,
name: '',
canvas_size_id: null,
canvas_size_width: 1920,
canvas_size_height: 1080,
background_color: '#111111',
background_image_path: '',
region_usage: [],
regions: []
};
}
function resolveTemplateCanvasSize(template, canvasSizes) {
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
if (matched) {
return Object.assign({}, template, {
canvas_size_id: matched.id,
canvas_size_label: matched.name,
canvas_size_width: matched.width,
canvas_size_height: matched.height,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
return Object.assign({}, template, {
canvas_size_id: '',
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
canvas_size_width: currentWidth,
canvas_size_height: currentHeight,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit) {
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
const assetVersion = Date.now().toString(36);
return {
title: isEdit ? 'Edit template' : 'Create Template',
active: 'templates',
message: message,
currentUser: currentUser || null,
template: current,
inUse: Boolean(current.inUse),
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(current.inUse),
formAction: isEdit && current.id ? '/templates/' + current.id : '/templates',
formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save' + (isEdit ? ' data-async-save-close-url="/templates"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"',
cancelUrl: '/templates',
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
canvasSizes: canvasSizes || [],
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
};
}
module.exports = {
buildTemplateFormViewModel: buildTemplateFormViewModel
};