Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9425fc640 | ||
|
|
4491c15215 | ||
|
|
08b06941a4 | ||
|
|
c0c05f76e3 | ||
|
|
78a34e4105 | ||
|
|
3c3864e6ac | ||
|
|
6d8bf0f5f0 | ||
|
|
c36b9122c9 | ||
|
|
39f2098ffe |
@@ -3,6 +3,7 @@
|
|||||||
## Versioning and releases
|
## Versioning and releases
|
||||||
|
|
||||||
- Treat `package.json` as the source of truth for the application version.
|
- 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.
|
- When the app version changes, update `CHANGELOG.md` in the same change.
|
||||||
- Keep database migration versions aligned with the release they actually belong to.
|
- 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.
|
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
|
||||||
|
|||||||
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.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
|
## 2.6.19 - 2026-08-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.6.19",
|
"version": "2.6.25",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.6.19",
|
"version": "2.6.25",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
+20
-10
@@ -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_feeds` - RSS feed definitions and refresh cadence.
|
||||||
- `i_rss_feed_items` - cached RSS feed items.
|
- `i_rss_feed_items` - cached RSS feed items.
|
||||||
- `i_api_sources` - API source definitions and last response snapshot.
|
- `i_api_sources` - API source definitions and last response snapshot.
|
||||||
- `i_schedule_groups` - grouped schedule definitions used by the schedule region.
|
- `i_timetable_groups` - grouped timetable definitions used by the timetable region.
|
||||||
- `i_schedule_entries` - dated entries that belong to a schedule group.
|
- `i_timetable_entries` - dated entries that belong to a timetable group.
|
||||||
|
|
||||||
### `i_rss_feeds`
|
### `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`
|
- `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`
|
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||||
- Foreign key:
|
- 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:
|
- Index:
|
||||||
- `(schedule_group_id, start_datetime)`
|
- `(schedule_group_id, start_datetime)`
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
- `o_background_tasks` - queue and history for background jobs.
|
- `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`
|
### `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`
|
- `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`.
|
- 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
|
## Notes
|
||||||
|
|
||||||
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
|
- 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.
|
- `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
|
```mermaid
|
||||||
erDiagram
|
erDiagram
|
||||||
@@ -255,9 +263,11 @@ erDiagram
|
|||||||
}
|
}
|
||||||
I_API_SOURCES {
|
I_API_SOURCES {
|
||||||
}
|
}
|
||||||
I_SCHEDULE_GROUPS {
|
I_TIMETABLE_GROUPS {
|
||||||
}
|
}
|
||||||
I_SCHEDULE_ENTRIES {
|
I_TIMETABLE_ENTRIES {
|
||||||
|
}
|
||||||
|
O_APP_STATE {
|
||||||
}
|
}
|
||||||
O_BACKGROUND_TASKS {
|
O_BACKGROUND_TASKS {
|
||||||
}
|
}
|
||||||
@@ -282,5 +292,5 @@ erDiagram
|
|||||||
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
|
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
|
||||||
|
|
||||||
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
|
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
|
||||||
```
|
```
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.19",
|
"version": "2.6.25",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+11
-2
@@ -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.
|
// Snapshot only: keep this file aligned with the current schema state.
|
||||||
async function ensureSchema(pool, options) {
|
async function ensureSchema(pool, options) {
|
||||||
const schemaLockName = 'pulse_signage_schema_lock';
|
const schemaLockName = 'pulse_signage_schema_lock';
|
||||||
@@ -12,6 +15,12 @@ async function ensureSchema(pool, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -384,8 +393,8 @@ async function ensureSchema(pool, options) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const { runMigrations } = require('./migrations');
|
await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion }));
|
||||||
await runMigrations(pool, options);
|
await recordSchemaVersion(pool, appVersion);
|
||||||
} finally {
|
} finally {
|
||||||
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
|
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
|
||||||
});
|
});
|
||||||
|
|||||||
+88
-13
@@ -1,5 +1,7 @@
|
|||||||
const { version: appVersion } = require('#root/package.json');
|
const { version: appVersion } = require('#root/package.json');
|
||||||
const TIMETABLE_TIME_ZONE = 'Europe/London';
|
const TIMETABLE_TIME_ZONE = 'Europe/London';
|
||||||
|
const APP_STATE_TABLE = 'o_app_state';
|
||||||
|
const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version';
|
||||||
|
|
||||||
const VERSIONED_MIGRATIONS = [
|
const VERSIONED_MIGRATIONS = [
|
||||||
{
|
{
|
||||||
@@ -301,23 +303,42 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
version: '2.6.16',
|
version: '2.6.16',
|
||||||
label: 'v2.6.16 timetable timezone schema',
|
label: 'v2.6.16 timetable timezone schema',
|
||||||
run: async function (pool) {
|
run: async function (pool) {
|
||||||
await ensureColumn(pool, 'i_schedule_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
|
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',
|
version: '2.6.17',
|
||||||
label: 'v2.6.17 timetable europe/london conversion',
|
label: 'v2.6.17 timetable europe/london conversion',
|
||||||
run: async function (pool) {
|
run: async function (pool) {
|
||||||
await ensureColumn(pool, 'i_schedule_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
|
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;
|
||||||
|
|
||||||
await pool.query('UPDATE i_schedule_groups SET timezone = ?', [TIMETABLE_TIME_ZONE]);
|
if (!groupTableName || !entryTableName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [rows] = await pool.query('SELECT id, start_datetime, end_datetime FROM i_schedule_entries ORDER BY id ASC');
|
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) {
|
for (const row of rows) {
|
||||||
const startDate = convertMigrationDateTimeFromTimeZone(row.start_datetime, TIMETABLE_TIME_ZONE);
|
const startDate = convertMigrationDateTimeFromTimeZone(row.start_datetime, TIMETABLE_TIME_ZONE);
|
||||||
const endDate = row.end_datetime ? convertMigrationDateTimeFromTimeZone(row.end_datetime, TIMETABLE_TIME_ZONE) : null;
|
const endDate = row.end_datetime ? convertMigrationDateTimeFromTimeZone(row.end_datetime, TIMETABLE_TIME_ZONE) : null;
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE i_schedule_entries SET start_datetime = ?, end_datetime = ? WHERE id = ?',
|
'UPDATE ' + entryTableName + ' SET start_datetime = ?, end_datetime = ? WHERE id = ?',
|
||||||
[formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id]
|
[formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -333,12 +354,15 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
if (scheduleGroupsExists) {
|
if (scheduleGroupsExists) {
|
||||||
if (!timetableGroupsExists) {
|
if (!timetableGroupsExists) {
|
||||||
await pool.query('RENAME TABLE i_schedule_groups TO i_timetable_groups, i_schedule_entries TO i_timetable_entries');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT IGNORE INTO i_timetable_groups (id, name, short_description, timezone, created_at, created_by, modified_at, modified_by)
|
INSERT IGNORE INTO i_timetable_groups (id, name, short_description, timezone, created_at, created_by, modified_at, modified_by)
|
||||||
SELECT 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
|
FROM i_schedule_groups
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`);
|
`);
|
||||||
@@ -389,6 +413,47 @@ async function tableExists(pool, tableName) {
|
|||||||
return Number(rows && rows[0] && rows[0].table_count) > 0;
|
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) {
|
async function columnIsAutoIncrement(pool, tableName, columnName) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT COUNT(*) AS auto_increment_count
|
`SELECT COUNT(*) AS auto_increment_count
|
||||||
@@ -741,8 +806,7 @@ function compareVersions(leftVersion, rightVersion) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMigrations(pool, options) {
|
async function getPendingMigrations(pool, options) {
|
||||||
// Only run migrations that are newer than the installed schema version and not beyond the app version.
|
|
||||||
const targetVersion = String(appVersion || '0.0.0').trim();
|
const targetVersion = String(appVersion || '0.0.0').trim();
|
||||||
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
|
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
|
||||||
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
|
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
|
||||||
@@ -763,14 +827,25 @@ async function runMigrations(pool, options) {
|
|||||||
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
|
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const migration of VERSIONED_MIGRATIONS) {
|
return VERSIONED_MIGRATIONS.filter(function (migration) {
|
||||||
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
|
return compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0;
|
||||||
await migration.run(pool);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
module.exports = {
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
runMigrations: runMigrations
|
getPendingMigrations: getPendingMigrations,
|
||||||
|
runMigrations: runMigrations,
|
||||||
|
detectSchemaVersion: detectSchemaVersion,
|
||||||
|
recordSchemaVersion: recordSchemaVersion,
|
||||||
|
compareVersions: compareVersions
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,12 @@
|
|||||||
function getOnboardingDeviceId() {
|
function getOnboardingDeviceId() {
|
||||||
try {
|
try {
|
||||||
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
|
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) {
|
} catch (_error) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function applyPendingPlaylistUpdate() {
|
|||||||
if (!pendingPlaylistUpdate) {
|
if (!pendingPlaylistUpdate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
var nextIndex = Number(index || 0);
|
||||||
slides = pendingPlaylistUpdate.slides;
|
slides = pendingPlaylistUpdate.slides;
|
||||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||||
@@ -80,7 +81,11 @@ function applyPendingPlaylistUpdate() {
|
|||||||
templateLayoutCache = Object.create(null);
|
templateLayoutCache = Object.create(null);
|
||||||
templateRenderPlanCache = Object.create(null);
|
templateRenderPlanCache = Object.create(null);
|
||||||
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
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.');
|
logDebug('Applied updated playlist on slide transition.');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,15 @@ function refresh() {
|
|||||||
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
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({
|
savePlaylistSnapshot({
|
||||||
slides: nextSlides,
|
slides: nextSlides,
|
||||||
signature: nextSignature,
|
signature: nextSignature,
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ function getFormattedParts(timeZone, date) {
|
|||||||
MMM: toTitleCase(getPart(monthShort, 'month')),
|
MMM: toTitleCase(getPart(monthShort, 'month')),
|
||||||
MMMM: toTitleCase(getPart(monthLong, 'month')),
|
MMMM: toTitleCase(getPart(monthLong, 'month')),
|
||||||
a: toTitleCase(getPart(ampm, 'dayPeriod')),
|
a: toTitleCase(getPart(ampm, 'dayPeriod')),
|
||||||
tz: resolvedTimeZone,
|
tz: getPart(timezoneShort, 'timeZoneName'),
|
||||||
tz_short: getPart(timezoneShort, 'timeZoneName'),
|
tz_long: resolvedTimeZone,
|
||||||
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
|
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
|
||||||
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
|
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ async function start() {
|
|||||||
mediaDir: webConfig.mediaDir,
|
mediaDir: webConfig.mediaDir,
|
||||||
backgroundTaskQueue: backgroundTaskQueue,
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
webBootstrap: webBootstrap,
|
webBootstrap: webBootstrap,
|
||||||
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
loadCurrentUser: loadCurrentUser,
|
loadCurrentUser: loadCurrentUser,
|
||||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||||
captureSlideThumbnail: captureSlideThumbnail,
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function registerDataSourceRefreshTask(options) {
|
|||||||
if (!apiSource) {
|
if (!apiSource) {
|
||||||
throw new Error('API source not found.');
|
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') {
|
if (sourceType === 'rss-feed') {
|
||||||
@@ -32,7 +32,7 @@ function registerDataSourceRefreshTask(options) {
|
|||||||
if (!rssFeed) {
|
if (!rssFeed) {
|
||||||
throw new Error('RSS feed not found.');
|
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.');
|
throw new Error('Unsupported data source refresh task.');
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
|||||||
sourceName: apiSource.name
|
sourceName: apiSource.name
|
||||||
},
|
},
|
||||||
run: function () {
|
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
|
sourceName: rssFeed.name
|
||||||
},
|
},
|
||||||
run: function () {
|
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) {
|
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) {
|
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 {
|
return {
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ function scheduleStartupDataSourceRefreshes(options) {
|
|||||||
|
|
||||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
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) {
|
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,112 @@
|
|||||||
async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
|
||||||
|
const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||||
|
const slideIds = [];
|
||||||
|
const seenSlideIds = new Set();
|
||||||
|
|
||||||
|
slideRows.forEach(function (row) {
|
||||||
|
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
|
||||||
|
if (!content || typeof content !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [content];
|
||||||
|
while (stack.length) {
|
||||||
|
const value = stack.pop();
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(function (item) {
|
||||||
|
stack.push(item);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, slideMatchKey) && Number(value[slideMatchKey]) === Number(sourceId)) {
|
||||||
|
const slideId = Number(row.id);
|
||||||
|
if (Number.isFinite(slideId) && slideId > 0 && !seenSlideIds.has(slideId)) {
|
||||||
|
seenSlideIds.add(slideId);
|
||||||
|
slideIds.push(slideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(value).forEach(function (key) {
|
||||||
|
stack.push(value[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!slideIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [screenRows] = await connection.query(
|
||||||
|
`SELECT DISTINCT s.slug
|
||||||
|
FROM d_screens s
|
||||||
|
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||||
|
WHERE ps.slide_id IN (?)
|
||||||
|
AND s.slug IS NOT NULL`,
|
||||||
|
[slideIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
return screenRows.map(function (row) {
|
||||||
|
return String(row.slug || '').trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyAffectedScreens(connection, common, notifyPlayerScreens, slideMatchKey, sourceId) {
|
||||||
|
if (typeof notifyPlayerScreens !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugs = await getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId);
|
||||||
|
if (!slugs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyPlayerScreens(slugs, 'refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshotValue(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasRssFeedChanged(connection, rssFeedId, items) {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT item_json
|
||||||
|
FROM i_rss_feed_items
|
||||||
|
WHERE rss_feed_id = ?
|
||||||
|
ORDER BY position ASC, id ASC`,
|
||||||
|
[rssFeedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSnapshots = (rows || []).map(function (row) {
|
||||||
|
return normalizeSnapshotValue(row && row.item_json);
|
||||||
|
});
|
||||||
|
const nextSnapshots = (Array.isArray(items) ? items : []).map(function (item) {
|
||||||
|
return normalizeSnapshotValue(JSON.stringify(item || {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentSnapshots.length !== nextSnapshots.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < currentSnapshots.length; index += 1) {
|
||||||
|
if (currentSnapshots[index] !== nextSnapshots[index]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasApiSourceChanged(apiSource, responseDetails) {
|
||||||
|
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
|
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]
|
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
|
||||||
);
|
);
|
||||||
await connection.commit();
|
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) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
await connection.rollback();
|
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();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
let updatedItems = [];
|
let updatedItems = [];
|
||||||
@@ -50,11 +166,20 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
|
|||||||
}
|
}
|
||||||
|
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
|
const rssFeedChanged = await hasRssFeedChanged(connection, rssFeedId, updatedItems);
|
||||||
if (typeof common.replaceRssFeedItems === 'function') {
|
if (typeof common.replaceRssFeedItems === 'function') {
|
||||||
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||||
}
|
}
|
||||||
await connection.commit();
|
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) {
|
if (pullError) {
|
||||||
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async function initializeWebServer(options) {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
backgroundTaskQueue: backgroundTaskQueue,
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
notifyPlayerScreens: options && options.notifyPlayerScreens ? options.notifyPlayerScreens : null,
|
||||||
uploadSyncService: webBootstrap.uploadSyncService,
|
uploadSyncService: webBootstrap.uploadSyncService,
|
||||||
captureSlideThumbnail: captureSlideThumbnail,
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
mediaDir: mediaDir,
|
mediaDir: mediaDir,
|
||||||
|
|||||||
@@ -84,8 +84,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tz: timeZone,
|
tz: shortName,
|
||||||
tz_short: shortName
|
tz_long: timeZone
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
|
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
|
||||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
|
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
|
||||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> or <code>{{start.tz_short()}}</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">' +
|
'<details class="mt-2">' +
|
||||||
'<summary class="small text-body-secondary">Supported date format tokens</summary>' +
|
'<summary class="small text-body-secondary">Supported date format tokens</summary>' +
|
||||||
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
|
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
|
||||||
@@ -392,8 +392,8 @@
|
|||||||
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
|
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,
|
display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
|
||||||
tz: timezoneValues.tz,
|
tz: timezoneValues.tz,
|
||||||
tz_short: timezoneValues.tz_short,
|
tz_long: timezoneValues.tz_long,
|
||||||
timeZone: timezoneValues.tz,
|
timeZone: timezoneValues.tz_long,
|
||||||
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
|
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
|
||||||
existingContent: existingContent || {},
|
existingContent: existingContent || {},
|
||||||
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
|
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
|
||||||
@@ -421,8 +421,8 @@
|
|||||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||||
tz: timezoneValues.tz,
|
tz: timezoneValues.tz,
|
||||||
tz_short: timezoneValues.tz_short,
|
tz_long: timezoneValues.tz_long,
|
||||||
timeZone: timezoneValues.tz,
|
timeZone: timezoneValues.tz_long,
|
||||||
group: group || {},
|
group: group || {},
|
||||||
entries: entries,
|
entries: entries,
|
||||||
index: index + 1
|
index: index + 1
|
||||||
|
|||||||
@@ -156,11 +156,11 @@
|
|||||||
MMM: toTitleCase(getPart(monthShort, 'month')),
|
MMM: toTitleCase(getPart(monthShort, 'month')),
|
||||||
MMMM: toTitleCase(getPart(monthLong, 'month')),
|
MMMM: toTitleCase(getPart(monthLong, 'month')),
|
||||||
a: toTitleCase(getPart(ampm, 'dayPeriod')),
|
a: toTitleCase(getPart(ampm, 'dayPeriod')),
|
||||||
tz: resolvedTimeZone,
|
tz: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
|
||||||
tz_short: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
|
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
}).formatToParts(targetDate), 'timeZoneName'),
|
}).formatToParts(targetDate), 'timeZoneName'),
|
||||||
|
tz_long: resolvedTimeZone,
|
||||||
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
|
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
|
||||||
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
|
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
|
||||||
};
|
};
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
'MMMM',
|
'MMMM',
|
||||||
'a',
|
'a',
|
||||||
'tz',
|
'tz',
|
||||||
'tz_short'
|
'tz_long'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (typeof placeholderChips.renderChips === 'function') {
|
if (typeof placeholderChips.renderChips === 'function') {
|
||||||
|
|||||||
@@ -245,6 +245,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTimeZoneLongName(timeZone) {
|
||||||
|
return getTransformTimeZone([], { timeZone: timeZone });
|
||||||
|
}
|
||||||
|
|
||||||
function getTransformTimeZone(args, options) {
|
function getTransformTimeZone(args, options) {
|
||||||
return resolveTimeZone((args && args[0]) || (options && options.timeZone) || '');
|
return resolveTimeZone((args && args[0]) || (options && options.timeZone) || '');
|
||||||
}
|
}
|
||||||
@@ -273,11 +277,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'tz') {
|
if (name === 'tz') {
|
||||||
return getTransformTimeZone(args, options);
|
return getTimeZoneShortName(value, getTransformTimeZone(args, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'tz_short') {
|
if (name === 'tz_long') {
|
||||||
return getTimeZoneShortName(value, getTransformTimeZone(args, options));
|
return getTimeZoneLongName(getTransformTimeZone(args, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
var HELPER_PLACEHOLDERS = [
|
var HELPER_PLACEHOLDERS = [
|
||||||
{ token: 'tz', output: 'Europe/London', description: 'The resolved time zone name' },
|
{ token: 'tz', output: 'BST/GMT', description: 'The time zone abbreviation for the selected date' },
|
||||||
{ token: 'tz_short', 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);
|
var PLACEHOLDERS = FORMAT_PLACEHOLDERS.concat(HELPER_PLACEHOLDERS);
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'setclientname') {
|
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();
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return res.status(400).json({ error: 'Device ID is required' });
|
return res.status(400).json({ error: 'Device ID is required' });
|
||||||
@@ -344,7 +344,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'moveclient') {
|
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 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();
|
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
return text.slice(0, limit);
|
return text.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value) {
|
|
||||||
return String(value || '').trim().replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExplicitPlayerBaseUrl(value) {
|
function normalizeExplicitPlayerBaseUrl(value) {
|
||||||
return String(value || '').trim().replace(/\/$/, '');
|
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)) {
|
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||||
return res.status(400).send('A screen with that name already exists.');
|
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 playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||||
const previousPlaylistId = screen.playlist_id;
|
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();
|
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]);
|
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
}
|
}
|
||||||
if (previousSlug && previousSlug !== slug) {
|
if (previousSlug && previousSlug !== slug) {
|
||||||
const previousScreenTargets = await pool.query(
|
const previousConnections = await fetchLiveConnectionsForScreen(previousSlug);
|
||||||
`SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url
|
const redirectPayload = {
|
||||||
FROM d_screens s
|
command: 'redirect',
|
||||||
LEFT JOIN d_players p ON p.device_id = s.player_id
|
url: `/screen/${encodeURIComponent(slug)}`
|
||||||
WHERE s.slug = ?
|
};
|
||||||
LIMIT 1`,
|
if (previousConnections.length) {
|
||||||
[previousSlug]
|
await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload);
|
||||||
);
|
|
||||||
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)}`
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await forwardPlayerCommand(previousSlug, {
|
await forwardPlayerCommand(previousSlug, redirectPayload);
|
||||||
command: 'redirect',
|
|
||||||
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
|
|
||||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-xl-8">
|
<div class="col-12 col-xl-8 mb-4">
|
||||||
<div class="card card-outline card-primary dashboard-actions-card pb-4">
|
<div class="card card-outline card-primary dashboard-actions-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Quick actions</h3>
|
<h3 class="card-title">Quick actions</h3>
|
||||||
@@ -108,8 +108,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-xl-4">
|
<div class="col-12 col-xl-4 mb-4">
|
||||||
<div class="card card-outline card-secondary dashboard-launcher-card h-100 pb-4">
|
<div class="card card-outline card-secondary dashboard-launcher-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Kiosk launchers</h3>
|
<h3 class="card-title">Kiosk launchers</h3>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="screen-slug" class="form-label">Slug</label>
|
<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>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label for="screen-playlist" class="form-label">Playlist</label>
|
<label for="screen-playlist" class="form-label">Playlist</label>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { refreshApiSource, refreshRssFeed } = require('../src/web/lib/data-source-refresh');
|
||||||
|
|
||||||
|
function createConnection(options) {
|
||||||
|
const state = Object.assign({
|
||||||
|
rssItemSnapshots: [],
|
||||||
|
slideRows: [],
|
||||||
|
screenRows: []
|
||||||
|
}, options || {});
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async beginTransaction() {
|
||||||
|
calls.push(['beginTransaction']);
|
||||||
|
},
|
||||||
|
async commit() {
|
||||||
|
calls.push(['commit']);
|
||||||
|
},
|
||||||
|
async rollback() {
|
||||||
|
calls.push(['rollback']);
|
||||||
|
},
|
||||||
|
release() {
|
||||||
|
calls.push(['release']);
|
||||||
|
},
|
||||||
|
async query(sql, params) {
|
||||||
|
calls.push(['query', sql, params]);
|
||||||
|
|
||||||
|
if (sql.includes('SELECT item_json')) {
|
||||||
|
return [state.rssItemSnapshots.map(function (itemJson) {
|
||||||
|
return { item_json: itemJson };
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('SELECT id, content_json FROM c_slides')) {
|
||||||
|
return [state.slideRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('SELECT DISTINCT s.slug')) {
|
||||||
|
return [state.screenRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refreshApiSource notifies players only when the API snapshot changes', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-a' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const common = {
|
||||||
|
async fetchApiSourceResponse() {
|
||||||
|
return {
|
||||||
|
responseJson: JSON.stringify({ value: 'new' }, null, 2),
|
||||||
|
responseStatus: 200,
|
||||||
|
responseContentType: 'application/json'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshApiSource(pool, common, {
|
||||||
|
id: 7,
|
||||||
|
last_response_json: JSON.stringify({ value: 'old' }, null, 2)
|
||||||
|
}, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, [{ slugs: ['screen-a'], payload: 'refresh' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshApiSource skips notifications when the API snapshot is unchanged', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-a' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const snapshot = JSON.stringify({ value: 'same' }, null, 2);
|
||||||
|
const common = {
|
||||||
|
async fetchApiSourceResponse() {
|
||||||
|
return {
|
||||||
|
responseJson: snapshot,
|
||||||
|
responseStatus: 200,
|
||||||
|
responseContentType: 'application/json'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshApiSource(pool, common, {
|
||||||
|
id: 7,
|
||||||
|
last_response_json: snapshot
|
||||||
|
}, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshRssFeed notifies players only when the RSS items change', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
rssItemSnapshots: [JSON.stringify({ title: 'Old item' })],
|
||||||
|
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-b' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const common = {
|
||||||
|
async fetchRssFeedItems() {
|
||||||
|
return [{ title: 'New item' }];
|
||||||
|
},
|
||||||
|
async replaceRssFeedItems() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, [{ slugs: ['screen-b'], payload: 'refresh' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshRssFeed skips notifications when the RSS items are unchanged', async () => {
|
||||||
|
const snapshot = JSON.stringify({ title: 'Same item' });
|
||||||
|
const connection = createConnection({
|
||||||
|
rssItemSnapshots: [snapshot],
|
||||||
|
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-b' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const common = {
|
||||||
|
async fetchRssFeedItems() {
|
||||||
|
return [{ title: 'Same item' }];
|
||||||
|
},
|
||||||
|
async replaceRssFeedItems() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, []);
|
||||||
|
});
|
||||||
@@ -39,6 +39,11 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
pausedRemainingMs: null,
|
pausedRemainingMs: null,
|
||||||
app: null,
|
app: null,
|
||||||
currentPlaylistEtag: '',
|
currentPlaylistEtag: '',
|
||||||
|
initialData: {
|
||||||
|
rssFeeds: [{ id: 1 }],
|
||||||
|
apiSources: [{ id: 2 }],
|
||||||
|
timetableGroups: [{ id: 3 }]
|
||||||
|
},
|
||||||
activeSlidesCacheKey: '',
|
activeSlidesCacheKey: '',
|
||||||
activeSlidesCacheValue: [],
|
activeSlidesCacheValue: [],
|
||||||
renderCacheViewportKey: '',
|
renderCacheViewportKey: '',
|
||||||
@@ -94,6 +99,9 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
this.responseText = JSON.stringify({
|
this.responseText = JSON.stringify({
|
||||||
signature: 'next-signature',
|
signature: 'next-signature',
|
||||||
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
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 }
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
||||||
});
|
});
|
||||||
if (typeof this.onreadystatechange === 'function') {
|
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(calls.showCurrent, 0);
|
||||||
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
||||||
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
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('Unable to load screen playlist.')), false);
|
||||||
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
|
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 }]);
|
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 () => {
|
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
|
||||||
const calls = {
|
const calls = {
|
||||||
showCurrent: 0,
|
showCurrent: 0,
|
||||||
|
|||||||
@@ -295,4 +295,45 @@ test('onboarding device ids stay scoped to the tab session', () => {
|
|||||||
|
|
||||||
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
|
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
|
||||||
assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-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');
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -86,7 +86,7 @@ test('timetable region editor lists timezone transforms in helper text', () => {
|
|||||||
const html = module.renderEditorCard({
|
const html = module.renderEditorCard({
|
||||||
region: { id: 47, label: 'Timetable' },
|
region: { id: 47, label: 'Timetable' },
|
||||||
current: {
|
current: {
|
||||||
value: '<p>{{title}} {{start.tz()}} {{start.tz_short()}}</p>',
|
value: '<p>{{title}} {{start.tz()}} {{start.tz_long()}}</p>',
|
||||||
timetable_group_id: 1,
|
timetable_group_id: 1,
|
||||||
display_mode: 'current',
|
display_mode: 'current',
|
||||||
max_items: 3
|
max_items: 3
|
||||||
@@ -103,7 +103,7 @@ test('timetable region editor lists timezone transforms in helper text', () => {
|
|||||||
|
|
||||||
assert.match(html, /Placeholder values support transforms/);
|
assert.match(html, /Placeholder values support transforms/);
|
||||||
assert.match(html, /<code>\{\{start\.tz\(\)\}\}<\/code>/);
|
assert.match(html, /<code>\{\{start\.tz\(\)\}\}<\/code>/);
|
||||||
assert.match(html, /<code>\{\{start\.tz_short\(\)\}\}<\/code>/);
|
assert.match(html, /<code>\{\{start\.tz_long\(\)\}\}<\/code>/);
|
||||||
assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/);
|
assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ test('timetable region editor shows supported date format tokens', () => {
|
|||||||
assert.doesNotMatch(html, /<td><code>yyyy<\/code><\/td>/);
|
assert.doesNotMatch(html, /<td><code>yyyy<\/code><\/td>/);
|
||||||
assert.doesNotMatch(html, /<td><code>yy<\/code><\/td>/);
|
assert.doesNotMatch(html, /<td><code>yy<\/code><\/td>/);
|
||||||
assert.doesNotMatch(html, /<td><code>tz<\/code><\/td>/);
|
assert.doesNotMatch(html, /<td><code>tz<\/code><\/td>/);
|
||||||
assert.doesNotMatch(html, /<td><code>tz_short<\/code><\/td>/);
|
assert.match(html, /start\.tz_long\(\)/);
|
||||||
assert.match(html, /Use these tokens inside <code>\.format\(\.\.\.\)<\/code>/);
|
assert.match(html, /Use these tokens inside <code>\.format\(\.\.\.\)<\/code>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ test('timetable region preview resolves timezone placeholders from explicit time
|
|||||||
const preview = module.renderPreview(
|
const preview = module.renderPreview(
|
||||||
{ id: 47, width: 500, height: 280 },
|
{ id: 47, width: 500, height: 280 },
|
||||||
{
|
{
|
||||||
value: '<p>{{start.tz("UTC")}} {{start.tz_short("UTC")}}</p>',
|
value: '<p>{{start.tz("UTC")}} {{start.tz_long("UTC")}}</p>',
|
||||||
timetable_group_id: 1,
|
timetable_group_id: 1,
|
||||||
display_mode: 'both',
|
display_mode: 'both',
|
||||||
max_items: 3
|
max_items: 3
|
||||||
@@ -210,7 +210,7 @@ test('timetable timezone abbreviation changes across daylight saving boundaries'
|
|||||||
const preview = module.renderPreview(
|
const preview = module.renderPreview(
|
||||||
{ id: 47, width: 500, height: 280 },
|
{ id: 47, width: 500, height: 280 },
|
||||||
{
|
{
|
||||||
value: '<p>{{start.tz_short("Europe/London")}}</p>',
|
value: '<p>{{start.tz("Europe/London")}}</p>',
|
||||||
timetable_group_id: 1,
|
timetable_group_id: 1,
|
||||||
display_mode: 'both',
|
display_mode: 'both',
|
||||||
max_items: 10
|
max_items: 10
|
||||||
@@ -265,7 +265,7 @@ test('timetable preview does not force the group timezone into placeholder trans
|
|||||||
const preview = module.renderPreview(
|
const preview = module.renderPreview(
|
||||||
{ id: 47, width: 500, height: 280 },
|
{ id: 47, width: 500, height: 280 },
|
||||||
{
|
{
|
||||||
value: '<p>{{start.tz_short()}}</p>',
|
value: '<p>{{start.tz_long()}}</p>',
|
||||||
timetable_group_id: 1,
|
timetable_group_id: 1,
|
||||||
display_mode: 'both',
|
display_mode: 'both',
|
||||||
max_items: 3
|
max_items: 3
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
const registerManageRoutes = require('../src/web/routes/admin/manage');
|
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 handlers = {};
|
||||||
const app = {
|
const app = {
|
||||||
post(path, ...routeHandlers) {
|
post(path, ...routeHandlers) {
|
||||||
@@ -14,37 +14,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
|
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql) {
|
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')) {
|
if (sql.includes('SELECT id, name, slug, playlist_id')) {
|
||||||
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
|
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')) {
|
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?')) {
|
||||||
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 = ?')) {
|
|
||||||
return [{ affectedRows: 1 }];
|
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.equal(res.redirectedTo, '/screens?edit=42');
|
||||||
assert.deepEqual(calls, [
|
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' }
|
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,22 @@ test('screen edit page includes shared launcher downloads and base player url',
|
|||||||
pages,
|
pages,
|
||||||
buildDashboardState: async () => ({ screens: [] }),
|
buildDashboardState: async () => ({ screens: [] }),
|
||||||
getScreenDeleteBlockMessage: async () => '',
|
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',
|
playerPublicBaseUrl: 'http://player.example',
|
||||||
requirePermission() {
|
requirePermission() {
|
||||||
return function (_req, _res, next) {
|
return function (_req, _res, next) {
|
||||||
@@ -265,6 +280,8 @@ test('screen edit page renders player urls as an adminlte table', async () => {
|
|||||||
name: 'Demo Conference',
|
name: 'Demo Conference',
|
||||||
slug: 'demo-conference',
|
slug: 'demo-conference',
|
||||||
playlist_id: null,
|
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: [
|
player_urls: [
|
||||||
{
|
{
|
||||||
identifier: 'player-alpha',
|
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, /card-body table-responsive p-0/);
|
||||||
assert.match(html, /table table-striped w-100 mb-0/);
|
assert.match(html, /table table-striped w-100 mb-0/);
|
||||||
assert.match(html, /player-alpha/);
|
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 () => {
|
test('screen launcher downloads are shared and attached', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user