Release v2.14.2
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 30s

This commit is contained in:
lzstealth committed 2026-10-12 00:56:09 +01:00
1 parent c3299abaac
commit ddcc665f81
47 files changed
+1209 -66

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.14.2 - 2026-10-11
### Added
- Added managed weather icon sets under Settings > Assets, with day and optional night variants, PNG/WebP/SVG uploads, per-region selection, and player synchronization.
### Changed
- API and RSS image placeholders now support optional top, middle, or bottom alignment relative to surrounding text.
## 2.14.1 - 2026-10-11 ## 2.14.1 - 2026-10-11
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.14.1", "version": "2.14.2",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.14.1", "version": "2.14.2",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.14.1", "version": "2.14.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.14.1", "version": "2.14.2",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.14.1", "version": "2.14.2",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+1
View File
@@ -469,6 +469,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
type: 'weather', type: 'weather',
value: body[`region_text_${region.id}`] !== undefined ? String(body[`region_text_${region.id}`] || '') : String(current.value || ''), value: body[`region_text_${region.id}`] !== undefined ? String(body[`region_text_${region.id}`] || '') : String(current.value || ''),
weather_location_id: locationId === undefined || locationId === null ? (current.weather_location_id || null) : (locationId === '' ? null : Number(locationId)), weather_location_id: locationId === undefined || locationId === null ? (current.weather_location_id || null) : (locationId === '' ? null : Number(locationId)),
weather_icon_set_id: body[`region_weather_icon_set_id_${region.id}`] === undefined ? String(current.weather_icon_set_id || '') : String(body[`region_weather_icon_set_id_${region.id}`] || '').trim(),
forecast_mode: body[`region_weather_forecast_mode_${region.id}`] === undefined ? (['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily') : forecastMode, forecast_mode: body[`region_weather_forecast_mode_${region.id}`] === undefined ? (['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily') : forecastMode,
font_family: style.font_family, font_family: style.font_family,
font_size: style.font_size, font_size: style.font_size,
+5 -3
View File
@@ -404,8 +404,9 @@ function createPlayerPlaylistService(options) {
await cachePlaceholderImages(slides, rssFeeds, apiSources); await cachePlaceholderImages(slides, rssFeeds, apiSources);
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations); const weatherIconSets = mediaDir ? require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir) : [];
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, weatherLocations: weatherLocations, revision: revision }; const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations, weatherIconSets);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, weatherLocations: weatherLocations, weatherIconSets: weatherIconSets, revision: revision };
await writeSnapshot(slug, payload); await writeSnapshot(slug, payload);
return payload; return payload;
} catch (error) { } catch (error) {
@@ -422,7 +423,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0'); hash.update('\0');
} }
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations) { function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations, weatherIconSets) {
const hash = crypto.createHash('sha1'); const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id); updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -489,6 +490,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || [])); updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
updatePlaylistRevisionHash(hash, JSON.stringify(weatherLocations || [])); updatePlaylistRevisionHash(hash, JSON.stringify(weatherLocations || []));
updatePlaylistRevisionHash(hash, JSON.stringify(weatherIconSets || []));
return hash.digest('hex'); return hash.digest('hex');
} }
@@ -231,6 +231,7 @@ function refresh(applyImmediately, bypassDebounce) {
apiSources: Array.isArray(data.apiSources) ? data.apiSources : [], apiSources: Array.isArray(data.apiSources) ? data.apiSources : [],
timetableGroups: Array.isArray(data.timetableGroups) ? data.timetableGroups : [], timetableGroups: Array.isArray(data.timetableGroups) ? data.timetableGroups : [],
weatherLocations: Array.isArray(data.weatherLocations) ? data.weatherLocations : [], weatherLocations: Array.isArray(data.weatherLocations) ? data.weatherLocations : [],
weatherIconSets: Array.isArray(data.weatherIconSets) ? data.weatherIconSets : [],
revision: nextSignature revision: nextSignature
}); });
if (typeof initialData !== 'undefined') { if (typeof initialData !== 'undefined') {
+11 -3
View File
@@ -84,9 +84,17 @@ function substituteApiVariables(html, item, sourceId) {
? 'display:block;width:100%;height:100%;object-fit:contain;' ? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />'; var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds if (hasImageBounds || imageConfig.verticalAlign) {
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>' var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
: image; if (hasImageBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
}
return image;
} }
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
}); });
+11 -3
View File
@@ -70,9 +70,17 @@ function substituteRssVariables(html, item, feedId) {
? 'display:block;width:100%;height:100%;object-fit:contain;' ? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />'; var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds if (hasImageBounds || imageConfig.verticalAlign) {
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>' var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
: image; if (hasImageBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
}
return image;
} }
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
}); });
+9 -5
View File
@@ -46,7 +46,7 @@ function withWeatherUnits(snapshot, location) {
return data; return data;
} }
function substituteWeatherVariables(html, value) { function substituteWeatherVariables(html, value, iconSetId) {
return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!value || typeof value !== 'object' || typeof weatherPlaceholderUtils.resolvePlaceholderExpression !== 'function' || typeof weatherPlaceholderUtils.formatPlaceholderValue !== 'function') return ''; if (!value || typeof value !== 'object' || typeof weatherPlaceholderUtils.resolvePlaceholderExpression !== 'function' || typeof weatherPlaceholderUtils.formatPlaceholderValue !== 'function') return '';
var rawExpression = String(expression).trim(); var rawExpression = String(expression).trim();
@@ -54,15 +54,19 @@ function substituteWeatherVariables(html, value) {
if (typeof weatherPlaceholderUtils.isProgressPlaceholderExpression === 'function' && weatherPlaceholderUtils.isProgressPlaceholderExpression(rawExpression)) { if (typeof weatherPlaceholderUtils.isProgressPlaceholderExpression === 'function' && weatherPlaceholderUtils.isProgressPlaceholderExpression(rawExpression)) {
return weatherPlaceholderUtils.renderProgressPlaceholder(value, rawExpression); return weatherPlaceholderUtils.renderProgressPlaceholder(value, rawExpression);
} }
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/); var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\))?$/);
if (iconMatch) { if (iconMatch) {
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, ''); var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
var width = iconMatch[1] ? Math.max(1, Math.min(1000, Number(iconMatch[1]))) : 0; var width = iconMatch[1] ? Math.max(1, Math.min(1000, Number(iconMatch[1]))) : 0;
var height = iconMatch[2] ? Math.max(1, Math.min(1000, Number(iconMatch[2]))) : width; var height = iconMatch[2] ? Math.max(1, Math.min(1000, Number(iconMatch[2]))) : width;
var style = width ? ' style="display:inline-block;vertical-align:middle;font-size:' + width + 'px;line-height:' + height + 'px;width:' + width + 'px;height:' + height + 'px;"' : ''; var alignment = iconMatch[3] || 'middle';
var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;width:' + width + 'px;height:' + height + 'px;"' : '';
var weatherCode = weatherPlaceholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone }); var weatherCode = weatherPlaceholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
var source = window.pulseWeatherIcons && window.pulseWeatherIcons.imageForCode(initialData && initialData.weatherIconSets, iconSetId, weatherCode, window.pulseWeatherIcons.isNightForWeather(value));
if (source) return window.pulseWeatherIcons.renderImage(source, width, height, alignment);
var icon = '<i class="bi ' + weatherIconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>'; var icon = '<i class="bi ' + weatherIconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
return width ? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + width + 'px;height:' + height + 'px;">' + icon + '</span>' : icon; var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return width ? '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + width + 'px;height:' + height + 'px;">' + icon + '</span>' : icon;
} }
return escapeHtml(weatherPlaceholderUtils.formatPlaceholderValue(weatherPlaceholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone }))); return escapeHtml(weatherPlaceholderUtils.formatPlaceholderValue(weatherPlaceholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone })));
}); });
@@ -82,7 +86,7 @@ function renderWeatherRegion(region, regionContent) {
if (value) { if (value) {
var weatherData = withWeatherUnits(snapshot, location); var weatherData = withWeatherUnits(snapshot, location);
var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32); var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData)) + '</div></div>'; return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData, regionContent.weather_icon_set_id)) + '</div></div>';
} }
var daily = snapshot.daily || {}; var daily = snapshot.daily || {};
var days = (daily.time || []).slice(0, 7).map(function (day, index) { var days = (daily.time || []).slice(0, 7).map(function (day, index) {
+2 -1
View File
@@ -1,6 +1,7 @@
// Player page rendering and bootstrap script assembly. // Player page rendering and bootstrap script assembly.
const Handlebars = require('handlebars'); const Handlebars = require('handlebars');
const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const path = require('path'); const path = require('path');
const packageMetadata = require('#root/package.json'); const packageMetadata = require('#root/package.json');
@@ -102,7 +103,7 @@ function renderOnboardingFormBody(deviceId) {
function renderPlayerPage(slug, initialData) { function renderPlayerPage(slug, initialData) {
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript(); const serviceWorkerScript = '<script>' + fs.readFileSync(path.join(__dirname, '../web/public/js/shared/weather-icons.js'), 'utf8') + '</script>' + getPlayerServiceWorkerRegistrationScript();
const template = getPlayerPageTemplate(); const template = getPlayerPageTemplate();
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>'; const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const chartScriptTag = '<script src="/assets/vendor/chart.js/chart.umd.js"></script>'; const chartScriptTag = '<script src="/assets/vendor/chart.js/chart.umd.js"></script>';
+11
View File
@@ -242,6 +242,17 @@ const PERMISSION_SECTIONS = [
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' } { key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
] ]
}, },
{
key: 'weather-icons',
order: 35,
name: 'Weather icons',
permissions: [
{ key: 'read', name: 'Read', description: 'View weather icon sets.' },
{ key: 'create', name: 'Create', description: 'Upload weather icon sets.' },
{ key: 'update', name: 'Update', description: 'Update weather icon sets.' },
{ key: 'delete', name: 'Delete', description: 'Delete unused weather icon sets.' }
]
},
{ {
key: 'scheduled-tasks', key: 'scheduled-tasks',
order: 50, order: 50,
@@ -31,7 +31,8 @@ function registerFontSyncTask(options) {
if (String(operation.type || '').trim().toLowerCase() === 'delete') { if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier); await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
} else { } else {
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier); const copied = await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
if (payload.requireSuccess && copied === false) throw new Error('Unable to synchronize ' + uploadPath + ' to player ' + playerIdentifier + '.');
} }
} }
}); });
@@ -12,6 +12,7 @@ const {
collectFontLibraryDirectoryUploadPaths, collectFontLibraryDirectoryUploadPaths,
collectFontLibrarySyncOperations collectFontLibrarySyncOperations
} = require('#src/web/lib/media/font-library'); } = require('#src/web/lib/media/font-library');
const { collectWeatherIconDirectoryUploadPaths, collectWeatherIconSyncOperations } = require('#src/web/lib/media/weather-icon-library');
function registerFontSweepTask(options) { function registerFontSweepTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -33,11 +34,11 @@ function registerFontSweepTask(options) {
mediaDir: mediaDir mediaDir: mediaDir
}, },
run: async function () { run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir); const desiredOperations = collectFontLibrarySyncOperations(mediaDir).concat(collectWeatherIconSyncOperations(mediaDir));
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : ''; return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean)); }).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir); const currentUploadPaths = (await collectFontLibraryDirectoryUploadPaths(mediaDir)).concat(await collectWeatherIconDirectoryUploadPaths(mediaDir));
for (let i = 0; i < desiredOperations.length; i += 1) { for (let i = 0; i < desiredOperations.length; i += 1) {
const operation = desiredOperations[i] || {}; const operation = desiredOperations[i] || {};
@@ -56,7 +56,7 @@ function registerInitialFontSyncTask(options) {
return null; return null;
} }
const operations = collectFontLibrarySyncOperations(mediaDir); const operations = collectFontLibrarySyncOperations(mediaDir).concat(require('#src/web/lib/media/weather-icon-library').collectWeatherIconSyncOperations(mediaDir));
return Promise.all(livePlayers.map(function (player) { return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player); const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({ return backgroundTaskQueue.enqueueTask({
+1 -1
View File
@@ -181,7 +181,7 @@ function createDashboardStateService(options) {
const kioskPlayers = buildKioskLauncherPlayers(playerRegistrations, connectedPlayerCountStaleSeconds); const kioskPlayers = buildKioskLauncherPlayers(playerRegistrations, connectedPlayerCountStaleSeconds);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN; return socket && socket.readyState === WebSocket.OPEN;
}); }) || Number(connectedPlayersCount) > 0;
return { return {
playlists: data.playlists || [], playlists: data.playlists || [],
+81
View File
@@ -0,0 +1,81 @@
const fs = require('fs');
const path = require('path');
function resolveMediaRoot(mediaRootOrUploadDir) {
const root = path.resolve(mediaRootOrUploadDir);
return path.basename(root) === 'uploads' ? path.dirname(root) : root;
}
function moveDirectory(source, destination) {
if (!fs.existsSync(source)) return;
fs.mkdirSync(destination, { recursive: true });
let entries;
try {
entries = fs.readdirSync(source, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return;
throw error;
}
for (const entry of entries) {
const sourcePath = path.join(source, entry.name);
const targetPath = path.join(destination, entry.name);
try {
if (entry.isDirectory()) {
moveDirectory(sourcePath, targetPath);
} else if (!fs.existsSync(targetPath)) {
fs.renameSync(sourcePath, targetPath);
} else if (fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath))) {
fs.unlinkSync(sourcePath);
} else {
throw new Error('Conflicting managed asset: ' + targetPath);
}
} catch (error) {
if (error.code !== 'ENOENT' || !fs.existsSync(targetPath)) throw error;
}
}
try {
fs.rmdirSync(source);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
function migrateLegacyAssets(mediaRootOrUploadDir) {
const root = resolveMediaRoot(mediaRootOrUploadDir);
const assets = path.join(root, 'assets');
const legacyFonts = path.join(root, 'fonts');
const legacyWeather = path.join(root, 'weather-icons');
if (!fs.existsSync(legacyFonts) && !fs.existsSync(legacyWeather)) return;
fs.mkdirSync(assets, { recursive: true });
for (const fileName of ['fonts.json', 'fonts.css']) {
const source = path.join(legacyFonts, fileName);
if (fs.existsSync(source)) {
const target = path.join(assets, fileName);
try {
if (!fs.existsSync(target)) fs.renameSync(source, target);
else if (fs.readFileSync(source).equals(fs.readFileSync(target))) fs.unlinkSync(source);
else throw new Error('Conflicting managed asset: ' + target);
} catch (error) {
if (error.code !== 'ENOENT' || !fs.existsSync(target)) throw error;
}
}
}
moveDirectory(legacyFonts, path.join(assets, 'fonts'));
moveDirectory(legacyWeather, path.join(assets, 'weather-icons'));
const stylesheet = path.join(assets, 'fonts.css');
if (fs.existsSync(stylesheet)) {
fs.writeFileSync(stylesheet, fs.readFileSync(stylesheet, 'utf8').replaceAll('/media/fonts/', '/media/assets/fonts/'));
}
const manifest = path.join(assets, 'weather-icons', 'sets.json');
if (fs.existsSync(manifest)) {
const sets = JSON.parse(fs.readFileSync(manifest, 'utf8'));
for (const set of sets) {
for (const condition of Object.keys(set.icons || {})) {
set.icons[condition] = String(set.icons[condition]).replace(/^\/media\/weather-icons\//, '/media/assets/weather-icons/');
}
}
fs.writeFileSync(manifest, JSON.stringify(sets, null, 2));
}
}
module.exports = { resolveMediaRoot, migrateLegacyAssets };
+10 -6
View File
@@ -1,8 +1,9 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { migrateLegacyAssets } = require('#src/web/lib/media/assets-storage');
const FONT_LIBRARY_DIR_NAME = 'fonts'; const FONT_LIBRARY_DIR_NAME = 'assets/fonts';
const FONT_MANIFEST_FILE = 'fonts.json'; const FONT_MANIFEST_FILE = 'fonts.json';
const FONT_STYLESHEET_FILE = 'fonts.css'; const FONT_STYLESHEET_FILE = 'fonts.css';
const DEFAULT_FONT_FAMILY_FORMATS = [ const DEFAULT_FONT_FAMILY_FORMATS = [
@@ -77,21 +78,22 @@ function getFontLibraryDir(mediaRootOrUploadDir) {
} }
function getFontManifestPath(mediaRootOrUploadDir) { function getFontManifestPath(mediaRootOrUploadDir) {
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_MANIFEST_FILE); return path.join(resolveMediaRoot(mediaRootOrUploadDir), 'assets', FONT_MANIFEST_FILE);
} }
function getFontStylesheetPath(mediaRootOrUploadDir) { function getFontStylesheetPath(mediaRootOrUploadDir) {
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_STYLESHEET_FILE); return path.join(resolveMediaRoot(mediaRootOrUploadDir), 'assets', FONT_STYLESHEET_FILE);
} }
function getFontStylesheetHref(mediaRootOrUploadDir) { function getFontStylesheetHref(mediaRootOrUploadDir) {
migrateLegacyAssets(mediaRootOrUploadDir);
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
if (!fs.existsSync(stylesheetPath)) { if (!fs.existsSync(stylesheetPath)) {
return ''; return '';
} }
const stat = fs.statSync(stylesheetPath); const stat = fs.statSync(stylesheetPath);
return `/media/${FONT_LIBRARY_DIR_NAME}/${FONT_STYLESHEET_FILE}?v=${Number(stat.mtimeMs) || Date.now()}`; return `/media/assets/${FONT_STYLESHEET_FILE}?v=${Number(stat.mtimeMs) || Date.now()}`;
} }
function normalizeFontEntry(entry) { function normalizeFontEntry(entry) {
@@ -117,6 +119,7 @@ function sortFonts(left, right) {
} }
function readManifest(mediaRootOrUploadDir) { function readManifest(mediaRootOrUploadDir) {
migrateLegacyAssets(mediaRootOrUploadDir);
const manifestPath = getFontManifestPath(mediaRootOrUploadDir); const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
if (!fs.existsSync(manifestPath)) { if (!fs.existsSync(manifestPath)) {
return []; return [];
@@ -180,6 +183,7 @@ function buildFontFamilyFormats(fonts) {
} }
async function ensureFontLibrary(mediaRootOrUploadDir) { async function ensureFontLibrary(mediaRootOrUploadDir) {
migrateLegacyAssets(mediaRootOrUploadDir);
const fontDir = getFontLibraryDir(mediaRootOrUploadDir); const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
const manifestPath = getFontManifestPath(mediaRootOrUploadDir); const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
@@ -368,8 +372,8 @@ async function collectFontLibraryDirectoryUploadPaths(mediaRootOrUploadDir) {
function collectFontLibrarySyncOperations(mediaRootOrUploadDir) { function collectFontLibrarySyncOperations(mediaRootOrUploadDir) {
const fonts = readManifest(mediaRootOrUploadDir); const fonts = readManifest(mediaRootOrUploadDir);
const operations = [ const operations = [
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_MANIFEST_FILE }, { type: 'put', uploadPath: '/media/assets/' + FONT_MANIFEST_FILE },
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_STYLESHEET_FILE } { type: 'put', uploadPath: '/media/assets/' + FONT_STYLESHEET_FILE }
]; ];
fonts.forEach(function (font) { fonts.forEach(function (font) {
+9 -3
View File
@@ -189,11 +189,17 @@ function substitutePlaceholders(html, regionContent, options) {
} }
if (type === 'weather' && /(?:^|\.)weather_code\.\d+\.icon(?:\(|$)|^current\.weather_code\.icon/.test(weatherExpression)) { if (type === 'weather' && /(?:^|\.)weather_code\.\d+\.icon(?:\(|$)|^current\.weather_code\.icon/.test(weatherExpression)) {
const codeExpression = weatherExpression.replace(/\.icon(?:\(.*\))?$/, ''); const codeExpression = weatherExpression.replace(/\.icon(?:\(.*\))?$/, '');
const iconSize = String(expression).match(/\.icon\(\s*(\d+)(?:\s*,\s*(\d+))?\s*\)$/); const iconSize = String(expression).match(/\.icon\(\s*(\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\s*\)$/);
const width = iconSize ? Number(iconSize[1]) : 0; const width = iconSize ? Number(iconSize[1]) : 0;
const height = iconSize && iconSize[2] ? Number(iconSize[2]) : width; const height = iconSize && iconSize[2] ? Number(iconSize[2]) : width;
const sizeStyle = width ? ' style="display:inline-block;width:' + width + 'px;height:' + height + 'px;font-size:' + width + 'px;line-height:' + height + 'px;"' : ''; const alignment = iconSize && iconSize[3] || 'middle';
return '<i class="bi ' + weatherIconForCode(placeholderUtils.resolvePlaceholderExpression(item, codeExpression)) + '"' + sizeStyle + ' aria-hidden="true"></i>'; const weatherIcons = require('#src/web/public/js/shared/weather-icons');
const source = weatherIcons.imageForCode(options && options.weatherIconSets, regionContent.weather_icon_set_id, placeholderUtils.resolvePlaceholderExpression(item, codeExpression), weatherIcons.isNightForWeather(item));
if (source) return weatherIcons.renderImage(resolveAssetUrl(options && options.baseUrl, source), width, height, alignment);
const sizeStyle = width ? ' style="display:block;width:' + width + 'px;height:' + height + 'px;font-size:' + width + 'px;line-height:1;"' : '';
const icon = '<i class="bi ' + weatherIconForCode(placeholderUtils.resolvePlaceholderExpression(item, codeExpression)) + '"' + sizeStyle + ' aria-hidden="true"></i>';
const verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return width ? '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + width + 'px;height:' + height + 'px;">' + icon + '</span>' : icon;
} }
const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || ''); const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || '');
if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) { if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) {
+1 -1
View File
@@ -946,7 +946,7 @@ function createUploadSyncService(options) {
uploadRefs.add(reference); uploadRefs.add(reference);
}); });
}); });
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir); const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir).concat(require('#src/web/lib/media/weather-icon-library').collectWeatherIconSyncOperations(uploadDir));
const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir); const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir);
generatedCachePaths.filter(function (uploadPath) { generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/'); return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
+162
View File
@@ -0,0 +1,162 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { resolveMediaRoot, migrateLegacyAssets } = require('#src/web/lib/media/assets-storage');
const CONDITIONS = [
['clear', 'Clear'], ['partly-cloudy', 'Partly cloudy'], ['overcast', 'Overcast'],
['fog', 'Fog'], ['drizzle', 'Drizzle'], ['rain', 'Rain'], ['snow', 'Snow'],
['showers', 'Showers'], ['thunderstorm', 'Thunderstorm']
].map(function (entry) { return { key: entry[0], label: entry[1] }; });
function libraryDir(mediaRoot) {
return path.join(resolveMediaRoot(mediaRoot), 'assets', 'weather-icons');
}
function getWeatherManifestPath(mediaRoot) {
const root = resolveMediaRoot(mediaRoot);
const manifest = path.join(root, 'assets', 'weather.json');
const legacyManifest = path.join(libraryDir(mediaRoot), 'sets.json');
if (!fs.existsSync(legacyManifest)) return manifest;
fs.mkdirSync(path.dirname(manifest), { recursive: true });
if (!fs.existsSync(manifest)) {
fs.renameSync(legacyManifest, manifest);
} else if (fs.readFileSync(manifest).equals(fs.readFileSync(legacyManifest))) {
fs.unlinkSync(legacyManifest);
} else {
throw new Error('Conflicting weather icon manifest: ' + manifest);
}
return manifest;
}
function readWeatherIconSets(mediaRoot) {
migrateLegacyAssets(mediaRoot);
const manifest = getWeatherManifestPath(mediaRoot);
if (!fs.existsSync(manifest)) return [];
const sets = JSON.parse(fs.readFileSync(manifest, 'utf8'));
if (!Array.isArray(sets)) throw new Error('Invalid weather icon manifest.');
return sets.map(function (set) {
return Object.assign({}, set, { enabled: set && set.enabled !== false, nightIcons: set && set.nightIcons || {} });
});
}
function validateSvgSafety(buffer) {
const svg = Buffer.from(buffer).toString('utf8');
if (/<(?:script|foreignObject|iframe|object|embed)\b|\son[a-z]+\s*=|(?:href|xlink:href)\s*=\s*["']\s*(?:https?:|\/\/|data:|javascript:)/i.test(svg)) {
throw new Error('SVG icons cannot contain scripts or external resources.');
}
}
async function saveWeatherIconSet(mediaRoot, name, files, setId) {
const label = String(name || '').trim();
if (!label || label.length > 100) throw new Error('Enter a set name of up to 100 characters.');
const sets = readWeatherIconSets(mediaRoot);
const existing = setId ? sets.find(function (set) { return set.id === setId; }) : null;
if (setId && !existing) throw new Error('Weather icon set not found.');
if (sets.some(function (set) { return set.id !== setId && set.name.toLowerCase() === label.toLowerCase(); })) {
throw new Error('A weather icon set with this name already exists.');
}
const id = existing ? existing.id : crypto.randomUUID();
const icons = Object.assign({}, existing && existing.icons);
const nightIcons = Object.assign({}, existing && existing.nightIcons);
const prepared = [];
for (const file of files || []) {
const fieldName = String(file.fieldname || '');
const isNight = fieldName.startsWith('night_icon_');
const condition = fieldName.replace(/^(?:night_)?icon_/, '');
if (!CONDITIONS.some(function (entry) { return entry.key === condition; })) throw new Error('Unknown weather condition.');
if (!file.buffer || file.buffer.length > 5 * 1024 * 1024) throw new Error('Each icon must be smaller than 5MB.');
const image = require('sharp')(file.buffer, { limitInputPixels: 16777216 });
const metadata = await image.metadata();
if (!['png', 'webp', 'svg'].includes(metadata.format) || metadata.pages > 1) throw new Error('Upload static PNG, WebP, or SVG icons.');
const buffer = metadata.format === 'svg'
? (validateSvgSafety(file.buffer), Buffer.from(file.buffer))
: await image.resize({ width: 1024, height: 1024, fit: 'inside', withoutEnlargement: true }).png().toBuffer();
const extension = metadata.format === 'svg' ? '.svg' : '.png';
const fileName = (isNight ? 'night-' : '') + condition + '-' + crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16) + extension;
prepared.push({ fileName: fileName, buffer: buffer });
(isNight ? nightIcons : icons)[condition] = '/media/assets/weather-icons/' + id + '/' + fileName;
}
const missingConditions = CONDITIONS.filter(function (condition) {
return !icons[condition.key];
}).map(function (condition) {
return condition.label;
});
if (missingConditions.length) throw new Error('Upload an icon for every weather condition: ' + missingConditions.join(', ') + '.');
const directory = path.join(libraryDir(mediaRoot), id);
fs.mkdirSync(directory, { recursive: true });
prepared.forEach(function (file) { fs.writeFileSync(path.join(directory, file.fileName), file.buffer); });
const set = { id: id, name: label, icons: icons, nightIcons: nightIcons, enabled: existing ? existing.enabled !== false : true };
const next = existing ? sets.map(function (entry) { return entry.id === id ? set : entry; }) : sets.concat(set);
const manifest = getWeatherManifestPath(mediaRoot);
fs.writeFileSync(manifest + '.tmp', JSON.stringify(next, null, 2));
fs.renameSync(manifest + '.tmp', manifest);
return set;
}
function collectWeatherIconSyncOperations(mediaRoot) {
const sets = readWeatherIconSets(mediaRoot);
if (!fs.existsSync(getWeatherManifestPath(mediaRoot))) return [];
return sets.flatMap(function (set) {
return Object.values(set.icons).concat(Object.values(set.nightIcons || {})).map(function (uploadPath) { return { type: 'put', uploadPath: uploadPath }; });
}).concat([{ type: 'put', uploadPath: '/media/assets/weather.json' }, { type: 'delete', uploadPath: '/media/assets/weather-icons/sets.json' }]);
}
function setWeatherIconSetEnabled(mediaRoot, id, enabled) {
const sets = readWeatherIconSets(mediaRoot);
const index = sets.findIndex(function (set) { return set.id === id; });
if (index === -1) return null;
const next = sets.slice();
next[index] = Object.assign({}, next[index], { enabled: Boolean(enabled) });
const manifest = getWeatherManifestPath(mediaRoot);
fs.writeFileSync(manifest + '.tmp', JSON.stringify(next, null, 2));
fs.renameSync(manifest + '.tmp', manifest);
return next[index];
}
function collectWeatherIconPaths(sets) {
return Array.from(new Set((Array.isArray(sets) ? sets : []).flatMap(function (set) {
return Object.values(set && set.icons || {}).concat(Object.values(set && set.nightIcons || {})).filter(function (uploadPath) {
return typeof uploadPath === 'string' && uploadPath.startsWith('/media/assets/weather-icons/');
});
})));
}
function collectWeatherIconRemovalOperations(previousSets, nextSets) {
const desired = new Set(collectWeatherIconPaths(nextSets));
return collectWeatherIconPaths(previousSets).filter(function (uploadPath) {
return !desired.has(uploadPath);
}).map(function (uploadPath) {
return { type: 'delete', uploadPath: uploadPath };
});
}
async function collectWeatherIconDirectoryUploadPaths(mediaRoot) {
const root = libraryDir(mediaRoot);
const paths = [];
async function walk(directory, relative) {
let entries;
try { entries = await fs.promises.readdir(directory, { withFileTypes: true }); } catch (_error) { return; }
await Promise.all(entries.map(async function (entry) {
const childRelative = path.join(relative, entry.name);
const child = path.join(directory, entry.name);
if (entry.isDirectory()) return walk(child, childRelative);
if (entry.isFile()) paths.push('/media/assets/weather-icons/' + childRelative.replace(/\\/g, '/'));
}));
}
await walk(root, '');
return paths;
}
function deleteWeatherIconSet(mediaRoot, id) {
const sets = readWeatherIconSets(mediaRoot);
const existing = sets.find(function (set) { return set.id === id; });
if (!existing) throw new Error('Weather icon set not found.');
const manifest = getWeatherManifestPath(mediaRoot);
fs.writeFileSync(manifest + '.tmp', JSON.stringify(sets.filter(function (set) { return set.id !== id; }), null, 2));
fs.renameSync(manifest + '.tmp', manifest);
fs.rmSync(path.join(libraryDir(mediaRoot), id), { recursive: true, force: true });
return existing;
}
module.exports = { CONDITIONS, readWeatherIconSets, saveWeatherIconSet, setWeatherIconSetEnabled, collectWeatherIconSyncOperations, collectWeatherIconRemovalOperations, collectWeatherIconDirectoryUploadPaths, deleteWeatherIconSet, getWeatherManifestPath };
+1 -1
View File
@@ -28,7 +28,7 @@ function getRegionModuleScripts(assetVersion) {
} }
function getRegionEditorScripts(assetVersion) { function getRegionEditorScripts(assetVersion) {
return REGION_CORE_SCRIPTS.map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion)); return ['js/shared/weather-icons.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
} }
module.exports = { module.exports = {
+83
View File
@@ -0,0 +1,83 @@
.weather-icon-upload-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: .5rem;
}
.weather-icon-upload-section + .weather-icon-upload-section {
margin-top: 1rem;
}
.weather-icon-upload-row-label {
color: var(--bs-body-color);
font-size: .875rem;
font-weight: 600;
margin-bottom: .5rem;
}
.weather-icon-upload-row-label span {
color: var(--bs-secondary-color);
font-weight: 400;
}
.weather-icon-upload-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .25rem;
width: 100%;
height: 112px;
padding: .5rem;
color: var(--bs-body-color);
background: var(--bs-tertiary-bg);
border: 1px dashed var(--bs-border-color);
border-radius: 4px;
}
.weather-icon-upload-tile:hover,
.weather-icon-upload-tile:focus-visible {
border-color: var(--bs-primary);
background: var(--bs-secondary-bg);
}
.weather-icon-upload-tile:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: 2px;
}
.weather-icon-upload-preview {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
font-size: 1.5rem;
color: var(--bs-secondary-color);
}
.weather-icon-upload-label {
font-size: .75rem;
line-height: 1.25;
text-align: center;
overflow-wrap: anywhere;
}
.weather-icon-upload.is-missing .weather-icon-upload-tile {
border-color: var(--bs-warning);
background: color-mix(in srgb, var(--bs-warning) 10%, var(--bs-tertiary-bg));
}
.weather-icon-upload.is-invalid .weather-icon-upload-tile {
border-color: var(--bs-danger);
background: color-mix(in srgb, var(--bs-danger) 10%, var(--bs-tertiary-bg));
}
.weather-icon-upload-status {
color: var(--bs-warning-text-emphasis);
font-size: .75rem;
font-weight: 600;
}
.weather-icon-upload.is-invalid .weather-icon-upload-status {
color: var(--bs-danger-text-emphasis);
}
+10 -2
View File
@@ -119,8 +119,16 @@
? 'display:block;width:100%;height:100%;object-fit:contain;' ? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />'; var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) { var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height;
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>'; if (hasPreviewBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
if (hasPreviewBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
} }
return image; return image;
} }
+10 -2
View File
@@ -138,8 +138,16 @@
? 'display:block;width:100%;height:100%;object-fit:contain;' ? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />'; var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) { var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height;
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>'; if (hasPreviewBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
if (hasPreviewBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);';
}
if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
}
return '<span style="' + wrapperStyle + '">' + image + '</span>';
} }
return image; return image;
} }
+34 -9
View File
@@ -109,7 +109,7 @@
return data; return data;
} }
function substituteVariables(html, value) { function substituteVariables(html, value, iconSetId) {
return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!value || typeof value !== 'object' || typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') return ''; if (!value || typeof value !== 'object' || typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') return '';
var rawExpression = String(expression).trim(); var rawExpression = String(expression).trim();
@@ -117,16 +117,20 @@
if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(rawExpression)) { if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(rawExpression)) {
return placeholderUtils.renderProgressPlaceholder(value, rawExpression); return placeholderUtils.renderProgressPlaceholder(value, rawExpression);
} }
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/); var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\))?$/);
if (iconMatch) { if (iconMatch) {
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, ''); var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?\)$/); var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\)$/);
var weatherCode = placeholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone }); var weatherCode = placeholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
var width = sizeExpression ? Math.max(1, Math.min(1000, Number(sizeExpression[1]))) : 0; var width = sizeExpression ? Math.max(1, Math.min(1000, Number(sizeExpression[1]))) : 0;
var height = sizeExpression && sizeExpression[2] ? Math.max(1, Math.min(1000, Number(sizeExpression[2]))) : width; var height = sizeExpression && sizeExpression[2] ? Math.max(1, Math.min(1000, Number(sizeExpression[2]))) : width;
var style = width ? ' style="display:inline-block;vertical-align:middle;font-size:' + width + 'px;line-height:' + height + 'px;width:' + width + 'px;height:' + height + 'px;"' : ''; var alignment = sizeExpression && sizeExpression[3] || 'middle';
var source = window.pulseWeatherIcons && window.pulseWeatherIcons.imageForCode(window.pulseWeatherIconSets, iconSetId, weatherCode, window.pulseWeatherIcons.isNightForWeather(value));
if (source) return window.pulseWeatherIcons.renderImage(source, width, height, alignment);
var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;width:' + width + 'px;height:' + height + 'px;"' : '';
var icon = '<i class="bi ' + iconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>'; var icon = '<i class="bi ' + iconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
return width ? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + width + 'px;height:' + height + 'px;border:1px dashed rgba(120,120,120,.75);">' + icon + '</span>' : icon; var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return width ? '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + width + 'px;height:' + height + 'px;border:1px dashed rgba(120,120,120,.75);">' + icon + '</span>' : icon;
} }
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone }))); return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone })));
}); });
@@ -138,7 +142,7 @@
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' + '<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
'<div class="offcanvas-header"><h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">Weather placeholder transforms</h2><button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button></div>' + '<div class="offcanvas-header"><h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">Weather placeholder transforms</h2><button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button></div>' +
'<div class="offcanvas-body"><p class="small text-body-secondary">Placeholders read values from the selected weather snapshot. Nested fields use dots.</p>' + '<div class="offcanvas-body"><p class="small text-body-secondary">Placeholders read values from the selected weather snapshot. Nested fields use dots.</p>' +
'<h3 class="h6 mt-3">Weather icons</h3><p class="small text-body-secondary">Use the icon property to insert a Bootstrap weather icon. Add width and height in pixels when sizing is needed.</p><ul class="small"><li><code>{{current.icon}}</code></li><li><code>{{current.icon(48,48)}}</code></li><li><code>{{daily.0.icon(32,24)}}</code></li></ul>' + '<h3 class="h6 mt-3">Weather icons</h3><p class="small text-body-secondary">Use the icon property to insert a weather icon. Add width, height, and optional top, middle, or bottom alignment when sizing is needed.</p><ul class="small"><li><code>{{current.icon}}</code></li><li><code>{{current.icon(48,48)}}</code></li><li><code>{{daily.0.icon(32,96,top)}}</code></li></ul>' +
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderMathTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderMathTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') + (window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
@@ -154,9 +158,10 @@
var snapshot = getSnapshot(location); var snapshot = getSnapshot(location);
if (!location || !snapshot) return ''; if (!location || !snapshot) return '';
var weatherData = withWeatherUnits(snapshot, location); var weatherData = withWeatherUnits(snapshot, location);
weatherData.iconSetId = content && content.weather_icon_set_id;
if (value) { if (value) {
var fontSize = Math.max(8, Number(content && content.font_size || region && (region.font_size || region.fontSize) || 32) || 32); var fontSize = Math.max(8, Number(content && content.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
return '<div class="template-region weather" style="width:100%;height:100%;overflow:hidden;font-family:Arial;font-size:' + fontSize + 'px;line-height:1.5;color:#000000;"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + (utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(substituteVariables(value, weatherData)) : substituteVariables(value, weatherData)) + '</div></div>'; return '<div class="template-region weather" style="width:100%;height:100%;overflow:hidden;font-family:Arial;font-size:' + fontSize + 'px;line-height:1.5;color:#000000;"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + (utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(substituteVariables(value, weatherData, weatherData.iconSetId)) : substituteVariables(value, weatherData, weatherData.iconSetId)) + '</div></div>';
} }
var current = snapshot.current || {}; var current = snapshot.current || {};
var daily = snapshot.daily || {}; var daily = snapshot.daily || {};
@@ -198,14 +203,34 @@
return { region: context.region, current: context.current || {}, weatherLocations: context.weatherLocations || [] }; return { region: context.region, current: context.current || {}, weatherLocations: context.weatherLocations || [] };
} }
function renderEditorCardWithIcons(context) {
var html = renderEditorCard(context);
var id = context.region.id;
var selected = context.current && context.current.weather_icon_set_id || '';
var sets = Array.isArray(window.pulseWeatherIconSets) ? window.pulseWeatherIconSets : [];
var options = sets.filter(function (set) { return set.enabled !== false; }).map(function (set) {
return '<option value="' + escapeHtml(set.id) + '"' + (selected === set.id ? ' selected' : '') + '>' + escapeHtml(set.name) + '</option>';
}).join('');
var selectedSet = sets.find(function (set) { return set.id === selected; });
if (selectedSet && selectedSet.enabled === false) {
options += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selectedSet.name) + '</option>';
} else if (selected && !selectedSet) {
options += '<option value="' + escapeHtml(selected) + '" selected>Unavailable set</option>';
}
var control = '<div class="col-12 col-lg-3"><label class="form-label" for="region_weather_icon_set_id_' + escapeHtml(id) + '">Icon set</label><select class="form-select" id="region_weather_icon_set_id_' + escapeHtml(id) + '" name="region_weather_icon_set_id_' + escapeHtml(id) + '"><option value="">Built-in icons</option>' + options + '</select></div>';
html = html.replace('class="col-12 col-md-8"', 'class="col-12 col-lg-6"').replace('class="col-12 col-md-4"', 'class="col-12 col-lg-3"');
return html.replace('</select></div></div><input type="hidden" name="region_text', '</select></div>' + control + '</div><input type="hidden" name="region_text');
}
function buildPreviewRenderContext(region, card, existingContent, locations) { function buildPreviewRenderContext(region, card, existingContent, locations) {
var current = existingContent[region.region_key] || {}; var current = existingContent[region.region_key] || {};
var input = card && card.querySelector('select[name="region_weather_location_id_' + region.id + '"]'); var input = card && card.querySelector('select[name="region_weather_location_id_' + region.id + '"]');
var modeInput = card && card.querySelector('select[name="region_weather_forecast_mode_' + region.id + '"]'); var modeInput = card && card.querySelector('select[name="region_weather_forecast_mode_' + region.id + '"]');
var iconInput = card && card.querySelector('select[name="region_weather_icon_set_id_' + region.id + '"]');
var textInput = card && card.querySelector('input[name="region_text_' + region.id + '"]'); var textInput = card && card.querySelector('input[name="region_text_' + region.id + '"]');
var textArea = card && card.querySelector('textarea.editor-source'); var textArea = card && card.querySelector('textarea.editor-source');
return { value: textInput ? textInput.value : textArea ? textArea.value : current.value || '', weather_location_id: input ? input.value : current.weather_location_id, forecast_mode: modeInput ? modeInput.value : current.forecast_mode || 'daily', weatherLocations: locations || [] }; return { value: textInput ? textInput.value : textArea ? textArea.value : current.value || '', weather_location_id: input ? input.value : current.weather_location_id, weather_icon_set_id: iconInput ? iconInput.value : current.weather_icon_set_id || '', forecast_mode: modeInput ? modeInput.value : current.forecast_mode || 'daily', weatherLocations: locations || [] };
} }
registry.register('weather', { label: 'Weather', getDefaultStyle: getDefaultStyle, getDefaultRegionSize: function () { return { width: 720, height: 260 }; }, renderPreview: renderPreview, renderEditorCard: renderEditorCard, buildEditorCardContext: buildEditorCardContext, buildPreviewRenderContext: buildPreviewRenderContext }); registry.register('weather', { label: 'Weather', getDefaultStyle: getDefaultStyle, getDefaultRegionSize: function () { return { width: 720, height: 260 }; }, renderPreview: renderPreview, renderEditorCard: renderEditorCardWithIcons, buildEditorCardContext: buildEditorCardContext, buildPreviewRenderContext: buildPreviewRenderContext });
}()); }());
@@ -0,0 +1,59 @@
(function () {
function validateSetForm(form) {
var missing = Array.prototype.slice.call(form.querySelectorAll('[data-weather-icon-upload][data-weather-icon-required]')).filter(function (tile) {
var input = tile.querySelector('[data-weather-icon-file]');
var preview = tile.querySelector('[data-weather-icon-preview]');
return !(input.files && input.files.length) && !preview.getAttribute('src');
});
form.querySelectorAll('[data-weather-icon-upload]').forEach(function (tile) {
tile.classList.toggle('is-invalid', missing.includes(tile));
});
if (!missing.length) return true;
var labels = missing.map(function (tile) {
var label = tile.querySelector('[data-weather-icon-picker]');
return String(label && label.getAttribute('aria-label') || '').replace(/^Upload\s+|\s+icon$/g, '');
}).filter(Boolean);
if (typeof window.showToast === 'function') {
window.showToast('Add images for: ' + labels.join(', ') + '.', 'warning');
}
missing[0].querySelector('[data-weather-icon-picker]').focus();
return false;
}
document.querySelectorAll('[data-weather-icon-upload]').forEach(function (tile) {
var picker = tile.querySelector('[data-weather-icon-picker]');
var input = tile.querySelector('[data-weather-icon-file]');
var preview = tile.querySelector('[data-weather-icon-preview]');
var empty = tile.querySelector('[data-weather-icon-empty]');
var status = tile.querySelector('[data-weather-icon-status]');
var originalSource = preview.getAttribute('src') || '';
var previewUrl = '';
function updatePreview() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
var file = input.files && input.files[0];
previewUrl = file ? URL.createObjectURL(file) : '';
var source = previewUrl || originalSource;
if (source) preview.src = source;
else preview.removeAttribute('src');
preview.hidden = !source;
empty.hidden = Boolean(source);
tile.classList.toggle('is-missing', !source && tile.hasAttribute('data-weather-icon-required'));
tile.classList.remove('is-invalid');
if (status) status.hidden = Boolean(source);
}
picker.addEventListener('click', function () { input.click(); });
input.addEventListener('change', updatePreview);
input.form.addEventListener('reset', function () { setTimeout(updatePreview, 0); });
window.addEventListener('pagehide', function () {
if (previewUrl) URL.revokeObjectURL(previewUrl);
});
updatePreview();
});
document.querySelectorAll('[data-weather-icon-set-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!validateSetForm(form)) event.preventDefault();
});
});
}());
+2 -2
View File
@@ -58,8 +58,8 @@
function renderImageTransform() { function renderImageTransform() {
return '<h3 class="fs-6 mt-4 fw-normal">Image transform</h3>' + return '<h3 class="fs-6 mt-4 fw-normal">Image transform</h3>' +
'<p class="small">Use <code>image(width, height)</code> to render an image URL inside a proportional bounding box.</p>' + '<p class="small">Use <code>image(width, height, alignment)</code> to render an image URL inside a proportional bounding box. Alignment can be <code>top</code>, <code>middle</code>, or <code>bottom</code> to position it relative to surrounding text.</p>' +
'<ul class="small"><li>The preview shows the width and height boundary.</li><li>The player has no boundary.</li><li>The image keeps its original aspect ratio.</li><li>Both dimensions are required for a bounding box.</li></ul>'; '<ul class="small"><li>The preview shows the width and height boundary.</li><li>The player has no boundary.</li><li>The image keeps its original aspect ratio.</li><li>Both dimensions are required for a bounding box.</li><li>Alignment is optional; without it, existing image alignment is unchanged.</li></ul>';
} }
root.placeholderInfo = { root.placeholderInfo = {
@@ -386,9 +386,11 @@
var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : []; var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : [];
var width = Number(args[0]); var width = Number(args[0]);
var height = Number(args[1]); var height = Number(args[1]);
var verticalAlign = String(args[2] || '').trim().toLowerCase();
return { return {
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 0, width: Number.isFinite(width) && width > 0 ? Math.round(width) : 0,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0 height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0,
verticalAlign: ['top', 'middle', 'bottom'].includes(verticalAlign) ? verticalAlign : ''
}; };
} }
+34
View File
@@ -0,0 +1,34 @@
(function (root) {
function conditionForCode(code) {
if (code === null || code === undefined || code === '') return '';
var value = Number(code);
if (value === 0) return 'clear';
if (value === 1 || value === 2) return 'partly-cloudy';
if (value === 3) return 'overcast';
if ([45, 48].includes(value)) return 'fog';
if ([51, 53, 55, 56, 57].includes(value)) return 'drizzle';
if ([61, 63, 65, 66, 67].includes(value)) return 'rain';
if ([71, 73, 75, 77, 85, 86].includes(value)) return 'snow';
if ([80, 81, 82].includes(value)) return 'showers';
if ([95, 96, 99].includes(value)) return 'thunderstorm';
return '';
}
function imageForCode(sets, id, code, isNight) {
var set = (Array.isArray(sets) ? sets : []).find(function (entry) { return entry.id === id; });
var condition = conditionForCode(code);
var source = set && (isNight && set.nightIcons && set.nightIcons[condition] || set.icons && set.icons[condition]);
return typeof source === 'string' && /^\/media\/(?:assets\/)?weather-icons\/[a-f0-9-]+\/[a-z-]+-[a-f0-9]+\.(?:png|svg)$/.test(source) ? source : '';
}
function isNightForWeather(data) {
return Boolean(data && data.current && Number(data.current.is_day) === 0);
}
function renderImage(source, width, height, alignment) {
var iconWidth = width ? width + 'px' : '1em';
var iconHeight = height ? height + 'px' : '1em';
var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + iconWidth + ';height:' + iconHeight + ';"><img src="' + source + '" alt="" style="display:block;width:100%;height:100%;object-fit:contain;" /></span>';
}
var api = { conditionForCode: conditionForCode, imageForCode: imageForCode, isNightForWeather: isNightForWeather, renderImage: renderImage };
if (typeof module === 'object' && module.exports) module.exports = api;
else root.pulseWeatherIcons = api;
}(typeof window === 'object' ? window : globalThis));
+1
View File
@@ -23,6 +23,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : []; var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : []; var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
var weatherLocations = Array.isArray(slideEditorData.weatherLocations) ? slideEditorData.weatherLocations : []; var weatherLocations = Array.isArray(slideEditorData.weatherLocations) ? slideEditorData.weatherLocations : [];
window.pulseWeatherIconSets = Array.isArray(slideEditorData.weatherIconSets) ? slideEditorData.weatherIconSets : [];
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim(); var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {}; var existingContent = slideEditorData.existingContent || {};
+2
View File
@@ -3,6 +3,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { loadFontLibrary } = require('#src/web/lib/media/font-library'); const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library');
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units'); const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log'); const { buildAuditChanges } = require('#src/data/audit-log');
@@ -95,6 +96,7 @@ module.exports = function registerContentRoutes(app, deps) {
apiSources: apiSources, apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [], timetableGroups: timetableData.timetableGroups || [],
weatherLocations: weatherLocations, weatherLocations: weatherLocations,
weatherIconSets: readWeatherIconSets(deps.uploadDir),
mediaAssets: mediaAssets, mediaAssets: mediaAssets,
fontLibrary: loadFontLibrary(deps.uploadDir), fontLibrary: loadFontLibrary(deps.uploadDir),
uploadLimits: { uploadLimits: {
+2 -1
View File
@@ -2,6 +2,7 @@
const { verifyRequestAuth } = require('#src/request-auth'); const { verifyRequestAuth } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library'); const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { collectWeatherIconSyncOperations } = require('#src/web/lib/media/weather-icon-library');
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users'); const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
function requireRequestAuth(req, res, next) { function requireRequestAuth(req, res, next) {
@@ -63,7 +64,7 @@ module.exports = function registerInternalSyncRoutes(app, deps) {
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim(); const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim(); const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim(); const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const operations = collectFontLibrarySyncOperations(mediaDir); const operations = collectFontLibrarySyncOperations(mediaDir).concat(collectWeatherIconSyncOperations(mediaDir));
const task = await backgroundTaskQueue.enqueueTask({ const task = await backgroundTaskQueue.enqueueTask({
key: 'player-font-sync', key: 'player-font-sync',
title: 'Player font sync', title: 'Player font sync',
+2
View File
@@ -16,6 +16,7 @@ const registerTimetableRoutes = require('#src/web/routes/data-sources/timetables
const registerWeatherRoutes = require('#src/web/routes/data-sources/weather'); const registerWeatherRoutes = require('#src/web/routes/data-sources/weather');
const registerSettingsRoutes = require('#src/web/routes/settings/background-tasks'); const registerSettingsRoutes = require('#src/web/routes/settings/background-tasks');
const registerFontRoutes = require('#src/web/routes/settings/fonts'); const registerFontRoutes = require('#src/web/routes/settings/fonts');
const registerWeatherIconRoutes = require('#src/web/routes/settings/weather-icons');
const registerMediaLibraryRoutes = require('#src/web/routes/settings/media-library'); const registerMediaLibraryRoutes = require('#src/web/routes/settings/media-library');
const registerSettingsPageRoutes = require('#src/web/routes/settings/routes'); const registerSettingsPageRoutes = require('#src/web/routes/settings/routes');
const registerAuditLogRoutes = require('#src/web/routes/settings/audit-log'); const registerAuditLogRoutes = require('#src/web/routes/settings/audit-log');
@@ -26,6 +27,7 @@ const registerOnboardingRoutes = require('#src/web/routes/onboarding');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac'); const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
function registerRoutes(app, deps) { function registerRoutes(app, deps) {
registerWeatherIconRoutes(app, deps);
// Group routes by the dependency bundle they need. // Group routes by the dependency bundle they need.
registerAuthAndAccountRoutes(app, deps); registerAuthAndAccountRoutes(app, deps);
registerOnboardingRoutes(app, { registerOnboardingRoutes(app, {
+1 -1
View File
@@ -231,7 +231,7 @@ module.exports = function registerFontRoutes(app, deps) {
payload: { payload: {
uploadDir: uploadDir, uploadDir: uploadDir,
operations: [ operations: [
removed.fileName ? { type: 'delete', uploadPath: '/media/fonts/' + removed.fileName } : null removed.fileName ? { type: 'delete', uploadPath: '/media/assets/fonts/' + removed.fileName } : null
].concat(fontLibrary.collectFontLibrarySyncOperations(uploadDir)).filter(Boolean) ].concat(fontLibrary.collectFontLibrarySyncOperations(uploadDir)).filter(Boolean)
}, },
persist: true persist: true
+1
View File
@@ -6,6 +6,7 @@ module.exports = function renderFontsPage(data, message, currentUser) {
return renderView('settings/fonts/list', { return renderView('settings/fonts/list', {
title: 'Fonts', title: 'Fonts',
active: 'fonts', active: 'fonts',
assetsMenuOpen: true,
message: message, message: message,
currentUser: currentUser || null, currentUser: currentUser || null,
fonts: data.fonts || [], fonts: data.fonts || [],
+96
View File
@@ -0,0 +1,96 @@
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 { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
module.exports = function registerWeatherIconRoutes(app, deps) {
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } });
const fields = library.CONDITIONS.flatMap(function (condition) {
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 search = getSearchQuery(req);
const sort = getSortQuery(req);
const direction = getSortDirectionQuery(req);
const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) {
const usageCount = 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;
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 });
}) });
}), function (set) { return sort === 'usage' ? set.usageCount : sort === 'status' ? (set.enabled ? 'Enabled' : 'Disabled') : set.name; }, direction)
.filter(createSearchMatcher(search, ['name']));
const pagination = buildPagination(sets.length, parsePageNumber(req.query && req.query.page), 'page', { search: search, sort: sort, direction: direction }, 25, 'icon sets', 'Icon set pages');
const startIndex = (pagination.currentPage - 1) * pagination.pageSize;
res.status(status || 200).send(renderView('settings/weather-icons/list', {
title: 'Weather icons', active: 'weather-icons', assetsMenuOpen: true,
stylesheets: ['/assets/css/weather-icons.css'], scripts: ['js/settings/weather-icons.js'],
currentUser: req.currentUser, message: message || '', sets: sets.slice(startIndex, startIndex + pagination.pageSize), conditions: library.CONDITIONS, pagination: pagination
}));
}
async function sync(extraOperations) {
if (deps.backgroundTaskQueue) {
const players = await fetchPlayerRegistrations(deps.pool);
for (const player of players) {
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 });
}
}
}
}
app.get('/settings/weather-icons', deps.requirePermission('weather-icons.read'), async function (req, res, next) {
try { await page(req, res, req.query.message); } catch (error) { next(error); }
});
async function save(req, res, next) {
try {
const previousSets = library.readWeatherIconSets(deps.mediaDir);
await library.saveWeatherIconSet(deps.mediaDir, req.body.name, Object.values(req.files || {}).flat(), req.params.id);
req.weatherIconRemovalOperations = library.collectWeatherIconRemovalOperations(previousSets, library.readWeatherIconSets(deps.mediaDir));
} catch (error) {
try { return await page(req, res, error.message, 400); } catch (pageError) { return next(pageError); }
}
try {
await sync(req.weatherIconRemovalOperations);
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set saved.'));
} catch (error) { next(error); }
}
app.post('/settings/weather-icons', deps.requirePermission('weather-icons.create'), upload.fields(fields), save);
app.post('/settings/weather-icons/:id', deps.requirePermission('weather-icons.update'), upload.fields(fields), save);
app.post('/settings/weather-icons/:id/toggle', deps.requirePermission('weather-icons.update'), async function (req, res, next) {
try {
const enabled = String(req.body && req.body.enabled || '').trim() !== '0';
const updated = library.setWeatherIconSetEnabled(deps.mediaDir, req.params.id, enabled);
if (!updated) return res.redirect('/settings/weather-icons?message=' + encodeURIComponent('That weather icon set was not found.'));
await sync();
res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.'));
} catch (error) { next(error); }
});
app.post('/settings/weather-icons/:id/delete', deps.requirePermission('weather-icons.delete'), async function (req, res, next) {
try {
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
const used = slides.some(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 === req.params.id;
});
});
if (used) return await page(req, res, 'That icon set is still used by one or more slides.', 400);
const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id);
await sync(library.collectWeatherIconRemovalOperations([removed], []));
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.'));
} catch (error) { next(error); }
});
};
@@ -51,6 +51,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
apiSources: apiSources, apiSources: apiSources,
timetableGroups: timetableGroups, timetableGroups: timetableGroups,
weatherLocations: weatherLocations, weatherLocations: weatherLocations,
weatherIconSets: data && data.weatherIconSets || [],
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '', fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '', fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null, existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
+1
View File
@@ -134,6 +134,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
const payload = buildThumbnailPreviewPayload(slide, { const payload = buildThumbnailPreviewPayload(slide, {
baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''), baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''),
fontStylesheetHref: getFontStylesheetHref(mediaDir), fontStylesheetHref: getFontStylesheetHref(mediaDir),
weatherIconSets: require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir),
apiSources: apiSources, apiSources: apiSources,
getItem: getItem, getItem: getItem,
getCachedImagePath: getCachedImagePath getCachedImagePath: getCachedImagePath
@@ -0,0 +1,179 @@
<div class="page-header">
<div>
<h2>Weather icons</h2>
<p>Upload and manage shared icon sets for weather regions.</p>
</div>
</div>
{{#if (hasPermission currentUser 'weather-icons.create')}}
<div class="card card-outline card-primary mb-4 collapsed-card">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Upload icon set</h3>
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Toggle upload icon set" title="Toggle upload icon set">
<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>
<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>
</button>
</div>
</div>
<form method="post" action="/settings/weather-icons" enctype="multipart/form-data" class="card-body p-0" data-weather-icon-set-form>
<div class="p-3">
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<label class="form-label" for="new-set-name">Name</label>
<input id="new-set-name" name="name" class="form-control" maxlength="100" required>
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Day icons</div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload data-weather-icon-required>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} day icon" title="Upload {{label}} day icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}}" hidden><i class="bi bi-cloud-arrow-up" data-weather-icon-empty aria-hidden="true"></i></span>
<span class="weather-icon-upload-label">{{label}} day</span>
<span class="weather-icon-upload-status" data-weather-icon-status>Required</span>
</button>
<input id="new-icon-{{key}}" name="icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Night icons <span>Optional</span></div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} night icon" title="Upload {{label}} night icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}} night" hidden><i class="bi bi-moon-stars" data-weather-icon-empty aria-hidden="true"></i></span>
<span class="weather-icon-upload-label">{{label}} night</span>
</button>
<input id="new-night-icon-{{key}}" name="night_icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} night icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<button class="btn btn-primary" type="submit">Create set</button>
</div>
</form>
</div>
{{/if}}
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Managed icon sets</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search icon sets" aria-label="Search icon sets" data-table-search>
</div>
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped align-middle mb-0" data-table-searchable>
<thead><tr>
<th data-table-sort-key="name">Name</th>
<th>Preview</th>
<th data-table-sort-key="usage">Slides</th>
<th data-table-sort-key="status">Status</th>
{{#if (anyPermission currentUser 'weather-icons.update' 'weather-icons.delete')}}<th>Actions</th>{{/if}}
</tr></thead>
<tbody>
{{#each sets}}
<tr data-table-search-row>
<td data-label="Name" class="fw-semibold text-break">{{name}}</td>
<td data-label="Preview">
<div class="d-flex flex-wrap gap-2" style="min-width:144px;">
{{#each conditions}}
<div class="d-flex align-items-center justify-content-center border rounded bg-body-tertiary" style="width:40px;height:40px;flex-shrink:0;" title="{{label}}">
{{#if image}}<img src="{{image}}" alt="{{label}}" width="32" height="32" style="object-fit:contain;">
{{else}}<i class="bi bi-cloud text-body-secondary" aria-label="{{label}}: built-in fallback"></i>{{/if}}
</div>
{{/each}}
</div>
</td>
<td data-label="Slides">{{usageCount}}</td>
<td data-label="Status">{{#if enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{/if}}</td>
{{#if (anyPermission ../currentUser 'weather-icons.update' 'weather-icons.delete')}}
<td data-label="Actions" class="text-nowrap">
{{#if (hasPermission ../currentUser 'weather-icons.update')}}
<button type="button" class="btn btn-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#edit-set-{{id}}">Edit</button>
<form method="post" action="/settings/weather-icons/{{id}}/toggle" class="d-inline ms-1">
<input type="hidden" name="enabled" value="{{#if enabled}}0{{else}}1{{/if}}">
<button type="submit" class="btn btn-secondary btn-sm">{{#if enabled}}Disable{{else}}Enable{{/if}}</button>
</form>
{{/if}}
{{#if (hasPermission ../currentUser 'weather-icons.delete')}}
<form method="post" action="/settings/weather-icons/{{id}}/delete" class="d-inline ms-1" data-confirm-message="Delete this weather icon set?">
<button type="submit" class="btn {{#if usageCount}}btn-outline-danger{{else}}btn-danger{{/if}} btn-sm" {{#if usageCount}}disabled{{/if}}>Delete</button>
</form>
{{/if}}
</td>
{{/if}}
</tr>
{{else}}
<tr data-table-search-empty-default><td colspan="{{#if (anyPermission currentUser 'weather-icons.update' 'weather-icons.delete')}}5{{else}}4{{/if}}" class="empty">No uploaded icon sets.</td></tr>
{{/each}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/weather-icons" alwaysShow=true}}
</div>
{{#each sets}}
{{#if (hasPermission ../currentUser 'weather-icons.update')}}
<div class="modal fade" id="edit-set-{{id}}" tabindex="-1" aria-labelledby="edit-set-title-{{id}}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header">
<h3 class="modal-title fs-5 text-break" id="edit-set-title-{{id}}">Edit {{name}}</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/settings/weather-icons/{{id}}" enctype="multipart/form-data" class="d-flex flex-column overflow-hidden" data-weather-icon-set-form>
<div class="modal-body">
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<label class="form-label" for="set-name-{{id}}">Name</label>
<input id="set-name-{{id}}" name="name" value="{{name}}" class="form-control" maxlength="100" required>
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Day icons</div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload data-weather-icon-required>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} day icon" title="Upload {{label}} day icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}}" {{#if image}}src="{{image}}"{{else}}hidden{{/if}}><i class="bi bi-cloud-arrow-up" data-weather-icon-empty aria-hidden="true" {{#if image}}hidden{{/if}}></i></span>
<span class="weather-icon-upload-label">{{label}} day</span>
<span class="weather-icon-upload-status" data-weather-icon-status {{#if image}}hidden{{/if}}>Required</span>
</button>
<input id="icon-{{setId}}-{{key}}" name="icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
<div class="weather-icon-upload-section">
<div class="weather-icon-upload-row-label">Night icons <span>Optional</span></div>
<div class="weather-icon-upload-grid">
{{#each conditions}}
<div class="weather-icon-upload" data-weather-icon-upload>
<button type="button" class="weather-icon-upload-tile" data-weather-icon-picker aria-label="Upload {{label}} night icon" title="Upload {{label}} night icon">
<span class="weather-icon-upload-preview"><img data-weather-icon-preview alt="{{label}} night" {{#if nightImage}}src="{{nightImage}}"{{else}}hidden{{/if}}><i class="bi bi-moon-stars" data-weather-icon-empty aria-hidden="true" {{#if nightImage}}hidden{{/if}}></i></span>
<span class="weather-icon-upload-label">{{label}} night</span>
</button>
<input id="night-icon-{{setId}}-{{key}}" name="night_icon_{{key}}" type="file" accept="image/png,image/webp,image/svg+xml" hidden aria-label="{{label}} night icon" data-weather-icon-file>
</div>
{{/each}}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div></div>
</div>
{{/if}}
{{/each}}
+19 -9
View File
@@ -282,17 +282,9 @@
</a> </a>
</li> </li>
{{/if}} {{/if}}
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'invitations.read' 'fonts.read')}} {{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'invitations.read' 'fonts.read' 'weather-icons.read')}}
<li class="nav-header">SETTINGS</li> <li class="nav-header">SETTINGS</li>
{{/if}} {{/if}}
{{#if (hasPermission currentUser 'fonts.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
<i class="nav-icon bi bi-type"></i>
<p>Fonts</p>
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}} {{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}}
<li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}"> <li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#"> <a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#">
@@ -330,6 +322,24 @@
</ul> </ul>
</li> </li>
{{/if}} {{/if}}
{{#if (anyPermission currentUser 'fonts.read' 'weather-icons.read')}}
<li class="nav-item has-treeview{{#if assetsMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if assetsMenuOpen}} active{{/if}}" href="#"><i class="nav-icon bi bi-folder2-open"></i><p>Assets<i class="nav-arrow bi bi-chevron-right"></i></p></a>
<ul class="nav nav-treeview">
{{#if (hasPermission currentUser 'fonts.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
<i class="nav-icon bi bi-type"></i>
<p>Fonts</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'weather-icons.read')}}
<li class="nav-item"><a class="nav-link {{#if (eq active 'weather-icons')}}active{{/if}}" href="/settings/weather-icons"><i class="nav-icon bi bi-cloud-sun"></i><p>Weather icons</p></a></li>
{{/if}}
</ul>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}} {{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}"> <li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#"> <a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
+2 -2
View File
@@ -34,7 +34,7 @@ test('font sweep registers a generic cleanup recurring task without player metad
await registeredTask.run(); await registeredTask.run();
assert.deepEqual(calls, [ assert.deepEqual(calls, [
['put', '/media/fonts/fonts.json', '/tmp/media'], ['put', '/media/assets/fonts.json', '/tmp/media'],
['put', '/media/fonts/fonts.css', '/tmp/media'] ['put', '/media/assets/fonts.css', '/tmp/media']
]); ]);
}); });
+1
View File
@@ -64,6 +64,7 @@ test('dashboard state counts active players from the registry heartbeat', async
const state = await service.buildDashboardState(); const state = await service.buildDashboardState();
assert.equal(state.connectedPlayersCount, 6); assert.equal(state.connectedPlayersCount, 6);
assert.equal(state.playerServiceConnected, true);
assert.equal(state.connectedClientsCount, 1); assert.equal(state.connectedClientsCount, 1);
assert.equal(state.clients[0].player_identifier, 'player-alpha'); assert.equal(state.clients[0].player_identifier, 'player-alpha');
assert.equal(state.kioskPlayers.length, 1); assert.equal(state.kioskPlayers.length, 1);
+61 -1
View File
@@ -30,6 +30,66 @@ test('buildFontStylesheet keeps disabled fonts available for rendering', () => {
assert.match(stylesheet, /Enabled Font/); assert.match(stylesheet, /Enabled Font/);
assert.match(stylesheet, /Disabled Font/); assert.match(stylesheet, /Disabled Font/);
assert.match(stylesheet, /\/media\/assets\/fonts\/enabled-font.ttf/);
});
test('legacy fonts and weather icons migrate into shared assets without losing metadata', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-assets-'));
try {
const fonts = path.join(root, 'fonts');
const weather = path.join(root, 'weather-icons', 'abc');
await fs.promises.mkdir(fonts, { recursive: true });
await fs.promises.mkdir(weather, { recursive: true });
await fs.promises.writeFile(path.join(fonts, 'custom.ttf'), 'font-data');
await fs.promises.writeFile(path.join(fonts, 'fonts.json'), JSON.stringify([{ id: 'custom', family: 'Custom', fileName: 'custom.ttf', enabled: false }]));
await fs.promises.writeFile(path.join(fonts, 'fonts.css'), "src:url('/media/fonts/custom.ttf')");
await fs.promises.writeFile(path.join(weather, 'clear-123abc.png'), 'icon-data');
await fs.promises.writeFile(path.join(root, 'weather-icons', 'sets.json'), JSON.stringify([{ id: 'abc', name: 'Custom', icons: { clear: '/media/weather-icons/abc/clear-123abc.png' } }]));
const library = require('../src/web/lib/media/font-library');
await library.ensureFontLibrary(path.join(root, 'uploads'));
const loaded = library.loadFontLibrary(root);
assert.equal(loaded.fonts[0].id, 'custom');
assert.equal(loaded.fonts[0].enabled, false);
assert.equal(loaded.manifestPath, path.join(root, 'assets', 'fonts.json'));
assert.equal(loaded.stylesheetPath, path.join(root, 'assets', 'fonts.css'));
assert.match(loaded.stylesheetHref, /^\/media\/assets\/fonts.css/);
assert.equal(fs.readFileSync(path.join(root, 'assets', 'fonts', 'custom.ttf'), 'utf8'), 'font-data');
assert.match(fs.readFileSync(loaded.stylesheetPath, 'utf8'), /\/media\/assets\/fonts\/custom.ttf/);
const weatherLibrary = require('../src/web/lib/media/weather-icon-library');
assert.equal(weatherLibrary.readWeatherIconSets(root)[0].icons.clear, '/media/assets/weather-icons/abc/clear-123abc.png');
assert.equal(weatherLibrary.getWeatherManifestPath(root), path.join(root, 'assets', 'weather.json'));
assert.equal(fs.existsSync(path.join(root, 'assets', 'weather-icons', 'sets.json')), false);
assert.equal(fs.readFileSync(path.join(root, 'assets', 'weather-icons', 'abc', 'clear-123abc.png'), 'utf8'), 'icon-data');
assert.equal(fs.existsSync(fonts), false);
assert.equal(fs.existsSync(path.join(root, 'weather-icons')), false);
await library.ensureFontLibrary(root);
assert.equal(library.loadFontLibrary(root).fonts.length, 1);
} finally {
await fs.promises.rm(root, { recursive: true, force: true });
}
});
test('shared assets migration tolerates simultaneous service startup', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-assets-race-'));
try {
const fonts = path.join(root, 'fonts');
await fs.promises.mkdir(fonts);
await fs.promises.writeFile(path.join(fonts, 'fonts.json'), '[]');
await fs.promises.writeFile(path.join(fonts, 'fonts.css'), "src:url('/media/fonts/custom.ttf')");
await Promise.all(Array.from({ length: 100 }, function (_unused, index) {
return fs.promises.writeFile(path.join(fonts, 'font-' + index + '.ttf'), 'font-data');
}));
const run = require('node:util').promisify(require('node:child_process').execFile);
const modulePath = require.resolve('../src/web/lib/media/assets-storage');
await Promise.all(Array.from({ length: 4 }, function () {
return run(process.execPath, ['-e', 'require(' + JSON.stringify(modulePath) + ').migrateLegacyAssets(' + JSON.stringify(root) + ')']);
}));
assert.equal(fs.existsSync(fonts), false);
assert.equal(fs.readdirSync(path.join(root, 'assets', 'fonts')).length, 100);
assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'assets', 'fonts.json'))).length, 0);
} finally {
await fs.promises.rm(root, { recursive: true, force: true });
}
}); });
test('collectFontLibrarySyncOperations keeps disabled fonts as puts', async () => { test('collectFontLibrarySyncOperations keeps disabled fonts as puts', async () => {
@@ -43,7 +103,7 @@ test('collectFontLibrarySyncOperations keeps disabled fonts as puts', async () =
const operations = collectFontLibrarySyncOperations(tempRoot); const operations = collectFontLibrarySyncOperations(tempRoot);
const fontOps = operations.filter(function (operation) { const fontOps = operations.filter(function (operation) {
return String(operation.uploadPath || '').indexOf('/media/fonts/') === 0 && /\.ttf$/.test(String(operation.uploadPath || '')); return String(operation.uploadPath || '').indexOf('/media/assets/fonts/') === 0 && /\.ttf$/.test(String(operation.uploadPath || ''));
}); });
assert.deepEqual(fontOps.map(function (operation) { assert.deepEqual(fontOps.map(function (operation) {
+59
View File
@@ -0,0 +1,59 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function loadRegionRenderer(regionType, dataKey, data) {
let renderer;
const window = {
placeholderUtils: {},
pulsePlayerRegionTypes: {
register: function (_type, module) {
renderer = module;
}
},
pulsePlayerRenderingShared: {
normalizeRegionValue: function (value) {
return String(value || '');
}
}
};
const context = {
window,
initialData: { [dataKey]: data },
escapeHtml: String,
sanitizeFontFamily: String,
sanitizeFontSize: function (value) { return Number(value) || 16; },
sanitizeTextColor: String,
sanitizeRichText: String,
renderEditorJsContent: String
};
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), context);
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/player/regions', regionType + '.js'), 'utf8'), context);
return renderer;
}
test('image placeholders align inline with text in player API and RSS regions', function () {
const sourceUrl = 'https://example.com/image.png';
const api = loadRegionRenderer('api', 'apiSources', [{
id: 1,
responseJson: { image: sourceUrl }
}]);
const rss = loadRegionRenderer('rss', 'rssFeeds', [{
id: 1,
items: [{ image: sourceUrl }]
}]);
const region = { pixelWidth: 400, pixelHeight: 100, canvasScale: 1, baseStyle: '' };
for (const [renderer, contentKey] of [[api, 'source_id'], [rss, 'feed_id']]) {
const content = { [contentKey]: 1, value: 'Before {{image.image(120,80,bottom)}} after' };
const rendered = renderer.renderRegion(region, content);
assert.match(rendered, /vertical-align:bottom/);
assert.match(rendered, /width:120px;height:80px/);
const legacy = renderer.renderRegion(region, { [contentKey]: 1, value: 'Before {{image.image(120,80)}} after' });
assert.doesNotMatch(legacy, /vertical-align:/);
assert.match(legacy, /width:120px;height:80px/);
}
});
+209
View File
@@ -0,0 +1,209 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const sharp = require('sharp');
require('../src/common');
const library = require('../src/web/lib/media/weather-icon-library');
const weatherIcons = require('../src/web/public/js/shared/weather-icons');
const vm = require('node:vm');
const placeholderContext = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), placeholderContext);
const placeholders = placeholderContext.window.placeholderUtils;
test('weather icon picker warns about each missing condition', function () {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/weather-icons.js'), 'utf8');
assert.match(source, /window\.showToast\('Add images for: ' \+ labels\.join\(', '\) \+ '\.', 'warning'\)/);
assert.match(source, /replace\(\/\^Upload\\s\+\|\\s\+icon\$\/g, ''\)/);
});
test('weather editor and player render day or night variants from one selected set', function () {
const sets = [{ id: 'abc', name: 'Colour', icons: { clear: '/media/weather-icons/abc/clear-123abc.png' }, nightIcons: { clear: '/media/weather-icons/abc/night-clear-456def.png' } }];
const location = { id: 1, responseJson: { current: { weather_code: 0, is_day: 1 } } };
let editor;
const editorContext = { window: { pulseWeatherIconSets: sets, pulseWeatherIcons: weatherIcons, placeholderUtils: placeholders,
pulseRegionTypes: { register: function (_type, module) { editor = module; } } }, document: { addEventListener: function () {} } };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/weather.js'), 'utf8'), editorContext);
const region = { id: 1, region_key: 'weather', label: 'Forecast', baseStyle: '' };
const content = { weather_location_id: 1, weather_icon_set_id: 'abc', value: '{{current.icon(48,24)}}' };
const card = editor.renderEditorCard({ region: region, current: content, weatherLocations: [location] });
assert.match(card, /name="region_weather_icon_set_id_1"/);
assert.match(card, /value="abc" selected/);
assert.match(card, /row g-3 align-items-end"><div class="col-12 col-lg-6"/);
assert.match(card, /region_weather_forecast_mode_1"[^]*?region_weather_icon_set_id_1/);
assert.doesNotMatch(card, /region_weather_night_icon_set_id_1/);
sets[0].enabled = false;
const disabledCard = editor.renderEditorCard({ region: region, current: content, weatherLocations: [location] });
assert.match(disabledCard, /<option value="abc" selected>Colour<\/option>/);
assert.doesNotMatch(disabledCard, /value="abc" selected disabled/);
sets[0].enabled = true;
const preview = editor.renderPreview(region, content, { weatherLocations: [location] });
assert.match(preview, /clear-123abc.png/);
assert.match(preview, /width:48px;height:24px/);
const topPreview = editor.renderPreview(region, Object.assign({}, content, { value: '{{current.icon(48,96,top)}}' }), { weatherLocations: [location] });
assert.match(topPreview, /vertical-align:top/);
let player;
const playerContext = { window: { pulseWeatherIcons: weatherIcons, placeholderUtils: placeholders,
pulsePlayerRegionTypes: { register: function (_type, module) { player = module; } } },
initialData: { weatherLocations: [location], weatherIconSets: sets }, escapeHtml: String, renderEditorJsContent: String };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/weather.js'), 'utf8'), playerContext);
assert.match(player.renderRegion(region, content), /clear-123abc.png/);
assert.match(player.renderRegion(region, Object.assign({}, content, { value: '{{current.icon(48,96,bottom)}}' })), /vertical-align:bottom/);
location.responseJson.current.is_day = 0;
assert.match(player.renderRegion(region, content), /night-clear-456def.png/);
location.responseJson.current.is_day = 1;
assert.match(player.renderRegion(region, Object.assign({}, content, { weather_icon_set_id: '' })), /bi-sun/);
location.responseJson.current.weather_code = 61;
assert.match(player.renderRegion(region, content), /bi-cloud-rain/);
});
test('weather icons map exact WMO codes and fall back for missing or unsafe images', function () {
assert.equal(weatherIcons.conditionForCode(3), 'overcast');
assert.equal(weatherIcons.conditionForCode(85), 'snow');
assert.equal(weatherIcons.conditionForCode(null), '');
assert.equal(weatherIcons.conditionForCode(4), '');
const sets = [{ id: 'abc', icons: { clear: '/media/weather-icons/abc/clear-123abc.png', rain: 'javascript:alert(1)' } }];
assert.equal(weatherIcons.imageForCode(sets, 'abc', 0), sets[0].icons.clear);
assert.equal(weatherIcons.imageForCode(sets, 'abc', 61), '');
assert.equal(weatherIcons.imageForCode(sets, 'abc', 45), '');
sets[0].enabled = false;
assert.equal(weatherIcons.imageForCode(sets, 'abc', 0), sets[0].icons.clear);
assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /width:48px;height:24px/);
assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /display:inline-flex;vertical-align:middle;align-items:center;justify-content:center/);
assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /vertical-align:middle.*line-height:0/);
assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 96, 'bottom'), /vertical-align:bottom/);
assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /object-fit:contain/);
});
test('weather management template renders Assets and permission gated uploads', function () {
const html = require('../src/web/view').renderView('settings/weather-icons/list', {
title: 'Weather icons', active: 'weather-icons', assetsMenuOpen: true, conditions: library.CONDITIONS,
sets: [], currentUser: { permissionKeys: ['weather-icons.read', 'weather-icons.create'] }
});
assert.match(html, /Weather icons/);
assert.match(html, /<p>Assets/);
assert.match(html, /nav-icon bi bi-folder2-open/);
assert.ok(html.indexOf('User management') < html.indexOf('Assets'));
assert.match(html, /action="\/settings\/weather-icons"/);
assert.match(html, /name="icon_clear"/);
assert.match(html, /class="page-header"/);
assert.match(html, /Upload and manage shared icon sets for weather regions/);
assert.match(html, /class="card card-outline card-primary mb-4 collapsed-card"/);
assert.match(html, /class="card-title mb-0">Upload icon set/);
assert.doesNotMatch(html, /Add a PNG or WebP image for every weather condition/);
assert.match(html, /data-lte-toggle="card-collapse"/);
assert.match(html, /data-lte-icon="expand"/);
assert.match(html, /data-lte-icon="collapse"/);
assert.match(html, /action="\/settings\/weather-icons" enctype="multipart\/form-data" class="card-body p-0"/);
assert.match(html, /class="card-title">Managed icon sets/);
assert.match(html, /data-table-sort-key="status">Status/);
assert.equal((html.match(/class="app-content"/g) || []).length, 1);
assert.doesNotMatch(html, /<details|<summary/);
assert.match(html, /card-footer d-flex justify-content-end/);
assert.match(html, /data-table-search-container data-table-pagination-card/);
assert.match(html, /data-table-searchable/);
assert.match(html, /data-table-search-empty-default/);
assert.equal((html.match(/data-weather-icon-picker/g) || []).length, library.CONDITIONS.length * 2);
assert.equal((html.match(/weather-icon-upload-row-label">Day icons/g) || []).length, 1);
assert.equal((html.match(/Night icons <span>Optional/g) || []).length, 1);
assert.match(html, /data-weather-icon-preview/);
assert.match(html, /data-weather-icon-status>Required/);
assert.match(html, /hidden aria-label="Clear icon" data-weather-icon-file/);
assert.match(html, /accept="image\/png,image\/webp,image\/svg\+xml"/);
assert.match(html, /data-weather-icon-set-form/);
});
test('weather management route paginates, sorts and preserves search state', async function () {
const originalRead = library.readWeatherIconSets;
const handlers = {};
library.readWeatherIconSets = function () {
return Array.from({ length: 30 }, function (_unused, index) {
return { id: 'set-' + index, name: 'Set ' + String(index).padStart(2, '0'), icons: {} };
});
};
try {
require('../src/web/routes/settings/weather-icons')({
get: function (url, ...callbacks) { handlers[url] = callbacks; },
post: function () {}
}, { mediaDir: 'media', pool: { query: async function () { return [[]]; } }, requirePermission: function () { return function () {}; } });
const response = { status: function () { return this; }, send: function (html) { this.html = html; } };
const currentUser = { permissionKeys: ['weather-icons.read', 'weather-icons.update'] };
const fail = function (error) { throw error; };
await handlers['/settings/weather-icons'][1]({ query: { page: '2', sort: 'name', direction: 'asc' }, currentUser: currentUser }, response, fail);
assert.equal((response.html.match(/<tr data-table-search-row>/g) || []).length, 5);
assert.match(response.html, /Set 25/);
assert.doesNotMatch(response.html, /Set 00/);
assert.match(response.html, /data-bs-toggle="modal"/);
assert.match(response.html, /href="\/assets\/css\/weather-icons.css"/);
await handlers['/settings/weather-icons'][1]({ query: { search: 'Set 29', page: '2' }, currentUser: currentUser }, response, fail);
assert.equal((response.html.match(/<tr data-table-search-row>/g) || []).length, 1);
assert.match(response.html, /Set 29/);
await handlers['/settings/weather-icons'][1]({ query: { search: 'Set', page: '2' }, currentUser: currentUser }, response, fail);
assert.match(response.html, /search(?:=|&#x3D;)Set/);
} finally {
library.readWeatherIconSets = originalRead;
}
});
test('weather sets require every condition image and support replacement', async function () {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'weather-icons-'));
try {
const buffer = await sharp({ create: { width: 16, height: 16, channels: 4, background: '#ff0000' } }).png().toBuffer();
const allFiles = library.CONDITIONS.map(function (condition) { return { fieldname: 'icon_' + condition.key, buffer: buffer }; });
const svgBuffer = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#00f"><animate attributeName="fill" values="#00f;#0ff;#00f" dur="1s" repeatCount="indefinite"/></rect></svg>');
await assert.rejects(library.saveWeatherIconSet(root, 'Partial', allFiles.slice(0, 2)), /every weather condition.*Overcast/);
const first = await library.saveWeatherIconSet(root, 'Colour', allFiles);
const second = await library.saveWeatherIconSet(root, 'Outline', allFiles);
assert.notEqual(first.id, second.id);
assert.equal(Object.keys(first.icons).length, library.CONDITIONS.length);
assert.match(first.icons.clear, /^\/media\/assets\/weather-icons\//);
const disabled = library.setWeatherIconSetEnabled(root, first.id, false);
assert.equal(disabled.enabled, false);
assert.equal(library.readWeatherIconSets(root).find(function (set) { return set.id === first.id; }).enabled, false);
assert.ok(library.collectWeatherIconSyncOperations(root).some(function (operation) {
return operation.type === 'put' && operation.uploadPath === first.icons.clear;
}));
assert.equal(fs.existsSync(path.join(root, 'assets', 'weather-icons', first.id, path.basename(first.icons.clear))), true);
library.setWeatherIconSetEnabled(root, first.id, true);
assert.equal(library.getWeatherManifestPath(root), path.join(root, 'assets', 'weather.json'));
const svgSet = await library.saveWeatherIconSet(root, 'Vector', library.CONDITIONS.map(function (condition) { return { fieldname: 'icon_' + condition.key, buffer: svgBuffer }; }));
const svgFileName = path.basename(svgSet.icons.clear);
assert.match(svgFileName, /\.svg$/);
assert.match(fs.readFileSync(path.join(root, 'assets', 'weather-icons', svgSet.id, svgFileName), 'utf8'), /<animate/);
const replacementBuffer = await sharp({ create: { width: 16, height: 16, channels: 4, background: '#0000ff' } }).png().toBuffer();
const updated = await library.saveWeatherIconSet(root, 'Colour updated', [{ fieldname: 'icon_snow', buffer: buffer }, { fieldname: 'night_icon_clear', buffer: replacementBuffer }], first.id);
assert.equal(Object.keys(updated.icons).length, library.CONDITIONS.length);
assert.match(updated.nightIcons.clear, /night-clear-/);
assert.equal(library.readWeatherIconSets(root).length, 3);
assert.deepEqual(library.collectWeatherIconRemovalOperations([first], [updated]), []);
const replaced = await library.saveWeatherIconSet(root, 'Colour updated', [{ fieldname: 'icon_clear', buffer: replacementBuffer }], first.id);
assert.deepEqual(library.collectWeatherIconRemovalOperations([updated], [replaced]), [{ type: 'delete', uploadPath: updated.icons.clear }]);
const removed = library.deleteWeatherIconSet(root, second.id);
const removalOperations = library.collectWeatherIconRemovalOperations([removed], []);
assert.equal(removalOperations.length, library.CONDITIONS.length);
assert.deepEqual(removalOperations.find(function (operation) { return operation.uploadPath === second.icons.snow; }), { type: 'delete', uploadPath: second.icons.snow });
assert.equal(fs.existsSync(path.join(root, 'assets', 'weather-icons', second.id)), false);
await assert.rejects(library.saveWeatherIconSet(root, 'Colour updated', []), /already exists/);
await assert.rejects(library.saveWeatherIconSet(root, 'Invalid', allFiles.map(function (file, index) {
return index === 0 ? { fieldname: file.fieldname, buffer: Buffer.from('not an image') } : file;
})));
await assert.rejects(library.saveWeatherIconSet(root, 'Unsafe SVG', library.CONDITIONS.map(function (condition) {
return { fieldname: 'icon_' + condition.key, buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><script>alert(1)</script></svg>') };
})), /cannot contain scripts/);
assert.equal(library.readWeatherIconSets(root).length, 2);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('weather icon usage recognizes one set reference', function () {
const content = { weather: { weather_icon_set_id: 'set' } };
const isUsed = function (setId) {
return Object.values(content).some(function (region) {
return region && region.weather_icon_set_id === setId;
});
};
assert.equal(isUsed('set'), true);
assert.equal(isUsed('other'), false);
});