Release v2.19.0: indexed content references
This commit is contained in:
1 parent
24f1603b43
commit
b59fe08298
31 files changed
+663
-237
No files matched your search
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.18.0 - 2026-10-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.18.0",
|
"version": "2.19.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.18.0",
|
"version": "2.19.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.18.0",
|
"version": "2.19.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.18.0",
|
"version": "2.19.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.13.1",
|
"bootstrap-icons": "1.13.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.18.0",
|
"version": "2.19.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { API_AUTH_FIELDS, API_REQUEST_SELECT, buildApiSourcePayload } = require('#src/data/api-sources');
|
const { API_AUTH_FIELDS, API_REQUEST_SELECT, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||||
const { fetchPagedRows, validateMaxLength, parseJsonSafe } = require('#src/data/utils');
|
const { fetchPagedRows, validateMaxLength, parseJsonSafe } = require('#src/data/utils');
|
||||||
|
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||||
|
|
||||||
function toFormFields(value) {
|
function toFormFields(value) {
|
||||||
const fields = Object.assign({}, value);
|
const fields = Object.assign({}, value);
|
||||||
@@ -54,7 +55,7 @@ async function fetchApiGroupById(pool, id) {
|
|||||||
|
|
||||||
async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) {
|
async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) {
|
||||||
const data = await fetchPagedRows(pool, {
|
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',
|
countSql: 'SELECT COUNT(*) AS count FROM i_api_source_groups auth',
|
||||||
searchColumns: ['auth.name', 'auth.token_url'], searchTerm: search,
|
searchColumns: ['auth.name', 'auth.token_url'], searchTerm: search,
|
||||||
sortColumns: { name: 'auth.name', requests: 'request_count', last_pulled: 'last_pulled_at' },
|
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) {
|
async function getApiRequestUsage(pool) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
return new Set((await getContentReferenceValues(pool, 'api-request')).map(Number).filter(Number.isFinite));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveApiGroup(pool, id, payload, actorId, canDelete) {
|
async function saveApiGroup(pool, id, payload, actorId, canDelete) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -198,6 +198,21 @@ async function ensureSchema(pool, options) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) 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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS d_announcements (
|
CREATE TABLE IF NOT EXISTS d_announcements (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Ordered database migrations kept independent from the application version.
|
// Ordered database migrations kept independent from the application version.
|
||||||
|
|
||||||
const { version: appVersion } = require('#root/package.json');
|
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 TIMETABLE_TIME_ZONE = 'Europe/London';
|
||||||
const APP_STATE_TABLE = 'o_app_state';
|
const APP_STATE_TABLE = 'o_app_state';
|
||||||
const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version';
|
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', 'default_content_json', 'JSON NULL', 'animation_json');
|
||||||
await ensureColumn(pool, 'c_template_regions', 'read_only', 'TINYINT(1) NOT NULL DEFAULT 0', 'default_content_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { countContentReferenceOwners } = require('#src/data/content-references');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
|
||||||
@@ -247,25 +248,13 @@ async function countMediaAssetReferences(pool, mediaPath) {
|
|||||||
if (!normalizedPath) {
|
if (!normalizedPath) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const [slideRows] = await pool.query(
|
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', normalizedPath);
|
||||||
`SELECT COUNT(*) AS ref_count
|
|
||||||
FROM c_slides
|
|
||||||
WHERE LOCATE(?, CAST(COALESCE(content_json, '') AS CHAR)) > 0`,
|
|
||||||
[normalizedPath]
|
|
||||||
);
|
|
||||||
const [templateRows] = await pool.query(
|
const [templateRows] = await pool.query(
|
||||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||||
[normalizedPath]
|
[normalizedPath]
|
||||||
);
|
);
|
||||||
const [templateRegionRows] = await pool.query(
|
const [thumbnailRows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', [normalizedPath]);
|
||||||
`SELECT COUNT(*) AS ref_count
|
return contentReferenceCount + Number(templateRows[0] && templateRows[0].ref_count || 0) + Number(thumbnailRows[0] && thumbnailRows[0].ref_count || 0);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishMediaAssets(pool, mediaPaths) {
|
async function publishMediaAssets(pool, mediaPaths) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const crypto = require('crypto');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
|
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
|
||||||
|
const { countContentReferenceOwners } = require('#src/data/content-references');
|
||||||
const {
|
const {
|
||||||
fetchPlayerRegistrations,
|
fetchPlayerRegistrations,
|
||||||
getConfiguredPlayerIdentifier,
|
getConfiguredPlayerIdentifier,
|
||||||
@@ -368,12 +369,7 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function countUploadReferences(pool, uploadPath) {
|
async function countUploadReferences(pool, uploadPath) {
|
||||||
const [slideRows] = await pool.query(
|
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', uploadPath);
|
||||||
`SELECT COUNT(*) AS ref_count
|
|
||||||
FROM c_slides
|
|
||||||
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
|
||||||
[uploadPath]
|
|
||||||
);
|
|
||||||
const [thumbnailRows] = await pool.query(
|
const [thumbnailRows] = await pool.query(
|
||||||
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
|
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
|
||||||
[uploadPath]
|
[uploadPath]
|
||||||
@@ -382,12 +378,7 @@ function createUploadSyncService(options) {
|
|||||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||||
[uploadPath]
|
[uploadPath]
|
||||||
);
|
);
|
||||||
const [templateRegionRows] = await pool.query(
|
return contentReferenceCount + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
|
||||||
`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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFileExists(uploadDir, uploadPath) {
|
async function uploadFileExists(uploadDir, uploadPath) {
|
||||||
|
|||||||
+109
-11
@@ -7,6 +7,7 @@ const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library
|
|||||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
const { replaceContentReferences } = require('#src/data/content-references');
|
||||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||||
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
|
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
|
||||||
const { retainLayoutAnimation, getTemplateAnimationSnapshot } = require('#src/web/public/js/shared/region-layout');
|
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) {
|
async function fetchSlideFormData(includeTemplates) {
|
||||||
const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool);
|
const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool);
|
||||||
const rssData = await common.fetchRssFeedsData(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) {
|
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||||
|
let connection;
|
||||||
|
let transaction = false;
|
||||||
try {
|
try {
|
||||||
const settings = await validateUploadedFiles(req.files || []);
|
const settings = await validateUploadedFiles(req.files || []);
|
||||||
for (const file of 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.');
|
return res.status(400).send('A slide with that title already exists.');
|
||||||
}
|
}
|
||||||
const actorId = getAuditUserId(req);
|
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 (?, ?, ?, ?, ?)',
|
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||||
[payload.title, payload.templateId, payload.contentJson, actorId, actorId]
|
[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 publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
|
||||||
await syncPlaylistUploadsOnChange({
|
await syncPlaylistUploadsOnChange({
|
||||||
key: 'slide:create:' + result.insertId,
|
key: 'slide:create:' + result.insertId,
|
||||||
@@ -595,11 +613,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
message: 'Slide created.'
|
message: 'Slide created.'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (transaction && connection) await connection.rollback();
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||||
|
let connection;
|
||||||
|
let transaction = false;
|
||||||
try {
|
try {
|
||||||
const settings = await validateUploadedFiles(req.files || []);
|
const settings = await validateUploadedFiles(req.files || []);
|
||||||
for (const file of req.files || []) {
|
for (const file of req.files || []) {
|
||||||
@@ -630,10 +653,24 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
templateId: Number(payload.templateId),
|
templateId: Number(payload.templateId),
|
||||||
content: common.parseJsonSafe(payload.contentJson) || {}
|
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 = ?',
|
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.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 publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
|
||||||
await syncPlaylistUploadsOnChange({
|
await syncPlaylistUploadsOnChange({
|
||||||
key: 'slide:update:' + slide.id,
|
key: 'slide:update:' + slide.id,
|
||||||
@@ -656,11 +693,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
message: 'Slide updated.'
|
message: 'Slide updated.'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (transaction && connection) await connection.rollback();
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
||||||
|
let connection;
|
||||||
|
let transaction = false;
|
||||||
try {
|
try {
|
||||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||||
if (!slide) {
|
if (!slide) {
|
||||||
@@ -673,7 +715,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
||||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||||
const uploadRefs = collectUploadReferencesFromSlide(slide);
|
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 removeMediaFile(slide.thumbnail_path);
|
||||||
await syncPlaylistUploadsOnChange({
|
await syncPlaylistUploadsOnChange({
|
||||||
key: 'slide:delete:' + slide.id,
|
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 });
|
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.'));
|
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (transaction && connection) await connection.rollback();
|
||||||
next(error);
|
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) {
|
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||||
|
let connection;
|
||||||
|
let transaction = false;
|
||||||
try {
|
try {
|
||||||
await validateUploadedFiles(req.files || []);
|
await validateUploadedFiles(req.files || []);
|
||||||
await registerMediaAssets(pool, req.files, function (file) {
|
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.'));
|
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||||
}
|
}
|
||||||
const actorId = getAuditUserId(req);
|
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 (?, ?, ?, ?, ?, ?, ?)',
|
'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]
|
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, actorId]
|
||||||
);
|
);
|
||||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||||
const region = payload.regions[i];
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'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]
|
[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 publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
|
||||||
await syncPlaylistUploadsOnChange({
|
await syncPlaylistUploadsOnChange({
|
||||||
key: 'template:create:' + result.insertId,
|
key: 'template:create:' + result.insertId,
|
||||||
@@ -758,7 +821,10 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
message: 'Template created.'
|
message: 'Template created.'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (transaction && connection) await connection.rollback();
|
||||||
next(error);
|
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) {
|
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
|
||||||
|
let connection;
|
||||||
|
let transaction = false;
|
||||||
try {
|
try {
|
||||||
await validateUploadedFiles(req.files || []);
|
await validateUploadedFiles(req.files || []);
|
||||||
await registerMediaAssets(pool, req.files, function (file) {
|
await registerMediaAssets(pool, req.files, function (file) {
|
||||||
@@ -987,18 +1055,32 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
backgroundColor: payload.backgroundColor,
|
backgroundColor: payload.backgroundColor,
|
||||||
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
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 = ?',
|
'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]
|
[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) {
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||||
const region = payload.regions[i];
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'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]
|
[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 publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
|
||||||
await syncPlaylistUploadsOnChange({
|
await syncPlaylistUploadsOnChange({
|
||||||
key: 'template:update:' + template.id,
|
key: 'template:update:' + template.id,
|
||||||
@@ -1021,11 +1103,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
message: 'Template updated.'
|
message: 'Template updated.'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (transaction && connection) await connection.rollback();
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
||||||
|
let connection;
|
||||||
|
let transaction = false;
|
||||||
try {
|
try {
|
||||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||||
if (!template) {
|
if (!template) {
|
||||||
@@ -1037,9 +1124,17 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
const uploadRefs = collectUploadReferencesFromTemplate(template);
|
const uploadRefs = collectUploadReferencesFromTemplate(template);
|
||||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
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]);
|
connection = await pool.getConnection();
|
||||||
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
await connection.beginTransaction();
|
||||||
await pool.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
|
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({
|
await syncPlaylistUploadsOnChange({
|
||||||
key: 'template:delete:' + template.id,
|
key: 'template:delete:' + template.id,
|
||||||
pool: pool,
|
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 });
|
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.'));
|
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (transaction && connection) await connection.rollback();
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
|||||||
const usage = await getApiRequestUsage(connection);
|
const usage = await getApiRequestUsage(connection);
|
||||||
if (requests.some(function (request) { return usage.has(Number(request.id)); })) {
|
if (requests.some(function (request) { return usage.has(Number(request.id)); })) {
|
||||||
await connection.rollback();
|
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_sources WHERE auth_source_id = ?', [Number(req.params.id)]);
|
||||||
await connection.query('DELETE FROM i_api_source_groups WHERE 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 renderRssFeedEditPage = require('#src/web/routes/data-sources/rss-feeds/edit');
|
||||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('#src/web/routes/data-sources/rss-feeds/duplicate');
|
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('#src/web/routes/data-sources/rss-feeds/duplicate');
|
||||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||||
|
|
||||||
async function getDataSourceUsageMaps(pool, common) {
|
async function getDataSourceUsageMaps(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
return new Set((await getContentReferenceValues(pool, 'rss-feed')).map(Number).filter(Number.isFinite));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isRssFeedInUse(pool, common, rssFeedId) {
|
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 renderTimetableGroupAddPage = require('#src/web/routes/data-sources/timetables/add');
|
||||||
const renderTimetableGroupEditPage = require('#src/web/routes/data-sources/timetables/edit');
|
const renderTimetableGroupEditPage = require('#src/web/routes/data-sources/timetables/edit');
|
||||||
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('#src/web/routes/data-sources/timetables/duplicate');
|
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('#src/web/routes/data-sources/timetables/duplicate');
|
||||||
|
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||||
|
|
||||||
async function getDataSourceUsageMaps(pool, common) {
|
async function getDataSourceUsageMaps(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
return new Set((await getContentReferenceValues(pool, 'timetable-group')).map(Number).filter(Number.isFinite));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTimetableArrayFieldValue(body, keys) {
|
function readTimetableArrayFieldValue(body, keys) {
|
||||||
|
|||||||
@@ -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 { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('#src/web/routes/data-sources/weather/duplicate');
|
||||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||||
|
|
||||||
async function getWeatherLocationUsageIds(pool, common) {
|
async function getWeatherLocationUsageIds(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
return new Set((await getContentReferenceValues(pool, 'weather-location')).map(Number).filter(Number.isFinite));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isWeatherLocationInUse(pool, common, locationId) {
|
async function isWeatherLocationInUse(pool, common, locationId) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||||
const fontLibrary = require('#src/web/lib/media/font-library');
|
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 { buildPagination } = require('#src/web/lib/pagination');
|
||||||
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
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) {
|
async function fetchUsedFontFamilies(pool, fonts) {
|
||||||
const usedFamilies = new Set();
|
const usedFamilies = new Set();
|
||||||
|
const references = new Set((await getContentReferenceValues(pool, 'font-family')).map(normalizeFontFamilyName));
|
||||||
for (let i = 0; i < (Array.isArray(fonts) ? fonts.length : 0); i += 1) {
|
(Array.isArray(fonts) ? fonts : []).forEach(function (font) {
|
||||||
const family = normalizeFontFamilyName(fonts[i] && (fonts[i].family || fonts[i].name));
|
const family = normalizeFontFamilyName(font && (font.family || font.name));
|
||||||
if (!family) {
|
if (family && references.has(family)) usedFamilies.add(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return usedFamilies;
|
return usedFamilies;
|
||||||
}
|
}
|
||||||
@@ -43,16 +30,7 @@ async function isFontUsed(pool, family) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
return hasContentReference(pool, 'font-family', normalizedFamily);
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildFontsPageData(pool, uploadDir, req, common) {
|
async function buildFontsPageData(pool, uploadDir, req, common) {
|
||||||
|
|||||||
@@ -2,23 +2,12 @@ const multer = require('multer');
|
|||||||
const { renderView } = require('#src/web/view');
|
const { renderView } = require('#src/web/view');
|
||||||
const library = require('#src/web/lib/media/weather-icon-library');
|
const library = require('#src/web/lib/media/weather-icon-library');
|
||||||
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||||
|
const { getContentReferenceOwnerCounts, getContentReferenceOwners, hasContentReference } = require('#src/data/content-references');
|
||||||
const { buildPagination } = require('#src/web/lib/pagination');
|
const { buildPagination } = require('#src/web/lib/pagination');
|
||||||
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
||||||
|
|
||||||
async function isWeatherIconSetUsed(pool, iconSetId) {
|
async function isWeatherIconSetUsed(pool, iconSetId) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides');
|
return hasContentReference(pool, 'weather-icon-set', iconSetId);
|
||||||
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); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function registerWeatherIconRoutes(app, deps) {
|
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 }];
|
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }];
|
||||||
});
|
});
|
||||||
async function page(req, res, message, status) {
|
async function page(req, res, message, status) {
|
||||||
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
|
const usageCounts = await getContentReferenceOwnerCounts(deps.pool, 'weather-icon-set');
|
||||||
const [templateRegions] = await deps.pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
|
|
||||||
const search = getSearchQuery(req);
|
const search = getSearchQuery(req);
|
||||||
const sort = getSortQuery(req);
|
const sort = getSortQuery(req);
|
||||||
const direction = getSortDirectionQuery(req);
|
const direction = getSortDirectionQuery(req);
|
||||||
const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) {
|
const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) {
|
||||||
const slideUsageCount = slides.filter(function (slide) {
|
const usageCount = usageCounts.get(String(set.id)) || 0;
|
||||||
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;
|
|
||||||
return Object.assign({}, set, { usageCount: usageCount, conditions: library.CONDITIONS.map(function (condition) {
|
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 });
|
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',
|
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 });
|
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');
|
const owners = await getContentReferenceOwners(deps.pool, 'weather-icon-set');
|
||||||
for (const slide of slides) {
|
const slideIds = new Set(owners.filter(function (owner) { return owner.ownerType === 'slide'; }).map(function (owner) { return owner.ownerId; }));
|
||||||
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
|
const templateIds = Array.from(new Set(owners.filter(function (owner) { return owner.ownerType === 'template'; }).map(function (owner) { return owner.ownerId; })));
|
||||||
if (Object.values(content).some(function (region) { return region && region.weather_icon_set_id; })) {
|
if (templateIds.length) {
|
||||||
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 [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>
|
<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>
|
||||||
<div class="d-flex align-items-center justify-content-end flex-wrap ms-auto">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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"><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 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>
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</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}}
|
{{#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}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
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 { getPendingMigrations } = require('../src/db/migrations');
|
||||||
const { fetchApiSourcesData, fetchApiSourceById, testApiSourceAuthentication } = require('../src/data/api-sources');
|
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, 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_source_groups WHERE name')) return [[]];
|
||||||
if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))];
|
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('FROM c_content_references')) {
|
||||||
if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))];
|
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_sources')) return [{ insertId: 99 }];
|
||||||
if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }];
|
if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }];
|
||||||
return [{}];
|
return [{}];
|
||||||
@@ -30,6 +40,23 @@ function createPool(existing, slides, groupEnabled = 1, templateDefaults = []) {
|
|||||||
return { calls, async getConnection() { return connection; } };
|
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', () => {
|
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 }]);
|
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');
|
assert.equal(group.auth.auth_method, 'none');
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ test('api source duplicate form model keeps info toast variant', () => {
|
|||||||
assert.equal(model.showSaveSecondaryActions, true);
|
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', () => {
|
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 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;
|
const add = buildApiSourceFormViewModel(source, '', null, {}, false).apiSource;
|
||||||
|
|||||||
@@ -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 = ?'));
|
||||||
|
});
|
||||||
@@ -198,7 +198,8 @@ test('template default font references mark fonts in use and block deletion', as
|
|||||||
registerFontRoutes(app, {
|
registerFontRoutes(app, {
|
||||||
pool: {
|
pool: {
|
||||||
async query(sql) {
|
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 }]];
|
return [[{ match_count: 0 }]];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -171,22 +171,26 @@ test('media library counts slide, template background, and region default refere
|
|||||||
const pool = {
|
const pool = {
|
||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
queries.push({ 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 }]];
|
return [[{ ref_count: 2 }]];
|
||||||
}
|
}
|
||||||
if (sql.includes('FROM c_templates')) return [[{ ref_count: 1 }]];
|
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(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 6);
|
||||||
assert.equal(queries.length, 3);
|
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[1].params[0], '/media/uploads/shared.png');
|
||||||
assert.equal(queries[2].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, /c_content_references/);
|
||||||
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
|
assert.match(queries[0].sql, /idx_content_reference_lookup|reference_type/);
|
||||||
assert.match(queries[2].sql, /default_content_json/);
|
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 () => {
|
test('media library upload and delete routes audit successful asset changes', async () => {
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ test('rss feed duplicate form model keeps info toast variant and secondary actio
|
|||||||
assert.equal(model.showSaveSecondaryActions, true);
|
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', () => {
|
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');
|
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'list.hbs'), 'utf8');
|
||||||
|
|
||||||
|
|||||||
@@ -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 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')));
|
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'));
|
||||||
|
});
|
||||||
@@ -5,12 +5,25 @@ const { getDataSourceUsageMaps: getRssUsage } = require('../src/web/routes/data-
|
|||||||
const { getDataSourceUsageMaps: getTimetableUsage } = require('../src/web/routes/data-sources/timetables/routes');
|
const { getDataSourceUsageMaps: getTimetableUsage } = require('../src/web/routes/data-sources/timetables/routes');
|
||||||
const { getWeatherLocationUsageIds } = require('../src/web/routes/data-sources/weather');
|
const { getWeatherLocationUsageIds } = require('../src/web/routes/data-sources/weather');
|
||||||
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
|
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
|
||||||
|
const { collectContentReferences } = require('../src/data/content-references');
|
||||||
|
|
||||||
function createPool(slideContent, templateContent) {
|
function createPool(slideContent, templateContent) {
|
||||||
|
const references = collectContentReferences('slide', 1, { slide: slideContent })
|
||||||
|
.concat(collectContentReferences('template', 2, { region: templateContent }));
|
||||||
return {
|
return {
|
||||||
async query(sql) {
|
async query(sql, params) {
|
||||||
if (sql.includes('FROM c_slides')) return [[{ content_json: JSON.stringify(slideContent) }]];
|
if (!sql.includes('FROM c_content_references')) throw new Error('Unexpected query: ' + sql);
|
||||||
return [[{ default_content_json: JSON.stringify(templateContent) }]];
|
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 }))];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ test('weather list template includes duplicate action', () => {
|
|||||||
assert.match(template, /\/data-sources\/weather\/{\{id\}\}\/duplicate/);
|
assert.match(template, /\/data-sources\/weather\/{\{id\}\}\/duplicate/);
|
||||||
assert.match(template, />Dupe<\/a>/);
|
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\?"/);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ const sharp = require('sharp');
|
|||||||
require('../src/common');
|
require('../src/common');
|
||||||
const library = require('../src/web/lib/media/weather-icon-library');
|
const library = require('../src/web/lib/media/weather-icon-library');
|
||||||
const weatherIcons = require('../src/web/public/js/shared/weather-icons');
|
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 vm = require('node:vm');
|
||||||
const placeholderContext = { window: {} };
|
const placeholderContext = { window: {} };
|
||||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), placeholderContext);
|
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 () {
|
test('weather icon usage guard checks the indexed reference value', async function () {
|
||||||
const content = { weather: { weather_icon_set_id: 'set' } };
|
const pool = { async query(_sql, params) { return [[{ owner_count: params[1] === 'set' ? 1 : 0 }]]; } };
|
||||||
const isUsed = function (setId) {
|
assert.equal(await isWeatherIconSetUsed(pool, 'set'), true);
|
||||||
return Object.values(content).some(function (region) {
|
assert.equal(await isWeatherIconSetUsed(pool, 'other'), false);
|
||||||
return region && region.weather_icon_set_id === setId;
|
});
|
||||||
});
|
|
||||||
|
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);
|
library.setWeatherIconSetEnabled = function (_root, id, enabled) { return { id: id, name: 'Set', enabled: enabled }; };
|
||||||
assert.equal(isUsed('other'), false);
|
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 () {
|
test('weather icon set create, update, toggle, and delete are audited', async function () {
|
||||||
|
|||||||
Reference in new issue
Block a user