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

+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
## 2.16.2 - 2026-10-21
### Added
- Added an API source Enable/Disable action in the Connection card that pauses all request refreshes while preserving each request's individual enabled setting and unsaved form changes.
### Fixed
- API request Refresh now updates only the latest response without reloading the editor or discarding unsaved connection settings and request changes.
- Progress placeholders ignore inline rich-text formatting inside their arguments, so WYSIWYG-styled values and field paths calculate the correct percentage instead of displaying zero.
- API Items path accepts a single JSON object as item 1 as well as arrays, with consistent selection across editor samples, playback, charts, previews, thumbnails, and remote-image preparation.
## 2.16.1 - 2026-10-21
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.16.1",
"version": "2.16.2",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.16.1",
"version": "2.16.2",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+2 -1
View File
@@ -214,8 +214,9 @@ Tables generally use a numeric auto-increment `id` primary key. The relationship
### `i_api_source_groups`
- `id`, `name`, `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_refresh_url`, `token_refresh_request_body_json`, `token_refresh_response_path`, `token_header_name`, `token_header_prefix`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `id`, `name`, `enabled`, `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_refresh_url`, `token_refresh_request_body_json`, `token_refresh_response_path`, `token_header_name`, `token_header_prefix`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Unique group name; authentication applies to every child request. `auth_method = 'none'` supports public endpoints. Token caches are keyed by group and authentication configuration.
- `enabled` is the source-wide refresh gate, added by migration 2.16.2 with a default of `1`. Disabling a group leaves child enabled flags unchanged. Refresh lookups combine group/request flags, and queued work checks effective enabled state before making an outbound request.
### `i_api_sources`
+3 -1
View File
@@ -27,12 +27,14 @@ When an RSS feed fails, open the latest-items preview and check the reported pul
An API source groups one or more named requests under shared authentication. Use **Add request** to create separate cards for endpoints such as event information and donors. Each request has its own URL, method, items path, enabled state, refresh interval, latest response, and **Test Connection** / **Refresh now** actions. Save the source to persist added, edited, or removed requests. A request used by a slide cannot be removed.
Use **Disable** in the Connection card to pause the entire API source. This stops scheduled and manual refreshes without changing individual request switches or discarding unsaved form edits. **Enable** resumes only requests whose own switches are enabled. Existing response snapshots are retained, and authentication/connection tests remain available for troubleshooting. The API source list shows the overall enabled status.
Each request can use either GET or POST:
- **GET:** retrieve data directly from the API URL.
- **POST:** send a JSON request body with the request.
Use **Items path** when the records needed by a slide are nested inside the response. Enter a dot-separated path such as `items` or `results.data` to identify the array that should be treated as the source items.
Use **Items path** when the records needed by a slide are nested inside the response. Enter a dot-separated path such as `items` or `results.data` to select an array or a single object. An object is treated as item 1. For a campaign returned as `{ "data": { ... } }`, use `data` and placeholders relative to it, such as `amount_raised.value`. Leave the path blank to retain the response root and existing automatic array detection. Missing paths or paths selecting scalar values return no items.
API authentication supports:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.16.1",
"version": "2.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.16.1",
"version": "2.16.2",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.16.1",
"version": "2.16.2",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+5 -3
View File
@@ -49,7 +49,7 @@ async function fetchApiGroupById(pool, id) {
const [groups] = await pool.query('SELECT * FROM i_api_source_groups WHERE id = ?', [id]);
if (!groups.length) return null;
const [requests] = await pool.query(API_REQUEST_SELECT + ' WHERE request.auth_source_id = ? ORDER BY request.id', [id]);
return Object.assign({}, groups[0], { requests: requests.map(function (request) { return Object.assign({}, request, { name: request.request_name }); }) });
return Object.assign({}, groups[0], { requests: requests.map(function (request) { return Object.assign({}, request, { name: request.request_name, enabled: request.request_enabled === undefined ? request.enabled : request.request_enabled }); }) });
}
async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) {
@@ -77,11 +77,13 @@ async function getApiRequestUsage(pool) {
async function saveApiGroup(pool, id, payload, actorId, canDelete) {
const connection = await pool.getConnection();
let enabled = 1;
try {
await connection.beginTransaction();
if (id) {
const [groups] = await connection.query('SELECT id FROM i_api_source_groups WHERE id = ? FOR UPDATE', [id]);
const [groups] = await connection.query('SELECT id, enabled FROM i_api_source_groups WHERE id = ? FOR UPDATE', [id]);
if (!groups.length) throw validationError('API source not found.', 404);
enabled = groups[0].enabled === 0 || groups[0].enabled === false ? 0 : 1;
}
const [duplicates] = await connection.query('SELECT id FROM i_api_source_groups WHERE name = ? AND id <> ?', [payload.name, id || 0]);
if (duplicates.length) throw validationError('An API source with that name already exists.');
@@ -113,7 +115,7 @@ async function saveApiGroup(pool, id, payload, actorId, canDelete) {
}
if (removed.length) await connection.query('DELETE FROM i_api_sources WHERE auth_source_id = ? AND id IN (?)', [id, removed]);
await connection.commit();
return { id: Number(id), removed: removed, requests: payload.requests };
return { id: Number(id), enabled: enabled, removed: removed, requests: payload.requests };
} catch (error) {
await connection.rollback();
if (error.code === 'ER_DUP_ENTRY') throw validationError('An API source with that name already exists.');
+2 -2
View File
@@ -17,7 +17,7 @@ const tokenCache = new Map();
const tokenRequests = new Map();
const diagnosticContext = Symbol('apiSourceDiagnostics');
const API_AUTH_FIELDS = ['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_refresh_url', 'token_refresh_request_body_json', 'token_refresh_response_path', 'token_header_name', 'token_header_prefix'];
const API_REQUEST_SELECT = 'SELECT request.*, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, \' / \', request.name) AS name, ' + API_AUTH_FIELDS.map(function (field) { return 'auth.' + field; }).join(', ') + ' FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id';
const API_REQUEST_SELECT = 'SELECT request.*, request.enabled AS request_enabled, auth.enabled AS source_enabled, (request.enabled AND auth.enabled) AS enabled, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, \' / \', request.name) AS name, ' + API_AUTH_FIELDS.map(function (field) { return 'auth.' + field; }).join(', ') + ' FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id';
function createDiagnosticSanitizer(source) {
const secrets = [];
@@ -176,7 +176,7 @@ function buildAuthHeaders(source) {
async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query(
"SELECT request.id, request.auth_source_id, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, ' / ', request.name) AS name, request.items_path, request.enabled, request.update_interval_value, request.update_interval_unit, request.last_pulled_at, request.last_pull_error, request.last_response_status, request.last_response_content_type, request.last_response_json FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id ORDER BY auth.name, request.id"
"SELECT request.id, request.auth_source_id, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, ' / ', request.name) AS name, request.items_path, (request.enabled AND auth.enabled) AS enabled, request.update_interval_value, request.update_interval_unit, request.last_pulled_at, request.last_pull_error, request.last_response_status, request.last_response_content_type, request.last_response_json FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id ORDER BY auth.name, request.id"
);
return { apiSources: apiSources };
+1
View File
@@ -263,6 +263,7 @@ async function ensureSchema(pool, options) {
CREATE TABLE IF NOT EXISTS i_api_source_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
enabled TINYINT(1) NOT NULL DEFAULT 1,
auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
auth_username VARCHAR(255) NULL,
auth_password MEDIUMTEXT NULL,
+7
View File
@@ -717,6 +717,13 @@ const VERSIONED_MIGRATIONS = [
await pool.query('UPDATE i_api_sources SET auth_source_id = id WHERE auth_source_id IS NULL');
await ensureForeignKey(pool, 'i_api_sources', 'fk_api_sources_auth_source', 'auth_source_id', 'i_api_source_groups', 'id', 'RESTRICT');
}
},
{
version: '2.16.2',
label: 'v2.16.2 API source group enable state',
run: async function (pool) {
await ensureColumn(pool, 'i_api_source_groups', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'name');
}
}
];
+2 -17
View File
@@ -5,6 +5,7 @@ const { convertWeatherSnapshot } = require('#src/data/weather-units');
const fs = require('fs');
const path = require('path');
const { createStyledQrCodeDataUrl } = require('#src/data/qr-code');
const { resolveApiItems } = require('#src/web/public/js/shared/placeholder-utils');
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
@@ -106,22 +107,6 @@ function createPlayerPlaylistService(options) {
}, value);
}
function getApiItems(responseJson, itemsPath) {
let current = responseJson;
const pathValue = String(itemsPath || '').trim();
if (pathValue) {
pathValue.split('.').forEach(function (segment) {
current = current === undefined || current === null ? '' : 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] : [];
}
async function cacheRemoteImage(url) {
const remoteUrl = String(url || '').trim();
if (!remoteImageCacheDir || !/^https?:\/\//i.test(remoteUrl)) return '';
@@ -172,7 +157,7 @@ function createPlayerPlaylistService(options) {
const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1;
if (String(region.type || '').toLowerCase() === 'api') {
const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); });
const items = source ? getApiItems(source.responseJson, region.items_path) : [];
const items = source ? resolveApiItems(source.responseJson, region.items_path === undefined || region.items_path === null ? source.items_path : region.items_path) : [];
await cacheSource(source, items[itemNumber], expressions);
}
if (String(region.type || '').toLowerCase() === 'rss') {
+1 -24
View File
@@ -23,30 +23,7 @@ function getApiSourceItems(sourceId, itemsPathOverride) {
var source = getApiSourceById(sourceId);
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 placeholderUtils.resolveApiItems(responseJson, itemsPath);
}
function getApiItem(sourceId, itemNumber, itemsPathOverride) {
+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}}
+12
View File
@@ -22,4 +22,16 @@ test('data-source toggles update in place instead of reloading the edit form', (
assert.ok(adminPageScript.includes('data-async-data-source-toggle'));
assert.ok(adminPageScript.includes("showToast(message || (willEnable ? 'Data source enabled.' : 'Data source disabled.'), 'success')"));
assert.ok(!adminPageScript.includes("/^\\/data-sources\\/(?:api-sources|rss-feeds|weather)\\/\\d+$/.test(actionPath);"));
assert.ok(adminPageScript.includes('data-source-toggle-owner'));
assert.ok(adminPageScript.includes("control.disabled = !willEnable || control.getAttribute('data-source-item-enabled') === 'false';"));
});
test('manual refresh targets poll and replace response panels instead of navigating the form', () => {
const start = adminPageScript.indexOf("var refreshButton = target.closest('[data-manual-refresh-url]');");
const end = adminPageScript.indexOf("var button = target.closest('button[name=\"save_action\"]", start);
const handler = adminPageScript.slice(start, end);
assert.match(handler, /getAttribute\('data-manual-refresh-target'\)/);
assert.match(handler, /if \(refreshTargetSelector\)\s*\{\s*return response\.json\(\)/);
assert.match(handler, /watchRefreshTask\(payload\.stateUrl, payload\.taskId, refreshTargetSelector, refreshSequence\)/);
assert.ok(handler.indexOf('return response.json()') < handler.indexOf('window.location.assign(response.url)'));
});
+132 -6
View File
@@ -1,6 +1,6 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildApiGroupPayload, saveApiGroup } = require('../src/data/api-source-groups');
const { buildApiGroupPayload, saveApiGroup, fetchApiGroupById } = require('../src/data/api-source-groups');
const { getPendingMigrations } = require('../src/db/migrations');
const { fetchApiSourcesData, fetchApiSourceById, testApiSourceAuthentication } = require('../src/data/api-sources');
@@ -8,7 +8,7 @@ function payload(requests, auth) {
return buildApiGroupPayload(Object.assign({ name: 'Tiltify', auth_method: 'none', requests_json: JSON.stringify(requests) }, auth));
}
function createPool(existing, slides) {
function createPool(existing, slides, groupEnabled = 1) {
const calls = [];
const connection = {
async beginTransaction() { calls.push(['begin']); },
@@ -17,7 +17,7 @@ function createPool(existing, slides) {
release() { calls.push(['release']); },
async query(sql, params) {
calls.push([sql, params]);
if (sql.includes('SELECT id FROM i_api_source_groups WHERE id')) return [[{ id: 5 }]];
if (sql.includes('SELECT id, enabled FROM i_api_source_groups WHERE id')) return [[{ id: 5, enabled: groupEnabled }]];
if (sql.includes('SELECT id FROM i_api_source_groups WHERE name')) return [[]];
if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))];
if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))];
@@ -81,14 +81,29 @@ test('2.16.1 migration preserves source IDs and backfills parent authentication'
return [[]];
} };
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.0' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.16.1');
await pending[0].run(pool);
const migration = pending.find(value => value.version === '2.16.1');
assert.ok(migration);
await migration.run(pool);
assert.ok(calls.some(([sql]) => sql.includes('INSERT IGNORE INTO i_api_source_groups (id,') && sql.includes('SELECT id,')));
assert.ok(calls.some(([sql]) => sql.includes('SET auth_source_id = id WHERE auth_source_id IS NULL')));
assert.ok(!calls.some(([sql]) => /UPDATE i_api_sources SET id\s*=/.test(sql)));
});
test('2.16.2 adds an enabled master switch without rewriting individual requests', async () => {
const calls = [];
const pool = { async query(sql) {
calls.push(sql);
if (sql.includes('information_schema.COLUMNS')) return [[{ column_count: 0 }]];
return [[]];
} };
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.1' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.16.2');
await pending[0].run(pool);
assert.ok(calls.some(sql => sql.includes('ALTER TABLE i_api_source_groups ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1')));
assert.ok(!calls.some(sql => sql.includes('UPDATE i_api_sources')));
});
test('editor/player API lists exclude credentials while refresh lookup resolves group authentication', async () => {
const queries = [];
const pool = { async query(sql) { queries.push(sql); return [[]]; } };
@@ -116,4 +131,115 @@ test('authentication-only tests never call a data endpoint', async () => {
assert.equal(publicAuth.ok, true);
assert.equal(calls.length, 1);
} finally { global.fetch = originalFetch; }
});
test('API refresh queues a saved request without redirects and scopes task status to that request', async () => {
const registerRoutes = require('../src/web/routes/data-sources/api-sources/routes');
const routes = { get: {}, post: {} };
const queued = [];
let enabled = 1;
let taskKey = 'api-source-refresh:43';
const pool = { async query(sql, params) {
if (sql.startsWith('SELECT * FROM i_api_source_groups')) return [[{ id: 5, name: 'Tiltify' }]];
if (sql.includes('WHERE request.auth_source_id')) return [[{ id: 43, request_name: 'Event', enabled }]];
if (sql.startsWith('SELECT id FROM i_api_sources WHERE id')) return [params[0] === 43 && params[1] === 5 ? [{ id: 43 }] : []];
throw new Error('Unexpected query: ' + sql);
} };
registerRoutes({ get(path, ...handlers) { routes.get[path] = handlers; }, post(path, ...handlers) { routes.post[path] = handlers; } }, {
pool, common: {}, requirePermission: permission => permission, getAuditUserId: () => 1,
backgroundTaskQueue: { async enqueueTask(task) { queued.push(task); return { id: 101 }; } },
dataSourceTasks: { async getTaskStatusById() { return { id: 101, key: taskKey, status: 'completed' }; } }
});
function response() {
return { statusCode: 200, headers: {}, status(code) { this.statusCode = code; return this; }, set(key, value) { this.headers[key] = value; return this; }, json(body) { this.body = body; return this; }, redirect() { throw new Error('AJAX refresh must not redirect'); } };
}
const refresh = routes.post['/data-sources/api-sources/:id/requests/:requestId/refresh'];
const state = routes.get['/data-sources/api-sources/:id/requests/:requestId/state'];
assert.equal(refresh[0], 'api-sources.allow');
assert.equal(state[0], 'api-sources.allow');
const req = { params: { id: '5', requestId: '43' }, get: () => 'application/json', query: { refresh_task_id: '101' } };
const next = error => { throw error; };
const started = response();
await refresh[1](req, started, next);
assert.equal(started.statusCode, 202);
assert.equal(started.body.taskId, 101);
assert.equal(started.body.stateUrl, '/data-sources/api-sources/5/requests/43/state');
assert.equal(queued[0].payload.sourceId, 43);
const completed = response();
await state[1](req, completed, next);
assert.equal(completed.body.status, 'completed');
assert.equal(completed.headers['Cache-Control'], 'no-store');
taskKey = 'api-source-refresh:44';
const wrongTask = response();
await state[1](req, wrongTask, next);
assert.equal(wrongTask.statusCode, 404);
const wrongGroup = response();
await state[1]({ ...req, params: { id: '6', requestId: '43' } }, wrongGroup, next);
assert.equal(wrongGroup.statusCode, 404);
enabled = 0;
const disabled = response();
await refresh[1](req, disabled, next);
assert.equal(disabled.statusCode, 409);
assert.equal(queued.length, 1);
});
test('saving a disabled group does not re-enable it or erase individual request switches', async () => {
const pool = createPool([5, 6], [], 0);
const result = await saveApiGroup(pool, 5, payload([
{ id: 5, name: 'Event', api_url: 'https://example.com/event', enabled: true },
{ id: 6, name: 'Donors', api_url: 'https://example.com/donors', enabled: false }
]), 1, true);
assert.equal(result.enabled, 0);
assert.deepEqual(result.requests.map(request => request.enabled), [1, 0]);
const parentUpdate = pool.calls.find(([sql]) => sql.startsWith('UPDATE i_api_source_groups'));
assert.doesNotMatch(parentUpdate[0], /\benabled\s*=/);
});
test('disabled parent groups preserve raw request switches in the editor', async () => {
const pool = { async query(sql) {
if (sql.startsWith('SELECT * FROM i_api_source_groups')) return [[{ id: 5, enabled: 0 }]];
return [[{ id: 43, request_name: 'Event', enabled: 0, request_enabled: 1, source_enabled: 0 }]];
} };
const group = await fetchApiGroupById(pool, 5);
assert.equal(group.enabled, 0);
assert.equal(group.requests[0].enabled, 1);
assert.equal(group.requests[0].source_enabled, 0);
});
test('the master toggle changes only the group and resumes only individually enabled requests', async () => {
const registerRoutes = require('../src/web/routes/data-sources/api-sources/routes');
const routes = {};
const queries = [];
const removed = [];
const registered = [];
const requests = [{ id: 43, request_name: 'Event', request_enabled: 1 }, { id: 44, request_name: 'Donors', request_enabled: 0 }];
let enabled = 1;
const pool = { async query(sql, params) {
queries.push(sql);
if (sql.startsWith('SELECT * FROM i_api_source_groups')) return [[{ id: 5, name: 'Tiltify', enabled }]];
if (sql.includes('WHERE request.auth_source_id')) return [requests.map(request => ({ ...request, enabled: enabled && request.request_enabled, source_enabled: enabled, update_interval_value: 30, update_interval_unit: 'seconds' }))];
if (sql.startsWith('UPDATE i_api_source_groups SET enabled')) { enabled = params[0]; return [{}]; }
throw new Error('Unexpected query: ' + sql);
} };
registerRoutes({ get() {}, post(path, ...handlers) { routes[path] = handlers; } }, {
pool, common: {}, requirePermission: permission => permission, getAuditUserId: () => 1,
dataSourceTasks: { removeRecurringRefresh(type, id) { removed.push(id); }, registerRecurringRefresh(type, id) { registered.push(id); } }
});
const res = { redirect(url) { this.url = url; }, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; } };
const next = error => { throw error; };
const route = routes['/data-sources/api-sources/:id'];
assert.equal(route[0], 'api-sources.update');
const req = { params: { id: '5' }, body: { data_source_action: 'toggle', name: 'Unsaved draft must not be saved' } };
await route[1](req, res, next);
assert.equal(enabled, 0);
assert.deepEqual(removed, [43, 44]);
assert.deepEqual(registered, []);
assert.deepEqual(requests.map(request => request.request_enabled), [1, 0]);
await routes['/data-sources/api-sources/:id/requests/:requestId/refresh'][1]({ params: { id: '5', requestId: '43' } }, res, next);
assert.equal(res.statusCode, 409);
assert.match(res.body.error, /source is disabled/);
await route[1](req, res, next);
assert.equal(enabled, 1);
assert.deepEqual(registered, [43]);
assert.ok(!queries.some(sql => sql.startsWith('UPDATE i_api_sources')));
});
+22 -1
View File
@@ -244,7 +244,7 @@ test('saved requests show visible full-width response trees with independent exp
const html = renderEdit({ id: 42, name: 'Campaign', requests: [
{ id: 43, name: 'Event', last_response_status: 200, last_response_json: '{"data":{"total":42}}' },
{ id: 44, name: 'Donors', last_response_status: 200, last_response_json: '{"data":[{"name":"Donor"}]}' }
] }, {}, '', { permissionKeys: ['api-sources.update'] });
] }, {}, '', { permissionKeys: ['api-sources.update', 'api-sources.allow'] });
assert.equal((html.match(/data-api-request-response/g) || []).length, 2);
assert.equal((html.match(/data-json-toggle-expand-all/g) || []).length, 2);
assert.equal((html.match(/data-json-toggle-collapse-all/g) || []).length, 2);
@@ -253,6 +253,27 @@ test('saved requests show visible full-width response trees with independent exp
assert.match(html, /HTTP 200/);
assert.match(html, /data-api-request-title>Request - Event<\/h3>/);
assert.match(html, /data-api-request-title>Request - Donors<\/h3>/);
for (const id of [43, 44]) {
assert.ok(html.includes('id="api-request-response-' + id + '"'));
assert.ok(html.includes('data-manual-refresh-target="#api-request-response-' + id + '"'));
}
});
test('the Connection master toggle disables refresh actions without changing request switches', () => {
const renderEdit = require('../src/web/routes/data-sources/api-sources/edit');
const user = { permissionKeys: ['api-sources.update', 'api-sources.allow'] };
const requests = [{ id: 43, name: 'Event', enabled: 1 }];
const enabled = renderEdit({ id: 5, name: 'Tiltify', enabled: 1, requests }, {}, '', user);
const disabled = renderEdit({ id: 5, name: 'Tiltify', enabled: 0, requests }, {}, '', user);
assert.match(enabled, /form="api-source-toggle-form" data-async-data-source-toggle data-enabled="true"/);
assert.match(disabled, /form="api-source-toggle-form" data-async-data-source-toggle data-enabled="false"/);
assert.match(disabled, /data-request-field="enabled" checked/);
assert.match(disabled, /data-source-item-enabled="true" disabled/);
assert.match(disabled, /data-async-command><input type="hidden" name="data_source_action" value="toggle"/);
for (const html of [enabled, disabled]) {
assert.ok(html.indexOf('data-api-auth-test-url') < html.indexOf('data-async-data-source-toggle'));
assert.ok(html.indexOf('data-async-data-source-toggle') < html.indexOf('data-api-request-add'));
}
});
test('connection diagnostics follow the tested section and can be dismissed and reopened', async () => {
+12
View File
@@ -5,6 +5,7 @@ const vm = require('node:vm');
function loadChartData() {
const context = { window: {} };
vm.runInNewContext(fs.readFileSync('src/web/public/js/shared/placeholder-utils.js', 'utf8'), context);
vm.runInNewContext(fs.readFileSync('src/web/public/js/regions/chart-data.js', 'utf8'), context);
return context.window.pulseChartData;
}
@@ -64,6 +65,17 @@ test('uses an API source default items path and ignores non-numeric rows', () =>
assert.deepEqual(Array.from(result.datasets[0].data), [1200, 4]);
});
test('API charts accept a single object items path without falling back on missing paths', () => {
const chartData = loadChartData();
const sources = [{ id: 4, items_path: 'data', responseJson: { data: { name: 'Campaign', amount_raised: { value: '200' } } } }];
const config = { source_kind: 'api', source_id: 4, label_path: 'name', value_path: 'amount_raised.value' };
const result = chartData.fromContent(config, sources);
assert.deepEqual(Array.from(result.labels), ['Campaign']);
assert.deepEqual(Array.from(result.datasets[0].data), [200]);
const missing = chartData.fromContent({ ...config, items_path: 'missing' }, sources);
assert.deepEqual(Array.from(missing.labels), []);
});
test('normalizes multiple series for line charts', () => {
const chartData = loadChartData();
const result = chartData.fromContent({
+9
View File
@@ -116,6 +116,15 @@ test('refreshApiSource skips notifications when the API snapshot is unchanged',
assert.deepEqual(notifyCalls, []);
});
test('refreshApiSource skips disabled requests before outbound calls or snapshot writes', async () => {
const connection = createConnection();
const result = await refreshApiSource({ async getConnection() { return connection; } }, {
async fetchApiSourceResponse() { throw new Error('Disabled sources must not be fetched'); }
}, { id: 7, enabled: 0 }, 1);
assert.deepEqual(result, { skipped: true, reason: 'disabled' });
assert.deepEqual(connection.calls, [['release']]);
});
test('refreshRssFeed notifies players only when the RSS items change', async () => {
const connection = createConnection({
rssItemSnapshots: [JSON.stringify({ title: 'Old item' })],
+70
View File
@@ -195,6 +195,57 @@ test('server and browser rich-text rendering stay in sync', () => {
assert.equal(browserContext.renderEditorJsContent(value), renderEditorJsContent(value));
});
test('shared API item selection treats object paths as one item and preserves array/root behavior', () => {
const helpers = require('../src/web/public/js/shared/placeholder-utils');
const campaign = { name: 'Campaign', amount_raised: { value: '200' }, goal: { value: '1000' } };
const response = { data: campaign };
assert.deepEqual(helpers.resolveApiItems(response, 'data'), [campaign]);
assert.deepEqual(helpers.resolveApiItems(response, ''), [response]);
assert.deepEqual(helpers.resolveApiItems(response, 'missing'), []);
assert.deepEqual(helpers.resolveApiItems(response, 'data.name'), []);
assert.deepEqual(helpers.resolveApiItems(response, 'data.absent.nested'), []);
assert.deepEqual(helpers.resolveApiItems({ data: null }, 'data'), []);
assert.deepEqual(helpers.resolveApiItems({ data: {} }, 'data'), [{}]);
const rows = [campaign];
for (const key of ['items', 'results', 'data']) {
assert.equal(helpers.resolveApiItems({ [key]: rows }, ''), rows);
assert.equal(helpers.resolveApiItems({ [key]: rows }, key), rows);
}
assert.equal(helpers.resolveApiItems(rows, ''), rows);
assert.deepEqual(helpers.resolveApiItems({ data: rows }, 'data[0]'), [campaign]);
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
assert.equal(JSON.stringify(sandbox.window.placeholderUtils.resolveApiItems(response, 'data')), JSON.stringify([campaign]));
});
test('API editor and player resolve object paths, root overrides, and numeric placeholders consistently', () => {
let editor;
const campaign = { name: 'Campaign', amount_raised: { value: '200' }, goal: { value: '1000' } };
const response = { data: campaign };
const sources = [{ id: 4, items_path: 'data', responseJson: response }];
const context = {
window: {
pulseRegionTypes: { register(_type, definition) { editor = definition; } },
pulsePlayerRegionTypes: { register() {} }
},
initialData: { apiSources: sources },
escapeHtml: String
};
for (const file of ['../src/web/public/js/shared/placeholder-utils.js', '../src/web/public/js/regions/type/api.js', '../src/player/regions/api.js']) {
vm.runInNewContext(fs.readFileSync(require.resolve(file), 'utf8'), context);
}
assert.equal(editor.getItems(4, sources)[0], campaign);
assert.equal(editor.getItemCount(4, sources), 1);
assert.equal(context.getApiItem(4, 1), campaign);
assert.equal(context.getApiItem(4, 2), null);
assert.equal(editor.getItems(4, sources, '')[0], response);
assert.equal(context.getApiItem(4, 1, ''), response);
assert.equal(editor.getItemCount(4, sources, 'missing'), 0);
assert.equal(context.getApiItem(4, 1, 'missing'), null);
assert.equal(context.substituteApiVariables('{{amount_raised.value}}', campaign, 4), '200');
assert.match(context.substituteApiVariables('{{progress(amount_raised.value,goal.value)}}', campaign, 4), /width:20%/);
});
test('shared API progress placeholder renders a clamped bar from numeric fields', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
@@ -237,6 +288,25 @@ test('shared API progress placeholder renders a clamped bar from numeric fields'
assert.match(literalMarkup, /aria-valuenow="30"/);
});
test('progress placeholders ignore rich-text formatting inside numeric and field arguments', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
const placeholderUtils = sandbox.window.placeholderUtils;
const literal = 'progress(<span style="font-family: \'Ubuntu Regular\', Helvetica, sans-serif; font-size: 96px; color: #ffffff;">200</span>,<span style="font-family: \'Ubuntu Regular\', Helvetica, sans-serif; font-size: 96px; color: #ffffff;">1000</span>,#F47B20,#2D2D2D,pill)';
assert.equal(placeholderUtils.isProgressPlaceholderExpression(literal), true);
const markup = placeholderUtils.renderProgressPlaceholder({}, literal);
assert.match(markup, /aria-valuenow="20"/);
assert.match(markup, /width:20%/);
assert.match(markup, /background-color:#f47b20/);
assert.match(markup, /background-color:#2d2d2d/);
assert.doesNotMatch(markup, /Ubuntu|font-size:96px/);
const fields = placeholderUtils.renderProgressPlaceholder(
{ raised: { value: 200 }, target: { amount: 1000 } },
'progress(<strong><span>raised.value</span></strong>,<em>target.amount</em>,pill)'
);
assert.match(fields, /aria-valuenow="20"/);
});
test('shared placeholder transforms support arithmetic', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);