diff --git a/CHANGELOG.md b/CHANGELOG.md index e335a34..ba43be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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 ### Added diff --git a/build/package.player.json b/build/package.player.json index cee110d..30a0d9e 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.14.1", + "version": "2.14.2", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 0130e5c..328c192 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.14.1", + "version": "2.14.2", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 1094af5..0e3d596 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.14.1", + "version": "2.14.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.14.1", + "version": "2.14.2", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index ce98c55..a89746c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.14.1", + "version": "2.14.2", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/slides.js b/src/data/slides.js index a0746a7..5502c5f 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -469,6 +469,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing type: 'weather', 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_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, font_family: style.font_family, font_size: style.font_size, diff --git a/src/player/playlist.js b/src/player/playlist.js index e8ccc3a..3336465 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -404,8 +404,9 @@ function createPlayerPlaylistService(options) { await cachePlaceholderImages(slides, rssFeeds, apiSources); - const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations); - const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, weatherLocations: weatherLocations, revision: revision }; + const weatherIconSets = mediaDir ? require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir) : []; + 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); return payload; } catch (error) { @@ -422,7 +423,7 @@ function createPlayerPlaylistService(options) { 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'); updatePlaylistRevisionHash(hash, screen && screen.id); @@ -489,6 +490,7 @@ function createPlayerPlaylistService(options) { updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || [])); updatePlaylistRevisionHash(hash, JSON.stringify(weatherLocations || [])); + updatePlaylistRevisionHash(hash, JSON.stringify(weatherIconSets || [])); return hash.digest('hex'); } diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js index cc40e1b..9ed8e51 100644 --- a/src/player/public/js/player-page-playback.js +++ b/src/player/public/js/player-page-playback.js @@ -231,6 +231,7 @@ function refresh(applyImmediately, bypassDebounce) { apiSources: Array.isArray(data.apiSources) ? data.apiSources : [], timetableGroups: Array.isArray(data.timetableGroups) ? data.timetableGroups : [], weatherLocations: Array.isArray(data.weatherLocations) ? data.weatherLocations : [], + weatherIconSets: Array.isArray(data.weatherIconSets) ? data.weatherIconSets : [], revision: nextSignature }); if (typeof initialData !== 'undefined') { diff --git a/src/player/regions/api.js b/src/player/regions/api.js index e6acb7e..c26ab7a 100644 --- a/src/player/regions/api.js +++ b/src/player/regions/api.js @@ -84,9 +84,17 @@ function substituteApiVariables(html, item, sourceId) { ? '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;' : ''); var image = ''; - return hasImageBounds - ? '' + image + '' - : image; + if (hasImageBounds || imageConfig.verticalAlign) { + var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;'; + if (hasImageBounds) { + wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;'; + } + if (imageConfig.verticalAlign) { + wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';'; + } + return '' + image + ''; + } + return image; } return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); }); diff --git a/src/player/regions/rss.js b/src/player/regions/rss.js index d8b8a22..b593c9a 100644 --- a/src/player/regions/rss.js +++ b/src/player/regions/rss.js @@ -70,9 +70,17 @@ function substituteRssVariables(html, item, feedId) { ? '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;' : ''); var image = ''; - return hasImageBounds - ? '' + image + '' - : image; + if (hasImageBounds || imageConfig.verticalAlign) { + var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;'; + if (hasImageBounds) { + wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;'; + } + if (imageConfig.verticalAlign) { + wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';'; + } + return '' + image + ''; + } + return image; } return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); }); diff --git a/src/player/regions/weather.js b/src/player/regions/weather.js index 67f6f30..746e9e7 100644 --- a/src/player/regions/weather.js +++ b/src/player/regions/weather.js @@ -46,7 +46,7 @@ function withWeatherUnits(snapshot, location) { return data; } -function substituteWeatherVariables(html, value) { +function substituteWeatherVariables(html, value, iconSetId) { return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!value || typeof value !== 'object' || typeof weatherPlaceholderUtils.resolvePlaceholderExpression !== 'function' || typeof weatherPlaceholderUtils.formatPlaceholderValue !== 'function') return ''; var rawExpression = String(expression).trim(); @@ -54,15 +54,19 @@ function substituteWeatherVariables(html, value) { if (typeof weatherPlaceholderUtils.isProgressPlaceholderExpression === 'function' && weatherPlaceholderUtils.isProgressPlaceholderExpression(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) { var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, ''); 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 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 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 = ''; - return width ? '' + icon + '' : icon; + var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle'; + return width ? '' + icon + '' : icon; } return escapeHtml(weatherPlaceholderUtils.formatPlaceholderValue(weatherPlaceholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone }))); }); @@ -82,7 +86,7 @@ function renderWeatherRegion(region, regionContent) { if (value) { var weatherData = withWeatherUnits(snapshot, location); var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32); - return '
' + renderEditorJsContent(substituteWeatherVariables(value, weatherData)) + '
'; + return '
' + renderEditorJsContent(substituteWeatherVariables(value, weatherData, regionContent.weather_icon_set_id)) + '
'; } var daily = snapshot.daily || {}; var days = (daily.time || []).slice(0, 7).map(function (day, index) { diff --git a/src/player/render.js b/src/player/render.js index 8747b97..e836305 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -1,6 +1,7 @@ // Player page rendering and bootstrap script assembly. const Handlebars = require('handlebars'); +const fs = require('fs'); const crypto = require('crypto'); const path = require('path'); const packageMetadata = require('#root/package.json'); @@ -102,7 +103,7 @@ function renderOnboardingFormBody(deviceId) { function renderPlayerPage(slug, initialData) { - const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript(); + const serviceWorkerScript = '' + getPlayerServiceWorkerRegistrationScript(); const template = getPlayerPageTemplate(); const hlsScriptTag = ''; const chartScriptTag = ''; diff --git a/src/rbac.js b/src/rbac.js index 707b22b..e66bb21 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -242,6 +242,17 @@ const PERMISSION_SECTIONS = [ { 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', order: 50, diff --git a/src/web/lib/background-tasks/tasks-adhoc/font-sync.js b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js index 170169a..2e86f73 100644 --- a/src/web/lib/background-tasks/tasks-adhoc/font-sync.js +++ b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js @@ -31,7 +31,8 @@ function registerFontSyncTask(options) { if (String(operation.type || '').trim().toLowerCase() === 'delete') { await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier); } 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 + '.'); } } }); diff --git a/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js index 5e50108..1692984 100644 --- a/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js +++ b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js @@ -12,6 +12,7 @@ const { collectFontLibraryDirectoryUploadPaths, collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library'); +const { collectWeatherIconDirectoryUploadPaths, collectWeatherIconSyncOperations } = require('#src/web/lib/media/weather-icon-library'); function registerFontSweepTask(options) { const backgroundTaskQueue = options && options.backgroundTaskQueue; @@ -33,11 +34,11 @@ function registerFontSweepTask(options) { mediaDir: mediaDir }, run: async function () { - const desiredOperations = collectFontLibrarySyncOperations(mediaDir); + const desiredOperations = collectFontLibrarySyncOperations(mediaDir).concat(collectWeatherIconSyncOperations(mediaDir)); const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { return operation && operation.uploadPath ? operation.uploadPath : ''; }).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) { const operation = desiredOperations[i] || {}; diff --git a/src/web/lib/background-tasks/tasks-startup/font-sync.js b/src/web/lib/background-tasks/tasks-startup/font-sync.js index 33e8c16..78d3ed3 100644 --- a/src/web/lib/background-tasks/tasks-startup/font-sync.js +++ b/src/web/lib/background-tasks/tasks-startup/font-sync.js @@ -56,7 +56,7 @@ function registerInitialFontSyncTask(options) { 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) { const metadata = normalizePlayerMetadata(player); return backgroundTaskQueue.enqueueTask({ diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js index 9563a8e..b3ebb5e 100644 --- a/src/web/lib/dashboard-state.js +++ b/src/web/lib/dashboard-state.js @@ -181,7 +181,7 @@ function createDashboardStateService(options) { const kioskPlayers = buildKioskLauncherPlayers(playerRegistrations, connectedPlayerCountStaleSeconds); const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { return socket && socket.readyState === WebSocket.OPEN; - }); + }) || Number(connectedPlayersCount) > 0; return { playlists: data.playlists || [], diff --git a/src/web/lib/media/assets-storage.js b/src/web/lib/media/assets-storage.js new file mode 100644 index 0000000..b0953d6 --- /dev/null +++ b/src/web/lib/media/assets-storage.js @@ -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 }; \ No newline at end of file diff --git a/src/web/lib/media/font-library.js b/src/web/lib/media/font-library.js index 020431b..d3b82b3 100644 --- a/src/web/lib/media/font-library.js +++ b/src/web/lib/media/font-library.js @@ -1,8 +1,9 @@ const fs = require('fs'); const path = require('path'); 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_STYLESHEET_FILE = 'fonts.css'; const DEFAULT_FONT_FAMILY_FORMATS = [ @@ -77,21 +78,22 @@ function getFontLibraryDir(mediaRootOrUploadDir) { } function getFontManifestPath(mediaRootOrUploadDir) { - return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_MANIFEST_FILE); + return path.join(resolveMediaRoot(mediaRootOrUploadDir), 'assets', FONT_MANIFEST_FILE); } function getFontStylesheetPath(mediaRootOrUploadDir) { - return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_STYLESHEET_FILE); + return path.join(resolveMediaRoot(mediaRootOrUploadDir), 'assets', FONT_STYLESHEET_FILE); } function getFontStylesheetHref(mediaRootOrUploadDir) { + migrateLegacyAssets(mediaRootOrUploadDir); const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); if (!fs.existsSync(stylesheetPath)) { return ''; } 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) { @@ -117,6 +119,7 @@ function sortFonts(left, right) { } function readManifest(mediaRootOrUploadDir) { + migrateLegacyAssets(mediaRootOrUploadDir); const manifestPath = getFontManifestPath(mediaRootOrUploadDir); if (!fs.existsSync(manifestPath)) { return []; @@ -180,6 +183,7 @@ function buildFontFamilyFormats(fonts) { } async function ensureFontLibrary(mediaRootOrUploadDir) { + migrateLegacyAssets(mediaRootOrUploadDir); const fontDir = getFontLibraryDir(mediaRootOrUploadDir); const manifestPath = getFontManifestPath(mediaRootOrUploadDir); const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); @@ -368,8 +372,8 @@ async function collectFontLibraryDirectoryUploadPaths(mediaRootOrUploadDir) { function collectFontLibrarySyncOperations(mediaRootOrUploadDir) { const fonts = readManifest(mediaRootOrUploadDir); const operations = [ - { type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_MANIFEST_FILE }, - { type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_STYLESHEET_FILE } + { type: 'put', uploadPath: '/media/assets/' + FONT_MANIFEST_FILE }, + { type: 'put', uploadPath: '/media/assets/' + FONT_STYLESHEET_FILE } ]; fonts.forEach(function (font) { diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index 2ca5f57..f229327 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -189,11 +189,17 @@ function substitutePlaceholders(html, regionContent, options) { } if (type === 'weather' && /(?:^|\.)weather_code\.\d+\.icon(?:\(|$)|^current\.weather_code\.icon/.test(weatherExpression)) { 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 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;"' : ''; - return ''; + const alignment = iconSize && iconSize[3] || 'middle'; + 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 = ''; + const verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle'; + return width ? '' + icon + '' : icon; } const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || ''); if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) { diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 7bec2d8..197410f 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -946,7 +946,7 @@ function createUploadSyncService(options) { 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); generatedCachePaths.filter(function (uploadPath) { return String(uploadPath || '').startsWith('/media/player-cache/remote-images/'); diff --git a/src/web/lib/media/weather-icon-library.js b/src/web/lib/media/weather-icon-library.js new file mode 100644 index 0000000..7578b03 --- /dev/null +++ b/src/web/lib/media/weather-icon-library.js @@ -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 }; \ No newline at end of file diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js index 6269389..5744414 100644 --- a/src/web/lib/region-scripts.js +++ b/src/web/lib/region-scripts.js @@ -28,7 +28,7 @@ function getRegionModuleScripts(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 = { diff --git a/src/web/public/css/weather-icons.css b/src/web/public/css/weather-icons.css new file mode 100644 index 0000000..363ea9a --- /dev/null +++ b/src/web/public/css/weather-icons.css @@ -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); +} diff --git a/src/web/public/js/regions/type/api.js b/src/web/public/js/regions/type/api.js index e2c87be..b2fb28b 100644 --- a/src/web/public/js/regions/type/api.js +++ b/src/web/public/js/regions/type/api.js @@ -119,8 +119,16 @@ ? '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;' : ''); var image = ''; - if (options && options.preview && imageConfig.width && imageConfig.height) { - return '' + image + ''; + var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height; + 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 '' + image + ''; } return image; } diff --git a/src/web/public/js/regions/type/rss.js b/src/web/public/js/regions/type/rss.js index 183d1ce..da6d5a7 100644 --- a/src/web/public/js/regions/type/rss.js +++ b/src/web/public/js/regions/type/rss.js @@ -138,8 +138,16 @@ ? '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;' : ''); var image = ''; - if (options && options.preview && imageConfig.width && imageConfig.height) { - return '' + image + ''; + var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height; + 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 '' + image + ''; } return image; } diff --git a/src/web/public/js/regions/type/weather.js b/src/web/public/js/regions/type/weather.js index 8fad3d2..be73143 100644 --- a/src/web/public/js/regions/type/weather.js +++ b/src/web/public/js/regions/type/weather.js @@ -109,7 +109,7 @@ return data; } - function substituteVariables(html, value) { + function substituteVariables(html, value, iconSetId) { return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!value || typeof value !== 'object' || typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') return ''; var rawExpression = String(expression).trim(); @@ -117,16 +117,20 @@ if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(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) { 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 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 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 = ''; - return width ? '' + icon + '' : icon; + var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle'; + return width ? '' + icon + '' : icon; } return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone }))); }); @@ -138,7 +142,7 @@ '
' + '

Weather placeholder transforms

' + '

Placeholders read values from the selected weather snapshot. Nested fields use dots.

' + - '

Weather icons

Use the icon property to insert a Bootstrap weather icon. Add width and height in pixels when sizing is needed.

' + + '

Weather icons

Use the icon property to insert a weather icon. Add width, height, and optional top, middle, or bottom alignment when sizing is needed.

' + (window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderMathTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') + @@ -154,9 +158,10 @@ var snapshot = getSnapshot(location); if (!location || !snapshot) return ''; var weatherData = withWeatherUnits(snapshot, location); + weatherData.iconSetId = content && content.weather_icon_set_id; if (value) { var fontSize = Math.max(8, Number(content && content.font_size || region && (region.font_size || region.fontSize) || 32) || 32); - return '
' + (utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(substituteVariables(value, weatherData)) : substituteVariables(value, weatherData)) + '
'; + return '
' + (utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(substituteVariables(value, weatherData, weatherData.iconSetId)) : substituteVariables(value, weatherData, weatherData.iconSetId)) + '
'; } var current = snapshot.current || {}; var daily = snapshot.daily || {}; @@ -198,14 +203,34 @@ 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 ''; + }).join(''); + var selectedSet = sets.find(function (set) { return set.id === selected; }); + if (selectedSet && selectedSet.enabled === false) { + options += ''; + } else if (selected && !selectedSet) { + options += ''; + } + var control = '
'; + 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('
Image transform' + - '

Use image(width, height) to render an image URL inside a proportional bounding box.

' + - ''; + '

Use image(width, height, alignment) to render an image URL inside a proportional bounding box. Alignment can be top, middle, or bottom to position it relative to surrounding text.

' + + ''; } root.placeholderInfo = { diff --git a/src/web/public/js/shared/placeholder-utils.js b/src/web/public/js/shared/placeholder-utils.js index 861da37..ced6aad 100644 --- a/src/web/public/js/shared/placeholder-utils.js +++ b/src/web/public/js/shared/placeholder-utils.js @@ -386,9 +386,11 @@ var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : []; var width = Number(args[0]); var height = Number(args[1]); + var verticalAlign = String(args[2] || '').trim().toLowerCase(); return { 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 : '' }; } diff --git a/src/web/public/js/shared/weather-icons.js b/src/web/public/js/shared/weather-icons.js new file mode 100644 index 0000000..05addab --- /dev/null +++ b/src/web/public/js/shared/weather-icons.js @@ -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 ''; + } + 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)); \ No newline at end of file diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 2946437..045d379 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -23,6 +23,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : []; var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : []; var weatherLocations = Array.isArray(slideEditorData.weatherLocations) ? slideEditorData.weatherLocations : []; + window.pulseWeatherIconSets = Array.isArray(slideEditorData.weatherIconSets) ? slideEditorData.weatherIconSets : []; var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim(); var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; var existingContent = slideEditorData.existingContent || {}; diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 4ad5d80..71d0d6b 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -3,6 +3,7 @@ const fs = require('fs'); const path = require('path'); 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 { convertWeatherSnapshot } = require('#src/data/weather-units'); const { buildAuditChanges } = require('#src/data/audit-log'); @@ -95,6 +96,7 @@ module.exports = function registerContentRoutes(app, deps) { apiSources: apiSources, timetableGroups: timetableData.timetableGroups || [], weatherLocations: weatherLocations, + weatherIconSets: readWeatherIconSets(deps.uploadDir), mediaAssets: mediaAssets, fontLibrary: loadFontLibrary(deps.uploadDir), uploadLimits: { diff --git a/src/web/routes/internal/sync.js b/src/web/routes/internal/sync.js index 29e2ef3..749f4f5 100644 --- a/src/web/routes/internal/sync.js +++ b/src/web/routes/internal/sync.js @@ -2,6 +2,7 @@ const { verifyRequestAuth } = require('#src/request-auth'); 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'); function requireRequestAuth(req, res, next) { @@ -63,7 +64,7 @@ module.exports = function registerInternalSyncRoutes(app, deps) { const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim(); const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim(); const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim(); - const operations = collectFontLibrarySyncOperations(mediaDir); + const operations = collectFontLibrarySyncOperations(mediaDir).concat(collectWeatherIconSyncOperations(mediaDir)); const task = await backgroundTaskQueue.enqueueTask({ key: 'player-font-sync', title: 'Player font sync', diff --git a/src/web/routes/register.js b/src/web/routes/register.js index af27fcc..424c26a 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -16,6 +16,7 @@ const registerTimetableRoutes = require('#src/web/routes/data-sources/timetables const registerWeatherRoutes = require('#src/web/routes/data-sources/weather'); const registerSettingsRoutes = require('#src/web/routes/settings/background-tasks'); 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 registerSettingsPageRoutes = require('#src/web/routes/settings/routes'); 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'); function registerRoutes(app, deps) { + registerWeatherIconRoutes(app, deps); // Group routes by the dependency bundle they need. registerAuthAndAccountRoutes(app, deps); registerOnboardingRoutes(app, { diff --git a/src/web/routes/settings/fonts.js b/src/web/routes/settings/fonts.js index c19f307..e43887f 100644 --- a/src/web/routes/settings/fonts.js +++ b/src/web/routes/settings/fonts.js @@ -231,7 +231,7 @@ module.exports = function registerFontRoutes(app, deps) { payload: { uploadDir: uploadDir, 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) }, persist: true diff --git a/src/web/routes/settings/fonts/list.js b/src/web/routes/settings/fonts/list.js index bc6a96a..a99a9f6 100644 --- a/src/web/routes/settings/fonts/list.js +++ b/src/web/routes/settings/fonts/list.js @@ -6,6 +6,7 @@ module.exports = function renderFontsPage(data, message, currentUser) { return renderView('settings/fonts/list', { title: 'Fonts', active: 'fonts', + assetsMenuOpen: true, message: message, currentUser: currentUser || null, fonts: data.fonts || [], diff --git a/src/web/routes/settings/weather-icons.js b/src/web/routes/settings/weather-icons.js new file mode 100644 index 0000000..31269a9 --- /dev/null +++ b/src/web/routes/settings/weather-icons.js @@ -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); } + }); +}; \ No newline at end of file diff --git a/src/web/routes/signage/slides/form-view-model.js b/src/web/routes/signage/slides/form-view-model.js index 4a97f98..c49a160 100644 --- a/src/web/routes/signage/slides/form-view-model.js +++ b/src/web/routes/signage/slides/form-view-model.js @@ -51,6 +51,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) { apiSources: apiSources, timetableGroups: timetableGroups, weatherLocations: weatherLocations, + weatherIconSets: data && data.weatherIconSets || [], fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '', fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '', existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null, diff --git a/src/web/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js index 1be9875..0852c21 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -134,6 +134,7 @@ module.exports = function registerSlidesRoutes(app, deps) { const payload = buildThumbnailPreviewPayload(slide, { baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''), fontStylesheetHref: getFontStylesheetHref(mediaDir), + weatherIconSets: require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir), apiSources: apiSources, getItem: getItem, getCachedImagePath: getCachedImagePath diff --git a/src/web/views/settings/weather-icons/list.hbs b/src/web/views/settings/weather-icons/list.hbs new file mode 100644 index 0000000..7abd9d0 --- /dev/null +++ b/src/web/views/settings/weather-icons/list.hbs @@ -0,0 +1,179 @@ + + +{{#if (hasPermission currentUser 'weather-icons.create')}} +
+
+

Upload icon set

+
+ +
+
+
+
+
+
+ + +
+
+
+
Day icons
+
+ {{#each conditions}} +
+ + +
+ {{/each}} +
+
+
+
Night icons Optional
+
+ {{#each conditions}} +
+ + +
+ {{/each}} +
+
+
+ +
+
+{{/if}} + +
+
+

Managed icon sets

+
+
+ + +
+
+
+
+ + + + + + + {{#if (anyPermission currentUser 'weather-icons.update' 'weather-icons.delete')}}{{/if}} + + + {{#each sets}} + + + + + + {{#if (anyPermission ../currentUser 'weather-icons.update' 'weather-icons.delete')}} + + {{/if}} + + {{else}} + + {{/each}} + +
NamePreviewSlidesStatusActions
{{name}} +
+ {{#each conditions}} +
+ {{#if image}}{{label}} + {{else}}{{/if}} +
+ {{/each}} +
+
{{usageCount}}{{#if enabled}}Enabled{{else}}Disabled{{/if}} + {{#if (hasPermission ../currentUser 'weather-icons.update')}} + +
+ + +
+ {{/if}} + {{#if (hasPermission ../currentUser 'weather-icons.delete')}} +
+ +
+ {{/if}} +
No uploaded icon sets.
+
+ {{> table-pagination pagination=pagination basePath="/settings/weather-icons" alwaysShow=true}} +
+ +{{#each sets}} + {{#if (hasPermission ../currentUser 'weather-icons.update')}} + + {{/if}} +{{/each}} \ No newline at end of file diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs index a19c370..5a92564 100644 --- a/src/web/views/shared/layout.hbs +++ b/src/web/views/shared/layout.hbs @@ -282,17 +282,9 @@ {{/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')}} {{/if}} - {{#if (hasPermission currentUser 'fonts.read')}} - - {{/if}} {{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}} {{/if}} + {{#if (anyPermission currentUser 'fonts.read' 'weather-icons.read')}} + + {{/if}} {{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}