Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d867dd6f | ||
|
|
196c640f9b | ||
|
|
7bd4a792ee | ||
|
|
e1e759f64e | ||
|
|
f071c21219 | ||
|
|
e984f36875 | ||
|
|
bbd517abbb | ||
|
|
403a928b9e | ||
|
|
7bb34f40fe | ||
|
|
ea72747822 |
@@ -2,6 +2,59 @@
|
|||||||
|
|
||||||
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.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
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Standardized update audit events on from/to changes and added readable table diffs for nested JSON, arrays, null values, and empty strings.
|
||||||
|
- Standardized internal `src/data` imports on the `#src` alias.
|
||||||
|
|
||||||
## 2.8.1 - 2026-08-16
|
## 2.8.1 - 2026-08-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.8.1",
|
"version": "2.8.7",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.8.1",
|
"version": "2.8.7",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.8.1",
|
"version": "2.8.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.8.1",
|
"version": "2.8.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^149.0.0",
|
"@sparticuz/chromium": "^149.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.8.1",
|
"version": "2.8.7",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+179
-17
@@ -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 : {};
|
||||||
if (typeof fetch === 'function') {
|
const method = String(requestOptions && requestOptions.method || 'GET').toUpperCase();
|
||||||
const response = await fetch(urlValue, {
|
const body = requestOptions && requestOptions.body !== undefined ? requestOptions.body : undefined;
|
||||||
headers: Object.assign({
|
const headers = Object.assign({
|
||||||
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||||
'User-Agent': 'Pulse Signage API Reader'
|
'User-Agent': 'Pulse Signage API Reader'
|
||||||
}, extraHeaders)
|
}, extraHeaders);
|
||||||
});
|
if (typeof fetch === 'function') {
|
||||||
|
const fetchOptions = { method: method, headers: headers };
|
||||||
|
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||||
|
fetchOptions.body = body;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ function normalizeDetails(details) {
|
|||||||
return JSON.stringify(details);
|
return JSON.stringify(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAuditChanges(previousValues, nextValues) {
|
||||||
|
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
|
||||||
|
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
|
||||||
|
const changes = {};
|
||||||
|
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
|
||||||
|
|
||||||
|
keys.forEach(function (key) {
|
||||||
|
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
|
||||||
|
changes[key] = { from: previous[key], to: next[key] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
function getRequestMetadata(req) {
|
function getRequestMetadata(req) {
|
||||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||||
return {
|
return {
|
||||||
@@ -97,6 +112,7 @@ module.exports = {
|
|||||||
AUDIT_CATEGORY_KEYS,
|
AUDIT_CATEGORY_KEYS,
|
||||||
AUDIT_CATEGORY_LABELS,
|
AUDIT_CATEGORY_LABELS,
|
||||||
getRequestMetadata,
|
getRequestMetadata,
|
||||||
|
buildAuditChanges,
|
||||||
recordAuditEvent,
|
recordAuditEvent,
|
||||||
recordRequestAuditEvent
|
recordRequestAuditEvent
|
||||||
};
|
};
|
||||||
+2
-2
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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
@@ -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 '';
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : ''
|
|
||||||
});
|
|
||||||
await page.setViewport({
|
await page.setViewport({
|
||||||
width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)),
|
width: Math.max(1, Number(canvasSize.width || PLAYER_VIEWPORT.width)),
|
||||||
height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)),
|
height: Math.max(1, Number(canvasSize.height || PLAYER_VIEWPORT.height)),
|
||||||
deviceScaleFactor: 1
|
deviceScaleFactor: 1
|
||||||
});
|
});
|
||||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
await page.setExtraHTTPHeaders(Object.assign({}, 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,39 @@
|
|||||||
.template-preview-card .btn-group {
|
.template-preview-card .btn-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
.audit-change-list {
|
||||||
|
display: grid;
|
||||||
|
padding: 0.08rem 0.3rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 18rem;
|
||||||
|
}
|
||||||
|
.audit-change-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
|
||||||
|
background: var(--bs-danger-bg-subtle);
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
background: var(--bs-success-bg-subtle);
|
||||||
|
.audit-change-to {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.audit-change-from {
|
||||||
|
color: var(--bs-danger-text-emphasis);
|
||||||
|
}
|
||||||
|
.audit-change-to {
|
||||||
|
color: var(--bs-success-text-emphasis);
|
||||||
|
}
|
||||||
|
.audit-change-from del,
|
||||||
|
.audit-change-to ins {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
.audit-change-arrow {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2350,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,
|
||||||
@@ -2500,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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}());
|
}());
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">' +
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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>' +
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -97,6 +193,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.escapeHtml = escapeHtml;
|
window.escapeHtml = escapeHtml;
|
||||||
|
initializeUrlValidation();
|
||||||
|
|
||||||
var defaultQrOptions = window.defaultQrOptions || {
|
var defaultQrOptions = window.defaultQrOptions || {
|
||||||
width: 1000,
|
width: 1000,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Admin account route registration and profile helpers.
|
// Admin account route registration and profile helpers.
|
||||||
|
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
module.exports = function registerAccountRoutes(app, deps) {
|
module.exports = function registerAccountRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||||
|
|
||||||
module.exports = function registerContentRoutes(app, deps) {
|
module.exports = function registerContentRoutes(app, deps) {
|
||||||
@@ -39,6 +40,26 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
function normalizeTemplateRegionsForAudit(regions) {
|
||||||
|
return (Array.isArray(regions) ? regions : []).map(function (region) {
|
||||||
|
const animation = typeof region.animation_json === 'string'
|
||||||
|
? common.parseJsonSafe(region.animation_json) || {}
|
||||||
|
: region.animation_json || {};
|
||||||
|
return {
|
||||||
|
region_key: region.region_key,
|
||||||
|
region_type: region.region_type,
|
||||||
|
label: region.label,
|
||||||
|
lock_ratio: region.lock_ratio,
|
||||||
|
animation_json: animation,
|
||||||
|
x: Number(region.x),
|
||||||
|
y: Number(region.y),
|
||||||
|
width: Number(region.width),
|
||||||
|
height: Number(region.height),
|
||||||
|
z_index: Number(region.z_index)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSlideFormData() {
|
async function fetchSlideFormData() {
|
||||||
const data = await common.fetchTemplatesData(pool);
|
const data = await common.fetchTemplatesData(pool);
|
||||||
const rssData = await common.fetchRssFeedsData(pool);
|
const rssData = await common.fetchRssFeedsData(pool);
|
||||||
@@ -497,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
title: slide.title,
|
||||||
|
templateId: Number(slide.template_id),
|
||||||
|
content: slide.content
|
||||||
|
}, {
|
||||||
|
title: payload.title,
|
||||||
|
templateId: Number(payload.templateId),
|
||||||
|
content: common.parseJsonSafe(payload.contentJson) || {}
|
||||||
|
});
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
||||||
@@ -512,7 +542,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
screenSlideCounts: screenSlideCounts
|
screenSlideCounts: screenSlideCounts
|
||||||
});
|
});
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
|
||||||
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
||||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||||
});
|
});
|
||||||
@@ -655,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: template.name,
|
||||||
|
canvasSizeId: Number(template.canvas_size_id),
|
||||||
|
backgroundImagePath: template.background_image_path,
|
||||||
|
backgroundColor: template.background_color,
|
||||||
|
regions: normalizeTemplateRegionsForAudit(template.regions)
|
||||||
|
}, {
|
||||||
|
name: payload.name,
|
||||||
|
canvasSizeId: Number(payload.canvasSizeId),
|
||||||
|
backgroundImagePath: payload.backgroundImagePath,
|
||||||
|
backgroundColor: payload.backgroundColor,
|
||||||
|
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
||||||
|
});
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
||||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
||||||
@@ -681,7 +724,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
||||||
closeUrl: '/templates',
|
closeUrl: '/templates',
|
||||||
newUrl: '/templates/new',
|
newUrl: '/templates/new',
|
||||||
@@ -813,8 +856,17 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||||
return res.status(400).send('That canvas size already exists.');
|
return res.status(400).send('That canvas size already exists.');
|
||||||
}
|
}
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: canvasSize.name,
|
||||||
|
width: Number(canvasSize.width),
|
||||||
|
height: Number(canvasSize.height)
|
||||||
|
}, {
|
||||||
|
name: payload.name,
|
||||||
|
width: payload.width,
|
||||||
|
height: payload.height
|
||||||
|
});
|
||||||
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||||
closeUrl: '/canvas-sizes',
|
closeUrl: '/canvas-sizes',
|
||||||
newUrl: '/canvas-sizes/new',
|
newUrl: '/canvas-sizes/new',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Admin manage routes for screens and commands.
|
// Admin manage routes for screens and commands.
|
||||||
|
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerManageRoutes(app, deps) {
|
module.exports = function registerManageRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
@@ -248,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const previousPlaylistId = screen.playlist_id;
|
const previousPlaylistId = screen.playlist_id;
|
||||||
const slug = String(screen.slug || '').trim();
|
const slug = String(screen.slug || '').trim();
|
||||||
const previousSlug = String(screen.slug || '').trim();
|
const previousSlug = String(screen.slug || '').trim();
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: screen.name,
|
||||||
|
slug: previousSlug,
|
||||||
|
playlistId: previousPlaylistId === null ? null : Number(previousPlaylistId)
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
slug: slug,
|
||||||
|
playlistId: playlistId
|
||||||
|
});
|
||||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
@@ -264,7 +275,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
await forwardPlayerCommand(previousSlug, redirectPayload);
|
await forwardPlayerCommand(previousSlug, redirectPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { slug: slug, playlistId: playlistId } });
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
closeUrl: '/screens',
|
closeUrl: '/screens',
|
||||||
newUrl: '/screens/new',
|
newUrl: '/screens/new',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Playlist admin routes and playlist-slide management.
|
// Playlist admin routes and playlist-slide management.
|
||||||
|
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerPlaylistRoutes(app, deps) {
|
module.exports = function registerPlaylistRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -356,7 +357,20 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
await connection.commit();
|
await connection.commit();
|
||||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name });
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: playlist.name,
|
||||||
|
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||||
|
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||||
|
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
fadeBetweenSlides: Boolean(fadeBetweenSlides),
|
||||||
|
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
|
||||||
|
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
|
||||||
|
});
|
||||||
|
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
|
||||||
|
}
|
||||||
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
|
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
|
||||||
closeUrl: '/playlists',
|
closeUrl: '/playlists',
|
||||||
newUrl: '/playlists/new',
|
newUrl: '/playlists/new',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Admin RBAC route registration and permission management.
|
// Admin RBAC route registration and permission management.
|
||||||
const { DEFAULT_ROLE } = require('#src/rbac');
|
const { DEFAULT_ROLE } = require('#src/rbac');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerRbacRoutes(app, deps) {
|
module.exports = function registerRbacRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -413,10 +414,14 @@
|
|||||||
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingRolePermissionKeys = shouldSyncPermissions
|
||||||
|
? await rbacData.fetchRolePermissionKeys(pool, roleId)
|
||||||
|
: [];
|
||||||
|
let existingRoleUserIds = [];
|
||||||
let availableUsers = [];
|
let availableUsers = [];
|
||||||
if (shouldSyncUsers) {
|
if (shouldSyncUsers) {
|
||||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||||
const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||||
const visibleUserIdSet = new Set(visibleUserIds);
|
const visibleUserIdSet = new Set(visibleUserIds);
|
||||||
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
|
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
|
||||||
return !visibleUserIdSet.has(userId);
|
return !visibleUserIdSet.has(userId);
|
||||||
@@ -452,6 +457,17 @@
|
|||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
if (typeof recordRequestAuditEvent === 'function') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
permissionKeys: existingRolePermissionKeys,
|
||||||
|
userIds: existingRoleUserIds
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
description: description || null,
|
||||||
|
permissionKeys: normalizedPermissionKeys,
|
||||||
|
userIds: normalizedUserIds
|
||||||
|
});
|
||||||
await recordRequestAuditEvent(pool, req, {
|
await recordRequestAuditEvent(pool, req, {
|
||||||
category: 'roles',
|
category: 'roles',
|
||||||
eventType: 'role.updated',
|
eventType: 'role.updated',
|
||||||
@@ -459,7 +475,7 @@
|
|||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: roleId,
|
targetId: roleId,
|
||||||
targetLabel: name,
|
targetLabel: name,
|
||||||
details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers }
|
details: { changes: changes }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||||
@@ -494,6 +510,7 @@
|
|||||||
})) {
|
})) {
|
||||||
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||||
}
|
}
|
||||||
|
const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
@@ -514,7 +531,7 @@
|
|||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: roleId,
|
targetId: roleId,
|
||||||
targetLabel: role.name,
|
targetLabel: role.name,
|
||||||
details: { permissionKeys: normalizedPermissionKeys }
|
details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||||
@@ -541,6 +558,7 @@
|
|||||||
? [].concat(req.body.user_ids)
|
? [].concat(req.body.user_ids)
|
||||||
: [];
|
: [];
|
||||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||||
|
const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||||
return Number(user.id);
|
return Number(user.id);
|
||||||
@@ -561,7 +579,7 @@
|
|||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: roleId,
|
targetId: roleId,
|
||||||
targetLabel: role.name,
|
targetLabel: role.name,
|
||||||
details: { userIds: normalizedUserIds }
|
details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Admin user route registration and user-role management.
|
// Admin user route registration and user-role management.
|
||||||
|
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerUsersRoutes(app, deps) {
|
module.exports = function registerUsersRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -319,6 +320,8 @@
|
|||||||
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingUser = await rbacData.fetchUserWithRoles(pool, userId);
|
||||||
|
const changes = buildAuditChanges({ roleIds: existingUser ? existingUser.roleIds : [] }, { roleIds: roleCheck.roleIds });
|
||||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||||
if (typeof recordRequestAuditEvent === 'function') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
await recordRequestAuditEvent(pool, req, {
|
await recordRequestAuditEvent(pool, req, {
|
||||||
@@ -328,7 +331,7 @@
|
|||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
targetLabel: String(userId),
|
targetLabel: String(userId),
|
||||||
details: { roleIds: roleCheck.roleIds }
|
details: { changes: changes }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||||
@@ -404,6 +407,19 @@
|
|||||||
return renderValidationError('That username already exists.');
|
return renderValidationError('That username already exists.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: user.name,
|
||||||
|
username: user.username,
|
||||||
|
roleIds: user.roleIds,
|
||||||
|
accountLocked: Boolean(user.account_locked),
|
||||||
|
passwordReset: false
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
username: username,
|
||||||
|
roleIds: roleCheck.roleIds,
|
||||||
|
accountLocked: accountLocked,
|
||||||
|
passwordReset: shouldUpdatePassword
|
||||||
|
});
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
|
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
|
||||||
if (!result.affectedRows) {
|
if (!result.affectedRows) {
|
||||||
@@ -432,7 +448,7 @@
|
|||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
targetLabel: username,
|
targetLabel: username,
|
||||||
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked }
|
details: { changes: changes }
|
||||||
});
|
});
|
||||||
if (Boolean(user.account_locked) !== accountLocked) {
|
if (Boolean(user.account_locked) !== accountLocked) {
|
||||||
await recordRequestAuditEvent(pool, req, {
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Authentication route registration for the web app.
|
// Authentication route registration for the web app.
|
||||||
|
|
||||||
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
||||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
module.exports = function registerAuthRoutes(app, deps) {
|
module.exports = function registerAuthRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const renderApiSourcesPage = require('./list');
|
|||||||
const renderApiSourceAddPage = require('./add');
|
const renderApiSourceAddPage = require('./add');
|
||||||
const renderApiSourceEditPage = require('./edit');
|
const renderApiSourceEditPage = require('./edit');
|
||||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
||||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
function toIsoTimestamp(value) {
|
function toIsoTimestamp(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const renderRssFeedsPage = require('./list');
|
|||||||
const renderRssFeedAddPage = require('./add');
|
const renderRssFeedAddPage = require('./add');
|
||||||
const renderRssFeedEditPage = require('./edit');
|
const renderRssFeedEditPage = require('./edit');
|
||||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
||||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
async function getDataSourceUsageMaps(pool, common) {
|
async function getDataSourceUsageMaps(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Shared timetable group form view-model builder.
|
// Shared timetable group form view-model builder.
|
||||||
|
|
||||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||||
|
|
||||||
const COMMON_TIME_ZONES = [
|
const COMMON_TIME_ZONES = [
|
||||||
'UTC',
|
'UTC',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Timetable group list page renderer.
|
// Timetable group list page renderer.
|
||||||
|
|
||||||
const { renderView } = require('../../../view');
|
const { renderView } = require('../../../view');
|
||||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||||
|
|
||||||
function formatDateInTimeZone(value, timeZone) {
|
function formatDateInTimeZone(value, timeZone) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery
|
|||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) {
|
||||||
|
return String(left).localeCompare(String(right));
|
||||||
|
});
|
||||||
|
|
||||||
function requireAuditLogAccess(setAuthMessageCookie) {
|
function requireAuditLogAccess(setAuthMessageCookie) {
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
if (!req.currentUser) {
|
if (!req.currentUser) {
|
||||||
@@ -44,11 +48,109 @@ function csvCell(value) {
|
|||||||
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
|
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value) {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditValue(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return 'undefined';
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimitiveArray(value) {
|
||||||
|
return Array.isArray(value) && value.every(function (item) {
|
||||||
|
return item === null || typeof item !== 'object';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingAuditValue(value) {
|
||||||
|
return value === null || value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAuditChangeRows(previousValue, nextValue, path, rows) {
|
||||||
|
if (isMissingAuditValue(previousValue) && isMissingAuditValue(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMissingAuditValue(previousValue)) {
|
||||||
|
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(nextValue) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMissingAuditValue(nextValue)) {
|
||||||
|
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(previousValue), to: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrimitiveArray(previousValue) && isPrimitiveArray(nextValue)) {
|
||||||
|
const previousItems = new Set(previousValue.map(function (item) { return JSON.stringify(item); }));
|
||||||
|
const nextItems = new Set(nextValue.map(function (item) { return JSON.stringify(item); }));
|
||||||
|
const removedItems = previousValue.filter(function (item) { return !nextItems.has(JSON.stringify(item)); });
|
||||||
|
const addedItems = nextValue.filter(function (item) { return !previousItems.has(JSON.stringify(item)); });
|
||||||
|
if (removedItems.length) {
|
||||||
|
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(removedItems), to: '' });
|
||||||
|
}
|
||||||
|
if (addedItems.length) {
|
||||||
|
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(addedItems) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(previousValue) === JSON.stringify(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(previousValue) && isRecord(nextValue)) {
|
||||||
|
const keys = new Set(Object.keys(previousValue).concat(Object.keys(nextValue)));
|
||||||
|
keys.forEach(function (key) {
|
||||||
|
collectAuditChangeRows(previousValue[key], nextValue[key], path ? path + '.' + key : key, rows);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(previousValue) && Array.isArray(nextValue) && previousValue.length === nextValue.length && previousValue.some(isRecord)) {
|
||||||
|
for (let index = 0; index < previousValue.length; index += 1) {
|
||||||
|
collectAuditChangeRows(previousValue[index], nextValue[index], path + '[' + index + ']', rows);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
path: path,
|
||||||
|
direction: 'changed',
|
||||||
|
from: formatAuditValue(previousValue),
|
||||||
|
to: formatAuditValue(nextValue)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditDetailView(details) {
|
||||||
|
if (!isRecord(details) || !isRecord(details.changes)) {
|
||||||
|
return { hasChanges: false, changeRows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
Object.keys(details.changes).forEach(function (key) {
|
||||||
|
const change = details.changes[key];
|
||||||
|
if (isRecord(change) && Object.prototype.hasOwnProperty.call(change, 'from') && Object.prototype.hasOwnProperty.call(change, 'to')) {
|
||||||
|
collectAuditChangeRows(change.from, change.to, key, rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { hasChanges: rows.length > 0, changeRows: rows };
|
||||||
|
}
|
||||||
|
|
||||||
function mapAuditRow(row, formatDashboardDate) {
|
function mapAuditRow(row, formatDashboardDate) {
|
||||||
let details = '';
|
let details = '';
|
||||||
|
let parsedDetails = null;
|
||||||
if (row.details_json) {
|
if (row.details_json) {
|
||||||
try {
|
try {
|
||||||
details = JSON.stringify(typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json);
|
parsedDetails = typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json;
|
||||||
|
details = JSON.stringify(parsedDetails);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
details = String(row.details_json);
|
details = String(row.details_json);
|
||||||
}
|
}
|
||||||
@@ -60,7 +162,8 @@ function mapAuditRow(row, formatDashboardDate) {
|
|||||||
actorLabel: row.actor_name || row.actor_username || 'System',
|
actorLabel: row.actor_name || row.actor_username || 'System',
|
||||||
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
||||||
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
|
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
|
||||||
detailsDisplay: details
|
detailsDisplay: details,
|
||||||
|
auditDetailView: buildAuditDetailView(parsedDetails)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +239,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
|
|||||||
active: 'audit-log',
|
active: 'audit-log',
|
||||||
currentUser: req.currentUser,
|
currentUser: req.currentUser,
|
||||||
events: events,
|
events: events,
|
||||||
categories: AUDIT_CATEGORY_KEYS,
|
categories: SORTED_AUDIT_CATEGORY_KEYS,
|
||||||
eventTypes: eventTypes,
|
eventTypes: eventTypes,
|
||||||
selectedCategory: category,
|
selectedCategory: category,
|
||||||
selectedEventType: eventType,
|
selectedEventType: eventType,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
|||||||
const { buildPagination } = require('../../../lib/pagination');
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
|
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
|
||||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 25;
|
const LIST_PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
|||||||
@@ -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="{ "ids": [1, 2, 3] }" 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="{ "username": "...", "password": "..." }" 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">
|
||||||
|
|||||||
@@ -44,7 +44,22 @@
|
|||||||
<td>{{actorLabel}}</td>
|
<td>{{actorLabel}}</td>
|
||||||
<td>{{targetLabelDisplay}}</td>
|
<td>{{targetLabelDisplay}}</td>
|
||||||
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td>
|
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td>
|
||||||
<td class="text-break"><small>{{detailsDisplay}}</small></td>
|
<td class="text-break">
|
||||||
|
{{#if auditDetailView.hasChanges}}
|
||||||
|
<div class="audit-change-list">
|
||||||
|
{{#each auditDetailView.changeRows}}
|
||||||
|
<div class="audit-change-row">
|
||||||
|
<code class="audit-change-path">{{path}}</code>
|
||||||
|
<span class="audit-change-from"><span class="visually-hidden">From: </span>{{from}}</span>
|
||||||
|
{{#if (eq direction "changed")}}<span class="audit-change-arrow" aria-hidden="true">→</span>{{else}}<span></span>{{/if}}
|
||||||
|
<span class="audit-change-to"><span class="visually-hidden">To: </span>{{to}}</span>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<small>{{detailsDisplay}}</small>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerAccountRoutes = require('../src/web/routes/admin/account');
|
const registerAccountRoutes = require('../src/web/routes/admin/account');
|
||||||
const { validatePasswordStrength } = require('../src/auth');
|
const { validatePasswordStrength } = require('../src/auth');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
|
|
||||||
test('user create route allows users without roles', async () => {
|
test('user create route allows users without roles', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
|
|
||||||
test('user edit save and new goes to the blank create page', async () => {
|
test('user edit save and new goes to the blank create page', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
const { validatePasswordStrength } = require('../src/auth');
|
const { validatePasswordStrength } = require('../src/auth');
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -24,7 +24,26 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
category: 'authentication',
|
category: 'authentication',
|
||||||
event_type: 'login.success',
|
event_type: 'login.success',
|
||||||
target_label: 'newer',
|
target_label: 'newer',
|
||||||
details_json: null
|
details_json: JSON.stringify({
|
||||||
|
changes: {
|
||||||
|
content: {
|
||||||
|
from: { title: 'Same', body: { color: 'red', keep: 'same' } },
|
||||||
|
to: { title: 'Same', body: { color: 'blue', keep: 'same' } }
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
from: ['dashboard.read', 'screens.read'],
|
||||||
|
to: ['screens.read', 'playlists.read']
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
from: null,
|
||||||
|
to: '#111111'
|
||||||
|
},
|
||||||
|
logoPath: {
|
||||||
|
from: '',
|
||||||
|
to: '/media/logo.png'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -60,10 +79,23 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
assert.match(response.body, /Audit log/);
|
assert.match(response.body, /Audit log/);
|
||||||
|
assert.ok(response.body.indexOf('>All categories</option>') < response.body.indexOf('>announcements</option>'));
|
||||||
|
assert.ok(response.body.indexOf('>announcements</option>') < response.body.indexOf('>api-sources</option>'));
|
||||||
|
assert.ok(response.body.indexOf('>api-sources</option>') < response.body.indexOf('>canvas-sizes</option>'));
|
||||||
assert.match(response.body, /table-pagination/);
|
assert.match(response.body, /table-pagination/);
|
||||||
assert.match(response.body, /data-local-datetime/);
|
assert.match(response.body, /data-local-datetime/);
|
||||||
assert.match(response.body, /audit-event-type-options/);
|
assert.match(response.body, /audit-event-type-options/);
|
||||||
assert.match(response.body, /login\.success/);
|
assert.match(response.body, /login\.success/);
|
||||||
|
assert.match(response.body, /content\.body\.color/);
|
||||||
|
assert.match(response.body, />red</);
|
||||||
|
assert.match(response.body, />blue</);
|
||||||
|
assert.doesNotMatch(response.body, /content\.body\.keep/);
|
||||||
|
assert.match(response.body, /permissions removed/);
|
||||||
|
assert.match(response.body, /permissions added/);
|
||||||
|
assert.match(response.body, /dashboard\.read/);
|
||||||
|
assert.match(response.body, /playlists\.read/);
|
||||||
|
assert.match(response.body, /backgroundColor added/);
|
||||||
|
assert.match(response.body, /logoPath added/);
|
||||||
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
|
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
|
||||||
assert.match(response.body, />Export</);
|
assert.match(response.body, />Export</);
|
||||||
assert.ok(handlers['/settings/audit-log/export']);
|
assert.ok(handlers['/settings/audit-log/export']);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
const { recordRequestAuditEvent } = require('../src/data/audit-log');
|
const { buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
|
||||||
|
|
||||||
function createPool(settings) {
|
function createPool(settings) {
|
||||||
const inserts = [];
|
const inserts = [];
|
||||||
@@ -45,3 +45,18 @@ test('audit writer omits request metadata when disabled', async () => {
|
|||||||
assert.equal(pool.inserts[0][6], null);
|
assert.equal(pool.inserts[0][6], null);
|
||||||
assert.equal(pool.inserts[0][7], null);
|
assert.equal(pool.inserts[0][7], null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('audit changes include only fields with different from and to values', () => {
|
||||||
|
assert.deepEqual(buildAuditChanges({
|
||||||
|
name: 'Old name',
|
||||||
|
playlistId: 4,
|
||||||
|
roleIds: [1, 2]
|
||||||
|
}, {
|
||||||
|
name: 'New name',
|
||||||
|
playlistId: 4,
|
||||||
|
roleIds: [2, 3]
|
||||||
|
}), {
|
||||||
|
name: { from: 'Old name', to: 'New name' },
|
||||||
|
roleIds: { from: [1, 2], to: [2, 3] }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
|
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
|
||||||
|
|
||||||
function createAppAndHandlers() {
|
function createAppAndHandlers() {
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
buildDuplicateTimetableGroupName,
|
buildDuplicateTimetableGroupName,
|
||||||
buildDuplicateTimetableGroup,
|
buildDuplicateTimetableGroup,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||||
|
|
||||||
test('user duplicate route pre-fills the add form', async () => {
|
test('user duplicate route pre-fills the add form', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
||||||
|
|
||||||
test('screen update keeps the existing slug on edit', async () => {
|
test('screen update keeps the existing slug on edit', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user