Release 2.9.0

This commit is contained in:
2026-08-28 02:53:59 +01:00
parent 9097d45d6a
commit 3de0e89e94
67 changed files with 1767 additions and 98 deletions
@@ -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,
+34 -23
View File
@@ -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));
+172
View File
@@ -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
});
};