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

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

No files matched your search

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