diff --git a/CHANGELOG.md b/CHANGELOG.md index 789335c..d60f96d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build/package.player.json b/build/package.player.json index ebf4720..73645c9 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -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": { diff --git a/build/package.web.json b/build/package.web.json index ba8c084..f7a352c 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index 6ca9a68..43ff81e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ff9108d..4b4aa06 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/data/api-source-groups.js b/src/data/api-source-groups.js index e51780c..9fd0eb7 100644 --- a/src/data/api-source-groups.js +++ b/src/data/api-source-groups.js @@ -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) { diff --git a/src/data/content-references.js b/src/data/content-references.js new file mode 100644 index 0000000..e3aadaa --- /dev/null +++ b/src/data/content-references.js @@ -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 +}; \ No newline at end of file diff --git a/src/db/index.js b/src/db/index.js index 80837c0..ff56dde 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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, diff --git a/src/db/migrations.js b/src/db/migrations.js index 1336487..7989114 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -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); + } + } } ]; diff --git a/src/web/lib/media/library.js b/src/web/lib/media/library.js index 960ce5a..5820a94 100644 --- a/src/web/lib/media/library.js +++ b/src/web/lib/media/library.js @@ -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) { diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 4455033..7a4df14 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -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) { diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 9842cf6..56709b2 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -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(); } }); diff --git a/src/web/routes/data-sources/api-sources/routes.js b/src/web/routes/data-sources/api-sources/routes.js index d3bc6b3..2381cf1 100644 --- a/src/web/routes/data-sources/api-sources/routes.js +++ b/src/web/routes/data-sources/api-sources/routes.js @@ -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)]); diff --git a/src/web/routes/data-sources/rss-feeds/routes.js b/src/web/routes/data-sources/rss-feeds/routes.js index 32a309a..c112772 100644 --- a/src/web/routes/data-sources/rss-feeds/routes.js +++ b/src/web/routes/data-sources/rss-feeds/routes.js @@ -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) { diff --git a/src/web/routes/data-sources/timetables/routes.js b/src/web/routes/data-sources/timetables/routes.js index 3f8dee5..ae50cf3 100644 --- a/src/web/routes/data-sources/timetables/routes.js +++ b/src/web/routes/data-sources/timetables/routes.js @@ -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) { diff --git a/src/web/routes/data-sources/weather.js b/src/web/routes/data-sources/weather.js index 9118bd5..17e0f96 100644 --- a/src/web/routes/data-sources/weather.js +++ b/src/web/routes/data-sources/weather.js @@ -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) { diff --git a/src/web/routes/settings/fonts.js b/src/web/routes/settings/fonts.js index 46f65ba..6ac606c 100644 --- a/src/web/routes/settings/fonts.js +++ b/src/web/routes/settings/fonts.js @@ -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) { diff --git a/src/web/routes/settings/weather-icons.js b/src/web/routes/settings/weather-icons.js index 41bfe83..f93480f 100644 --- a/src/web/routes/settings/weather-icons.js +++ b/src/web/routes/settings/weather-icons.js @@ -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 }); } } } diff --git a/src/web/views/data-sources/api-sources/form.hbs b/src/web/views/data-sources/api-sources/form.hbs index f5ca8bd..381c704 100644 --- a/src/web/views/data-sources/api-sources/form.hbs +++ b/src/web/views/data-sources/api-sources/form.hbs @@ -82,7 +82,7 @@
Brand',
+ 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 = ?'));
+});
\ No newline at end of file
diff --git a/test/fonts-routes.test.js b/test/fonts-routes.test.js
index e18b03c..79e36b7 100644
--- a/test/fonts-routes.test.js
+++ b/test/fonts-routes.test.js
@@ -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 }]];
}
},
diff --git a/test/media-library.test.js b/test/media-library.test.js
index 0ffe21a..946ca81 100644
--- a/test/media-library.test.js
+++ b/test/media-library.test.js
@@ -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 () => {
diff --git a/test/rss-feeds-duplicate.test.js b/test/rss-feeds-duplicate.test.js
index 46a036d..f839867 100644
--- a/test/rss-feeds-duplicate.test.js
+++ b/test/rss-feeds-duplicate.test.js
@@ -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');
diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js
index c1a3630..9c78ed0 100644
--- a/test/schema-update-log.test.js
+++ b/test/schema-update-log.test.js
@@ -215,4 +215,44 @@ 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'));
});
\ No newline at end of file
diff --git a/test/template-default-dependencies.test.js b/test/template-default-dependencies.test.js
index 47bdf4d..0c7ddea 100644
--- a/test/template-default-dependencies.test.js
+++ b/test/template-default-dependencies.test.js
@@ -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 }))];
}
};
}
diff --git a/test/weather-duplicate.test.js b/test/weather-duplicate.test.js
index de0fe4b..dec2f8d 100644
--- a/test/weather-duplicate.test.js
+++ b/test/weather-duplicate.test.js
@@ -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\?"/);
+});
diff --git a/test/weather-icon-library.test.js b/test/weather-icon-library.test.js
index 12c7bd3..e4be4be 100644
--- a/test/weather-icon-library.test.js
+++ b/test/weather-icon-library.test.js
@@ -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 () {