Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ec276317 | ||
|
|
30f5ed11b8 | ||
|
|
d6a8b45357 | ||
|
|
f9425fc640 | ||
|
|
4491c15215 | ||
|
|
08b06941a4 | ||
|
|
c0c05f76e3 | ||
|
|
78a34e4105 | ||
|
|
3c3864e6ac | ||
|
|
6d8bf0f5f0 |
@@ -3,6 +3,7 @@
|
||||
## Versioning and releases
|
||||
|
||||
- Treat `package.json` as the source of truth for the application version.
|
||||
- Keep `package.json`, `build/package.player.json`, and `build/package.web.json` on the same version number.
|
||||
- When the app version changes, update `CHANGELOG.md` in the same change.
|
||||
- Keep database migration versions aligned with the release they actually belong to.
|
||||
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
|
||||
|
||||
@@ -2,6 +2,60 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.7.0 - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- The WYSIWYG editor now supports adding small images.
|
||||
|
||||
### Changed
|
||||
|
||||
- The WYSIWYG image insertion flow also received a small code cleanup to simplify the related helper logic.
|
||||
- The default table formatting has been applied.
|
||||
- Timetable regions now use the renamed helpers end to end in the editor and player, including timezone-aware rendering for timetable entry placeholders.
|
||||
|
||||
## 2.6.27 - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scheduled task intervals now display the most appropriate exact unit, such as seconds, minutes, hours, or days, while keeping the sort order numeric.
|
||||
|
||||
## 2.6.26 - 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- API sources and RSS feeds now accept hours as an update interval unit, and the list and background task scheduling paths now format and convert that unit correctly.
|
||||
|
||||
## 2.6.25 - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Screen group edit now keeps the slug locked after creation, so existing screen group URLs remain stable.
|
||||
|
||||
## 2.6.24 - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deferred playlist updates now keep the current slide index when the next playlist snapshot is applied, so playback no longer jumps back to the first slide mid-cycle.
|
||||
|
||||
## 2.6.23 - 2026-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dashboard quick-action and kiosk-launcher cards now use row spacing instead of extra card padding, so the layout stays consistent at large widths.
|
||||
- API and RSS refresh jobs now notify affected player screens only when the refreshed data actually changes, so unchanged polls no longer trigger redundant player refreshes.
|
||||
|
||||
## 2.6.22 - 2026-08-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Startup now logs the previously detected schema version, the current app version, and whether pending migrations exist.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Direct-to-URL player sessions now generate and persist a stable onboarding device id in session storage, so connected screens can still be moved and renamed independently without going through the onboarding flow first.
|
||||
- The connected-clients move action now tolerates rows that only have a live connection id, which keeps move operations working for screens that skipped onboarding.
|
||||
|
||||
## 2.6.21 - 2026-08-09
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.6.21",
|
||||
"version": "2.7.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"main": "src/common.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.6.21",
|
||||
"version": "2.7.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"main": "src/common.js",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.6.21",
|
||||
"version": "2.7.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
@@ -11,7 +11,10 @@ const ITEMS_PATH_MAX_LENGTH = 255;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
|
||||
return unit;
|
||||
}
|
||||
return 'minutes';
|
||||
}
|
||||
|
||||
function normalizeAuthMethod(value) {
|
||||
|
||||
@@ -9,7 +9,10 @@ const URL_MAX_LENGTH = 1024;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
|
||||
return unit;
|
||||
}
|
||||
return 'minutes';
|
||||
}
|
||||
|
||||
async function fetchRssFeedsData(pool) {
|
||||
|
||||
+69
-6
@@ -6,27 +6,90 @@ const { parseJsonSafe, validateMaxLength } = require('./utils');
|
||||
const TITLE_MAX_LENGTH = 255;
|
||||
const { buildQrCodeContent } = require('./qr-code');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
const DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
const allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
if (!allowed.includes(lowerKey)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
const targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + String(value || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, (tag) => {
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const closing = Boolean(match[1]);
|
||||
const name = String(match[2] || '').toLowerCase();
|
||||
const attrText = String(match[3] || '');
|
||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||
return '';
|
||||
}
|
||||
if (name === 'br') {
|
||||
return '<br>';
|
||||
if (closing) {
|
||||
return `</${name}>`;
|
||||
}
|
||||
return closing ? `</${name}>` : `<${name}>`;
|
||||
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,7 +493,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
||||
const style = getTextRegionStyle(body, region, existingContent);
|
||||
content[region.region_key] = {
|
||||
type: 'text',
|
||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))),
|
||||
value: sanitizeRichText(stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || '')))),
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
const { version: appVersion } = require('#root/package.json');
|
||||
const { compareVersions, detectSchemaVersion, recordSchemaVersion, runMigrations } = require('./migrations');
|
||||
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
|
||||
|
||||
// Snapshot only: keep this file aligned with the current schema state.
|
||||
async function ensureSchema(pool, options) {
|
||||
@@ -16,7 +16,8 @@ async function ensureSchema(pool, options) {
|
||||
|
||||
try {
|
||||
const currentVersion = await detectSchemaVersion(pool);
|
||||
const updateRequired = compareVersions(currentVersion, appVersion) < 0;
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: currentVersion });
|
||||
const updateRequired = pendingMigrations.length > 0;
|
||||
|
||||
console.info('[schema] previous=' + currentVersion + ' current=' + appVersion + ' update=' + (updateRequired ? 'yes' : 'no'));
|
||||
|
||||
|
||||
+13
-6
@@ -806,8 +806,7 @@ function compareVersions(leftVersion, rightVersion) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runMigrations(pool, options) {
|
||||
// Only run migrations that are newer than the installed schema version and not beyond the app version.
|
||||
async function getPendingMigrations(pool, options) {
|
||||
const targetVersion = String(appVersion || '0.0.0').trim();
|
||||
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
|
||||
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
|
||||
@@ -828,15 +827,23 @@ async function runMigrations(pool, options) {
|
||||
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
|
||||
}
|
||||
|
||||
for (const migration of VERSIONED_MIGRATIONS) {
|
||||
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
|
||||
await migration.run(pool);
|
||||
}
|
||||
return VERSIONED_MIGRATIONS.filter(function (migration) {
|
||||
return compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function runMigrations(pool, options) {
|
||||
// Only run migrations that are newer than the installed schema version and not beyond the app version.
|
||||
const pendingMigrations = await getPendingMigrations(pool, options);
|
||||
|
||||
for (const migration of pendingMigrations) {
|
||||
await migration.run(pool);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appVersion: appVersion,
|
||||
getPendingMigrations: getPendingMigrations,
|
||||
runMigrations: runMigrations,
|
||||
detectSchemaVersion: detectSchemaVersion,
|
||||
recordSchemaVersion: recordSchemaVersion,
|
||||
|
||||
@@ -23,7 +23,12 @@
|
||||
function getOnboardingDeviceId() {
|
||||
try {
|
||||
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
|
||||
return String(storedDeviceId || '').trim();
|
||||
if (storedDeviceId) {
|
||||
return String(storedDeviceId || '').trim();
|
||||
}
|
||||
var nextDeviceId = window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'device-' + Date.now() + '-' + Math.random().toString(16).slice(2);
|
||||
setSessionStorageItem(onboardingDeviceIdStorageKey, nextDeviceId);
|
||||
return String(nextDeviceId || '').trim();
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css?v=37" />
|
||||
{{{STYLESHEETS}}}
|
||||
</head>
|
||||
<body class="{{BODY_CLASS}}">
|
||||
|
||||
@@ -4,7 +4,7 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ body.thumbnail-preview .player-offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #111;
|
||||
background: #0a0a0a;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -294,15 +294,6 @@ body.screen-blackout #app {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide img,
|
||||
.slide video,
|
||||
.slide iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: absolute;
|
||||
left: 5%;
|
||||
|
||||
@@ -70,6 +70,7 @@ function applyPendingPlaylistUpdate() {
|
||||
if (!pendingPlaylistUpdate) {
|
||||
return false;
|
||||
}
|
||||
var nextIndex = Number(index || 0);
|
||||
slides = pendingPlaylistUpdate.slides;
|
||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||
@@ -80,7 +81,11 @@ function applyPendingPlaylistUpdate() {
|
||||
templateLayoutCache = Object.create(null);
|
||||
templateRenderPlanCache = Object.create(null);
|
||||
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
index = 0;
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
if (!Number.isFinite(nextIndex) || nextIndex < 0) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
index = activeSlides.length ? Math.min(nextIndex, activeSlides.length - 1) : 0;
|
||||
logDebug('Applied updated playlist on slide transition.');
|
||||
return true;
|
||||
}
|
||||
@@ -184,6 +189,15 @@ function refresh() {
|
||||
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||
if (window.initialData && typeof window.initialData === 'object') {
|
||||
window.initialData.screen = data.screen || window.initialData.screen || null;
|
||||
window.initialData.playlist = data.playlist || null;
|
||||
window.initialData.slides = nextSlides;
|
||||
window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : [];
|
||||
window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : [];
|
||||
window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : [];
|
||||
window.initialData.revision = nextSignature;
|
||||
}
|
||||
savePlaylistSnapshot({
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
|
||||
@@ -334,7 +334,7 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
@@ -349,14 +349,19 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
tbody: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
thead: ['class', 'style'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
@@ -365,6 +370,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Time/date region rendering and live updates.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
|
||||
var DEFAULT_STYLE = {
|
||||
font_family: 'Arial',
|
||||
@@ -9,15 +10,6 @@ var DEFAULT_STYLE = {
|
||||
};
|
||||
var timeDateFormatterCache = Object.create(null);
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeTagAttributes(tagName, attrText) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
@@ -133,7 +125,7 @@ function resolveTimeZone(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function getFormatter(key, options) {
|
||||
function getTimeDateFormatter(key, options) {
|
||||
if (!timeDateFormatterCache[key]) {
|
||||
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
|
||||
}
|
||||
@@ -141,10 +133,10 @@ function getFormatter(key, options) {
|
||||
return timeDateFormatterCache[key];
|
||||
}
|
||||
|
||||
function getFormattedParts(timeZone, date) {
|
||||
function getTimeDateFormattedParts(timeZone, date) {
|
||||
var targetDate = date instanceof Date ? date : new Date();
|
||||
var resolvedTimeZone = resolveTimeZone(timeZone);
|
||||
var numericParts = getFormatter('numeric:' + resolvedTimeZone, {
|
||||
var numericParts = getTimeDateFormatter('numeric:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
@@ -154,29 +146,29 @@ function getFormattedParts(timeZone, date) {
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).formatToParts(targetDate);
|
||||
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, {
|
||||
var weekdayLong = getTimeDateFormatter('weekday-long:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
weekday: 'long'
|
||||
}).formatToParts(targetDate);
|
||||
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, {
|
||||
var weekdayShort = getTimeDateFormatter('weekday-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
weekday: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
var monthLong = getFormatter('month-long:' + resolvedTimeZone, {
|
||||
var monthLong = getTimeDateFormatter('month-long:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
month: 'long'
|
||||
}).formatToParts(targetDate);
|
||||
var monthShort = getFormatter('month-short:' + resolvedTimeZone, {
|
||||
var monthShort = getTimeDateFormatter('month-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
month: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
var ampm = getFormatter('ampm:' + resolvedTimeZone, {
|
||||
var ampm = getTimeDateFormatter('ampm:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
hour12: true,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).formatToParts(targetDate);
|
||||
var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, {
|
||||
var timezoneShort = getTimeDateFormatter('tz-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
timeZoneName: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
@@ -220,20 +212,21 @@ function getFormattedParts(timeZone, date) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTimeDatePlaceholder(values, expression) {
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
|
||||
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
|
||||
function resolveTimeDateTemplatePlaceholder(values, expression) {
|
||||
var currentPlaceholderUtils = window.placeholderUtils || placeholderUtils || {};
|
||||
if (typeof currentPlaceholderUtils.resolvePlaceholderExpression === 'function' && typeof currentPlaceholderUtils.formatPlaceholderValue === 'function') {
|
||||
return currentPlaceholderUtils.formatPlaceholderValue(currentPlaceholderUtils.resolvePlaceholderExpression(values, expression));
|
||||
}
|
||||
|
||||
var parsed = String(expression || '').trim();
|
||||
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
|
||||
}
|
||||
|
||||
function renderTemplate(format, timeZone, date) {
|
||||
function renderTimeDateTemplate(format, timeZone, date) {
|
||||
var template = String(format || '').trim() || DEFAULT_FORMAT;
|
||||
var values = getFormattedParts(timeZone, date);
|
||||
var values = getTimeDateFormattedParts(timeZone, date);
|
||||
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
|
||||
return String(resolveTimeDatePlaceholder(values, key, { timeZone: timeZone }) || '');
|
||||
return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || '');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,7 +250,7 @@ function renderTimeDateRegion(region, regionContent) {
|
||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||
var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
|
||||
var renderedText = renderTemplate(format, timeZone, new Date());
|
||||
var renderedText = renderTimeDateTemplate(format, timeZone, new Date());
|
||||
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
|
||||
}
|
||||
|
||||
@@ -273,7 +266,7 @@ function updateTimeDateRegion(element) {
|
||||
return;
|
||||
}
|
||||
|
||||
scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date()));
|
||||
scaleWrapper.innerHTML = renderEditorJsContent(renderTimeDateTemplate(format, timeZone, new Date()));
|
||||
}
|
||||
|
||||
function scheduleTimeDateRegionUpdate(element) {
|
||||
|
||||
@@ -2,101 +2,6 @@
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
tbody: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
thead: ['class', 'style'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
var allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
||||
var lowerKey = String(key || '').toLowerCase();
|
||||
if (allowed.indexOf(lowerKey) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
var targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function substituteTimetableVariables(html, entry) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
@@ -41,7 +41,7 @@ function sanitizeTextColor(value, fallback) {
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
const allowedAttributes = {
|
||||
@@ -56,6 +56,9 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
@@ -72,6 +75,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||
const lowerKey = String(key || '').toLowerCase();
|
||||
|
||||
@@ -207,6 +207,7 @@ async function start() {
|
||||
mediaDir: webConfig.mediaDir,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
webBootstrap: webBootstrap,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
|
||||
@@ -19,6 +19,9 @@ function normalizeIntervalMs(value, unit) {
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
if (normalizedUnit === 'hours') {
|
||||
return numericValue * 60 * 60 * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function registerDataSourceRefreshTask(options) {
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null);
|
||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
@@ -32,7 +32,7 @@ function registerDataSourceRefreshTask(options) {
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
|
||||
@@ -34,7 +34,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -115,11 +115,11 @@ function createDataSourceTaskService(options) {
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, actorId) {
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId);
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId);
|
||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -33,13 +33,13 @@ function scheduleStartupDataSourceRefreshes(options) {
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
|
||||
}));
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,112 @@
|
||||
async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
||||
async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
|
||||
const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const slideIds = [];
|
||||
const seenSlideIds = new Set();
|
||||
|
||||
slideRows.forEach(function (row) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const stack = [content];
|
||||
while (stack.length) {
|
||||
const value = stack.pop();
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(function (item) {
|
||||
stack.push(item);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, slideMatchKey) && Number(value[slideMatchKey]) === Number(sourceId)) {
|
||||
const slideId = Number(row.id);
|
||||
if (Number.isFinite(slideId) && slideId > 0 && !seenSlideIds.has(slideId)) {
|
||||
seenSlideIds.add(slideId);
|
||||
slideIds.push(slideId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
stack.push(value[key]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!slideIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [screenRows] = await connection.query(
|
||||
`SELECT DISTINCT s.slug
|
||||
FROM d_screens s
|
||||
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
WHERE ps.slide_id IN (?)
|
||||
AND s.slug IS NOT NULL`,
|
||||
[slideIds]
|
||||
);
|
||||
|
||||
return screenRows.map(function (row) {
|
||||
return String(row.slug || '').trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
async function notifyAffectedScreens(connection, common, notifyPlayerScreens, slideMatchKey, sourceId) {
|
||||
if (typeof notifyPlayerScreens !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const slugs = await getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId);
|
||||
if (!slugs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await notifyPlayerScreens(slugs, 'refresh');
|
||||
}
|
||||
|
||||
function normalizeSnapshotValue(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
async function hasRssFeedChanged(connection, rssFeedId, items) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT item_json
|
||||
FROM i_rss_feed_items
|
||||
WHERE rss_feed_id = ?
|
||||
ORDER BY position ASC, id ASC`,
|
||||
[rssFeedId]
|
||||
);
|
||||
|
||||
const currentSnapshots = (rows || []).map(function (row) {
|
||||
return normalizeSnapshotValue(row && row.item_json);
|
||||
});
|
||||
const nextSnapshots = (Array.isArray(items) ? items : []).map(function (item) {
|
||||
return normalizeSnapshotValue(JSON.stringify(item || {}));
|
||||
});
|
||||
|
||||
if (currentSnapshots.length !== nextSnapshots.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let index = 0; index < currentSnapshots.length; index += 1) {
|
||||
if (currentSnapshots[index] !== nextSnapshots[index]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasApiSourceChanged(apiSource, responseDetails) {
|
||||
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
|
||||
}
|
||||
|
||||
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
|
||||
@@ -25,6 +133,14 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
if (!pullError && hasApiSourceChanged(apiSource, responseDetails)) {
|
||||
try {
|
||||
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'source_id', apiSource.id);
|
||||
} catch (notifyError) {
|
||||
console.warn('[data-source-refresh] Unable to notify players after API source refresh ' + apiSource.id + ':', notifyError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
@@ -37,7 +153,7 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, notifyPlayerScreens) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
@@ -50,11 +166,20 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
const rssFeedChanged = await hasRssFeedChanged(connection, rssFeedId, updatedItems);
|
||||
if (typeof common.replaceRssFeedItems === 'function') {
|
||||
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.commit();
|
||||
|
||||
if (!pullError && rssFeedChanged) {
|
||||
try {
|
||||
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'feed_id', rssFeedId);
|
||||
} catch (notifyError) {
|
||||
console.warn('[data-source-refresh] Unable to notify players after RSS feed refresh ' + rssFeedId + ':', notifyError);
|
||||
}
|
||||
}
|
||||
|
||||
if (pullError) {
|
||||
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
var raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/<img\b/i.test(raw)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
|
||||
@@ -83,7 +83,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
var raw = String(html || '').trim();
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/<img\b/i.test(raw)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
|
||||
@@ -22,6 +22,7 @@ async function initializeWebServer(options) {
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
notifyPlayerScreens: options && options.notifyPlayerScreens ? options.notifyPlayerScreens : null,
|
||||
uploadSyncService: webBootstrap.uploadSyncService,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
mediaDir: mediaDir,
|
||||
|
||||
@@ -423,19 +423,6 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
|
||||
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
|
||||
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
|
||||
return true;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var responseDocument = null;
|
||||
try {
|
||||
@@ -456,6 +443,19 @@
|
||||
|
||||
clearFormDirty(form);
|
||||
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
|
||||
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
|
||||
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
|
||||
return true;
|
||||
}
|
||||
|
||||
var successMessage = typeof settings.getSuccessMessage === 'function'
|
||||
? settings.getSuccessMessage({
|
||||
form: form,
|
||||
|
||||
@@ -28,11 +28,19 @@
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var attrText = String(match[3] || '');
|
||||
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (name === 'img') {
|
||||
var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
var srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
@@ -42,7 +50,100 @@
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return sanitizePreviewHtml(html);
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var attrText = String(match[3] || '');
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
|
||||
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
var allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||
var srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
var attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
||||
var lowerKey = String(key || '').toLowerCase();
|
||||
if (allowed.indexOf(lowerKey) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
var targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeTagAttributes(tagName, attrText) {
|
||||
@@ -64,6 +165,7 @@
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
@@ -844,6 +946,7 @@
|
||||
escapeHtml: escapeHtml,
|
||||
sanitizePreviewHtml: sanitizePreviewHtml,
|
||||
sanitizeRichText: sanitizeRichText,
|
||||
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
|
||||
sanitizeFontFamily: sanitizeFontFamily,
|
||||
sanitizeTextColor: sanitizeTextColor,
|
||||
normalizeAcceptList: normalizeAcceptList,
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
|
||||
function sanitizePreviewHtml(html) {
|
||||
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
@@ -128,7 +128,7 @@
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<div>' + sanitizeRichText(description) + '</div>');
|
||||
summary.push('<div>' + sanitizePreviewHtml(description) + '</div>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '';
|
||||
@@ -183,7 +183,7 @@
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = sanitizeRichText(body);
|
||||
var renderedBody = sanitizePreviewHtml(body);
|
||||
return renderedBody ? '<div class="template-region api" 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>' : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : sanitizePreviewHtml(html);
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
|
||||
}
|
||||
@@ -198,7 +194,7 @@
|
||||
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
||||
}
|
||||
if (item.description) {
|
||||
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
|
||||
summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
|
||||
}
|
||||
body = summaryParts.join('');
|
||||
}
|
||||
@@ -206,7 +202,7 @@
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = sanitizeRichText(body);
|
||||
var renderedBody = sanitizePreviewHtml(body);
|
||||
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>' : '';
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -15,10 +15,6 @@
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: DEFAULT_STYLE.font_family,
|
||||
@@ -417,7 +413,7 @@
|
||||
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
|
||||
var entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || '');
|
||||
var timezoneValues = getTimezoneValues(group, entryDate);
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + (utils.sanitizeRichText ? utils.sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
|
||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||
tz: timezoneValues.tz,
|
||||
@@ -426,7 +422,16 @@
|
||||
group: group || {},
|
||||
entries: entries,
|
||||
index: index + 1
|
||||
}))) + '</div>';
|
||||
}))) : escapeHtml(renderTemplate(value, Object.assign({}, entry || {}, {
|
||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||
tz: timezoneValues.tz,
|
||||
tz_long: timezoneValues.tz_long,
|
||||
timeZone: timezoneValues.tz_long,
|
||||
group: group || {},
|
||||
entries: entries,
|
||||
index: index + 1
|
||||
})))) + '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ export function createSlideFormEditorController(options) {
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
|
||||
return null;
|
||||
};
|
||||
var imageUploadUrl = String(settings.imageUploadUrl || '/slides/uploads').trim() || '/slides/uploads';
|
||||
var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));
|
||||
var imageUploadLimitLabel = String(settings.imageUploadLimitLabel || '').trim() || Math.max(1, Math.round(imageUploadMaxBytes / (1024 * 1024))) + ' MB';
|
||||
var imageUploadContext = String(settings.imageUploadContext || 'wysiwyg').trim() || 'wysiwyg';
|
||||
var imageUploadAllowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff'];
|
||||
var imageUploadAllowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif', 'image/tiff'];
|
||||
var imageUploadFileTypes = imageUploadAllowedExtensions.join(',');
|
||||
var editorImageUploadPaths = new Set();
|
||||
var committedEditorImageUploadPaths = new Set();
|
||||
var pendingEditorImageUploadCleanupPaths = new Set();
|
||||
var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () {
|
||||
return '#111111';
|
||||
};
|
||||
@@ -29,6 +39,10 @@ export function createSlideFormEditorController(options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/<img\b/i.test(raw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var stripped = raw
|
||||
.replace(/<\s*br\s*\/?>/gi, '')
|
||||
.replace(/<p[^>]*>(?:\s| |<br\s*\/?>)*<\/p>/gi, '')
|
||||
@@ -209,6 +223,191 @@ export function createSlideFormEditorController(options) {
|
||||
: themeAssets.contentCss;
|
||||
}
|
||||
|
||||
function normalizeUploadPath(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function collectEditorImageUploadPaths(html) {
|
||||
var matches = String(html || '').match(/\/media\/uploads\/[^^\s"'<>]+/g);
|
||||
return matches ? Array.from(new Set(matches.map(normalizeUploadPath).filter(Boolean))) : [];
|
||||
}
|
||||
|
||||
function collectCurrentEditorImageUploadPaths() {
|
||||
var currentPaths = new Set();
|
||||
|
||||
editorInstances.forEach(function (editor, regionId) {
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
|
||||
var fallbackContent = hidden && hidden.value !== undefined ? hidden.value : (sourceElm && sourceElm.value !== undefined ? sourceElm.value : '');
|
||||
collectEditorImageUploadPaths(getEditorContentSafely(editor, fallbackContent)).forEach(function (path) {
|
||||
currentPaths.add(path);
|
||||
});
|
||||
});
|
||||
|
||||
return currentPaths;
|
||||
}
|
||||
|
||||
function getImageUploadCleanupPaths() {
|
||||
var currentPaths = collectCurrentEditorImageUploadPaths();
|
||||
return Array.from(editorImageUploadPaths).filter(function (path) {
|
||||
return !currentPaths.has(path);
|
||||
});
|
||||
}
|
||||
|
||||
function getCommittedImageUploadCleanupPaths() {
|
||||
var currentPaths = collectCurrentEditorImageUploadPaths();
|
||||
return Array.from(committedEditorImageUploadPaths).filter(function (path) {
|
||||
return !currentPaths.has(path);
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingImageUploadPaths() {
|
||||
return Array.from(editorImageUploadPaths).filter(function (path) {
|
||||
return !committedEditorImageUploadPaths.has(path);
|
||||
});
|
||||
}
|
||||
|
||||
function queueImageUploadCleanupPaths(paths) {
|
||||
Array.from(new Set((paths || []).map(normalizeUploadPath).filter(Boolean))).forEach(function (path) {
|
||||
pendingEditorImageUploadCleanupPaths.add(path);
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingImageUploadCleanupPaths() {
|
||||
var currentPaths = collectCurrentEditorImageUploadPaths();
|
||||
return Array.from(pendingEditorImageUploadCleanupPaths).filter(function (path) {
|
||||
return !currentPaths.has(path);
|
||||
});
|
||||
}
|
||||
|
||||
function markImageUploadsCommitted() {
|
||||
committedEditorImageUploadPaths = collectCurrentEditorImageUploadPaths();
|
||||
}
|
||||
|
||||
function getAllImageUploadPaths() {
|
||||
return Array.from(editorImageUploadPaths);
|
||||
}
|
||||
|
||||
function clearImageUploadPaths() {
|
||||
editorImageUploadPaths.clear();
|
||||
committedEditorImageUploadPaths.clear();
|
||||
pendingEditorImageUploadCleanupPaths.clear();
|
||||
}
|
||||
|
||||
function getFileExtension(fileName) {
|
||||
var match = String(fileName || '').toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||
return match ? String(match[1] || '') : '';
|
||||
}
|
||||
|
||||
function getImageUploadValidationMessage(blobInfo) {
|
||||
var blob = blobInfo && typeof blobInfo.blob === 'function' ? blobInfo.blob() : null;
|
||||
var fileName = blobInfo && typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || '') : '';
|
||||
var mimeType = blob && blob.type ? String(blob.type || '').trim().toLowerCase() : '';
|
||||
var extension = getFileExtension(fileName);
|
||||
|
||||
if (!blob) {
|
||||
return 'No image file was provided.';
|
||||
}
|
||||
|
||||
if (Number(blob.size || 0) > imageUploadMaxBytes) {
|
||||
return 'Image must be ' + imageUploadLimitLabel + ' or smaller. Larger images should use the dedicated Image region.';
|
||||
}
|
||||
|
||||
if (mimeType && imageUploadAllowedMimeTypes.indexOf(mimeType) === -1) {
|
||||
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
|
||||
}
|
||||
|
||||
if (!mimeType && extension && imageUploadAllowedExtensions.indexOf(extension) === -1) {
|
||||
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
|
||||
}
|
||||
|
||||
if (!mimeType && !extension) {
|
||||
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function uploadEditorImage(blobInfo, progress) {
|
||||
var validationError = getImageUploadValidationMessage(blobInfo);
|
||||
if (validationError) {
|
||||
return Promise.reject(new Error(validationError));
|
||||
}
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
var formData = new FormData();
|
||||
var blob = blobInfo.blob();
|
||||
var fileName = typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || 'image') : 'image';
|
||||
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
xhr.open('POST', imageUploadUrl, true);
|
||||
xhr.responseType = 'text';
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
xhr.setRequestHeader('X-Upload-Context', imageUploadContext);
|
||||
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (!progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.lengthComputable || !event.total) {
|
||||
progress(0);
|
||||
return;
|
||||
}
|
||||
|
||||
progress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
var responseText = String(xhr.responseText || '');
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(responseText || 'Unable to upload image.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseText.trim().toLowerCase().indexOf('<!doctype html') === 0 || responseText.toLowerCase().indexOf('<html') !== -1) {
|
||||
reject(new Error('Upload redirected to an HTML page. Please sign in again and retry.'));
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = {};
|
||||
try {
|
||||
payload = JSON.parse(responseText || '{}') || {};
|
||||
} catch (_error) {
|
||||
reject(new Error('Unable to parse the upload response.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.path) {
|
||||
reject(new Error('Unable to upload image.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
progress(100);
|
||||
}
|
||||
|
||||
editorImageUploadPaths.add(String(payload.path || '').trim());
|
||||
|
||||
resolve(String(payload.path || ''));
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
reject(new Error('Unable to upload image.'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = function () {
|
||||
reject(new Error('Upload timed out. Please try again.'));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function attachEditorEvents(regionId, editor) {
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var source = editor && editor.targetElm ? editor.targetElm : null;
|
||||
@@ -305,16 +504,27 @@ export function createSlideFormEditorController(options) {
|
||||
menubar: false,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
relative_urls: false,
|
||||
remove_script_host: false,
|
||||
convert_urls: true,
|
||||
paste_data_images: false,
|
||||
plugins: 'lists code advlist fullscreen table',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
||||
automatic_uploads: true,
|
||||
images_file_types: imageUploadFileTypes,
|
||||
images_upload_handler: uploadEditorImage,
|
||||
plugins: 'lists code advlist fullscreen table image',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table image chip | fullscreen',
|
||||
toolbar_mode: 'sliding',
|
||||
license_key: 'gpl',
|
||||
table_default_attributes: {
|
||||
border: '1',
|
||||
cellpadding: '0',
|
||||
cellspacing: '0'
|
||||
},
|
||||
skin: themeAssets.skinName,
|
||||
skin_url: themeAssets.skinUrl,
|
||||
content_css: getContentCss(),
|
||||
body_class: themeAssets.bodyClass,
|
||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; border-spacing: 0; width: 100%; } td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
font_family_formats: getFontFamilyFormats(),
|
||||
font_size_input_default_unit: 'px',
|
||||
invalid_elements: 'a',
|
||||
@@ -394,6 +604,7 @@ export function createSlideFormEditorController(options) {
|
||||
if (editor.targetElm) {
|
||||
editor.targetElm.value = editor.getContent({ format: 'html' });
|
||||
}
|
||||
markImageUploadsCommitted();
|
||||
return editor;
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to initialize TinyMCE.', error);
|
||||
@@ -431,7 +642,10 @@ export function createSlideFormEditorController(options) {
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return Promise.all(saves);
|
||||
return Promise.all(saves).then(function (results) {
|
||||
getCommittedImageUploadCleanupPaths();
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
function destroyEditors() {
|
||||
@@ -469,6 +683,14 @@ export function createSlideFormEditorController(options) {
|
||||
return {
|
||||
renderEditors: renderEditors,
|
||||
syncEditors: syncEditors,
|
||||
getImageUploadCleanupPaths: getImageUploadCleanupPaths,
|
||||
getCommittedImageUploadCleanupPaths: getCommittedImageUploadCleanupPaths,
|
||||
getPendingImageUploadPaths: getPendingImageUploadPaths,
|
||||
getPendingImageUploadCleanupPaths: getPendingImageUploadCleanupPaths,
|
||||
getAllImageUploadPaths: getAllImageUploadPaths,
|
||||
queueImageUploadCleanupPaths: queueImageUploadCleanupPaths,
|
||||
markImageUploadsCommitted: markImageUploadsCommitted,
|
||||
clearImageUploadPaths: clearImageUploadPaths,
|
||||
destroyEditors: function () {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
|
||||
@@ -41,6 +41,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var uploadMaxLabel = '100 MB';
|
||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||
var uploadVideoMaxLabel = '1 GB';
|
||||
var wysiwygImageUploadMaxBytes = 2 * 1024 * 1024;
|
||||
var wysiwygImageUploadLimitLabel = '2 MB';
|
||||
var videoDurationCache = Object.create(null);
|
||||
var previewRenderFrame = 0;
|
||||
var previewPopupWindow = null;
|
||||
@@ -106,6 +108,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamilyFormats: slideEditorData.fontFamilyFormats || '',
|
||||
fontStylesheetHref: fontStylesheetHref,
|
||||
imageUploadMaxBytes: wysiwygImageUploadMaxBytes,
|
||||
imageUploadLimitLabel: wysiwygImageUploadLimitLabel,
|
||||
imageUploadContext: 'wysiwyg',
|
||||
getRegionTypeModule: getRegionTypeModule,
|
||||
getEditorBackgroundColor: function () {
|
||||
var template = getTemplateById(templateSelect.value);
|
||||
@@ -923,6 +928,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
if (regionMediaController) {
|
||||
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
|
||||
}
|
||||
if (slideFormEditorController && typeof slideFormEditorController.getCommittedImageUploadCleanupPaths === 'function' && regionMediaController) {
|
||||
regionMediaController.queueUploadCleanup(slideFormEditorController.getCommittedImageUploadCleanupPaths());
|
||||
}
|
||||
if (slideFormEditorController && typeof slideFormEditorController.getImageUploadCleanupPaths === 'function' && regionMediaController) {
|
||||
regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths());
|
||||
}
|
||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||
previewPopupWindow.close();
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (command === 'setclientname') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
@@ -344,7 +344,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (command === 'moveclient') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
@@ -104,9 +105,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
function getUploadedFileMediaType(file) {
|
||||
function getUploadedFileMediaType(file, uploadContext) {
|
||||
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
||||
const isWysiwyg = String(uploadContext || '').trim().toLowerCase() === 'wysiwyg';
|
||||
|
||||
if (isWysiwyg && mimeType.indexOf('image/') !== 0 && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
||||
return 'video';
|
||||
}
|
||||
@@ -116,12 +123,20 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getUploadedFileLimitBytes(file) {
|
||||
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||
function getUploadedFileLimitBytes(file, uploadContext) {
|
||||
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
|
||||
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
|
||||
function getUploadedFileLimitLabel(file) {
|
||||
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
|
||||
function getUploadedFileLimitLabel(file, uploadContext) {
|
||||
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||
return '10 MB';
|
||||
}
|
||||
|
||||
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
|
||||
}
|
||||
|
||||
async function removeUploadedFile(file) {
|
||||
@@ -137,11 +152,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUploadedFiles(files) {
|
||||
async function validateUploadedFiles(files, uploadContext) {
|
||||
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const file = list[i];
|
||||
const mediaType = getUploadedFileMediaType(file);
|
||||
const mediaType = getUploadedFileMediaType(file, uploadContext);
|
||||
if (!mediaType) {
|
||||
await removeUploadedFile(file);
|
||||
const error = new Error('Unsupported upload type.');
|
||||
@@ -150,9 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
|
||||
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
|
||||
await removeUploadedFile(file);
|
||||
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.');
|
||||
const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
|
||||
? 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'
|
||||
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext) + ' or smaller.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
throw error;
|
||||
@@ -376,7 +393,9 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
||||
}
|
||||
|
||||
await validateUploadedFiles([req.file]);
|
||||
const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
|
||||
|
||||
await validateUploadedFiles([req.file], uploadContext);
|
||||
|
||||
res.json({
|
||||
path: '/media/uploads/' + req.file.filename,
|
||||
|
||||
@@ -26,10 +26,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function normalizeExplicitPlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
@@ -246,39 +242,24 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
||||
: readLimitedText(req.body.slug || '', 255);
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
const slug = String(screen.slug || '').trim();
|
||||
const previousSlug = String(screen.slug || '').trim();
|
||||
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');
|
||||
}
|
||||
if (previousSlug && previousSlug !== slug) {
|
||||
const previousScreenTargets = await pool.query(
|
||||
`SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN d_players p ON p.device_id = s.player_id
|
||||
WHERE s.slug = ?
|
||||
LIMIT 1`,
|
||||
[previousSlug]
|
||||
);
|
||||
const previousTargetRow = previousScreenTargets[0] && previousScreenTargets[0][0] || null;
|
||||
const previousInternalBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.internal_base_url) || '';
|
||||
const previousPublicBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.public_base_url) || '';
|
||||
if (previousInternalBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||
await forwardPlayerCommandToBaseUrl(previousInternalBaseUrl, previousSlug, {
|
||||
command: 'redirect',
|
||||
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
const previousConnections = await fetchLiveConnectionsForScreen(previousSlug);
|
||||
const redirectPayload = {
|
||||
command: 'redirect',
|
||||
url: `/screen/${encodeURIComponent(slug)}`
|
||||
};
|
||||
if (previousConnections.length) {
|
||||
await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload);
|
||||
} else {
|
||||
await forwardPlayerCommand(previousSlug, {
|
||||
command: 'redirect',
|
||||
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
await forwardPlayerCommand(previousSlug, redirectPayload);
|
||||
}
|
||||
}
|
||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||
|
||||
@@ -13,10 +13,13 @@ function toIsoTimestamp(value) {
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
const value = Math.max(1, Number(interval) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||
}
|
||||
if (normalizedUnit === 'hours') {
|
||||
return value === 1 ? 'Every hour' : `Every ${value} hours`;
|
||||
}
|
||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,9 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
itemsPath: apiSource.items_path || '',
|
||||
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 minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
: apiSource.update_interval_unit === 'hours'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every hour' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} hours`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||
inUse: usageIds.has(Number(apiSource.id))
|
||||
|
||||
@@ -4,10 +4,13 @@ const { renderView } = require('../../../view');
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
const value = Math.max(1, Number(interval) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||
}
|
||||
if (normalizedUnit === 'hours') {
|
||||
return value === 1 ? 'Every hour' : `Every ${value} hours`;
|
||||
}
|
||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ const DATE_FILTER_FIELDS = {
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
if (value % 86400000 === 0) {
|
||||
const days = Math.max(1, value / 86400000);
|
||||
return days === 1 ? 'Every day' : `Every ${days} days`;
|
||||
}
|
||||
if (value % 3600000 === 0) {
|
||||
const hours = Math.max(1, value / 3600000);
|
||||
return hours === 1 ? 'Every hour' : `Every ${hours} hours`;
|
||||
}
|
||||
if (value % 60000 === 0) {
|
||||
const minutes = Math.max(1, value / 60000);
|
||||
return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`;
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
<option value="hours" {{#if (eq apiSource.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
<option value="hours" {{#if (eq rssFeed.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
|
||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-primary dashboard-actions-card pb-4">
|
||||
<div class="col-12 col-xl-8 mb-4">
|
||||
<div class="card card-outline card-primary dashboard-actions-card">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Quick actions</h3>
|
||||
@@ -108,8 +108,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-secondary dashboard-launcher-card h-100 pb-4">
|
||||
<div class="col-12 col-xl-4 mb-4">
|
||||
<div class="card card-outline card-secondary dashboard-launcher-card">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Kiosk launchers</h3>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="screen-slug" class="form-label">Slug</label>
|
||||
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" />
|
||||
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" {{#if isEdit}}disabled aria-disabled="true" title="Slug cannot be changed after creation"{{/if}} />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="screen-playlist" class="form-label">Playlist</label>
|
||||
|
||||
@@ -8,4 +8,11 @@ test('async save errors keep validation failures as warning toasts', () => {
|
||||
assert.ok(adminPageScript.includes('function isWarningSaveError(error)'));
|
||||
assert.ok(adminPageScript.includes('error.status = response.status;'));
|
||||
assert.ok(adminPageScript.includes('var variant = isWarningSaveError(error) ? \'warning\' : \'danger\';'));
|
||||
});
|
||||
|
||||
test('async save runs success hooks before redirecting close or new saves', () => {
|
||||
assert.ok(adminPageScript.includes('var responseText = await response.text();'));
|
||||
assert.ok(adminPageScript.includes('if (typeof settings.afterSuccess === \'function\')'));
|
||||
assert.ok(adminPageScript.includes('clearFormDirty(form);'));
|
||||
assert.ok(adminPageScript.includes("if (submitterValue === 'close' || submitterValue === 'new')"));
|
||||
});
|
||||
@@ -91,4 +91,44 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
||||
assert.match(queueDateSearchHtml, /Example task/);
|
||||
assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
|
||||
assert.match(scheduledHtml, /Player:\s+Player Beta/);
|
||||
});
|
||||
|
||||
test('scheduled task interval labels use the largest exact unit while sorting stays numeric', () => {
|
||||
const scheduledHtml = renderBackgroundTasksScheduledPage(
|
||||
{
|
||||
recurringTasks: [
|
||||
{
|
||||
title: 'Hourly task',
|
||||
key: 'hourly',
|
||||
intervalMs: 7200000,
|
||||
metadata: {}
|
||||
},
|
||||
{
|
||||
title: 'Second task',
|
||||
key: 'seconds',
|
||||
intervalMs: 3000,
|
||||
metadata: {}
|
||||
},
|
||||
{
|
||||
title: 'Minute task',
|
||||
key: 'minutes',
|
||||
intervalMs: 60000,
|
||||
metadata: {}
|
||||
}
|
||||
],
|
||||
sort: 'interval',
|
||||
direction: 'asc',
|
||||
summary: { counts: {}, total: 3, activeCount: 0, scheduledCount: 3 }
|
||||
},
|
||||
'',
|
||||
{
|
||||
permissions: ['scheduled-tasks.allow']
|
||||
}
|
||||
);
|
||||
|
||||
assert.match(scheduledHtml, /Every 3 seconds/);
|
||||
assert.match(scheduledHtml, /Every minute/);
|
||||
assert.match(scheduledHtml, /Every 2 hours/);
|
||||
assert.ok(scheduledHtml.indexOf('Every 3 seconds') < scheduledHtml.indexOf('Every minute'));
|
||||
assert.ok(scheduledHtml.indexOf('Every minute') < scheduledHtml.indexOf('Every 2 hours'));
|
||||
});
|
||||
@@ -292,3 +292,127 @@ test('slide upload cleanup route removes unused uploads', async () => {
|
||||
assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads');
|
||||
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']);
|
||||
});
|
||||
|
||||
test('wysiwyg image uploads are capped below the dedicated image region limit', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
},
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
const deps = {
|
||||
pool: {
|
||||
async query() {
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
fetchTemplatesData: async () => ({}),
|
||||
fetchRssFeedsData: async () => ({ rssFeeds: [] }),
|
||||
fetchApiSourcesData: async () => ({ apiSources: [] }),
|
||||
fetchTimetablesData: async () => ({ timetableGroups: [] }),
|
||||
parseJsonSafe: () => null,
|
||||
fetchRssFeedItemsByFeedId: async () => [],
|
||||
normalizeRssFeedItem: (item) => item,
|
||||
fetchSlidesPage: async () => ({}),
|
||||
fetchSlideById: async () => null,
|
||||
fetchTemplatesPage: async () => ({}),
|
||||
fetchTemplateById: async () => null,
|
||||
fetchCanvasSizesPage: async () => ({}),
|
||||
fetchCanvasSizeById: async () => null,
|
||||
getSearchQuery: () => '',
|
||||
getSortQuery: () => '',
|
||||
getSortDirectionQuery: () => 'asc',
|
||||
fetchDuplicateName: async () => null,
|
||||
buildCanvasSizePayload
|
||||
},
|
||||
pages: {
|
||||
renderCanvasSizesPage() { return ''; },
|
||||
renderCanvasSizeEditPage() { return ''; },
|
||||
renderCanvasSizeAddPage() { return ''; },
|
||||
renderSlideAddPage() { return ''; },
|
||||
renderSlideEditPage() { return ''; },
|
||||
renderTemplatesPage() { return ''; },
|
||||
renderTemplateAddPage() { return ''; },
|
||||
renderTemplateEditPage() { return ''; }
|
||||
},
|
||||
upload: {
|
||||
any() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
},
|
||||
single() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
},
|
||||
setAuthMessageCookie() {},
|
||||
fetchScreensBySlideId: async () => [],
|
||||
fetchScreensByTemplateId: async () => [],
|
||||
collectUploadReferencesFromSlide: () => [],
|
||||
collectUploadReferencesFromTemplate: () => [],
|
||||
collectUploadReferencesFromPayload: () => [],
|
||||
removeUnusedUploadFiles: async () => {},
|
||||
syncPlaylistUploadsOnChange: async () => {},
|
||||
getAuditUserId: () => 1,
|
||||
redirectAfterSave: () => {},
|
||||
notifyPlayerScreens: async () => 0,
|
||||
broadcastDashboardState: async () => {},
|
||||
backgroundTaskQueue: { enqueueTask: async () => null },
|
||||
getSlideDeleteBlockMessage: async () => '',
|
||||
getTemplateDeleteBlockMessage: async () => '',
|
||||
getCanvasSizeDeleteBlockMessage: async () => '',
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
},
|
||||
hasAnyPermission: () => true,
|
||||
uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads'
|
||||
};
|
||||
|
||||
registerContentRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/slides/uploads'];
|
||||
assert.ok(Array.isArray(routeHandlers));
|
||||
|
||||
const req = {
|
||||
file: {
|
||||
filename: 'wysiwyg-large.png',
|
||||
originalname: 'wysiwyg-large.png',
|
||||
mimetype: 'image/png',
|
||||
size: 11 * 1024 * 1024
|
||||
},
|
||||
get(headerName) {
|
||||
return headerName === 'X-Upload-Context' ? 'wysiwyg' : '';
|
||||
},
|
||||
currentUser: { id: 1, permissions: ['slides.create'] }
|
||||
};
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
body: '',
|
||||
json(body) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
let nextError = null;
|
||||
|
||||
await routeHandlers[2](req, res, (error) => {
|
||||
nextError = error || null;
|
||||
});
|
||||
|
||||
assert.ok(nextError);
|
||||
assert.equal(nextError.statusCode, 400);
|
||||
assert.equal(nextError.message, 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { buildApiSourcePayload } = require('../src/data/api-sources');
|
||||
const { buildRssFeedPayload } = require('../src/data/rss-feeds');
|
||||
const { normalizeIntervalMs } = require('../src/web/lib/background-tasks/queue');
|
||||
|
||||
test('data source payloads accept hours as an update interval unit', () => {
|
||||
const apiPayload = buildApiSourcePayload({
|
||||
body: {
|
||||
name: 'API source',
|
||||
api_url: 'https://example.com/api',
|
||||
update_interval_value: '2',
|
||||
update_interval_unit: 'hours'
|
||||
}
|
||||
}, null);
|
||||
|
||||
const rssPayload = buildRssFeedPayload({
|
||||
body: {
|
||||
name: 'RSS feed',
|
||||
feed_url: 'https://example.com/feed.xml',
|
||||
update_interval_value: '3',
|
||||
update_interval_unit: 'hours',
|
||||
item_limit: '5'
|
||||
}
|
||||
}, null);
|
||||
|
||||
assert.equal(apiPayload.updateIntervalUnit, 'hours');
|
||||
assert.equal(apiPayload.updateIntervalValue, 2);
|
||||
assert.equal(rssPayload.updateIntervalUnit, 'hours');
|
||||
assert.equal(rssPayload.updateIntervalValue, 3);
|
||||
});
|
||||
|
||||
test('background task interval conversion supports hours', () => {
|
||||
assert.equal(normalizeIntervalMs(2, 'hours'), 7_200_000);
|
||||
});
|
||||
|
||||
test('data source forms expose hours as an interval option', () => {
|
||||
const apiTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'api-sources', 'form.hbs'), 'utf8');
|
||||
const rssTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8');
|
||||
|
||||
assert.match(apiTemplate, /value="hours"[\s\S]*>Hours<\/option>/);
|
||||
assert.match(rssTemplate, /value="hours"[\s\S]*>Hours<\/option>/);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { refreshApiSource, refreshRssFeed } = require('../src/web/lib/data-source-refresh');
|
||||
|
||||
function createConnection(options) {
|
||||
const state = Object.assign({
|
||||
rssItemSnapshots: [],
|
||||
slideRows: [],
|
||||
screenRows: []
|
||||
}, options || {});
|
||||
const calls = [];
|
||||
|
||||
return {
|
||||
calls,
|
||||
async beginTransaction() {
|
||||
calls.push(['beginTransaction']);
|
||||
},
|
||||
async commit() {
|
||||
calls.push(['commit']);
|
||||
},
|
||||
async rollback() {
|
||||
calls.push(['rollback']);
|
||||
},
|
||||
release() {
|
||||
calls.push(['release']);
|
||||
},
|
||||
async query(sql, params) {
|
||||
calls.push(['query', sql, params]);
|
||||
|
||||
if (sql.includes('SELECT item_json')) {
|
||||
return [state.rssItemSnapshots.map(function (itemJson) {
|
||||
return { item_json: itemJson };
|
||||
})];
|
||||
}
|
||||
|
||||
if (sql.includes('SELECT id, content_json FROM c_slides')) {
|
||||
return [state.slideRows];
|
||||
}
|
||||
|
||||
if (sql.includes('SELECT DISTINCT s.slug')) {
|
||||
return [state.screenRows];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('refreshApiSource notifies players only when the API snapshot changes', async () => {
|
||||
const connection = createConnection({
|
||||
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
|
||||
screenRows: [{ slug: 'screen-a' }]
|
||||
});
|
||||
const notifyCalls = [];
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
async fetchApiSourceResponse() {
|
||||
return {
|
||||
responseJson: JSON.stringify({ value: 'new' }, null, 2),
|
||||
responseStatus: 200,
|
||||
responseContentType: 'application/json'
|
||||
};
|
||||
},
|
||||
parseJsonSafe(value) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
};
|
||||
|
||||
await refreshApiSource(pool, common, {
|
||||
id: 7,
|
||||
last_response_json: JSON.stringify({ value: 'old' }, null, 2)
|
||||
}, 42, async function (slugs, payload) {
|
||||
notifyCalls.push({ slugs, payload });
|
||||
});
|
||||
|
||||
assert.deepEqual(notifyCalls, [{ slugs: ['screen-a'], payload: 'refresh' }]);
|
||||
});
|
||||
|
||||
test('refreshApiSource skips notifications when the API snapshot is unchanged', async () => {
|
||||
const connection = createConnection({
|
||||
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
|
||||
screenRows: [{ slug: 'screen-a' }]
|
||||
});
|
||||
const notifyCalls = [];
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
const snapshot = JSON.stringify({ value: 'same' }, null, 2);
|
||||
const common = {
|
||||
async fetchApiSourceResponse() {
|
||||
return {
|
||||
responseJson: snapshot,
|
||||
responseStatus: 200,
|
||||
responseContentType: 'application/json'
|
||||
};
|
||||
},
|
||||
parseJsonSafe(value) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
};
|
||||
|
||||
await refreshApiSource(pool, common, {
|
||||
id: 7,
|
||||
last_response_json: snapshot
|
||||
}, 42, async function (slugs, payload) {
|
||||
notifyCalls.push({ slugs, payload });
|
||||
});
|
||||
|
||||
assert.deepEqual(notifyCalls, []);
|
||||
});
|
||||
|
||||
test('refreshRssFeed notifies players only when the RSS items change', async () => {
|
||||
const connection = createConnection({
|
||||
rssItemSnapshots: [JSON.stringify({ title: 'Old item' })],
|
||||
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
|
||||
screenRows: [{ slug: 'screen-b' }]
|
||||
});
|
||||
const notifyCalls = [];
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
async fetchRssFeedItems() {
|
||||
return [{ title: 'New item' }];
|
||||
},
|
||||
async replaceRssFeedItems() {
|
||||
return 1;
|
||||
},
|
||||
parseJsonSafe(value) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
};
|
||||
|
||||
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
|
||||
notifyCalls.push({ slugs, payload });
|
||||
});
|
||||
|
||||
assert.deepEqual(notifyCalls, [{ slugs: ['screen-b'], payload: 'refresh' }]);
|
||||
});
|
||||
|
||||
test('refreshRssFeed skips notifications when the RSS items are unchanged', async () => {
|
||||
const snapshot = JSON.stringify({ title: 'Same item' });
|
||||
const connection = createConnection({
|
||||
rssItemSnapshots: [snapshot],
|
||||
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
|
||||
screenRows: [{ slug: 'screen-b' }]
|
||||
});
|
||||
const notifyCalls = [];
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
async fetchRssFeedItems() {
|
||||
return [{ title: 'Same item' }];
|
||||
},
|
||||
async replaceRssFeedItems() {
|
||||
return 1;
|
||||
},
|
||||
parseJsonSafe(value) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
};
|
||||
|
||||
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
|
||||
notifyCalls.push({ slugs, payload });
|
||||
});
|
||||
|
||||
assert.deepEqual(notifyCalls, []);
|
||||
});
|
||||
@@ -39,6 +39,11 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
||||
pausedRemainingMs: null,
|
||||
app: null,
|
||||
currentPlaylistEtag: '',
|
||||
initialData: {
|
||||
rssFeeds: [{ id: 1 }],
|
||||
apiSources: [{ id: 2 }],
|
||||
timetableGroups: [{ id: 3 }]
|
||||
},
|
||||
activeSlidesCacheKey: '',
|
||||
activeSlidesCacheValue: [],
|
||||
renderCacheViewportKey: '',
|
||||
@@ -94,6 +99,9 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
||||
this.responseText = JSON.stringify({
|
||||
signature: 'next-signature',
|
||||
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
||||
rssFeeds: [{ id: 10 }],
|
||||
apiSources: [{ id: 20 }],
|
||||
timetableGroups: [{ id: 30 }],
|
||||
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
||||
});
|
||||
if (typeof this.onreadystatechange === 'function') {
|
||||
@@ -133,6 +141,9 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
||||
assert.equal(calls.showCurrent, 0);
|
||||
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
||||
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
||||
assert.deepEqual(sandbox.initialData.rssFeeds, [{ id: 10 }]);
|
||||
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 20 }]);
|
||||
assert.deepEqual(sandbox.initialData.timetableGroups, [{ id: 30 }]);
|
||||
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
|
||||
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
|
||||
});
|
||||
@@ -279,6 +290,122 @@ test('single-slide playlists re-render the active slide instead of refreshing af
|
||||
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
|
||||
});
|
||||
|
||||
test('deferred playlist updates preserve the current slide index', async () => {
|
||||
const sandbox = {
|
||||
window: null,
|
||||
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
currentPlaylistSignature: 'old-signature',
|
||||
currentPlaylistFadeBetweenSlides: false,
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
slug: 'test2',
|
||||
pendingPlaylistUpdate: null,
|
||||
slides: [
|
||||
{ id: 1, duration_seconds: 12 },
|
||||
{ id: 2, duration_seconds: 12 },
|
||||
{ id: 3, duration_seconds: 12 }
|
||||
],
|
||||
lastRenderedSlide: { id: 2, duration_seconds: 12 },
|
||||
index: 1,
|
||||
timer: null,
|
||||
slideExpiresAt: null,
|
||||
pausedRemainingMs: null,
|
||||
app: null,
|
||||
currentPlaylistEtag: '',
|
||||
activeSlidesCacheKey: '',
|
||||
activeSlidesCacheValue: [],
|
||||
renderCacheViewportKey: '',
|
||||
slideMarkupCache: Object.create(null),
|
||||
templateLayoutCache: Object.create(null),
|
||||
templateRenderPlanCache: Object.create(null),
|
||||
getCurrentActiveSlides() {
|
||||
return sandbox.slides;
|
||||
},
|
||||
getPlaylistRevision(data) {
|
||||
return data.signature;
|
||||
},
|
||||
normalizeSlide(slide) {
|
||||
return slide;
|
||||
},
|
||||
getActiveSlidesFrom(slideList) {
|
||||
return slideList;
|
||||
},
|
||||
savePlaylistSnapshot() {},
|
||||
markRefreshHealthy() {},
|
||||
setOfflineBannerVisible() {},
|
||||
scheduleRefreshRetry() {},
|
||||
syncWebpagePreloads() {},
|
||||
syncRtmpWarmups() {},
|
||||
clearActiveSlidesCache() {},
|
||||
showCurrent() {},
|
||||
sendCommandState() {},
|
||||
logDebug() {}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
const prelude = `
|
||||
var pendingPlaylistUpdate = null;
|
||||
var slides = [
|
||||
{ id: 1, duration_seconds: 12 },
|
||||
{ id: 2, duration_seconds: 12 },
|
||||
{ id: 3, duration_seconds: 12 }
|
||||
];
|
||||
var currentPlaylistSignature = 'old-signature';
|
||||
var currentPlaylistFadeBetweenSlides = false;
|
||||
var currentPlaylistSkipUnavailableRtmp = false;
|
||||
var currentPlaylistEtag = '';
|
||||
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
|
||||
var index = 1;
|
||||
var timer = null;
|
||||
var slideExpiresAt = null;
|
||||
var pausedRemainingMs = null;
|
||||
var app = null;
|
||||
var activeSlidesCacheKey = '';
|
||||
var activeSlidesCacheValue = [];
|
||||
var renderCacheViewportKey = '';
|
||||
var slideMarkupCache = Object.create(null);
|
||||
var templateLayoutCache = Object.create(null);
|
||||
var templateRenderPlanCache = Object.create(null);
|
||||
`;
|
||||
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
|
||||
|
||||
sandbox.pendingPlaylistUpdate = {
|
||||
slides: [
|
||||
{ id: 10, duration_seconds: 12 },
|
||||
{ id: 11, duration_seconds: 12 },
|
||||
{ id: 12, duration_seconds: 12 }
|
||||
],
|
||||
signature: 'next-signature',
|
||||
fadeBetweenSlides: false,
|
||||
skipUnavailableRtmp: false
|
||||
};
|
||||
|
||||
const applied = sandbox.applyPendingPlaylistUpdate();
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
|
||||
assert.equal(sandbox.pendingPlaylistUpdate, null);
|
||||
assert.deepEqual(sandbox.slides, [
|
||||
{ id: 10, duration_seconds: 12 },
|
||||
{ id: 11, duration_seconds: 12 },
|
||||
{ id: 12, duration_seconds: 12 }
|
||||
]);
|
||||
assert.equal(sandbox.index, 1);
|
||||
});
|
||||
|
||||
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
|
||||
const calls = {
|
||||
showCurrent: 0,
|
||||
|
||||
@@ -295,4 +295,45 @@ test('onboarding device ids stay scoped to the tab session', () => {
|
||||
|
||||
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
|
||||
assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-device-id');
|
||||
});
|
||||
|
||||
test('player onboarding device ids are generated when missing', () => {
|
||||
const sessionStorage = (() => {
|
||||
const values = new Map();
|
||||
return {
|
||||
getItem(key) {
|
||||
return values.has(key) ? values.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(String(key), String(value));
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
const sandbox = {
|
||||
window: null,
|
||||
Date,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Object,
|
||||
Math,
|
||||
console,
|
||||
sessionStorage,
|
||||
localStorage: sessionStorage,
|
||||
crypto: {
|
||||
randomUUID() {
|
||||
return 'generated-device-id';
|
||||
}
|
||||
},
|
||||
WebSocket: { OPEN: 1 },
|
||||
sendCommandState() {}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'player-client-name.script.html'), sandbox);
|
||||
|
||||
assert.equal(sandbox.getOnboardingDeviceId(), 'generated-device-id');
|
||||
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'generated-device-id');
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const timetableRegionSource = fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8');
|
||||
|
||||
const {
|
||||
mediaKind,
|
||||
normalizeSlide,
|
||||
@@ -18,11 +21,11 @@ test('mediaKind classifies player media by extension', () => {
|
||||
});
|
||||
|
||||
test('sanitizeRichText strips unsafe content but preserves allowed markup', () => {
|
||||
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><img src="x" onerror="alert(1)"></div>';
|
||||
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><table class="grid"><colgroup><col span="1" style="width:50%"><col span="1" style="width:50%"></colgroup><thead><tr><th scope="col">Name</th><th scope="col">Value</th></tr></thead><tbody><tr><td>Alpha</td><td>Beta</td></tr></tbody></table><img src="/media/uploads/photo.png" alt="Photo" loading="lazy" onerror="alert(1)"></div>';
|
||||
|
||||
assert.equal(
|
||||
sanitizeRichText(html),
|
||||
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span></div>'
|
||||
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span><table class="grid"><colgroup><col span="1" style="width:50%"><col span="1" style="width:50%"></colgroup><thead><tr><th scope="col">Name</th><th scope="col">Value</th></tr></thead><tbody><tr><td>Alpha</td><td>Beta</td></tr></tbody></table><img src="/media/uploads/photo.png" alt="Photo" loading="lazy"></div>'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,4 +81,9 @@ test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () =
|
||||
'<h2><strong>Title</strong></h2><p><a>bad</a><em>ok</em></p><ol style="list-style-type:decimal;padding-left:1.4em;"><li>One</li><li><span>Two</span></li></ol>'
|
||||
);
|
||||
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
|
||||
});
|
||||
|
||||
test('timetable region registers the timetable type', () => {
|
||||
assert.ok(timetableRegionSource.includes("registry.register('timetable'"));
|
||||
assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value"));
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function loadTimeDateModule() {
|
||||
const webUiHelpersScript = fs.readFileSync(require.resolve('../src/web/public/js/web-ui-helpers.js'), 'utf8');
|
||||
const renderingScript = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8');
|
||||
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
|
||||
const timeDateScript = fs.readFileSync(require.resolve('../src/player/regions/time-date.js'), 'utf8');
|
||||
const registry = new Map();
|
||||
const sandbox = {
|
||||
document: {
|
||||
addEventListener() {}
|
||||
},
|
||||
window: {
|
||||
pulsePlayerRegionTypes: {
|
||||
register(type, module) {
|
||||
registry.set(type, module);
|
||||
}
|
||||
},
|
||||
innerWidth: 1280,
|
||||
innerHeight: 720,
|
||||
Intl: Intl,
|
||||
Date: Date,
|
||||
Object: Object,
|
||||
Array: Array,
|
||||
Number: Number,
|
||||
String: String,
|
||||
Boolean: Boolean,
|
||||
Math: Math,
|
||||
JSON: JSON,
|
||||
RegExp: RegExp,
|
||||
console: console
|
||||
}
|
||||
};
|
||||
|
||||
sandbox.window = Object.assign({}, sandbox.window);
|
||||
|
||||
vm.runInNewContext(webUiHelpersScript, sandbox, { filename: 'web-ui-helpers.js' });
|
||||
sandbox.escapeHtml = sandbox.window.escapeHtml;
|
||||
vm.runInNewContext(renderingScript, sandbox, { filename: 'player-page-rendering.js' });
|
||||
vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' });
|
||||
vm.runInNewContext(timeDateScript, sandbox, { filename: 'time-date.js' });
|
||||
|
||||
return registry.get('time-date');
|
||||
}
|
||||
|
||||
test('time/date region renders placeholder tokens on the player side', () => {
|
||||
const module = loadTimeDateModule();
|
||||
const markup = module.renderRegion(
|
||||
{
|
||||
pixelWidth: 320,
|
||||
pixelHeight: 180,
|
||||
canvasScale: 1,
|
||||
baseStyle: 'position:absolute;'
|
||||
},
|
||||
{
|
||||
value: '{{hh}}:{{mm}}',
|
||||
timezone: 'UTC'
|
||||
}
|
||||
);
|
||||
|
||||
assert.match(markup, /<p>\d{2}:\d{2}<\/p>/);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { getPendingMigrations } = require('../src/db/migrations');
|
||||
|
||||
function createPool(responses) {
|
||||
const queries = [];
|
||||
return {
|
||||
queries,
|
||||
async query(sql, params) {
|
||||
queries.push([sql, params]);
|
||||
const text = String(sql);
|
||||
for (const response of responses) {
|
||||
if (response.match(text, params)) {
|
||||
return response.result;
|
||||
}
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('pending migrations are empty when the schema already matches the app version', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql) {
|
||||
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) {
|
||||
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
||||
},
|
||||
result: [[{ table_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) {
|
||||
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
||||
},
|
||||
result: [[{ table_count: 0 }]]
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.21' });
|
||||
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
|
||||
test('pending migrations are reported when an older schema still needs scripts', async () => {
|
||||
const pool = createPool([
|
||||
{
|
||||
match(sql) {
|
||||
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) {
|
||||
return Array.isArray(params) && params[0] === 'i_schedule_groups';
|
||||
},
|
||||
result: [[{ table_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) {
|
||||
return Array.isArray(params) && params[0] === 'i_schedule_entries';
|
||||
},
|
||||
result: [[{ table_count: 1 }]]
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
|
||||
|
||||
assert.ok(pendingMigrations.length > 0);
|
||||
});
|
||||
@@ -3,7 +3,57 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
|
||||
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
|
||||
const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
|
||||
const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
|
||||
|
||||
test('slide editor disables pasted data images in TinyMCE', () => {
|
||||
assert.ok(slideFormEditorSource.includes('paste_data_images: false'));
|
||||
});
|
||||
|
||||
test('slide editor enables server-backed image uploads', () => {
|
||||
assert.ok(slideFormEditorSource.includes("plugins: 'lists code advlist fullscreen table image'"));
|
||||
assert.ok(slideFormEditorSource.includes('automatic_uploads: true'));
|
||||
assert.ok(slideFormEditorSource.includes('images_file_types: imageUploadFileTypes'));
|
||||
assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage'));
|
||||
assert.ok(slideFormEditorSource.includes('table image chip | fullscreen'));
|
||||
assert.ok(slideFormEditorSource.includes('relative_urls: false'));
|
||||
assert.ok(slideFormEditorSource.includes('remove_script_host: false'));
|
||||
});
|
||||
|
||||
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'"));
|
||||
assert.ok(slideFormEditorSource.includes("cellspacing: '0'"));
|
||||
assert.ok(slideFormEditorSource.includes('td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; }'));
|
||||
});
|
||||
|
||||
test('slide editor uses a smaller wysiwyg image limit', () => {
|
||||
assert.ok(slideFormEditorSource.includes('var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));'));
|
||||
assert.ok(slideFormEditorSource.includes('Image must be '));
|
||||
assert.ok(slideFormEditorSource.includes('Larger images should use the dedicated Image region.'));
|
||||
assert.ok(slideFormEditorSource.includes("xhr.setRequestHeader('X-Upload-Context', imageUploadContext);"));
|
||||
});
|
||||
|
||||
test('slide editor tracks uploaded image paths for cleanup', () => {
|
||||
assert.ok(slideFormEditorSource.includes('var editorImageUploadPaths = new Set();'));
|
||||
assert.ok(slideFormEditorSource.includes('getImageUploadCleanupPaths'));
|
||||
assert.ok(slideFormEditorSource.includes('getCommittedImageUploadCleanupPaths'));
|
||||
assert.ok(slideFormEditorSource.includes('getPendingImageUploadPaths'));
|
||||
assert.ok(slideFormEditorSource.includes('clearImageUploadPaths'));
|
||||
});
|
||||
|
||||
test('slide editor keeps image-only rich text from being treated as empty', () => {
|
||||
assert.ok(slideFormEditorSource.includes('/<img\\b/i.test(raw)'));
|
||||
});
|
||||
|
||||
test('slide form queues editor image cleanup on save and close', () => {
|
||||
assert.ok(slideFormSource.includes('getImageUploadCleanupPaths'));
|
||||
assert.ok(slideFormSource.includes('getCommittedImageUploadCleanupPaths'));
|
||||
assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())"));
|
||||
});
|
||||
|
||||
test('slide thumbnail previews treat image-only text as visible content', () => {
|
||||
assert.ok(slideThumbnailPreviewSource.includes('/<img\\b/i.test(raw)'));
|
||||
assert.ok(slideThumbnailsSource.includes('/<img\\b/i.test(raw)'));
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const vm = require('node:vm');
|
||||
function loadScheduleModule(overrides) {
|
||||
const timeDatePlaceholdersScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/time-date-placeholders.js'), 'utf8');
|
||||
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
|
||||
const scriptPath = require.resolve('../src/web/public/js/regions/type/schedule.js');
|
||||
const scriptPath = require.resolve('../src/web/public/js/regions/type/timetable.js');
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
const registry = new Map();
|
||||
const customWindow = overrides && overrides.window ? overrides.window : {};
|
||||
@@ -161,8 +161,8 @@ test('timetable region preview resolves timezone placeholders from explicit time
|
||||
id: 101,
|
||||
title: 'Launch',
|
||||
short_description: 'Doors open',
|
||||
start_datetime: '2026-08-10T10:00:00.000Z',
|
||||
end_datetime: '2026-08-10T11:00:00.000Z'
|
||||
start_datetime: '2026-08-20T10:00:00.000Z',
|
||||
end_datetime: '2026-08-20T11:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -281,8 +281,8 @@ test('timetable preview does not force the group timezone into placeholder trans
|
||||
id: 101,
|
||||
title: 'Launch',
|
||||
short_description: 'Doors open',
|
||||
start_datetime: '2026-08-10T10:00:00.000Z',
|
||||
end_datetime: '2026-08-10T11:00:00.000Z'
|
||||
start_datetime: '2026-08-20T10:00:00.000Z',
|
||||
end_datetime: '2026-08-20T11:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,4 +39,62 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
|
||||
assert.equal(content.timetable.max_items, '7');
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
|
||||
});
|
||||
|
||||
test('buildSlidePayload preserves safe image markup in text regions', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM c_templates st')) {
|
||||
return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM c_template_regions')) {
|
||||
return [[{ id: 47, template_id: 9, region_key: 'body', region_type: 'text', label: 'Body' }]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
|
||||
const payload = await buildSlidePayload(pool, {
|
||||
body: {
|
||||
title: 'Text slide',
|
||||
template_id: '9',
|
||||
region_text_47: '<p><img src="/media/uploads/photo.png" alt="Photo" onerror="alert(1)"></p>'
|
||||
},
|
||||
files: []
|
||||
}, null);
|
||||
|
||||
const content = JSON.parse(payload.contentJson);
|
||||
assert.equal(content.body.type, 'text');
|
||||
assert.equal(content.body.value, '<p><img src="/media/uploads/photo.png" alt="Photo"></p>');
|
||||
});
|
||||
|
||||
test('buildSlidePayload preserves safe color spans in text regions', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM c_templates st')) {
|
||||
return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM c_template_regions')) {
|
||||
return [[{ id: 47, template_id: 9, region_key: 'body', region_type: 'text', label: 'Body' }]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
|
||||
const payload = await buildSlidePayload(pool, {
|
||||
body: {
|
||||
title: 'Text slide',
|
||||
template_id: '9',
|
||||
region_text_47: '<p><span style="color:#ff0000" class="text-emphasis">Hello</span></p>'
|
||||
},
|
||||
files: []
|
||||
}, null);
|
||||
|
||||
const content = JSON.parse(payload.contentJson);
|
||||
assert.equal(content.body.type, 'text');
|
||||
assert.equal(content.body.value, '<p><span style="color:#ff0000" class="text-emphasis">Hello</span></p>');
|
||||
});
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
||||
|
||||
test('screen update redirects and forwards redirect when the slug changes', async () => {
|
||||
test('screen update keeps the existing slug on edit', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
post(path, ...routeHandlers) {
|
||||
@@ -14,37 +14,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
||||
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url') && sql.includes('WHERE s.slug = ?')) {
|
||||
return [[{
|
||||
slug: 'alpha',
|
||||
player_id: 'player-a',
|
||||
public_base_url: 'http://player.local',
|
||||
internal_base_url: 'http://player.internal'
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('SELECT id, name, slug, playlist_id')) {
|
||||
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
|
||||
}
|
||||
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
|
||||
return [[
|
||||
{
|
||||
slug: 'alpha',
|
||||
player_id: 'player-a',
|
||||
public_base_url: 'http://player-a.example',
|
||||
internal_base_url: 'http://player-a.internal'
|
||||
},
|
||||
{
|
||||
slug: 'beta',
|
||||
player_id: 'player-b',
|
||||
public_base_url: 'http://player-b.example',
|
||||
internal_base_url: 'http://player-b.internal'
|
||||
}
|
||||
]];
|
||||
}
|
||||
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
|
||||
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,7 +113,6 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
||||
|
||||
assert.equal(res.redirectedTo, '/screens?edit=42');
|
||||
assert.deepEqual(calls, [
|
||||
{ kind: 'forwardPlayerCommandToBaseUrl', baseUrl: 'http://player.internal', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
|
||||
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,22 @@ test('screen edit page includes shared launcher downloads and base player url',
|
||||
pages,
|
||||
buildDashboardState: async () => ({ screens: [] }),
|
||||
getScreenDeleteBlockMessage: async () => '',
|
||||
getScreenConnections: async () => [],
|
||||
getScreenConnections: async (slug) => {
|
||||
if (slug !== 'demo-conference') {
|
||||
return { connections: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
clientId: 'conn-1',
|
||||
deviceId: 'device-1',
|
||||
playerPublicBaseUrl: 'http://player.example/'
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
playerPublicBaseUrl: 'http://player.example',
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
@@ -265,6 +280,8 @@ test('screen edit page renders player urls as an adminlte table', async () => {
|
||||
name: 'Demo Conference',
|
||||
slug: 'demo-conference',
|
||||
playlist_id: null,
|
||||
slug_update_confirm_live_connection_count: 2,
|
||||
slug_update_confirm_message: 'Are you sure you want to update the slug? This will refresh all screens using this slug.',
|
||||
player_urls: [
|
||||
{
|
||||
identifier: 'player-alpha',
|
||||
@@ -283,6 +300,8 @@ test('screen edit page renders player urls as an adminlte table', async () => {
|
||||
assert.match(html, /card-body table-responsive p-0/);
|
||||
assert.match(html, /table table-striped w-100 mb-0/);
|
||||
assert.match(html, /player-alpha/);
|
||||
assert.match(html, /<input[^>]+id="screen-slug"[^>]+disabled/);
|
||||
assert.match(html, /Slug cannot be changed after creation/);
|
||||
});
|
||||
|
||||
test('screen launcher downloads are shared and attached', async () => {
|
||||
|
||||
Reference in New Issue
Block a user