Compare commits

...
15 Commits
Author SHA1 Message Date
lzstealth e7ec276317 Release v2.7.0
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m18s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 33s
2026-08-14 13:36:47 +01:00
lzstealth 30f5ed11b8 Format scheduled task intervals
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m25s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
2026-08-14 00:20:48 +01:00
lzstealth d6a8b45357 Fresh initial commit
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m12s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 30s
2026-08-12 02:41:31 +01:00
lzstealth f9425fc640 Release 2.6.25
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m12s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 30s
2026-08-10 01:04:49 +01:00
lzstealth 4491c15215 Release 2.6.24 2026-08-10 00:32:28 +01:00
lzstealth 08b06941a4 Adjust dashboard card spacing 2026-08-09 13:53:38 +01:00
lzstealth c0c05f76e3 Make API and RSS refresh notifications change-only 2026-08-09 13:44:17 +01:00
lzstealth 78a34e4105 Release 2.6.23 2026-08-09 13:38:30 +01:00
lzstealth 3c3864e6ac Sync build package versions 2026-08-09 13:06:55 +01:00
lzstealth 6d8bf0f5f0 Release v2.6.22 2026-08-09 13:06:02 +01:00
lzstealth c36b9122c9 Release v2.6.21 2026-08-09 12:30:22 +01:00
lzstealth 39f2098ffe Release v2.6.20 2026-08-09 12:18:44 +01:00
lzstealth 459f84ed94 Release v2.6.19 2026-08-09 11:57:40 +01:00
lzstealth 49c72923b4 Release 2.6.15 2026-08-08 23:15:04 +01:00
lzstealth 6c28a1c028 Release 2.6.14 2026-08-08 21:49:22 +01:00
85 changed files with 3932 additions and 619 deletions
+1
View File
@@ -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.
+116
View File
@@ -2,6 +2,122 @@
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
- Startup now logs the previously detected schema version, the current app version, and whether migrations are required.
- The schema documentation now reflects the timetable table rename, the new `o_app_state` table, and startup version tracking.
- Timetable timezone placeholders now use `tz` for the short zone name and `tz_long` for the full IANA zone.
## 2.6.20 - 2026-08-09
### Changed
- Startup now logs the previously detected schema version, the current app version, and whether migrations are required.
## 2.6.19 - 2026-08-09
### Changed
- Timetable editor helpers, routes, and table layout now use timetable-specific naming and tighter card/table styling.
- Connected clients now sort by client identity fields instead of the old IP-based ordering assumption.
## 2.6.18 - 2026-08-09
### Changed
- Timetable tables were renamed from the old `schedule` names to `timetable` names, and existing databases now rename those tables during migration.
- The timetable group editor now uses timetable-specific naming in its shared helpers and keeps the entries table aligned with the standard admin card/table layout.
## 2.6.17 - 2026-08-09
### Changed
- Existing timetable groups and entries are now migrated to Europe/London, and timetable dates are rewritten to UTC using that source timezone so the wall-clock meaning stays intact.
### Fixed
- New timetable groups now default to Europe/London so the timetable editor and saved data start from the same timezone assumption as the migrated rows.
## 2.6.16 - 2026-08-09
### Changed
- Timetable groups now store an IANA time zone and render their entry datetimes in that timetable time zone, so schedules keep the same wall-clock meaning when they are edited from another country.
### Fixed
- Timetable entry date inputs now round-trip through the timetable time zone instead of the browser locale, so saving from Florida while targeting Germany keeps the intended local times.
## 2.6.15 - 2026-08-08
### Fixed
- Slide update media sync on the player now removes uploads that were removed from the slide, so player storage stays aligned with the current slide content.
- Routine player and player-bridge media upload/delete logs were removed to keep remote player and bridge operation quieter.
- Background task descriptions no longer repeat the player slug when the task key already ends with that player name.
## 2.6.14 - 2026-08-08
### Fixed
- Slide updates and template deletes now fan out media sync work across all live players instead of targeting only one player.
## 2.6.13 - 2026-08-08
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.6.13",
"version": "2.7.0",
"private": false,
"description": "Pulse Signage player application bundle",
"main": "src/common.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.6.13",
"version": "2.7.0",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"main": "src/common.js",
+20 -10
View File
@@ -165,8 +165,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `i_rss_feeds` - RSS feed definitions and refresh cadence.
- `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot.
- `i_schedule_groups` - grouped schedule definitions used by the schedule region.
- `i_schedule_entries` - dated entries that belong to a schedule group.
- `i_timetable_groups` - grouped timetable definitions used by the timetable region.
- `i_timetable_entries` - dated entries that belong to a timetable group.
### `i_rss_feeds`
@@ -184,32 +184,40 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_schedule_groups`
### `i_timetable_groups`
- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `id`, `name`, `short_description`, `timezone`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `timezone` defaults to `Europe/London`.
### `i_schedule_entries`
### `i_timetable_entries`
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE`
- `schedule_group_id` -> `i_timetable_groups.id` with `ON DELETE CASCADE`
- Index:
- `(schedule_group_id, start_datetime)`
## Operations
- `o_background_tasks` - queue and history for background jobs.
- `o_app_state` - generic app state and version markers stored as key/value pairs.
### `o_background_tasks`
- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message`
- Indexed by `status`, `task_key`, and `task_type`.
### `o_app_state`
- `state_key`, `state_value`, `created_at`, `modified_at`
- `state_key` is the primary key.
- `schema_version` is stored here so startup can detect the previously recorded schema version before deciding whether migrations need to run.
## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
- `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role.
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
- `src/db/migrations.js` records the current schema version in `o_app_state` during startup so later launches can tell whether an update is happening.
```mermaid
erDiagram
@@ -255,9 +263,11 @@ erDiagram
}
I_API_SOURCES {
}
I_SCHEDULE_GROUPS {
I_TIMETABLE_GROUPS {
}
I_SCHEDULE_ENTRIES {
I_TIMETABLE_ENTRIES {
}
O_APP_STATE {
}
O_BACKGROUND_TASKS {
}
@@ -282,5 +292,5 @@ erDiagram
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains
I_TIMETABLE_GROUPS ||--o{ I_TIMETABLE_ENTRIES : contains
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.6.13",
"version": "2.6.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.6.13",
"version": "2.6.15",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.6.13",
"version": "2.7.0",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+4 -1
View File
@@ -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) {
+1 -1
View File
@@ -4,7 +4,7 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage,
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
+4 -1
View File
@@ -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) {
+112 -6
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') + '"');
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)}>`;
});
}
@@ -308,6 +371,49 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''),
timezone: timezoneValue === undefined || timezoneValue === null ? String(current.timezone || current.time_zone || '') : String(timezoneValue || '').trim()
};
} else if (region.region_type === 'timetable') {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
const suffix = '_' + region.id;
const generic = {};
const submittedText = body[`region_text_${region.id}`];
const normalizedText = submittedText === undefined ? String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : '') : String(submittedText || '');
Object.keys(body || {}).forEach((key) => {
if (!key.startsWith('region_') || !key.endsWith(suffix)) {
return;
}
const field = key.slice('region_'.length, -suffix.length);
if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') {
return;
}
generic[field] = body[key];
});
if (Object.prototype.hasOwnProperty.call(generic, 'timetable_display_mode')) {
generic.display_mode = generic.timetable_display_mode;
delete generic.timetable_display_mode;
}
if (Object.prototype.hasOwnProperty.call(generic, 'timetable_max_items')) {
generic.max_items = generic.timetable_max_items;
delete generic.timetable_max_items;
}
Object.keys(current).forEach((key) => {
if (generic[key] === undefined) {
generic[key] = current[key];
}
});
delete generic.timetable_display_mode;
delete generic.timetable_max_items;
generic.text = normalizedText;
generic.value = normalizedText;
generic.type = region.region_type;
content[region.region_key] = generic;
} else if (region.region_type === 'rss') {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
@@ -387,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
@@ -4,6 +4,23 @@ const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255;
const DESCRIPTION_MAX_LENGTH = 255;
const DEFAULT_TIME_ZONE = 'Europe/London';
function normalizeTimeZone(value, fallback) {
const raw = String(value || '').trim();
if (!raw) {
return String(fallback || DEFAULT_TIME_ZONE).trim() || DEFAULT_TIME_ZONE;
}
try {
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
return raw;
} catch (_error) {
const error = new Error('Timetable time zone is invalid.');
error.statusCode = 400;
throw error;
}
}
function normalizeDisplayMode(value) {
const mode = String(value || 'upcoming').trim().toLowerCase();
@@ -15,15 +32,15 @@ function normalizeDisplayMode(value) {
async function fetchTimetablesData(pool) {
const [timetableGroups] = await pool.query(`
SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_schedule_groups g
SELECT g.id, g.name, g.short_description, g.timezone, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_timetable_groups g
ORDER BY g.modified_at DESC, g.id DESC
`);
const [timetableEntries] = await pool.query(`
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
FROM i_timetable_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`);
@@ -50,17 +67,18 @@ async function fetchTimetablesData(pool) {
async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_schedule_groups g
selectSql: `SELECT g.id, g.name, g.short_description, g.timezone, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_timetable_groups g
ORDER BY g.modified_at DESC, g.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups',
countSql: 'SELECT COUNT(*) AS count FROM i_timetable_groups',
searchColumns: ['g.name', 'g.short_description'],
searchTerm: searchTerm,
sortColumns: {
name: 'g.name',
description: 'g.short_description',
timezone: 'g.timezone',
entries: 'entry_count',
next_start: 'next_start_datetime',
created: 'g.created_at',
@@ -77,7 +95,7 @@ async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKe
async function fetchTimetableGroupById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?',
'SELECT id, name, short_description, timezone, created_at, modified_at, created_by, modified_by FROM i_timetable_groups WHERE id = ?',
[id]
);
@@ -87,7 +105,7 @@ async function fetchTimetableGroupById(pool, id) {
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
const [rows] = await pool.query(
`SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
FROM i_timetable_entries
WHERE schedule_group_id = ?
ORDER BY start_datetime ASC, id ASC`,
[timetableGroupId]
@@ -100,6 +118,7 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) {
const fallback = existingTimetableGroup || {};
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name');
const shortDescription = validateMaxLength(req.body.short_description || req.body.shortDescription || fallback.short_description || '', DESCRIPTION_MAX_LENGTH, 'Timetable group description');
const timezone = normalizeTimeZone(req.body.timezone || req.body.time_zone || fallback.timezone || DEFAULT_TIME_ZONE, fallback.timezone || DEFAULT_TIME_ZONE);
if (!name) {
const error = new Error('Timetable group name is required.');
@@ -109,7 +128,8 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) {
return {
name: name,
shortDescription: shortDescription
shortDescription: shortDescription,
timezone: timezone
};
}
@@ -119,5 +139,6 @@ module.exports = {
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload
buildTimetableGroupPayload,
normalizeTimeZone
};
+16 -6
View File
@@ -1,3 +1,6 @@
const { version: appVersion } = require('#root/package.json');
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
// Snapshot only: keep this file aligned with the current schema state.
async function ensureSchema(pool, options) {
const schemaLockName = 'pulse_signage_schema_lock';
@@ -12,6 +15,12 @@ async function ensureSchema(pool, options) {
}
try {
const currentVersion = await detectSchemaVersion(pool);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: currentVersion });
const updateRequired = pendingMigrations.length > 0;
console.info('[schema] previous=' + currentVersion + ' current=' + appVersion + ' update=' + (updateRequired ? 'yes' : 'no'));
await pool.query(`
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -238,10 +247,11 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_schedule_groups (
CREATE TABLE IF NOT EXISTS i_timetable_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
short_description VARCHAR(255) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/London',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -250,7 +260,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_schedule_entries (
CREATE TABLE IF NOT EXISTS i_timetable_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
schedule_group_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
@@ -261,8 +271,8 @@ async function ensureSchema(pool, options) {
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE,
INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime)
CONSTRAINT fk_timetable_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_timetable_groups(id) ON DELETE CASCADE,
INDEX idx_timetable_entries_group_start (schedule_group_id, start_datetime)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
@@ -383,8 +393,8 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const { runMigrations } = require('./migrations');
await runMigrations(pool, options);
await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion }));
await recordSchemaVersion(pool, appVersion);
} finally {
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
});
+290 -30
View File
@@ -1,4 +1,7 @@
const { version: appVersion } = require('#root/package.json');
const TIMETABLE_TIME_ZONE = 'Europe/London';
const APP_STATE_TABLE = 'o_app_state';
const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version';
const VERSIONED_MIGRATIONS = [
{
@@ -22,32 +25,32 @@ const VERSIONED_MIGRATIONS = [
// Store the player pointer on screens so we can resolve the player without needing a player-side screen_id.
// This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'.
if (!(await columnExists(pool, 'd_screens', 'player_id'))) {
await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
} else {
await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'");
await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
} else {
await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'");
const [playerColumnNullableRows] = await pool.query(
`SELECT COUNT(*) AS nullable_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND COLUMN_NAME = 'player_id'
AND IS_NULLABLE = 'YES'`
);
if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) {
await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
}
}
const [screenPlayerUniqueRows] = await pool.query(
`SELECT COUNT(*) AS index_count
FROM information_schema.STATISTICS
const [playerColumnNullableRows] = await pool.query(
`SELECT COUNT(*) AS nullable_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND INDEX_NAME = 'uq_screens_player_id'`
AND COLUMN_NAME = 'player_id'
AND IS_NULLABLE = 'YES'`
);
if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) {
await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id');
if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) {
await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
}
}
const [screenPlayerUniqueRows] = await pool.query(
`SELECT COUNT(*) AS index_count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND INDEX_NAME = 'uq_screens_player_id'`
);
if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) {
await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id');
}
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
@@ -56,7 +59,6 @@ const VERSIONED_MIGRATIONS = [
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
}
}
},
{
@@ -276,6 +278,7 @@ const VERSIONED_MIGRATIONS = [
await pool.query('RENAME TABLE d_players_rebuild TO d_players');
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
return;
}
},
{
@@ -295,6 +298,93 @@ const VERSIONED_MIGRATIONS = [
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
await dropColumnIfExists(pool, 'd_screens', 'player_id');
}
},
{
version: '2.6.16',
label: 'v2.6.16 timetable timezone schema',
run: async function (pool) {
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const tableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null;
if (!tableName) {
return;
}
await ensureColumn(pool, tableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
}
},
{
version: '2.6.17',
label: 'v2.6.17 timetable europe/london conversion',
run: async function (pool) {
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries');
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
const groupTableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null;
const entryTableName = timetableEntriesExists ? 'i_timetable_entries' : scheduleEntriesExists ? 'i_schedule_entries' : null;
if (!groupTableName || !entryTableName) {
return;
}
await ensureColumn(pool, groupTableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
await pool.query('UPDATE ' + groupTableName + ' SET timezone = ?', [TIMETABLE_TIME_ZONE]);
const [rows] = await pool.query('SELECT id, start_datetime, end_datetime FROM ' + entryTableName + ' ORDER BY id ASC');
for (const row of rows) {
const startDate = convertMigrationDateTimeFromTimeZone(row.start_datetime, TIMETABLE_TIME_ZONE);
const endDate = row.end_datetime ? convertMigrationDateTimeFromTimeZone(row.end_datetime, TIMETABLE_TIME_ZONE) : null;
await pool.query(
'UPDATE ' + entryTableName + ' SET start_datetime = ?, end_datetime = ? WHERE id = ?',
[formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id]
);
}
}
},
{
version: '2.6.18',
label: 'v2.6.18 timetable table rename',
run: async function (pool) {
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
if (scheduleGroupsExists) {
if (!timetableGroupsExists) {
await pool.query('RENAME TABLE i_schedule_groups TO i_timetable_groups, i_schedule_entries TO i_timetable_entries');
await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
return;
}
await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
await pool.query(`
INSERT IGNORE INTO i_timetable_groups (id, name, short_description, timezone, created_at, created_by, modified_at, modified_by)
SELECT id, name, short_description, 'Europe/London' AS timezone, created_at, created_by, modified_at, modified_by
FROM i_schedule_groups
ORDER BY id ASC
`);
await pool.query(`
INSERT IGNORE INTO i_timetable_entries (id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by)
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by
FROM i_schedule_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`);
const [groupRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups');
const [entryRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries');
const nextGroupId = Number(groupRows && groupRows[0] && groupRows[0].max_id) + 1;
const nextEntryId = Number(entryRows && entryRows[0] && entryRows[0].max_id) + 1;
await pool.query('ALTER TABLE i_timetable_groups AUTO_INCREMENT = ' + nextGroupId);
await pool.query('ALTER TABLE i_timetable_entries AUTO_INCREMENT = ' + nextEntryId);
await pool.query('DROP TABLE i_schedule_entries');
await pool.query('DROP TABLE i_schedule_groups');
}
}
}
];
@@ -311,6 +401,59 @@ async function columnExists(pool, tableName, columnName) {
return Number(rows && rows[0] && rows[0].column_count) > 0;
}
async function tableExists(pool, tableName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS table_count
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?`,
[tableName]
);
return Number(rows && rows[0] && rows[0].table_count) > 0;
}
async function detectSchemaVersion(pool) {
if (await tableExists(pool, APP_STATE_TABLE)) {
const [rows] = await pool.query(
'SELECT state_value FROM ' + APP_STATE_TABLE + ' WHERE state_key = ? LIMIT 1',
[APP_STATE_SCHEMA_VERSION_KEY]
);
const storedVersion = String(rows && rows[0] && rows[0].state_value || '').trim();
if (storedVersion) {
return storedVersion;
}
}
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries');
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
if ((timetableGroupsExists || timetableEntriesExists) && !scheduleGroupsExists && !scheduleEntriesExists) {
return '2.6.18';
}
return '0.0.0';
}
async function recordSchemaVersion(pool, version) {
await pool.query(
`CREATE TABLE IF NOT EXISTS ${APP_STATE_TABLE} (
state_key VARCHAR(191) NOT NULL PRIMARY KEY,
state_value MEDIUMTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
);
await pool.query(
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE state_value = VALUES(state_value)',
[APP_STATE_SCHEMA_VERSION_KEY, String(version || appVersion || '0.0.0').trim()]
);
}
async function columnIsAutoIncrement(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS auto_increment_count
@@ -512,6 +655,107 @@ function formatMigrationDateTime(value) {
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
}
function parseMigrationDateTimeParts(value) {
if (!value) {
return null;
}
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
return null;
}
return {
year: value.getUTCFullYear(),
month: value.getUTCMonth() + 1,
day: value.getUTCDate(),
hour: value.getUTCHours(),
minute: value.getUTCMinutes(),
second: value.getUTCSeconds()
};
}
const raw = String(value || '').trim();
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
if (!match) {
return null;
}
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] || 0),
minute: Number(match[5] || 0),
second: Number(match[6] || 0)
};
}
function getMigrationTimeZoneOffsetMillis(date, timeZone) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return 0;
}
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date).reduce(function (acc, part) {
if (part && part.type && part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, Object.create(null));
const localAsUtc = Date.UTC(
Number(parts.year) || 0,
(Number(parts.month) || 1) - 1,
Number(parts.day) || 1,
Number(parts.hour) || 0,
Number(parts.minute) || 0,
Number(parts.second) || 0,
0
);
return localAsUtc - date.getTime();
}
function convertMigrationDateTimeFromTimeZone(value, timeZone) {
const parts = parseMigrationDateTimeParts(value);
if (!parts) {
return null;
}
const utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
let adjusted = new Date(utcMillis - getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone));
const adjustedOffset = getMigrationTimeZoneOffsetMillis(adjusted, timeZone);
if (adjustedOffset !== getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone)) {
adjusted = new Date(utcMillis - adjustedOffset);
}
return adjusted;
}
function formatMigrationDateTimeUtc(value) {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
const hours = String(value.getUTCHours()).padStart(2, '0');
const minutes = String(value.getUTCMinutes()).padStart(2, '0');
const seconds = String(value.getUTCSeconds()).padStart(2, '0');
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds;
}
function formatMigrationTime(value) {
if (!value) {
return null;
@@ -562,12 +806,13 @@ 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');
const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id');
const legacyTimetableGroupsPresent = await tableExists(pool, 'i_schedule_groups');
const legacyTimetableEntriesPresent = await tableExists(pool, 'i_schedule_entries');
let effectiveCurrentVersion = currentVersion;
if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) {
@@ -578,14 +823,29 @@ async function runMigrations(pool, options) {
effectiveCurrentVersion = '2.6.3';
}
for (const migration of VERSIONED_MIGRATIONS) {
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
await migration.run(pool);
}
if (legacyTimetableGroupsPresent || legacyTimetableEntriesPresent) {
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
}
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,
runMigrations: runMigrations
getPendingMigrations: getPendingMigrations,
runMigrations: runMigrations,
detectSchemaVersion: detectSchemaVersion,
recordSchemaVersion: recordSchemaVersion,
compareVersions: compareVersions
};
-25
View File
@@ -532,12 +532,6 @@ async function start() {
return res.status(400).json({ error: 'Device ID is required.' });
}
logBridge('Forwarding media upload to player', {
deviceId: deviceId,
relativePath: relativePath,
contentLength: Buffer.isBuffer(req.body) ? req.body.length : 0
});
const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const response = await sendPlayerCommand({
command: 'media-put',
@@ -545,13 +539,6 @@ async function start() {
bodyBase64: bodyBuffer.toString('base64')
}, deviceId);
logBridge('Player media upload completed', {
deviceId: deviceId,
relativePath: relativePath,
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) {
logBridge('Player media upload failed', {
@@ -574,23 +561,11 @@ async function start() {
return res.status(400).json({ error: 'Device ID is required.' });
}
logBridge('Forwarding media delete to player', {
deviceId: deviceId,
relativePath: relativePath
});
const response = await sendPlayerCommand({
command: 'media-delete',
relativePath: relativePath
}, deviceId);
logBridge('Player media delete completed', {
deviceId: deviceId,
relativePath: relativePath,
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) {
logBridge('Player media delete failed', {
-11
View File
@@ -252,10 +252,6 @@ async function start() {
if (!filePath) {
response.error = 'Invalid media path.';
} else {
console.info('[player] Removing media file', {
relativePath: relativePath,
filePath: filePath
});
try {
await fs.promises.unlink(filePath);
} catch (error) {
@@ -264,10 +260,6 @@ async function start() {
}
}
response.ok = true;
console.info('[player] Media file removed', {
relativePath: relativePath,
filePath: filePath
});
}
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) {
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
@@ -467,9 +459,6 @@ async function start() {
}
if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') {
console.info('[player] Bridge registration acknowledged', {
playerIdentifier: PLAYER_DEVICE_ID
});
sendHeartbeat();
return;
}
+6 -1
View File
@@ -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 '';
}
+1 -1
View File
@@ -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}}">
+2 -11
View File
@@ -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%;
+15 -1
View File
@@ -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,
+14 -1
View File
@@ -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();
+21 -28
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);
@@ -213,27 +205,28 @@ function getFormattedParts(timeZone, date) {
MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone,
tz_short: getPart(timezoneShort, 'timeZoneName'),
tz: getPart(timezoneShort, 'timeZoneName'),
tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
};
}
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) || '');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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) {
@@ -228,6 +133,7 @@ function renderRegion(region, regionContent) {
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
var timeZone = group && (group.timezone || group.time_zone) ? String(group.timezone || group.time_zone) : '';
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
@@ -241,6 +147,7 @@ function renderRegion(region, regionContent) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
timeZone: timeZone,
group: group || {},
entries: entries,
index: index + 1
+12 -1
View File
@@ -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();
+1
View File
@@ -207,6 +207,7 @@ async function start() {
mediaDir: webConfig.mediaDir,
backgroundTaskQueue: backgroundTaskQueue,
webBootstrap: webBootstrap,
notifyPlayerScreens: notifyPlayerScreens,
loadCurrentUser: loadCurrentUser,
initializeBackgroundTasks: initializeBackgroundTasks,
captureSlideThumbnail: captureSlideThumbnail,
+3
View File
@@ -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);
}));
});
+127 -2
View File
@@ -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);
}
+10 -1
View File
@@ -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) {
+10 -1
View File
@@ -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) {
+111 -29
View File
@@ -453,6 +453,23 @@ function createUploadSyncService(options) {
return Boolean(localUploadDir);
}
async function fetchLivePlayerRegistrations() {
if (!pool || typeof fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const players = await fetchPlayerRegistrations(pool);
return Array.isArray(players)
? players.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
})
: [];
} catch (_error) {
return [];
}
}
function isPlayerUnavailableError(error) {
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
@@ -462,16 +479,33 @@ function createUploadSyncService(options) {
return Boolean(response) && Number(response.status) === 503;
}
function buildPendingPlayerUploadSyncKey(operation) {
const uploadPath = normalizeUploadReference(operation && operation.uploadPath);
const metadata = operation && operation.metadata && typeof operation.metadata === 'object'
? operation.metadata
: null;
const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim();
const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || '');
return [uploadPath, playerIdentifier, playerInternalBaseUrl].filter(Boolean).join('|');
}
function queuePlayerUploadSync(operation) {
if (!operation || !operation.uploadPath) {
return;
}
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
const metadata = operation.metadata && typeof operation.metadata === 'object' ? operation.metadata : null;
const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim();
const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || '');
pendingPlayerUploadSyncs.set(buildPendingPlayerUploadSyncKey(operation), {
type: operation.type === 'delete' ? 'delete' : 'put',
uploadPath: normalizeUploadReference(operation.uploadPath),
uploadDir: operation.uploadDir || null,
metadata: operation.metadata || null
metadata: metadata,
playerIdentifier: playerIdentifier || null,
playerInternalBaseUrl: playerInternalBaseUrl || null
});
schedulePendingPlayerUploadSyncFlush();
@@ -592,20 +626,26 @@ function createUploadSyncService(options) {
}
}
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
if (!shouldMirrorUploads(localUploadDir)) {
return;
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) {
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl);
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
if (!success) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uniqueRefs[i],
uploadDir: localUploadDir
uploadDir: localUploadDir,
playerIdentifier: preferredPlayerIdentifier,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
playerIdentifier: preferredPlayerIdentifier || null,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
} : null
});
}
}
@@ -723,10 +763,27 @@ function createUploadSyncService(options) {
return;
}
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
mode: 'playlist',
operation: operation
});
const players = await fetchLivePlayerRegistrations();
if (!players.length) {
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
mode: 'playlist',
operation: operation
});
}
return Promise.all(players.map(function (player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
return queueMediaSyncTask('media-sync:' + operation.key + (playerIdentifier ? ':' + playerIdentifier : ''), 'Media sync', {
mode: 'playlist',
operation: operation,
playerIdentifier: playerIdentifier || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerPublicBaseUrl: playerPublicBaseUrl || null
});
}));
}
async function flushPendingPlaylistUploadSyncs() {
@@ -787,22 +844,27 @@ function createUploadSyncService(options) {
}
pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata
? pendingEntries[0].metadata
: await getPlayerTaskMetadata();
if (playerMetadata && playerMetadata.playerActive === false) {
pendingPlayerUploadSyncs.clear();
pendingPlayerUploadSyncRetryLogAt = 0;
return;
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl);
const pendingEntries = Array.from(pendingPlayerUploadSyncs.entries());
const firstOperation = pendingEntries.length && pendingEntries[0] ? pendingEntries[0][1] : null;
const summaryPlayerMetadata = firstOperation && firstOperation.metadata
? firstOperation.metadata
: await getPlayerTaskMetadata(firstOperation && firstOperation.playerIdentifier, firstOperation && firstOperation.playerInternalBaseUrl);
let successCount = 0;
let failureCount = 0;
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
const entry = pendingEntries[i];
const pendingKey = entry[0];
const operation = entry[1];
const playerMetadata = operation && operation.metadata
? operation.metadata
: await getPlayerTaskMetadata(operation && operation.playerIdentifier, operation && operation.playerInternalBaseUrl);
if (playerMetadata && playerMetadata.playerActive === false) {
pendingPlayerUploadSyncs.delete(pendingKey);
continue;
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl);
let success = false;
if (operation.type === 'delete') {
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
@@ -811,20 +873,20 @@ function createUploadSyncService(options) {
}
if (success) {
successCount += 1;
pendingPlayerUploadSyncs.delete(operation.uploadPath);
pendingPlayerUploadSyncs.delete(pendingKey);
} else {
failureCount += 1;
}
}
if (successCount) {
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata);
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, summaryPlayerMetadata);
}
if (failureCount) {
const now = Date.now();
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
pendingPlayerUploadSyncRetryLogAt = now;
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata);
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, summaryPlayerMetadata);
}
} else if (!pendingPlayerUploadSyncs.size) {
pendingPlayerUploadSyncRetryLogAt = 0;
@@ -892,14 +954,34 @@ function createUploadSyncService(options) {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
const removedUploadRefs = operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference);
}));
});
for (let i = 0; i < removedUploadRefs.length; i += 1) {
const removedUploadRef = removedUploadRefs[i];
const referenceCount = await countUploadReferences(pool, removedUploadRef);
if (referenceCount > 0) {
continue;
}
const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier);
if (!deleted) {
queuePlayerUploadSync({
type: 'delete',
uploadPath: removedUploadRef,
uploadDir: operation.localUploadDir,
playerIdentifier: taskPayload.playerIdentifier,
playerInternalBaseUrl: taskPayload.playerInternalBaseUrl,
metadata: playerMetadata
});
}
}
await removeUnusedUploadFiles(pool, operation.localUploadDir, removedUploadRefs);
}
if (operation.refreshScreenSlugs.length) {
+1
View File
@@ -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,
+32
View File
@@ -756,6 +756,38 @@
background: var(--bs-tertiary-bg);
}
.timetable-entries-table-shell {
overflow: hidden;
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
}
.timetable-entries-table-shell > .table-responsive {
margin-bottom: 0;
}
.timetable-entries-table {
border-collapse: collapse;
}
.timetable-entries-table > thead > tr:first-child > * {
border-top-width: 0;
}
.timetable-entries-table > :not(caption) > * > :first-child {
border-left-width: 0;
padding-left: 0;
}
.timetable-entries-table > :not(caption) > * > :last-child {
border-right-width: 0;
padding-right: 0;
}
.timetable-entries-table > tbody > tr:last-child > * {
border-bottom-width: 0;
}
.api-source-section-heading {
display: flex;
align-items: center;
+13 -13
View File
@@ -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,
@@ -2,14 +2,17 @@
(function () {
var form = document.getElementById('timetable-group-form');
var timezoneInput = document.getElementById('timetable-group-timezone');
var body = document.querySelector('[data-timetable-entries-body]');
var addButton = document.querySelector('[data-add-timetable-entry]');
var template = document.getElementById('timetable-entry-row-template');
if (!form || !body || !addButton || !template) {
if (!form || !timezoneInput || !body || !addButton || !template) {
return;
}
var DEFAULT_TIME_ZONE = 'Europe/London';
function markDirty() {
form.dataset.dirty = 'true';
}
@@ -18,43 +21,150 @@
return String(value).padStart(2, '0');
}
function formatDateTimeLocalValue(date) {
function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIME_ZONE;
} catch (_error) {
return DEFAULT_TIME_ZONE;
}
}
function resolveTimeZone(value) {
var raw = String(value || '').trim();
if (!raw) {
return getDefaultTimeZone();
}
try {
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
return raw;
} catch (_error) {
return getDefaultTimeZone();
}
}
function parseDateTimeLocalParts(value) {
var raw = String(value || '').trim();
var match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
if (!match) {
return null;
}
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4]),
minute: Number(match[5]),
second: Number(match[6] || '0')
};
}
function getDateTimeParts(date, timeZone) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return null;
}
var resolvedTimeZone = resolveTimeZone(timeZone);
var parts = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date);
var mapped = Object.create(null);
parts.forEach(function (part) {
if (part && part.type && part.type !== 'literal') {
mapped[part.type] = part.value;
}
});
return mapped;
}
function formatDateTimeLocalValue(date, timeZone) {
var parts = getDateTimeParts(date, timeZone);
if (!parts) {
return '';
}
return [
String(date.getFullYear()).padStart(4, '0'),
String(parts.year || '').padStart(4, '0'),
'-',
pad(date.getMonth() + 1),
pad(parts.month),
'-',
pad(date.getDate()),
pad(parts.day),
'T',
pad(date.getHours()),
pad(parts.hour),
':',
pad(date.getMinutes())
pad(parts.minute)
].join('');
}
function parseUtcDateTimeLocalValue(value) {
var raw = String(value || '').trim();
if (!raw) {
return null;
function getTimeZoneOffsetMillis(date, timeZone) {
var parts = getDateTimeParts(date, timeZone);
if (!parts) {
return 0;
}
var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z';
var date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
var localAsUtc = Date.UTC(
Number(parts.year) || 0,
(Number(parts.month) || 1) - 1,
Number(parts.day) || 1,
Number(parts.hour) || 0,
Number(parts.minute) || 0,
Number(parts.second) || 0,
0
);
return localAsUtc - date.getTime();
}
function parseDateTimeLocalValue(value) {
function parseDateTimeLocalAsUtc(value, timeZone) {
var raw = String(value || '').trim();
if (!raw) {
return null;
var isoDate;
var parts = parseDateTimeLocalParts(value);
if (!parts) {
isoDate = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? new Date(raw) : null;
return isoDate && !Number.isNaN(isoDate.getTime()) ? isoDate : null;
}
var date = new Date(raw);
return Number.isNaN(date.getTime()) ? null : date;
var resolvedTimeZone = resolveTimeZone(timeZone);
var utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
var date = new Date(utcMillis);
var offset = getTimeZoneOffsetMillis(date, resolvedTimeZone);
var adjusted = new Date(utcMillis - offset);
var adjustedOffset = getTimeZoneOffsetMillis(adjusted, resolvedTimeZone);
if (adjustedOffset !== offset) {
adjusted = new Date(utcMillis - adjustedOffset);
}
return Number.isNaN(adjusted.getTime()) ? null : adjusted;
}
function convertDateTimeLocalValue(value, sourceTimeZone, targetTimeZone) {
var raw = String(value || '').trim();
if (!raw) {
return '';
}
var utcDate = parseDateTimeLocalAsUtc(raw, sourceTimeZone);
if (!utcDate) {
return '';
}
var resolvedTargetTimeZone = resolveTimeZone(targetTimeZone);
if (resolvedTargetTimeZone === 'UTC') {
return utcDate.toISOString();
}
return formatDateTimeLocalValue(utcDate, resolvedTargetTimeZone);
}
function clearFieldValidity(input) {
@@ -82,17 +192,21 @@
}
function validateEntryRow(row) {
if (!row) {
return;
}
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
var timezone = resolveTimeZone(timezoneInput.value);
var startInput;
var endInput;
var startValue;
var endValue;
var startDate;
var endDate;
if (!row) {
return;
}
startInput = row.querySelector('[name="entry_start_datetime[]"]');
endInput = row.querySelector('[name="entry_end_datetime[]"]');
clearFieldValidity(endInput);
if (!startInput || !endInput) {
@@ -106,8 +220,8 @@
return;
}
startDate = parseDateTimeLocalValue(startValue);
endDate = parseDateTimeLocalValue(endValue);
startDate = parseDateTimeLocalAsUtc(startValue, timezone);
endDate = parseDateTimeLocalAsUtc(endValue, timezone);
if (!startDate || !endDate) {
return;
@@ -119,12 +233,15 @@
}
function bindRowValidation(row) {
var startInput;
var endInput;
if (!row) {
return;
}
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
startInput = row.querySelector('[name="entry_start_datetime[]"]');
endInput = row.querySelector('[name="entry_end_datetime[]"]');
if (!startInput || !endInput) {
return;
@@ -142,32 +259,37 @@
validateEntryRow(row);
}
function toUtcDateTimeLocalValue(value) {
var localDate = new Date(String(value || '').trim());
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
}
function syncRowValuesToLocal(row) {
function syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone) {
if (!row) {
return;
}
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
var input = row.querySelector('[name="' + name + '"]');
if (!input || input.dataset.timetableTimezoneSynced === 'true') {
var convertedValue;
if (!input) {
return;
}
var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value));
if (localValue) {
input.value = localValue;
convertedValue = convertDateTimeLocalValue(input.value, sourceTimeZone, targetTimeZone);
if (convertedValue) {
input.value = convertedValue;
}
input.dataset.timetableTimezoneSynced = 'true';
});
}
function syncAllRowsToTimeZone(sourceTimeZone, targetTimeZone) {
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone);
validateEntryRow(row);
});
}
function syncFormDataToUtc(formData) {
var rows = body.querySelectorAll('[data-timetable-entry-row]');
var currentTimeZone = resolveTimeZone(timezoneInput.value);
['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
formData.delete(name);
});
@@ -182,8 +304,8 @@
formData.append('entry_id[]', idInput ? idInput.value : '');
formData.append('entry_title[]', titleInput ? titleInput.value : '');
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : '');
formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : '');
formData.append('entry_start_datetime[]', startInput ? convertDateTimeLocalValue(startInput.value, currentTimeZone, 'UTC') : '');
formData.append('entry_end_datetime[]', endInput ? convertDateTimeLocalValue(endInput.value, currentTimeZone, 'UTC') : '');
});
}
@@ -210,9 +332,16 @@
markDirty();
}
function handleTimezoneChange() {
var nextTimeZone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE);
form.dataset.timetableTimezone = nextTimeZone;
markDirty();
}
form.dataset.timetableTimezone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE);
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
bindRemove(row);
syncRowValuesToLocal(row);
bindRowValidation(row);
});
@@ -220,5 +349,8 @@
syncFormDataToUtc(event.formData);
});
timezoneInput.addEventListener('change', handleTimezoneChange);
timezoneInput.addEventListener('input', handleTimezoneChange);
addButton.addEventListener('click', addRow);
}());
+105 -2
View File
@@ -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,
+15 -5
View File
@@ -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>' : '';
}
@@ -272,8 +272,18 @@
function buildPreviewRenderContext(region, card, existingContent, sources) {
var current = getCurrentConfig(region, existingContent || {}, sources || []);
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var fallbackValue = card && card.querySelector ? ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '') : current.value;
var content = card && card.querySelector ? {
value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')),
value: String((function () {
if (!editor || typeof editor.getContent !== 'function') {
return fallbackValue;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallbackValue;
}
}())),
source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id,
item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number,
items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path
+13 -7
View File
@@ -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>' : '';
}
@@ -277,8 +273,18 @@
function buildPreviewRenderContext(region, card, existingContent, feeds) {
var current = getCurrentConfig(region, existingContent || {});
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var fallbackValue = card && card.querySelector ? ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '') : current.value;
var content = card && card.querySelector ? {
value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')),
value: String((function () {
if (!editor || typeof editor.getContent !== 'function') {
return fallbackValue;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallbackValue;
}
}())),
feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id,
item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number
} : current;
+11 -1
View File
@@ -84,7 +84,17 @@
} : {};
return {
value: String(editor ? editor.getContent({ format: 'html' }) : (hidden && hidden.value !== undefined ? hidden.value : (textarea && textarea.value !== undefined ? textarea.value : (current && current.value !== undefined ? current.value : '')))),
value: String((function () {
var fallback = hidden && hidden.value !== undefined ? hidden.value : (textarea && textarea.value !== undefined ? textarea.value : (current && current.value !== undefined ? current.value : ''));
if (!editor || typeof editor.getContent !== 'function') {
return fallback;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallback;
}
}())),
style: style,
existingContent: existingContent || {}
};
+6 -6
View File
@@ -156,19 +156,19 @@
MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone,
tz_short: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
tz: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
timeZoneName: 'short'
}).formatToParts(targetDate), 'timeZoneName'),
tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
};
}
function resolveTimeDatePlaceholder(values, expression) {
function resolveTimeDatePlaceholder(values, expression, options) {
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression, options || {}));
}
var parsed = String(expression || '').trim();
@@ -200,7 +200,7 @@
'MMMM',
'a',
'tz',
'tz_short'
'tz_long'
];
if (typeof placeholderChips.renderChips === 'function') {
@@ -220,7 +220,7 @@
var template = String(format || '').trim();
var values = getFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
return String(resolveTimeDatePlaceholder(values, key) || '');
return String(resolveTimeDatePlaceholder(values, key, { timeZone: timeZone }) || '');
});
}
@@ -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,
@@ -43,6 +39,52 @@
return group && Array.isArray(group.entries) ? group.entries : [];
}
function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch (_error) {
return 'UTC';
}
}
function resolveTimeZone(value) {
var raw = String(value || '').trim();
if (!raw) {
return getDefaultTimeZone();
}
try {
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
return raw;
} catch (_error) {
return getDefaultTimeZone();
}
}
function getTimezoneValues(group, dateValue) {
var timeZone = resolveTimeZone(group && (group.timezone || group.time_zone || ''));
var shortName = timeZone;
var targetDate = dateValue instanceof Date ? dateValue : new Date(dateValue || Date.now());
try {
var parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timeZone,
timeZoneName: 'short'
}).formatToParts(Number.isNaN(targetDate.getTime()) ? new Date() : targetDate);
var timeZonePart = parts.find(function (part) {
return part && part.type === 'timeZoneName';
});
shortName = timeZonePart && timeZonePart.value ? String(timeZonePart.value) : timeZone;
} catch (_error) {
shortName = timeZone;
}
return {
tz: shortName,
tz_long: timeZone
};
}
function toDate(value) {
if (!value) {
return null;
@@ -119,9 +161,9 @@
var current = existingContent[region.region_key] || {};
return {
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''),
max_items: Math.max(1, Number(current.max_items || 5)),
display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''),
max_items: Math.max(1, Number(current.max_items || current.timetable_max_items || 5)),
font_family: current.font_family || region.font_family || getDefaultStyle().font_family,
font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
font_color: current.font_color || region.font_color || getDefaultStyle().font_color
@@ -143,14 +185,47 @@
}).join('');
}
function renderScheduleFormatTokenTable() {
if (window.timeDatePlaceholders && typeof window.timeDatePlaceholders.renderTable === 'function') {
return window.timeDatePlaceholders.renderTable();
}
return '' +
'<div class="table-responsive">' +
'<table class="table table-sm align-middle mb-0">' +
'<thead><tr><th scope="col">Format</th><th scope="col">Output</th><th scope="col">Description</th></tr></thead>' +
'<tbody>' +
'<tr><td><code>h</code></td><td>1-12</td><td>The hour, 12-hour clock</td></tr>' +
'<tr><td><code>hh</code></td><td>01-12</td><td>The hour, 12-hour clock, 2-digits</td></tr>' +
'<tr><td><code>m</code></td><td>0-59</td><td>The minute</td></tr>' +
'<tr><td><code>mm</code></td><td>00-59</td><td>The minute, 2-digits</td></tr>' +
'<tr><td><code>s</code></td><td>0-59</td><td>The second</td></tr>' +
'<tr><td><code>ss</code></td><td>00-59</td><td>The second, 2-digits</td></tr>' +
'<tr><td><code>A</code></td><td>AM/PM</td><td>Uppercase day period</td></tr>' +
'<tr><td><code>a</code></td><td>am/pm</td><td>Lowercase day period</td></tr>' +
'<tr><td><code>D</code></td><td>1-31</td><td>The day of the month</td></tr>' +
'<tr><td><code>DD</code></td><td>01-31</td><td>The day of the month, 2-digits</td></tr>' +
'<tr><td><code>ddd</code></td><td>Mon</td><td>The abbreviated weekday name</td></tr>' +
'<tr><td><code>dddd</code></td><td>Monday</td><td>The full weekday name</td></tr>' +
'<tr><td><code>M</code></td><td>1-12</td><td>The month, beginning at 1</td></tr>' +
'<tr><td><code>MM</code></td><td>01-12</td><td>The month, 2-digits</td></tr>' +
'<tr><td><code>MMM</code></td><td>Jan-Dec</td><td>The abbreviated month name</td></tr>' +
'<tr><td><code>MMMM</code></td><td>January-December</td><td>The full month name</td></tr>' +
'<tr><td><code>YY</code></td><td>18</td><td>The two-digit year</td></tr>' +
'<tr><td><code>YYYY</code></td><td>2018</td><td>The four-digit year</td></tr>' +
'</tbody>' +
'</table>' +
'</div>';
}
function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
var current = existingContent[regionId] || {};
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null;
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null;
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null;
var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id));
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5));
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5));
var group = getGroupById(groupId, timetableGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
if (!entries.length) {
@@ -169,10 +244,10 @@
var region = context.region;
var current = context.current || {};
var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : [];
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '');
var currentValue = String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : '');
var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5));
var currentDisplayMode = String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || current.timetable_max_items || 5));
var currentGroup = getGroupById(currentGroupId, timetableGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
if (!currentEntries.length) {
@@ -219,7 +294,12 @@
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code> or <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> for the short zone name, or <code>{{start.tz_long()}}</code> for the full IANA zone name.</div>' +
'<details class="mt-2">' +
'<summary class="small text-body-secondary">Supported date format tokens</summary>' +
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
'<div class="text-body-secondary small mt-1">Use these tokens inside <code>.format(...)</code>; for example <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</details>' +
'</div>' +
'</div>' +
'</div>' +
@@ -287,13 +367,29 @@
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var value = String(editor ? editor.getContent({ format: 'html' }) : (hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value)));
var fallbackValue = hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value);
var currentGroup = getGroupById(timetableGroupInput ? timetableGroupInput.value : current.timetable_group_id, timetableGroups);
var timezoneValues = getTimezoneValues(currentGroup);
var value = String((function () {
if (!editor || typeof editor.getContent !== 'function') {
return fallbackValue;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallbackValue;
}
}()));
return {
value: value,
style: getTextStyle(region, current),
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
tz: timezoneValues.tz,
tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long,
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {},
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
@@ -303,7 +399,7 @@
function renderPreview(region, regionContent, context) {
var groups = context && context.timetableGroups ? context.timetableGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var value = String(regionContent && (regionContent.text !== undefined ? regionContent.text : regionContent.value !== undefined ? regionContent.value : '') || '').trim();
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
@@ -315,13 +411,27 @@
}
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) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
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 + '">' + (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,
tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long,
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>';
}
+177 -28
View File
@@ -75,36 +75,148 @@
return text;
}
function formatDateValue(value, pattern) {
var date = value instanceof Date ? value : new Date(value);
function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch (_error) {
return 'UTC';
}
}
function getDateValue(value) {
return value instanceof Date ? value : new Date(value);
}
function resolveTimeZone(value) {
var raw = String(value || '').trim();
if (!raw) {
return getDefaultTimeZone();
}
try {
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
return raw;
} catch (_error) {
return getDefaultTimeZone();
}
}
function getDatePartsFromLocalTime(date) {
var dayPeriod = date.getHours() >= 12 ? 'PM' : 'AM';
return {
year: String(date.getFullYear()),
month: padNumber(date.getMonth() + 1, 2),
day: padNumber(date.getDate(), 2),
hour24: padNumber(date.getHours(), 2),
hour12: padNumber(date.getHours() % 12 || 12, 2),
minute: padNumber(date.getMinutes(), 2),
second: padNumber(date.getSeconds(), 2),
weekdayLong: dayNamesLong[date.getDay()],
weekdayShort: dayNamesShort[date.getDay()],
monthLong: monthNamesLong[date.getMonth()],
monthShort: monthNamesShort[date.getMonth()],
dayPeriod: dayPeriod
};
}
function getDatePartsFromTimeZone(date, timeZone) {
var resolvedTimeZone = resolveTimeZone(timeZone);
var baseFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
var weekdayLongFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
weekday: 'long'
});
var weekdayShortFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
weekday: 'short'
});
var monthLongFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
month: 'long'
});
var monthShortFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
month: 'short'
});
var ampmFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
hour12: true,
hour: '2-digit',
minute: '2-digit'
});
var numericParts = baseFormatter.formatToParts(date);
var weekdayLong = weekdayLongFormatter.formatToParts(date);
var weekdayShort = weekdayShortFormatter.formatToParts(date);
var monthLong = monthLongFormatter.formatToParts(date);
var monthShort = monthShortFormatter.formatToParts(date);
var ampm = ampmFormatter.formatToParts(date);
function getPart(parts, type) {
var match = parts.find(function (part) {
return part && part.type === type;
});
return match ? String(match.value || '') : '';
}
var dayPeriod = getPart(ampm, 'dayPeriod');
return {
year: getPart(numericParts, 'year'),
month: getPart(numericParts, 'month'),
day: getPart(numericParts, 'day'),
hour24: getPart(numericParts, 'hour'),
hour12: getPart(ampm, 'hour'),
minute: getPart(numericParts, 'minute'),
second: getPart(numericParts, 'second'),
weekdayLong: getPart(weekdayLong, 'weekday'),
weekdayShort: getPart(weekdayShort, 'weekday'),
monthLong: getPart(monthLong, 'month'),
monthShort: getPart(monthShort, 'month'),
dayPeriod: String(dayPeriod || '').toUpperCase(),
timeZone: resolvedTimeZone
};
}
function formatDateValue(value, pattern, timeZone) {
var date = getDateValue(value);
if (Number.isNaN(date.getTime())) {
return '';
}
var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm';
var hours24 = date.getHours();
var hours12 = hours24 % 12 || 12;
var parts = timeZone ? getDatePartsFromTimeZone(date, timeZone) : getDatePartsFromLocalTime(date);
var hours24 = parts.hour24;
var hours12 = parts.hour12;
var tokenMap = {
YYYY: String(date.getFullYear()),
YY: String(date.getFullYear()).slice(-2),
MMMM: monthNamesLong[date.getMonth()],
MMM: monthNamesShort[date.getMonth()],
MM: padNumber(date.getMonth() + 1, 2),
M: String(date.getMonth() + 1),
DD: padNumber(date.getDate(), 2),
D: String(date.getDate()),
dddd: dayNamesLong[date.getDay()],
ddd: dayNamesShort[date.getDay()],
HH: padNumber(hours24, 2),
H: String(hours24),
hh: padNumber(hours12, 2),
h: String(hours12),
mm: padNumber(date.getMinutes(), 2),
m: String(date.getMinutes()),
ss: padNumber(date.getSeconds(), 2),
s: String(date.getSeconds()),
A: hours24 >= 12 ? 'PM' : 'AM',
a: hours24 >= 12 ? 'pm' : 'am'
YYYY: parts.year,
YY: parts.year.slice(-2),
MMMM: parts.monthLong,
MMM: parts.monthShort,
MM: parts.month,
M: String(Number(parts.month) || 0),
DD: parts.day,
D: String(Number(parts.day) || 0),
dddd: parts.weekdayLong,
ddd: parts.weekdayShort,
HH: hours24,
H: String(Number(hours24) || 0),
hh: hours12,
h: String(Number(hours12) || 0),
mm: parts.minute,
m: String(Number(parts.minute) || 0),
ss: parts.second,
s: String(Number(parts.second) || 0),
A: parts.dayPeriod,
a: String(parts.dayPeriod || '').toLowerCase()
};
return format.replace(/\[([^\]]+)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g, function (match, literal) {
@@ -112,7 +224,36 @@
});
}
function applyTransform(value, transform) {
function getTimeZoneShortName(value, timeZone) {
var date = getDateValue(value);
if (Number.isNaN(date.getTime())) {
return '';
}
var resolvedTimeZone = resolveTimeZone(timeZone);
try {
var parts = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
timeZoneName: 'short'
}).formatToParts(date);
var match = parts.find(function (part) {
return part && part.type === 'timeZoneName';
});
return match ? String(match.value || '') : resolvedTimeZone;
} catch (_error) {
return resolvedTimeZone;
}
}
function getTimeZoneLongName(timeZone) {
return getTransformTimeZone([], { timeZone: timeZone });
}
function getTransformTimeZone(args, options) {
return resolveTimeZone((args && args[0]) || (options && options.timeZone) || '');
}
function applyTransform(value, transform, options) {
var text = String(value === undefined || value === null ? '' : value);
var name = String(transform && transform.name || '').trim().toLowerCase();
var args = Array.isArray(transform && transform.args) ? transform.args : [];
@@ -132,18 +273,26 @@
}
if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') {
return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A'));
return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A'), getTransformTimeZone(args.slice(1), options));
}
if (name === 'tz') {
return getTimeZoneShortName(value, getTransformTimeZone(args, options));
}
if (name === 'tz_long') {
return getTimeZoneLongName(getTransformTimeZone(args, options));
}
return text;
}
function resolvePlaceholderExpression(value, expression) {
function resolvePlaceholderExpression(value, expression, options) {
var parsed = parsePlaceholderExpression(expression);
var resolved = resolvePath(value, parsed.path);
parsed.transforms.forEach(function (transform) {
resolved = applyTransform(resolved, transform);
resolved = applyTransform(resolved, transform, options || {});
});
return resolved;
@@ -2,47 +2,79 @@
(function () {
var root = window;
var PLACEHOLDERS = [
{ token: 'h', label: 'h' },
{ token: 'hh', label: 'hh' },
{ token: 'm', label: 'm' },
{ token: 'mm', label: 'mm' },
{ token: 's', label: 's' },
{ token: 'ss', label: 'ss' },
{ token: 'd', label: 'd' },
{ token: 'dd', label: 'dd' },
{ token: 'M', label: 'M' },
{ token: 'MM', label: 'MM' },
{ token: 'y', label: 'y' },
{ token: 'yyyy', label: 'yyyy' },
{ token: 'yy', label: 'yy' },
{ token: 'ddd', label: 'ddd' },
{ token: 'dddd', label: 'dddd' },
{ token: 'MMM', label: 'MMM' },
{ token: 'MMMM', label: 'MMMM' },
{ token: 'a', label: 'a' },
{ token: 'tz', label: 'tz' },
{ token: 'tz_short', label: 'tz_short' }
var FORMAT_PLACEHOLDERS = [
{ token: 'h', output: '1-12', description: 'The hour, 12-hour clock' },
{ token: 'hh', output: '01-12', description: 'The hour, 12-hour clock, 2-digits' },
{ token: 'm', output: '0-59', description: 'The minute' },
{ token: 'mm', output: '00-59', description: 'The minute, 2-digits' },
{ token: 's', output: '0-59', description: 'The second' },
{ token: 'ss', output: '00-59', description: 'The second, 2-digits' },
{ token: 'A', output: 'AM/PM', description: 'Uppercase day period' },
{ token: 'a', output: 'am/pm', description: 'Lowercase day period' },
{ token: 'D', output: '1-31', description: 'The day of the month' },
{ token: 'DD', output: '01-31', description: 'The day of the month, 2-digits' },
{ token: 'ddd', output: 'Mon', description: 'The abbreviated weekday name' },
{ token: 'dddd', output: 'Monday', description: 'The full weekday name' },
{ token: 'M', output: '1-12', description: 'The month, beginning at 1' },
{ token: 'MM', output: '01-12', description: 'The month, 2-digits' },
{ token: 'MMM', output: 'Jan-Dec', description: 'The abbreviated month name' },
{ token: 'MMMM', output: 'January-December', description: 'The full month name' },
{ token: 'YY', output: '18', description: 'The two-digit year' },
{ token: 'YYYY', output: '2018', description: 'The four-digit year' }
];
var HELPER_PLACEHOLDERS = [
{ token: 'tz', output: 'BST/GMT', description: 'The time zone abbreviation for the selected date' },
{ token: 'tz_long', output: 'Europe/London', description: 'The resolved time zone name' }
];
var PLACEHOLDERS = FORMAT_PLACEHOLDERS.concat(HELPER_PLACEHOLDERS);
function getTokens() {
return PLACEHOLDERS.slice();
}
function renderChips() {
if (root.placeholderChips && typeof root.placeholderChips.renderChips === 'function') {
return root.placeholderChips.renderChips(PLACEHOLDERS.map(function (item) {
return item.token;
}));
}
function getFormatTokens() {
return FORMAT_PLACEHOLDERS.slice();
}
return PLACEHOLDERS.map(function (item) {
return '<span class="chip">{{' + item.token + '}}</span>';
}).join('');
function renderTable() {
return '' +
'<div class="table-responsive">' +
'<table class="table table-sm align-middle mb-0">' +
'<thead>' +
'<tr>' +
'<th scope="col">Format</th>' +
'<th scope="col">Output</th>' +
'<th scope="col">Description</th>' +
'</tr>' +
'</thead>' +
'<tbody>' +
FORMAT_PLACEHOLDERS.map(function (item) {
return '' +
'<tr>' +
'<td><code>' + item.token + '</code></td>' +
'<td>' + escapeHtml(item.output || '') + '</td>' +
'<td>' + escapeHtml(item.description || '') + '</td>' +
'</tr>';
}).join('') +
'</tbody>' +
'</table>' +
'</div>';
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
root.timeDatePlaceholders = {
getTokens: getTokens,
renderChips: renderChips
getFormatTokens: getFormatTokens,
renderTable: renderTable
};
}());
+249 -12
View File
@@ -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|&nbsp;|<br\s*\/?>)*<\/p>/gi, '')
@@ -72,6 +86,18 @@ export function createSlideFormEditorController(options) {
return window.tinymce || null;
}
function getEditorContentSafely(editor, fallbackValue) {
if (!editor || typeof editor.getContent !== 'function') {
return String(fallbackValue || '');
}
try {
return String(editor.getContent({ format: 'html' }));
} catch (_error) {
return String(fallbackValue || '');
}
}
function getThemeName() {
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
if (theme === 'auto') {
@@ -95,14 +121,19 @@ export function createSlideFormEditorController(options) {
}
function syncEditorState(regionId, editor, shouldLock, hydrated) {
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 : '');
if (editor && typeof editor.save === 'function') {
if (typeof editor.blur === 'function') {
editor.blur();
}
editor.save();
}
var content = editor.getContent({ format: 'html' });
var content = sourceElm && sourceElm.value !== undefined ? String(sourceElm.value || '') : getEditorContentSafely(editor, fallbackContent);
content = isEmptyRichTextValue(content) ? '' : content;
var hidden = getEditorHiddenInput(regionId);
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
if (hidden) {
hidden.value = content;
@@ -192,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;
@@ -211,7 +427,7 @@ export function createSlideFormEditorController(options) {
}
editor.on('init', function () {
var content = editor.getContent({ format: 'html' });
var content = getEditorContentSafely(editor, hidden && hidden.value !== undefined ? hidden.value : (source && source.value !== undefined ? source.value : ''));
content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) {
hidden.value = content;
@@ -288,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',
@@ -377,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);
@@ -405,18 +633,19 @@ export function createSlideFormEditorController(options) {
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) {
var regionId = holder.getAttribute('data-region-id');
var editor = editorInstances.get(regionId);
var hidden = getEditorHiddenInput(regionId);
if (!editor || !hidden) {
if (!editor) {
return Promise.resolve();
}
hidden.value = editor.getContent({ format: 'html' });
syncEditorFontSizeHidden(regionId);
syncEditorState(regionId, editor, false, true);
return Promise.resolve();
});
return Promise.all(saves);
return Promise.all(saves).then(function (results) {
getCommittedImageUploadCleanupPaths();
return results;
});
}
function destroyEditors() {
@@ -454,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();
+19
View File
@@ -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);
@@ -840,6 +845,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
throw new Error('Wait for the image upload to finish before saving.');
}
await slideFormEditorController.syncEditors();
await new Promise(function (resolve) {
window.requestAnimationFrame(function () {
resolve();
});
});
await new Promise(function (resolve) {
window.setTimeout(resolve, 0);
});
await syncRegionCards();
},
fallbackSuccessMessage: 'Saved slide.',
@@ -915,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();
}
+2 -2
View File
@@ -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();
+29 -10
View File
@@ -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,
+9 -28
View File
@@ -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`;
}
@@ -9,7 +9,8 @@ function buildDuplicateTimetableGroup(timetableGroup, duplicateName) {
return Object.assign({}, timetableGroup, {
id: null,
name: duplicateName,
shortDescription: timetableGroup.short_description || ''
shortDescription: timetableGroup.short_description || '',
timezone: timetableGroup.timezone || 'UTC'
});
}
@@ -1,14 +1,40 @@
// Shared timetable group form view-model builder.
const { normalizeTimeZone } = require('../../../../data/timetables');
const COMMON_TIME_ZONES = [
'UTC',
'Europe/London',
'Europe/Dublin',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Toronto',
'America/Vancouver',
'America/Sao_Paulo',
'Asia/Dubai',
'Asia/Kolkata',
'Asia/Singapore',
'Asia/Tokyo',
'Asia/Seoul',
'Australia/Sydney',
'Pacific/Auckland'
];
function buildDefaultTimetableGroup() {
return {
id: null,
name: '',
shortDescription: ''
shortDescription: '',
timezone: 'Europe/London'
};
}
function formatDateTimeLocalValue(value) {
function formatDateTimeLocalValue(value, timeZone) {
if (!value) {
return '';
}
@@ -18,22 +44,49 @@ function formatDateTimeLocalValue(value) {
return '';
}
const year = String(date.getFullYear()).padStart(4, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
const resolvedTimeZone = normalizeTimeZone(timeZone, 'Europe/London');
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date).reduce(function (acc, part) {
if (part && part.type && part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, Object.create(null));
if (!parts.year || !parts.month || !parts.day || !parts.hour || !parts.minute) {
return '';
}
return [
String(parts.year).padStart(4, '0'),
'-',
String(parts.month).padStart(2, '0'),
'-',
String(parts.day).padStart(2, '0'),
'T',
String(parts.hour).padStart(2, '0'),
':',
String(parts.minute).padStart(2, '0')
].join('');
}
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit, options) {
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
viewTimetableGroup.timezone = normalizeTimeZone(viewTimetableGroup.timezone, 'Europe/London');
const viewOptions = options || {};
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
? timetableEntries.map(function (entry) {
return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime),
endValue: formatDateTimeLocalValue(entry.end_datetime)
startValue: formatDateTimeLocalValue(entry.start_datetime, viewTimetableGroup.timezone),
endValue: formatDateTimeLocalValue(entry.end_datetime, viewTimetableGroup.timezone)
});
})
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
@@ -46,6 +99,7 @@ function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, mess
messageVariant: viewOptions.messageVariant || '',
isEdit: Boolean(isEdit),
timetableGroup: viewTimetableGroup,
timeZoneOptions: COMMON_TIME_ZONES,
timetableEntries: viewTimetableEntries,
inUse: Boolean(viewTimetableGroup.inUse),
showSaveSecondaryActions: true,
+29 -6
View File
@@ -1,22 +1,45 @@
// Timetable group list page renderer.
const { renderView } = require('../../../view');
const { normalizeTimeZone } = require('../../../../data/timetables');
function formatNextStartLabel(value, formatDashboardDate) {
function formatDateInTimeZone(value, timeZone) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const resolvedTimeZone = normalizeTimeZone(timeZone, 'Europe/London');
return new Intl.DateTimeFormat('en-US', {
timeZone: resolvedTimeZone,
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function formatNextStartLabel(value, timeZone) {
if (!value) {
return 'No entries';
}
const label = typeof formatDashboardDate === 'function'
? formatDashboardDate(value)
: String(value);
const label = formatDateInTimeZone(value, timeZone) || String(value);
return label || 'No entries';
}
module.exports = function renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) {
module.exports = function renderTimetableGroupsPage(data, message, currentUser) {
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
const timeZone = normalizeTimeZone(timetableGroup.timezone, 'Europe/London');
return Object.assign({}, timetableGroup, {
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate),
timeZone: timeZone,
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, timeZone),
nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : ''
});
});
@@ -42,7 +42,7 @@ async function getDataSourceUsageMaps(pool, common) {
return timetableGroupIds;
}
function readScheduleArrayFieldValue(body, keys) {
function readTimetableArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
@@ -57,17 +57,17 @@ function readScheduleArrayFieldValue(body, keys) {
return [];
}
function buildScheduleEntryRows(req, parseDateTimeLocal) {
function buildTimetableEntryRows(req, parseDateTimeLocal) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const ids = readTimetableArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readTimetableArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readTimetableArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readTimetableArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readTimetableArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
const error = new Error('Timetable entry data is invalid.');
error.statusCode = 400;
throw error;
}
@@ -86,19 +86,19 @@ function buildScheduleEntryRows(req, parseDateTimeLocal) {
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
const error = new Error('Each timetable entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
const error = new Error('Each timetable entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
const error = new Error('Timetable entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
@@ -191,7 +191,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name);
let duplicateIndex = 2;
while (await common.fetchDuplicateName(pool, 'i_schedule_groups', duplicateName)) {
while (await common.fetchDuplicateName(pool, 'i_timetable_groups', duplicateName)) {
duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name) + ' (' + duplicateIndex + ')';
duplicateIndex += 1;
}
@@ -207,16 +207,16 @@ module.exports = function registerTimetableRoutes(app, deps) {
const connection = await pool.getConnection();
try {
const payload = common.buildTimetableGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
if (await common.fetchDuplicateName(pool, 'i_timetable_groups', payload.name)) {
return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
const entryRows = buildTimetableEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
'INSERT INTO i_timetable_groups (name, short_description, timezone, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, payload.timezone, actorId, actorId]
);
if (entryRows.length) {
@@ -233,7 +233,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
'INSERT INTO i_timetable_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
@@ -265,18 +265,18 @@ module.exports = function registerTimetableRoutes(app, deps) {
}
const payload = common.buildTimetableGroupPayload(req, timetableGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) {
if (await common.fetchDuplicateName(pool, 'i_timetable_groups', payload.name, timetableGroup.id)) {
return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
const entryRows = buildTimetableEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, timetableGroup.id]
'UPDATE i_timetable_groups SET name = ?, short_description = ?, timezone = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, payload.timezone, actorId, timetableGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]);
await connection.query('DELETE FROM i_timetable_entries WHERE schedule_group_id = ?', [timetableGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
@@ -292,7 +292,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
'INSERT INTO i_timetable_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
@@ -330,7 +330,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]);
await connection.query('DELETE FROM i_timetable_groups WHERE id = ?', [timetableGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
@@ -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`;
@@ -167,6 +175,31 @@ function buildTaskPlayerLabel(metadata) {
return '';
}
function buildTaskDescription(task, metadata) {
const playerLabel = buildTaskPlayerLabel(metadata);
const taskKey = String(task && task.key || '').trim();
let displayKey = taskKey;
if (playerLabel && displayKey) {
const playerPrefix = `${playerLabel}:`;
const playerSuffix = `:${playerLabel}`;
if (displayKey.startsWith(playerPrefix)) {
displayKey = displayKey.slice(playerPrefix.length);
}
if (displayKey.endsWith(playerSuffix)) {
displayKey = displayKey.slice(0, -playerSuffix.length);
}
if (displayKey === playerLabel) {
displayKey = '';
}
}
return [playerLabel, displayKey].filter(Boolean).join(':');
}
function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
@@ -262,7 +295,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
const visibleTasksWithSources = visibleTasks.map(function (task) {
const playerLabel = buildTaskPlayerLabel(task.metadata);
const taskDescription = [playerLabel, String(task && task.key || '').trim()].filter(Boolean).join(':');
const taskDescription = buildTaskDescription(task, task.metadata);
return Object.assign({}, task, {
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
@@ -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">
+21 -9
View File
@@ -10,15 +10,29 @@
<div class="card-header">
<h3 class="card-title">Timetable group details</h3>
</div>
<div class="card-body d-grid gap-4">
<div class="card-body d-grid gap-4 pb-0">
<div class="row g-3">
<div class="col-12 col-lg-4">
<label for="timetable-group-name" class="form-label">Name</label>
<input id="timetable-group-name" name="name" class="form-control" value="{{timetableGroup.name}}" maxlength="128" data-limit-text-length required />
</div>
<div class="col-12 col-lg-8">
<label for="timetable-group-short-description" class="form-label">Short description</label>
<input id="timetable-group-short-description" name="short_description" class="form-control" value="{{timetableGroup.shortDescription}}" maxlength="255" data-limit-text-length placeholder="Morning events" />
<div class="row g-3">
<div class="col-12 col-lg-7">
<label for="timetable-group-short-description" class="form-label">Short description</label>
<input id="timetable-group-short-description" name="short_description" class="form-control" value="{{timetableGroup.shortDescription}}" maxlength="255" data-limit-text-length placeholder="Morning events" />
</div>
<div class="col-12 col-lg-5">
<label for="timetable-group-timezone" class="form-label">Time zone</label>
<input id="timetable-group-timezone" name="timezone" class="form-control" value="{{timetableGroup.timezone}}" list="timetable-group-timezones" placeholder="Europe/Berlin" autocomplete="off" required />
<datalist id="timetable-group-timezones">
{{#each timeZoneOptions}}
<option value="{{this}}"></option>
{{/each}}
</datalist>
<div class="form-text">Entries are stored and displayed using this IANA time zone.</div>
</div>
</div>
</div>
</div>
</div>
@@ -32,9 +46,9 @@
<h3 class="card-title mb-0 flex-grow-1">Entries</h3>
<button type="button" class="btn btn-primary btn-sm ms-auto" data-add-timetable-entry>Add entry</button>
</div>
<div class="card-body d-grid">
<div class="card-body p-0 timetable-entries-table-shell">
<div class="table-responsive">
<table class="table table-bordered align-middle schedule-entries-table mb-0">
<table class="table table-bordered align-middle timetable-entries-table mb-0">
<thead>
<tr>
<th style="width: 18rem;">Title</th>
@@ -59,10 +73,9 @@
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
<div class="invalid-feedback">End time must be at least 1 minute after the start time.</div>
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
<button type="button" class="btn btn-danger btn-sm" data-remove-timetable-entry>Remove</button>
</td>
</tr>
{{/each}}
@@ -87,10 +100,9 @@
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" />
<div class="invalid-feedback">End time must be at least 1 minute after the start time.</div>
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
<button type="button" class="btn btn-danger btn-sm" data-remove-timetable-entry>Remove</button>
</td>
</tr>
</template>
@@ -24,6 +24,7 @@
<tr>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="description">Description</th>
<th data-table-sort-key="timezone">Time zone</th>
<th data-table-sort-key="entries">Entries</th>
<th data-table-sort-key="next_start">Next start</th>
<th>Actions</th>
@@ -35,10 +36,11 @@
<tr data-table-search-row>
<td data-label="Name">{{name}}</td>
<td data-label="Description" class="text-break">{{short_description}}</td>
<td data-label="Time zone">{{timeZone}}</td>
<td data-label="Entries">{{entry_count}}</td>
<td data-label="Next start">
{{#if nextStartValue}}
<time data-local-datetime datetime="{{nextStartValue}}">{{nextStartLabel}}</time>
<time datetime="{{nextStartValue}}">{{nextStartLabel}}</time>
{{else}}
{{nextStartLabel}}
{{/if}}
@@ -69,7 +71,7 @@
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="5" class="empty">No timetable groups yet.</td></tr>
<tr data-table-search-empty-default><td colspan="6" class="empty">No timetable groups yet.</td></tr>
{{/if}}
</tbody>
</table>
+6 -5
View File
@@ -43,8 +43,8 @@
</section>
{{#if (hasPermission currentUser "dashboard.allow")}}
<div class="row pb-4">
<div class="col-12 col-xl-8">
<div class="row">
<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">
@@ -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">
<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>
@@ -144,7 +144,8 @@
<li>The script looks for a supported browser on the device and launches the first one it finds.</li>
<li>The launcher opens the correct player page automatically, so no manual URL entry is needed.</li>
<li>It uses a kiosk-mode browser window, so the screen stays focused on signage instead of normal browsing.</li>
<li>Exit with Alt+F4 on Windows or Linux. On Linux, that is usually the standard close-window shortcut too.</li>
<li>If a browser is already open or open in the background, the launcher will bring it to the front, but not enter kiosk mode.</li>
<li>Exit with Alt+F4 on Windows or Linux.</li>
</ul>
<div class="mt-3 pt-3 border-top">
<p class="mb-0 text-muted small">These downloads are scripts, not applications: the Windows download is a .bat file and the Linux download is a .sh file.</p>
+1 -1
View File
@@ -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>
+7
View File
@@ -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')"));
});
+54
View File
@@ -23,6 +23,18 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
playerLabel: 'Player Alpha'
}
},
{
title: 'Media sync task',
key: 'media-sync:slide:update:2:lzstealthcom',
category: 'Refresh',
status: 'completed',
createdAt: '2026-08-04T11:15:00.000Z',
startedAt: '2026-08-04T11:16:00.000Z',
finishedAt: '2026-08-04T11:17:00.000Z',
metadata: {
playerLabel: 'lzstealthcom'
}
},
{
title: 'Older task',
key: 'older-key',
@@ -73,8 +85,50 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/);
assert.match(queueHtml, /data-local-datetime-format="24h"/);
assert.match(queueHtml, /Player Alpha:secret-key/);
assert.match(queueHtml, /lzstealthcom:media-sync:slide:update:2/);
assert.doesNotMatch(queueHtml, /lzstealthcom:media-sync:slide:update:2:lzstealthcom/);
assert.doesNotMatch(queueKeySearchHtml, /Example task/);
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'));
});
+124
View File
@@ -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.');
});
+8 -8
View File
@@ -17,7 +17,7 @@ function createHandlers() {
return { app, handlers };
}
test('clients list defaults to client then ip ordering', async () => {
test('clients list defaults to client ordering', async () => {
const { app, handlers } = createHandlers();
registerClientsRoutes(app, {
@@ -38,7 +38,7 @@ test('clients list defaults to client then ip ordering', async () => {
return JSON.stringify(data.clients.map(function (client) {
return {
client_name: client.client_name,
clientIp: client.clientIp
clientId: client.clientId
};
}));
}
@@ -46,9 +46,9 @@ test('clients list defaults to client then ip ordering', async () => {
buildDashboardState: async () => ({
screens: [],
clients: [
{ client_name: 'Beta', clientIp: '10.0.0.9' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Alpha', clientIp: '10.0.0.2' }
{ client_name: 'Beta', clientId: 'beta-1' },
{ client_name: 'Alpha', clientId: 'alpha-1' },
{ client_name: 'Alpha', clientId: 'alpha-2' }
]
}),
requirePermission() {
@@ -76,9 +76,9 @@ test('clients list defaults to client then ip ordering', async () => {
}, response, () => {});
assert.deepEqual(JSON.parse(response.body), [
{ client_name: 'Alpha', clientIp: '10.0.0.2' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Beta', clientIp: '10.0.0.9' }
{ client_name: 'Alpha', clientId: 'alpha-1' },
{ client_name: 'Alpha', clientId: 'alpha-2' },
{ client_name: 'Beta', clientId: 'beta-1' }
]);
});
+46
View File
@@ -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>/);
});
+180
View File
@@ -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, []);
});
+127
View File
@@ -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,
+41
View File
@@ -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');
});
+10 -2
View File
@@ -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"));
});
+65
View File
@@ -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>/);
});
+77
View File
@@ -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);
});
+50
View File
@@ -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)'));
});
+43 -21
View File
@@ -97,26 +97,9 @@ function createRow(startValue, endValue) {
return row;
}
function formatDateTimeLocalValue(date) {
function pad(value) {
return String(value).padStart(2, '0');
}
return [
String(date.getFullYear()).padStart(4, '0'),
'-',
pad(date.getMonth() + 1),
'-',
pad(date.getDate()),
'T',
pad(date.getHours()),
':',
pad(date.getMinutes())
].join('');
}
test('timetable group form highlights an end time that is too early', () => {
const row = createRow('2026-08-07T10:00', '2026-08-07T10:00');
const row = createRow('2026-08-07T12:00', '2026-08-07T12:00');
const timezoneInput = createInput('timezone', 'Europe/Berlin');
const body = {
rows: [row],
querySelectorAll(selector) {
@@ -140,7 +123,10 @@ test('timetable group form highlights an end time that is too early', () => {
};
const form = {
dataset: {},
addEventListener() {}
listeners: Object.create(null),
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const templateRow = createRow('', '');
const template = {
@@ -161,6 +147,9 @@ test('timetable group form highlights an end time that is too early', () => {
if (id === 'timetable-group-form') {
return form;
}
if (id === 'timetable-group-timezone') {
return timezoneInput;
}
if (id === 'timetable-entry-row-template') {
return template;
}
@@ -194,14 +183,47 @@ test('timetable group form highlights an end time that is too early', () => {
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(row.startInput.value, '2026-08-07T12:00');
assert.equal(row.endInput.value, '2026-08-07T12:00');
assert.equal(row.endInput.validityMessage, 'End time must be at least 1 minute after the start time.');
assert.equal(row.endInput.classList.contains('is-invalid'), true);
assert.equal(row.endInput.attributes['aria-invalid'], 'true');
row.endInput.value = formatDateTimeLocalValue(new Date(new Date(row.startInput.value).getTime() + 60000));
row.endInput.value = '2026-08-07T12:01';
row.endInput.dispatchEvent({ type: 'input' });
assert.equal(row.endInput.validityMessage, '');
assert.equal(row.endInput.classList.contains('is-invalid'), false);
assert.equal(Object.prototype.hasOwnProperty.call(row.endInput.attributes, 'aria-invalid'), false);
timezoneInput.value = 'Europe/London';
timezoneInput.dispatchEvent({ type: 'change' });
assert.equal(row.startInput.value, '2026-08-07T12:00');
assert.equal(row.endInput.value, '2026-08-07T12:01');
assert.equal(form.dataset.timetableTimezone, 'Europe/London');
assert.equal(form.dataset.dirty, 'true');
const formData = {
deleted: [],
appended: [],
delete(name) {
this.deleted.push(name);
},
append(name, value) {
this.appended.push([name, value]);
}
};
form.listeners.formdata({ formData: formData });
const startEntry = formData.appended.find(function (item) {
return item[0] === 'entry_start_datetime[]';
});
const endEntry = formData.appended.find(function (item) {
return item[0] === 'entry_end_datetime[]';
});
assert.equal(startEntry[1], '2026-08-07T11:00:00.000Z');
assert.equal(endEntry[1], '2026-08-07T11:01:00.000Z');
});
+145
View File
@@ -0,0 +1,145 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { runMigrations } = 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('timetable rename migration does not depend on legacy timezone column', 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) {
return sql.includes('FROM information_schema.TABLES') && sql.includes('TABLE_NAME = ?');
},
result: [[{ table_count: 1 }]]
},
{
match(sql) {
return sql.includes('SELECT id, name, short_description,') && sql.includes("'Europe/London' AS timezone") && sql.includes('FROM i_schedule_groups');
},
result: [{}]
},
{
match(sql) {
return sql.includes('SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by FROM i_schedule_entries');
},
result: [{}]
},
{
match(sql) {
return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups');
},
result: [[{ max_id: 1 }]]
},
{
match(sql) {
return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries');
},
result: [[{ max_id: 2 }]]
}
]);
await runMigrations(pool, { currentVersion: '2.6.17' });
assert.ok(pool.queries.some(function ([sql]) {
return String(sql).includes("SELECT id, name, short_description, 'Europe/London' AS timezone") && String(sql).includes('FROM i_schedule_groups');
}));
assert.ok(!pool.queries.some(function ([sql]) {
return String(sql).includes('SELECT id, name, short_description, timezone, created_at, created_by, modified_at, modified_by') && String(sql).includes('FROM i_schedule_groups');
}));
});
test('timetable timezone migrations use timetable tables when legacy tables are gone', 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_timetable_groups';
},
result: [[{ table_count: 1 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_timetable_entries';
},
result: [[{ table_count: 1 }]]
},
{
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 }]]
},
{
match(sql) {
return sql.includes('SELECT id, start_datetime, end_datetime FROM i_timetable_entries ORDER BY id ASC');
},
result: [[{ id: 1, start_datetime: '2026-08-09 09:00:00', end_datetime: '2026-08-09 10:00:00' }]]
},
{
match(sql) {
return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups');
},
result: [[{ max_id: 1 }]]
},
{
match(sql) {
return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries');
},
result: [[{ max_id: 1 }]]
}
]);
await runMigrations(pool, { currentVersion: '2.6.15' });
assert.ok(pool.queries.some(function ([sql]) {
return String(sql).includes('ALTER TABLE i_timetable_groups ADD COLUMN timezone');
}));
assert.ok(pool.queries.some(function ([sql]) {
return String(sql).includes('UPDATE i_timetable_groups SET timezone = ?');
}));
assert.ok(pool.queries.some(function ([sql]) {
return String(sql).includes('UPDATE i_timetable_entries SET start_datetime = ?, end_datetime = ? WHERE id = ?');
}));
assert.ok(!pool.queries.some(function ([sql]) {
return String(sql).includes('i_schedule_groups') || String(sql).includes('i_schedule_entries');
}));
});
+295
View File
@@ -0,0 +1,295 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
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/timetable.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const registry = new Map();
const customWindow = overrides && overrides.window ? overrides.window : {};
const sandbox = {
document: {
addEventListener() {}
},
window: {
pulseRegionTypes: {
register(type, module) {
registry.set(type, module);
}
},
pulseRegionUtils: {
escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
sanitizeRichText(value) {
return String(value === undefined || value === null ? '' : value);
}
},
placeholderChips: {
renderChips(tokens) {
return tokens.map((token) => '<span class="chip">{{' + token + '}}</span>').join(' ');
}
},
initialData: {
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: [
{
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-09T10:00:00.000Z',
end_datetime: '2026-08-09T11:00:00.000Z'
}
]
}
]
},
Intl: Intl,
Date: Date,
Object: Object,
Array: Array,
Number: Number,
String: String,
Boolean: Boolean,
Math: Math,
JSON: JSON,
RegExp: RegExp,
console: console
},
...customWindow
};
sandbox.window = Object.assign({}, sandbox.window, customWindow);
vm.runInNewContext(timeDatePlaceholdersScript, sandbox, { filename: 'time-date-placeholders.js' });
vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' });
if (customWindow.placeholderUtils) {
sandbox.window.placeholderUtils = customWindow.placeholderUtils;
}
vm.runInNewContext(script, sandbox, { filename: scriptPath });
return registry.get('timetable');
}
test('timetable region editor lists timezone transforms in helper text', () => {
const module = loadScheduleModule();
const html = module.renderEditorCard({
region: { id: 47, label: 'Timetable' },
current: {
value: '<p>{{title}} {{start.tz()}} {{start.tz_long()}}</p>',
timetable_group_id: 1,
display_mode: 'current',
max_items: 3
},
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: []
}
]
});
assert.match(html, /Placeholder values support transforms/);
assert.match(html, /<code>\{\{start\.tz\(\)\}\}<\/code>/);
assert.match(html, /<code>\{\{start\.tz_long\(\)\}\}<\/code>/);
assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/);
});
test('timetable region editor shows supported date format tokens', () => {
const module = loadScheduleModule();
const html = module.renderEditorCard({
region: { id: 47, label: 'Timetable' },
current: {
value: '<p>{{start.format("MMM D, YYYY h:mm A")}}</p>',
timetable_group_id: 1,
display_mode: 'current',
max_items: 3
},
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: []
}
]
});
assert.match(html, /Supported date format tokens/);
assert.match(html, /<th scope="col">Format<\/th>/);
assert.ok(html.indexOf('<td><code>D</code></td><td>1-31</td><td>The day of the month</td>') < html.indexOf('<td><code>ddd</code></td><td>Mon</td><td>The abbreviated weekday name</td>'));
assert.ok(html.indexOf('<td><code>ddd</code></td><td>Mon</td><td>The abbreviated weekday name</td>') < html.indexOf('<td><code>M</code></td><td>1-12</td><td>The month, beginning at 1</td>'));
assert.ok(html.indexOf('<td><code>YY</code></td><td>18</td><td>The two-digit year</td>') < html.indexOf('<td><code>YYYY</code></td><td>2018</td><td>The four-digit year</td>'));
assert.doesNotMatch(html, /<td><code>y<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>yyyy<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>yy<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>tz<\/code><\/td>/);
assert.match(html, /start\.tz_long\(\)/);
assert.match(html, /Use these tokens inside <code>\.format\(\.\.\.\)<\/code>/);
});
test('timetable region preview resolves timezone placeholders from explicit timezone transforms', () => {
const module = loadScheduleModule();
const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 },
{
value: '<p>{{start.tz("UTC")}} {{start.tz_long("UTC")}}</p>',
timetable_group_id: 1,
display_mode: 'both',
max_items: 3
},
{
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: [
{
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-20T10:00:00.000Z',
end_datetime: '2026-08-20T11:00:00.000Z'
}
]
}
]
}
);
assert.match(preview, /UTC/);
});
test('timetable placeholder format transform respects uppercase and lowercase day period tokens', () => {
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
const sandbox = {
window: {},
Intl: Intl,
Date: Date,
Object: Object,
Array: Array,
Number: Number,
String: String,
Boolean: Boolean,
Math: Math,
JSON: JSON,
RegExp: RegExp,
console: console
};
vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' });
const upper = sandbox.window.placeholderUtils.resolvePlaceholderExpression(
{ start: '2026-08-09T13:05:00.000Z' },
'start.format("MMM D, YYYY h:mm A")'
);
const lower = sandbox.window.placeholderUtils.resolvePlaceholderExpression(
{ start: '2026-08-09T13:05:00.000Z' },
'start.format("MMM D, YYYY h:mm a")'
);
assert.match(String(upper), /PM$/);
assert.match(String(lower), /pm$/);
});
test('timetable timezone abbreviation changes across daylight saving boundaries', () => {
const module = loadScheduleModule();
const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 },
{
value: '<p>{{start.tz("Europe/London")}}</p>',
timetable_group_id: 1,
display_mode: 'both',
max_items: 10
},
{
timetableGroups: [
{
id: 1,
name: 'London board',
timezone: 'Europe/London',
entries: [
{
id: 201,
title: 'Before DST ends',
short_description: '',
start_datetime: '2026-10-24T12:00:00.000Z',
end_datetime: '2026-10-24T13:00:00.000Z'
},
{
id: 202,
title: 'After DST ends',
short_description: '',
start_datetime: '2026-10-26T12:00:00.000Z',
end_datetime: '2026-10-26T13:00:00.000Z'
}
]
}
]
}
);
assert.match(preview, /BST/);
assert.match(preview, /(GMT|UTC)/);
});
test('timetable preview does not force the group timezone into placeholder transforms', () => {
const calls = [];
const module = loadScheduleModule({
window: {
placeholderUtils: {
resolvePlaceholderExpression(value, expression, options) {
calls.push({ expression, options: options || {} });
return options && options.timeZone ? options.timeZone : 'local';
},
formatPlaceholderValue(value) {
return String(value === undefined || value === null ? '' : value);
}
}
}
});
const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 },
{
value: '<p>{{start.tz_long()}}</p>',
timetable_group_id: 1,
display_mode: 'both',
max_items: 3
},
{
timetableGroups: [
{
id: 1,
name: 'Madrid board',
timezone: 'Europe/Madrid',
entries: [
{
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-20T10:00:00.000Z',
end_datetime: '2026-08-20T11:00:00.000Z'
}
]
}
]
}
);
assert.match(preview, /local/);
assert.equal(calls.length > 0 ? Boolean(calls[0].options && calls[0].options.timeZone) : false, false);
});
+100
View File
@@ -0,0 +1,100 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildSlidePayload } = require('../src/data/slides');
test('buildSlidePayload normalizes timetable region fields', 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: 'timetable', region_type: 'timetable', label: 'Timetable' }]];
}
return [[]];
}
};
const payload = await buildSlidePayload(pool, {
body: {
title: 'Timetable slide',
template_id: '9',
region_text_47: '<p>{{title}}</p>',
region_timetable_group_id_47: '1',
region_timetable_display_mode_47: 'current',
region_timetable_max_items_47: '7'
},
files: []
}, null);
const content = JSON.parse(payload.contentJson);
assert.equal(content.timetable.type, 'timetable');
assert.equal(content.timetable.text, '<p>{{title}}</p>');
assert.equal(content.timetable.value, '<p>{{title}}</p>');
assert.equal(content.timetable.timetable_group_id, '1');
assert.equal(content.timetable.display_mode, 'current');
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>');
});
+5 -2
View File
@@ -14,7 +14,8 @@ test('timetable duplicate helper copies group and entry fields', () => {
const group = {
id: 5,
name: 'Morning Shift',
short_description: 'Weekday events'
short_description: 'Weekday events',
timezone: 'Europe/Berlin'
};
const entry = {
id: 12,
@@ -32,6 +33,7 @@ test('timetable duplicate helper copies group and entry fields', () => {
assert.equal(duplicateGroup.name, 'Copy of Morning Shift');
assert.equal(duplicateGroup.short_description, 'Weekday events');
assert.equal(duplicateGroup.shortDescription, 'Weekday events');
assert.equal(duplicateGroup.timezone, 'Europe/Berlin');
assert.equal(duplicateEntry.id, null);
assert.equal(duplicateEntry.schedule_group_id, null);
assert.equal(duplicateEntry.title, 'Opening');
@@ -42,7 +44,7 @@ test('timetable duplicate helper copies group and entry fields', () => {
test('timetable duplicate form model keeps info toast variant and secondary actions', () => {
const model = buildTimetableGroupFormViewModel(
{ id: null, name: 'Morning Shift', shortDescription: 'Weekday events' },
{ id: null, name: 'Morning Shift', shortDescription: 'Weekday events', timezone: 'Europe/Berlin' },
[],
'Review the copied values and save when ready.',
{ permissionKeys: ['timetables.create'] },
@@ -52,6 +54,7 @@ test('timetable duplicate form model keeps info toast variant and secondary acti
assert.equal(model.messageVariant, 'info');
assert.equal(model.showSaveSecondaryActions, true);
assert.equal(model.timetableGroup.timezone, 'Europe/Berlin');
});
test('timetable list template includes duplicate action', () => {
+155
View File
@@ -0,0 +1,155 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerTimetableRoutes = require('../src/web/routes/data-sources/timetables/routes');
function createApp() {
const handlers = {};
return {
handlers: handlers,
app: {
get(path, ...routeHandlers) {
handlers['GET ' + path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers['POST ' + path] = routeHandlers;
}
}
};
}
function createConnection() {
const queries = [];
return {
queries: queries,
async beginTransaction() {
queries.push(['beginTransaction']);
},
async query(sql, params) {
queries.push([sql, params]);
if (String(sql).indexOf('INSERT INTO i_timetable_groups') !== -1) {
return [{ insertId: 42 }];
}
return [{}];
},
async commit() {
queries.push(['commit']);
},
async rollback() {
queries.push(['rollback']);
},
release() {
queries.push(['release']);
}
};
}
test('timetable create and update persist the selected timezone', async () => {
const connection = createConnection();
const { app, handlers } = createApp();
registerTimetableRoutes(app, {
pool: {
getConnection() {
return connection;
}
},
common: {
fetchTimetableGroupById: async function () {
return {
id: 7,
name: 'Existing',
short_description: 'Old description',
timezone: 'Europe/London'
};
},
fetchDuplicateName: async function () {
return false;
},
buildTimetableGroupPayload: function (req) {
return {
name: req.body.name,
shortDescription: req.body.short_description,
timezone: req.body.timezone
};
},
fetchTimetableEntriesByGroupId: async function () {
return [];
}
},
getAuditUserId: function () {
return 9;
},
redirectAfterSave: function (_req, res, url) {
res.redirect(url);
},
parseDateTimeLocal: function (value) {
return value ? new Date(value) : null;
},
requirePermission: function () {
return function (_req, _res, next) {
next();
};
}
});
const createRoute = handlers['POST /data-sources/timetables'][1];
const updateRoute = handlers['POST /data-sources/timetables/:id'][1];
const createResponse = {
redirects: [],
redirect(url) {
this.redirects.push(url);
},
status() {
return this;
}
};
await createRoute({
body: {
name: 'Morning',
short_description: 'Breakfast block',
timezone: 'Europe/Berlin'
},
currentUser: { id: 1 },
query: {}
}, createResponse, function () {});
assert.deepEqual(connection.queries[1], [
'INSERT INTO i_timetable_groups (name, short_description, timezone, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
['Morning', 'Breakfast block', 'Europe/Berlin', 9, 9]
]);
assert.equal(createResponse.redirects[0], '/data-sources/timetables/42/edit');
connection.queries.length = 0;
const updateResponse = {
redirects: [],
redirect(url) {
this.redirects.push(url);
},
status() {
return this;
}
};
await updateRoute({
params: { id: '7' },
body: {
name: 'Morning',
short_description: 'Breakfast block',
timezone: 'Europe/Berlin'
},
currentUser: { id: 1 },
query: {}
}, updateResponse, function () {});
assert.deepEqual(connection.queries[1], [
'UPDATE i_timetable_groups SET name = ?, short_description = ?, timezone = ?, modified_by = ? WHERE id = ?',
['Morning', 'Breakfast block', 'Europe/Berlin', 9, 7]
]);
assert.equal(updateResponse.redirects[0], '/data-sources/timetables/7/edit');
});
+186
View File
@@ -274,4 +274,190 @@ test('stale player registrations stop media sync retries and warnings', async ()
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('slide update sync removes uploads that were removed from the slide on the player', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-slide-remove-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep'));
fs.writeFileSync(path.join(uploadDir, 'uploads', 'remove.bin'), Buffer.from('remove'));
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, method: init && init.method ? init.method : 'GET' });
return {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get() {
return null;
}
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const uploadSyncService = createUploadSyncService({
common: {
async fetchAdminData() {
return {
slides: [
{
content_json: JSON.stringify({
imageRegion: {
type: 'image',
value: '/media/uploads/keep.bin'
}
})
}
],
templates: []
};
}
},
pool: {
async query(sql, params) {
if (String(sql).includes('COUNT(*) AS ref_count')) {
return [[{
ref_count: params && params[0] === '/media/uploads/keep.bin' ? 1 : 0
}]];
}
return [[
{
identifier: 'player-one',
internal_base_url: 'http://player-one:8081',
last_seen_at: liveLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: {
async enqueueTaskAndWait(definition) {
await uploadSyncService.runMediaSyncTask(definition.payload);
return definition;
}
}
});
try {
await uploadSyncService.syncPlaylistUploadsOnChange({
key: 'slide:update:123',
pool: {},
localUploadDir: uploadDir,
previousUploadRefs: ['/media/uploads/keep.bin', '/media/uploads/remove.bin'],
nextUploadRefs: ['/media/uploads/keep.bin']
});
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'keep.bin')), true);
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'remove.bin')), false);
assert.equal(fetchCalls.filter(function (call) {
return call.method === 'PUT';
}).length, 1);
assert.equal(fetchCalls.filter(function (call) {
return call.method === 'DELETE';
}).length, 1);
assert.ok(fetchCalls.some(function (call) {
return call.method === 'DELETE' && call.url.includes('remove.bin');
}));
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('slide update sync queues one media task per live player and targets each player base url', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-multi-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const fetchCalls = [];
const queuedTasks = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get() {
return null;
}
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
let uploadSyncService;
uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-one',
internal_base_url: 'http://player-one:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-two',
internal_base_url: 'http://player-two:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-stale',
internal_base_url: 'http://player-stale:8081',
last_seen_at: staleLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: {
async enqueueTaskAndWait(definition) {
queuedTasks.push(definition);
await uploadSyncService.runMediaSyncTask(definition.payload);
return definition;
}
}
});
try {
await uploadSyncService.syncPlaylistUploadsOnChange({
key: 'slide:update:123',
pool: {},
localUploadDir: uploadDir,
nextUploadRefs: ['/media/uploads/sample.bin']
});
assert.equal(queuedTasks.length, 2);
assert.deepEqual(queuedTasks.map(function (task) {
return task.metadata.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.ok(queuedTasks.every(function (task) {
return task.key.startsWith('media-sync:slide:update:123:');
}));
assert.deepEqual(fetchCalls.map(function (call) {
return call.url;
}).sort(), [
'http://player-one:8081/api/media/uploads%2Fsample.bin?deviceId=player-one',
'http://player-two:8081/api/media/uploads%2Fsample.bin?deviceId=player-two'
].sort());
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
+3 -28
View File
@@ -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' }
]);
});
+20 -1
View File
@@ -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 () => {