From 3de0e89e942631a97d1e8cf83711c9b831e80771 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Fri, 28 Aug 2026 02:53:59 +0100 Subject: [PATCH] Release 2.9.0 --- CHANGELOG.md | 23 +- build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/common.js | 6 + src/data/api-sources.js | 6 +- src/data/app-settings.js | 2 + src/data/index.js | 7 + src/data/rss-feeds.js | 6 +- src/data/slides.js | 17 +- src/data/weather-units.js | 67 ++++++ src/data/weather.js | 138 ++++++++++++ src/db/index.js | 29 +++ src/db/migrations.js | 40 ++++ src/player/playlist.js | 26 ++- src/player/public/js/player-page-playback.js | 1 + src/player/public/js/player-page-rendering.js | 1 + src/player/regions/weather.js | 90 ++++++++ src/player/render-helpers.js | 1 + src/rbac.js | 14 ++ src/web/handlebars.js | 2 + .../tasks-adhoc/data-source-refresh.js | 13 +- .../tasks-scheduled/data-source-refresh.js | 26 ++- .../tasks-startup/data-source-refresh.js | 34 ++- src/web/lib/data-source-refresh.js | 47 +++- src/web/lib/media/slide-thumbnails.js | 59 ++++- src/web/pages/index.js | 3 + src/web/public/css/theme-custom.css | 51 +++++ src/web/public/js/admin/admin-page.js | 24 +- .../js/data-sources/weather-location-form.js | 126 +++++++++++ src/web/public/js/regions/region-utils.js | 2 + src/web/public/js/regions/type/weather.js | 206 ++++++++++++++++++ src/web/public/js/slides/slide-form-editor.js | 2 + .../public/js/slides/slide-form-regions.js | 21 +- src/web/public/js/slides/slide-form.js | 2 + src/web/routes/admin/content.js | 10 +- src/web/routes/admin/data-sources.js | 5 +- .../api-sources/form-view-model.js | 1 + .../routes/data-sources/api-sources/routes.js | 61 +++--- .../data-sources/rss-feeds/form-view-model.js | 1 + .../routes/data-sources/rss-feeds/routes.js | 57 +++-- src/web/routes/data-sources/weather.js | 172 +++++++++++++++ src/web/routes/data-sources/weather/add.js | 3 + src/web/routes/data-sources/weather/edit.js | 3 + .../data-sources/weather/form-view-model.js | 69 ++++++ src/web/routes/data-sources/weather/list.js | 16 ++ src/web/routes/register.js | 11 + src/web/routes/settings/routes.js | 30 ++- .../routes/signage/slides/form-view-model.js | 2 + src/web/routes/signage/slides/routes.js | 19 ++ .../views/data-sources/api-sources/form.hbs | 4 +- .../views/data-sources/api-sources/list.hbs | 4 +- src/web/views/data-sources/rss-feeds/form.hbs | 4 +- src/web/views/data-sources/rss-feeds/list.hbs | 4 +- .../data-sources/weather/forecast-preview.hbs | 8 + src/web/views/data-sources/weather/form.hbs | 25 +++ src/web/views/data-sources/weather/list.hbs | 38 ++++ src/web/views/data-sources/weather/mock.hbs | 117 ++++++++++ .../views/data-sources/weather/preview.hbs | 13 ++ src/web/views/settings/rbac/list.hbs | 2 +- src/web/views/settings/system/index.hbs | 41 ++++ src/web/views/shared/layout.hbs | 12 +- test/player-playlist.test.js | 1 + test/schema-update-log.test.js | 4 +- test/slide-form-editor.test.js | 2 + test/weather-units.test.js | 24 ++ 67 files changed, 1767 insertions(+), 98 deletions(-) create mode 100644 src/data/weather-units.js create mode 100644 src/data/weather.js create mode 100644 src/player/regions/weather.js create mode 100644 src/web/public/js/data-sources/weather-location-form.js create mode 100644 src/web/public/js/regions/type/weather.js create mode 100644 src/web/routes/data-sources/weather.js create mode 100644 src/web/routes/data-sources/weather/add.js create mode 100644 src/web/routes/data-sources/weather/edit.js create mode 100644 src/web/routes/data-sources/weather/form-view-model.js create mode 100644 src/web/routes/data-sources/weather/list.js create mode 100644 src/web/views/data-sources/weather/forecast-preview.hbs create mode 100644 src/web/views/data-sources/weather/form.hbs create mode 100644 src/web/views/data-sources/weather/list.hbs create mode 100644 src/web/views/data-sources/weather/mock.hbs create mode 100644 src/web/views/data-sources/weather/preview.hbs create mode 100644 test/weather-units.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7615ffa..4626662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,32 @@ All notable changes to this project will be documented in this file. -## 2.8.8 - 2026-08-26 +## 2.9.0 - 2026-08-28 + +### Added + +- API sources, RSS feeds, and weather locations can be enabled or disabled without deleting their cached data. +- Added Weather slide regions with current, daily, and hourly placeholders, date/time transforms, and Bootstrap weather icon transforms. +- Added multiple saved weather locations with provider, coordinate, unit, and refresh settings. +- Added separate Allow permissions for manually refreshing API sources, RSS feeds, and weather locations. ### Changed +- API, RSS, and Weather refresh jobs now skip disabled sources across scheduled, startup, queued, and manual refresh paths, while re-enabling a source resumes refresh scheduling. +- Weather placeholders now show all current fields, or all fields for the first daily/hourly entry before the remaining entries in a Show more section. +- Weather previews, player playback, and slide thumbnails now use cached Weather data and consistent text/icon sizing. +- API, RSS, and Weather lists now show enabled status and their forms provide action-oriented Enable/Disable controls. +- Startup data-source refreshes now respect each API, RSS, and weather source's configured repull interval. +- RSS feeds now persist their last collection timestamp. - Refreshed the vendored AdminLTE assets to 4.8.5. - Added AdminLTE extended palette colours to announcement colour choices and player rendering. -- Remove Digital Signage Subheading and top padding. +- Removed Digital Signage Subheading and top padding. +- API and RSS source saves now preserve cached data without pulling; added explicit manual refresh actions. + +### Fixed + +- Prevented deletion of Weather locations that are referenced by slides, including locations nested in saved slide content. +- Preserved Weather Bootstrap Icon classes through player rich-text sanitization so Weather icons render during playback. ## 2.8.7 - 2026-08-22 diff --git a/build/package.player.json b/build/package.player.json index 4a12224..3230c03 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.8.8", + "version": "2.9.0", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index c98b4cd..9ea4bdd 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.8.8", + "version": "2.9.0", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index f23058d..9164b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.8.8", + "version": "2.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.8.8", + "version": "2.9.0", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 6c322c6..9f6a74a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.8.8", + "version": "2.9.0", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/common.js b/src/common.js index f28c693..081967b 100644 --- a/src/common.js +++ b/src/common.js @@ -82,6 +82,12 @@ module.exports = { buildRssFeedPayload: data.buildRssFeedPayload, fetchRssFeedItems: data.fetchRssFeedItems, replaceRssFeedItems: data.replaceRssFeedItems, + fetchWeatherLocationsData: data.fetchWeatherLocationsData, + fetchWeatherLocationsPage: data.fetchWeatherLocationsPage, + fetchWeatherLocationById: data.fetchWeatherLocationById, + fetchWeatherLocationSuggestions: data.fetchWeatherLocationSuggestions, + fetchWeatherLocationForecast: data.fetchWeatherLocationForecast, + buildWeatherLocationPayload: data.buildWeatherLocationPayload, fetchScreenById: data.fetchScreenById, fetchScreenEditData: data.fetchScreenEditData, fetchScreenPlayerUrls: data.fetchScreenPlayerUrls, diff --git a/src/data/api-sources.js b/src/data/api-sources.js index 8a3ef68..b375aa2 100644 --- a/src/data/api-sources.js +++ b/src/data/api-sources.js @@ -59,7 +59,7 @@ function buildAuthHeaders(source) { async function fetchApiSourcesData(pool) { const [apiSources] = await pool.query( - 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' + 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' ); return { apiSources: apiSources }; @@ -67,7 +67,7 @@ async function fetchApiSourcesData(pool) { async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC', + selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC', countSql: 'SELECT COUNT(*) AS count FROM i_api_sources', searchColumns: ['name', 'api_url', 'last_pull_error'], searchTerm: searchTerm, @@ -91,7 +91,7 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so async function fetchApiSourceById(pool, id) { const [rows] = await pool.query( - 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', + 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', [id] ); diff --git a/src/data/app-settings.js b/src/data/app-settings.js index 3fdd95a..d0aa329 100644 --- a/src/data/app-settings.js +++ b/src/data/app-settings.js @@ -40,6 +40,8 @@ const SETTING_DEFINITIONS = [ ,{ key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' } ,{ key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 } ,{ key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' } + ,{ key: 'weather.open_meteo_api_key', type: 'string', defaultValue: '' } + ,{ key: 'weather.pirate_weather_api_key', type: 'string', defaultValue: '' } ]; const DEFINITIONS_BY_KEY = new Map(SETTING_DEFINITIONS.map(function (definition) { diff --git a/src/data/index.js b/src/data/index.js index fec85f7..0bedcfe 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -7,6 +7,7 @@ const { fetchPlaylistById } = require('./playlists'); const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); +const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('./weather'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens'); const { fetchPlayerRegistrations } = require('./player-registry'); const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates'); @@ -59,6 +60,12 @@ module.exports = { buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems, + fetchWeatherLocationsData, + fetchWeatherLocationsPage, + fetchWeatherLocationById, + fetchWeatherLocationSuggestions, + fetchWeatherLocationForecast, + buildWeatherLocationPayload, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, diff --git a/src/data/rss-feeds.js b/src/data/rss-feeds.js index c1ea303..0bdf68b 100644 --- a/src/data/rss-feeds.js +++ b/src/data/rss-feeds.js @@ -17,7 +17,7 @@ function normalizeUpdateIntervalUnit(value) { async function fetchRssFeedsData(pool) { const [rssFeeds] = await pool.query( - 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC' + 'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC' ); return { rssFeeds: rssFeeds }; @@ -25,7 +25,7 @@ async function fetchRssFeedsData(pool) { async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC', + selectSql: 'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC', countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds', searchColumns: ['name', 'feed_url'], searchTerm: searchTerm, @@ -48,7 +48,7 @@ async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sort async function fetchRssFeedById(pool, id) { const [rows] = await pool.query( - 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?', + 'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?', [id] ); diff --git a/src/data/slides.js b/src/data/slides.js index a06bff4..6c16364 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -444,7 +444,22 @@ async function buildTemplateContent(pool, template, body, filesByField, existing font_size: style.font_size, font_color: style.font_color }; - } else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) { + } else if (region.region_type === 'weather') { + const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; + const locationId = body[`region_weather_location_id_${region.id}`]; + const submittedForecastMode = body[`region_weather_forecast_mode_${region.id}`]; + const forecastMode = ['current', 'hourly'].includes(submittedForecastMode) ? submittedForecastMode : 'daily'; + const style = getTextRegionStyle(body, region, existingContent); + content[region.region_key] = { + 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)), + 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, + font_color: style.font_color + }; + } else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather'].includes(String(region.region_type || '').trim())) { const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; const suffix = '_' + region.id; const generic = {}; diff --git a/src/data/weather-units.js b/src/data/weather-units.js new file mode 100644 index 0000000..da682d1 --- /dev/null +++ b/src/data/weather-units.js @@ -0,0 +1,67 @@ +// Convert cached weather snapshots for display without refetching. + +function convertTemperature(value, fromUnit, toUnit) { + const number = Number(value); + if (!Number.isFinite(number) || fromUnit === toUnit) return value; + const converted = toUnit === 'fahrenheit' ? number * 9 / 5 + 32 : (number - 32) * 5 / 9; + return Math.round(converted * 10) / 10; +} + +function convertWind(value, fromUnit, toUnit) { + const number = Number(value); + if (!Number.isFinite(number) || fromUnit === toUnit) return value; + const metresPerSecond = fromUnit === 'mph' ? number * 0.44704 : fromUnit === 'kmh' ? number / 3.6 : number; + const converted = toUnit === 'mph' ? metresPerSecond / 0.44704 : toUnit === 'kmh' ? metresPerSecond * 3.6 : metresPerSecond; + return Math.round(converted * 10) / 10; +} + +function convertPrecipitation(value, fromUnit, toUnit) { + const number = Number(value); + if (!Number.isFinite(number) || fromUnit === toUnit) return value; + const converted = toUnit === 'inch' ? number / 25.4 : number * 25.4; + return Math.round(converted * 100) / 100; +} + +function temperatureUnitFromLabel(label) { + return /f/i.test(String(label || '')) ? 'fahrenheit' : 'celsius'; +} + +function windUnitFromLabel(label) { + const value = String(label || '').toLowerCase(); + return value.includes('mph') ? 'mph' : value.includes('m/s') ? 'ms' : 'kmh'; +} + +function precipitationUnitFromLabel(label) { + return /in/i.test(String(label || '')) ? 'inch' : 'mm'; +} + +function convertField(data, fields, converter, fromUnit, toUnit) { + fields.forEach(function (field) { + if (data[field] === undefined || data[field] === null) return; + data[field] = Array.isArray(data[field]) + ? data[field].map(function (value) { return converter(value, fromUnit, toUnit); }) + : converter(data[field], fromUnit, toUnit); + }); +} + +function convertWeatherSnapshot(snapshot, targetUnits) { + const source = snapshot && typeof snapshot === 'object' ? snapshot : {}; + const target = Object.assign({ temperature: 'celsius', wind: 'kmh', precipitation: 'mm' }, targetUnits || {}); + const result = JSON.parse(JSON.stringify(source)); + const currentUnits = source.current_units || {}; + const hourlyUnits = source.hourly_units || currentUnits; + const dailyUnits = source.daily_units || currentUnits; + + convertField(result.current || {}, ['temperature_2m', 'apparent_temperature'], convertTemperature, temperatureUnitFromLabel(currentUnits.temperature_2m), target.temperature); + convertField(result.current || {}, ['wind_speed_10m'], convertWind, windUnitFromLabel(currentUnits.wind_speed_10m), target.wind); + convertField(result.current || {}, ['precipitation', 'rain'], convertPrecipitation, precipitationUnitFromLabel(currentUnits.precipitation), target.precipitation); + convertField(result.hourly || {}, ['temperature_2m'], convertTemperature, temperatureUnitFromLabel(hourlyUnits.temperature_2m), target.temperature); + convertField(result.hourly || {}, ['wind_speed_10m'], convertWind, windUnitFromLabel(hourlyUnits.wind_speed_10m), target.wind); + convertField(result.hourly || {}, ['precipitation'], convertPrecipitation, precipitationUnitFromLabel(hourlyUnits.precipitation), target.precipitation); + convertField(result.daily || {}, ['temperature_2m_max', 'temperature_2m_min'], convertTemperature, temperatureUnitFromLabel(dailyUnits.temperature_2m_max), target.temperature); + convertField(result.daily || {}, ['wind_speed_10m_max'], convertWind, windUnitFromLabel(dailyUnits.wind_speed_10m_max), target.wind); + convertField(result.daily || {}, ['precipitation_sum'], convertPrecipitation, precipitationUnitFromLabel(dailyUnits.precipitation_sum), target.precipitation); + return result; +} + +module.exports = { convertWeatherSnapshot }; \ No newline at end of file diff --git a/src/data/weather.js b/src/data/weather.js new file mode 100644 index 0000000..ec1de61 --- /dev/null +++ b/src/data/weather.js @@ -0,0 +1,138 @@ +// Weather location data access and form normalization. + +const { fetchPagedRows, validateMaxLength } = require('./utils'); +const { fetchAppSettings } = require('./app-settings'); + +const NAME_MAX_LENGTH = 255; +const LOCATION_MAX_LENGTH = 255; +const TIMEZONE_MAX_LENGTH = 128; +const PROVIDERS = ['open-meteo', 'pirate-weather']; +const TEMPERATURE_UNITS = ['celsius', 'fahrenheit']; +const WIND_UNITS = ['kmh', 'mph', 'ms']; +const PRECIPITATION_UNITS = ['mm', 'inch']; + +async function fetchWeatherLocationSuggestions(query) { + const search = validateMaxLength(query, LOCATION_MAX_LENGTH, 'Location search'); + if (!search) { + return []; + } + + const response = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(search) + '&count=8&language=en&format=json', { + headers: { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather location lookup' } + }); + if (!response.ok) { + throw new Error('Weather location lookup failed.'); + } + const data = await response.json(); + return (Array.isArray(data.results) ? data.results : []).map(function (result) { + return { + label: [result.name, result.admin1, result.country].filter(Boolean).join(', '), + latitude: Number(result.latitude), + longitude: Number(result.longitude), + timezone: String(result.timezone || '') + }; + }).filter(function (result) { + return result.label && Number.isFinite(result.latitude) && Number.isFinite(result.longitude) && result.timezone; + }); +} + +function normalizeChoice(value, choices, fallback) { + const normalized = String(value || '').trim().toLowerCase(); + return choices.includes(normalized) ? normalized : fallback; +} + +async function fetchWeatherLocationsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { + const paged = await fetchPagedRows(pool, { + selectSql: 'SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC', + countSql: 'SELECT COUNT(*) AS count FROM i_weather_locations', + searchColumns: ['name', 'location_label', 'timezone', 'provider'], + searchTerm: searchTerm, + sortColumns: { + name: 'name', + location: 'location_label', + provider: 'provider', + interval: ['update_interval_value', 'update_interval_unit'], + last_pulled: 'last_pulled_at', + modified: 'modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, + page: page, + pageSize: pageSize + }); + return Object.assign({ weatherLocations: paged.rows }, paged); +} + +async function fetchWeatherLocationsData(pool) { + const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC'); + return { weatherLocations: rows }; +} + +async function fetchWeatherLocationById(pool, id) { + const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at, created_by, modified_by FROM i_weather_locations WHERE id = ?', [id]); + return rows[0] || null; +} + +async function fetchWeatherLocationForecast(pool, location) { + const source = location || {}; + const settings = await fetchAppSettings(pool); + const latitude = Number(source.latitude); + const longitude = Number(source.longitude); + const temperatureUnit = source.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius'; + const windUnit = source.wind_unit === 'mph' ? 'mph' : source.wind_unit === 'ms' ? 'ms' : 'kmh'; + const precipitationUnit = source.precipitation_unit === 'inch' ? 'inch' : 'mm'; + let url; + let headers = { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather reader' }; + + if (source.provider === 'pirate-weather') { + const apiKey = String(settings['weather.pirate_weather_api_key'] || '').trim(); + if (!apiKey) throw new Error('Pirate Weather API key is not configured.'); + url = 'https://api.pirateweather.net/forecast/' + encodeURIComponent(apiKey) + '/' + latitude + ',' + longitude + '?units=' + (temperatureUnit === 'fahrenheit' ? 'us' : 'si'); + } else { + const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit }); + const apiKey = String(settings['weather.open_meteo_api_key'] || '').trim(); + if (apiKey) params.set('apikey', apiKey); + url = 'https://api.open-meteo.com/v1/forecast?' + params.toString(); + } + + const response = await fetch(url, { headers: headers }); + if (!response.ok) throw new Error('Weather provider returned HTTP ' + response.status + '.'); + const snapshot = await response.json(); + return { snapshot: snapshot, responseJson: JSON.stringify(snapshot), fetchedAt: new Date() }; +} + +function buildWeatherLocationPayload(req, existingLocation) { + const body = req && req.body ? req.body : {}; + const fallback = existingLocation || {}; + const name = validateMaxLength(body.name || fallback.name || '', NAME_MAX_LENGTH, 'Weather location name'); + const locationLabel = validateMaxLength(body.location_label || fallback.location_label || '', LOCATION_MAX_LENGTH, 'Location label'); + const latitude = Number(body.latitude !== undefined ? body.latitude : fallback.latitude); + const longitude = Number(body.longitude !== undefined ? body.longitude : fallback.longitude); + const timezone = validateMaxLength(body.timezone || fallback.timezone || '', TIMEZONE_MAX_LENGTH, 'Timezone'); + const provider = normalizeChoice(body.provider || fallback.provider, PROVIDERS, 'open-meteo'); + const temperatureUnit = normalizeChoice(body.temperature_unit || fallback.temperature_unit, TEMPERATURE_UNITS, 'celsius'); + const windUnit = normalizeChoice(body.wind_unit || fallback.wind_unit, WIND_UNITS, 'kmh'); + const precipitationUnit = normalizeChoice(body.precipitation_unit || fallback.precipitation_unit, PRECIPITATION_UNITS, 'mm'); + const updateIntervalValue = Number(body.update_interval_value || fallback.update_interval_value || 30); + const updateIntervalUnit = normalizeChoice(body.update_interval_unit || fallback.update_interval_unit, ['minutes', 'hours'], 'minutes'); + + if (!name || !locationLabel || !timezone) { + const error = new Error('Name, location label, and timezone are required.'); + error.statusCode = 400; + throw error; + } + if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) { + const error = new Error('Latitude must be between -90 and 90, and longitude must be between -180 and 180.'); + error.statusCode = 400; + throw error; + } + if (!Number.isInteger(updateIntervalValue) || updateIntervalValue < 1 || updateIntervalValue > 1440) { + const error = new Error('Update interval must be a whole number between 1 and 1440.'); + error.statusCode = 400; + throw error; + } + + return { name, locationLabel, latitude, longitude, timezone, provider, temperatureUnit, windUnit, precipitationUnit, updateIntervalValue, updateIntervalUnit }; +} + +module.exports = { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload, PROVIDERS, TEMPERATURE_UNITS, WIND_UNITS, PRECIPITATION_UNITS }; \ No newline at end of file diff --git a/src/db/index.js b/src/db/index.js index 7cd4c1a..f1802b2 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -203,9 +203,11 @@ async function ensureSchema(pool, options) { id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, feed_url VARCHAR(1024) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, update_interval_value INT NOT NULL DEFAULT 60, update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes', item_limit INT NOT NULL DEFAULT 1, + last_pulled_at DATETIME NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -247,6 +249,7 @@ async function ensureSchema(pool, options) { token_header_name VARCHAR(255) NULL DEFAULT 'Authorization', token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer', items_path VARCHAR(255) NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, update_interval_value INT NOT NULL DEFAULT 60, update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes', last_pulled_at TIMESTAMP NULL, @@ -261,6 +264,32 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS i_weather_locations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + location_label VARCHAR(255) NOT NULL, + latitude DECIMAL(9,6) NOT NULL, + longitude DECIMAL(9,6) NOT NULL, + timezone VARCHAR(128) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'open-meteo', + temperature_unit VARCHAR(16) NOT NULL DEFAULT 'celsius', + wind_unit VARCHAR(16) NOT NULL DEFAULT 'kmh', + precipitation_unit VARCHAR(16) NOT NULL DEFAULT 'mm', + enabled TINYINT(1) NOT NULL DEFAULT 1, + update_interval_value INT NOT NULL DEFAULT 30, + update_interval_unit VARCHAR(16) NOT NULL DEFAULT 'minutes', + last_pulled_at DATETIME NULL, + last_pull_error VARCHAR(1024) NULL, + last_response_json MEDIUMTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + INDEX idx_weather_locations_modified_at (modified_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS i_timetable_groups ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/src/db/migrations.js b/src/db/migrations.js index 2ee709f..be3c721 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -505,6 +505,46 @@ const VERSIONED_MIGRATIONS = [ await ensureColumn(pool, 'i_api_sources', 'token_header_name', "VARCHAR(255) NULL DEFAULT 'Authorization'", 'token_response_path'); await ensureColumn(pool, 'i_api_sources', 'token_header_prefix', "VARCHAR(64) NULL DEFAULT 'Bearer'", 'token_header_name'); } + }, + { + version: '2.8.8', + label: 'v2.8.8 weather locations and RSS collection timestamps schema', + run: async function (pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS i_weather_locations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + location_label VARCHAR(255) NOT NULL, + latitude DECIMAL(9,6) NOT NULL, + longitude DECIMAL(9,6) NOT NULL, + timezone VARCHAR(128) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'open-meteo', + temperature_unit VARCHAR(16) NOT NULL DEFAULT 'celsius', + wind_unit VARCHAR(16) NOT NULL DEFAULT 'kmh', + precipitation_unit VARCHAR(16) NOT NULL DEFAULT 'mm', + update_interval_value INT NOT NULL DEFAULT 30, + update_interval_unit VARCHAR(16) NOT NULL DEFAULT 'minutes', + last_pulled_at DATETIME NULL, + last_pull_error VARCHAR(1024) NULL, + last_response_json MEDIUMTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + INDEX idx_weather_locations_modified_at (modified_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await ensureColumn(pool, 'i_rss_feeds', 'last_pulled_at', 'DATETIME NULL', 'item_limit'); + } + }, + { + version: '2.8.9', + label: 'v2.8.9 data source enablement schema', + run: async function (pool) { + await ensureColumn(pool, 'i_api_sources', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'items_path'); + await ensureColumn(pool, 'i_rss_feeds', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'feed_url'); + await ensureColumn(pool, 'i_weather_locations', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'precipitation_unit'); + } } ]; diff --git a/src/player/playlist.js b/src/player/playlist.js index 4c5250c..55a78af 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -1,6 +1,7 @@ // Player playlist assembly, snapshot persistence, and playlist revision helpers. const crypto = require('crypto'); +const { convertWeatherSnapshot } = require('#src/data/weather-units'); const fs = require('fs'); const path = require('path'); const { createStyledQrCodeDataUrl } = require('../data/qr-code'); @@ -166,7 +167,7 @@ function createPlayerPlaylistService(options) { try { const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]); if (!screenRows.length) { - return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] }; + return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], weatherLocations: [] }; } const screen = screenRows[0]; @@ -178,6 +179,7 @@ function createPlayerPlaylistService(options) { rssFeeds: [], apiSources: [], timetableGroups: [], + weatherLocations: [], revision: getPlaylistRevision(screen, null, [], [], [], [], [], []) }; await writeSnapshot(slug, payloadWithoutPlaylist); @@ -342,10 +344,25 @@ function createPlayerPlaylistService(options) { timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; } + let weatherLocations = []; + if (typeof common.fetchWeatherLocationsData === 'function') { + const weatherData = await common.fetchWeatherLocationsData(pool); + weatherLocations = (weatherData.weatherLocations || []).map(function (location) { + const responseJson = common.parseJsonSafe ? common.parseJsonSafe(location.last_response_json) : null; + return Object.assign({}, location, { + responseJson: convertWeatherSnapshot(responseJson, { + temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', + wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', + precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' + }) + }); + }); + } + await cachePlaceholderImages(slides, rssFeeds, apiSources); - const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups); - const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision }; + 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 }; await writeSnapshot(slug, payload); return payload; } catch (error) { @@ -362,7 +379,7 @@ function createPlayerPlaylistService(options) { hash.update('\0'); } - function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) { + function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations) { const hash = crypto.createHash('sha1'); updatePlaylistRevisionHash(hash, screen && screen.id); @@ -426,6 +443,7 @@ function createPlayerPlaylistService(options) { updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || [])); updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || [])); + updatePlaylistRevisionHash(hash, JSON.stringify(weatherLocations || [])); 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 0e21088..95c8c48 100644 --- a/src/player/public/js/player-page-playback.js +++ b/src/player/public/js/player-page-playback.js @@ -196,6 +196,7 @@ function refresh() { window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : []; window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : []; window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : []; + window.initialData.weatherLocations = Array.isArray(data.weatherLocations) ? data.weatherLocations : []; window.initialData.revision = nextSignature; } savePlaylistSnapshot({ diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js index 594b3e8..77abeb5 100644 --- a/src/player/public/js/player-page-rendering.js +++ b/src/player/public/js/player-page-rendering.js @@ -358,6 +358,7 @@ function sanitizeRichTextAttributes(tagName, attrText) { h5: ['class', 'style'], h6: ['class', 'style'], img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], + i: ['class', 'style', 'aria-hidden'], col: ['class', 'style', 'span', 'width'], colgroup: ['class', 'style', 'span'], li: ['class', 'style'], diff --git a/src/player/regions/weather.js b/src/player/regions/weather.js new file mode 100644 index 0000000..b5809f7 --- /dev/null +++ b/src/player/regions/weather.js @@ -0,0 +1,90 @@ +// Weather region rendering for live playback. + +var weatherRegistry = window.pulsePlayerRegionTypes; +var weatherPlaceholderUtils = window.placeholderUtils || {}; + +function weatherIconForCode(code) { + var value = Number(code); + if (value === 0) return 'bi-sun'; + if (value <= 3) return 'bi-cloud-sun'; + if (value <= 48) return 'bi-cloud-fog'; + if (value <= 67 || value > 77 && value <= 82) return 'bi-cloud-rain'; + if (value <= 77) return 'bi-cloud-snow'; + return 'bi-cloud-lightning-rain'; +} + +function getWeatherLocation(locationId) { + var locations = Array.isArray(initialData && initialData.weatherLocations) ? initialData.weatherLocations : []; + return locations.find(function (location) { return Number(location.id) === Number(locationId); }) || null; +} + +function normalizeWeatherExpression(expression) { + var value = String(expression || '').trim(); + var currentAliases = { temp: 'current.temperature_2m', temp_unit: 'current.temperature_unit', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', code: 'current.weather_code', wind: 'current.wind_speed_10m', wind_unit: 'current.wind_speed_unit', precip: 'current.precipitation', precip_unit: 'current.precipitation_unit', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' }; + var globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' }; + if (globalAliases[value]) return globalAliases[value]; + var currentField = value.replace(/^current\./, ''); + var currentMatch = currentField.match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); + if (currentMatch && currentAliases[currentMatch[1]]) return currentAliases[currentMatch[1]] + (currentMatch[2] || ''); + var indexed = value.match(/^(daily|hourly)\.(\d+)\.(.+)$/); + if (!indexed) return value; + var fieldMatch = indexed[3].match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); + var field = fieldMatch ? fieldMatch[1] : indexed[3]; + var aliases = indexed[1] === 'daily' ? { time: 'time', code: 'weather_code', temp_max: 'temperature_2m_max', temp_min: 'temperature_2m_min', precip: 'precipitation_sum', wind: 'wind_speed_10m_max', uv_index: 'uv_index_max', cloud_cover: 'cloud_cover_mean', sunrise: 'sunrise', sunset: 'sunset', icon: 'weather_code' } : { time: 'time', code: 'weather_code', temp: 'temperature_2m', precip: 'precipitation', wind: 'wind_speed_10m', uv_index: 'uv_index', cloud_cover: 'cloud_cover', icon: 'weather_code' }; + if (!Object.prototype.hasOwnProperty.call(aliases, field)) return value; + return field === 'icon' ? indexed[1] + '.' + aliases[field] + '.' + indexed[2] + '.icon' + (fieldMatch[2] || '') : indexed[1] + '.' + aliases[field] + '.' + indexed[2] + (fieldMatch[2] || ''); +} + +function withWeatherUnits(snapshot, location) { + var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' }); + var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C'; + var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h'; + var precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm'; + data.current = Object.assign({}, snapshot.current || {}, { temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit }); + data.daily = Object.assign({}, snapshot.daily || {}, { temperature_unit: temperatureUnit }); + data.hourly = Object.assign({}, snapshot.hourly || {}, { temperature_unit: temperatureUnit }); + return data; +} + +function substituteWeatherVariables(html, value) { + 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(); + var normalizedExpression = normalizeWeatherExpression(rawExpression); + var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/); + 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 weatherCode = weatherPlaceholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone }); + var icon = ''; + return width ? '' + icon + '' : icon; + } + return escapeHtml(weatherPlaceholderUtils.formatPlaceholderValue(weatherPlaceholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone }))); + }); +} + +function renderWeatherRegion(region, regionContent) { + var location = getWeatherLocation(regionContent && regionContent.weather_location_id); + var snapshot = location && location.responseJson && typeof location.responseJson === 'object' ? location.responseJson : null; + var width = Math.max(1, Math.round(Number(region && region.pixelWidth) || 1)); + var height = Math.max(1, Math.round(Number(region && region.pixelHeight) || 1)); + var scale = Number(region && region.canvasScale) || 1; + var style = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + scale + ');transform-origin:top left;overflow:hidden;'; + if (!location || !snapshot) return '
'; + var current = snapshot.current || {}; + var value = String(regionContent && regionContent.value || ''); + 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)) + '
'; + } + var daily = snapshot.daily || {}; + var days = (daily.time || []).slice(0, 7).map(function (day, index) { + return '
' + escapeHtml(index === 0 ? 'Today' : String(day).slice(5)) + '' + escapeHtml(daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] + '°' : '-') + '
'; + }).join(''); + return '
' + escapeHtml(location.location_label || location.name) + '
' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°
' + days + '
'; +} + +weatherRegistry.register('weather', { renderRegion: renderWeatherRegion }); diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js index 7fba5a7..32a8155 100644 --- a/src/player/render-helpers.js +++ b/src/player/render-helpers.js @@ -64,6 +64,7 @@ function sanitizeRichTextAttributes(tagName, attrText) { h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], + i: ['class', 'style', 'aria-hidden'], img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], col: ['class', 'style', 'span', 'width'], colgroup: ['class', 'style', 'span'], diff --git a/src/rbac.js b/src/rbac.js index 5e69773..6ce350b 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -109,6 +109,7 @@ const PERMISSION_SECTIONS = [ { key: 'read', name: 'Read', description: 'View configured RSS feeds.' }, { key: 'create', name: 'Create', description: 'Create new RSS feeds.' }, { key: 'update', name: 'Update', description: 'Update RSS feeds.' }, + { key: 'allow', name: 'Allow', description: 'Manually refresh RSS feeds.' }, { key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' } ] }, @@ -120,6 +121,7 @@ const PERMISSION_SECTIONS = [ { key: 'read', name: 'Read', description: 'View configured API sources.' }, { key: 'create', name: 'Create', description: 'Create new API sources.' }, { key: 'update', name: 'Update', description: 'Update API sources.' }, + { key: 'allow', name: 'Allow', description: 'Manually refresh API sources.' }, { key: 'delete', name: 'Delete', description: 'Delete API sources.' } ] }, @@ -133,6 +135,18 @@ const PERMISSION_SECTIONS = [ { key: 'update', name: 'Update', description: 'Update timetable groups and entries.' }, { key: 'delete', name: 'Delete', description: 'Delete timetable groups.' } ] + }, + { + key: 'weather', + order: 40, + name: 'Weather locations', + permissions: [ + { key: 'read', name: 'Read', description: 'View configured weather locations.' }, + { key: 'create', name: 'Create', description: 'Create new weather locations.' }, + { key: 'update', name: 'Update', description: 'Update weather locations.' }, + { key: 'allow', name: 'Allow', description: 'Manually refresh weather locations.' }, + { key: 'delete', name: 'Delete', description: 'Delete weather locations.' } + ] } ] }, diff --git a/src/web/handlebars.js b/src/web/handlebars.js index 2a620ab..2603f3a 100644 --- a/src/web/handlebars.js +++ b/src/web/handlebars.js @@ -179,6 +179,8 @@ function registerPartials(Handlebars, viewsRoot) { Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8')); Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8')); Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8')); + Handlebars.registerPartial('data-sources/weather/preview', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'weather', 'preview.hbs'), 'utf8')); + Handlebars.registerPartial('data-sources/weather/forecast-preview', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8')); } // One entry point keeps Handlebars bootstrap centralized. diff --git a/src/web/lib/background-tasks/tasks-adhoc/data-source-refresh.js b/src/web/lib/background-tasks/tasks-adhoc/data-source-refresh.js index f38c207..773c145 100644 --- a/src/web/lib/background-tasks/tasks-adhoc/data-source-refresh.js +++ b/src/web/lib/background-tasks/tasks-adhoc/data-source-refresh.js @@ -1,4 +1,4 @@ -const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh'); +const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh'); const TASK = { taskType: 'data-source-refresh' @@ -24,6 +24,7 @@ function registerDataSourceRefreshTask(options) { if (!apiSource) { throw new Error('API source not found.'); } + if (apiSource.enabled === 0 || apiSource.enabled === false) return { skipped: true, reason: 'disabled' }; return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null, options.notifyPlayerScreens); } @@ -32,9 +33,19 @@ function registerDataSourceRefreshTask(options) { if (!rssFeed) { throw new Error('RSS feed not found.'); } + if (rssFeed.enabled === 0 || rssFeed.enabled === false) return { skipped: true, reason: 'disabled' }; return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null, options.notifyPlayerScreens); } + if (sourceType === 'weather-location') { + const location = await common.fetchWeatherLocationById(pool, sourceId); + if (!location) { + throw new Error('Weather location not found.'); + } + if (location.enabled === 0 || location.enabled === false) return { skipped: true, reason: 'disabled' }; + return refreshWeatherLocation(pool, common, location, Number(payload.actorId) || null, options.notifyPlayerScreens); + } + throw new Error('Unsupported data source refresh task.'); }); } diff --git a/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js index 47237d9..b64945a 100644 --- a/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js +++ b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js @@ -9,7 +9,7 @@ const TASK = { }; const { normalizeIntervalMs } = require('../queue'); -const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh'); +const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh'); function registerRecurringDataSourceRefreshes(options) { const pool = options && options.pool; @@ -23,6 +23,7 @@ function registerRecurringDataSourceRefreshes(options) { return (async function () { const apiSourcesData = await common.fetchApiSourcesData(pool); (apiSourcesData.apiSources || []).forEach(function (apiSource) { + if (apiSource.enabled === 0 || apiSource.enabled === false) return; backgroundTaskQueue.registerRecurringTask({ key: 'api-source-refresh:' + Number(apiSource.id), title: 'API source refresh', @@ -41,6 +42,7 @@ function registerRecurringDataSourceRefreshes(options) { const rssFeedsData = await common.fetchRssFeedsData(pool); (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { + if (rssFeed.enabled === 0 || rssFeed.enabled === false) return; backgroundTaskQueue.registerRecurringTask({ key: 'rss-feed-refresh:' + Number(rssFeed.id), title: 'RSS feed refresh', @@ -56,6 +58,19 @@ function registerRecurringDataSourceRefreshes(options) { } }); }); + + const weatherLocationsData = await common.fetchWeatherLocationsData(pool); + (weatherLocationsData.weatherLocations || []).forEach(function (location) { + if (location.enabled === 0 || location.enabled === false) return; + backgroundTaskQueue.registerRecurringTask({ + key: 'weather-location-refresh:' + Number(location.id), + title: 'Weather location refresh', + category: TASK.category, + intervalMs: normalizeIntervalMs(location.update_interval_value, location.update_interval_unit), + metadata: { sourceType: 'weather-location', sourceId: Number(location.id), sourceName: location.name }, + run: function () { return refreshWeatherLocation(pool, common, location, null, options.notifyPlayerScreens); } + }); + }); })(); } @@ -73,7 +88,7 @@ function createDataSourceTaskService(options) { } function buildRecurringTitle(sourceType) { - return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh'; + return sourceType === 'rss-feed' ? 'RSS feed refresh' : sourceType === 'weather-location' ? 'Weather location refresh' : 'API source refresh'; } function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) { @@ -122,6 +137,10 @@ function createDataSourceTaskService(options) { return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, options.notifyPlayerScreens); } + async function refreshWeatherLocationInBackground(locationId, actorId) { + return refreshWeatherLocation(pool, common, locationId, actorId, options.notifyPlayerScreens); + } + return { formatRecurringKey: formatRecurringKey, buildRecurringTitle: buildRecurringTitle, @@ -129,7 +148,8 @@ function createDataSourceTaskService(options) { removeRecurringRefresh: removeRecurringRefresh, getTaskStatusById: getTaskStatusById, refreshApiSourceInBackground: refreshApiSourceInBackground, - refreshRssFeedInBackground: refreshRssFeedInBackground + refreshRssFeedInBackground: refreshRssFeedInBackground, + refreshWeatherLocationInBackground: refreshWeatherLocationInBackground }; } diff --git a/src/web/lib/background-tasks/tasks-startup/data-source-refresh.js b/src/web/lib/background-tasks/tasks-startup/data-source-refresh.js index b205298..812a8d2 100644 --- a/src/web/lib/background-tasks/tasks-startup/data-source-refresh.js +++ b/src/web/lib/background-tasks/tasks-startup/data-source-refresh.js @@ -1,4 +1,5 @@ -const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh'); +const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh'); +const { normalizeIntervalMs } = require('../queue'); const TASK = { key: 'startup-data-source-refresh', @@ -26,23 +27,52 @@ function scheduleStartupDataSourceRefreshes(options) { }; } + function shouldRefreshAtStartup(source) { + const lastPulledAt = source && source.last_pulled_at ? new Date(source.last_pulled_at).getTime() : NaN; + if (!Number.isFinite(lastPulledAt)) { + return true; + } + + const intervalMs = normalizeIntervalMs(source.update_interval_value, source.update_interval_unit); + return Date.now() - lastPulledAt >= intervalMs; + } + return (async function () { const apiSourcesData = await common.fetchApiSourcesData(pool); const rssFeedsData = await common.fetchRssFeedsData(pool); + const weatherLocationsData = await common.fetchWeatherLocationsData(pool); const startupSources = []; (apiSourcesData.apiSources || []).forEach(function (apiSource) { + if (apiSource.enabled === 0 || apiSource.enabled === false) return; + if (!shouldRefreshAtStartup(apiSource)) { + return; + } startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () { return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens); })); }); (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { + if (rssFeed.enabled === 0 || rssFeed.enabled === false) return; + if (!shouldRefreshAtStartup(rssFeed)) { + return; + } startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () { return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens); })); }); + (weatherLocationsData.weatherLocations || []).forEach(function (location) { + if (location.enabled === 0 || location.enabled === false) return; + if (!shouldRefreshAtStartup(location)) { + return; + } + startupSources.push(buildStartupSource('weather-location', location.id, location.name, function () { + return refreshWeatherLocation(pool, common, location, null, options.notifyPlayerScreens); + })); + }); + // Stagger startup refreshes to avoid a burst against the DB/player. startupSources.forEach(function (source, index) { const startupDelayMs = index * staggerMs; @@ -50,7 +80,7 @@ function scheduleStartupDataSourceRefreshes(options) { setTimeout(function () { backgroundTaskQueue.enqueueTask({ key: TASK.key + ':' + source.type + ':' + source.id + ':' + Date.now(), - title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh', + title: source.type === 'rss-feed' ? 'RSS feed refresh' : source.type === 'weather-location' ? 'Weather location refresh' : 'API source refresh', category: TASK.category, taskType: 'data-source-refresh', metadata: { diff --git a/src/web/lib/data-source-refresh.js b/src/web/lib/data-source-refresh.js index 4bbebb0..0063ccc 100644 --- a/src/web/lib/data-source-refresh.js +++ b/src/web/lib/data-source-refresh.js @@ -153,6 +153,46 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlay } } +async function refreshWeatherLocation(pool, common, weatherLocationOrId, actorId, notifyPlayerScreens) { + const connection = await pool.getConnection(); + try { + const location = weatherLocationOrId && typeof weatherLocationOrId === 'object' + ? weatherLocationOrId + : await common.fetchWeatherLocationById(pool, Number(weatherLocationOrId)); + if (!location) throw new Error('Weather location not found.'); + + let result = null; + let pullError = ''; + try { + result = await common.fetchWeatherLocationForecast(pool, location); + } catch (error) { + pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.'); + } + + await connection.beginTransaction(); + await connection.query( + 'UPDATE i_weather_locations SET last_pulled_at = ?, last_pull_error = ?, last_response_json = COALESCE(?, last_response_json), modified_by = ? WHERE id = ?', + [new Date(), pullError || null, result ? result.responseJson : null, actorId, location.id] + ); + await connection.commit(); + + if (pullError) { + console.error('[data-source-refresh] Weather location refresh completed with an error for location ' + location.id + ': ' + pullError); + } else if (typeof notifyAffectedScreens === 'function') { + try { + await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id); + } catch (notifyError) { + console.warn('[data-source-refresh] Unable to notify players after weather refresh ' + location.id + ':', notifyError); + } + } + } catch (error) { + try { await connection.rollback(); } catch (_rollbackError) { } + throw error; + } finally { + connection.release(); + } +} + async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, notifyPlayerScreens) { const connection = await pool.getConnection(); try { @@ -170,6 +210,10 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor if (typeof common.replaceRssFeedItems === 'function') { await common.replaceRssFeedItems(connection, rssFeedId, updatedItems); } + await connection.query( + 'UPDATE i_rss_feeds SET last_pulled_at = ? WHERE id = ?', + [new Date(), rssFeedId] + ); await connection.commit(); if (!pullError && rssFeedChanged) { @@ -197,5 +241,6 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor module.exports = { refreshApiSource: refreshApiSource, - refreshRssFeed: refreshRssFeed + refreshRssFeed: refreshRssFeed, + refreshWeatherLocation: refreshWeatherLocation }; \ No newline at end of file diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index 327d69c..2c20167 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -13,6 +13,7 @@ const { sanitizeTextColor } = require('#src/player/render-helpers'); const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth'); +const { convertWeatherSnapshot } = require('#src/data/weather-units'); const placeholderUtils = (() => { const sandbox = { window: {} }; @@ -147,15 +148,50 @@ function getImagePlaceholderConfig(expression) { }; } +function normalizeWeatherExpression(expression) { + const value = String(expression || '').trim(); + const currentAliases = { temp: 'current.temperature_2m', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', wind: 'current.wind_speed_10m', precip: 'current.precipitation', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' }; + const globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' }; + if (globalAliases[value]) return globalAliases[value]; + const currentField = value.replace(/^current\./, '').match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); + if (currentField && currentAliases[currentField[1]]) return currentAliases[currentField[1]] + (currentField[2] || ''); + const indexed = value.match(/^(daily|hourly)\.(\d+)\.(.+)$/); + if (!indexed) return value; + const fieldMatch = indexed[3].match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); + const field = fieldMatch ? fieldMatch[1] : indexed[3]; + const aliases = indexed[1] === 'daily' ? { time: 'time', temp_max: 'temperature_2m_max', temp_min: 'temperature_2m_min', precip: 'precipitation_sum', wind: 'wind_speed_10m_max', uv_index: 'uv_index_max', cloud_cover: 'cloud_cover_mean', sunrise: 'sunrise', sunset: 'sunset', icon: 'weather_code' } : { time: 'time', temp: 'temperature_2m', precip: 'precipitation', wind: 'wind_speed_10m', uv_index: 'uv_index', cloud_cover: 'cloud_cover', icon: 'weather_code' }; + if (!Object.prototype.hasOwnProperty.call(aliases, field)) return value; + return field === 'icon' ? indexed[1] + '.' + aliases[field] + '.' + indexed[2] + '.icon' + (fieldMatch[2] || '') : indexed[1] + '.' + aliases[field] + '.' + indexed[2] + (fieldMatch[2] || ''); +} + +function weatherIconForCode(code) { + const value = Number(code); + if (value === 0) return 'bi-sun'; + if (value <= 3) return 'bi-cloud-sun'; + if (value <= 48) return 'bi-cloud-fog'; + if (value <= 67 || value > 77 && value <= 82) return 'bi-cloud-rain'; + if (value <= 77) return 'bi-cloud-snow'; + return 'bi-cloud-lightning-rain'; +} + function substitutePlaceholders(html, regionContent, options) { const source = String(html || ''); const type = String(regionContent && regionContent.type || '').trim().toLowerCase(); const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null; if (!item) return source; return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) { + const weatherExpression = type === 'weather' ? normalizeWeatherExpression(expression) : expression; const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function' - ? placeholderUtils.resolvePlaceholderExpression(item, expression) + ? placeholderUtils.resolvePlaceholderExpression(item, weatherExpression, { timeZone: item.timezone }) : resolvePlaceholderPath(item, expression); + 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 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 value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || ''); if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) { return escapeHtml(value); @@ -348,6 +384,23 @@ async function captureSlideThumbnail(options) { const apiSources = (apiSourcesData.apiSources || []).map(function (source) { return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null }); }); + const weatherLocationsData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] }; + const weatherLocations = (weatherLocationsData.weatherLocations || []).map(function (location) { + let snapshot = null; + try { + snapshot = JSON.parse(location.last_response_json || ''); + } catch (_error) { + snapshot = null; + } + if (!snapshot || typeof snapshot !== 'object') return null; + const temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C'; + const windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h'; + const precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm'; + const data = Object.assign({}, convertWeatherSnapshot(snapshot, { temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' }), { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: temperatureUnit, wind_unit: windUnit, precip_unit: precipitationUnit }); + data.current = Object.assign({}, data.current || {}, { temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit }); + data.daily = Object.assign({}, data.daily || {}, { temperature_unit: temperatureUnit }); + return { id: location.id, data: data }; + }).filter(Boolean); function getApiItems(source) { const response = source && source.responseJson; @@ -375,6 +428,10 @@ async function captureSlideThumbnail(options) { const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); }); return feed && Array.isArray(feed.items) ? feed.items[index] || null : null; } + if (type === 'weather') { + const location = weatherLocations.find(function (entry) { return Number(entry.id) === Number(regionContent.weather_location_id); }); + return location ? location.data : null; + } return null; } diff --git a/src/web/pages/index.js b/src/web/pages/index.js index 39fafad..9f11cd2 100644 --- a/src/web/pages/index.js +++ b/src/web/pages/index.js @@ -29,6 +29,9 @@ module.exports = { renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')), renderRssFeedAddPage: require(routePath('data-sources', 'rss-feeds', 'add')), renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')), + renderWeatherLocationsPage: require(routePath('data-sources', 'weather', 'list')), + renderWeatherLocationAddPage: require(routePath('data-sources', 'weather', 'add')), + renderWeatherLocationEditPage: require(routePath('data-sources', 'weather', 'edit')), renderScreensPage: require(routePath('signage', 'screens', 'list')), renderScreenFormPage: require(routePath('signage', 'screens', 'add')), renderScreenEditPage: require(routePath('signage', 'screens', 'edit')), diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 507e601..d5b1bcd 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -3279,4 +3279,55 @@ table.table thead th.sort-desc .table-sort-indicator { .card-body.table-responsive > table.table > thead th { position: static; } +} + +.weather-preview-card { + display: flex; + flex-direction: column; +} + +.weather-preview-card-body { + flex: 1 1 auto; +} + +.weather-preview-card-footer { + min-height: 3.5rem; +} + +.weather-preview-icon { + font-size: 4rem; + color: #e0a11a; +} + +.weather-preview-forecast-icon { + display: block; + font-size: 2rem; + color: #e0a11a; + margin: 1rem 0 0.65rem; +} + +.weather-daily-forecast { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.weather-daily-forecast > * { + min-width: 0; +} + +@media (min-width: 768px) { + .weather-daily-forecast { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (min-width: 992px) { + .weather-daily-forecast { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } +} + +.weather-preview-hourly-item { + width: 7.5rem; } \ No newline at end of file diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index 7d9371c..f0e5814 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -610,7 +610,7 @@ } catch (_error) { actionPath = String(form.action || ''); } - var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath); + var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath) || /^\/data-sources\/(?:api-sources|rss-feeds|weather)\/\d+$/.test(actionPath); event.preventDefault(); @@ -799,6 +799,28 @@ return; } + var refreshButton = target.closest('[data-manual-refresh-url]'); + if (refreshButton) { + event.preventDefault(); + if (refreshButton.disabled) { + return; + } + refreshButton.disabled = true; + fetch(refreshButton.getAttribute('data-manual-refresh-url'), { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }).then(function (response) { + if (!response.ok) { + throw new Error('Refresh failed'); + } + window.location.assign(response.url); + }).catch(function () { + refreshButton.disabled = false; + }); + return; + } + var button = target.closest('button[name="save_action"], button[name="action"]'); if (!button) { return; diff --git a/src/web/public/js/data-sources/weather-location-form.js b/src/web/public/js/data-sources/weather-location-form.js new file mode 100644 index 0000000..404dea5 --- /dev/null +++ b/src/web/public/js/data-sources/weather-location-form.js @@ -0,0 +1,126 @@ +(function () { + 'use strict'; + + var label = document.getElementById('weather-label'); + var results = document.getElementById('weather-location-results'); + var latitude = document.getElementById('weather-latitude'); + var longitude = document.getElementById('weather-longitude'); + var latitudeValue = document.getElementById('weather-latitude-value'); + var longitudeValue = document.getElementById('weather-longitude-value'); + var timezone = document.getElementById('weather-timezone'); + var manualCoordinates = document.getElementById('weather-manual-coordinates'); + var timer; + var requestId = 0; + + if (!label || !results || !latitude || !longitude || !timezone) { + return; + } + + function clearResults() { + results.replaceChildren(); + results.classList.add('d-none'); + } + + function selectResult(result) { + label.value = result.label; + latitude.value = result.latitude; + longitude.value = result.longitude; + timezone.value = result.timezone; + latitudeValue.value = result.latitude; + longitudeValue.value = result.longitude; + latitude.disabled = true; + longitude.disabled = true; + latitude.removeAttribute('name'); + longitude.removeAttribute('name'); + latitudeValue.name = 'latitude'; + longitudeValue.name = 'longitude'; + manualCoordinates.classList.remove('d-none'); + clearResults(); + } + + function enableManualCoordinates() { + latitude.disabled = false; + longitude.disabled = false; + latitude.name = 'latitude'; + longitude.name = 'longitude'; + latitudeValue.removeAttribute('name'); + longitudeValue.removeAttribute('name'); + manualCoordinates.classList.add('d-none'); + } + + function renderResults(items) { + results.replaceChildren(); + if (!items.length) { + clearResults(); + return; + } + items.forEach(function (item) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'list-group-item list-group-item-action'; + button.textContent = item.label; + button.addEventListener('click', function () { selectResult(item); }); + results.appendChild(button); + }); + results.classList.remove('d-none'); + } + + label.addEventListener('input', function () { + enableManualCoordinates(); + window.clearTimeout(timer); + var query = label.value.trim(); + if (query.length < 2) { + clearResults(); + return; + } + timer = window.setTimeout(function () { + var currentRequestId = ++requestId; + fetch('/data-sources/weather/geocode?q=' + encodeURIComponent(query), { credentials: 'same-origin' }) + .then(function (response) { if (!response.ok) throw new Error('Lookup failed'); return response.json(); }) + .then(function (data) { if (currentRequestId === requestId) renderResults(data.results || []); }) + .catch(function () { if (currentRequestId === requestId) clearResults(); }); + }, 300); + }); + + manualCoordinates.addEventListener('click', enableManualCoordinates); + + if (latitude.value && longitude.value) { + selectResult({ label: label.value, latitude: latitude.value, longitude: longitude.value, timezone: timezone.value }); + } + + var refreshButton = document.querySelector('[data-weather-refresh-url]'); + if (refreshButton) { + refreshButton.addEventListener('click', function () { + refreshButton.disabled = true; + fetch(refreshButton.getAttribute('data-weather-refresh-url'), { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }) + .then(function (response) { + if (!response.ok) throw new Error('Refresh failed'); + window.location.assign(response.url); + }) + .catch(function () { refreshButton.disabled = false; }); + }); + } + + var forecastButtons = document.querySelectorAll('[data-weather-forecast-mode]'); + var dailyForecast = document.getElementById('weather-daily-forecast'); + var hourlyForecast = document.getElementById('weather-hourly-forecast'); + forecastButtons.forEach(function (button) { + button.addEventListener('click', function () { + var hourly = button.getAttribute('data-weather-forecast-mode') === 'hourly'; + dailyForecast.classList.toggle('d-none', hourly); + hourlyForecast.classList.toggle('d-none', !hourly); + forecastButtons.forEach(function (item) { + var selected = item === button; + item.classList.toggle('btn-primary', selected); + item.classList.toggle('btn-outline-secondary', !selected); + item.setAttribute('aria-pressed', selected ? 'true' : 'false'); + }); + }); + }); + + document.addEventListener('click', function (event) { + if (!results.contains(event.target) && event.target !== label) { + clearResults(); + } + }); +}()); diff --git a/src/web/public/js/regions/region-utils.js b/src/web/public/js/regions/region-utils.js index 25defa5..21857fd 100644 --- a/src/web/public/js/regions/region-utils.js +++ b/src/web/public/js/regions/region-utils.js @@ -88,6 +88,7 @@ h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], + i: ['class', 'style', 'aria-hidden'], img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], col: ['class', 'style', 'span', 'width'], colgroup: ['class', 'style', 'span'], @@ -159,6 +160,7 @@ h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], + i: ['class', 'style', 'aria-hidden'], li: ['class', 'style'], ol: ['class', 'style', 'start'], p: ['class', 'style'], diff --git a/src/web/public/js/regions/type/weather.js b/src/web/public/js/regions/type/weather.js new file mode 100644 index 0000000..74d34b5 --- /dev/null +++ b/src/web/public/js/regions/type/weather.js @@ -0,0 +1,206 @@ +// Weather region helpers for editor previews and defaults. + +(function () { + var registry = window.pulseRegionTypes; + var utils = window.pulseRegionUtils || {}; + var placeholderUtils = window.placeholderUtils || {}; + + function escapeHtml(value) { + return utils.escapeHtml ? utils.escapeHtml(value) : String(value == null ? '' : value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + function getLocationById(locationId, locations) { + return (Array.isArray(locations) ? locations : []).find(function (location) { + return Number(location.id) === Number(locationId); + }) || null; + } + + function iconForCode(code) { + var value = Number(code); + if (!Number.isFinite(value)) return 'bi-cloud'; + if (value === 0) return 'bi-sun'; + if (value <= 3) return 'bi-cloud-sun'; + if (value <= 48) return 'bi-cloud-fog'; + if (value <= 67 || value > 77 && value <= 82) return 'bi-cloud-rain'; + if (value <= 77) return 'bi-cloud-snow'; + return 'bi-cloud-lightning-rain'; + } + + function conditionForCode(code) { + var value = Number(code); + if (value === 0) return 'Clear sky'; + if (value <= 3) return 'Partly cloudy'; + if (value <= 48) return 'Foggy'; + if (value <= 67) return 'Rain'; + if (value <= 77) return 'Snow'; + if (value <= 82) return 'Showers'; + return 'Thunderstorm'; + } + + function getSnapshot(location) { + return location && location.responseJson && typeof location.responseJson === 'object' ? location.responseJson : null; + } + + function getDefaultStyle() { + return { font_family: 'Arial', font_size: 32, font_color: '#000000' }; + } + + function getPlaceholderFields(forecastMode) { + var fields = ['location_label', 'name', 'temp_unit', 'wind_unit', 'precip_unit']; + if (forecastMode === 'current') { + return fields.concat(['current.temp', 'current.feels_like', 'current.humidity', 'current.wind', 'current.precip', 'current.uv_index', 'current.cloud_cover', 'current.icon']); + } + var prefix = forecastMode === 'hourly' ? 'hourly' : 'daily'; + var fieldsByMode = forecastMode === 'hourly' ? ['time', 'code', 'temp', 'precip', 'wind', 'uv_index', 'cloud_cover'] : ['time', 'code', 'temp_max', 'temp_min', 'precip', 'wind', 'uv_index', 'cloud_cover', 'sunrise', 'sunset']; + var count = forecastMode === 'hourly' ? 24 : 7; + for (var index = 0; index < count; index += 1) { + fieldsByMode.forEach(function (field) { fields.push(prefix + '.' + index + '.' + field); }); + fields.push(prefix + '.' + index + '.icon'); + } + return fields; + } + + function renderLimitedPlaceholderChips(fields, visibleCount) { + if (visibleCount >= fields.length) return fields.map(function (field) { + return window.placeholderChips && typeof window.placeholderChips.renderChip === 'function' + ? window.placeholderChips.renderChip(field) + : '{{' + escapeHtml(field) + '}}'; + }).join(''); + var visibleFields = fields.slice(0, visibleCount); + var overflowFields = fields.slice(visibleFields.length); + var renderChip = function (field) { + return window.placeholderChips && typeof window.placeholderChips.renderChip === 'function' + ? window.placeholderChips.renderChip(field) + : '{{' + escapeHtml(field) + '}}'; + }; + var visibleMarkup = visibleFields.map(renderChip).join(''); + if (!overflowFields.length) return visibleMarkup; + return visibleMarkup + '
Show ' + escapeHtml(overflowFields.length) + ' more fields
' + overflowFields.map(renderChip).join('') + '
'; + } + + function normalizeWeatherExpression(expression) { + var value = String(expression || '').trim(); + var currentAliases = { + temp: 'current.temperature_2m', temp_unit: 'current.temperature_unit', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', code: 'current.weather_code', wind: 'current.wind_speed_10m', wind_unit: 'current.wind_speed_unit', precip: 'current.precipitation', precip_unit: 'current.precipitation_unit', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' + }; + var globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' }; + if (globalAliases[value]) return globalAliases[value]; + var currentField = value.replace(/^current\./, '').match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); + if (currentField && currentAliases[currentField[1]]) return currentAliases[currentField[1]] + (currentField[2] || ''); + var indexed = value.match(/^(daily|hourly)\.(\d+)\.(.+)$/); + if (!indexed) return value; + var prefix = indexed[1]; + var index = indexed[2]; + var fieldMatch = indexed[3].match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); + var field = fieldMatch ? fieldMatch[1] : indexed[3]; + var aliases = prefix === 'daily' ? { time: 'time', code: 'weather_code', temp_max: 'temperature_2m_max', temp_min: 'temperature_2m_min', precip: 'precipitation_sum', wind: 'wind_speed_10m_max', uv_index: 'uv_index_max', cloud_cover: 'cloud_cover_mean', sunrise: 'sunrise', sunset: 'sunset', icon: 'weather_code' } : { time: 'time', code: 'weather_code', temp: 'temperature_2m', precip: 'precipitation', wind: 'wind_speed_10m', uv_index: 'uv_index', cloud_cover: 'cloud_cover', icon: 'weather_code' }; + if (!Object.prototype.hasOwnProperty.call(aliases, field)) return value; + return field === 'icon' ? prefix + '.' + aliases[field] + '.' + index + '.icon' + (fieldMatch[2] || '') : prefix + '.' + aliases[field] + '.' + index + (fieldMatch[2] || ''); + } + + function withWeatherUnits(snapshot, location) { + var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' }); + var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C'; + var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h'; + var precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm'; + data.current = Object.assign({}, snapshot.current || {}, { temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit }); + data.daily = Object.assign({}, snapshot.daily || {}, { temperature_unit: temperatureUnit }); + data.hourly = Object.assign({}, snapshot.hourly || {}, { temperature_unit: temperatureUnit }); + return data; + } + + function substituteVariables(html, value) { + 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(); + var normalizedExpression = normalizeWeatherExpression(rawExpression); + var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/); + if (iconMatch) { + var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, ''); + var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?\)$/); + 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 icon = ''; + return width ? '' + icon + '' : icon; + } + return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone }))); + }); + } + + function renderTransformInfo(regionId) { + var offcanvasId = 'weather-placeholder-info-' + regionId; + return '' + + '
' + + '

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.

  • {{current.icon}}
  • {{current.icon(48,48)}}
  • {{daily.0.icon(32,24)}}
' + + (window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') + + (window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') + + '
' + + '
'; + } + + function renderPreview(region, content, context) { + var locations = context && context.weatherLocations ? context.weatherLocations : []; + var location = getLocationById(content && content.weather_location_id, locations); + var snapshot = getSnapshot(location); + var value = String(content && content.value || ''); + if (!location || !snapshot) return '
Select a weather location with cached data
'; + var weatherData = withWeatherUnits(snapshot, location); + 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)) + '
'; + } + var current = snapshot.current || {}; + var daily = snapshot.daily || {}; + var code = current.weather_code; + var days = (daily.time || []).slice(0, 7).map(function (day, index) { + return '
' + escapeHtml(index === 0 ? 'Today' : String(day).slice(5)) + '' + escapeHtml(daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] + '°' : '-') + '
'; + }).join(''); + return '
' + escapeHtml(location.location_label || location.name) + '
' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°
' + escapeHtml(conditionForCode(code)) + '
' + days + '
'; + } + + function renderEditorCard(context) { + var region = context.region; + var current = context.current || {}; + var locations = Array.isArray(context.weatherLocations) ? context.weatherLocations : []; + var options = locations.map(function (location) { + return ''; + }).join(''); + var currentValue = current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''; + var forecastMode = ['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily'; + var placeholderFields = getPlaceholderFields(forecastMode); + var visiblePlaceholderCount = forecastMode === 'current' ? placeholderFields.length : 5 + (forecastMode === 'hourly' ? 8 : 11); + var chips = renderLimitedPlaceholderChips(placeholderFields, visiblePlaceholderCount); + return '
' + escapeHtml(region.label) + '
Weather
Available placeholders ' + renderTransformInfo(region.id) + '
' + chips + '
'; + } + + document.addEventListener('change', function (event) { + var target = event.target; + if (!target || !target.matches || !target.matches('select[name^="region_weather_forecast_mode_"]')) return; + var card = target.closest('[data-region-id]'); + var chipContainer = card && card.querySelector('[data-placeholder-chips]'); + if (!chipContainer || !window.placeholderChips || typeof window.placeholderChips.renderChips !== 'function') return; + var forecastMode = target.value === 'hourly' ? 'hourly' : target.value === 'current' ? 'current' : 'daily'; + var placeholderFields = getPlaceholderFields(forecastMode); + var visiblePlaceholderCount = forecastMode === 'current' ? placeholderFields.length : 5 + (forecastMode === 'hourly' ? 8 : 11); + chipContainer.innerHTML = renderLimitedPlaceholderChips(placeholderFields, visiblePlaceholderCount); + }); + + function buildEditorCardContext(context) { + return { region: context.region, current: context.current || {}, weatherLocations: context.weatherLocations || [] }; + } + + function buildPreviewRenderContext(region, card, existingContent, locations) { + var current = existingContent[region.region_key] || {}; + 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 textInput = card && card.querySelector('input[name="region_text_' + region.id + '"]'); + 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 || [] }; + } + + registry.register('weather', { label: 'Weather', getDefaultStyle: getDefaultStyle, getDefaultRegionSize: function () { return { width: 720, height: 260 }; }, renderPreview: renderPreview, renderEditorCard: renderEditorCard, buildEditorCardContext: buildEditorCardContext, buildPreviewRenderContext: buildPreviewRenderContext }); +}()); diff --git a/src/web/public/js/slides/slide-form-editor.js b/src/web/public/js/slides/slide-form-editor.js index 39c920b..4119a2e 100644 --- a/src/web/public/js/slides/slide-form-editor.js +++ b/src/web/public/js/slides/slide-form-editor.js @@ -617,6 +617,8 @@ export function createSlideFormEditorController(options) { remove_script_host: false, convert_urls: true, paste_data_images: false, + paste_block_drop: true, + resize: false, automatic_uploads: true, images_file_types: imageUploadFileTypes, images_upload_handler: uploadEditorImage, diff --git a/src/web/public/js/slides/slide-form-regions.js b/src/web/public/js/slides/slide-form-regions.js index 3f4273b..fa0dcdd 100644 --- a/src/web/public/js/slides/slide-form-regions.js +++ b/src/web/public/js/slides/slide-form-regions.js @@ -9,6 +9,7 @@ export function createSlideFormRegionHelpers(options) { var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : []; var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : []; var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : []; + var weatherLocations = Array.isArray(settings.weatherLocations) ? settings.weatherLocations : []; var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; }; var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {}; var placeholderUtils = window.placeholderUtils || {}; @@ -537,7 +538,7 @@ export function createSlideFormRegionHelpers(options) { if (module && typeof module.buildEditorCardContext === 'function') { return module.buildEditorCardContext({ region: region, - current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region), + current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' || region.region_type === 'weather' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region), currentDuration: getCurrentRegionVideoDuration(region), regionRatio: reduceAspectRatio(region.width, region.height), uploadMaxLabel: uploadMaxLabel, @@ -546,7 +547,7 @@ export function createSlideFormRegionHelpers(options) { uploadVideoMaxBytes: uploadVideoMaxBytes, uploadMimeTypes: uploadMimeTypes, disableAudio: (existingContent[region.region_key] || {}).disable_audio, - config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region), + config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: (existingContent[region.region_key] || {}).weather_location_id || '' } : getCurrentRssConfig(region), style: getCurrentTextStyle(region), fontSize: getCurrentTextStyle(region).font_size, feedOptions: rssFeeds.map(function (feed) { @@ -568,8 +569,9 @@ export function createSlideFormRegionHelpers(options) { ? getRssFieldList(getCurrentRssConfig(region).feed_id) : [], sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path), - timetableGroups: timetableGroups - }, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources); + timetableGroups: timetableGroups, + weatherLocations: weatherLocations + }, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources); } var textStyle = getCurrentTextStyle(region); @@ -580,7 +582,7 @@ export function createSlideFormRegionHelpers(options) { return { region: region, - current: region.region_type === 'rss' || region.region_type === 'api' ? currentContent : getCurrentRegionValue(region), + current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'weather' ? currentContent : getCurrentRegionValue(region), currentDuration: getCurrentRegionVideoDuration(region), regionRatio: reduceAspectRatio(region.width, region.height), uploadMaxLabel: uploadMaxLabel, @@ -589,7 +591,7 @@ export function createSlideFormRegionHelpers(options) { uploadVideoMaxBytes: uploadVideoMaxBytes, uploadMimeTypes: uploadMimeTypes, disableAudio: currentContent.disable_audio, - config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig, + config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: currentContent.weather_location_id || '' } : rssConfig, style: textStyle, fontSize: textStyle.font_size, feedOptions: rssFeeds.map(function (feed) { @@ -609,7 +611,8 @@ export function createSlideFormRegionHelpers(options) { ? getRssFieldList(rssConfig.feed_id) : [], sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath), - timetableGroups: timetableGroups + timetableGroups: timetableGroups, + weatherLocations: weatherLocations }; } @@ -745,7 +748,7 @@ export function createSlideFormRegionHelpers(options) { function getPreviewRegionContent(card, region) { var module = getRegionTypeModule(region.region_type); if (module && typeof module.buildPreviewRenderContext === 'function') { - return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources); + return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources); } var current = existingContent[region.region_key]; @@ -813,7 +816,7 @@ export function createSlideFormRegionHelpers(options) { function buildPreviewRenderContext(region, card) { var module = getRegionTypeModule(region.region_type); if (module && typeof module.buildPreviewRenderContext === 'function') { - return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources); + return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources); } var textStyle = getCurrentTextStyle(region); diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 79e5759..fbe1041 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -30,6 +30,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : []; var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : []; var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : []; + var weatherLocations = Array.isArray(slideEditorData.weatherLocations) ? slideEditorData.weatherLocations : []; var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim(); var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; var existingContent = slideEditorData.existingContent || {}; @@ -139,6 +140,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, + weatherLocations: weatherLocations, defaultFontSize: DEFAULT_FONT_SIZE, uploadMaxLabel: uploadMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel, diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 58318b3..cb053a5 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { loadFontLibrary } = require('#src/web/lib/media/font-library'); const { fetchAppSettings } = require('#src/data/app-settings'); +const { convertWeatherSnapshot } = require('#src/data/weather-units'); const { buildAuditChanges } = require('#src/data/audit-log'); const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); @@ -65,6 +66,7 @@ module.exports = function registerContentRoutes(app, deps) { const rssData = await common.fetchRssFeedsData(pool); const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] }; const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] }; + const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] }; const appSettings = await fetchAppSettings(pool); const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) { return Object.assign({}, source, { @@ -80,10 +82,16 @@ module.exports = function registerContentRoutes(app, deps) { }) }); })); - return Object.assign(data, rssData, apiData, timetableData, { + const weatherLocations = Array.isArray(weatherData.weatherLocations) ? weatherData.weatherLocations.map(function (location) { + const responseJson = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(location.last_response_json) : null; + return Object.assign({}, location, { responseJson: convertWeatherSnapshot(responseJson, { temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' }) }); + }) : []; + + return Object.assign(data, rssData, apiData, timetableData, weatherData, { rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableData.timetableGroups || [], + weatherLocations: weatherLocations, fontLibrary: loadFontLibrary(deps.uploadDir), uploadLimits: { imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES, diff --git a/src/web/routes/admin/data-sources.js b/src/web/routes/admin/data-sources.js index 74a5256..798f043 100644 --- a/src/web/routes/admin/data-sources.js +++ b/src/web/routes/admin/data-sources.js @@ -15,13 +15,16 @@ module.exports = function registerDataSourceRoutes(app, deps) { return res.redirect('/login'); } - if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) { + if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read', 'weather.read'])) { if (hasAnyPermission(req.currentUser, ['timetables.read'])) { return res.redirect('/data-sources/timetables'); } if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) { return res.redirect('/data-sources/rss-feeds'); } + if (hasAnyPermission(req.currentUser, ['weather.read'])) { + return res.redirect('/data-sources/weather'); + } return res.redirect('/data-sources/api-sources'); } diff --git a/src/web/routes/data-sources/api-sources/form-view-model.js b/src/web/routes/data-sources/api-sources/form-view-model.js index b9d9703..d3696ea 100644 --- a/src/web/routes/data-sources/api-sources/form-view-model.js +++ b/src/web/routes/data-sources/api-sources/form-view-model.js @@ -3,6 +3,7 @@ function buildDefaultApiSource() { return { id: null, + enabled: 1, name: '', apiUrl: '', requestMethod: 'GET', diff --git a/src/web/routes/data-sources/api-sources/routes.js b/src/web/routes/data-sources/api-sources/routes.js index b182cd6..b059e25 100644 --- a/src/web/routes/data-sources/api-sources/routes.js +++ b/src/web/routes/data-sources/api-sources/routes.js @@ -288,6 +288,19 @@ module.exports = function registerApiSourceRoutes(app, deps) { return res.status(404).send('API source not found'); } + if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') { + const enabled = apiSource.enabled === 0 || apiSource.enabled === false ? 1 : 0; + await connection.query('UPDATE i_api_sources SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), apiSource.id]); + if (enabled) { + dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, apiSource.name, apiSource.update_interval_value, apiSource.update_interval_unit, function () { + return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, getAuditUserId(req)); + }); + } else { + dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id); + } + return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(enabled ? 'API source enabled.' : 'API source disabled.')); + } + const payload = common.buildApiSourcePayload(req, apiSource); if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) { return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.')); @@ -295,35 +308,21 @@ module.exports = function registerApiSourceRoutes(app, deps) { const actorId = getAuditUserId(req); await connection.beginTransaction(); await connection.query( - 'UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, token_url = ?, token_request_body_json = ?, token_response_path = ?, token_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', - [payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id] + 'UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, token_url = ?, token_request_body_json = ?, token_response_path = ?, token_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ?', + [payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, actorId, apiSource.id] ); await connection.commit(); - dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { - return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId); - }); - const message = 'API source updated. Refresh is running in the background.'; - const refreshTask = await backgroundTaskQueue.enqueueTask({ - key: 'api-source-refresh:' + apiSource.id, - title: 'API source refresh', - category: 'data-source', - taskType: 'data-source-refresh', - payload: { - sourceType: 'api-source', - sourceId: apiSource.id, - sourceName: payload.name, - actorId: actorId - }, - metadata: { - sourceType: 'api-source', - sourceId: apiSource.id, - sourceName: payload.name - } - }); - redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), { + if (apiSource.enabled === 0 || apiSource.enabled === false) { + dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id); + } else { + dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId); + }); + } + redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit', { closeUrl: '/data-sources/api-sources', newUrl: '/data-sources/api-sources/new', - message: message + message: 'API source updated.' }); } catch (error) { try { @@ -337,6 +336,18 @@ module.exports = function registerApiSourceRoutes(app, deps) { } }); + app.post('/data-sources/api-sources/:id/refresh', requirePermission('api-sources.allow'), async function (req, res, next) { + try { + const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); + if (!apiSource) return res.status(404).send('API source not found'); + if (apiSource.enabled === 0 || apiSource.enabled === false) { + return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('API source is disabled.')); + } + await backgroundTaskQueue.enqueueTask({ key: 'api-source-refresh:' + apiSource.id, title: 'API source refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'api-source', sourceId: apiSource.id, sourceName: apiSource.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'api-source', sourceId: apiSource.id, sourceName: apiSource.name } }); + res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('API source refresh queued.')); + } catch (error) { next(error); } + }); + app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) { try { const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); diff --git a/src/web/routes/data-sources/rss-feeds/form-view-model.js b/src/web/routes/data-sources/rss-feeds/form-view-model.js index 4f69126..76e382a 100644 --- a/src/web/routes/data-sources/rss-feeds/form-view-model.js +++ b/src/web/routes/data-sources/rss-feeds/form-view-model.js @@ -3,6 +3,7 @@ function buildDefaultRssFeed() { return { id: null, + enabled: 1, name: '', feedUrl: '', updateIntervalValue: 60, diff --git a/src/web/routes/data-sources/rss-feeds/routes.js b/src/web/routes/data-sources/rss-feeds/routes.js index 8dbe5e9..a2d0647 100644 --- a/src/web/routes/data-sources/rss-feeds/routes.js +++ b/src/web/routes/data-sources/rss-feeds/routes.js @@ -249,6 +249,19 @@ module.exports = function registerRssFeedRoutes(app, deps) { return res.status(404).send('RSS feed not found'); } + if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') { + const enabled = rssFeed.enabled === 0 || rssFeed.enabled === false ? 1 : 0; + await connection.query('UPDATE i_rss_feeds SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), rssFeed.id]); + if (enabled) { + dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, rssFeed.name, rssFeed.update_interval_value, rssFeed.update_interval_unit, function () { + return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, getAuditUserId(req)); + }); + } else { + dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id); + } + return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(enabled ? 'RSS feed enabled.' : 'RSS feed disabled.')); + } + const payload = common.buildRssFeedPayload(req, rssFeed); if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) { return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.')); @@ -260,31 +273,17 @@ module.exports = function registerRssFeedRoutes(app, deps) { [payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id] ); await connection.commit(); - dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { - return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId); - }); - const message = 'RSS feed updated. Refresh is running in the background.'; - const refreshTask = await backgroundTaskQueue.enqueueTask({ - key: 'rss-feed-refresh:' + rssFeed.id, - title: 'RSS feed refresh', - category: 'data-source', - taskType: 'data-source-refresh', - payload: { - sourceType: 'rss-feed', - sourceId: rssFeed.id, - sourceName: payload.name, - actorId: actorId - }, - metadata: { - sourceType: 'rss-feed', - sourceId: rssFeed.id, - sourceName: payload.name - } - }); - redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), { + if (rssFeed.enabled === 0 || rssFeed.enabled === false) { + dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id); + } else { + dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId); + }); + } + redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit', { closeUrl: '/data-sources/rss-feeds', newUrl: '/data-sources/rss-feeds/new', - message: message + message: 'RSS feed updated.' }); } catch (error) { try { @@ -298,6 +297,18 @@ module.exports = function registerRssFeedRoutes(app, deps) { } }); + app.post('/data-sources/rss-feeds/:id/refresh', requirePermission('rss-feeds.allow'), async function (req, res, next) { + try { + const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); + if (!rssFeed) return res.status(404).send('RSS feed not found'); + if (rssFeed.enabled === 0 || rssFeed.enabled === false) { + return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('RSS feed is disabled.')); + } + await backgroundTaskQueue.enqueueTask({ key: 'rss-feed-refresh:' + rssFeed.id, title: 'RSS feed refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'rss-feed', sourceId: rssFeed.id, sourceName: rssFeed.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'rss-feed', sourceId: rssFeed.id, sourceName: rssFeed.name } }); + res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('RSS feed refresh queued.')); + } catch (error) { next(error); } + }); + app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) { try { const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); diff --git a/src/web/routes/data-sources/weather.js b/src/web/routes/data-sources/weather.js new file mode 100644 index 0000000..e068c84 --- /dev/null +++ b/src/web/routes/data-sources/weather.js @@ -0,0 +1,172 @@ +// Weather location source CRUD routes. + +const { buildPagination } = require('../../lib/pagination'); +const renderWeatherLocationsPage = require('./weather/list'); +const renderWeatherLocationAddPage = require('./weather/add'); +const renderWeatherLocationEditPage = require('./weather/edit'); +const { fetchAppSettings } = require('../../../data/app-settings'); + +async function getWeatherLocationUsageIds(pool, common) { + const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const weatherLocationIds = new Set(); + slides.forEach(function (slide) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null; + if (!content || typeof content !== 'object') return; + (function walk(value) { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + if (Object.prototype.hasOwnProperty.call(value, 'weather_location_id')) { + const locationId = Number(value.weather_location_id); + if (Number.isFinite(locationId)) weatherLocationIds.add(locationId); + } + Object.keys(value).forEach(function (key) { walk(value[key]); }); + }(content)); + }); + return weatherLocationIds; +} + +async function isWeatherLocationInUse(pool, common, locationId) { + const usageIds = await getWeatherLocationUsageIds(pool, common); + return usageIds.has(Number(locationId)); +} + +module.exports = function registerWeatherRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const getAuditUserId = deps.getAuditUserId; + const redirectAfterSave = deps.redirectAfterSave; + const dataSourceTasks = deps.dataSourceTasks; + const backgroundTaskQueue = deps.backgroundTaskQueue; + const requirePermission = deps.requirePermission; + const listPageSize = 25; + async function getProviderAvailability() { + const settings = await fetchAppSettings(pool); + return { openMeteo: true, pirateWeather: Boolean(String(settings['weather.pirate_weather_api_key'] || '').trim()) }; + } + + app.get('/data-sources/weather', requirePermission('weather.read'), async function (req, res, next) { + try { + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchWeatherLocationsPage(pool, page, listPageSize, search, sort, direction); + const usageIds = await getWeatherLocationUsageIds(pool, common); + res.send(renderWeatherLocationsPage({ + weatherLocations: data.rows.map(function (location) { return Object.assign({}, location, { inUse: usageIds.has(Number(location.id)) }); }), + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search, sort, direction }, listPageSize, 'weather locations', 'weather location pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { next(error); } + }); + + app.get('/data-sources/weather/new', requirePermission('weather.create'), function (req, res) { + getProviderAvailability().then(function (providerAvailability) { + res.send(renderWeatherLocationAddPage({}, req.query.message ? String(req.query.message) : '', req.currentUser, { providerAvailability: providerAvailability })); + }).catch(next); + }); + + app.get('/data-sources/weather/geocode', requirePermission('weather.read'), async function (req, res, next) { + try { + const results = await common.fetchWeatherLocationSuggestions(req.query.q); + res.json({ results: results }); + } catch (error) { next(error); } + }); + + app.get('/data-sources/weather/:id/edit', requirePermission('weather.update'), async function (req, res, next) { + try { + const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); + if (!location) return res.status(404).send('Weather location not found'); + const providerAvailability = await getProviderAvailability(); + const inUse = await isWeatherLocationInUse(pool, common, location.id); + res.send(renderWeatherLocationEditPage(Object.assign({}, location, { inUse: inUse }), req.query.message ? String(req.query.message) : '', req.currentUser, { providerAvailability: providerAvailability })); + } catch (error) { next(error); } + }); + + app.post('/data-sources/weather', requirePermission('weather.create'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const payload = common.buildWeatherLocationPayload(req, null); + if (await common.fetchDuplicateName(pool, 'i_weather_locations', payload.name)) { + return res.redirect('/data-sources/weather/new?message=' + encodeURIComponent('A weather location with that name already exists.')); + } + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + const [result] = await connection.query('INSERT INTO i_weather_locations (name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, update_interval_value, update_interval_unit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [payload.name, payload.locationLabel, payload.latitude, payload.longitude, payload.timezone, payload.provider, payload.temperatureUnit, payload.windUnit, payload.precipitationUnit, payload.updateIntervalValue, payload.updateIntervalUnit, actorId, actorId]); + await connection.commit(); + dataSourceTasks.registerRecurringRefresh('weather-location', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshWeatherLocationInBackground(result.insertId, null); + }); + await backgroundTaskQueue.enqueueTask({ key: 'weather-location-refresh:' + result.insertId, title: 'Weather location refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'weather-location', sourceId: result.insertId, sourceName: payload.name, actorId: actorId }, metadata: { sourceType: 'weather-location', sourceId: result.insertId, sourceName: payload.name } }); + redirectAfterSave(req, res, '/data-sources/weather/' + result.insertId + '/edit', { closeUrl: '/data-sources/weather', newUrl: '/data-sources/weather/new', message: 'Weather location created.' }); + } catch (error) { + try { await connection.rollback(); } catch (_rollbackError) { } + next(error); + } finally { connection.release(); } + }); + + app.post('/data-sources/weather/:id', requirePermission('weather.update'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); + if (!location) return res.status(404).send('Weather location not found'); + if (String(req.body && req.body.data_source_action || '').trim() === 'toggle') { + const enabled = location.enabled === 0 || location.enabled === false ? 1 : 0; + await connection.query('UPDATE i_weather_locations SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), location.id]); + if (enabled) { + dataSourceTasks.registerRecurringRefresh('weather-location', location.id, location.name, location.update_interval_value, location.update_interval_unit, function () { + return dataSourceTasks.refreshWeatherLocationInBackground(location.id, getAuditUserId(req)); + }); + } else { + dataSourceTasks.removeRecurringRefresh('weather-location', location.id); + } + return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent(enabled ? 'Weather location enabled.' : 'Weather location disabled.')); + } + const payload = common.buildWeatherLocationPayload(req, location); + if (await common.fetchDuplicateName(pool, 'i_weather_locations', payload.name, location.id)) { + return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('A weather location with that name already exists.')); + } + await connection.beginTransaction(); + await connection.query('UPDATE i_weather_locations SET name = ?, location_label = ?, latitude = ?, longitude = ?, timezone = ?, provider = ?, temperature_unit = ?, wind_unit = ?, precipitation_unit = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ?', [payload.name, payload.locationLabel, payload.latitude, payload.longitude, payload.timezone, payload.provider, payload.temperatureUnit, payload.windUnit, payload.precipitationUnit, payload.updateIntervalValue, payload.updateIntervalUnit, getAuditUserId(req), location.id]); + await connection.commit(); + if (location.enabled === 0 || location.enabled === false) { + dataSourceTasks.removeRecurringRefresh('weather-location', location.id); + } else { + dataSourceTasks.registerRecurringRefresh('weather-location', location.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshWeatherLocationInBackground(location.id, null); + }); + } + redirectAfterSave(req, res, '/data-sources/weather/' + location.id + '/edit', { closeUrl: '/data-sources/weather', newUrl: '/data-sources/weather/new', message: 'Weather location updated.' }); + } catch (error) { + try { await connection.rollback(); } catch (_rollbackError) { } + next(error); + } finally { connection.release(); } + }); + + app.post('/data-sources/weather/:id/refresh', requirePermission('weather.allow'), async function (req, res, next) { + try { + const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); + if (!location) return res.status(404).send('Weather location not found'); + if (location.enabled === 0 || location.enabled === false) { + return res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('Weather location is disabled.')); + } + await backgroundTaskQueue.enqueueTask({ key: 'weather-location-refresh:' + location.id, title: 'Weather location refresh', category: 'data-source', taskType: 'data-source-refresh', payload: { sourceType: 'weather-location', sourceId: location.id, sourceName: location.name, actorId: getAuditUserId(req) }, metadata: { sourceType: 'weather-location', sourceId: location.id, sourceName: location.name } }); + res.redirect('/data-sources/weather/' + location.id + '/edit?message=' + encodeURIComponent('Weather refresh queued.')); + } catch (error) { next(error); } + }); + + app.post('/data-sources/weather/:id/delete', requirePermission('weather.delete'), async function (req, res, next) { + try { + const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); + if (!location) return res.status(404).send('Weather location not found'); + if (await isWeatherLocationInUse(pool, common, location.id)) { + return res.redirect('/data-sources/weather?message=' + encodeURIComponent('This weather location is still used by one or more slides.')); + } + await pool.query('DELETE FROM i_weather_locations WHERE id = ?', [location.id]); + dataSourceTasks.removeRecurringRefresh('weather-location', location.id); + res.redirect('/data-sources/weather?message=' + encodeURIComponent('Weather location deleted.')); + } catch (error) { next(error); } + }); +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/weather/add.js b/src/web/routes/data-sources/weather/add.js new file mode 100644 index 0000000..3588045 --- /dev/null +++ b/src/web/routes/data-sources/weather/add.js @@ -0,0 +1,3 @@ +const { renderView } = require('../../../view'); +const buildWeatherLocationFormViewModel = require('./form-view-model'); +module.exports = function renderWeatherLocationAddPage(location, message, currentUser, options) { return renderView('data-sources/weather/form', buildWeatherLocationFormViewModel(location, message, currentUser, false, options)); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/weather/edit.js b/src/web/routes/data-sources/weather/edit.js new file mode 100644 index 0000000..83467a5 --- /dev/null +++ b/src/web/routes/data-sources/weather/edit.js @@ -0,0 +1,3 @@ +const { renderView } = require('../../../view'); +const buildWeatherLocationFormViewModel = require('./form-view-model'); +module.exports = function renderWeatherLocationEditPage(location, message, currentUser, options) { return renderView('data-sources/weather/form', buildWeatherLocationFormViewModel(location, message, currentUser, true, options)); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/weather/form-view-model.js b/src/web/routes/data-sources/weather/form-view-model.js new file mode 100644 index 0000000..eaa8b2f --- /dev/null +++ b/src/web/routes/data-sources/weather/form-view-model.js @@ -0,0 +1,69 @@ +const { convertWeatherSnapshot } = require('../../../../data/weather-units'); + +function buildDefaultWeatherLocation() { + return { id: null, enabled: 1, name: '', location_label: '', latitude: '', longitude: '', timezone: 'Europe/London', provider: 'open-meteo', temperature_unit: 'celsius', wind_unit: 'kmh', precipitation_unit: 'mm', update_interval_value: 30, update_interval_unit: 'minutes' }; +} + +function weatherIconForCode(code) { + const value = Number(code); + if (!Number.isFinite(value)) return 'bi-cloud'; + if (value === 0) return 'bi-sun'; + if (value <= 3) return 'bi-cloud-sun'; + if (value <= 48) return 'bi-cloud-fog'; + if (value <= 67) return 'bi-cloud-rain'; + if (value <= 77) return 'bi-cloud-snow'; + if (value <= 82) return 'bi-cloud-rain'; + return 'bi-cloud-lightning-rain'; +} + +function buildWeatherPreview(location) { + const preview = { + hasSnapshot: false, + forecast: [{ day: 'Today', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Tomorrow', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 3', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 4', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 5', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 6', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }, { day: 'Day 7', condition: '-', icon: 'bi-cloud', high: '-', low: '-', rain: '-' }], + hourly: Array.from({ length: 24 }, function () { return { day: '--:--', icon: 'bi-cloud', temperature: '-', rain: '-' }; }), + }; + let snapshot; + try { + snapshot = JSON.parse(location && location.last_response_json || ''); + } catch (_error) { + return preview; + } + if (!snapshot || typeof snapshot !== 'object') return preview; + const displaySnapshot = convertWeatherSnapshot(snapshot, { temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' }); + const current = displaySnapshot.current || {}; + const daily = displaySnapshot.daily || {}; + const hourly = displaySnapshot.hourly || {}; + const weatherCode = Number(current.weather_code); + preview.hasSnapshot = true; + preview.location = location.location_label; + preview.temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C'; + preview.windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h'; + preview.precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm'; + preview.temperature = current.temperature_2m ?? current.temperature ?? '-'; + preview.feelsLike = current.apparent_temperature ?? current.feels_like ?? '-'; + preview.icon = weatherIconForCode(weatherCode); + preview.condition = weatherCode === 0 ? 'Clear sky' : weatherCode <= 3 ? 'Partly cloudy' : weatherCode <= 48 ? 'Foggy' : weatherCode <= 67 ? 'Rain' : weatherCode <= 77 ? 'Snow' : weatherCode <= 82 ? 'Showers' : 'Thunderstorm'; + preview.humidity = current.relative_humidity_2m === undefined ? '-' : current.relative_humidity_2m + '%'; + preview.wind = current.wind_speed_10m ?? current.windSpeed ?? '-'; + preview.precipitation = current.precipitation ?? '-'; + preview.uvIndex = current.uv_index ?? '-'; + preview.cloudCover = current.cloud_cover ?? '-'; + preview.sunrise = daily.sunrise && daily.sunrise[0] ? String(daily.sunrise[0]).slice(11, 16) : '-'; + preview.sunset = daily.sunset && daily.sunset[0] ? String(daily.sunset[0]).slice(11, 16) : '-'; + preview.fetchedAt = location.last_pulled_at || ''; + preview.forecast = (daily.time || []).slice(0, 7).map(function (day, index) { + const code = Number(daily.weather_code && daily.weather_code[index]); + return { day: index === 0 ? 'Today' : day, condition: code <= 3 ? 'Partly cloudy' : code <= 67 ? 'Rain' : code <= 77 ? 'Snow' : 'Showers', icon: weatherIconForCode(code), high: daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] : '-', low: daily.temperature_2m_min && daily.temperature_2m_min[index] !== undefined ? daily.temperature_2m_min[index] : '-', rain: daily.precipitation_sum && daily.precipitation_sum[index] !== undefined ? daily.precipitation_sum[index] : '-', wind: daily.wind_speed_10m_max && daily.wind_speed_10m_max[index] !== undefined ? daily.wind_speed_10m_max[index] : '-', uvIndex: daily.uv_index_max && daily.uv_index_max[index] !== undefined ? daily.uv_index_max[index] : '-' }; + }); + preview.hourly = (hourly.time || []).slice(0, 24).map(function (time, index) { + return { day: String(time).slice(11, 16), icon: weatherIconForCode(hourly.weather_code && hourly.weather_code[index]), temperature: hourly.temperature_2m && hourly.temperature_2m[index] !== undefined ? hourly.temperature_2m[index] : '-', rain: hourly.precipitation_probability && hourly.precipitation_probability[index] !== undefined ? hourly.precipitation_probability[index] + '%' : '-' }; + }); + return preview; +} + +module.exports = function buildWeatherLocationFormViewModel(location, message, currentUser, isEdit, options) { + const weatherLocation = Object.assign(buildDefaultWeatherLocation(), location || {}); + const providerAvailability = options && options.providerAvailability ? options.providerAvailability : { openMeteo: true, pirateWeather: false }; + const formAction = isEdit ? '/data-sources/weather/' + weatherLocation.id : '/data-sources/weather'; + return { title: isEdit ? 'Edit weather location' : 'Add weather location', active: 'weather', message, currentUser: currentUser || null, weatherLocation, weatherPreview: buildWeatherPreview(weatherLocation), providerAvailability, isEdit: Boolean(isEdit), formAction: formAction, formAttrs: isEdit ? 'data-async-save data-async-save-close-url="/data-sources/weather" data-async-save-new-url="/data-sources/weather/new"' : 'data-async-save data-async-save-new-redirect="response-url" data-async-save-close-url="/data-sources/weather" data-async-save-new-url="/data-sources/weather/new"', cancelUrl: '/data-sources/weather', deleteUrl: isEdit ? '/data-sources/weather/' + weatherLocation.id + '/delete' : '', deleteDisabled: !isEdit || Boolean(weatherLocation.inUse), showSaveSecondaryActions: true, scripts: ['js/data-sources/weather-location-form.js'] }; +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/weather/list.js b/src/web/routes/data-sources/weather/list.js new file mode 100644 index 0000000..6cce1e5 --- /dev/null +++ b/src/web/routes/data-sources/weather/list.js @@ -0,0 +1,16 @@ +const { renderView } = require('../../../view'); + +function formatInterval(value, unit) { + const amount = Math.max(1, Number(value) || 0); + const normalized = String(unit || 'minutes'); + return 'Every ' + amount + ' ' + normalized.replace(/s$/, '') + (amount === 1 ? '' : 's'); +} + +module.exports = function renderWeatherLocationsPage(data, message, currentUser) { + return renderView('data-sources/weather/list', { + title: 'Weather locations', active: 'weather', message, currentUser: currentUser || null, + weatherLocations: (data.weatherLocations || []).map(function (location) { + return Object.assign({}, location, { intervalLabel: formatInterval(location.update_interval_value, location.update_interval_unit) }); + }), pagination: data.pagination || null + }); +}; \ No newline at end of file diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 5bc2c6a..7f468e9 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -13,6 +13,7 @@ const registerRootDataSourceRoutes = require('./admin/data-sources'); const registerApiSourceRoutes = require('./data-sources/api-sources/routes'); const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes'); const registerTimetableRoutes = require('./data-sources/timetables/routes'); +const registerWeatherRoutes = require('./data-sources/weather'); const registerSettingsRoutes = require('./settings/background-tasks'); const registerFontRoutes = require('./settings/fonts'); const registerSettingsPageRoutes = require('./settings/routes'); @@ -282,6 +283,16 @@ function registerSettingsAndContentRoutes(app, deps) { requirePermission: deps.requirePermission }); + registerWeatherRoutes(app, { + pool: deps.pool, + common: deps.common, + getAuditUserId: deps.getAuditUserId, + redirectAfterSave: deps.redirectAfterSave, + dataSourceTasks: deps.dataSourceTasks, + backgroundTaskQueue: deps.backgroundTaskQueue, + requirePermission: deps.requirePermission + }); + registerSettingsRoutes(app, { pages: deps.pages, backgroundTaskQueue: deps.backgroundTaskQueue, diff --git a/src/web/routes/settings/routes.js b/src/web/routes/settings/routes.js index 9495a7e..f3be513 100644 --- a/src/web/routes/settings/routes.js +++ b/src/web/routes/settings/routes.js @@ -58,9 +58,18 @@ module.exports = function registerSettingsPageRoutes(app, deps) { ], audit: ['audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days'], media: ['uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types'], + weather: ['weather.open_meteo_api_key', 'weather.pirate_weather_api_key'], icons: ['announcements.suggested_icons'] }; + function maskApiKey(value) { + const key = String(value || ''); + if (!key) { + return ''; + } + return key.length <= 8 ? 'Configured' : key.slice(0, 4) + '...' + key.slice(-4); + } + function buildSettingChanges(previousSettings, nextSettings, keys) { const changes = {}; (Array.isArray(keys) ? keys : []).forEach(function (key) { @@ -157,6 +166,10 @@ module.exports = function registerSettingsPageRoutes(app, deps) { ,rssDefaultIntervalUnit: String(settings['data-sources.rss_default_interval_unit'] || 'minutes') ,apiDefaultIntervalValue: Number(settings['data-sources.api_default_interval_value']) || 60 ,apiDefaultIntervalUnit: String(settings['data-sources.api_default_interval_unit'] || 'minutes') + ,weatherPirateWeatherConfigured: Boolean(settings['weather.pirate_weather_api_key']) + ,weatherPirateWeatherKeyLabel: maskApiKey(settings['weather.pirate_weather_api_key']) + ,weatherOpenMeteoConfigured: Boolean(settings['weather.open_meteo_api_key']) + ,weatherOpenMeteoKeyLabel: maskApiKey(settings['weather.open_meteo_api_key']) ,auditRetentionDays: Number(settings['audit.retention_days']) || 0 ,auditEnabled: Boolean(settings['audit.enabled']) ,auditIncludeRequestMetadata: Boolean(settings['audit.include_request_metadata']) @@ -231,7 +244,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) { app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) { try { const section = String(req.body && req.body.settings_section || '').trim().toLowerCase(); - if (section !== 'icons' && section !== 'media' && section !== 'security' && section !== 'defaults' && section !== 'audit') { + if (section !== 'icons' && section !== 'media' && section !== 'security' && section !== 'defaults' && section !== 'audit' && section !== 'weather') { const error = new Error('Unknown settings section.'); error.statusCode = 400; error.expose = true; @@ -358,7 +371,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) { error.expose = true; throw error; } - await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']); + await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'weather.open_meteo_api_key', 'weather.pirate_weather_api_key', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']); const savedSettings = await saveAppSettings(pool, { 'uploads.image_max_bytes': imageMaxMb * 1024 * 1024, 'uploads.video_max_bytes': videoMaxMb * 1024 * 1024, @@ -369,6 +382,17 @@ module.exports = function registerSettingsPageRoutes(app, deps) { return res.redirect('/settings/system?message=' + encodeURIComponent('Media settings saved.') + '#media-uploads'); } + if (section === 'weather') { + const submittedOpenMeteoKey = String(req.body && req.body.open_meteo_api_key || '').trim(); + const submittedPirateWeatherKey = String(req.body && req.body.pirate_weather_api_key || '').trim(); + const savedSettings = await saveAppSettings(pool, { + 'weather.open_meteo_api_key': submittedOpenMeteoKey || previousSettings['weather.open_meteo_api_key'], + 'weather.pirate_weather_api_key': submittedPirateWeatherKey || previousSettings['weather.pirate_weather_api_key'] + }, req.currentUser && req.currentUser.id); + await recordSettingsAuditEvent(req, previousSettings, savedSettings, section); + return res.redirect('/settings/system?message=' + encodeURIComponent('Weather provider settings saved.') + '#weather-providers'); + } + const selectedIcons = req.body && (req.body['suggested_icons[]'] !== undefined ? req.body['suggested_icons[]'] : req.body.suggested_icons); const catalogKeys = new Set(ANNOUNCEMENT_ICON_CATALOG.map(function (option) { return option.value; @@ -384,7 +408,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) { error.expose = true; throw error; } - await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']); + await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'weather.open_meteo_api_key', 'weather.pirate_weather_api_key', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']); const savedSettings = await saveAppSettings(pool, { 'announcements.suggested_icons': suggestedIcons }, req.currentUser && req.currentUser.id); diff --git a/src/web/routes/signage/slides/form-view-model.js b/src/web/routes/signage/slides/form-view-model.js index 839a906..e56d506 100644 --- a/src/web/routes/signage/slides/form-view-model.js +++ b/src/web/routes/signage/slides/form-view-model.js @@ -25,6 +25,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) { const rssFeeds = data && data.rssFeeds ? data.rssFeeds : []; const apiSources = data && data.apiSources ? data.apiSources : []; const timetableGroups = data && data.timetableGroups ? data.timetableGroups : []; + const weatherLocations = data && data.weatherLocations ? data.weatherLocations : []; const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null; const uploadLimits = data && data.uploadLimits ? data.uploadLimits : {}; const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions); @@ -48,6 +49,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) { rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, + weatherLocations: weatherLocations, 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 3f6a8bc..fe2ba9f 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -6,6 +6,7 @@ const crypto = require('crypto'); const path = require('path'); const { verifyRequestAuth, verifyPageAuthToken } = require('#src/request-auth'); const { getFontStylesheetHref } = require('#src/web/lib/media/font-library'); +const { convertWeatherSnapshot } = require('#src/data/weather-units'); const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnails'); function safeJsonForScript(value) { @@ -80,6 +81,20 @@ module.exports = function registerSlidesRoutes(app, deps) { const apiSources = (apiData.apiSources || []).map(function (source) { return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null }); }); + const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] }; + const weatherLocations = (weatherData.weatherLocations || []).map(function (location) { + const snapshot = common.parseJsonSafe ? common.parseJsonSafe(location.last_response_json) : null; + if (!snapshot || typeof snapshot !== 'object') return null; + const item = Object.assign({}, convertWeatherSnapshot(snapshot, { + temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius', + wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh', + precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm' + }), { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' }); + item.current = Object.assign({}, item.current || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_speed_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precipitation_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' }); + item.daily = Object.assign({}, item.daily || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' }); + item.hourly = Object.assign({}, item.hourly || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' }); + return { id: location.id, data: item }; + }).filter(Boolean); const getApiItems = function (source) { const response = source && source.responseJson; const itemsPath = String(source && source.items_path || '').trim(); @@ -103,6 +118,10 @@ module.exports = function registerSlidesRoutes(app, deps) { const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); }); return feed && Array.isArray(feed.items) ? feed.items[index] || null : null; } + if (type === 'weather') { + const location = weatherLocations.find(function (entry) { return Number(entry.id) === Number(regionContent.weather_location_id); }); + return location ? location.data : null; + } return null; }; const getCachedImagePath = function (remoteUrl) { diff --git a/src/web/views/data-sources/api-sources/form.hbs b/src/web/views/data-sources/api-sources/form.hbs index c3a89cf..8b94280 100644 --- a/src/web/views/data-sources/api-sources/form.hbs +++ b/src/web/views/data-sources/api-sources/form.hbs @@ -137,11 +137,13 @@ - +{{#if isEdit}}
{{/if}}
diff --git a/src/web/views/data-sources/api-sources/list.hbs b/src/web/views/data-sources/api-sources/list.hbs index b1cdc2c..397bfb0 100644 --- a/src/web/views/data-sources/api-sources/list.hbs +++ b/src/web/views/data-sources/api-sources/list.hbs @@ -27,6 +27,7 @@ Refresh interval Last pulled Last response + Status Actions @@ -54,6 +55,7 @@ - {{/if}} + {{#if enabled}}Enabled{{else}}Disabled{{/if}} {{#if (anyPermission ../currentUser 'api-sources.create' 'api-sources.update' 'api-sources.delete')}}
@@ -80,7 +82,7 @@ {{/each}} {{else}} - No API sources yet. + No API sources yet. {{/if}} diff --git a/src/web/views/data-sources/rss-feeds/form.hbs b/src/web/views/data-sources/rss-feeds/form.hbs index 167def7..631e779 100644 --- a/src/web/views/data-sources/rss-feeds/form.hbs +++ b/src/web/views/data-sources/rss-feeds/form.hbs @@ -38,11 +38,13 @@
- +{{#if isEdit}}
{{/if}}
diff --git a/src/web/views/data-sources/rss-feeds/list.hbs b/src/web/views/data-sources/rss-feeds/list.hbs index 2fcd2cb..d39e125 100644 --- a/src/web/views/data-sources/rss-feeds/list.hbs +++ b/src/web/views/data-sources/rss-feeds/list.hbs @@ -26,6 +26,7 @@ Feed URL Refresh interval Items pulled + Status Actions @@ -37,6 +38,7 @@ {{feed_url}} {{intervalLabel}} {{itemLabel}} + {{#if enabled}}Enabled{{else}}Disabled{{/if}} {{#if (anyPermission ../currentUser 'rss-feeds.create' 'rss-feeds.update' 'rss-feeds.delete')}}
@@ -63,7 +65,7 @@ {{/each}} {{else}} - No RSS feeds yet. + No RSS feeds yet. {{/if}} diff --git a/src/web/views/data-sources/weather/forecast-preview.hbs b/src/web/views/data-sources/weather/forecast-preview.hbs new file mode 100644 index 0000000..f094cdd --- /dev/null +++ b/src/web/views/data-sources/weather/forecast-preview.hbs @@ -0,0 +1,8 @@ +
+

Forecast preview

+
+
Daily and hourly forecasts will appear here after the first successful fetch.
+
{{#each weatherPreview.forecast}}
{{day}}
{{condition}}
{{high}}°{{low}}°
{{rain}} rain
{{/each}}
+
Hourly forecast · next 24 hours
{{#each weatherPreview.hourly}}
{{day}}
{{temperature}}°
{{rain}}
{{/each}}
+
+
diff --git a/src/web/views/data-sources/weather/form.hbs b/src/web/views/data-sources/weather/form.hbs new file mode 100644 index 0000000..565ed19 --- /dev/null +++ b/src/web/views/data-sources/weather/form.hbs @@ -0,0 +1,25 @@ + +
+
+
+
+

Weather location details

+
+
+
Choose a result to fill the coordinates and timezone automatically.
+
+ +
Coordinates are locked after selecting a lookup result.
+
+
+
+ +
+
+{{#if isEdit}}
{{/if}} +
+
{{> data-sources/weather/preview}}
+
+{{> data-sources/weather/forecast-preview}} diff --git a/src/web/views/data-sources/weather/list.hbs b/src/web/views/data-sources/weather/list.hbs new file mode 100644 index 0000000..cce2dbf --- /dev/null +++ b/src/web/views/data-sources/weather/list.hbs @@ -0,0 +1,38 @@ + + +
+
+

Saved locations

+
+
+ + +
+ {{#if (hasPermission currentUser 'weather.create')}}Add location{{/if}} +
+
+
+ + + + {{#if weatherLocations.length}} + {{#each weatherLocations}} + + + + + {{/each}} + {{else}}{{/if}} + +
NameLocationProviderRefresh intervalStatusActions
{{name}}{{location_label}}
{{latitude}}, {{longitude}} · {{timezone}}
{{provider}}{{intervalLabel}}{{#if enabled}}Enabled{{else}}Disabled{{/if}}
+ {{#if (hasPermission ../currentUser 'weather.update')}}Edit{{/if}} + {{#if (hasPermission ../currentUser 'weather.delete')}}
{{/if}} +
No weather locations yet.
+
+ {{> table-pagination pagination=pagination basePath="/data-sources/weather" alwaysShow=true}} +
diff --git a/src/web/views/data-sources/weather/mock.hbs b/src/web/views/data-sources/weather/mock.hbs new file mode 100644 index 0000000..77500dc --- /dev/null +++ b/src/web/views/data-sources/weather/mock.hbs @@ -0,0 +1,117 @@ + + +
+
+
+

Source configuration

+
+
+
+ + +
+
+ + +
+
+ + +
+
Location is the display name. Coordinates identify the weather point and can be set from a map picker, browser location, or manual entry.
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
The source can cache both datasets; this controls what the region displays.
+
+
+
+ +
+
+ +
+
+

Cached snapshot

Last fetch succeeded
+
+
+
{{weather.location}}
{{weather.temperature}}°
{{weather.condition}}
+ +
+
+
Feels like{{weather.feelsLike}}°
+
Humidity{{weather.humidity}}
+
Wind{{weather.wind}}
+
UV index{{weather.uvIndex}}
+
Sunrise{{weather.sunrise}}
+
Sunset{{weather.sunset}}
+
+
Cached {{weather.cacheAge}}. Players keep using this snapshot if the next request fails.
+
+ +
+
+
+ +
+

Forecast preview

+
+
{{weather.forecastModeLabel}} · timezone {{weather.timezone}}
+
+ {{#each weather.forecast}} +
{{day}}
{{condition}}
{{high}}°{{low}}°
{{rain}} rain
+ {{/each}} +
+
+
Hourly option preview · next 24 hours
+
+ {{#each weather.hourly}} +
{{day}}
{{temperature}}°
{{rain}}
+ {{/each}} +
+
+
+
+ + \ No newline at end of file diff --git a/src/web/views/data-sources/weather/preview.hbs b/src/web/views/data-sources/weather/preview.hbs new file mode 100644 index 0000000..913eb7a --- /dev/null +++ b/src/web/views/data-sources/weather/preview.hbs @@ -0,0 +1,13 @@ +
+

Cached snapshot

{{#if weatherPreview.hasSnapshot}}Available{{else}}Not available{{/if}}
+
+ {{#if weatherPreview.hasSnapshot}} +
{{weatherPreview.location}}
{{weatherPreview.temperature}}{{weatherPreview.temperatureUnit}}
{{weatherPreview.condition}}
+
Feels like{{weatherPreview.feelsLike}}{{weatherPreview.temperatureUnit}}
Humidity{{weatherPreview.humidity}}
Wind{{weatherPreview.wind}} {{weatherPreview.windUnit}}
Precipitation{{weatherPreview.precipitation}} {{weatherPreview.precipitationUnit}}
Cloud cover{{weatherPreview.cloudCover}}%
UV index{{weatherPreview.uvIndex}}
Sunrise{{weatherPreview.sunrise}}
Sunset{{weatherPreview.sunset}}
+
Cached snapshot will be served to players between refreshes.
+ {{else}} +

No cached weather snapshot yet.

Save this location to enable weather refreshes.
+ {{/if}} +
+ +
diff --git a/src/web/views/settings/rbac/list.hbs b/src/web/views/settings/rbac/list.hbs index 59463a7..c7f1d65 100644 --- a/src/web/views/settings/rbac/list.hbs +++ b/src/web/views/settings/rbac/list.hbs @@ -7,7 +7,7 @@
-
+

Existing roles

diff --git a/src/web/views/settings/system/index.hbs b/src/web/views/settings/system/index.hbs index 72aa704..cebc701 100644 --- a/src/web/views/settings/system/index.hbs +++ b/src/web/views/settings/system/index.hbs @@ -20,6 +20,9 @@ Icon Suggestions + + Weather Providers + Security and Sessions @@ -146,6 +149,44 @@
+
+ + +
+