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
@@ -1,5 +1,6 @@
|
||||
const { API_AUTH_FIELDS, API_REQUEST_SELECT, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchPagedRows, validateMaxLength, parseJsonSafe } = require('#src/data/utils');
|
||||
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||
|
||||
function toFormFields(value) {
|
||||
const fields = Object.assign({}, value);
|
||||
@@ -54,7 +55,7 @@ async function fetchApiGroupById(pool, id) {
|
||||
|
||||
async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) {
|
||||
const data = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT auth.*, (SELECT COUNT(*) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS request_count, (SELECT MAX(last_pulled_at) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS last_pulled_at FROM i_api_source_groups auth ORDER BY auth.modified_at DESC, auth.id DESC',
|
||||
selectSql: 'SELECT auth.*, (SELECT COUNT(*) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS request_count, (SELECT MAX(last_pulled_at) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS last_pulled_at, EXISTS (SELECT 1 FROM i_api_sources used_request INNER JOIN c_content_references content_ref ON content_ref.reference_type = \'api-request\' AND content_ref.reference_value = CAST(used_request.id AS CHAR) WHERE used_request.auth_source_id = auth.id) AS inUse FROM i_api_source_groups auth ORDER BY auth.modified_at DESC, auth.id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_api_source_groups auth',
|
||||
searchColumns: ['auth.name', 'auth.token_url'], searchTerm: search,
|
||||
sortColumns: { name: 'auth.name', requests: 'request_count', last_pulled: 'last_pulled_at' },
|
||||
@@ -64,17 +65,7 @@ async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction)
|
||||
}
|
||||
|
||||
async function getApiRequestUsage(pool) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
|
||||
const ids = new Set();
|
||||
function walk(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (value.source_id) ids.add(Number(value.source_id));
|
||||
Object.values(value).forEach(walk);
|
||||
}
|
||||
slides.forEach(function (slide) { walk(parseJsonSafe(slide.content_json)); });
|
||||
templateRegions.forEach(function (region) { walk(parseJsonSafe(region.default_content_json)); });
|
||||
return ids;
|
||||
return new Set((await getContentReferenceValues(pool, 'api-request')).map(Number).filter(Number.isFinite));
|
||||
}
|
||||
|
||||
async function saveApiGroup(pool, id, payload, actorId, canDelete) {
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_content_references (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_type VARCHAR(16) NOT NULL,
|
||||
owner_id BIGINT UNSIGNED NOT NULL,
|
||||
owner_key VARCHAR(100) NOT NULL,
|
||||
reference_type VARCHAR(32) NOT NULL,
|
||||
reference_value VARCHAR(512) NOT NULL,
|
||||
reference_hash BINARY(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_content_reference_owner (owner_type, owner_id, owner_key, reference_type, reference_hash),
|
||||
INDEX idx_content_reference_lookup (reference_type, reference_value(191))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_announcements (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Ordered database migrations kept independent from the application version.
|
||||
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
const { parseJsonSafe } = require('#src/data/utils');
|
||||
const { collectContentReferences, insertContentReferences } = require('#src/data/content-references');
|
||||
const TIMETABLE_TIME_ZONE = 'Europe/London';
|
||||
const APP_STATE_TABLE = 'o_app_state';
|
||||
const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version';
|
||||
@@ -769,6 +771,60 @@ const VERSIONED_MIGRATIONS = [
|
||||
await ensureColumn(pool, 'c_template_regions', 'default_content_json', 'JSON NULL', 'animation_json');
|
||||
await ensureColumn(pool, 'c_template_regions', 'read_only', 'TINYINT(1) NOT NULL DEFAULT 0', 'default_content_json');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.19.0',
|
||||
label: 'v2.19.0 indexed content references',
|
||||
run: async function (pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_content_references (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_type VARCHAR(16) NOT NULL,
|
||||
owner_id BIGINT UNSIGNED NOT NULL,
|
||||
owner_key VARCHAR(100) NOT NULL,
|
||||
reference_type VARCHAR(32) NOT NULL,
|
||||
reference_value VARCHAR(512) NOT NULL,
|
||||
reference_hash BINARY(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_content_reference_owner (owner_type, owner_id, owner_key, reference_type, reference_hash),
|
||||
INDEX idx_content_reference_lookup (reference_type, reference_value(191))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await ensureColumn(pool, 'c_content_references', 'id', 'BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY', 'owner_type');
|
||||
|
||||
let lastSlideId = 0;
|
||||
while (true) {
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, content_json FROM c_slides WHERE id > ? AND content_json IS NOT NULL ORDER BY id ASC LIMIT 500',
|
||||
[lastSlideId]
|
||||
);
|
||||
if (!slides.length) break;
|
||||
const references = [];
|
||||
slides.forEach(function (slide) {
|
||||
references.push.apply(references, collectContentReferences('slide', slide.id, parseJsonSafe(slide.content_json) || {}));
|
||||
});
|
||||
await insertContentReferences(pool, references);
|
||||
lastSlideId = Number(slides[slides.length - 1].id);
|
||||
}
|
||||
|
||||
let lastRegionId = 0;
|
||||
while (true) {
|
||||
const [regions] = await pool.query(
|
||||
'SELECT id, template_id, region_key, default_content_json FROM c_template_regions WHERE id > ? AND default_content_json IS NOT NULL ORDER BY id ASC LIMIT 500',
|
||||
[lastRegionId]
|
||||
);
|
||||
if (!regions.length) break;
|
||||
const references = [];
|
||||
regions.forEach(function (region) {
|
||||
const content = parseJsonSafe(region.default_content_json) || {};
|
||||
const regionContent = {};
|
||||
regionContent[region.region_key] = content;
|
||||
references.push.apply(references, collectContentReferences('template', region.template_id, regionContent));
|
||||
});
|
||||
await insertContentReferences(pool, references);
|
||||
lastRegionId = Number(regions[regions.length - 1].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { countContentReferenceOwners } = require('#src/data/content-references');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
|
||||
@@ -247,25 +248,13 @@ async function countMediaAssetReferences(pool, mediaPath) {
|
||||
if (!normalizedPath) {
|
||||
return 0;
|
||||
}
|
||||
const [slideRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count
|
||||
FROM c_slides
|
||||
WHERE LOCATE(?, CAST(COALESCE(content_json, '') AS CHAR)) > 0`,
|
||||
[normalizedPath]
|
||||
);
|
||||
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', normalizedPath);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
[normalizedPath]
|
||||
);
|
||||
const [templateRegionRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count
|
||||
FROM c_template_regions
|
||||
WHERE LOCATE(?, CAST(COALESCE(default_content_json, '') AS CHAR)) > 0`,
|
||||
[normalizedPath]
|
||||
);
|
||||
return Number(slideRows[0] && slideRows[0].ref_count || 0)
|
||||
+ Number(templateRows[0] && templateRows[0].ref_count || 0)
|
||||
+ Number(templateRegionRows[0] && templateRegionRows[0].ref_count || 0);
|
||||
const [thumbnailRows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', [normalizedPath]);
|
||||
return contentReferenceCount + Number(templateRows[0] && templateRows[0].ref_count || 0) + Number(thumbnailRows[0] && thumbnailRows[0].ref_count || 0);
|
||||
}
|
||||
|
||||
async function publishMediaAssets(pool, mediaPaths) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
|
||||
const { countContentReferenceOwners } = require('#src/data/content-references');
|
||||
const {
|
||||
fetchPlayerRegistrations,
|
||||
getConfiguredPlayerIdentifier,
|
||||
@@ -368,12 +369,7 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
async function countUploadReferences(pool, uploadPath) {
|
||||
const [slideRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count
|
||||
FROM c_slides
|
||||
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||
[uploadPath]
|
||||
);
|
||||
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', uploadPath);
|
||||
const [thumbnailRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
|
||||
[uploadPath]
|
||||
@@ -382,12 +378,7 @@ function createUploadSyncService(options) {
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
[uploadPath]
|
||||
);
|
||||
const [templateRegionRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count FROM c_template_regions
|
||||
WHERE JSON_SEARCH(COALESCE(default_content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||
[uploadPath]
|
||||
);
|
||||
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0) + Number(templateRegionRows[0].ref_count || 0);
|
||||
return contentReferenceCount + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
|
||||
}
|
||||
|
||||
async function uploadFileExists(uploadDir, uploadPath) {
|
||||
|
||||
+109
-11
@@ -7,6 +7,7 @@ const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { replaceContentReferences } = require('#src/data/content-references');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
|
||||
const { retainLayoutAnimation, getTemplateAnimationSnapshot } = require('#src/web/public/js/shared/region-layout');
|
||||
@@ -68,6 +69,13 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
function mapTemplateDefaultContent(regions) {
|
||||
return (Array.isArray(regions) ? regions : []).reduce(function (content, region) {
|
||||
content[String(region.region_key || '')] = region.default_content || {};
|
||||
return content;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function fetchSlideFormData(includeTemplates) {
|
||||
const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
@@ -561,6 +569,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
let connection;
|
||||
let transaction = false;
|
||||
try {
|
||||
const settings = await validateUploadedFiles(req.files || []);
|
||||
for (const file of req.files || []) {
|
||||
@@ -574,10 +584,18 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
transaction = true;
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[payload.title, payload.templateId, payload.contentJson, actorId, actorId]
|
||||
);
|
||||
await replaceContentReferences(connection, 'slide', result.insertId, common.parseJsonSafe(payload.contentJson) || {});
|
||||
await connection.commit();
|
||||
transaction = false;
|
||||
connection.release();
|
||||
connection = null;
|
||||
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'slide:create:' + result.insertId,
|
||||
@@ -595,11 +613,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
message: 'Slide created.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (transaction && connection) await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||
let connection;
|
||||
let transaction = false;
|
||||
try {
|
||||
const settings = await validateUploadedFiles(req.files || []);
|
||||
for (const file of req.files || []) {
|
||||
@@ -630,10 +653,24 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
templateId: Number(payload.templateId),
|
||||
content: common.parseJsonSafe(payload.contentJson) || {}
|
||||
});
|
||||
await pool.query(
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
transaction = true;
|
||||
const [lockedSlides] = await connection.query('SELECT id FROM c_slides WHERE id = ? FOR UPDATE', [slide.id]);
|
||||
if (!lockedSlides.length) {
|
||||
const error = new Error('Slide not found. Reload before saving.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await connection.query(
|
||||
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
||||
);
|
||||
await replaceContentReferences(connection, 'slide', slide.id, common.parseJsonSafe(payload.contentJson) || {});
|
||||
await connection.commit();
|
||||
transaction = false;
|
||||
connection.release();
|
||||
connection = null;
|
||||
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'slide:update:' + slide.id,
|
||||
@@ -656,11 +693,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
message: 'Slide updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (transaction && connection) await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
||||
let connection;
|
||||
let transaction = false;
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
@@ -673,7 +715,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||
const uploadRefs = collectUploadReferencesFromSlide(slide);
|
||||
await pool.query('DELETE FROM c_slides WHERE id = ?', [slide.id]);
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
transaction = true;
|
||||
await replaceContentReferences(connection, 'slide', slide.id, {});
|
||||
await connection.query('DELETE FROM c_slides WHERE id = ?', [slide.id]);
|
||||
await connection.commit();
|
||||
transaction = false;
|
||||
connection.release();
|
||||
connection = null;
|
||||
await removeMediaFile(slide.thumbnail_path);
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'slide:delete:' + slide.id,
|
||||
@@ -688,7 +738,10 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.deleted', actorUserId: getAuditUserId(req), targetType: 'slide', targetId: slide.id, targetLabel: slide.title });
|
||||
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||
} catch (error) {
|
||||
if (transaction && connection) await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -720,6 +773,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
let connection;
|
||||
let transaction = false;
|
||||
try {
|
||||
await validateUploadedFiles(req.files || []);
|
||||
await registerMediaAssets(pool, req.files, function (file) {
|
||||
@@ -733,17 +788,25 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
transaction = true;
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, background_gradient, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, actorId]
|
||||
);
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
await connection.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json, read_only, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), JSON.stringify(region.default_content || {}), region.read_only ? 1 : 0, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await replaceContentReferences(connection, 'template', result.insertId, mapTemplateDefaultContent(payload.regions));
|
||||
await connection.commit();
|
||||
transaction = false;
|
||||
connection.release();
|
||||
connection = null;
|
||||
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'template:create:' + result.insertId,
|
||||
@@ -758,7 +821,10 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
message: 'Template created.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (transaction && connection) await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -952,6 +1018,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
|
||||
let connection;
|
||||
let transaction = false;
|
||||
try {
|
||||
await validateUploadedFiles(req.files || []);
|
||||
await registerMediaAssets(pool, req.files, function (file) {
|
||||
@@ -987,18 +1055,32 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
backgroundColor: payload.backgroundColor,
|
||||
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
||||
});
|
||||
await pool.query(
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
transaction = true;
|
||||
const [lockedTemplates] = await connection.query('SELECT id FROM c_templates WHERE id = ? FOR UPDATE', [template.id]);
|
||||
if (!lockedTemplates.length) {
|
||||
const error = new Error('Template not found. Reload before saving.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await connection.query(
|
||||
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, background_gradient = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, template.id]
|
||||
);
|
||||
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
||||
await connection.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
await connection.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json, read_only, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), JSON.stringify(region.default_content || {}), region.read_only ? 1 : 0, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await replaceContentReferences(connection, 'template', template.id, mapTemplateDefaultContent(payload.regions));
|
||||
await connection.commit();
|
||||
transaction = false;
|
||||
connection.release();
|
||||
connection = null;
|
||||
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'template:update:' + template.id,
|
||||
@@ -1021,11 +1103,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
message: 'Template updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (transaction && connection) await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
||||
let connection;
|
||||
let transaction = false;
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
@@ -1037,9 +1124,17 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
const uploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
await pool.query('UPDATE c_slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
|
||||
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
||||
await pool.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
transaction = true;
|
||||
await connection.query('UPDATE c_slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
|
||||
await connection.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
||||
await replaceContentReferences(connection, 'template', template.id, {});
|
||||
await connection.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
|
||||
await connection.commit();
|
||||
transaction = false;
|
||||
connection.release();
|
||||
connection = null;
|
||||
await syncPlaylistUploadsOnChange({
|
||||
key: 'template:delete:' + template.id,
|
||||
pool: pool,
|
||||
@@ -1050,7 +1145,10 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.deleted', actorUserId: getAuditUserId(req), targetType: 'template', targetId: template.id, targetLabel: template.name });
|
||||
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||
} catch (error) {
|
||||
if (transaction && connection) await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
const usage = await getApiRequestUsage(connection);
|
||||
if (requests.some(function (request) { return usage.has(Number(request.id)); })) {
|
||||
await connection.rollback();
|
||||
return res.redirect(base + '?message=' + encodeURIComponent('This API source has requests used by slides.'));
|
||||
return res.redirect(base + '?message=' + encodeURIComponent('This API source has requests used by slides or template defaults.'));
|
||||
}
|
||||
await connection.query('DELETE FROM i_api_sources WHERE auth_source_id = ?', [Number(req.params.id)]);
|
||||
await connection.query('DELETE FROM i_api_source_groups WHERE id = ?', [Number(req.params.id)]);
|
||||
|
||||
@@ -6,45 +6,10 @@ const renderRssFeedAddPage = require('#src/web/routes/data-sources/rss-feeds/add
|
||||
const renderRssFeedEditPage = require('#src/web/routes/data-sources/rss-feeds/edit');
|
||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('#src/web/routes/data-sources/rss-feeds/duplicate');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
|
||||
const rssFeedIds = new Set();
|
||||
|
||||
function collectUsage(json) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
|
||||
const feedId = Number(value.feed_id);
|
||||
if (Number.isFinite(feedId)) {
|
||||
rssFeedIds.add(feedId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
slides.forEach(function (slide) { collectUsage(slide.content_json); });
|
||||
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
|
||||
|
||||
return rssFeedIds;
|
||||
return new Set((await getContentReferenceValues(pool, 'rss-feed')).map(Number).filter(Number.isFinite));
|
||||
}
|
||||
|
||||
async function isRssFeedInUse(pool, common, rssFeedId) {
|
||||
|
||||
@@ -5,45 +5,10 @@ const renderTimetableGroupsPage = require('#src/web/routes/data-sources/timetabl
|
||||
const renderTimetableGroupAddPage = require('#src/web/routes/data-sources/timetables/add');
|
||||
const renderTimetableGroupEditPage = require('#src/web/routes/data-sources/timetables/edit');
|
||||
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('#src/web/routes/data-sources/timetables/duplicate');
|
||||
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
|
||||
const timetableGroupIds = new Set();
|
||||
|
||||
function collectUsage(json) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'timetable_group_id')) {
|
||||
const timetableGroupId = Number(value.timetable_group_id);
|
||||
if (Number.isFinite(timetableGroupId)) {
|
||||
timetableGroupIds.add(timetableGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
slides.forEach(function (slide) { collectUsage(slide.content_json); });
|
||||
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
|
||||
|
||||
return timetableGroupIds;
|
||||
return new Set((await getContentReferenceValues(pool, 'timetable-group')).map(Number).filter(Number.isFinite));
|
||||
}
|
||||
|
||||
function readTimetableArrayFieldValue(body, keys) {
|
||||
|
||||
@@ -7,30 +7,10 @@ const renderWeatherLocationEditPage = require('#src/web/routes/data-sources/weat
|
||||
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('#src/web/routes/data-sources/weather/duplicate');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { getContentReferenceValues } = require('#src/data/content-references');
|
||||
|
||||
async function getWeatherLocationUsageIds(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
|
||||
const weatherLocationIds = new Set();
|
||||
function collectUsage(json) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
|
||||
if (!content || typeof content !== 'object') return;
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'weather_location_id')) {
|
||||
const locationId = Number(value.weather_location_id);
|
||||
if (Number.isFinite(locationId)) weatherLocationIds.add(locationId);
|
||||
}
|
||||
Object.keys(value).forEach(function (key) { walk(value[key]); });
|
||||
}(content));
|
||||
}
|
||||
slides.forEach(function (slide) { collectUsage(slide.content_json); });
|
||||
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
|
||||
return weatherLocationIds;
|
||||
return new Set((await getContentReferenceValues(pool, 'weather-location')).map(Number).filter(Number.isFinite));
|
||||
}
|
||||
|
||||
async function isWeatherLocationInUse(pool, common, locationId) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require('fs');
|
||||
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const fontLibrary = require('#src/web/lib/media/font-library');
|
||||
const { getContentReferenceValues, hasContentReference } = require('#src/data/content-references');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
||||
|
||||
@@ -14,25 +15,11 @@ function normalizeFontFamilyName(value) {
|
||||
|
||||
async function fetchUsedFontFamilies(pool, fonts) {
|
||||
const usedFamilies = new Set();
|
||||
|
||||
for (let i = 0; i < (Array.isArray(fonts) ? fonts.length : 0); i += 1) {
|
||||
const family = normalizeFontFamilyName(fonts[i] && (fonts[i].family || fonts[i].name));
|
||||
if (!family) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
|
||||
[family]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS match_count FROM c_template_regions WHERE LOCATE(LOWER(?), LOWER(CAST(COALESCE(default_content_json, "") AS CHAR))) > 0',
|
||||
[family]
|
||||
);
|
||||
if (Number(rows && rows[0] && rows[0].match_count) > 0 || Number(templateRows && templateRows[0] && templateRows[0].match_count) > 0) {
|
||||
usedFamilies.add(family);
|
||||
}
|
||||
}
|
||||
const references = new Set((await getContentReferenceValues(pool, 'font-family')).map(normalizeFontFamilyName));
|
||||
(Array.isArray(fonts) ? fonts : []).forEach(function (font) {
|
||||
const family = normalizeFontFamilyName(font && (font.family || font.name));
|
||||
if (family && references.has(family)) usedFamilies.add(family);
|
||||
});
|
||||
|
||||
return usedFamilies;
|
||||
}
|
||||
@@ -43,16 +30,7 @@ async function isFontUsed(pool, family) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
|
||||
[normalizedFamily]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS match_count FROM c_template_regions WHERE LOCATE(LOWER(?), LOWER(CAST(COALESCE(default_content_json, "") AS CHAR))) > 0',
|
||||
[normalizedFamily]
|
||||
);
|
||||
|
||||
return Number(rows && rows[0] && rows[0].match_count) > 0 || Number(templateRows && templateRows[0] && templateRows[0].match_count) > 0;
|
||||
return hasContentReference(pool, 'font-family', normalizedFamily);
|
||||
}
|
||||
|
||||
async function buildFontsPageData(pool, uploadDir, req, common) {
|
||||
|
||||
@@ -2,23 +2,12 @@ const multer = require('multer');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const library = require('#src/web/lib/media/weather-icon-library');
|
||||
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||
const { getContentReferenceOwnerCounts, getContentReferenceOwners, hasContentReference } = require('#src/data/content-references');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
||||
|
||||
async function isWeatherIconSetUsed(pool, iconSetId) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides');
|
||||
const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
|
||||
function matches(value) {
|
||||
let content = value;
|
||||
if (typeof content === 'string') {
|
||||
try { content = JSON.parse(content); } catch (_error) { content = null; }
|
||||
}
|
||||
if (!content || typeof content !== 'object') return false;
|
||||
if (!Array.isArray(content) && String(content.weather_icon_set_id || '') === String(iconSetId)) return true;
|
||||
return (Array.isArray(content) ? content : Object.values(content)).some(matches);
|
||||
}
|
||||
return slides.some(function (slide) { return matches(slide.content_json); })
|
||||
|| templateRegions.some(function (region) { return matches(region.default_content_json); });
|
||||
return hasContentReference(pool, 'weather-icon-set', iconSetId);
|
||||
}
|
||||
|
||||
module.exports = function registerWeatherIconRoutes(app, deps) {
|
||||
@@ -28,23 +17,12 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
|
||||
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }];
|
||||
});
|
||||
async function page(req, res, message, status) {
|
||||
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
|
||||
const [templateRegions] = await deps.pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL');
|
||||
const usageCounts = await getContentReferenceOwnerCounts(deps.pool, 'weather-icon-set');
|
||||
const search = getSearchQuery(req);
|
||||
const sort = getSortQuery(req);
|
||||
const direction = getSortDirectionQuery(req);
|
||||
const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) {
|
||||
const slideUsageCount = slides.filter(function (slide) {
|
||||
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
|
||||
return Object.values(content).some(function (region) {
|
||||
return region && region.weather_icon_set_id === set.id;
|
||||
});
|
||||
}).length;
|
||||
const templateUsageCount = templateRegions.filter(function (region) {
|
||||
const content = typeof region.default_content_json === 'string' ? JSON.parse(region.default_content_json || '{}') : region.default_content_json || {};
|
||||
return content && String(content.weather_icon_set_id || '') === String(set.id);
|
||||
}).length;
|
||||
const usageCount = slideUsageCount + templateUsageCount;
|
||||
const usageCount = usageCounts.get(String(set.id)) || 0;
|
||||
return Object.assign({}, set, { usageCount: usageCount, conditions: library.CONDITIONS.map(function (condition) {
|
||||
return Object.assign({}, condition, { image: set.icons[condition.key], nightImage: (set.nightIcons || {})[condition.key], setId: set.id });
|
||||
}) });
|
||||
@@ -65,12 +43,15 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
|
||||
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-sync:' + player.identifier + ':' + Date.now(), title: 'Weather icon sync', category: 'media', taskType: 'font-sync',
|
||||
payload: { uploadDir: deps.mediaDir, operations: library.collectWeatherIconSyncOperations(deps.mediaDir).concat(extraOperations || []), playerIdentifier: player.identifier, requireSuccess: true }, persist: true });
|
||||
}
|
||||
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
|
||||
for (const slide of slides) {
|
||||
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
|
||||
if (Object.values(content).some(function (region) { return region && region.weather_icon_set_id; })) {
|
||||
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-thumbnail:' + slide.id + ':' + Date.now(), title: 'Weather slide thumbnail refresh', category: 'media', taskType: 'slide-thumbnail-refresh', payload: { slideId: slide.id }, persist: true });
|
||||
}
|
||||
const owners = await getContentReferenceOwners(deps.pool, 'weather-icon-set');
|
||||
const slideIds = new Set(owners.filter(function (owner) { return owner.ownerType === 'slide'; }).map(function (owner) { return owner.ownerId; }));
|
||||
const templateIds = Array.from(new Set(owners.filter(function (owner) { return owner.ownerType === 'template'; }).map(function (owner) { return owner.ownerId; })));
|
||||
if (templateIds.length) {
|
||||
const [templateSlides] = await deps.pool.query('SELECT id FROM c_slides WHERE template_id IN (?)', [templateIds]);
|
||||
templateSlides.forEach(function (slide) { slideIds.add(Number(slide.id)); });
|
||||
}
|
||||
for (const slideId of slideIds) {
|
||||
await deps.backgroundTaskQueue.enqueueTask({ key: 'weather-icon-thumbnail:' + slideId + ':' + Date.now(), title: 'Weather slide thumbnail refresh', category: 'media', taskType: 'slide-thumbnail-refresh', payload: { slideId: slideId }, persist: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<button type="button" class="btn btn-primary" data-api-request-add {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-end flex-wrap ms-auto">
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage="Delete this API source?" deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage="Delete this RSS feed?" deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
|
||||
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasAssignedPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary" {{#if currentUser.isViewingAs}}disabled title="Unavailable in read-only preview"{{/if}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage="Delete this weather location?" deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
Reference in new issue
Block a user