Release v2.14.2
This commit is contained in:
1 parent
c3299abaac
commit
ddcc665f81
47 files changed
+1209
-66
No files matched your search
@@ -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,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,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": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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 || [],
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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/');
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -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 : ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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 || {};
|
||||||
|
|||||||
@@ -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,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',
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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="#">
|
||||||
|
|||||||
@@ -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']
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(?:=|=)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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user