Compare commits

...
10 Commits
Author SHA1 Message Date
lzstealth 9097d45d6a Improve announcement rendering and theme assets 2026-08-26 01:08:59 +01:00
lzstealth a7d867dd6f Adjust API source response header spacing
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-22 01:39:35 +01:00
lzstealth 196c640f9b Release 2.8.7
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s
2026-08-22 01:33:35 +01:00
lzstealth 7bd4a792ee Merge remote-tracking branch 'Gitea_SSH/main'
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m50s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
# Conflicts:
#	CHANGELOG.md
#	build/package.player.json
#	build/package.web.json
#	package-lock.json
#	package.json
2026-08-18 02:25:15 +01:00
lzstealth e1e759f64e Release 2.8.6 2026-08-18 02:23:42 +01:00
lzstealth f071c21219 Release 2.8.5
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 02:15:18 +01:00
lzstealth e984f36875 Release 2.8.5
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 02:07:48 +01:00
lzstealth bbd517abbb Fix scheduled task run notification
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 00:54:13 +01:00
lzstealth 403a928b9e Release 2.8.4
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 35s
2026-08-17 00:46:07 +01:00
lzstealth 7bb34f40fe Release 2.8.3
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 00:08:05 +01:00
59 changed files with 1276 additions and 231 deletions
+54
View File
@@ -2,6 +2,60 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.8.8 - 2026-08-26
### Changed
- Refreshed the vendored AdminLTE assets to 4.8.5.
- Added AdminLTE extended palette colours to announcement colour choices and player rendering.
- Remove Digital Signage Subheading and top padding.
## 2.8.7 - 2026-08-22
### Added
- Added configurable JSON POST requests and two-step login-then-token authentication for API sources.
## 2.8.6 - 2026-08-18
### Fixed
- Added live URL validation with inline feedback and explicit HTTP or HTTPS scheme enforcement for URL fields.
- Prevented Enter in slide editor inputs from implicitly saving the slide.
- Collapsed nested API response JSON sections by default while keeping the root response visible.
## 2.8.5 - 2026-08-17
### Fixed
- Fixed thumbnail capture timing so video assets are ready before the preview canvas is captured.
- Replaced unavailable webpage thumbnails with a subdued placeholder while keeping live webpage previews intact.
## 2.8.4 - 2026-08-17
### Added
- Added local caching for API and RSS image placeholders under `player-cache/remote-images` for offline player playback.
- Added reconciliation of cached remote images so files no longer referenced by slides are removed.
### Fixed
- Fixed popup preview authentication for background thumbnail capture.
- Fixed thumbnails so they use the same popup-preview canvas and resolve API/RSS placeholders, fonts, styles, and cached images correctly.
- Fixed manual scheduled-task run notifications so they use task-neutral wording.
## 2.8.3 - 2026-08-17
### Added
- Added API, RSS, Timetable, and Time / Date placeholder help panels with shared transform and date-token documentation.
- Added render-time API and RSS image placeholders with proportional sizing and preview-only bounding boxes.
### Changed
- API and RSS regions now preserve authored content when no data source is selected and remain blank when a selected source has no authored content.
- Normal WYSIWYG image insertion remains upload-backed and separate from image placeholder transforms.
## 2.8.2 - 2026-08-16 ## 2.8.2 - 2026-08-16
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.8.2", "version": "2.8.8",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.8.2", "version": "2.8.8",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.8.2", "version": "2.8.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.8.2", "version": "2.8.8",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.8.2", "version": "2.8.8",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+5 -1
View File
@@ -11,7 +11,11 @@ const { validateMaxLength } = require('./utils');
const SHORT_LABEL_MAX_LENGTH = 255; const SHORT_LABEL_MAX_LENGTH = 255;
const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner']; const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light']; const ANNOUNCEMENT_COLORS = [
'primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light',
'orange', 'amber', 'olive', 'teal', 'sky', 'indigo', 'violet', 'fuchsia', 'pink',
'navy', 'steel', 'slate', 'graphite', 'midnight'
];
function normalizeAnnouncementType(value) { function normalizeAnnouncementType(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
+181 -19
View File
@@ -8,6 +8,11 @@ const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024; const URL_MAX_LENGTH = 1024;
const AUTH_MAX_LENGTH = 255; const AUTH_MAX_LENGTH = 255;
const ITEMS_PATH_MAX_LENGTH = 255; const ITEMS_PATH_MAX_LENGTH = 255;
const REQUEST_BODY_MAX_LENGTH = 1000000;
const TOKEN_URL_MAX_LENGTH = 1024;
const TOKEN_RESPONSE_PATH_MAX_LENGTH = 255;
const TOKEN_HEADER_PREFIX_MAX_LENGTH = 64;
const tokenCache = new Map();
function normalizeUpdateIntervalUnit(value) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
@@ -19,7 +24,11 @@ function normalizeUpdateIntervalUnit(value) {
function normalizeAuthMethod(value) { function normalizeAuthMethod(value) {
const method = String(value || '').trim().toLowerCase(); const method = String(value || '').trim().toLowerCase();
return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none'; return ['basic', 'bearer', 'api_key_header', 'token_login'].includes(method) ? method : 'none';
}
function normalizeRequestMethod(value) {
return String(value || '').trim().toUpperCase() === 'POST' ? 'POST' : 'GET';
} }
function buildAuthHeaders(source) { function buildAuthHeaders(source) {
@@ -50,7 +59,7 @@ function buildAuthHeaders(source) {
async function fetchApiSourcesData(pool) { async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query( const [apiSources] = await pool.query(
'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
); );
return { apiSources: apiSources }; return { apiSources: apiSources };
@@ -58,7 +67,7 @@ async function fetchApiSourcesData(pool) {
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC', selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources', countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'], searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm, searchTerm: searchTerm,
@@ -82,7 +91,7 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so
async function fetchApiSourceById(pool, id) { async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
[id] [id]
); );
@@ -91,13 +100,18 @@ async function fetchApiSourceById(pool, id) {
async function loadUrlText(urlValue, requestOptions) { async function loadUrlText(urlValue, requestOptions) {
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {}; const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
const method = String(requestOptions && requestOptions.method || 'GET').toUpperCase();
const body = requestOptions && requestOptions.body !== undefined ? requestOptions.body : undefined;
const headers = Object.assign({
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}, extraHeaders);
if (typeof fetch === 'function') { if (typeof fetch === 'function') {
const response = await fetch(urlValue, { const fetchOptions = { method: method, headers: headers };
headers: Object.assign({ if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', fetchOptions.body = body;
'User-Agent': 'Pulse Signage API Reader' }
}, extraHeaders) const response = await fetch(urlValue, fetchOptions);
});
return { return {
statusCode: response.status, statusCode: response.status,
@@ -110,12 +124,9 @@ async function loadUrlText(urlValue, requestOptions) {
return await new Promise(function (resolve, reject) { return await new Promise(function (resolve, reject) {
const url = new URL(urlValue); const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http; const transport = url.protocol === 'https:' ? https : http;
const requestHeaders = Object.assign({ const request = transport.request(url, Object.assign({}, requestOptions || {}, {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', method: method,
'User-Agent': 'Pulse Signage API Reader' headers: headers
}, extraHeaders);
const request = transport.get(url, Object.assign({}, requestOptions || {}, {
headers: requestHeaders
}), function (response) { }), function (response) {
response.setEncoding('utf8'); response.setEncoding('utf8');
let body = ''; let body = '';
@@ -133,15 +144,126 @@ async function loadUrlText(urlValue, requestOptions) {
response.on('error', reject); response.on('error', reject);
}); });
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
request.write(body);
}
request.end();
request.on('error', reject); request.on('error', reject);
}); });
} }
function parseJsonRequestBody(value, fieldName) {
const text = String(value || '').trim();
if (!text) {
return undefined;
}
try {
return JSON.parse(text);
} catch (_error) {
const error = new Error(fieldName + ' must contain valid JSON.');
error.statusCode = 400;
throw error;
}
}
function resolveResponsePath(value, responsePath) {
let current = value;
String(responsePath || '').split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = undefined;
return;
}
current = current[segment];
});
return current;
}
function getTokenCacheKey(source) {
return JSON.stringify([
source && source.id || '',
source && (source.token_url || source.tokenUrl) || '',
source && (source.token_request_body_json || source.tokenRequestBodyJson) || '',
source && (source.token_response_path || source.tokenResponsePath) || 'access_token',
source && (source.token_header_name || source.tokenHeaderName) || 'Authorization',
source && (source.token_header_prefix || source.tokenHeaderPrefix) || 'Bearer'
]);
}
function clearCachedToken(source) {
tokenCache.delete(getTokenCacheKey(source));
}
async function fetchLoginToken(source) {
const cacheKey = getTokenCacheKey(source);
const cached = tokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const tokenUrl = source.token_url || source.tokenUrl;
const tokenBody = parseJsonRequestBody(source.token_request_body_json || source.tokenRequestBodyJson, 'Login request body');
const tokenHeaders = { 'Content-Type': 'application/json' };
const response = await loadUrlText(tokenUrl, {
method: 'POST',
headers: tokenHeaders,
body: tokenBody === undefined ? undefined : JSON.stringify(tokenBody)
});
if (!response.ok) {
throw new Error(`Unable to obtain API token (${response.statusCode}).`);
}
let parsed;
try {
parsed = JSON.parse(String(response.bodyText || '').trim());
} catch (_error) {
throw new Error('Token response was not valid JSON.');
}
const tokenPath = source.token_response_path || source.tokenResponsePath || 'access_token';
const token = resolveResponsePath(parsed, tokenPath);
if (token === undefined || token === null || String(token).trim() === '') {
throw new Error('Token response did not contain a token at the configured path.');
}
const expiresIn = Number(parsed && (parsed.expires_in || parsed.expiresIn));
const lifetimeMs = Number.isFinite(expiresIn) && expiresIn > 0
? Math.max(30000, expiresIn * 1000 - 60000)
: 300000;
tokenCache.set(cacheKey, { value: String(token), expiresAt: Date.now() + lifetimeMs });
return String(token);
}
async function buildRequestHeaders(source) {
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
if (method !== 'token_login') {
return buildAuthHeaders(source);
}
const token = await fetchLoginToken(source);
const headerName = String(source.token_header_name || source.tokenHeaderName || 'Authorization').trim() || 'Authorization';
const prefix = String(source.token_header_prefix || source.tokenHeaderPrefix || 'Bearer').trim();
return { [headerName]: prefix ? prefix + ' ' + token : token };
}
async function fetchApiSourceResponse(apiSource) { async function fetchApiSourceResponse(apiSource) {
const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource }; const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource };
const response = await loadUrlText(source.api_url, { const requestMethod = normalizeRequestMethod(source.request_method || source.requestMethod);
headers: buildAuthHeaders(source) const requestBody = parseJsonRequestBody(source.request_body_json || source.requestBodyJson, 'API request body');
}); let response;
let tokenRetry = false;
do {
response = await loadUrlText(source.api_url || source.apiUrl, {
method: requestMethod,
headers: Object.assign({}, await buildRequestHeaders(source), requestBody === undefined ? {} : { 'Content-Type': 'application/json' }),
body: requestBody === undefined ? undefined : JSON.stringify(requestBody)
});
if (response.statusCode === 401 && normalizeAuthMethod(source.auth_method || source.authMethod) === 'token_login' && !tokenRetry) {
clearCachedToken(source);
tokenRetry = true;
} else {
break;
}
} while (true);
if (!response.ok) { if (!response.ok) {
throw new Error(`Unable to load API response (${response.statusCode}).`); throw new Error(`Unable to load API response (${response.statusCode}).`);
} }
@@ -178,11 +300,18 @@ function buildApiSourcePayload(req, existingApiSource) {
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name'); const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL'); const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL');
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none'))); const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
const requestMethod = normalizeRequestMethod(readBodyValue('request_method', readBodyValue('requestMethod', fallback.request_method || 'GET')));
const requestBodyJson = validateMaxLength(readBodyValue('request_body_json', readBodyValue('requestBodyJson', fallback.request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'API request body');
const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username'); const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password'); const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token'); const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key'; const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key';
const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value'); const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
const tokenUrl = validateMaxLength(readBodyValue('token_url', readBodyValue('tokenUrl', fallback.token_url || '')) || '', TOKEN_URL_MAX_LENGTH, 'API token URL');
const tokenRequestBodyJson = validateMaxLength(readBodyValue('token_request_body_json', readBodyValue('tokenRequestBodyJson', fallback.token_request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'Login request body');
const tokenResponsePath = validateMaxLength(readBodyValue('token_response_path', readBodyValue('tokenResponsePath', fallback.token_response_path || 'access_token')) || 'access_token', TOKEN_RESPONSE_PATH_MAX_LENGTH, 'Token response path');
const tokenHeaderName = validateMaxLength(readBodyValue('token_header_name', readBodyValue('tokenHeaderName', fallback.token_header_name || 'Authorization')) || 'Authorization', AUTH_MAX_LENGTH, 'Token header name');
const tokenHeaderPrefix = validateMaxLength(readBodyValue('token_header_prefix', readBodyValue('tokenHeaderPrefix', fallback.token_header_prefix || 'Bearer')) || '', TOKEN_HEADER_PREFIX_MAX_LENGTH, 'Token prefix');
const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path'); const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path');
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60)); const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes'); const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
@@ -238,15 +367,48 @@ function buildApiSourcePayload(req, existingApiSource) {
throw error; throw error;
} }
parseJsonRequestBody(requestBodyJson, 'API request body');
parseJsonRequestBody(tokenRequestBodyJson, 'Login request body');
if (authMethod === 'token_login') {
if (!tokenUrl) {
const error = new Error('Token login requires a login URL.');
error.statusCode = 400;
throw error;
}
try {
const tokenParsedUrl = new URL(tokenUrl);
if (tokenParsedUrl.protocol !== 'http:' && tokenParsedUrl.protocol !== 'https:') {
throw new Error('invalid protocol');
}
} catch (_error) {
const error = new Error('Enter a valid API token URL.');
error.statusCode = 400;
throw error;
}
if (!tokenRequestBodyJson) {
const error = new Error('Token login requires a JSON request body.');
error.statusCode = 400;
throw error;
}
}
return { return {
name: name, name: name,
apiUrl: parsedUrl.toString(), apiUrl: parsedUrl.toString(),
requestMethod: requestMethod,
requestBodyJson: requestBodyJson,
authMethod: authMethod, authMethod: authMethod,
authUsername: authUsername, authUsername: authUsername,
authPassword: authPassword, authPassword: authPassword,
authBearerToken: authBearerToken, authBearerToken: authBearerToken,
authHeaderName: authHeaderName, authHeaderName: authHeaderName,
authHeaderValue: authHeaderValue, authHeaderValue: authHeaderValue,
tokenUrl: tokenUrl,
tokenRequestBodyJson: tokenRequestBodyJson,
tokenResponsePath: tokenResponsePath,
tokenHeaderName: tokenHeaderName,
tokenHeaderPrefix: tokenHeaderPrefix,
itemsPath: itemsPath, itemsPath: itemsPath,
updateIntervalValue: Math.floor(updateIntervalValue), updateIntervalValue: Math.floor(updateIntervalValue),
updateIntervalUnit: updateIntervalUnit updateIntervalUnit: updateIntervalUnit
+2 -2
View File
@@ -418,7 +418,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
content[region.region_key] = { content[region.region_key] = {
type: 'rss', type: 'rss',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId), feed_id: feedId === undefined || feedId === null ? (current.feed_id || null) : (feedId === '' ? null : Number(feedId)),
item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1), item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
variable_name: 'item', variable_name: 'item',
font_family: style.font_family, font_family: style.font_family,
@@ -436,7 +436,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
content[region.region_key] = { content[region.region_key] = {
type: 'api', type: 'api',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId), source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(), items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
variable_name: 'item', variable_name: 'item',
+14
View File
@@ -233,6 +233,20 @@ async function ensureSchema(pool, options) {
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL, api_url VARCHAR(1024) NOT NULL,
request_method VARCHAR(10) NOT NULL DEFAULT 'GET',
request_body_json MEDIUMTEXT NULL,
auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
auth_username VARCHAR(255) NULL,
auth_password MEDIUMTEXT NULL,
auth_bearer_token MEDIUMTEXT NULL,
auth_header_name VARCHAR(255) NULL,
auth_header_value MEDIUMTEXT NULL,
token_url VARCHAR(1024) NULL,
token_request_body_json MEDIUMTEXT NULL,
token_response_path VARCHAR(255) NULL DEFAULT 'access_token',
token_header_name VARCHAR(255) NULL DEFAULT 'Authorization',
token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer',
items_path VARCHAR(255) NULL,
update_interval_value INT NOT NULL DEFAULT 60, update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes', update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
last_pulled_at TIMESTAMP NULL, last_pulled_at TIMESTAMP NULL,
+13
View File
@@ -492,6 +492,19 @@ const VERSIONED_MIGRATIONS = [
AND permissions.permission_key = 'audit-log.allow'` AND permissions.permission_key = 'audit-log.allow'`
); );
} }
},
{
version: '2.8.7',
label: 'v2.8.7 API request and token authentication schema',
run: async function (pool) {
await ensureColumn(pool, 'i_api_sources', 'request_method', "VARCHAR(10) NOT NULL DEFAULT 'GET'", 'api_url');
await ensureColumn(pool, 'i_api_sources', 'request_body_json', 'MEDIUMTEXT NULL', 'request_method');
await ensureColumn(pool, 'i_api_sources', 'token_url', 'VARCHAR(1024) NULL', 'auth_header_value');
await ensureColumn(pool, 'i_api_sources', 'token_request_body_json', 'MEDIUMTEXT NULL', 'token_url');
await ensureColumn(pool, 'i_api_sources', 'token_response_path', "VARCHAR(255) NULL DEFAULT 'access_token'", 'token_request_body_json');
await ensureColumn(pool, 'i_api_sources', 'token_header_name', "VARCHAR(255) NULL DEFAULT 'Authorization'", 'token_response_path');
await ensureColumn(pool, 'i_api_sources', 'token_header_prefix', "VARCHAR(64) NULL DEFAULT 'Bearer'", 'token_header_name');
}
} }
]; ];
+1
View File
@@ -187,6 +187,7 @@ async function start() {
const playerPlaylistService = createPlayerPlaylistService({ const playerPlaylistService = createPlayerPlaylistService({
pool: pool, pool: pool,
common: common, common: common,
mediaDir: config.mediaDir,
snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists') snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists')
}); });
app.use(express.json()); app.use(express.json());
+1
View File
@@ -61,6 +61,7 @@ async function start() {
: createPlayerPlaylistService({ : createPlayerPlaylistService({
pool: pool, pool: pool,
common: common, common: common,
mediaDir: MEDIA_DIR,
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists') snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
}); });
const rtmpStreamService = createRtmpStreamService({ const rtmpStreamService = createRtmpStreamService({
+103
View File
@@ -9,6 +9,8 @@ function createPlayerPlaylistService(options) {
const pool = options && options.pool ? options.pool : null; const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null; const common = options && options.common ? options.common : null;
const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null; const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null;
const mediaDir = options && options.mediaDir ? path.resolve(String(options.mediaDir)) : null;
const remoteImageCacheDir = mediaDir ? path.join(mediaDir, 'player-cache', 'remote-images') : null;
if (!pool) { if (!pool) {
throw new Error('pool is required'); throw new Error('pool is required');
@@ -61,6 +63,105 @@ function createPlayerPlaylistService(options) {
return createStyledQrCodeDataUrl(value); return createStyledQrCodeDataUrl(value);
} }
function getPlaceholderImageExpressions(value, output) {
const expressions = output || [];
const source = String(value || '');
const pattern = /\{\{\s*([^{}]*?\.image\s*\([^{}]*\)[^{}]*?)\s*\}\}/gi;
let match = null;
while ((match = pattern.exec(source))) {
expressions.push(String(match[1] || '').trim());
}
return expressions;
}
function resolvePathValue(value, expression) {
const parsed = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
return parsed.split('.').reduce(function (current, segment) {
return current === undefined || current === null ? '' : current[segment];
}, 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 '';
const hash = crypto.createHash('sha256').update(remoteUrl).digest('hex');
await fs.promises.mkdir(remoteImageCacheDir, { recursive: true });
const existing = (await fs.promises.readdir(remoteImageCacheDir)).find(function (name) { return name.startsWith(hash + '.'); });
if (existing) return '/media/player-cache/remote-images/' + existing;
try {
const response = await fetch(remoteUrl, { signal: AbortSignal.timeout(15000) });
if (!response.ok || !String(response.headers.get('content-type') || '').toLowerCase().startsWith('image/')) return '';
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > 10 * 1024 * 1024) return '';
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
const extension = contentType.includes('svg') ? '.svg' : contentType.includes('png') ? '.png' : contentType.includes('webp') ? '.webp' : contentType.includes('gif') ? '.gif' : '.jpg';
const fileName = hash + extension;
const filePath = path.join(remoteImageCacheDir, fileName);
await fs.promises.writeFile(filePath, bytes);
return '/media/player-cache/remote-images/' + fileName;
} catch (_error) {
return '';
}
}
async function cachePlaceholderImages(slides, rssFeeds, apiSources) {
if (!remoteImageCacheDir) return;
const usedPaths = new Set();
const allSlideRows = await pool.query('SELECT content_json FROM c_slides').then(function (result) { return result[0] || []; });
const allContents = allSlideRows.map(function (row) { return common.parseJsonSafe(row.content_json) || {}; });
const sourceContent = allContents.concat((slides || []).map(function (slide) { return slide.content || {}; }));
const cacheSource = async function (source, item, expressions) {
if (!source || !item) return;
for (const expression of expressions) {
const remoteUrl = String(resolvePathValue(item, expression) || '').trim();
const localPath = await cacheRemoteImage(remoteUrl);
if (localPath) {
source.imageCache = source.imageCache || {};
source.imageCache[remoteUrl] = localPath;
usedPaths.add(localPath);
}
}
};
for (const content of sourceContent) {
for (const key of Object.keys(content || {})) {
const region = content[key];
if (!region || typeof region !== 'object') continue;
const expressions = getPlaceholderImageExpressions(region.value, []);
if (!expressions.length) continue;
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) : [];
await cacheSource(source, items[itemNumber], expressions);
}
if (String(region.type || '').toLowerCase() === 'rss') {
const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); });
await cacheSource(feed, feed && feed.items && feed.items[itemNumber], expressions);
}
}
}
const files = await fs.promises.readdir(remoteImageCacheDir).catch(function () { return []; });
await Promise.all(files.filter(function (file) { return !usedPaths.has('/media/player-cache/remote-images/' + file); }).map(function (file) {
return fs.promises.unlink(path.join(remoteImageCacheDir, file)).catch(function () {});
}));
}
async function buildScreenPlaylist(slug) { async function buildScreenPlaylist(slug) {
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]); const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
@@ -241,6 +342,8 @@ function createPlayerPlaylistService(options) {
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
} }
await cachePlaceholderImages(slides, rssFeeds, apiSources);
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups); const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision }; const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
await writeSnapshot(slug, payload); await writeSnapshot(slug, payload);
@@ -22,7 +22,11 @@
function normalizeAnnouncementColor(value) { function normalizeAnnouncementColor(value) {
var normalized = String(value || '').trim().toLowerCase(); var normalized = String(value || '').trim().toLowerCase();
if (['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'].indexOf(normalized) !== -1) { if ([
'primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light',
'orange', 'amber', 'olive', 'teal', 'sky', 'indigo', 'violet', 'fuchsia', 'pink',
'navy', 'steel', 'slate', 'graphite', 'midnight'
].indexOf(normalized) !== -1) {
return normalized; return normalized;
} }
return 'primary'; return 'primary';
@@ -42,7 +46,21 @@
warning: { accent: '#ffc107', foreground: '#201400', glow: 'rgba(255, 193, 7, 0.30)' }, warning: { accent: '#ffc107', foreground: '#201400', glow: 'rgba(255, 193, 7, 0.30)' },
danger: { accent: '#dc3545', foreground: '#ffffff', glow: 'rgba(220, 53, 69, 0.34)' }, danger: { accent: '#dc3545', foreground: '#ffffff', glow: 'rgba(220, 53, 69, 0.34)' },
dark: { accent: '#212529', foreground: '#ffffff', glow: 'rgba(33, 37, 41, 0.34)' }, dark: { accent: '#212529', foreground: '#ffffff', glow: 'rgba(33, 37, 41, 0.34)' },
light: { accent: '#f8f9fa', foreground: '#1f2937', glow: 'rgba(248, 249, 250, 0.34)' } light: { accent: '#f8f9fa', foreground: '#1f2937', glow: 'rgba(248, 249, 250, 0.34)' },
orange: { accent: '#c84e10', foreground: '#ffffff', glow: 'rgba(200, 78, 16, 0.34)' },
amber: { accent: '#a56710', foreground: '#ffffff', glow: 'rgba(165, 103, 16, 0.34)' },
olive: { accent: '#5f7f0f', foreground: '#ffffff', glow: 'rgba(95, 127, 15, 0.34)' },
teal: { accent: '#12827d', foreground: '#ffffff', glow: 'rgba(18, 130, 125, 0.34)' },
sky: { accent: '#127caf', foreground: '#ffffff', glow: 'rgba(18, 124, 175, 0.34)' },
indigo: { accent: '#6f60ea', foreground: '#ffffff', glow: 'rgba(111, 96, 234, 0.34)' },
violet: { accent: '#9553db', foreground: '#ffffff', glow: 'rgba(149, 83, 219, 0.34)' },
fuchsia: { accent: '#b347be', foreground: '#ffffff', glow: 'rgba(179, 71, 190, 0.34)' },
pink: { accent: '#cd388d', foreground: '#ffffff', glow: 'rgba(205, 56, 141, 0.34)' },
navy: { accent: '#1d2d4c', foreground: '#ffffff', glow: 'rgba(29, 45, 76, 0.34)' },
steel: { accent: '#3a4860', foreground: '#ffffff', glow: 'rgba(58, 72, 96, 0.34)' },
slate: { accent: '#566577', foreground: '#ffffff', glow: 'rgba(86, 101, 119, 0.34)' },
graphite: { accent: '#32363c', foreground: '#ffffff', glow: 'rgba(50, 54, 60, 0.34)' },
midnight: { accent: '#1e1d2d', foreground: '#ffffff', glow: 'rgba(30, 29, 45, 0.34)' }
}; };
return tokens[colorKey] || tokens.primary; return tokens[colorKey] || tokens.primary;
+22 -6
View File
@@ -56,7 +56,7 @@ function getApiItem(sourceId, itemNumber, itemsPathOverride) {
return items[index] || null; return items[index] || null;
} }
function substituteApiVariables(html, item) { function substituteApiVariables(html, item, sourceId) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
@@ -67,7 +67,25 @@ function substituteApiVariables(html, item) {
return ''; return '';
} }
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression))); var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
var source = getApiSourceById(sourceId);
imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource;
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var hasImageBounds = imageConfig.width && imageConfig.height;
var imageStyle = hasImageBounds
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
}); });
} }
@@ -120,10 +138,8 @@ function renderApiRegion(region, regionContent) {
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getApiItem(sourceId, itemNumber, itemsPath); var item = getApiItem(sourceId, itemNumber, itemsPath);
var body = item ? substituteApiVariables(content, item) : ''; var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
if (!body && item) { var body = hasSource ? (item ? substituteApiVariables(content, item, sourceId) : '') : content;
body = getApiPreviewFallback(item);
}
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
if (!body) { if (!body) {
return ''; return '';
+5 -2
View File
@@ -32,7 +32,7 @@ function buildHtmlDocument(html) {
return raw; return raw;
} }
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + raw + '</body></html>'; return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}html,body{background:transparent !important;}</style></head><body>' + raw + '</body></html>';
} }
function renderHtmlRegionContent(value) { function renderHtmlRegionContent(value) {
@@ -40,7 +40,10 @@ function renderHtmlRegionContent(value) {
if (!html) { if (!html) {
return ''; return '';
} }
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>'; if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<iframe class="template-region-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
return '<div class="template-region-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div>';
} }
function renderHtmlRegion(region, regionContent) { function renderHtmlRegion(region, regionContent) {
+27 -15
View File
@@ -1,6 +1,7 @@
// RSS region helpers for resolving feeds, items, and nested values. // RSS region helpers for resolving feeds, items, and nested values.
var registry = window.pulsePlayerRegionTypes; var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
function getDefaultStyle() { function getDefaultStyle() {
return { return {
@@ -63,14 +64,34 @@ function resolveRssPath(value, path) {
return current === undefined || current === null ? '' : current; return current === undefined || current === null ? '' : current;
} }
function substituteRssVariables(html, item) { function substituteRssVariables(html, item, feedId) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) { return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return ''; return '';
} }
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName; if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return escapeHtml(resolveRssPath(item, key || '')); return '';
}
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
var feed = getRssFeedById(feedId);
imageSource = feed && feed.imageCache && feed.imageCache[imageSource] ? feed.imageCache[imageSource] : imageSource;
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var hasImageBounds = imageConfig.width && imageConfig.height;
var imageStyle = hasImageBounds
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
}); });
} }
@@ -83,17 +104,8 @@ function renderRssRegion(region, regionContent) {
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var item = getRssFeedItem(feedId, itemNumber); var item = getRssFeedItem(feedId, itemNumber);
var body = item ? substituteRssVariables(content, item) : ''; var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
if (!body && item) { var body = hasFeed ? (item ? substituteRssVariables(content, item, feedId) : '') : content;
var summaryParts = [];
if (item.title) {
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
}
if (item.description) {
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
}
body = summaryParts.join('');
}
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
if (!body) { if (!body) {
return ''; return '';
+7 -3
View File
@@ -142,9 +142,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'html') { if (regionType === 'html') {
const html = String(rawValue || '').trim(); const html = String(rawValue || '').trim();
return html if (!html) {
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml('<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + html + '</body></html>') + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>' return '';
: ''; }
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml(html) + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>';
}
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><div class="slide-preview-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div></div>';
} }
if (regionType === 'rtmp') { if (regionType === 'rtmp') {
+151 -44
View File
@@ -2,6 +2,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const vm = require('vm');
const { const {
escapeHtml, escapeHtml,
mediaKind, mediaKind,
@@ -10,7 +12,13 @@ const {
sanitizeFontSize, sanitizeFontSize,
sanitizeTextColor sanitizeTextColor
} = require('#src/player/render-helpers'); } = require('#src/player/render-helpers');
const { createRequestAuthHeaders } = require('#src/request-auth'); const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
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 SYSTEM_CHROMIUM_PATHS = [ const SYSTEM_CHROMIUM_PATHS = [
process.env.PUPPETEER_EXECUTABLE_PATH, process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -124,11 +132,51 @@ function hasVisibleContent(html) {
return Boolean(raw.replace(/<[^>]+>/g, '').trim()); return Boolean(raw.replace(/<[^>]+>/g, '').trim());
} }
function buildTextRegionMarkup(region, regionContent) { function resolvePlaceholderPath(value, expression) {
const pathValue = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
return pathValue.split('.').reduce(function (current, segment) {
return current === undefined || current === null ? '' : current[segment];
}, value);
}
function getImagePlaceholderConfig(expression) {
const match = String(expression || '').match(/\.image\s*\(\s*([0-9]+)?\s*,?\s*([0-9]+)?\s*\)/i);
return {
width: match && match[1] ? Number(match[1]) : 0,
height: match && match[2] ? Number(match[2]) : 0
};
}
function substitutePlaceholders(html, regionContent, options) {
const source = String(html || '');
const type = String(regionContent && regionContent.type || '').trim().toLowerCase();
const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null;
if (!item) return source;
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) {
const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function'
? placeholderUtils.resolvePlaceholderExpression(item, expression)
: resolvePlaceholderPath(item, expression);
const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || '');
if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) {
return escapeHtml(value);
}
const remoteUrl = String(value || '').trim();
if (!/^https?:\/\//i.test(remoteUrl)) return '';
const imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : getImagePlaceholderConfig(expression);
const cacheFile = options && typeof options.getCachedImagePath === 'function' ? options.getCachedImagePath(remoteUrl) : '';
const imageUrl = cacheFile || remoteUrl;
const style = imageConfig.width && imageConfig.height
? 'display:block;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;object-fit:contain;'
: 'display:block;max-width:' + (imageConfig.width || 100) + 'px;max-height:' + (imageConfig.height || 100) + 'px;width:auto;height:auto;';
return '<img src="' + escapeHtml(resolveAssetUrl(options && options.baseUrl, imageUrl)) + '" alt="" style="' + style + '" />';
});
}
function buildTextRegionMarkup(region, regionContent, options) {
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family); const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size); const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color); const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
const renderedBody = renderEditorJsContent(regionContent.value || ''); const renderedBody = renderEditorJsContent(substitutePlaceholders(regionContent.value || '', regionContent, options));
if (!hasVisibleContent(renderedBody)) { if (!hasVisibleContent(renderedBody)) {
return ''; return '';
} }
@@ -136,7 +184,7 @@ function buildTextRegionMarkup(region, regionContent) {
return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>'; return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
} }
function buildRegionInnerHtml(region, regionContent, baseUrl) { function buildRegionInnerHtml(region, regionContent, baseUrl, options) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : ''); const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
@@ -155,10 +203,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
} }
if (regionType === 'webpage') { if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue); return '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><div class="slide-preview-webpage-placeholder" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.08);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.14);box-sizing:border-box;color:rgba(255,255,255,0.72);font-size:24px;font-family:Arial,sans-serif;">Webpage preview unavailable</div></div>';
return src
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe></div>'
: '';
} }
if (regionType === 'qr-code') { if (regionType === 'qr-code') {
@@ -172,9 +217,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'html') { if (regionType === 'html') {
const html = String(rawValue || '').trim(); const html = String(rawValue || '').trim();
return html if (!html) {
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml('<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + html + '</body></html>') + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>' return '';
: ''; }
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml(html) + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>';
}
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><div class="slide-preview-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div></div>';
} }
if (regionType === 'rtmp') { if (regionType === 'rtmp') {
@@ -182,7 +231,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>'; return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
} }
return buildTextRegionMarkup(region, regionContent); return buildTextRegionMarkup(region, regionContent, Object.assign({}, options, { baseUrl: baseUrl }));
} }
async function loadChromium() { async function loadChromium() {
@@ -199,7 +248,7 @@ function loadSharp() {
return require('sharp'); return require('sharp');
} }
function buildThumbnailPreviewMarkup(slide, baseUrl) { function buildThumbnailPreviewMarkup(slide, baseUrl, options) {
const template = slide && slide.template ? slide.template : null; const template = slide && slide.template ? slide.template : null;
if (!template) { if (!template) {
return ''; return '';
@@ -214,7 +263,7 @@ function buildThumbnailPreviewMarkup(slide, baseUrl) {
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)), pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1)) pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
}); });
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl); return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl, options);
}).join(''); }).join('');
} }
@@ -228,7 +277,7 @@ function buildThumbnailPreviewPayload(slide, options) {
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111', backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '', backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(), fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl) html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options)
}; };
} }
@@ -253,8 +302,7 @@ async function launchBrowser() {
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
'--disable-dev-shm-usage', '--disable-dev-shm-usage'
'--disable-gpu'
], ],
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 }, defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
executablePath: executablePath, executablePath: executablePath,
@@ -291,6 +339,53 @@ async function captureSlideThumbnail(options) {
throw new Error('Slide not found.'); throw new Error('Slide not found.');
} }
const rssFeedsData = typeof common.fetchRssFeedsData === 'function' ? await common.fetchRssFeedsData(pool) : { rssFeeds: [] };
const rssFeeds = await Promise.all((rssFeedsData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, feed.id) : [];
return Object.assign({}, feed, { items: items });
}));
const apiSourcesData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const apiSources = (apiSourcesData.apiSources || []).map(function (source) {
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
});
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;
}
if (type === 'rss') {
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
return feed && Array.isArray(feed.items) ? feed.items[index] || null : null;
}
return null;
}
function getCachedImagePath(remoteUrl) {
const hash = crypto.createHash('sha256').update(String(remoteUrl || '')).digest('hex');
const cacheDir = path.join(mediaDir, 'player-cache', 'remote-images');
const candidates = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
const candidate = candidates.map(function (extension) { return path.join(cacheDir, hash + extension); }).find(function (filePath) { return fs.existsSync(filePath); });
return candidate ? '/media/player-cache/remote-images/' + path.basename(candidate) : '';
}
const thumbnailDir = path.join(mediaDir, 'thumbnails'); const thumbnailDir = path.join(mediaDir, 'thumbnails');
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png'; const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
@@ -306,14 +401,9 @@ async function captureSlideThumbnail(options) {
}, { timeout: 30000 }); }, { timeout: 30000 });
await page.waitForFunction(function () { await page.waitForFunction(function () {
var canvas = document.querySelector('#popup-preview-canvas'); var videos = Array.prototype.slice.call(document.querySelectorAll('#popup-preview-canvas video'));
if (!canvas) { return videos.every(function (video) {
return false; return video.readyState >= 2;
}
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
return images.every(function (image) {
return image.complete && typeof image.naturalWidth === 'number';
}); });
}, { timeout: 30000 }); }, { timeout: 30000 });
@@ -322,17 +412,13 @@ async function captureSlideThumbnail(options) {
try { try {
await document.fonts.ready; await document.fonts.ready;
} catch (_error) { } catch (_error) {
// Ignore font readiness failures and fall back to the rendered frame. return null;
} }
} }
}); });
await page.evaluate(function () { await new Promise(function (resolve) {
return new Promise(function (resolve) { setTimeout(resolve, 1000);
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
}); });
} }
@@ -340,28 +426,49 @@ async function captureSlideThumbnail(options) {
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
try { try {
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/popup-preview'; const previewPath = '/api/internal/slide-thumbnails/' + encodeURIComponent(String(slide.id)) + '/popup-preview';
const previewUrl = baseUrl + previewPath; const previewUrl = baseUrl + previewPath;
const previewPayload = buildThumbnailPreviewPayload(slide, { const canvasSize = getThumbnailCanvasSize(slide);
baseUrl: baseUrl, await page.setViewport({
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : '' width: Math.max(1, Number(canvasSize.width || PLAYER_VIEWPORT.width)),
height: Math.max(1, Number(canvasSize.height || PLAYER_VIEWPORT.height)),
deviceScaleFactor: 1
}); });
await page.setViewport({ await page.setExtraHTTPHeaders(Object.assign({}, createRequestAuthHeaders({
width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)),
height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)),
deviceScaleFactor: 1
});
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
method: 'GET', method: 'GET',
pathname: previewPath pathname: previewPath
}), {
'x-pulse-page-auth': createPageAuthToken({ scope: 'thumbnail-preview', slideId: slide.id })
})); }));
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); const previewResponse = await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
if (!previewResponse || previewResponse.status() >= 400) {
throw new Error('Popup preview returned HTTP ' + (previewResponse ? previewResponse.status() : 'no response') + '.');
}
await waitForThumbnailRender(page); await waitForThumbnailRender(page);
const canvas = await page.$('#popup-preview-canvas'); const canvas = await page.$('#popup-preview-canvas');
if (!canvas) { if (!canvas) {
throw new Error('Popup preview did not produce a slide canvas.'); throw new Error('Popup preview did not produce a slide canvas.');
} }
await canvas.screenshot({ path: fullSizePath }); await page.evaluate(function () {
var canvasElement = document.querySelector('#popup-preview-canvas');
if (canvasElement) {
canvasElement.style.transform = 'none';
canvasElement.style.transformOrigin = 'top left';
}
});
const canvasBounds = await canvas.boundingBox();
if (!canvasBounds) {
throw new Error('Popup preview canvas has no screenshot bounds.');
}
await page.screenshot({
path: fullSizePath,
clip: {
x: Math.max(0, canvasBounds.x),
y: Math.max(0, canvasBounds.y),
width: Math.max(1, canvasBounds.width),
height: Math.max(1, canvasBounds.height)
}
});
} finally { } finally {
await page.close().catch(function () { await page.close().catch(function () {
return null; return null;
+4
View File
@@ -432,6 +432,10 @@ function createUploadSyncService(options) {
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName; const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
const nextAbsolutePath = path.join(currentDir, entryName); const nextAbsolutePath = path.join(currentDir, entryName);
if (!relativeDir && entryName === 'player-cache') {
continue;
}
if (entry.isDirectory && entry.isDirectory()) { if (entry.isDirectory && entry.isDirectory()) {
await walkDirectory(nextAbsolutePath, nextRelativePath); await walkDirectory(nextAbsolutePath, nextRelativePath);
continue; continue;
+1 -1
View File
@@ -4,7 +4,7 @@ const path = require('path');
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js'); const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions'); const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type'); const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js']; const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
function withAssetVersion(scriptPath, assetVersion) { function withAssetVersion(scriptPath, assetVersion) {
if (!assetVersion) { if (!assetVersion) {
+1 -1
View File
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
}); });
app.use(function (req, res, next) { app.use(function (req, res, next) {
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') { if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') {
return next(); return next();
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -2383,6 +2383,11 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
margin-bottom: 0; margin-bottom: 0;
} }
.template-field-card .form-label,
.region-item .form-label {
margin-bottom: 0.35rem;
}
.template-field-card .form-control, .template-field-card .form-control,
.template-field-card .form-select, .template-field-card .form-select,
.template-field-card textarea, .template-field-card textarea,
@@ -2424,7 +2429,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.announcement-color-picker__grid { .announcement-color-picker__grid {
display: grid; display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
gap: 0.375rem; gap: 0.375rem;
} }
@@ -2533,6 +2538,13 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
text-transform: uppercase; text-transform: uppercase;
} }
.api-region-placeholder-title-help {
font-size: 0.9rem;
font-weight: 800;
letter-spacing: normal;
text-transform: none;
}
.api-region-sample-accordion { .api-region-sample-accordion {
padding: 0.85rem 1rem 1rem; padding: 0.85rem 1rem 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
+14 -10
View File
@@ -886,7 +886,7 @@
var summaryLabel = isRoot ? (isArray ? 'Array' : 'Object') : String(key); var summaryLabel = isRoot ? (isArray ? 'Array' : 'Object') : String(key);
var summaryMeta = isArray ? '[' + value.length + ']' : '{' + Object.keys(value).length + '}'; var summaryMeta = isArray ? '[' + value.length + ']' : '{' + Object.keys(value).length + '}';
return '' + return '' +
'<details class="json-tree-node" open>' + '<details class="json-tree-node"' + (isRoot ? ' open' : '') + '>' +
'<summary><span class="json-tree-key">' + escapeHtml(summaryLabel) + '</span><span class="mx-1">:</span><span class="text-body-secondary">' + escapeHtml(summaryMeta) + '</span></summary>' + '<summary><span class="json-tree-key">' + escapeHtml(summaryLabel) + '</span><span class="mx-1">:</span><span class="text-body-secondary">' + escapeHtml(summaryMeta) + '</span></summary>' +
'<div class="ms-3 ps-3 border-start">' + entries + '</div>' + '<div class="ms-3 ps-3 border-start">' + entries + '</div>' +
'</details>'; '</details>';
@@ -945,26 +945,30 @@
detail.open = expanded; detail.open = expanded;
}); });
if (collapseButton) { syncButtons();
collapseButton.disabled = !details.length || !expanded;
}
if (expandButton) {
expandButton.disabled = !details.length || expanded;
}
} }
function syncButtons() { function syncButtons() {
var details = output.querySelectorAll('details');
var allExpanded = details.length > 0 && Array.prototype.every.call(details, function (detail) { return detail.open; });
var allCollapsed = details.length > 0 && Array.prototype.every.call(details, function (detail) { return !detail.open; });
if (collapseButton) { if (collapseButton) {
collapseButton.setAttribute('aria-pressed', 'false'); collapseButton.disabled = !details.length || allCollapsed;
collapseButton.setAttribute('aria-pressed', String(allCollapsed));
} }
if (expandButton) { if (expandButton) {
expandButton.setAttribute('aria-pressed', 'true'); expandButton.disabled = !details.length || allExpanded;
expandButton.setAttribute('aria-pressed', String(allExpanded));
} }
} }
output.innerHTML = formattedTree; output.innerHTML = formattedTree;
setAllSectionsExpanded(false);
var rootDetails = output.querySelector('details');
if (rootDetails) {
rootDetails.open = true;
}
syncButtons(); syncButtons();
setAllSectionsExpanded(true);
if (collapseButton) { if (collapseButton) {
collapseButton.addEventListener('click', function () { collapseButton.addEventListener('click', function () {
@@ -11,6 +11,9 @@
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]')); var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]'));
var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]'); var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]');
var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]'); var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]');
var requestMethodSelect = form.querySelector('[data-api-source-request-method]');
var requestSection = form.querySelector('[data-api-source-request-section]');
var requestBodyInput = form.querySelector('[data-api-source-request-body]');
function updateBearerTokenToggle() { function updateBearerTokenToggle() {
if (!bearerTokenInput || !bearerTokenToggle) { if (!bearerTokenInput || !bearerTokenToggle) {
@@ -47,14 +50,28 @@
}); });
} }
function updateRequestBodyVisibility() {
if (!requestMethodSelect || !requestSection || !requestBodyInput) {
return;
}
var isPost = String(requestMethodSelect.value || 'GET').toUpperCase() === 'POST';
requestSection.hidden = !isPost;
}
if (methodSelect) { if (methodSelect) {
methodSelect.addEventListener('change', updatePanels); methodSelect.addEventListener('change', updatePanels);
} }
if (requestMethodSelect) {
requestMethodSelect.addEventListener('change', updateRequestBodyVisibility);
}
if (bearerTokenToggle && bearerTokenInput) { if (bearerTokenToggle && bearerTokenInput) {
bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility); bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility);
updateBearerTokenToggle(); updateBearerTokenToggle();
} }
updatePanels(); updatePanels();
updateRequestBodyVisibility();
}()); }());
+40 -9
View File
@@ -94,7 +94,7 @@
}; };
} }
function substituteVariables(html, item) { function substituteVariables(html, item, options) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
@@ -105,7 +105,23 @@
return ''; return '';
} }
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression))); var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var imageStyle = options && options.preview && imageConfig.width && imageConfig.height
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) {
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
}
return image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
}); });
} }
@@ -136,6 +152,24 @@
return summary.join(''); return summary.join('');
} }
function renderTransformInfo(regionId) {
var offcanvasId = 'api-placeholder-info-' + regionId;
return '' +
'<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
'<div class="offcanvas-header">' +
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">API placeholder transforms</h2>' +
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
'</div>' +
'<div class="offcanvas-body">' +
'<p class="small text-body-secondary">Placeholders read values from the selected API item. Nested fields use dots.</p>' +
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : '') +
'</div>' +
'</div>';
}
function normalizeRenderableValue(value) { function normalizeRenderableValue(value) {
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
if (value.value !== undefined) { if (value.value !== undefined) {
@@ -170,11 +204,8 @@
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getItem(sourceId, itemNumber, sources, itemsPath); var item = getItem(sourceId, itemNumber, sources, itemsPath);
var body = item ? substituteVariables(content, item) : ''; var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
var body = hasSource ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
if (!body && item) {
body = getPreviewFallback(item);
}
body = String(body || '') body = String(body || '')
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '') .replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
@@ -239,7 +270,7 @@
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' + '<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' + '<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
'<div class="api-region-placeholder-section">' + '<div class="api-region-placeholder-section">' +
'<div class="api-region-placeholder-title">Available placeholders</div>' + '<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderTransformInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' + '<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
'</div>' + '</div>' +
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' + '<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
@@ -290,7 +321,7 @@
return fallbackValue; return fallbackValue;
} }
}())), }())),
source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id, source_id: card.querySelector('select[name="region_api_source_id_' + region.id + '"]') ? card.querySelector('select[name="region_api_source_id_' + region.id + '"]').value : current.source_id,
item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number, item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number,
items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path
} : current; } : current;
+1 -1
View File
@@ -1029,7 +1029,7 @@
'</div>' + '</div>' +
'<div class="card-body p-3 d-grid gap-2">' + '<div class="card-body p-3 d-grid gap-2">' +
'<label class="form-label mb-1" for="region_qr_code_' + region.id + '">URL</label>' + '<label class="form-label mb-1" for="region_qr_code_' + region.id + '">URL</label>' +
'<input id="region_qr_code_' + region.id + '" type="url" name="region_qr_code_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' + '<input id="region_qr_code_' + region.id + '" type="url" name="region_qr_code_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" required />' +
'<textarea name="region_qr_svg_' + region.id + '" class="visually-hidden" hidden>' + escapeHtml(currentSvg) + '</textarea>' + '<textarea name="region_qr_svg_' + region.id + '" class="visually-hidden" hidden>' + escapeHtml(currentSvg) + '</textarea>' +
'<input type="hidden" name="region_qr_preview_' + region.id + '" value="' + escapeHtml(String(context.qr_preview || '')) + '" />' + '<input type="hidden" name="region_qr_preview_' + region.id + '" value="' + escapeHtml(String(context.qr_preview || '')) + '" />' +
'<div class="card card-outline card-secondary overflow-hidden mt-2">' + '<div class="card card-outline card-secondary overflow-hidden mt-2">' +
+43 -20
View File
@@ -3,6 +3,7 @@
(function () { (function () {
var registry = window.pulseRegionTypes; var registry = window.pulseRegionTypes;
var utils = window.pulseRegionUtils || {}; var utils = window.pulseRegionUtils || {};
var placeholderUtils = window.placeholderUtils || {};
var DEFAULT_STYLE = { var DEFAULT_STYLE = {
font_family: 'Arial', font_family: 'Arial',
font_size: 32, font_size: 32,
@@ -114,14 +115,32 @@
return current === undefined || current === null ? '' : current; return current === undefined || current === null ? '' : current;
} }
function substituteVariables(html, item) { function substituteVariables(html, item, options) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) { return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return ''; return '';
} }
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName; if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return escapeHtml(resolvePath(item, key || '')); return '';
}
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var imageStyle = options && options.preview && imageConfig.width && imageConfig.height
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) {
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
}
return image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
}); });
} }
@@ -186,18 +205,8 @@
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var item = getItem(feedId, itemNumber, feeds); var item = getItem(feedId, itemNumber, feeds);
var body = item ? substituteVariables(content, item) : ''; var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
var body = hasFeed ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
if (!body && item) {
var summaryParts = [];
if (item.title) {
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
}
if (item.description) {
summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
}
body = summaryParts.join('');
}
if (!body) { if (!body) {
return ''; return '';
@@ -206,6 +215,21 @@
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : ''; return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
} }
function renderPlaceholderInfo(regionId) {
if (!window.placeholderInfo) {
return '';
}
return window.placeholderInfo.render(regionId, 'RSS placeholder transforms',
'<p class="small text-body-secondary">Placeholders read values from the selected RSS entry. Use the available field chips as the starting point for your message.</p>' +
'<h3 class="fs-6 mt-4 fw-normal">Placeholder paths</h3>' +
'<p class="small">Nested values use dots. Missing values render as empty text.</p>' +
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
'<h3 class="fs-6 mt-4 fw-normal">Date formatting</h3>' +
'<p class="small">Use <code>format("MMM D, YYYY")</code> with date fields. Use <code>tz()</code> for a short timezone name and <code>tz_long()</code> for the full timezone name.</p>' +
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : ''));
}
function renderEditorCard(context) { function renderEditorCard(context) {
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
@@ -221,7 +245,7 @@
'<span class="chip">RSS</span>' + '<span class="chip">RSS</span>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="card-body p-3 d-grid gap-3 pb-0">' + '<div class="card-body p-3 d-grid gap-3">' +
'<div class="d-flex justify-content-end">' + '<div class="d-flex justify-content-end">' +
'<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' + '<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size">-</button>' + '<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size">-</button>' +
@@ -247,9 +271,8 @@
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' + '<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' + '<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
'<div class="api-region-placeholder-section">' + '<div class="api-region-placeholder-section">' +
'<div class="api-region-placeholder-title">Available placeholders</div>' + '<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' + '<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, or <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>'; '</div>';
@@ -291,7 +314,7 @@
return fallbackValue; return fallbackValue;
} }
}())), }())),
feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id, feed_id: card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') ? card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]').value : current.feed_id,
item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number
} : current; } : current;
+3 -3
View File
@@ -33,9 +33,9 @@
} }
var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {}; var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {};
var fontFamily = style && style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; var fontFamily = 'font-family:' + escapeHtml(style && style.font_family || DEFAULT_STYLE.font_family) + ';';
var color = style && style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; var color = 'color:' + escapeHtml(style && style.font_color || DEFAULT_STYLE.font_color) + ';';
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style && style.font_size || DEFAULT_STYLE.font_size))) + 'px;';
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color; var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color;
+14 -2
View File
@@ -286,6 +286,19 @@
return renderTimeDatePlaceholderChips(); return renderTimeDatePlaceholderChips();
} }
function renderPlaceholderInfo(regionId) {
if (!window.placeholderInfo) {
return '';
}
var placeholders = window.timeDatePlaceholders && typeof window.timeDatePlaceholders.getTokens === 'function'
? window.timeDatePlaceholders.getTokens()
: [];
var table = '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Placeholder</th><th>Output</th><th>Description</th></tr></thead><tbody>' + placeholders.map(function (item) {
return '<tr><td><code>{{' + escapeHtml(item.token) + '}}</code></td><td>' + escapeHtml(item.output) + '</td><td>' + escapeHtml(item.description) + '</td></tr>';
}).join('') + '</tbody></table></div>';
return window.placeholderInfo.render(regionId, 'Time and date placeholders', table);
}
function renderEditorCard(context) { function renderEditorCard(context) {
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
@@ -317,9 +330,8 @@
'<datalist id="region_time_date_timezones_' + region.id + '">' + buildTimezoneOptionsMarkup(timeZone) + '</datalist>' + '<datalist id="region_time_date_timezones_' + region.id + '">' + buildTimezoneOptionsMarkup(timeZone) + '</datalist>' +
'</div>' + '</div>' +
'<div>' + '<div>' +
'<div class="api-region-placeholder-title mb-2">Available placeholders</div>' + '<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2 mb-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' + '<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, or <code>{{title.lower()}}</code>.</div>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>'; '</div>';
+16 -7
View File
@@ -240,6 +240,21 @@
}; };
} }
function renderPlaceholderInfo(regionId) {
if (!window.placeholderInfo) {
return '';
}
var body = '<p class="small text-body-secondary">Placeholders read fields from the selected timetable entries. The selected group and display mode determine which entries and fields are available.</p>' +
'<h3 class="fs-6 mt-4 fw-normal">Placeholder paths</h3>' +
'<p class="small">Use the available field chips. Nested values use dots, and missing values render as empty text.</p>' +
window.placeholderInfo.renderTextTransforms() +
'<h3 class="fs-6 mt-4 fw-normal">Date and time formatting</h3>' +
'<p class="small">Use <code>format("MMM D, YYYY h:mm A")</code> with date fields. Use <code>tz()</code> for a short timezone name and <code>tz_long()</code> for the full timezone name.</p>' +
window.placeholderInfo.renderDateFormatTokens() +
'';
return window.placeholderInfo.render(regionId, 'Timetable placeholder transforms', body);
}
function renderEditorCard(context) { function renderEditorCard(context) {
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
@@ -298,14 +313,8 @@
'</div>' + '</div>' +
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' + '<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' + '<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' + '<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' + '<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> for the short zone name, or <code>{{start.tz_long()}}</code> for the full IANA zone name.</div>' +
'<details class="mt-2">' +
'<summary class="small text-body-secondary">Supported date format tokens</summary>' +
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
'<div class="text-body-secondary small mt-1">Use these tokens inside <code>.format(...)</code>; for example <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</details>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
+1 -1
View File
@@ -50,7 +50,7 @@
headerActions: '<span class="chip">Webpage</span>', headerActions: '<span class="chip">Webpage</span>',
bodyHtml: '' + bodyHtml: '' +
'<div class="input-group flex-nowrap">' + '<div class="input-group flex-nowrap">' +
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' + '<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" required />' +
'<button type="button" class="btn btn-outline-secondary text-nowrap" data-webpage-preview-update data-region-id="' + region.id + '">Update</button>' + '<button type="button" class="btn btn-outline-secondary text-nowrap" data-webpage-preview-update data-region-id="' + region.id + '">Update</button>' +
'</div>' '</div>'
}); });
@@ -1,5 +1,6 @@
(function () { (function () {
if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') { var normalizedPathname = window.location.pathname.replace(/\/$/, '');
if (normalizedPathname !== '/settings/tasks-background' && normalizedPathname !== '/settings/tasks-scheduled') {
return; return;
} }
@@ -0,0 +1,67 @@
// Shared offcanvas renderer for region placeholder documentation.
(function () {
var root = window;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function render(regionId, title, body) {
var offcanvasId = String(regionId || 'region') + '-placeholder-info';
return '' +
'<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
'<div class="offcanvas-header">' +
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">' + escapeHtml(title) + '</h2>' +
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
'</div>' +
'<div class="offcanvas-body">' + body + '</div>' +
'</div>';
}
function renderTextTransforms() {
return '<h3 class="fs-6 mt-4 fw-normal">Text transforms</h3>' +
'<dl class="small fw-normal">' +
'<dt class="fw-normal"><code>upper()</code></dt><dd>Converts text to uppercase.</dd>' +
'<dt class="fw-normal"><code>lower()</code></dt><dd>Converts text to lowercase.</dd>' +
'<dt class="fw-normal"><code>title()</code></dt><dd>Capitalizes each word.</dd>' +
'<dt class="fw-normal"><code>tz()</code></dt><dd>Returns the short timezone name for a date.</dd>' +
'<dt class="fw-normal"><code>tz_long()</code></dt><dd>Returns the full timezone name for a date.</dd>' +
'</dl>';
}
function renderDateFormatTokens() {
return '<h3 class="fs-6 mt-4 fw-normal">Date format tokens</h3>' +
'<p class="small">Use the <code>format("...")</code> transform with these tokens. Text inside square brackets is treated as a literal.</p>' +
'<div class="table-responsive small"><table class="table table-sm align-middle mb-0"><thead><tr><th>Token</th><th>Meaning</th><th>Example</th></tr></thead><tbody>' +
'<tr><td><code>YYYY</code></td><td>Four-digit year</td><td><code>2026</code></td></tr><tr><td><code>YY</code></td><td>Two-digit year</td><td><code>26</code></td></tr>' +
'<tr><td><code>MMMM</code></td><td>Full month name</td><td><code>August</code></td></tr><tr><td><code>MMM</code></td><td>Short month name</td><td><code>Aug</code></td></tr>' +
'<tr><td><code>MM</code></td><td>Two-digit month</td><td><code>08</code></td></tr><tr><td><code>M</code></td><td>Month number</td><td><code>8</code></td></tr>' +
'<tr><td><code>DD</code></td><td>Two-digit day</td><td><code>16</code></td></tr><tr><td><code>D</code></td><td>Day number</td><td><code>16</code></td></tr>' +
'<tr><td><code>dddd</code></td><td>Full weekday name</td><td><code>Sunday</code></td></tr><tr><td><code>ddd</code></td><td>Short weekday name</td><td><code>Sun</code></td></tr>' +
'<tr><td><code>HH</code> / <code>H</code></td><td>24-hour time</td><td><code>19</code> / <code>7</code></td></tr><tr><td><code>hh</code> / <code>h</code></td><td>12-hour time</td><td><code>07</code> / <code>7</code></td></tr>' +
'<tr><td><code>mm</code> / <code>m</code></td><td>Minutes</td><td><code>05</code> / <code>5</code></td></tr><tr><td><code>ss</code> / <code>s</code></td><td>Seconds</td><td><code>09</code> / <code>9</code></td></tr>' +
'<tr><td><code>A</code> / <code>a</code></td><td>AM or PM</td><td><code>PM</code> / <code>pm</code></td></tr>' +
'</tbody></table></div>';
}
function renderImageTransform() {
return '<h3 class="fs-6 mt-4 fw-normal">Image transform</h3>' +
'<p class="small">Use <code>image(width, height)</code> to render an image URL inside a proportional bounding box.</p>' +
'<ul class="small"><li>The preview shows the width and height boundary.</li><li>The player has no boundary.</li><li>The image keeps its original aspect ratio.</li><li>Both dimensions are required for a bounding box.</li></ul>';
}
root.placeholderInfo = {
escapeHtml: escapeHtml,
render: render,
renderTextTransforms: renderTextTransforms,
renderDateFormatTokens: renderDateFormatTokens,
renderImageTransform: renderImageTransform
};
}());
@@ -298,6 +298,27 @@
return resolved; return resolved;
} }
function isImagePlaceholderExpression(expression) {
var parsed = parsePlaceholderExpression(expression);
return parsed.transforms.some(function (transform) {
return transform && transform.name === 'image';
});
}
function getImagePlaceholderConfig(expression) {
var parsed = parsePlaceholderExpression(expression);
var imageTransform = parsed.transforms.find(function (transform) {
return transform && transform.name === 'image';
});
var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : [];
var width = Number(args[0]);
var height = Number(args[1]);
return {
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 0,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0
};
}
function formatPlaceholderValue(value) { function formatPlaceholderValue(value) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return ''; return '';
@@ -349,6 +370,8 @@
resolvePath: resolvePath, resolvePath: resolvePath,
parsePlaceholderExpression: parsePlaceholderExpression, parsePlaceholderExpression: parsePlaceholderExpression,
resolvePlaceholderExpression: resolvePlaceholderExpression, resolvePlaceholderExpression: resolvePlaceholderExpression,
isImagePlaceholderExpression: isImagePlaceholderExpression,
getImagePlaceholderConfig: getImagePlaceholderConfig,
formatPlaceholderValue: formatPlaceholderValue, formatPlaceholderValue: formatPlaceholderValue,
collectPlaceholderFieldPaths: collectPlaceholderFieldPaths collectPlaceholderFieldPaths: collectPlaceholderFieldPaths
}; };
@@ -44,10 +44,6 @@ export function createSlideFormEditorController(options) {
return String(value || ''); return String(value || '');
} }
function normalizeEditorMarkup(value) {
return String(value === undefined || value === null ? '' : value).trim();
}
function isEmptyRichTextValue(value) { function isEmptyRichTextValue(value) {
var raw = String(value === undefined || value === null ? '' : value).trim(); var raw = String(value === undefined || value === null ? '' : value).trim();
if (!raw) { if (!raw) {
@@ -575,7 +571,7 @@ export function createSlideFormEditorController(options) {
return Promise.resolve(null); return Promise.resolve(null);
} }
source.value = normalizeEditorMarkup(normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : ''))); source.value = normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : ''));
if (!source.id) { if (!source.id) {
source.id = 'slide-editor-region-' + regionId; source.id = 'slide-editor-region-' + regionId;
@@ -198,9 +198,9 @@ export function createSlideFormPreviewHelpers(options) {
return module.renderPreview(region, value, style, scale); return module.renderPreview(region, value, style, scale);
} }
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; var fontFamily = 'font-family:' + escapeHtml(style.font_family || 'Arial') + ';';
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; var color = 'color:' + escapeHtml(style.font_color || '#000000') + ';';
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style.font_size || 32))) + 'px;';
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color; var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
@@ -123,7 +123,7 @@ export function createSlideFormRegionHelpers(options) {
return fields; return fields;
} }
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel) { function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel, includeTransformHint) {
var fields = Array.isArray(fieldList) ? fieldList : []; var fields = Array.isArray(fieldList) ? fieldList : [];
var visibleFields = fields.slice(0, 8); var visibleFields = fields.slice(0, 8);
var overflowFields = fields.slice(visibleFields.length); var overflowFields = fields.slice(visibleFields.length);
@@ -137,7 +137,7 @@ export function createSlideFormRegionHelpers(options) {
? placeholderChips.renderChip(field) ? placeholderChips.renderChip(field)
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>'; : '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join(''); }).join('');
var transformHint = '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>'; var transformHint = includeTransformHint === false ? '' : '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>';
if (!overflowFields.length) { if (!overflowFields.length) {
return visibleMarkup + transformHint; return visibleMarkup + transformHint;
@@ -417,7 +417,7 @@ export function createSlideFormRegionHelpers(options) {
return; return;
} }
container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.'); container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.', false);
} }
function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) { function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) {
@@ -561,7 +561,7 @@ export function createSlideFormRegionHelpers(options) {
? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) { ? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>'; return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('') }).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'), : buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.', false),
placeholderFields: region.region_type === 'api' placeholderFields: region.region_type === 'api'
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path) ? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
: region.region_type === 'rss' : region.region_type === 'rss'
@@ -602,7 +602,7 @@ export function createSlideFormRegionHelpers(options) {
}).join(''), }).join(''),
placeholderChips: region.region_type === 'rss' placeholderChips: region.region_type === 'rss'
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.') ? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'), : buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.', false),
placeholderFields: region.region_type === 'api' placeholderFields: region.region_type === 'api'
? getApiFieldList(apiConfig.source_id, apiItemsPath) ? getApiFieldList(apiConfig.source_id, apiItemsPath)
: region.region_type === 'rss' : region.region_type === 'rss'
+11 -21
View File
@@ -305,31 +305,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
previewBox.innerHTML = value previewBox.innerHTML = value
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : isQrImage ? 'QR image' : 'image') + '">' + ? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : isQrImage ? 'QR image' : 'image') + '">' +
(isVideo (isVideo
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>' ? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" muted playsinline preload="metadata"></video>'
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="' + (isQrImage ? 'Current QR image preview' : 'Current image preview') + '" />') + : '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="' + (isQrImage ? 'Current QR image preview' : 'Current image preview') + '" />') +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' + '<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>' '</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>'; : '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>';
if (isVideo) {
window.requestAnimationFrame(function () {
var video = previewBox.querySelector('video.slide-image-region-preview');
if (!video) {
return;
}
try {
video.load();
} catch (_error) {
// Ignore load failures; play() will retry if the browser allows it.
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
});
}
} }
function loadVideoDuration(mediaPath) { function loadVideoDuration(mediaPath) {
@@ -654,7 +635,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var content = typeModule && typeof typeModule.renderPreview === 'function' var content = typeModule && typeof typeModule.renderPreview === 'function'
? typeModule.renderPreview(region, previewContext, previewContext) ? typeModule.renderPreview(region, previewContext, previewContext)
: renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale); : renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
if (typeModule && typeof typeModule.renderPreview === 'function') { if (typeModule && typeof typeModule.renderPreview === 'function' && region.region_type !== 'api' && region.region_type !== 'rss') {
content = content || renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale); content = content || renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
} }
var selected = region.region_type === 'image' var selected = region.region_type === 'image'
@@ -861,6 +842,15 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
requestPreviewRender(); requestPreviewRender();
} }
slideForm.addEventListener('keydown', function (event) {
var target = event.target;
if (event.key !== 'Enter' || !target || !target.matches || !target.matches('input:not([type="submit"]):not([type="button"]):not([type="reset"]), select')) {
return;
}
event.preventDefault();
});
slideForm.addEventListener('submit', function (event) { slideForm.addEventListener('submit', function (event) {
if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') { if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') {
return; return;
+17 -16
View File
@@ -2,25 +2,26 @@
(function () { (function () {
var storageKey = 'lte-theme'; var storageKey = 'lte-theme';
var theme = 'dark'; var root = document.documentElement;
var authoredTheme = root.getAttribute('data-bs-theme');
function getPreferredTheme() { var theme = authoredTheme === 'light' || authoredTheme === 'dark' ? authoredTheme : 'dark';
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { var storedTheme = null;
return 'dark';
}
return 'light';
}
try { try {
var storedTheme = window.localStorage.getItem(storageKey); storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'auto') {
theme = storedTheme === 'auto' ? getPreferredTheme() : storedTheme;
}
} catch (error) { } catch (error) {
theme = 'dark'; storedTheme = null;
} }
document.documentElement.dataset.bsTheme = theme; if (storedTheme === 'light' || storedTheme === 'dark') {
document.documentElement.style.colorScheme = theme; theme = storedTheme;
} else if (storedTheme === 'auto' || (authoredTheme !== 'light' && authoredTheme !== 'dark')) {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
root.setAttribute('data-bs-theme', theme);
root.style.colorScheme = theme;
if (theme !== authoredTheme) {
root.setAttribute('data-lte-theme-resolved', '');
}
}()); }());
+97
View File
@@ -1,6 +1,102 @@
// Shared browser helpers for web UI escaping and formatting. // Shared browser helpers for web UI escaping and formatting.
(function () { (function () {
var urlValidationMessage = 'Please enter a URL.';
function isUrlInput(input) {
return input && input.matches && input.matches('input[type="url"]');
}
function isValidHttpUrl(input) {
input.setCustomValidity('');
var value = String(input.value || '').trim();
var hasValidNativeUrl = input.validity.valid;
var hasExplicitHttpScheme = /^https?:\/\/[^\s]+$/i.test(value);
var isValid = hasValidNativeUrl && hasExplicitHttpScheme;
if (!isValid) {
input.setCustomValidity(urlValidationMessage);
}
return isValid;
}
function getUrlFeedback(input) {
if (input._urlFeedback && input._urlFeedback.parentNode) {
return input._urlFeedback;
}
var feedbackId = input.id ? input.id + '-url-feedback' : '';
var feedback = feedbackId ? document.getElementById(feedbackId) : null;
if (feedback) {
var inputGroup = input.closest ? input.closest('.input-group') : null;
var feedbackAnchor = inputGroup || input;
feedbackAnchor.parentNode.insertBefore(feedback, feedbackAnchor.nextSibling);
input._urlFeedback = feedback;
return feedback;
}
feedback = document.createElement('div');
if (feedbackId) {
feedback.id = feedbackId;
}
feedback.className = 'invalid-feedback';
feedback.setAttribute('role', 'alert');
feedback.textContent = urlValidationMessage;
var inputGroup = input.closest ? input.closest('.input-group') : null;
var feedbackAnchor = inputGroup || input;
feedbackAnchor.parentNode.insertBefore(feedback, feedbackAnchor.nextSibling);
if (input.id) {
input.setAttribute('aria-describedby', feedback.id);
}
input._urlFeedback = feedback;
return feedback;
}
function updateUrlValidation(input, showFeedback) {
if (!isUrlInput(input)) {
return true;
}
var isValid = isValidHttpUrl(input);
if (!isValid && showFeedback) {
var feedback = getUrlFeedback(input);
feedback.hidden = false;
feedback.classList.add('d-block');
input.classList.add('is-invalid');
} else if (isValid) {
if (input.id) {
document.querySelectorAll('[id="' + input.id + '-url-feedback"]').forEach(function (feedback) {
feedback.hidden = true;
feedback.classList.remove('d-block');
});
} else if (input._urlFeedback) {
input._urlFeedback.hidden = true;
input._urlFeedback.classList.remove('d-block');
}
input.classList.remove('is-invalid');
}
return isValid;
}
function initializeUrlValidation() {
if (typeof document === 'undefined') {
return;
}
document.addEventListener('input', function (event) {
if (isUrlInput(event.target)) {
updateUrlValidation(event.target, true);
}
});
document.addEventListener('invalid', function (event) {
if (isUrlInput(event.target)) {
event.preventDefault();
updateUrlValidation(event.target, true);
}
}, true);
}
function escapeHtml(value) { function escapeHtml(value) {
return String(value ?? '') return String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -97,6 +193,7 @@
} }
window.escapeHtml = escapeHtml; window.escapeHtml = escapeHtml;
initializeUrlValidation();
var defaultQrOptions = window.defaultQrOptions || { var defaultQrOptions = window.defaultQrOptions || {
width: 1000, width: 1000,
@@ -21,6 +21,13 @@ function buildDuplicateApiSource(apiSource, duplicateName) {
authBearerToken: apiSource.auth_bearer_token || '', authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key', authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '', authHeaderValue: apiSource.auth_header_value || '',
requestMethod: apiSource.request_method || 'GET',
requestBodyJson: apiSource.request_body_json || '',
tokenUrl: apiSource.token_url || '',
tokenRequestBodyJson: apiSource.token_request_body_json || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
tokenHeaderName: apiSource.token_header_name || 'Authorization',
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
itemsPath: apiSource.items_path || '' itemsPath: apiSource.items_path || ''
}); });
} }
@@ -18,6 +18,13 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
authBearerToken: apiSource.auth_bearer_token || '', authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key', authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '', authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '' tokenUrl: apiSource.token_url || '',
tokenRequestBodyJson: apiSource.token_request_body_json || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
tokenHeaderName: apiSource.token_header_name || 'Authorization',
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
itemsPath: apiSource.items_path || '',
requestMethod: apiSource.request_method || 'GET',
requestBodyJson: apiSource.request_body_json || ''
}), message, currentUser, viewData, true)); }), message, currentUser, viewData, true));
}; };
@@ -5,12 +5,19 @@ function buildDefaultApiSource() {
id: null, id: null,
name: '', name: '',
apiUrl: '', apiUrl: '',
requestMethod: 'GET',
requestBodyJson: '',
authMethod: 'none', authMethod: 'none',
authUsername: '', authUsername: '',
authPassword: '', authPassword: '',
authBearerToken: '', authBearerToken: '',
authHeaderName: 'X-API-Key', authHeaderName: 'X-API-Key',
authHeaderValue: '', authHeaderValue: '',
tokenUrl: '',
tokenRequestBodyJson: '',
tokenResponsePath: 'access_token',
tokenHeaderName: 'Authorization',
tokenHeaderPrefix: 'Bearer',
itemsPath: '', itemsPath: '',
updateIntervalValue: 60, updateIntervalValue: 60,
updateIntervalUnit: 'minutes', updateIntervalUnit: 'minutes',
@@ -103,6 +103,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
authBearerToken: apiSource.auth_bearer_token || '', authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key', authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '', authHeaderValue: apiSource.auth_header_value || '',
tokenUrl: apiSource.token_url || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
itemsPath: apiSource.items_path || '', itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds' intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`) ? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
@@ -178,6 +180,11 @@ module.exports = function registerApiSourceRoutes(app, deps) {
authBearerToken: apiSource.auth_bearer_token || '', authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key', authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '', authHeaderValue: apiSource.auth_header_value || '',
tokenUrl: apiSource.token_url || '',
tokenRequestBodyJson: apiSource.token_request_body_json || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
tokenHeaderName: apiSource.token_header_name || 'Authorization',
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
itemsPath: apiSource.items_path || '', itemsPath: apiSource.items_path || '',
updateIntervalValue: apiSource.update_interval_value, updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes', updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
@@ -235,8 +242,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
await connection.beginTransaction(); await connection.beginTransaction();
const [result] = await connection.query( const [result] = await connection.query(
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO i_api_sources (name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId] [payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
); );
await connection.commit(); await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
@@ -288,8 +295,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query( await connection.query(
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', 'UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, token_url = ?, token_request_body_json = ?, token_response_path = ?, token_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id] [payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
); );
await connection.commit(); await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
+1 -1
View File
@@ -138,7 +138,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, async function (req, res, next) { app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, async function (req, res, next) {
try { try {
const ran = await backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim()); const ran = await backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.')); res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Task run queued.' : 'Unable to run that task.'));
} catch (error) { } catch (error) {
next(error); next(error);
} }
+15 -1
View File
@@ -28,7 +28,21 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
{ value: 'warning', label: 'Warning' }, { value: 'warning', label: 'Warning' },
{ value: 'danger', label: 'Danger' }, { value: 'danger', label: 'Danger' },
{ value: 'dark', label: 'Dark' }, { value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' } { value: 'light', label: 'Light' },
{ value: 'orange', label: 'Orange' },
{ value: 'amber', label: 'Amber' },
{ value: 'olive', label: 'Olive' },
{ value: 'teal', label: 'Teal' },
{ value: 'sky', label: 'Sky' },
{ value: 'indigo', label: 'Indigo' },
{ value: 'violet', label: 'Violet' },
{ value: 'fuchsia', label: 'Fuchsia' },
{ value: 'pink', label: 'Pink' },
{ value: 'navy', label: 'Navy' },
{ value: 'steel', label: 'Steel' },
{ value: 'slate', label: 'Slate' },
{ value: 'graphite', label: 'Graphite' },
{ value: 'midnight', label: 'Midnight' }
]; ];
const ANNOUNCEMENT_ICONS = announcementIcons.ANNOUNCEMENT_ICON_OPTIONS || []; const ANNOUNCEMENT_ICONS = announcementIcons.ANNOUNCEMENT_ICON_OPTIONS || [];
const ANNOUNCEMENT_ICON_CATALOG = announcementIcons.ANNOUNCEMENT_ICON_CATALOG || ANNOUNCEMENT_ICONS; const ANNOUNCEMENT_ICON_CATALOG = announcementIcons.ANNOUNCEMENT_ICON_CATALOG || ANNOUNCEMENT_ICONS;
+57 -4
View File
@@ -1,10 +1,12 @@
// Slide route registration and pagination wiring. // Slide route registration and pagination wiring.
const { renderFragment } = require('../../../view'); const { renderFragment } = require('../../../view');
const fs = require('fs');
const crypto = require('crypto');
const path = require('path'); const path = require('path');
const { verifyRequestAuth } = require('#src/request-auth'); const { verifyRequestAuth, verifyPageAuthToken } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library'); const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnail-preview'); const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnails');
function safeJsonForScript(value) { function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c'); return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
@@ -27,6 +29,14 @@ module.exports = function registerSlidesRoutes(app, deps) {
next(); next();
} }
function requirePopupPreviewAccess(req, res, next) {
if (verifyRequestAuth(req) || verifyPageAuthToken(req.headers && req.headers['x-pulse-page-auth'])) {
return next();
}
return requirePermission('slides.read')(req, res, next);
}
const LIST_PAGE_SIZE = 25; const LIST_PAGE_SIZE = 25;
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
@@ -45,7 +55,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
} }
}); });
app.get('/slides/popup-preview', requirePermission('slides.read'), function (_req, res) { app.get('/slides/popup-preview', requirePopupPreviewAccess, function (_req, res) {
res.send(renderFragment('slides/popup-preview', { res.send(renderFragment('slides/popup-preview', {
title: 'Slide preview', title: 'Slide preview',
framePopupCard: true, framePopupCard: true,
@@ -61,9 +71,52 @@ module.exports = function registerSlidesRoutes(app, deps) {
return res.status(404).send('Slide not found'); return res.status(404).send('Slide not found');
} }
const rssData = typeof common.fetchRssFeedsData === 'function' ? await common.fetchRssFeedsData(pool) : { rssFeeds: [] };
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, feed.id) : [];
return Object.assign({}, feed, { items: items });
}));
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const apiSources = (apiData.apiSources || []).map(function (source) {
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
});
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;
}
if (type === 'rss') {
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
return feed && Array.isArray(feed.items) ? feed.items[index] || null : null;
}
return null;
};
const getCachedImagePath = function (remoteUrl) {
const hash = crypto.createHash('sha256').update(String(remoteUrl || '')).digest('hex');
const cacheDir = path.join(mediaDir, 'player-cache', 'remote-images');
const extensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
const file = extensions.map(function (extension) { return hash + extension; }).find(function (name) { return fs.existsSync(path.join(cacheDir, name)); });
return file ? '/media/player-cache/remote-images/' + file : '';
};
const payload = buildThumbnailPreviewPayload(slide, { const payload = buildThumbnailPreviewPayload(slide, {
baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''), baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''),
fontStylesheetHref: getFontStylesheetHref(mediaDir) fontStylesheetHref: getFontStylesheetHref(mediaDir),
getItem: getItem,
getCachedImagePath: getCachedImagePath
}); });
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
@@ -12,14 +12,30 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-5">
<label for="api-source-name" class="form-label">Name</label> <label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length required /> <input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length required />
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-5">
<label for="api-source-url" class="form-label">API URL</label> <label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required /> <input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
</div> </div>
<div class="col-12 col-lg-2">
<label for="api-source-request-method" class="form-label">Request method</label>
<select id="api-source-request-method" name="request_method" class="form-select" data-api-source-request-method>
<option value="GET" {{#if (eq apiSource.requestMethod 'GET')}}selected{{/if}}>GET</option>
<option value="POST" {{#if (eq apiSource.requestMethod 'POST')}}selected{{/if}}>POST</option>
</select>
</div>
</div>
<div class="mb-4" data-api-source-request-section>
<h4 class="api-source-section-heading">Request</h4>
<div class="row g-3 align-items-end">
<div class="col-12">
<div class="form-text mb-2">POST requests use JSON with the existing authentication settings.</div>
<textarea id="api-source-request-body" name="request_body_json" class="form-control font-monospace" rows="5" data-api-source-request-body placeholder="{&#10; &quot;ids&quot;: [1, 2, 3]&#10;}" spellcheck="false">{{apiSource.requestBodyJson}}</textarea>
</div>
</div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h4 class="api-source-section-heading">Access and parsing</h4> <h4 class="api-source-section-heading">Access and parsing</h4>
@@ -31,6 +47,7 @@
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option> <option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option> <option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option> <option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
<option value="token_login" {{#if (eq apiSource.authMethod 'token_login')}}selected{{/if}}>Login then token</option>
</select> </select>
</div> </div>
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
@@ -40,7 +57,7 @@
</div> </div>
<div class="row mt-2"> <div class="row mt-2">
<div class="col-12 col-md-8 offset-md-4"> <div class="col-12 col-md-8 offset-md-4">
<div class="form-text">Optional. Use dot notation to point at the array this source should iterate through.</div> <div class="form-text">Optional. Use dot notation to change the default base for placeholders.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -77,6 +94,30 @@
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length /> <input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
</div> </div>
</div> </div>
<div class="row g-3 mb-3" data-api-source-auth-panel="token_login" hidden>
<div class="col-12 col-md-8">
<label for="api-source-token-url" class="form-label">Login / token URL</label>
<input id="api-source-token-url" name="token_url" type="url" class="form-control" value="{{apiSource.tokenUrl}}" maxlength="1024" placeholder="https://example.com/login" />
<div class="form-text">The login request is sent as POST with the JSON body below.</div>
</div>
<div class="col-12 col-md-4">
<label for="api-source-token-response-path" class="form-label">Token response path</label>
<input id="api-source-token-response-path" name="token_response_path" class="form-control" value="{{apiSource.tokenResponsePath}}" placeholder="access_token" />
<div class="form-text">Example: <code>data.token</code></div>
</div>
<div class="col-12">
<label for="api-source-token-body" class="form-label">Login request body</label>
<textarea id="api-source-token-body" name="token_request_body_json" class="form-control font-monospace" rows="4" placeholder="{&#10; &quot;username&quot;: &quot;...&quot;,&#10; &quot;password&quot;: &quot;...&quot;&#10;}" spellcheck="false">{{apiSource.tokenRequestBodyJson}}</textarea>
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-name" class="form-label">API token header</label>
<input id="api-source-token-header-name" name="token_header_name" class="form-control" value="{{apiSource.tokenHeaderName}}" placeholder="Authorization" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-prefix" class="form-label">Token prefix</label>
<input id="api-source-token-header-prefix" name="token_header_prefix" class="form-control" value="{{apiSource.tokenHeaderPrefix}}" placeholder="Bearer" />
</div>
</div>
</div> </div>
<div> <div>
<h4 class="api-source-section-heading">Refresh</h4> <h4 class="api-source-section-heading">Refresh</h4>
@@ -103,7 +144,7 @@
</form> </form>
<div class="card card-outline card-secondary mt-3" id="api-source-response-panel"> <div class="card card-outline card-secondary mt-3" id="api-source-response-panel">
<div class="card-header d-flex align-items-center gap-2"> <div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Latest response</h3> <h3 class="card-title mb-0">Latest response</h3>
{{#if lastResponseJson}} {{#if lastResponseJson}}
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions"> <div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
+1
View File
@@ -8,6 +8,7 @@
<script src="/assets/js/theme-init.js"></script> <script src="/assets/js/theme-init.js"></script>
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" /> <link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" /> <link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" /> <link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
{{#if stylesheets.length}} {{#if stylesheets.length}}
{{#each stylesheets}} {{#each stylesheets}}
+4 -3
View File
@@ -10,6 +10,7 @@
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff" as="font" type="font/woff" crossorigin="anonymous" /> <link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff" as="font" type="font/woff" crossorigin="anonymous" />
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" /> <link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" /> <link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" /> <link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" /> <link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
{{#if stylesheets.length}} {{#if stylesheets.length}}
@@ -171,11 +172,11 @@
</a> </a>
</div> </div>
<div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0"> <div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0">
<nav class="mt-2 flex-grow-1" aria-label="Main navigation"> <nav class="flex-grow-1" aria-label="Main navigation">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation"> <ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation">
{{#if (anyPermission currentUser 'dashboard.read' 'clients.read' 'screens.read' 'announcements.read' 'playlists.read' 'slides.read' 'templates.read' 'canvas-sizes.read')}} {{!-- {{#if (anyPermission currentUser 'dashboard.read' 'clients.read' 'screens.read' 'announcements.read' 'playlists.read' 'slides.read' 'templates.read' 'canvas-sizes.read')}}
<li class="nav-header">DIGITAL SIGNAGE</li> <li class="nav-header">DIGITAL SIGNAGE</li>
{{/if}} {{/if}} --}}
{{#if (hasPermission currentUser 'dashboard.read')}} {{#if (hasPermission currentUser 'dashboard.read')}}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{#if (eq active 'dashboard')}}active{{/if}}" href="/dashboard"> <a class="nav-link {{#if (eq active 'dashboard')}}active{{/if}}" href="/dashboard">
+83
View File
@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { fetchApiSourceResponse, buildApiSourcePayload } = require('../src/data/api-sources');
function jsonResponse(status, body) {
return {
status: status,
ok: status >= 200 && status < 300,
headers: { get: () => 'application/json' },
text: async () => JSON.stringify(body)
};
}
test('API source payload accepts POST request and token-login settings', () => {
const payload = buildApiSourcePayload({
body: {
name: 'Protected report',
api_url: 'https://example.com/report',
request_method: 'POST',
request_body_json: '{"site_id":42}',
auth_method: 'token_login',
token_url: 'https://example.com/login',
token_request_body_json: '{"username":"demo","password":"secret"}',
token_response_path: 'data.accessToken',
token_header_name: 'Authorization',
token_header_prefix: 'Bearer',
update_interval_value: '5',
update_interval_unit: 'minutes'
}
});
assert.equal(payload.requestMethod, 'POST');
assert.equal(payload.requestBodyJson, '{"site_id":42}');
assert.equal(payload.authMethod, 'token_login');
assert.equal(payload.tokenUrl, 'https://example.com/login');
assert.equal(payload.tokenResponsePath, 'data.accessToken');
});
test('API source sends login POST, uses token, and refreshes once after 401', async () => {
const originalFetch = global.fetch;
const calls = [];
let loginCount = 0;
let apiCount = 0;
global.fetch = async function (url, options) {
calls.push({ url, options });
if (url === 'https://example.com/login') {
loginCount += 1;
return jsonResponse(200, { accessToken: 'token-' + loginCount, expires_in: 300 });
}
apiCount += 1;
return apiCount === 1
? jsonResponse(401, { error: 'expired' })
: jsonResponse(200, { items: [{ id: 1 }] });
};
try {
const result = await fetchApiSourceResponse({
id: 987654,
api_url: 'https://example.com/report',
request_method: 'POST',
request_body_json: JSON.stringify({ site_id: 42 }),
auth_method: 'token_login',
token_url: 'https://example.com/login',
token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }),
token_response_path: 'accessToken',
token_header_name: 'Authorization',
token_header_prefix: 'Bearer'
});
assert.deepEqual(JSON.parse(result.responseJson), { items: [{ id: 1 }] });
assert.equal(loginCount, 2);
assert.equal(apiCount, 2);
assert.equal(calls[0].options.method, 'POST');
assert.equal(calls[0].options.headers['Content-Type'], 'application/json');
assert.deepEqual(JSON.parse(calls[0].options.body), { username: 'demo', password: 'secret' });
assert.equal(calls[1].options.headers.Authorization, 'Bearer token-1');
assert.equal(calls[3].options.headers.Authorization, 'Bearer token-2');
assert.deepEqual(JSON.parse(calls[3].options.body), { site_id: 42 });
} finally {
global.fetch = originalFetch;
}
});
+2 -2
View File
@@ -99,9 +99,9 @@ test('shared iframe renderers size preview content explicitly', () => {
assert.ok(playerHtmlRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); assert.ok(playerHtmlRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(playerWebpageRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"')); assert.ok(playerWebpageRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"'));
assert.ok(playerRenderHelpersSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); assert.ok(playerRenderHelpersSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(slideThumbnailPreviewSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"')); assert.ok(slideThumbnailPreviewSource.includes('slide-preview-webpage-region'));
assert.ok(slideThumbnailPreviewSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); assert.ok(slideThumbnailPreviewSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(slideThumbnailsSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"')); assert.ok(slideThumbnailsSource.includes('slide-preview-webpage-placeholder'));
assert.ok(slideThumbnailsSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); assert.ok(slideThumbnailsSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
}); });
+2 -2
View File
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
} }
]); ]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.0' }); const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.7' });
assert.equal(pendingMigrations.length, 0); assert.equal(pendingMigrations.length, 0);
}); });
@@ -74,7 +74,7 @@ test('pending migrations are reported when an older schema still needs scripts',
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' }); const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
assert.ok(pendingMigrations.length > 0); assert.ok(pendingMigrations.length > 0);
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1); assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 2);
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0'); assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
}); });
+7
View File
@@ -23,6 +23,11 @@ test('slide editor enables server-backed image uploads', () => {
assert.ok(slideFormEditorSource.includes('remove_script_host: false')); assert.ok(slideFormEditorSource.includes('remove_script_host: false'));
}); });
test('slide editor keeps normal TinyMCE image insertion unchanged', () => {
assert.doesNotMatch(slideFormEditorSource, /bindPlaceholderImageSupport|placeholderFallbackSource/);
assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage'));
});
test('slide editor inserts tables with zero padding and spacing by default', () => { test('slide editor inserts tables with zero padding and spacing by default', () => {
assert.ok(slideFormEditorSource.includes("table_default_attributes: {")); assert.ok(slideFormEditorSource.includes("table_default_attributes: {"));
assert.ok(slideFormEditorSource.includes("cellpadding: '0'")); assert.ok(slideFormEditorSource.includes("cellpadding: '0'"));
@@ -64,6 +69,8 @@ test('slide form queues editor image cleanup on save and close', () => {
test('slide thumbnail previews treat image-only text as visible content', () => { test('slide thumbnail previews treat image-only text as visible content', () => {
assert.ok(slideThumbnailPreviewSource.includes('/<img\\b/i.test(raw)')); assert.ok(slideThumbnailPreviewSource.includes('/<img\\b/i.test(raw)'));
assert.ok(slideThumbnailsSource.includes('/<img\\b/i.test(raw)')); assert.ok(slideThumbnailsSource.includes('/<img\\b/i.test(raw)'));
assert.ok(slideThumbnailsSource.includes('getCachedImagePath'));
assert.ok(slideThumbnailsSource.includes('getThumbnailItem'));
}); });
test('popup preview falls back to iframe sizing rules', () => { test('popup preview falls back to iframe sizing rules', () => {