Release 2.9.0
This commit is contained in:
+21
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
+16
-1
@@ -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 = {};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+22
-4
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 = '<i class="bi ' + weatherIconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
|
||||
return width ? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + width + 'px;height:' + height + 'px;">' + icon + '</span>' : icon;
|
||||
}
|
||||
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 '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + '"></div></div>';
|
||||
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 '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData)) + '</div></div>';
|
||||
}
|
||||
var daily = snapshot.daily || {};
|
||||
var days = (daily.time || []).slice(0, 7).map(function (day, index) {
|
||||
return '<div class="weather-region-day"><strong>' + escapeHtml(index === 0 ? 'Today' : String(day).slice(5)) + '</strong><i class="bi ' + weatherIconForCode(daily.weather_code && daily.weather_code[index]) + '"></i><span>' + escapeHtml(daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] + '°' : '-') + '</span></div>';
|
||||
}).join('');
|
||||
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'padding:3%;font-family:Arial,sans-serif;"><div style="display:flex;align-items:center;justify-content:space-between;"><div><div style="font-size:.8em;opacity:.72;">' + escapeHtml(location.location_label || location.name) + '</div><strong style="font-size:2em;">' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°</strong></div><i class="bi ' + weatherIconForCode(current.weather_code) + '" style="font-size:3em;"></i></div><div style="display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:.35em;margin-top:1em;">' + days + '</div></div></div>';
|
||||
}
|
||||
|
||||
weatherRegistry.register('weather', { renderRegion: renderWeatherRegion });
|
||||
@@ -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'],
|
||||
|
||||
+14
@@ -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.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 '<i class="bi ' + weatherIconForCode(placeholderUtils.resolvePlaceholderExpression(item, codeExpression)) + '"' + sizeStyle + ' aria-hidden="true"></i>';
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}());
|
||||
@@ -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'],
|
||||
|
||||
@@ -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, '"').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)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).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)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
};
|
||||
var visibleMarkup = visibleFields.map(renderChip).join('');
|
||||
if (!overflowFields.length) return visibleMarkup;
|
||||
return visibleMarkup + '<details class="w-100 mt-1" data-placeholder-chips-more><summary class="small text-body-secondary">Show ' + escapeHtml(overflowFields.length) + ' more fields</summary><div class="d-flex flex-wrap gap-2 mt-1">' + overflowFields.map(renderChip).join('') + '</div></details>';
|
||||
}
|
||||
|
||||
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 = '<i class="bi ' + iconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
|
||||
return width ? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + width + 'px;height:' + height + 'px;border:1px dashed rgba(120,120,120,.75);">' + icon + '</span>' : icon;
|
||||
}
|
||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone })));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTransformInfo(regionId) {
|
||||
var offcanvasId = 'weather-placeholder-info-' + regionId;
|
||||
return '<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
|
||||
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
|
||||
'<div class="offcanvas-header"><h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">Weather placeholder transforms</h2><button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button></div>' +
|
||||
'<div class="offcanvas-body"><p class="small text-body-secondary">Placeholders read values from the selected weather snapshot. Nested fields use dots.</p>' +
|
||||
'<h3 class="h6 mt-3">Weather icons</h3><p class="small text-body-secondary">Use the icon property to insert a Bootstrap weather icon. Add width and height in pixels when sizing is needed.</p><ul class="small"><li><code>{{current.icon}}</code></li><li><code>{{current.icon(48,48)}}</code></li><li><code>{{daily.0.icon(32,24)}}</code></li></ul>' +
|
||||
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
|
||||
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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 '<div class="template-region weather"><div class="slide-preview-placeholder">Select a weather location with cached data</div></div>';
|
||||
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 '<div class="template-region weather" style="width:100%;height:100%;overflow:hidden;font-family:Arial;font-size:' + fontSize + 'px;line-height:1.5;color:#000000;"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + (utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(substituteVariables(value, weatherData)) : substituteVariables(value, weatherData)) + '</div></div>';
|
||||
}
|
||||
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 '<div class="weather-region-day"><strong>' + escapeHtml(index === 0 ? 'Today' : String(day).slice(5)) + '</strong><i class="bi ' + iconForCode(daily.weather_code && daily.weather_code[index]) + '"></i><span>' + escapeHtml(daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] + '°' : '-') + '</span></div>';
|
||||
}).join('');
|
||||
return '<div class="template-region weather" style="width:100%;height:100%;overflow:hidden;padding:3%;font-family:Arial,sans-serif;"><div style="display:flex;align-items:center;justify-content:space-between;"><div><div style="font-size:.8em;opacity:.72;">' + escapeHtml(location.location_label || location.name) + '</div><strong style="font-size:2em;">' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°</strong><div>' + escapeHtml(conditionForCode(code)) + '</div></div><i class="bi ' + iconForCode(code) + '" style="font-size:3em;"></i></div><div style="display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:.35em;margin-top:1em;">' + days + '</div></div>';
|
||||
}
|
||||
|
||||
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 '<option value="' + escapeHtml(location.id) + '"' + (Number(location.id) === Number(current.weather_location_id) ? ' selected' : '') + '>' + escapeHtml(location.name || location.location_label || ('Location ' + location.id)) + '</option>';
|
||||
}).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 '<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + escapeHtml(region.id) + '"><div class="card-header template-field-head"><strong>' + escapeHtml(region.label) + '</strong><div class="template-field-actions"><span class="chip">Weather</span></div></div><div class="card-body p-3 d-grid gap-3"><div class="d-flex justify-content-end"><div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls"><button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size">-</button><button type="button" class="btn btn-outline-secondary" data-editor-size-action="increase" aria-label="Increase editor size">+</button></div></div><div class="editor-holder" data-region-id="' + escapeHtml(region.id) + '"><textarea class="editor-source" rows="10">' + escapeHtml(currentValue) + '</textarea></div><div class="row g-3 align-items-end"><div class="col-12 col-md-8"><label class="form-label" for="region_weather_location_id_' + escapeHtml(region.id) + '">Weather location</label><select id="region_weather_location_id_' + escapeHtml(region.id) + '" name="region_weather_location_id_' + escapeHtml(region.id) + '" class="form-select"><option value="">Select a location</option>' + options + '</select></div><div class="col-12 col-md-4"><label class="form-label" for="region_weather_forecast_mode_' + escapeHtml(region.id) + '">Forecast</label><select id="region_weather_forecast_mode_' + escapeHtml(region.id) + '" name="region_weather_forecast_mode_' + escapeHtml(region.id) + '" class="form-select"><option value="current"' + (forecastMode === 'current' ? ' selected' : '') + '>Current</option><option value="daily"' + (forecastMode === 'daily' ? ' selected' : '') + '>7 days</option><option value="hourly"' + (forecastMode === 'hourly' ? ' selected' : '') + '>24 hour</option></select></div></div><input type="hidden" name="region_text_' + escapeHtml(region.id) + '" value="' + escapeHtml(currentValue) + '" /><div class="api-region-placeholder-section"><div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderTransformInfo(region.id) + '</div><div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + chips + '</div></div></div></div>';
|
||||
}
|
||||
|
||||
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 });
|
||||
}());
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
enabled: 1,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
requestMethod: 'GET',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
enabled: 1,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
};
|
||||
@@ -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)); };
|
||||
@@ -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)); };
|
||||
@@ -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'] };
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -137,11 +137,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="api-source-toggle-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
<div class="card card-outline card-secondary mt-3" id="api-source-response-panel">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<th data-table-sort-key="interval">Refresh interval</th>
|
||||
<th data-table-sort-key="last_pulled">Last pulled</th>
|
||||
<th data-table-sort-key="last_response">Last response</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -54,6 +55,7 @@
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'api-sources.create' 'api-sources.update' 'api-sources.delete')}}
|
||||
<div class="actions">
|
||||
@@ -80,7 +82,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="6" class="empty">No API sources yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="7" class="empty">No API sources yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -38,11 +38,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="rss-feed-toggle-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
<div class="card card-outline card-secondary admin-form-card mt-4">
|
||||
<div class="card-header">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<th data-table-sort-key="url">Feed URL</th>
|
||||
<th data-table-sort-key="interval">Refresh interval</th>
|
||||
<th data-table-sort-key="items">Items pulled</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -37,6 +38,7 @@
|
||||
<td data-label="Feed URL" class="text-break">{{feed_url}}</td>
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
<td data-label="Items pulled">{{itemLabel}}</td>
|
||||
<td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'rss-feeds.create' 'rss-feeds.update' 'rss-feeds.delete')}}
|
||||
<div class="actions">
|
||||
@@ -63,7 +65,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No RSS feeds yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="6" class="empty">No RSS feeds yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="card card-outline card-secondary mt-3">
|
||||
<div class="card-header d-flex align-items-center flex-wrap"><h3 class="card-title mb-0 flex-grow-1">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0 ms-auto" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary" data-weather-forecast-mode="daily" aria-pressed="true">Daily</button><button type="button" class="btn btn-outline-secondary" data-weather-forecast-mode="hourly" aria-pressed="false">24 hours</button></div></div>
|
||||
<div class="card-body">
|
||||
<div class="small text-body-secondary mb-3">Daily and hourly forecasts will appear here after the first successful fetch.</div>
|
||||
<div id="weather-daily-forecast" class="weather-daily-forecast">{{#each weatherPreview.forecast}}<div class="border rounded p-2 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div>{{/each}}</div>
|
||||
<div id="weather-hourly-forecast" class="border-top mt-4 pt-3 d-none"><div class="small text-body-secondary mb-2">Hourly forecast · next 24 hours</div><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="page-header">
|
||||
<div><h2>{{#if isEdit}}Edit weather location{{else}}Add weather location{{/if}}</h2><p>Configure a saved location for weather data regions.</p></div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-7">
|
||||
<form id="weather-location-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header"><h3 class="card-title">Weather location details</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3"><label for="weather-name" class="form-label">Name</label><input id="weather-name" name="name" class="form-control" value="{{weatherLocation.name}}" maxlength="255" required /></div>
|
||||
<div class="mb-3 position-relative"><label for="weather-label" class="form-label">Location</label><input id="weather-label" name="location_label" class="form-control" value="{{weatherLocation.location_label}}" maxlength="255" placeholder="Start typing a town, city, or postcode" autocomplete="off" required /><div id="weather-location-results" class="list-group position-absolute w-100 d-none" style="z-index: 10;"></div><div class="form-text">Choose a result to fill the coordinates and timezone automatically.</div></div>
|
||||
<div class="row g-3"><div class="col-md-4"><label for="weather-latitude" class="form-label">Latitude</label><input id="weather-latitude" type="number" step="0.000001" min="-90" max="90" class="form-control" value="{{weatherLocation.latitude}}" required /><input id="weather-latitude-value" name="latitude" type="hidden" value="{{weatherLocation.latitude}}" /></div><div class="col-md-4"><label for="weather-longitude" class="form-label">Longitude</label><input id="weather-longitude" type="number" step="0.000001" min="-180" max="180" class="form-control" value="{{weatherLocation.longitude}}" required /><input id="weather-longitude-value" name="longitude" type="hidden" value="{{weatherLocation.longitude}}" /></div><div class="col-md-4 d-flex align-items-end"><button id="weather-manual-coordinates" type="button" class="btn btn-outline-secondary">Edit coordinates manually</button></div></div>
|
||||
<input id="weather-timezone" name="timezone" type="hidden" value="{{weatherLocation.timezone}}" required />
|
||||
<div class="form-text mb-3">Coordinates are locked after selecting a lookup result.</div>
|
||||
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
|
||||
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
</div>
|
||||
<div class="col-12 col-xl-5">{{> data-sources/weather/preview}}</div>
|
||||
</div>
|
||||
{{> data-sources/weather/forecast-preview}}
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Weather locations</h2>
|
||||
<p>Save the locations used by weather regions and control how often their snapshots refresh.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Saved locations</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search weather locations" aria-label="Search weather locations" data-table-search />
|
||||
</div>
|
||||
{{#if (hasPermission currentUser 'weather.create')}}<a class="btn btn-primary btn-sm" href="/data-sources/weather/new">Add location</a>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead><tr><th data-table-sort-key="name">Name</th><th data-table-sort-key="location">Location</th><th data-table-sort-key="provider">Provider</th><th data-table-sort-key="interval">Refresh interval</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if weatherLocations.length}}
|
||||
{{#each weatherLocations}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td><td data-label="Location">{{location_label}}<br><small class="text-muted">{{latitude}}, {{longitude}} · {{timezone}}</small></td><td data-label="Provider">{{provider}}</td><td data-label="Refresh interval">{{intervalLabel}}</td><td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions"><div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'weather.update')}}<a class="btn btn-sm btn-primary" href="/data-sources/weather/{{id}}/edit">Edit</a>{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'weather.delete')}}<form class="inline-form" method="post" action="/data-sources/weather/{{id}}/delete" data-confirm-message="Delete this weather location?"><button class="btn btn-sm btn-danger" type="submit">Delete</button></form>{{/if}}
|
||||
</div></td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}<tr data-table-search-empty-default><td colspan="6" class="empty">No weather locations yet.</td></tr>{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/data-sources/weather" alwaysShow=true}}
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Weather source</h2>
|
||||
<p>Configure a remote weather feed once, then serve its cached snapshot to every player.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-outline card-primary h-100 weather-mock-card">
|
||||
<div class="card-header"><h3 class="card-title">Source configuration</h3></div>
|
||||
<div class="card-body weather-mock-card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="weather-name">Name</label>
|
||||
<input id="weather-name" class="form-control" value="{{weather.name}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label class="form-label" for="weather-location">Location</label>
|
||||
<input id="weather-location" class="form-control" value="{{weather.location}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-provider">Provider</label>
|
||||
<select id="weather-provider" class="form-select">
|
||||
<option selected>{{weather.provider}} (no key)</option>
|
||||
<option>Custom JSON endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12"><div class="form-text"><i class="bi bi-info-circle me-1"></i>Location is the display name. Coordinates identify the weather point and can be set from a map picker, browser location, or manual entry.</div></div>
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="weather-latitude">Latitude</label>
|
||||
<input id="weather-latitude" class="form-control" value="{{weather.latitude}}" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="weather-longitude">Longitude</label>
|
||||
<input id="weather-longitude" class="form-control" value="{{weather.longitude}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-temperature-unit">Temperature</label>
|
||||
<select id="weather-temperature-unit" class="form-select"><option selected>{{weather.temperatureUnit}}</option><option>Fahrenheit (°F)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-wind-unit">Wind speed</label>
|
||||
<select id="weather-wind-unit" class="form-select"><option selected>{{weather.windUnit}}</option><option>Miles per hour (mph)</option><option>Metres per second (m/s)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-precipitation-unit">Precipitation</label>
|
||||
<select id="weather-precipitation-unit" class="form-select"><option selected>{{weather.precipitationUnit}}</option><option>Inches (in)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="weather-refresh">Refresh interval</label>
|
||||
<select id="weather-refresh" class="form-select"><option>15 minutes</option><option selected>{{weather.refreshInterval}}</option><option>1 hour</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="weather-forecast-mode">Forecast display</label>
|
||||
<select id="weather-forecast-mode" class="form-select"><option selected value="daily">Daily forecast</option><option value="hourly">Hourly forecast (next 24 hours)</option></select>
|
||||
<div class="form-text">The source can cache both datasets; this controls what the region displays.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer weather-mock-card-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1"></i>Refresh now</button>
|
||||
<button type="button" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Save source</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-outline card-secondary h-100 weather-mock-card">
|
||||
<div class="card-header d-flex align-items-center gap-2"><h3 class="card-title mb-0 flex-grow-1">Cached snapshot</h3><span class="badge text-bg-success flex-shrink-0"><i class="bi bi-check-circle me-1"></i>Last fetch succeeded</span></div>
|
||||
<div class="card-body weather-mock-card-body">
|
||||
<div class="d-flex align-items-start justify-content-between border-bottom pb-3 mb-3">
|
||||
<div><div class="text-body-secondary small">{{weather.location}}</div><div class="display-4 fw-semibold">{{weather.temperature}}°</div><div class="fw-medium">{{weather.condition}}</div></div>
|
||||
<i class="bi bi-cloud-sun weather-mock-icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="row g-3 small">
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Feels like</span><strong>{{weather.feelsLike}}°</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Humidity</span><strong>{{weather.humidity}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Wind</span><strong>{{weather.wind}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">UV index</span><strong>{{weather.uvIndex}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Sunrise</span><strong>{{weather.sunrise}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Sunset</span><strong>{{weather.sunset}}</strong></div>
|
||||
</div>
|
||||
<div class="alert alert-light border mt-4 mb-0 small"><i class="bi bi-database-check me-1"></i> Cached {{weather.cacheAge}}. Players keep using this snapshot if the next request fails.</div>
|
||||
</div>
|
||||
<div class="card-footer weather-mock-card-footer small text-body-secondary">Last successful fetch: {{weather.fetchedAt}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between gap-2 flex-wrap"><h3 class="card-title mb-0">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary">Daily</button><button type="button" class="btn btn-outline-secondary">24 hours</button></div></div>
|
||||
<div class="card-body">
|
||||
<div class="small text-body-secondary mb-3">{{weather.forecastModeLabel}} · timezone {{weather.timezone}}</div>
|
||||
<div class="row row-cols-2 row-cols-md-4 g-2">
|
||||
{{#each weather.forecast}}
|
||||
<div class="col"><div class="border rounded p-3 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="border-top mt-4 pt-3">
|
||||
<div class="small text-body-secondary mb-2">Hourly option preview · next 24 hours</div>
|
||||
<div class="d-flex gap-2 overflow-auto pb-2">
|
||||
{{#each weather.hourly}}
|
||||
<div class="border rounded p-2 text-center flex-shrink-0" style="width:7.5rem"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.weather-mock-card { display: flex; flex-direction: column; }
|
||||
.weather-mock-card-body { flex: 1 1 auto; }
|
||||
.weather-mock-card-footer { min-height: 3.5rem; }
|
||||
.weather-mock-icon { font-size: 4rem; color: #e0a11a; }
|
||||
.weather-mock-forecast-icon { display: block; font-size: 2rem; color: #e0a11a; margin: 1rem 0 .65rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="card card-outline card-secondary h-100 weather-preview-card">
|
||||
<div class="card-header d-flex align-items-center"><h3 class="card-title mb-0 flex-grow-1">Cached snapshot</h3><span class="badge text-bg-secondary flex-shrink-0">{{#if weatherPreview.hasSnapshot}}Available{{else}}Not available{{/if}}</span></div>
|
||||
<div class="card-body weather-preview-card-body">
|
||||
{{#if weatherPreview.hasSnapshot}}
|
||||
<div class="d-flex align-items-start justify-content-between border-bottom pb-3 mb-3"><div><div class="text-body-secondary small">{{weatherPreview.location}}</div><div class="display-4 fw-semibold">{{weatherPreview.temperature}}{{weatherPreview.temperatureUnit}}</div><div class="fw-medium">{{weatherPreview.condition}}</div></div><i class="bi {{weatherPreview.icon}} weather-preview-icon" aria-hidden="true"></i></div>
|
||||
<div class="row g-3 small"><div class="col-6"><span class="text-body-secondary d-block">Feels like</span><strong>{{weatherPreview.feelsLike}}{{weatherPreview.temperatureUnit}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Humidity</span><strong>{{weatherPreview.humidity}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Wind</span><strong>{{weatherPreview.wind}} {{weatherPreview.windUnit}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Precipitation</span><strong>{{weatherPreview.precipitation}} {{weatherPreview.precipitationUnit}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Cloud cover</span><strong>{{weatherPreview.cloudCover}}%</strong></div><div class="col-6"><span class="text-body-secondary d-block">UV index</span><strong>{{weatherPreview.uvIndex}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Sunrise</span><strong>{{weatherPreview.sunrise}}</strong></div><div class="col-6"><span class="text-body-secondary d-block">Sunset</span><strong>{{weatherPreview.sunset}}</strong></div></div>
|
||||
<div class="alert alert-light border mt-4 mb-0 small"><i class="bi bi-database-check me-1"></i>Cached snapshot will be served to players between refreshes.</div>
|
||||
{{else}}
|
||||
<div class="d-flex align-items-center justify-content-center text-body-secondary text-center h-100 py-5"><div><i class="bi bi-cloud-sun fs-1 d-block mb-3" aria-hidden="true"></i><p class="mb-1">No cached weather snapshot yet.</p><small>Save this location to enable weather refreshes.</small></div></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer weather-preview-card-footer d-flex align-items-center small text-body-secondary">{{#if weatherPreview.hasSnapshot}}Last successful fetch: {{weatherPreview.fetchedAt}}{{else}}Waiting for the first successful fetch{{/if}}</div>
|
||||
</div>
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card card-outline card-primary admin-form-card" data-table-search-container data-table-pagination-card>
|
||||
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing roles</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<a class="nav-link" href="#icon-suggestions" data-settings-section-link>
|
||||
<i class="bi bi-grid-3x3-gap me-2" aria-hidden="true"></i>Icon Suggestions
|
||||
</a>
|
||||
<a class="nav-link" href="#weather-providers" data-settings-section-link>
|
||||
<i class="bi bi-cloud-sun me-2" aria-hidden="true"></i>Weather Providers
|
||||
</a>
|
||||
<a class="nav-link" href="#security-sessions" data-settings-section-link>
|
||||
<i class="bi bi-shield-lock me-2" aria-hidden="true"></i>Security and Sessions
|
||||
</a>
|
||||
@@ -146,6 +149,44 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/settings/system" data-async-save data-settings-form="weather">
|
||||
<input type="hidden" name="settings_section" value="weather">
|
||||
<div id="weather-providers" class="card settings-section-card" data-settings-section hidden>
|
||||
<div class="card-header d-flex align-items-start justify-content-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-cloud-sun me-2 text-primary" aria-hidden="true"></i>Weather Providers</h3>
|
||||
<p class="text-muted small mb-0">Manage shared credentials used by weather locations.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12"><h4 class="h6 text-uppercase text-muted mb-0">Provider credentials</h4></div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-3"><div><strong>Open-Meteo</strong><div class="small text-body-secondary">Global forecast provider · API key optional</div></div>{{#if mediaSettings.weatherOpenMeteoConfigured}}<span class="badge text-bg-success flex-shrink-0">Configured</span>{{else}}<span class="badge text-bg-secondary flex-shrink-0">Keyless</span>{{/if}}</div>
|
||||
<label for="settings-open-meteo-key" class="form-label">API key</label>
|
||||
<input id="settings-open-meteo-key" name="open_meteo_api_key" type="password" class="form-control" autocomplete="off" placeholder="{{#if mediaSettings.weatherOpenMeteoConfigured}}{{mediaSettings.weatherOpenMeteoKeyLabel}}{{else}}Optional API key{{/if}}">
|
||||
<div class="form-text">Open-Meteo can be used without a key. Leave blank to keep the existing credential.</div>
|
||||
<a class="small" href="https://open-meteo.com/" target="_blank" rel="noreferrer">open-meteo.com</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-3"><div><strong>Pirate Weather</strong><div class="small text-body-secondary">Global forecast provider · API key required</div></div>{{#if mediaSettings.weatherPirateWeatherConfigured}}<span class="badge text-bg-success flex-shrink-0">Configured</span>{{else}}<span class="badge text-bg-secondary flex-shrink-0">Not configured</span>{{/if}}</div>
|
||||
<label for="settings-pirate-weather-key" class="form-label">API key</label>
|
||||
<input id="settings-pirate-weather-key" name="pirate_weather_api_key" type="password" class="form-control" autocomplete="off" placeholder="{{#if mediaSettings.weatherPirateWeatherConfigured}}{{mediaSettings.weatherPirateWeatherKeyLabel}}{{else}}Enter API key{{/if}}">
|
||||
<div class="form-text">Leave blank to keep the existing credential. One key is shared by all weather locations.</div>
|
||||
<a class="small" href="https://pirateweather.net/" target="_blank" rel="noreferrer">pirateweather.net</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{#if (hasPermission currentUser 'system-settings.update')}}<button type="submit" class="btn btn-success"><i class="bi bi-check2 me-1"></i>Save</button>{{else}}<button type="button" class="btn btn-outline-success" disabled>Save</button>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/settings/system" data-async-save data-settings-form="media">
|
||||
<input type="hidden" name="settings_section" value="media">
|
||||
<div id="media-uploads" class="card settings-section-card" data-settings-section hidden>
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}}
|
||||
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read' 'weather.read')}}
|
||||
<li class="nav-header">DATA SOURCES</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'api-sources.read')}}
|
||||
@@ -268,6 +268,14 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'weather.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'weather')}}active{{/if}}" href="/data-sources/weather">
|
||||
<i class="nav-icon bi bi-cloud-sun"></i>
|
||||
<p>Weather</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read')}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
{{/if}}
|
||||
@@ -304,7 +312,7 @@
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
{{#if (hasPermission currentUser 'background-tasks.read')}}
|
||||
<ul class="nav nav-treeview">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
|
||||
|
||||
@@ -2,6 +2,7 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
|
||||
require('../src/common');
|
||||
const { createPlayerPlaylistService } = require('../src/player/playlist');
|
||||
|
||||
function createFsStub() {
|
||||
|
||||
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.7' });
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.9' });
|
||||
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
@@ -74,7 +74,7 @@ test('pending migrations are reported when an older schema still needs scripts',
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
|
||||
|
||||
assert.ok(pendingMigrations.length > 0);
|
||||
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 2);
|
||||
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 4);
|
||||
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ const slideWebpageRegionSource = fs.readFileSync(require.resolve('../src/web/pub
|
||||
|
||||
test('slide editor disables pasted data images in TinyMCE', () => {
|
||||
assert.ok(slideFormEditorSource.includes('paste_data_images: false'));
|
||||
assert.ok(slideFormEditorSource.includes('paste_block_drop: true'));
|
||||
assert.ok(slideFormEditorSource.includes('resize: false'));
|
||||
});
|
||||
|
||||
test('slide editor enables server-backed image uploads', () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { convertWeatherSnapshot } = require('../src/data/weather-units');
|
||||
|
||||
test('converts cached weather snapshot values to selected units', () => {
|
||||
const snapshot = {
|
||||
current_units: { temperature_2m: '°C', apparent_temperature: '°C', wind_speed_10m: 'km/h', precipitation: 'mm' },
|
||||
current: { temperature_2m: 20, apparent_temperature: 18, wind_speed_10m: 36, precipitation: 2.54 },
|
||||
daily_units: { temperature_2m_max: '°C', temperature_2m_min: '°C', wind_speed_10m_max: 'km/h', precipitation_sum: 'mm' },
|
||||
daily: { temperature_2m_max: [25], temperature_2m_min: [15], wind_speed_10m_max: [18], precipitation_sum: [25.4] }
|
||||
};
|
||||
|
||||
const converted = convertWeatherSnapshot(snapshot, { temperature: 'fahrenheit', wind: 'mph', precipitation: 'inch' });
|
||||
|
||||
assert.equal(converted.current.temperature_2m, 68);
|
||||
assert.equal(converted.current.apparent_temperature, 64.4);
|
||||
assert.equal(converted.current.wind_speed_10m, 22.4);
|
||||
assert.equal(converted.current.precipitation, 0.1);
|
||||
assert.equal(converted.daily.temperature_2m_max[0], 77);
|
||||
assert.equal(converted.daily.wind_speed_10m_max[0], 11.2);
|
||||
assert.equal(converted.daily.precipitation_sum[0], 1);
|
||||
assert.equal(snapshot.current.temperature_2m, 20);
|
||||
});
|
||||
Reference in New Issue
Block a user