Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb34f40fe | ||
|
|
ea72747822 |
@@ -2,6 +2,25 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.8.1",
|
||||
"version": "2.8.3",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.8.1",
|
||||
"version": "2.8.3",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.8.1",
|
||||
"version": "2.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.8.1",
|
||||
"version": "2.8.3",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^149.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.8.1",
|
||||
"version": "2.8.3",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
|
||||
@@ -42,6 +42,21 @@ function normalizeDetails(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) {
|
||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
return {
|
||||
@@ -97,6 +112,7 @@ module.exports = {
|
||||
AUDIT_CATEGORY_KEYS,
|
||||
AUDIT_CATEGORY_LABELS,
|
||||
getRequestMetadata,
|
||||
buildAuditChanges,
|
||||
recordAuditEvent,
|
||||
recordRequestAuditEvent
|
||||
};
|
||||
+2
-2
@@ -418,7 +418,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
||||
content[region.region_key] = {
|
||||
type: 'rss',
|
||||
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),
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
@@ -436,7 +436,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
||||
content[region.region_key] = {
|
||||
type: 'api',
|
||||
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,
|
||||
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
|
||||
variable_name: 'item',
|
||||
|
||||
@@ -67,7 +67,23 @@ function substituteApiVariables(html, item) {
|
||||
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 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 +136,8 @@ function renderApiRegion(region, regionContent) {
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||
var item = getApiItem(sourceId, itemNumber, itemsPath);
|
||||
var body = item ? substituteApiVariables(content, item) : '';
|
||||
if (!body && item) {
|
||||
body = getApiPreviewFallback(item);
|
||||
}
|
||||
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
|
||||
var body = hasSource ? (item ? substituteApiVariables(content, item) : '') : content;
|
||||
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) {
|
||||
return '';
|
||||
|
||||
+24
-14
@@ -1,6 +1,7 @@
|
||||
// RSS region helpers for resolving feeds, items, and nested values.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
@@ -65,12 +66,30 @@ function resolveRssPath(value, path) {
|
||||
|
||||
function substituteRssVariables(html, item) {
|
||||
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') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
|
||||
return escapeHtml(resolveRssPath(item, key || ''));
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||
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 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 +102,8 @@ function renderRssRegion(region, regionContent) {
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||
var item = getRssFeedItem(feedId, itemNumber);
|
||||
var body = item ? substituteRssVariables(content, item) : '';
|
||||
if (!body && item) {
|
||||
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 hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
|
||||
var body = hasFeed ? (item ? substituteRssVariables(content, item) : '') : content;
|
||||
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) {
|
||||
return '';
|
||||
|
||||
@@ -4,7 +4,7 @@ const path = require('path');
|
||||
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
||||
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
||||
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) {
|
||||
if (!assetVersion) {
|
||||
|
||||
@@ -255,6 +255,39 @@
|
||||
.template-preview-card .btn-group {
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2500,6 +2533,13 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
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 {
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
function substituteVariables(html, item) {
|
||||
function substituteVariables(html, item, options) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
@@ -105,7 +105,23 @@
|
||||
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('');
|
||||
}
|
||||
|
||||
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) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
@@ -170,11 +204,8 @@
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||
var item = getItem(sourceId, itemNumber, sources, itemsPath);
|
||||
var body = item ? substituteVariables(content, item) : '';
|
||||
|
||||
if (!body && item) {
|
||||
body = getPreviewFallback(item);
|
||||
}
|
||||
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
|
||||
var body = hasSource ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
|
||||
|
||||
body = String(body || '')
|
||||
.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_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<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>' +
|
||||
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
||||
@@ -290,7 +321,7 @@
|
||||
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,
|
||||
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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var DEFAULT_STYLE = {
|
||||
font_family: 'Arial',
|
||||
font_size: 32,
|
||||
@@ -114,14 +115,32 @@
|
||||
return current === undefined || current === null ? '' : current;
|
||||
}
|
||||
|
||||
function substituteVariables(html, item) {
|
||||
function substituteVariables(html, item, options) {
|
||||
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') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
|
||||
return escapeHtml(resolvePath(item, key || ''));
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||
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 fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||
var item = getItem(feedId, itemNumber, feeds);
|
||||
var body = item ? substituteVariables(content, item) : '';
|
||||
|
||||
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('');
|
||||
}
|
||||
var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
|
||||
var body = hasFeed ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
|
||||
|
||||
if (!body) {
|
||||
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>' : '';
|
||||
}
|
||||
|
||||
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) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
@@ -221,7 +245,7 @@
|
||||
'<span class="chip">RSS</span>' +
|
||||
'</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="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>' +
|
||||
@@ -247,9 +271,8 @@
|
||||
'<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 : '') + '" />' +
|
||||
'<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="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>';
|
||||
@@ -291,7 +314,7 @@
|
||||
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
|
||||
} : current;
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
}
|
||||
|
||||
var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {};
|
||||
var fontFamily = style && style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
||||
var color = style && style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||
var fontFamily = 'font-family:' + escapeHtml(style && style.font_family || DEFAULT_STYLE.font_family) + ';';
|
||||
var color = 'color:' + escapeHtml(style && style.font_color || DEFAULT_STYLE.font_color) + ';';
|
||||
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 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;
|
||||
|
||||
@@ -286,6 +286,19 @@
|
||||
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) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
@@ -317,9 +330,8 @@
|
||||
'<datalist id="region_time_date_timezones_' + region.id + '">' + buildTimezoneOptionsMarkup(timeZone) + '</datalist>' +
|
||||
'</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="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>';
|
||||
|
||||
@@ -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) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
@@ -298,14 +313,8 @@
|
||||
'</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-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="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>' +
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
@@ -349,6 +370,8 @@
|
||||
resolvePath: resolvePath,
|
||||
parsePlaceholderExpression: parsePlaceholderExpression,
|
||||
resolvePlaceholderExpression: resolvePlaceholderExpression,
|
||||
isImagePlaceholderExpression: isImagePlaceholderExpression,
|
||||
getImagePlaceholderConfig: getImagePlaceholderConfig,
|
||||
formatPlaceholderValue: formatPlaceholderValue,
|
||||
collectPlaceholderFieldPaths: collectPlaceholderFieldPaths
|
||||
};
|
||||
|
||||
@@ -44,10 +44,6 @@ export function createSlideFormEditorController(options) {
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
function normalizeEditorMarkup(value) {
|
||||
return String(value === undefined || value === null ? '' : value).trim();
|
||||
}
|
||||
|
||||
function isEmptyRichTextValue(value) {
|
||||
var raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
if (!raw) {
|
||||
@@ -575,7 +571,7 @@ export function createSlideFormEditorController(options) {
|
||||
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) {
|
||||
source.id = 'slide-editor-region-' + regionId;
|
||||
|
||||
@@ -198,9 +198,9 @@ export function createSlideFormPreviewHelpers(options) {
|
||||
return module.renderPreview(region, value, style, scale);
|
||||
}
|
||||
|
||||
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
||||
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||
var fontFamily = 'font-family:' + escapeHtml(style.font_family || 'Arial') + ';';
|
||||
var color = 'color:' + escapeHtml(style.font_color || '#000000') + ';';
|
||||
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 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;
|
||||
|
||||
@@ -123,7 +123,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel) {
|
||||
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel, includeTransformHint) {
|
||||
var fields = Array.isArray(fieldList) ? fieldList : [];
|
||||
var visibleFields = fields.slice(0, 8);
|
||||
var overflowFields = fields.slice(visibleFields.length);
|
||||
@@ -137,7 +137,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).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) {
|
||||
return visibleMarkup + transformHint;
|
||||
@@ -417,7 +417,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
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) {
|
||||
@@ -561,7 +561,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).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'
|
||||
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
|
||||
: region.region_type === 'rss'
|
||||
@@ -602,7 +602,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
}).join(''),
|
||||
placeholderChips: region.region_type === 'rss'
|
||||
? 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'
|
||||
? getApiFieldList(apiConfig.source_id, apiItemsPath)
|
||||
: region.region_type === 'rss'
|
||||
|
||||
@@ -654,7 +654,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var content = typeModule && typeof typeModule.renderPreview === 'function'
|
||||
? typeModule.renderPreview(region, previewContext, previewContext)
|
||||
: 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);
|
||||
}
|
||||
var selected = region.region_type === 'image'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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) {
|
||||
const pool = deps.pool;
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
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 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() {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
@@ -497,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
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(
|
||||
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
||||
@@ -512,7 +542,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
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) {
|
||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||
});
|
||||
@@ -655,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
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(
|
||||
'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]
|
||||
@@ -681,7 +724,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
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', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
@@ -813,8 +856,17 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
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]);
|
||||
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', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Admin manage routes for screens and commands.
|
||||
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
|
||||
module.exports = function registerManageRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
@@ -248,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = 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]);
|
||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||
@@ -264,7 +275,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
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, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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) {
|
||||
const pool = deps.pool;
|
||||
@@ -356,7 +357,20 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||
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', {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Admin RBAC route registration and permission management.
|
||||
const { DEFAULT_ROLE } = require('#src/rbac');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
|
||||
module.exports = function registerRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -413,10 +414,14 @@
|
||||
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 = [];
|
||||
if (shouldSyncUsers) {
|
||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||
existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||
const visibleUserIdSet = new Set(visibleUserIds);
|
||||
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
|
||||
return !visibleUserIdSet.has(userId);
|
||||
@@ -452,6 +457,17 @@
|
||||
connection.release();
|
||||
}
|
||||
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, {
|
||||
category: 'roles',
|
||||
eventType: 'role.updated',
|
||||
@@ -459,7 +475,7 @@
|
||||
targetType: 'role',
|
||||
targetId: roleId,
|
||||
targetLabel: name,
|
||||
details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers }
|
||||
details: { changes: changes }
|
||||
});
|
||||
}
|
||||
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.'));
|
||||
}
|
||||
const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
@@ -514,7 +531,7 @@
|
||||
targetType: 'role',
|
||||
targetId: roleId,
|
||||
targetLabel: role.name,
|
||||
details: { permissionKeys: normalizedPermissionKeys }
|
||||
details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
@@ -541,6 +558,7 @@
|
||||
? [].concat(req.body.user_ids)
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
@@ -561,7 +579,7 @@
|
||||
targetType: 'role',
|
||||
targetId: roleId,
|
||||
targetLabel: role.name,
|
||||
details: { userIds: normalizedUserIds }
|
||||
details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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) {
|
||||
const pool = deps.pool;
|
||||
@@ -319,6 +320,8 @@
|
||||
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);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
@@ -328,7 +331,7 @@
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: String(userId),
|
||||
details: { roleIds: roleCheck.roleIds }
|
||||
details: { changes: changes }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
@@ -404,6 +407,19 @@
|
||||
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();
|
||||
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) {
|
||||
@@ -432,7 +448,7 @@
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: username,
|
||||
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked }
|
||||
details: { changes: changes }
|
||||
});
|
||||
if (Boolean(user.account_locked) !== accountLocked) {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Authentication route registration for the web app.
|
||||
|
||||
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) {
|
||||
const pool = deps.pool;
|
||||
|
||||
@@ -5,7 +5,7 @@ const renderApiSourcesPage = require('./list');
|
||||
const renderApiSourceAddPage = require('./add');
|
||||
const renderApiSourceEditPage = require('./edit');
|
||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
|
||||
@@ -5,7 +5,7 @@ const renderRssFeedsPage = require('./list');
|
||||
const renderRssFeedAddPage = require('./add');
|
||||
const renderRssFeedEditPage = require('./edit');
|
||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
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.
|
||||
|
||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
||||
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||
|
||||
const COMMON_TIME_ZONES = [
|
||||
'UTC',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Timetable group list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
||||
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||
|
||||
function formatDateInTimeZone(value, timeZone) {
|
||||
if (!value) {
|
||||
|
||||
@@ -6,6 +6,10 @@ const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery
|
||||
|
||||
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) {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
@@ -44,11 +48,109 @@ function csvCell(value) {
|
||||
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) {
|
||||
let details = '';
|
||||
let parsedDetails = null;
|
||||
if (row.details_json) {
|
||||
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) {
|
||||
details = String(row.details_json);
|
||||
}
|
||||
@@ -60,7 +162,8 @@ function mapAuditRow(row, formatDashboardDate) {
|
||||
actorLabel: row.actor_name || row.actor_username || 'System',
|
||||
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
||||
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',
|
||||
currentUser: req.currentUser,
|
||||
events: events,
|
||||
categories: AUDIT_CATEGORY_KEYS,
|
||||
categories: SORTED_AUDIT_CATEGORY_KEYS,
|
||||
eventTypes: eventTypes,
|
||||
selectedCategory: category,
|
||||
selectedEventType: eventType,
|
||||
|
||||
@@ -8,7 +8,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
|
||||
const { fetchAppSettings } = require('../../../../data/app-settings');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
|
||||
@@ -44,7 +44,22 @@
|
||||
<td>{{actorLabel}}</td>
|
||||
<td>{{targetLabelDisplay}}</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>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerAccountRoutes = require('../src/web/routes/admin/account');
|
||||
const { validatePasswordStrength } = require('../src/auth');
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||
|
||||
test('user create route allows users without roles', async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||
|
||||
test('user edit save and new goes to the blank create page', async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||
const { validatePasswordStrength } = require('../src/auth');
|
||||
|
||||
|
||||
@@ -24,7 +24,26 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
||||
category: 'authentication',
|
||||
event_type: 'login.success',
|
||||
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,
|
||||
@@ -60,10 +79,23 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
||||
throw error;
|
||||
});
|
||||
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, /data-local-datetime/);
|
||||
assert.match(response.body, /audit-event-type-options/);
|
||||
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.match(response.body, />Export</);
|
||||
assert.ok(handlers['/settings/audit-log/export']);
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { recordRequestAuditEvent } = require('../src/data/audit-log');
|
||||
const { buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
|
||||
|
||||
function createPool(settings) {
|
||||
const inserts = [];
|
||||
@@ -44,4 +44,19 @@ test('audit writer omits request metadata when disabled', async () => {
|
||||
assert.equal(pool.inserts.length, 1);
|
||||
assert.equal(pool.inserts[0][6], 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] }
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
|
||||
|
||||
function createAppAndHandlers() {
|
||||
|
||||
@@ -23,6 +23,11 @@ test('slide editor enables server-backed image uploads', () => {
|
||||
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', () => {
|
||||
assert.ok(slideFormEditorSource.includes("table_default_attributes: {"));
|
||||
assert.ok(slideFormEditorSource.includes("cellpadding: '0'"));
|
||||
|
||||
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const {
|
||||
buildDuplicateTimetableGroupName,
|
||||
buildDuplicateTimetableGroup,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||
|
||||
test('user duplicate route pre-fills the add form', async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
||||
|
||||
test('screen update keeps the existing slug on edit', async () => {
|
||||
|
||||
Reference in New Issue
Block a user