Release 2.16.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m13s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-10-21 01:38:22 +01:00
1 parent 8ce8228091
commit 49df51d92f
32 files changed
+390 -172

No files matched your search

+3
View File
@@ -137,6 +137,9 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlay
if (!apiSource) {
throw new Error('API source not found.');
}
if (apiSource.enabled === 0 || apiSource.enabled === false) {
return { skipped: true, reason: 'disabled' };
}
let responseDetails = null;
let pullError = '';
+2 -22
View File
@@ -16,11 +16,7 @@ const {
const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const placeholderUtils = (() => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8'), sandbox);
return sandbox.window.placeholderUtils || {};
})();
const placeholderUtils = require('#src/web/public/js/shared/placeholder-utils');
const timetableScript = new vm.Script(fs.readFileSync(require.resolve('#src/player/regions/timetable'), 'utf8'));
@@ -438,27 +434,11 @@ async function captureSlideThumbnail(options) {
return { id: location.id, data: data };
}).filter(Boolean);
function getApiItems(source) {
const response = source && source.responseJson;
const itemsPath = String(source && source.items_path || '').trim();
if (itemsPath) {
const selected = itemsPath.split('.').reduce(function (current, segment) {
return current === undefined || current === null ? '' : current[segment];
}, response);
return Array.isArray(selected) ? selected : [];
}
if (Array.isArray(response)) return response;
if (response && Array.isArray(response.items)) return response.items;
if (response && Array.isArray(response.results)) return response.results;
if (response && Array.isArray(response.data)) return response.data;
return response ? [response] : [];
}
function getThumbnailItem(type, regionContent) {
const index = Math.max(0, Math.max(1, Number(regionContent && regionContent.item_number || 1)) - 1);
if (type === 'api') {
const source = apiSources.find(function (entry) { return Number(entry.id) === Number(regionContent.source_id); });
return getApiItems(source)[index] || null;
return placeholderUtils.resolveApiItems(source && source.responseJson, regionContent.items_path === undefined || regionContent.items_path === null ? source && source.items_path : regionContent.items_path)[index] || null;
}
if (type === 'rss') {
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
+18 -1
View File
@@ -92,6 +92,10 @@
toggleButton.className = toggleButton.className.replace(/btn-(danger|success)/g, willEnable ? 'btn-danger' : 'btn-success');
toggleButton.innerHTML = '<i class="bi ' + (willEnable ? 'bi-pause-fill' : 'bi-play-fill') + ' me-1" aria-hidden="true"></i>' + (willEnable ? 'Disable' : 'Enable');
Array.prototype.forEach.call(document.querySelectorAll('[data-source-toggle-owner="' + form.id + '"]'), function (control) {
control.disabled = !willEnable || control.getAttribute('data-source-item-enabled') === 'false';
});
var message = '';
try {
message = new URL(response.url, window.location.href).searchParams.get('message') || '';
@@ -837,17 +841,30 @@
return;
}
refreshButton.disabled = true;
var refreshTargetSelector = refreshButton.getAttribute('data-manual-refresh-target');
fetch(refreshButton.getAttribute('data-manual-refresh-url'), {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': refreshTargetSelector ? 'application/json' : 'text/html, application/xhtml+xml'
}
}).then(function (response) {
if (!response.ok) {
throw new Error('Refresh failed');
}
if (refreshTargetSelector) {
return response.json().then(function (payload) {
if (!payload.taskId || !payload.stateUrl) throw new Error('Unable to track refresh task.');
watchRefreshTask(payload.stateUrl, payload.taskId, refreshTargetSelector, refreshSequence);
refreshButton.disabled = false;
showToast(payload.message || 'Refresh queued. Unsaved changes are kept.', 'success');
});
}
window.location.assign(response.url);
}).catch(function () {
refreshButton.disabled = false;
if (refreshTargetSelector) showToast('Unable to refresh API response. Unsaved changes are kept.', 'danger');
});
return;
}
+1 -11
View File
@@ -34,17 +34,7 @@
}
function getApiItems(response, itemsPath) {
var selected = itemsPath ? getPath(response, itemsPath) : response;
if (Array.isArray(selected)) {
return selected;
}
if (Array.isArray(response)) {
return response;
}
if (response && Array.isArray(response.items)) return response.items;
if (response && Array.isArray(response.results)) return response.results;
if (response && Array.isArray(response.data)) return response.data;
return response && typeof response === 'object' ? [response] : [];
return root.placeholderUtils.resolveApiItems(response, itemsPath);
}
function normalizeRows(rows, mapping) {
+1 -24
View File
@@ -51,30 +51,7 @@
var source = getSourceById(sourceId, sources);
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
var itemsPath = getSourceItemsPath(source, itemsPathOverride);
if (itemsPath !== undefined && itemsPath !== null && itemsPath !== '') {
var current = responseJson;
String(itemsPath).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return Array.isArray(current) ? current : [];
}
if (Array.isArray(responseJson)) {
return responseJson;
}
if (responseJson && Array.isArray(responseJson.items)) {
return responseJson.items;
}
if (responseJson && Array.isArray(responseJson.results)) {
return responseJson.results;
}
if (responseJson && Array.isArray(responseJson.data)) {
return responseJson.data;
}
return responseJson ? [responseJson] : [];
return placeholderUtils.resolveApiItems(responseJson, itemsPath);
}
function getItemCount(sourceId, sources, itemsPathOverride) {
+25 -5
View File
@@ -1,7 +1,12 @@
// Shared placeholder parsing and resolution helpers for browser-rendered API content.
(function () {
var root = window;
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.placeholderUtils = factory();
}
}(typeof window !== 'undefined' ? window : globalThis, function () {
var transformPattern = /^([a-z_][a-z0-9_]*)\((.*)\)$/i;
var monthNamesShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -48,6 +53,20 @@
return segments;
}
function resolveApiItems(value, itemsPath) {
var path = String(itemsPath || '').trim().replace(/\[([0-9]+)\]/g, '.$1');
if (path) {
var selected = resolvePath(value, path);
if (Array.isArray(selected)) return selected;
return selected && typeof selected === 'object' ? [selected] : [];
}
if (Array.isArray(value)) return value;
if (value && Array.isArray(value.items)) return value.items;
if (value && Array.isArray(value.results)) return value.results;
if (value && Array.isArray(value.data)) return value.data;
return value && typeof value === 'object' ? [value] : [];
}
function parsePlaceholderExpression(expression) {
var raw = String(expression || '').trim();
var segments = raw ? splitExpressionSegments(raw) : [];
@@ -402,7 +421,7 @@
}
function renderProgressPlaceholder(value, expression) {
var parsed = parsePlaceholderExpression(expression);
var parsed = parsePlaceholderExpression(String(expression || '').replace(/<\/?(?:span|strong|em|b|i|u|s|small|sub|sup|font)\b[^>]*>/gi, ''));
var transform = parsed.transforms.find(function (candidate) {
return candidate && candidate.name === 'progress';
});
@@ -582,8 +601,9 @@
return output;
}
root.placeholderUtils = {
return {
resolvePath: resolvePath,
resolveApiItems: resolveApiItems,
parsePlaceholderExpression: parsePlaceholderExpression,
resolvePlaceholderExpression: resolvePlaceholderExpression,
isImagePlaceholderExpression: isImagePlaceholderExpression,
@@ -593,4 +613,4 @@
formatPlaceholderValue: formatPlaceholderValue,
collectPlaceholderFieldPaths: collectPlaceholderFieldPaths
};
}());
}));
+1 -24
View File
@@ -365,30 +365,7 @@ export function createSlideFormRegionHelpers(options) {
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
var itemsPath = getApiSourceItemsPath(source, itemsPathOverride);
if (itemsPath !== undefined && itemsPath !== null && itemsPath !== '') {
var current = responseJson;
String(itemsPath).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return Array.isArray(current) ? current : [];
}
if (Array.isArray(responseJson)) {
return responseJson;
}
if (responseJson && Array.isArray(responseJson.items)) {
return responseJson.items;
}
if (responseJson && Array.isArray(responseJson.results)) {
return responseJson.results;
}
if (responseJson && Array.isArray(responseJson.data)) {
return responseJson.data;
}
return responseJson ? [responseJson] : [];
return window.placeholderUtils.resolveApiItems(responseJson, itemsPath);
}
function getApiItemCount(sourceId, itemsPathOverride) {
@@ -49,7 +49,7 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
const viewApiSource = normalizeApiSourceForForm(apiSource);
const viewOptions = options || {};
function requestModel(request) {
return Object.assign({ name: 'Request 1', api_url: '', request_method: 'GET', request_body_json: '', items_path: '', enabled: 1, update_interval_value: viewApiSource.updateIntervalValue, update_interval_unit: viewApiSource.updateIntervalUnit }, request, {
return Object.assign({ name: 'Request 1', api_url: '', request_method: 'GET', request_body_json: '', items_path: '', enabled: 1, source_enabled: viewApiSource.enabled, update_interval_value: viewApiSource.updateIntervalValue, update_interval_unit: viewApiSource.updateIntervalUnit }, request, {
lastResponseJson: typeof request.last_response_json === 'object' && request.last_response_json ? JSON.stringify(request.last_response_json, null, 2) : request.last_response_json || '',
lastPulledAtValue: request.last_pulled_at ? new Date(request.last_pulled_at).toISOString() : ''
});
@@ -16,7 +16,7 @@ module.exports = function registerApiSourceRoutes(app, deps) {
function syncSchedules(group, removed, actorId) {
(removed || []).forEach(function (id) { dataSourceTasks.removeRecurringRefresh('api-source', id); });
group.requests.forEach(function (request) {
if (!request.enabled) return dataSourceTasks.removeRecurringRefresh('api-source', request.id);
if (group.enabled === 0 || group.enabled === false || !request.enabled) return dataSourceTasks.removeRecurringRefresh('api-source', request.id);
dataSourceTasks.registerRecurringRefresh('api-source', request.id, group.name + ' / ' + request.name, request.update_interval_value || request.updateIntervalValue, request.update_interval_unit || request.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(request.id, actorId);
});
@@ -97,15 +97,15 @@ module.exports = function registerApiSourceRoutes(app, deps) {
if (req.body.data_source_action === 'toggle') {
const group = await fetchApiGroupById(pool, id);
if (!group) return res.status(404).send('API source not found');
const enabled = group.requests.some(function (request) { return request.enabled; }) ? 0 : 1;
await pool.query('UPDATE i_api_sources SET enabled = ?, modified_by = ? WHERE auth_source_id = ?', [enabled, getAuditUserId(req), id]);
group.requests.forEach(function (request) { request.enabled = enabled; });
const enabled = group.enabled === 0 || group.enabled === false ? 1 : 0;
await pool.query('UPDATE i_api_source_groups SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), id]);
group.enabled = enabled;
syncSchedules(group, [], getAuditUserId(req));
return res.redirect(base + '/' + id + '/edit?message=' + encodeURIComponent(enabled ? 'API requests enabled.' : 'API requests disabled.'));
return res.redirect(base + '/' + id + '/edit?message=' + encodeURIComponent(enabled ? 'API source enabled.' : 'API source disabled.'));
}
const payload = buildApiGroupPayload(req.body);
const result = await saveApiGroup(pool, id, payload, getAuditUserId(req), hasPermission(req.currentUser, 'api-sources.delete'));
syncSchedules({ name: payload.name, requests: result.requests }, result.removed, getAuditUserId(req));
syncSchedules({ name: payload.name, enabled: result.enabled, requests: result.requests }, result.removed, getAuditUserId(req));
if (!id) {
for (const request of result.requests) { if (request.enabled) await enqueue(request, payload, getAuditUserId(req)); }
}
@@ -119,15 +119,38 @@ module.exports = function registerApiSourceRoutes(app, deps) {
try {
const group = await fetchApiGroupById(pool, Number(req.params.id));
if (!group) return res.status(404).send('API source not found');
if (group.enabled === 0 || group.enabled === false) return res.status(409).json({ error: 'API source is disabled.' });
const requests = req.params.requestId ? group.requests.filter(function (request) { return Number(request.id) === Number(req.params.requestId); }) : group.requests;
if (!requests.length) return res.status(404).send('API request not found');
for (const request of requests) { if (request.enabled) await enqueue(request, group, getAuditUserId(req)); }
const tasks = [];
for (const request of requests) { if (request.enabled) tasks.push(await enqueue(request, group, getAuditUserId(req))); }
if (req.params.requestId && req.get('Accept') === 'application/json') {
if (!tasks.length) return res.status(409).json({ error: 'API request is disabled.' });
return res.status(202).json({
taskId: tasks[0].id,
stateUrl: base + '/' + group.id + '/requests/' + Number(req.params.requestId) + '/state',
message: 'Refreshing saved API request. Unsaved changes are kept.'
});
}
res.redirect(base + '/' + group.id + '/edit?message=' + encodeURIComponent('Enabled API request refreshes queued.'));
} catch (error) { next(error); }
}
app.post(base + '/:id/refresh', requirePermission('api-sources.allow'), refresh);
app.post(base + '/:id/requests/:requestId/refresh', requirePermission('api-sources.allow'), refresh);
app.get(base + '/:id/requests/:requestId/state', requirePermission('api-sources.allow'), async function (req, res, next) {
res.set('Cache-Control', 'no-store');
try {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isSafeInteger(taskId) || taskId <= 0) return res.status(400).json({ error: 'Missing refresh task id.' });
const [requests] = await pool.query('SELECT id FROM i_api_sources WHERE id = ? AND auth_source_id = ?', [Number(req.params.requestId), Number(req.params.id)]);
if (!requests.length) return res.status(404).json({ error: 'API request not found.' });
const task = await dataSourceTasks.getTaskStatusById(taskId);
if (!task || task.key !== 'api-source-refresh:' + Number(req.params.requestId)) return res.status(404).json({ error: 'Refresh task not found.' });
res.json(task);
} catch (error) { next(error); }
});
app.post(base + '/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
+2 -14
View File
@@ -8,6 +8,7 @@ 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');
const { resolveApiItems } = require('#src/web/public/js/shared/placeholder-utils');
function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
@@ -96,24 +97,11 @@ module.exports = function registerSlidesRoutes(app, deps) {
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();
if (itemsPath) {
const selected = itemsPath.split('.').reduce(function (current, segment) { return current === undefined || current === null ? '' : current[segment]; }, response);
return Array.isArray(selected) ? selected : [];
}
if (Array.isArray(response)) return response;
if (response && Array.isArray(response.items)) return response.items;
if (response && Array.isArray(response.results)) return response.results;
if (response && Array.isArray(response.data)) return response.data;
return response ? [response] : [];
};
const getItem = function (type, regionContent) {
const index = Math.max(0, Math.max(1, Number(regionContent && regionContent.item_number || 1)) - 1);
if (type === 'api') {
const source = apiSources.find(function (entry) { return Number(entry.id) === Number(regionContent.source_id); });
return getApiItems(source)[index] || null;
return resolveApiItems(source && source.responseJson, regionContent.items_path === undefined || regionContent.items_path === null ? source && source.items_path : regionContent.items_path)[index] || null;
}
if (type === 'rss') {
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
@@ -18,7 +18,7 @@
<div class="col-6 col-lg-3"><label class="form-label w-100">Unit<select class="form-select" data-request-field="update_interval_unit"><option value="seconds" {{#if (eq update_interval_unit 'seconds')}}selected{{/if}}>Seconds</option><option value="minutes" {{#if (eq update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></label></div>
</div>
{{#if id}}
<section class="mt-3 border-top pt-3" data-api-request-response aria-label="Latest response">
<section class="mt-3 border-top pt-3" id="api-request-response-{{id}}" data-api-request-response aria-label="Latest response">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h4 class="h6 mb-0">Latest response{{#if last_response_status}} <span class="badge text-bg-secondary ms-1">HTTP {{last_response_status}}</span>{{/if}}</h4>
{{#if lastResponseJson}}
@@ -36,7 +36,7 @@
</div>
<div class="card-footer d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary" data-api-source-test-url="{{#if groupId}}/data-sources/api-sources/{{groupId}}/test{{else}}/data-sources/api-sources/test{{/if}}"><i class="bi bi-plug me-1" aria-hidden="true"></i>Test Connection</button>
{{#if id}}{{#if (hasPermission currentUser 'api-sources.allow')}}<button type="button" class="btn btn-outline-secondary" data-manual-refresh-url="/data-sources/api-sources/{{groupId}}/requests/{{id}}/refresh" {{#unless enabled}}disabled{{/unless}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}
{{#if id}}{{#if (hasPermission currentUser 'api-sources.allow')}}<button type="button" class="btn btn-outline-secondary" data-manual-refresh-url="/data-sources/api-sources/{{groupId}}/requests/{{id}}/refresh" data-manual-refresh-target="#api-request-response-{{id}}" data-source-toggle-owner="api-source-toggle-form" data-source-item-enabled="{{#if enabled}}true{{else}}false{{/if}}" {{#unless enabled}}disabled{{else}}{{#unless source_enabled}}disabled{{/unless}}{{/unless}}><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}
</div>
</div>
{{/inline}}
@@ -47,6 +47,8 @@
</div>
</div>
{{#if isEdit}}<form id="api-source-toggle-form" method="post" action="{{formAction}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
<div class="row g-3 align-items-start api-source-layout">
<div class="col-12">
<form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
@@ -76,6 +78,7 @@
<div class="card-footer d-flex align-items-center justify-content-between row-gap-2 flex-wrap">
<div class="d-flex align-items-center flex-wrap gap-2 me-2">
<button type="button" class="btn btn-outline-primary" data-api-auth-test-url="{{#if isEdit}}/data-sources/api-sources/{{apiSource.id}}/auth-test{{else}}/data-sources/api-sources/auth-test{{/if}}"><i class="bi bi-shield-check me-1" aria-hidden="true"></i>Test Authentication</button>
{{#if isEdit}}{{#if (hasPermission currentUser 'api-sources.update')}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" 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}}
<button type="button" class="btn btn-primary" data-api-request-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add request</button>
</div>
<div class="d-flex align-items-center justify-content-end flex-wrap ms-auto">
@@ -25,6 +25,7 @@
<th data-table-sort-key="name">Name</th>
<th>Authentication</th>
<th data-table-sort-key="requests">Requests</th>
<th>Status</th>
<th data-table-sort-key="last_pulled">Last pulled</th>
<th>Actions</th>
</tr>
@@ -36,6 +37,7 @@
<td data-label="Name">{{name}}</td>
<td data-label="Authentication">{{#if (eq auth_method 'none')}}None{{else if (eq auth_method 'token_login')}}Login then token{{else if (eq auth_method 'bearer')}}Bearer token{{else if (eq auth_method 'basic')}}Basic{{else}}API key header{{/if}}</td>
<td data-label="Requests">{{request_count}}</td>
<td data-label="Status"><span class="badge {{#if enabled}}text-bg-success{{else}}text-bg-danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
<td data-label="Last pulled">
{{#if lastPulledAtValue}}
<time data-local-datetime datetime="{{lastPulledAtValue}}">{{lastPullLabel}}</time>
@@ -69,7 +71,7 @@
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="5" class="empty">No API sources yet.</td></tr>
<tr data-table-search-empty-default><td colspan="6" class="empty">No API sources yet.</td></tr>
{{/if}}
</tbody>
</table>
@@ -68,6 +68,7 @@
</div>
<script src="/assets/vendor/chart.js/chart.umd.js"></script>
<script src="/assets/js/shared/placeholder-utils.js"></script>
<script src="/assets/js/regions/chart-data.js"></script>
{{#if popupPreviewPayloadJson}}