From 49df51d92f48bc0a47f60acd7eb54651e198db94 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Wed, 21 Oct 2026 01:03:41 +0100 Subject: [PATCH] Release 2.16.2 --- CHANGELOG.md | 12 ++ build/package.player.json | 2 +- build/package.web.json | 2 +- docs/technical/schema.md | 3 +- docs/user/guide-data-sources.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/data/api-source-groups.js | 8 +- src/data/api-sources.js | 4 +- src/db/index.js | 1 + src/db/migrations.js | 7 + src/player/playlist.js | 19 +-- src/player/regions/api.js | 25 +--- src/web/lib/data-source-refresh.js | 3 + src/web/lib/media/slide-thumbnails.js | 24 +-- src/web/public/js/admin/admin-page.js | 19 ++- src/web/public/js/regions/chart-data.js | 12 +- src/web/public/js/regions/type/api.js | 25 +--- src/web/public/js/shared/placeholder-utils.js | 30 +++- .../public/js/slides/slide-form-regions.js | 25 +--- .../api-sources/form-view-model.js | 2 +- .../routes/data-sources/api-sources/routes.js | 37 ++++- src/web/routes/signage/slides/routes.js | 16 +- .../views/data-sources/api-sources/form.hbs | 7 +- .../views/data-sources/api-sources/list.hbs | 4 +- .../views/signage/slides/popup-preview.hbs | 1 + test/admin-page.test.js | 12 ++ test/api-source-groups.test.js | 138 +++++++++++++++++- test/api-source-request.test.js | 23 ++- test/chart-data.test.js | 12 ++ test/data-source-refresh.test.js | 9 ++ test/player-render-helpers.test.js | 70 +++++++++ 32 files changed, 390 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1741f3a..a1d8a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build/package.player.json b/build/package.player.json index 32b0be1..56642eb 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -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": { diff --git a/build/package.web.json b/build/package.web.json index 5552e5f..dbe1d33 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -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": { diff --git a/docs/technical/schema.md b/docs/technical/schema.md index de032e3..1e8d7ef 100644 --- a/docs/technical/schema.md +++ b/docs/technical/schema.md @@ -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` diff --git a/docs/user/guide-data-sources.md b/docs/user/guide-data-sources.md index 76070fc..80056fa 100644 --- a/docs/user/guide-data-sources.md +++ b/docs/user/guide-data-sources.md @@ -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: diff --git a/package-lock.json b/package-lock.json index 5fd15ba..10e8a94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5edda0c..6f6312d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/data/api-source-groups.js b/src/data/api-source-groups.js index 2378f7d..ff2224f 100644 --- a/src/data/api-source-groups.js +++ b/src/data/api-source-groups.js @@ -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.'); diff --git a/src/data/api-sources.js b/src/data/api-sources.js index aecf104..fe1e4a8 100644 --- a/src/data/api-sources.js +++ b/src/data/api-sources.js @@ -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 }; diff --git a/src/db/index.js b/src/db/index.js index 9c44f43..42452a2 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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, diff --git a/src/db/migrations.js b/src/db/migrations.js index 3adb618..f133bd8 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -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'); + } } ]; diff --git a/src/player/playlist.js b/src/player/playlist.js index 14a4341..921c15c 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -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') { diff --git a/src/player/regions/api.js b/src/player/regions/api.js index c26ab7a..a787ea1 100644 --- a/src/player/regions/api.js +++ b/src/player/regions/api.js @@ -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) { diff --git a/src/web/lib/data-source-refresh.js b/src/web/lib/data-source-refresh.js index f7c8c36..0b3e4ba 100644 --- a/src/web/lib/data-source-refresh.js +++ b/src/web/lib/data-source-refresh.js @@ -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 = ''; diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index a0cf079..96fdb15 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -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); }); diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index d0ce39f..943093a 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -92,6 +92,10 @@ toggleButton.className = toggleButton.className.replace(/btn-(danger|success)/g, willEnable ? 'btn-danger' : 'btn-success'); toggleButton.innerHTML = '' + (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; } diff --git a/src/web/public/js/regions/chart-data.js b/src/web/public/js/regions/chart-data.js index c8a54cb..9de349b 100644 --- a/src/web/public/js/regions/chart-data.js +++ b/src/web/public/js/regions/chart-data.js @@ -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) { diff --git a/src/web/public/js/regions/type/api.js b/src/web/public/js/regions/type/api.js index b2fb28b..7281435 100644 --- a/src/web/public/js/regions/type/api.js +++ b/src/web/public/js/regions/type/api.js @@ -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) { diff --git a/src/web/public/js/shared/placeholder-utils.js b/src/web/public/js/shared/placeholder-utils.js index ced6aad..cec4586 100644 --- a/src/web/public/js/shared/placeholder-utils.js +++ b/src/web/public/js/shared/placeholder-utils.js @@ -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 }; -}()); \ No newline at end of file +})); \ No newline at end of file diff --git a/src/web/public/js/slides/slide-form-regions.js b/src/web/public/js/slides/slide-form-regions.js index 36dc3c4..7f17123 100644 --- a/src/web/public/js/slides/slide-form-regions.js +++ b/src/web/public/js/slides/slide-form-regions.js @@ -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) { diff --git a/src/web/routes/data-sources/api-sources/form-view-model.js b/src/web/routes/data-sources/api-sources/form-view-model.js index 9f3f0c0..3877dc0 100644 --- a/src/web/routes/data-sources/api-sources/form-view-model.js +++ b/src/web/routes/data-sources/api-sources/form-view-model.js @@ -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() : '' }); diff --git a/src/web/routes/data-sources/api-sources/routes.js b/src/web/routes/data-sources/api-sources/routes.js index b858c41..d3bc6b3 100644 --- a/src/web/routes/data-sources/api-sources/routes.js +++ b/src/web/routes/data-sources/api-sources/routes.js @@ -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 { diff --git a/src/web/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js index 7471581..fd65015 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -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(/ {{#if id}} -
+

Latest response{{#if last_response_status}} HTTP {{last_response_status}}{{/if}}

{{#if lastResponseJson}} @@ -36,7 +36,7 @@
{{/inline}} @@ -47,6 +47,8 @@ +{{#if isEdit}}
{{/if}} +
@@ -76,6 +78,7 @@