Release v2.19.0: indexed content references
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m11s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
lzstealth committed 2026-10-24 19:50:12 +01:00
1 parent 24f1603b43
commit b59fe08298
31 files changed
+663 -237

No files matched your search

+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
## 2.19.0 - 2026-10-24
### Added
- Indexed slide and template-default references to media, fonts, API requests, RSS feeds, timetables, weather locations, and weather icon sets for efficient in-use checks and deletion protection.
### Changed
- API source, RSS feed, and weather location edit pages confirm before deleting.
- Slide and template saves now update only changed reference-index rows, preserving unchanged rows.
- Weather icon changes refresh thumbnails for slides using slide or template defaults.
## 2.18.0 - 2026-10-24
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.18.0",
"version": "2.19.0",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.18.0",
"version": "2.19.0",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.18.0",
"version": "2.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.18.0",
"version": "2.19.0",
"dependencies": {
"animate.css": "^4.1.1",
"bootstrap-icons": "1.13.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.18.0",
"version": "2.19.0",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+3 -12
View File
@@ -1,5 +1,6 @@
const { API_AUTH_FIELDS, API_REQUEST_SELECT, buildApiSourcePayload } = require('#src/data/api-sources');
const { fetchPagedRows, validateMaxLength, parseJsonSafe } = require('#src/data/utils');
const { getContentReferenceValues } = require('#src/data/content-references');
function toFormFields(value) {
const fields = Object.assign({}, value);
@@ -54,7 +55,7 @@ async function fetchApiGroupById(pool, id) {
async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) {
const data = await fetchPagedRows(pool, {
selectSql: 'SELECT auth.*, (SELECT COUNT(*) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS request_count, (SELECT MAX(last_pulled_at) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS last_pulled_at FROM i_api_source_groups auth ORDER BY auth.modified_at DESC, auth.id DESC',
selectSql: 'SELECT auth.*, (SELECT COUNT(*) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS request_count, (SELECT MAX(last_pulled_at) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS last_pulled_at, EXISTS (SELECT 1 FROM i_api_sources used_request INNER JOIN c_content_references content_ref ON content_ref.reference_type = \'api-request\' AND content_ref.reference_value = CAST(used_request.id AS CHAR) WHERE used_request.auth_source_id = auth.id) AS inUse FROM i_api_source_groups auth ORDER BY auth.modified_at DESC, auth.id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_api_source_groups auth',
searchColumns: ['auth.name', 'auth.token_url'], searchTerm: search,
sortColumns: { name: 'auth.name', requests: 'request_count', last_pulled: 'last_pulled_at' },
@@ -64,17 +65,7 @@ async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction)
}
async function getApiRequestUsage(pool) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
const ids = new Set();
function walk(value) {
if (!value || typeof value !== 'object') return;
if (value.source_id) ids.add(Number(value.source_id));
Object.values(value).forEach(walk);
}
slides.forEach(function (slide) { walk(parseJsonSafe(slide.content_json)); });
templateRegions.forEach(function (region) { walk(parseJsonSafe(region.default_content_json)); });
return ids;
return new Set((await getContentReferenceValues(pool, 'api-request')).map(Number).filter(Number.isFinite));
}
async function saveApiGroup(pool, id, payload, actorId, canDelete) {
+198
View File
@@ -0,0 +1,198 @@
const crypto = require('crypto');
const REFERENCE_TYPES_BY_FIELD = {
source_id: 'api-request',
feed_id: 'rss-feed',
timetable_group_id: 'timetable-group',
weather_location_id: 'weather-location',
weather_icon_set_id: 'weather-icon-set'
};
function normalizeMediaReference(value) {
const raw = String(value || '').trim();
if (!raw) return '';
let pathname = raw;
if (/^https?:\/\//i.test(raw)) {
try { pathname = new URL(raw).pathname; } catch (_error) { return ''; }
}
pathname = pathname.split(/[?#]/, 1)[0];
return pathname.startsWith('/media/') ? pathname : '';
}
function normalizeFontFamily(value) {
return String(value || '').trim().replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').toLowerCase();
}
function getFontFamilyCandidates(value) {
return String(value || '').split(',').map(normalizeFontFamily).filter(Boolean);
}
function addReference(references, seen, ownerKey, referenceType, value) {
let referenceValue = String(value === undefined || value === null ? '' : value).trim();
if (['api-request', 'rss-feed', 'timetable-group', 'weather-location'].includes(referenceType)) {
const numericValue = Number(referenceValue);
if (!Number.isFinite(numericValue)) return;
referenceValue = String(numericValue);
}
if (referenceType === 'font-family') referenceValue = normalizeFontFamily(referenceValue);
if (!referenceValue) return;
const key = String(ownerKey || '') + '\u0000' + referenceType + '\u0000' + referenceValue;
if (seen.has(key)) return;
seen.add(key);
references.push({
owner_key: String(ownerKey || ''),
reference_type: referenceType,
reference_value: referenceValue,
reference_hash: crypto.createHash('sha256').update(referenceType + '\u0000' + referenceValue).digest()
});
}
function collectValueReferences(value, ownerKey, references, seen) {
if (Array.isArray(value)) {
value.forEach(function (item) { collectValueReferences(item, ownerKey, references, seen); });
return;
}
if (value && typeof value === 'object') {
Object.keys(value).forEach(function (field) {
const fieldValue = value[field];
const normalizedField = String(field || '').toLowerCase();
if (normalizedField === 'font_family' || normalizedField === 'fontfamily') {
getFontFamilyCandidates(fieldValue).forEach(function (family) {
addReference(references, seen, ownerKey, 'font-family', family);
});
}
const referenceType = REFERENCE_TYPES_BY_FIELD[normalizedField];
if (referenceType) addReference(references, seen, ownerKey, referenceType, fieldValue);
collectValueReferences(fieldValue, ownerKey, references, seen);
});
return;
}
if (typeof value !== 'string') return;
const embeddedMedia = value.match(/(?:https?:\/\/[^\s"'<>]+)?\/media\/[^\s"'<>)]*/gi) || [];
embeddedMedia.forEach(function (mediaValue) {
const normalized = normalizeMediaReference(mediaValue);
if (normalized) addReference(references, seen, ownerKey, 'media', normalized);
});
const fontFamilies = /font-family\s*:\s*(?:"([^"]+)"|'([^']+)'|([^;}"']+))/gi;
let match;
while ((match = fontFamilies.exec(value))) {
getFontFamilyCandidates(match[1] || match[2] || match[3]).forEach(function (family) {
addReference(references, seen, ownerKey, 'font-family', family);
});
}
}
function collectContentReferences(ownerType, ownerId, content) {
const references = [];
const entries = content && typeof content === 'object' && !Array.isArray(content) ? Object.entries(content) : [];
entries.forEach(function (entry) {
const ownerKey = String(entry[0] || '').slice(0, 100);
const seen = new Set();
collectValueReferences(entry[1], ownerKey, references, seen);
});
return references.map(function (reference) {
return Object.assign({ owner_type: ownerType, owner_id: Number(ownerId) }, reference);
});
}
async function insertContentReferences(pool, references) {
const rows = Array.isArray(references) ? references : [];
for (let offset = 0; offset < rows.length; offset += 500) {
const batch = rows.slice(offset, offset + 500).map(function (reference) {
return [reference.owner_type, reference.owner_id, reference.owner_key, reference.reference_type, reference.reference_value, reference.reference_hash];
});
if (!batch.length) continue;
await pool.query(
'INSERT IGNORE INTO c_content_references (owner_type, owner_id, owner_key, reference_type, reference_value, reference_hash) VALUES ?',
[batch]
);
}
}
async function replaceContentReferences(connection, ownerType, ownerId, content) {
const references = collectContentReferences(ownerType, ownerId, content);
const [existing] = await connection.query(
'SELECT id, owner_key, reference_type, reference_value FROM c_content_references WHERE owner_type = ? AND owner_id = ?',
[ownerType, ownerId]
);
const existingByKey = new Map((existing || []).map(function (reference) {
return [JSON.stringify([String(reference.owner_key || ''), String(reference.reference_type || ''), String(reference.reference_value || '')]), reference];
}));
const nextKeys = new Set(references.map(function (reference) {
return JSON.stringify([reference.owner_key, reference.reference_type, reference.reference_value]);
}));
const staleIds = Array.from(existingByKey.entries())
.filter(function (entry) { return !nextKeys.has(entry[0]); })
.map(function (entry) { return entry[1].id; });
const additions = references.filter(function (reference) {
return !existingByKey.has(JSON.stringify([reference.owner_key, reference.reference_type, reference.reference_value]));
});
if (staleIds.length) {
await connection.query(
'DELETE FROM c_content_references WHERE owner_type = ? AND owner_id = ? AND id IN (?)',
[ownerType, ownerId, staleIds]
);
}
await insertContentReferences(connection, additions);
}
async function getContentReferenceValues(pool, referenceType) {
const [rows] = await pool.query(
'SELECT DISTINCT reference_value FROM c_content_references WHERE reference_type = ?',
[referenceType]
);
return (rows || []).map(function (row) { return String(row.reference_value || ''); }).filter(Boolean);
}
async function getContentReferenceOwnerCounts(pool, referenceType) {
const [rows] = await pool.query(
`SELECT reference_value, COUNT(DISTINCT owner_type, owner_id) AS owner_count
FROM c_content_references
WHERE reference_type = ?
GROUP BY reference_value`,
[referenceType]
);
return new Map((rows || []).map(function (row) {
return [String(row.reference_value || ''), Number(row.owner_count) || 0];
}));
}
async function getContentReferenceOwners(pool, referenceType) {
const [rows] = await pool.query(
'SELECT DISTINCT owner_type, owner_id FROM c_content_references WHERE reference_type = ?',
[referenceType]
);
return (rows || []).map(function (row) {
return { ownerType: String(row.owner_type || ''), ownerId: Number(row.owner_id) };
}).filter(function (owner) {
return owner.ownerType && Number.isFinite(owner.ownerId);
});
}
async function countContentReferenceOwners(pool, referenceType, referenceValue) {
const [rows] = await pool.query(
`SELECT COUNT(DISTINCT owner_type, owner_id) AS owner_count
FROM c_content_references
WHERE reference_type = ? AND reference_value = ?`,
[referenceType, String(referenceValue || '')]
);
return Number(rows && rows[0] && rows[0].owner_count) || 0;
}
async function hasContentReference(pool, referenceType, referenceValue) {
return (await countContentReferenceOwners(pool, referenceType, referenceValue)) > 0;
}
module.exports = {
collectContentReferences,
insertContentReferences,
replaceContentReferences,
getContentReferenceValues,
getContentReferenceOwnerCounts,
getContentReferenceOwners,
countContentReferenceOwners,
hasContentReference
};
+15
View File
@@ -198,6 +198,21 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_content_references (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
owner_type VARCHAR(16) NOT NULL,
owner_id BIGINT UNSIGNED NOT NULL,
owner_key VARCHAR(100) NOT NULL,
reference_type VARCHAR(32) NOT NULL,
reference_value VARCHAR(512) NOT NULL,
reference_hash BINARY(32) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_content_reference_owner (owner_type, owner_id, owner_key, reference_type, reference_hash),
INDEX idx_content_reference_lookup (reference_type, reference_value(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_announcements (
id INT AUTO_INCREMENT PRIMARY KEY,
+56
View File
@@ -1,6 +1,8 @@
// Ordered database migrations kept independent from the application version.
const { version: appVersion } = require('#root/package.json');
const { parseJsonSafe } = require('#src/data/utils');
const { collectContentReferences, insertContentReferences } = require('#src/data/content-references');
const TIMETABLE_TIME_ZONE = 'Europe/London';
const APP_STATE_TABLE = 'o_app_state';
const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version';
@@ -769,6 +771,60 @@ const VERSIONED_MIGRATIONS = [
await ensureColumn(pool, 'c_template_regions', 'default_content_json', 'JSON NULL', 'animation_json');
await ensureColumn(pool, 'c_template_regions', 'read_only', 'TINYINT(1) NOT NULL DEFAULT 0', 'default_content_json');
}
},
{
version: '2.19.0',
label: 'v2.19.0 indexed content references',
run: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS c_content_references (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
owner_type VARCHAR(16) NOT NULL,
owner_id BIGINT UNSIGNED NOT NULL,
owner_key VARCHAR(100) NOT NULL,
reference_type VARCHAR(32) NOT NULL,
reference_value VARCHAR(512) NOT NULL,
reference_hash BINARY(32) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_content_reference_owner (owner_type, owner_id, owner_key, reference_type, reference_hash),
INDEX idx_content_reference_lookup (reference_type, reference_value(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await ensureColumn(pool, 'c_content_references', 'id', 'BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY', 'owner_type');
let lastSlideId = 0;
while (true) {
const [slides] = await pool.query(
'SELECT id, content_json FROM c_slides WHERE id > ? AND content_json IS NOT NULL ORDER BY id ASC LIMIT 500',
[lastSlideId]
);
if (!slides.length) break;
const references = [];
slides.forEach(function (slide) {
references.push.apply(references, collectContentReferences('slide', slide.id, parseJsonSafe(slide.content_json) || {}));
});
await insertContentReferences(pool, references);
lastSlideId = Number(slides[slides.length - 1].id);
}
let lastRegionId = 0;
while (true) {
const [regions] = await pool.query(
'SELECT id, template_id, region_key, default_content_json FROM c_template_regions WHERE id > ? AND default_content_json IS NOT NULL ORDER BY id ASC LIMIT 500',
[lastRegionId]
);
if (!regions.length) break;
const references = [];
regions.forEach(function (region) {
const content = parseJsonSafe(region.default_content_json) || {};
const regionContent = {};
regionContent[region.region_key] = content;
references.push.apply(references, collectContentReferences('template', region.template_id, regionContent));
});
await insertContentReferences(pool, references);
lastRegionId = Number(regions[regions.length - 1].id);
}
}
}
];
+4 -15
View File
@@ -2,6 +2,7 @@
const fs = require('fs');
const path = require('path');
const { countContentReferenceOwners } = require('#src/data/content-references');
const crypto = require('crypto');
const sharp = require('sharp');
@@ -247,25 +248,13 @@ async function countMediaAssetReferences(pool, mediaPath) {
if (!normalizedPath) {
return 0;
}
const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM c_slides
WHERE LOCATE(?, CAST(COALESCE(content_json, '') AS CHAR)) > 0`,
[normalizedPath]
);
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', normalizedPath);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[normalizedPath]
);
const [templateRegionRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM c_template_regions
WHERE LOCATE(?, CAST(COALESCE(default_content_json, '') AS CHAR)) > 0`,
[normalizedPath]
);
return Number(slideRows[0] && slideRows[0].ref_count || 0)
+ Number(templateRows[0] && templateRows[0].ref_count || 0)
+ Number(templateRegionRows[0] && templateRegionRows[0].ref_count || 0);
const [thumbnailRows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', [normalizedPath]);
return contentReferenceCount + Number(templateRows[0] && templateRows[0].ref_count || 0) + Number(thumbnailRows[0] && thumbnailRows[0].ref_count || 0);
}
async function publishMediaAssets(pool, mediaPaths) {
+3 -12
View File
@@ -8,6 +8,7 @@ const crypto = require('crypto');
const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { countContentReferenceOwners } = require('#src/data/content-references');
const {
fetchPlayerRegistrations,
getConfiguredPlayerIdentifier,
@@ -368,12 +369,7 @@ function createUploadSyncService(options) {
}
async function countUploadReferences(pool, uploadPath) {
const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM c_slides
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
[uploadPath]
);
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', uploadPath);
const [thumbnailRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
[uploadPath]
@@ -382,12 +378,7 @@ function createUploadSyncService(options) {
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[uploadPath]
);
const [templateRegionRows] = await pool.query(
`SELECT COUNT(*) AS ref_count FROM c_template_regions
WHERE JSON_SEARCH(COALESCE(default_content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
[uploadPath]
);
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0) + Number(templateRegionRows[0].ref_count || 0);
return contentReferenceCount + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
}
async function uploadFileExists(uploadDir, uploadPath) {
+109 -11
View File
@@ -7,6 +7,7 @@ const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library
const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log');
const { replaceContentReferences } = require('#src/data/content-references');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
const { retainLayoutAnimation, getTemplateAnimationSnapshot } = require('#src/web/public/js/shared/region-layout');
@@ -68,6 +69,13 @@ module.exports = function registerContentRoutes(app, deps) {
});
}
function mapTemplateDefaultContent(regions) {
return (Array.isArray(regions) ? regions : []).reduce(function (content, region) {
content[String(region.region_key || '')] = region.default_content || {};
return content;
}, {});
}
async function fetchSlideFormData(includeTemplates) {
const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
@@ -561,6 +569,8 @@ module.exports = function registerContentRoutes(app, deps) {
});
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
let connection;
let transaction = false;
try {
const settings = await validateUploadedFiles(req.files || []);
for (const file of req.files || []) {
@@ -574,10 +584,18 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(400).send('A slide with that title already exists.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
const [result] = await connection.query(
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.title, payload.templateId, payload.contentJson, actorId, actorId]
);
await replaceContentReferences(connection, 'slide', result.insertId, common.parseJsonSafe(payload.contentJson) || {});
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
await syncPlaylistUploadsOnChange({
key: 'slide:create:' + result.insertId,
@@ -595,11 +613,16 @@ module.exports = function registerContentRoutes(app, deps) {
message: 'Slide created.'
});
} catch (error) {
if (transaction && connection) await connection.rollback();
next(error);
} finally {
if (connection) connection.release();
}
});
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
let connection;
let transaction = false;
try {
const settings = await validateUploadedFiles(req.files || []);
for (const file of req.files || []) {
@@ -630,10 +653,24 @@ module.exports = function registerContentRoutes(app, deps) {
templateId: Number(payload.templateId),
content: common.parseJsonSafe(payload.contentJson) || {}
});
await pool.query(
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
const [lockedSlides] = await connection.query('SELECT id FROM c_slides WHERE id = ? FOR UPDATE', [slide.id]);
if (!lockedSlides.length) {
const error = new Error('Slide not found. Reload before saving.');
error.statusCode = 404;
throw error;
}
await connection.query(
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
);
await replaceContentReferences(connection, 'slide', slide.id, common.parseJsonSafe(payload.contentJson) || {});
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
await syncPlaylistUploadsOnChange({
key: 'slide:update:' + slide.id,
@@ -656,11 +693,16 @@ module.exports = function registerContentRoutes(app, deps) {
message: 'Slide updated.'
});
} catch (error) {
if (transaction && connection) await connection.rollback();
next(error);
} finally {
if (connection) connection.release();
}
});
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
let connection;
let transaction = false;
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
@@ -673,7 +715,15 @@ module.exports = function registerContentRoutes(app, deps) {
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const uploadRefs = collectUploadReferencesFromSlide(slide);
await pool.query('DELETE FROM c_slides WHERE id = ?', [slide.id]);
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
await replaceContentReferences(connection, 'slide', slide.id, {});
await connection.query('DELETE FROM c_slides WHERE id = ?', [slide.id]);
await connection.commit();
transaction = false;
connection.release();
connection = null;
await removeMediaFile(slide.thumbnail_path);
await syncPlaylistUploadsOnChange({
key: 'slide:delete:' + slide.id,
@@ -688,7 +738,10 @@ module.exports = function registerContentRoutes(app, deps) {
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.deleted', actorUserId: getAuditUserId(req), targetType: 'slide', targetId: slide.id, targetLabel: slide.title });
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
} catch (error) {
if (transaction && connection) await connection.rollback();
next(error);
} finally {
if (connection) connection.release();
}
});
@@ -720,6 +773,8 @@ module.exports = function registerContentRoutes(app, deps) {
});
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
let connection;
let transaction = false;
try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
@@ -733,17 +788,25 @@ module.exports = function registerContentRoutes(app, deps) {
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
const [result] = await connection.query(
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, background_gradient, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, actorId]
);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
await connection.query(
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json, read_only, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), JSON.stringify(region.default_content || {}), region.read_only ? 1 : 0, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await replaceContentReferences(connection, 'template', result.insertId, mapTemplateDefaultContent(payload.regions));
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
await syncPlaylistUploadsOnChange({
key: 'template:create:' + result.insertId,
@@ -758,7 +821,10 @@ module.exports = function registerContentRoutes(app, deps) {
message: 'Template created.'
});
} catch (error) {
if (transaction && connection) await connection.rollback();
next(error);
} finally {
if (connection) connection.release();
}
});
@@ -952,6 +1018,8 @@ module.exports = function registerContentRoutes(app, deps) {
});
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
let connection;
let transaction = false;
try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
@@ -987,18 +1055,32 @@ module.exports = function registerContentRoutes(app, deps) {
backgroundColor: payload.backgroundColor,
regions: normalizeTemplateRegionsForAudit(payload.regions)
});
await pool.query(
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
const [lockedTemplates] = await connection.query('SELECT id FROM c_templates WHERE id = ? FOR UPDATE', [template.id]);
if (!lockedTemplates.length) {
const error = new Error('Template not found. Reload before saving.');
error.statusCode = 404;
throw error;
}
await connection.query(
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, background_gradient = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, template.id]
);
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
await connection.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
await connection.query(
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json, read_only, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), JSON.stringify(region.default_content || {}), region.read_only ? 1 : 0, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await replaceContentReferences(connection, 'template', template.id, mapTemplateDefaultContent(payload.regions));
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
await syncPlaylistUploadsOnChange({
key: 'template:update:' + template.id,
@@ -1021,11 +1103,16 @@ module.exports = function registerContentRoutes(app, deps) {
message: 'Template updated.'
});
} catch (error) {
if (transaction && connection) await connection.rollback();
next(error);
} finally {
if (connection) connection.release();
}
});
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
let connection;
let transaction = false;
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
@@ -1037,9 +1124,17 @@ module.exports = function registerContentRoutes(app, deps) {
}
const uploadRefs = collectUploadReferencesFromTemplate(template);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
await pool.query('UPDATE c_slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
await pool.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
await connection.query('UPDATE c_slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
await connection.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
await replaceContentReferences(connection, 'template', template.id, {});
await connection.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
await connection.commit();
transaction = false;
connection.release();
connection = null;
await syncPlaylistUploadsOnChange({
key: 'template:delete:' + template.id,
pool: pool,
@@ -1050,7 +1145,10 @@ module.exports = function registerContentRoutes(app, deps) {
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.deleted', actorUserId: getAuditUserId(req), targetType: 'template', targetId: template.id, targetLabel: template.name });
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
} catch (error) {
if (transaction && connection) await connection.rollback();
next(error);
} finally {
if (connection) connection.release();
}
});
@@ -161,7 +161,7 @@ module.exports = function registerApiSourceRoutes(app, deps) {
const usage = await getApiRequestUsage(connection);
if (requests.some(function (request) { return usage.has(Number(request.id)); })) {
await connection.rollback();
return res.redirect(base + '?message=' + encodeURIComponent('This API source has requests used by slides.'));
return res.redirect(base + '?message=' + encodeURIComponent('This API source has requests used by slides or template defaults.'));
}
await connection.query('DELETE FROM i_api_sources WHERE auth_source_id = ?', [Number(req.params.id)]);
await connection.query('DELETE FROM i_api_source_groups WHERE id = ?', [Number(req.params.id)]);
@@ -6,45 +6,10 @@ const renderRssFeedAddPage = require('#src/web/routes/data-sources/rss-feeds/add
const renderRssFeedEditPage = require('#src/web/routes/data-sources/rss-feeds/edit');
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('#src/web/routes/data-sources/rss-feeds/duplicate');
const { fetchAppSettings } = require('#src/data/app-settings');
const { getContentReferenceValues } = require('#src/data/content-references');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
const rssFeedIds = new Set();
function collectUsage(json) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
if (!content || typeof content !== 'object') {
return;
}
(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);
}
slides.forEach(function (slide) { collectUsage(slide.content_json); });
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
return rssFeedIds;
return new Set((await getContentReferenceValues(pool, 'rss-feed')).map(Number).filter(Number.isFinite));
}
async function isRssFeedInUse(pool, common, rssFeedId) {
@@ -5,45 +5,10 @@ const renderTimetableGroupsPage = require('#src/web/routes/data-sources/timetabl
const renderTimetableGroupAddPage = require('#src/web/routes/data-sources/timetables/add');
const renderTimetableGroupEditPage = require('#src/web/routes/data-sources/timetables/edit');
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('#src/web/routes/data-sources/timetables/duplicate');
const { getContentReferenceValues } = require('#src/data/content-references');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
const timetableGroupIds = new Set();
function collectUsage(json) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
if (!content || typeof content !== 'object') {
return;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'timetable_group_id')) {
const timetableGroupId = Number(value.timetable_group_id);
if (Number.isFinite(timetableGroupId)) {
timetableGroupIds.add(timetableGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
slides.forEach(function (slide) { collectUsage(slide.content_json); });
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
return timetableGroupIds;
return new Set((await getContentReferenceValues(pool, 'timetable-group')).map(Number).filter(Number.isFinite));
}
function readTimetableArrayFieldValue(body, keys) {
+2 -22
View File
@@ -7,30 +7,10 @@ const renderWeatherLocationEditPage = require('#src/web/routes/data-sources/weat
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('#src/web/routes/data-sources/weather/duplicate');
const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
const { getContentReferenceValues } = require('#src/data/content-references');
async function getWeatherLocationUsageIds(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
const weatherLocationIds = new Set();
function collectUsage(json) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
if (!content || typeof content !== 'object') return;
(function walk(value) {
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'weather_location_id')) {
const locationId = Number(value.weather_location_id);
if (Number.isFinite(locationId)) weatherLocationIds.add(locationId);
}
Object.keys(value).forEach(function (key) { walk(value[key]); });
}(content));
}
slides.forEach(function (slide) { collectUsage(slide.content_json); });
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
return weatherLocationIds;
return new Set((await getContentReferenceValues(pool, 'weather-location')).map(Number).filter(Number.isFinite));
}
async function isWeatherLocationInUse(pool, common, locationId) {
+7 -29
View File
@@ -3,6 +3,7 @@
const fs = require('fs');
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const fontLibrary = require('#src/web/lib/media/font-library');
const { getContentReferenceValues, hasContentReference } = require('#src/data/content-references');
const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
@@ -14,25 +15,11 @@ function normalizeFontFamilyName(value) {
async function fetchUsedFontFamilies(pool, fonts) {
const usedFamilies = new Set();
for (let i = 0; i < (Array.isArray(fonts) ? fonts.length : 0); i += 1) {
const family = normalizeFontFamilyName(fonts[i] && (fonts[i].family || fonts[i].name));
if (!family) {
continue;
}
const [rows] = await pool.query(
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
[family]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS match_count FROM c_template_regions WHERE LOCATE(LOWER(?), LOWER(CAST(COALESCE(default_content_json, "") AS CHAR))) > 0',
[family]
);
if (Number(rows && rows[0] && rows[0].match_count) > 0 || Number(templateRows && templateRows[0] && templateRows[0].match_count) > 0) {
usedFamilies.add(family);
}
}
const references = new Set((await getContentReferenceValues(pool, 'font-family')).map(normalizeFontFamilyName));
(Array.isArray(fonts) ? fonts : []).forEach(function (font) {
const family = normalizeFontFamilyName(font && (font.family || font.name));
if (family && references.has(family)) usedFamilies.add(family);
});
return usedFamilies;
}
@@ -43,16 +30,7 @@ async function isFontUsed(pool, family) {
return false;
}
const [rows] = await pool.query(
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
[normalizedFamily]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS match_count FROM c_template_regions WHERE LOCATE(LOWER(?), LOWER(CAST(COALESCE(default_content_json, "") AS CHAR))) > 0',
[normalizedFamily]
);
return Number(rows && rows[0] && rows[0].match_count) > 0 || Number(templateRows && templateRows[0] && templateRows[0].match_count) > 0;
return hasContentReference(pool, 'font-family', normalizedFamily);
}
async function buildFontsPageData(pool, uploadDir, req, common) {
+13 -32
View File
@@ -2,23 +2,12 @@ const multer = require('multer');
const { renderView } = require('#src/web/view');
const library = require('#src/web/lib/media/weather-icon-library');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { getContentReferenceOwnerCounts, getContentReferenceOwners, hasContentReference } = require('#src/data/content-references');
const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
async function isWeatherIconSetUsed(pool, iconSetId) {
const [slides] = await pool.query('SELECT content_json FROM c_slides');
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
function matches(value) {
let content = value;
if (typeof content === 'string') {
try { content = JSON.parse(content); } catch (_error) { content = null; }
}
if (!content || typeof content !== 'object') return false;
if (!Array.isArray(content) && String(content.weather_icon_set_id || '') === String(iconSetId)) return true;
return (Array.isArray(content) ? content : Object.values(content)).some(matches);
}
return slides.some(function (slide) { return matches(slide.content_json); })
|| templateRegions.some(function (region) { return matches(region.default_content_json); });
return hasContentReference(pool, 'weather-icon-set', iconSetId);
}
module.exports = function registerWeatherIconRoutes(app, deps) {
@@ -28,23 +17,12 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }];
});
async function page(req, res, message, status) {
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
const [templateRegions] = await deps.pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
const usageCounts = await getContentReferenceOwnerCounts(deps.pool, 'weather-icon-set');
const search = getSearchQuery(req);
const sort = getSortQuery(req);
const direction = getSortDirectionQuery(req);
const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) {
const slideUsageCount = slides.filter(function (slide) {
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
return Object.values(content).some(function (region) {
return region && region.weather_icon_set_id === set.id;
});
}).length;
const templateUsageCount = templateRegions.filter(function (region) {
const content = typeof region.default_content_json === 'string' ? JSON.parse(region.default_content_json || '{}') : region.default_content_json || {};
return content && String(content.weather_icon_set_id || '') === String(set.id);
}).length;
const usageCount = slideUsageCount + templateUsageCount;
const usageCount = usageCounts.get(String(set.id)) || 0;
return Object.assign({}, set, { usageCount: usageCount, conditions: library.CONDITIONS.map(function (condition) {
return Object.assign({}, condition, { image: set.icons[condition.key], nightImage: (set.nightIcons || {})[condition.key], setId: set.id });
}) });
@@ -65,12 +43,15 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-sync:' + player.identifier + ':' + Date.now(), title: 'Weather icon sync', category: 'media', taskType: 'font-sync',
payload: { uploadDir: deps.mediaDir, operations: library.collectWeatherIconSyncOperations(deps.mediaDir).concat(extraOperations || []), playerIdentifier: player.identifier, requireSuccess: true }, persist: true });
}
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
for (const slide of slides) {
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
if (Object.values(content).some(function (region) { return region && region.weather_icon_set_id; })) {
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-thumbnail:' + slide.id + ':' + Date.now(), title: 'Weather slide thumbnail refresh', category: 'media', taskType: 'slide-thumbnail-refresh', payload: { slideId: slide.id }, persist: true });
}
const owners = await getContentReferenceOwners(deps.pool, 'weather-icon-set');
const slideIds = new Set(owners.filter(function (owner) { return owner.ownerType === 'slide'; }).map(function (owner) { return owner.ownerId; }));
const templateIds = Array.from(new Set(owners.filter(function (owner) { return owner.ownerType === 'template'; }).map(function (owner) { return owner.ownerId; })));
if (templateIds.length) {
const [templateSlides] = await deps.pool.query('SELECT id FROM c_slides WHERE template_id IN (?)', [templateIds]);
templateSlides.forEach(function (slide) { slideIds.add(Number(slide.id)); });
}
for (const slideId of slideIds) {
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-thumbnail:' + slideId + ':' + Date.now(), title: 'Weather slide thumbnail refresh', category: 'media', taskType: 'slide-thumbnail-refresh', payload: { slideId: slideId }, persist: true });
}
}
}
@@ -82,7 +82,7 @@
<button type="button" class="btn btn-primary" data-api-request-add {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
</div>
<div class="d-flex align-items-center justify-content-end flex-wrap ms-auto">
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage="Delete this API source?" deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</div>
</div>
@@ -40,7 +40,7 @@
</div>
<div class="card-footer d-flex justify-content-end align-items-center">
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage="Delete this RSS feed?" deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</div>
</form>
+1 -1
View File
@@ -15,7 +15,7 @@
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
</div>
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage="Delete this weather location?" deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
</div>
</form>
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
+30 -3
View File
@@ -1,6 +1,6 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildApiGroupPayload, saveApiGroup, fetchApiGroupById } = require('../src/data/api-source-groups');
const { buildApiGroupPayload, saveApiGroup, fetchApiGroupById, fetchApiGroupsPage } = require('../src/data/api-source-groups');
const { getPendingMigrations } = require('../src/db/migrations');
const { fetchApiSourcesData, fetchApiSourceById, testApiSourceAuthentication } = require('../src/data/api-sources');
@@ -20,8 +20,18 @@ function createPool(existing, slides, groupEnabled = 1, templateDefaults = []) {
if (sql.includes('SELECT id, enabled FROM i_api_source_groups WHERE id')) return [[{ id: 5, enabled: groupEnabled }]];
if (sql.includes('SELECT id FROM i_api_source_groups WHERE name')) return [[]];
if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))];
if (sql.includes('SELECT default_content_json FROM c_template_regions')) return [templateDefaults.map(value => ({ default_content_json: value }))];
if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))];
if (sql.includes('FROM c_content_references')) {
const ids = new Set();
function walk(value) {
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) return value.forEach(walk);
if (value.source_id !== undefined && value.source_id !== null && value.source_id !== '') ids.add(String(Number(value.source_id)));
Object.values(value).forEach(walk);
}
(slides || []).forEach(value => walk(JSON.parse(value)));
(templateDefaults || []).forEach(value => walk(typeof value === 'string' ? JSON.parse(value) : value));
return [[...ids].map(reference_value => ({ reference_value }))];
}
if (sql.startsWith('INSERT INTO i_api_sources')) return [{ insertId: 99 }];
if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }];
return [{}];
@@ -30,6 +40,23 @@ function createPool(existing, slides, groupEnabled = 1, templateDefaults = []) {
return { calls, async getConnection() { return connection; } };
}
test('API source list marks groups with indexed request usage', async () => {
const queries = [];
const pool = {
async query(sql) {
queries.push(sql);
if (sql.includes('SELECT COUNT(*) AS count FROM i_api_source_groups')) return [[{ count: 1 }]];
if (sql.includes('FROM i_api_source_groups auth')) return [[{ id: 4, name: 'Product Metrics', request_count: 1, inUse: 1 }]];
return [[]];
}
};
const result = await fetchApiGroupsPage(pool, 1, 25, '', '', '');
const listQuery = queries.find(sql => sql.includes('AS inUse'));
assert.ok(listQuery);
assert.match(listQuery, /c_content_references/);
assert.equal(result.apiSources[0].inUse, 1);
});
test('group payload supports public requests and independent request settings', () => {
const group = payload([{ id: 5, name: 'Event', api_url: 'https://example.com/event', update_interval_value: 2, update_interval_unit: 'hours' }, { name: 'Donors', api_url: 'https://example.com/donors', items_path: 'data', update_interval_value: 30, update_interval_unit: 'seconds', enabled: false }]);
assert.equal(group.auth.auth_method, 'none');
+5
View File
@@ -54,6 +54,11 @@ test('api source duplicate form model keeps info toast variant', () => {
assert.equal(model.showSaveSecondaryActions, true);
});
test('API source edit delete action includes a confirmation message', () => {
const template = fs.readFileSync(path.join(__dirname, '../src/web/views/data-sources/api-sources/form.hbs'), 'utf8');
assert.match(template, /deleteConfirmMessage="Delete this API source\?"/);
});
test('API source form normalization is shared by add, edit, and duplication', () => {
const source = { id: 9, name: 'Tiltify', auth_method: 'token_login', token_url: 'https://example.com/token', token_response_path: 'data.token', token_header_prefix: '', update_interval_value: 30, update_interval_unit: 'seconds', enabled: 0 };
const add = buildApiSourceFormViewModel(source, '', null, {}, false).apiSource;
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { collectContentReferences, replaceContentReferences } = require('../src/data/content-references');
test('content reference extraction indexes typed sources, media paths, and fonts by owner region', () => {
const references = collectContentReferences('template', 42, {
brand: {
type: 'html',
value: '<img src="https://signage.example/media/uploads/brand.png"><span style="font-family: \'Acme Display\', sans-serif">Brand</span>',
font_family: 'Acme Display'
},
agenda: { type: 'timetable', timetable_group_id: 7 },
feed: { type: 'rss', feed_id: '8' },
forecast: { type: 'weather', weather_location_id: 9, weather_icon_set_id: 'night' },
api: { type: 'api', source_id: 10 }
});
const values = references.map(reference => [reference.owner_key, reference.reference_type, reference.reference_value]);
assert.deepEqual(values, [
['brand', 'media', '/media/uploads/brand.png'],
['brand', 'font-family', 'acme display'],
['agenda', 'timetable-group', '7'],
['feed', 'rss-feed', '8'],
['forecast', 'weather-location', '9'],
['forecast', 'weather-icon-set', 'night'],
['api', 'api-request', '10']
]);
assert.ok(references.every(reference => reference.owner_type === 'template' && reference.owner_id === 42 && Buffer.isBuffer(reference.reference_hash)));
});
test('content reference extraction deduplicates repeated references within a region', () => {
const references = collectContentReferences('slide', 12, {
hero: { value: '/media/uploads/a.png', secondary: '/media/uploads/a.png', source_id: 4, copy: { source_id: 4 } }
});
assert.deepEqual(references.map(reference => [reference.reference_type, reference.reference_value]), [
['media', '/media/uploads/a.png'],
['api-request', '4']
]);
});
test('content reference replacement preserves unchanged rows and only deletes or inserts the delta', async () => {
const calls = [];
const connection = {
async query(sql, params) {
calls.push([sql, params]);
if (sql.startsWith('SELECT id, owner_key')) {
return [[
{ id: 101, owner_key: 'hero', reference_type: 'media', reference_value: '/media/uploads/keep.png' },
{ id: 102, owner_key: 'api', reference_type: 'api-request', reference_value: '4' },
{ id: 103, owner_key: 'old', reference_type: 'font-family', reference_value: 'arial' }
]];
}
return [{}];
}
};
await replaceContentReferences(connection, 'slide', 22, {
hero: { value: '/media/uploads/keep.png' },
api: { source_id: 5 },
feed: { feed_id: 8 }
});
const deletion = calls.find(([sql]) => sql.startsWith('DELETE FROM c_content_references'));
assert.deepEqual(deletion[1], ['slide', 22, [102, 103]]);
const insertion = calls.find(([sql]) => sql.startsWith('INSERT IGNORE INTO c_content_references'));
assert.equal(insertion[1][0].length, 2);
assert.deepEqual(insertion[1][0].map(row => [row[3], row[4]]), [['api-request', '5'], ['rss-feed', '8']]);
assert.ok(!calls.some(([sql]) => sql === 'DELETE FROM c_content_references WHERE owner_type = ? AND owner_id = ?'));
});
+2 -1
View File
@@ -198,7 +198,8 @@ test('template default font references mark fonts in use and block deletion', as
registerFontRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('FROM c_template_regions')) return [[{ match_count: 1 }]];
if (sql.includes('FROM c_content_references') && sql.includes('COUNT(DISTINCT')) return [[{ owner_count: 1 }]];
if (sql.includes('FROM c_content_references')) return [[{ reference_value: 'alpha sans' }]];
return [[{ match_count: 0 }]];
}
},
+10 -6
View File
@@ -171,22 +171,26 @@ test('media library counts slide, template background, and region default refere
const pool = {
async query(sql, params) {
queries.push({ sql, params });
if (sql.includes('FROM c_slides')) {
if (sql.includes('FROM c_content_references')) {
return [[{ owner_count: 3 }]];
}
if (sql.includes('thumbnail_path')) {
return [[{ ref_count: 2 }]];
}
if (sql.includes('FROM c_templates')) return [[{ ref_count: 1 }]];
return [[{ ref_count: 3 }]];
return [[]];
}
};
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 6);
assert.equal(queries.length, 3);
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
assert.deepEqual(queries[0].params, ['media', '/media/uploads/shared.png']);
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
assert.equal(queries[2].params[0], '/media/uploads/shared.png');
assert.match(queries[0].sql, /LOCATE/);
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
assert.match(queries[2].sql, /default_content_json/);
assert.match(queries[0].sql, /c_content_references/);
assert.match(queries[0].sql, /idx_content_reference_lookup|reference_type/);
assert.match(queries[1].sql, /background_image_path/);
assert.match(queries[2].sql, /thumbnail_path/);
});
test('media library upload and delete routes audit successful asset changes', async () => {
+6
View File
@@ -39,6 +39,12 @@ test('rss feed duplicate form model keeps info toast variant and secondary actio
assert.equal(model.showSaveSecondaryActions, true);
});
test('RSS feed edit delete action includes a confirmation message', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8');
assert.match(template, /deleteConfirmMessage="Delete this RSS feed\?"/);
});
test('rss feed list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'list.hbs'), 'utf8');
+40
View File
@@ -216,3 +216,43 @@ test('template region defaults migration upgrades a 2.17.5 installation', async
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN default_content_json JSON NULL')));
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN read_only TINYINT(1) NOT NULL DEFAULT 0')));
});
test('2.19.0 indexed-reference migration backfills slide and template defaults', async () => {
const queries = [];
const insertedReferences = [];
const pool = {
queries,
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('FROM information_schema.COLUMNS')) return [[{ column_count: 1 }]];
if (text.includes('FROM information_schema.TABLES')) return [[{ table_count: 0 }]];
if (text.startsWith('SELECT id, content_json FROM c_slides')) {
return Number(params[0]) === 0
? [[{ id: 2, content_json: JSON.stringify({ hero: { type: 'image', value: '/media/uploads/hero.png' } }) }]]
: [[]];
}
if (text.startsWith('SELECT id, template_id, region_key, default_content_json FROM c_template_regions')) {
return Number(params[0]) === 0
? [[{ id: 7, template_id: 4, region_key: 'headline', default_content_json: { type: 'text', font_family: 'Acme Display' } }]]
: [[]];
}
if (text.startsWith('INSERT IGNORE INTO c_content_references')) {
insertedReferences.push.apply(insertedReferences, params[0]);
}
return [[]];
}
};
const pending = await getPendingMigrations(pool, { currentVersion: '2.18.0' });
const migration = pending.find(item => item.version === '2.19.0');
assert.ok(migration);
await migration.run(pool);
assert.ok(queries.some(([sql]) => sql.includes('CREATE TABLE IF NOT EXISTS c_content_references')));
assert.ok(queries.some(([sql]) => sql.includes('id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY')));
assert.ok(queries.some(([sql]) => sql.includes('UNIQUE KEY uq_content_reference_owner')));
assert.ok(queries.every(([sql]) => !sql.includes('idx_content_reference_owner')));
assert.ok(insertedReferences.some(row => row[0] === 'slide' && row[1] === 2 && row[3] === 'media' && row[4] === '/media/uploads/hero.png'));
assert.ok(insertedReferences.some(row => row[0] === 'template' && row[1] === 4 && row[2] === 'headline' && row[3] === 'font-family' && row[4] === 'acme display'));
});
+16 -3
View File
@@ -5,12 +5,25 @@ const { getDataSourceUsageMaps: getRssUsage } = require('../src/web/routes/data-
const { getDataSourceUsageMaps: getTimetableUsage } = require('../src/web/routes/data-sources/timetables/routes');
const { getWeatherLocationUsageIds } = require('../src/web/routes/data-sources/weather');
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
const { collectContentReferences } = require('../src/data/content-references');
function createPool(slideContent, templateContent) {
const references = collectContentReferences('slide', 1, { slide: slideContent })
.concat(collectContentReferences('template', 2, { region: templateContent }));
return {
async query(sql) {
if (sql.includes('FROM c_slides')) return [[{ content_json: JSON.stringify(slideContent) }]];
return [[{ default_content_json: JSON.stringify(templateContent) }]];
async query(sql, params) {
if (!sql.includes('FROM c_content_references')) throw new Error('Unexpected query: ' + sql);
const referenceType = params[0];
const matching = references.filter(function (reference) {
return reference.reference_type === referenceType && (params.length < 2 || reference.reference_value === String(params[1]));
});
if (sql.includes('COUNT(DISTINCT')) return [[{ owner_count: matching.length ? 1 : 0 }]];
if (sql.includes('GROUP BY reference_value')) {
const counts = new Map();
matching.forEach(function (reference) { counts.set(reference.reference_value, (counts.get(reference.reference_value) || 0) + 1); });
return [Array.from(counts, ([reference_value, owner_count]) => ({ reference_value, owner_count }))];
}
return [Array.from(new Set(matching.map(reference => reference.reference_value)), reference_value => ({ reference_value }))];
}
};
}
+6
View File
@@ -47,3 +47,9 @@ test('weather list template includes duplicate action', () => {
assert.match(template, /\/data-sources\/weather\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
test('weather edit delete action includes a confirmation message', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'form.hbs'), 'utf8');
assert.match(template, /deleteConfirmMessage="Delete this weather location\?"/);
});
+44 -8
View File
@@ -7,6 +7,7 @@ const sharp = require('sharp');
require('../src/common');
const library = require('../src/web/lib/media/weather-icon-library');
const weatherIcons = require('../src/web/public/js/shared/weather-icons');
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
const vm = require('node:vm');
const placeholderContext = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), placeholderContext);
@@ -206,15 +207,50 @@ test('weather sets require every condition image and support replacement', async
}
});
test('weather icon usage recognizes one set reference', function () {
const content = { weather: { weather_icon_set_id: 'set' } };
const isUsed = function (setId) {
return Object.values(content).some(function (region) {
return region && region.weather_icon_set_id === setId;
});
test('weather icon usage guard checks the indexed reference value', async function () {
const pool = { async query(_sql, params) { return [[{ owner_count: params[1] === 'set' ? 1 : 0 }]]; } };
assert.equal(await isWeatherIconSetUsed(pool, 'set'), true);
assert.equal(await isWeatherIconSetUsed(pool, 'other'), false);
});
test('weather icon sync refreshes slides using direct and template-default references once', async function () {
const originalSetWeatherIconSetEnabled = library.setWeatherIconSetEnabled;
const originalCollectWeatherIconSyncOperations = library.collectWeatherIconSyncOperations;
const handlers = {};
const tasks = [];
const app = {
get(route, ...callbacks) { handlers['GET ' + route] = callbacks; },
post(route, ...callbacks) { handlers['POST ' + route] = callbacks; }
};
assert.equal(isUsed('set'), true);
assert.equal(isUsed('other'), false);
library.setWeatherIconSetEnabled = function (_root, id, enabled) { return { id: id, name: 'Set', enabled: enabled }; };
library.collectWeatherIconSyncOperations = function () { return []; };
try {
require('../src/web/routes/settings/weather-icons')(app, {
mediaDir: 'unused',
pool: {
async query(sql) {
if (sql.includes('FROM d_players')) return [[{ id: 1, identifier: 'player-1' }]];
if (sql.includes('SELECT DISTINCT owner_type, owner_id')) return [[
{ owner_type: 'slide', owner_id: 22 },
{ owner_type: 'template', owner_id: 7 }
]];
if (sql.includes('FROM c_slides WHERE template_id IN')) return [[{ id: 23 }, { id: 22 }]];
return [[]];
}
},
backgroundTaskQueue: { async enqueueTask(task) { tasks.push(task); } },
requirePermission() { return function (_req, _res, next) { next(); }; }
});
const response = { redirect() { return this; } };
await handlers['POST /settings/weather-icons/:id/toggle'].at(-1)(
{ body: { enabled: '0' }, params: { id: 'set' } }, response, function (error) { if (error) throw error; }
);
assert.deepEqual(tasks.filter(function (task) { return task.taskType === 'slide-thumbnail-refresh'; }).map(function (task) { return task.payload.slideId; }).sort(), [22, 23]);
} finally {
library.setWeatherIconSetEnabled = originalSetWeatherIconSetEnabled;
library.collectWeatherIconSyncOperations = originalCollectWeatherIconSyncOperations;
}
});
test('weather icon set create, update, toggle, and delete are audited', async function () {