Compare commits

..
10 Commits
Author SHA1 Message Date
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
lzstealth c5172af62d Release 2.6.13 2026-08-08 21:11:34 +01:00
lzstealth cd8e333afd Restore Docker tag fan-out 2026-08-08 20:48:36 +01:00
lzstealth c3b5a0053c Split web and player build artifacts 2026-08-08 20:38:44 +01:00
lzstealth 38565a533d Wire remote player reconnect delay 2026-08-08 16:29:38 +01:00
lzstealth 5a08ccddbb Release v2.6.11 2026-08-08 15:11:11 +01:00
75 changed files with 3455 additions and 473 deletions
+2
View File
@@ -1,5 +1,7 @@
* *
!package.json !package.json
!build/
!build/**
!src/ !src/
!src/** !src/**
!scripts/ !scripts/
+13 -3
View File
@@ -10,6 +10,16 @@ jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: web
image: git.lzstealth.com/lzstealth/pulse-signage-web
dockerfile: ./build/Dockerfile
- name: player
image: git.lzstealth.com/lzstealth/pulse-signage-player
dockerfile: ./build/Dockerfile.player
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -28,18 +38,18 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: git.lzstealth.com/LZStealth/pulse-signage images: ${{ matrix.image }}
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=ref,event=tag type=ref,event=tag
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}} type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ${{ matrix.dockerfile }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+86
View File
@@ -2,6 +2,92 @@
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.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
- The player bundle now keeps the browser-only QR export dependencies lazy-loaded, so the remote player image no longer needs `puppeteer-core` or `@sparticuz/chromium` at startup.
- Remote player startup sync now falls back to `WEB_INTERNAL_URL` when it is configured, which avoids 404s when the bridge is not the correct sync target.
- The player startup sync and media-write info logs were removed to keep normal remote-player operation quieter.
## 2.6.12 - 2026-08-08
### Fixed
- The Docker build inputs now live under `build/`, with separate web and player package manifests so the player image no longer carries the web browser tooling bundle.
- The Docker publish workflow now restores `v2` and `v2.2` style image tags alongside `latest` and the full release tag.
- Remote player sync and upload routing now carry the exact connected player device id through the bridge, so startup media and font jobs target the correct player instance in split-device deployments.
- Player websocket registration now learns the public base URL from the actual screen request origin, so localhost and 127.0.0.1 aliases both keep websocket commands working.
## 2.6.11 - 2026-08-08
### Fixed
- Role edits now save selected permissions from the shared RBAC form, so changes on the role page persist when you submit the form.
- RBAC permission sections now stay open independently in the accordion, so opening one section no longer closes the others.
## 2.6.10 - 2026-08-08 ## 2.6.10 - 2026-08-08
### Fixed ### Fixed
-20
View File
@@ -1,20 +0,0 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
COPY package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont
COPY build/package.web.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg
COPY build/package.player.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:player"]
+19
View File
@@ -0,0 +1,19 @@
{
"name": "pulse-signage-player",
"version": "2.6.21",
"private": false,
"description": "Pulse Signage player application bundle",
"main": "src/common.js",
"scripts": {
"start": "node -r dotenv/config src/player.js",
"start:player": "node -r dotenv/config src/player.js"
},
"dependencies": {
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"hls.js": "^1.5.15",
"mysql2": "^3.14.3",
"ws": "^8.21.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pulse-signage-web",
"version": "2.6.21",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"main": "src/common.js",
"scripts": {
"start": "node -r dotenv/config src/web.js",
"start:web": "node -r dotenv/config src/web.js"
},
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"multer": "^2.2.0",
"mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"sharp": "^0.35.3",
"ws": "^8.21.0"
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
# Shared application settings # Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest" PULSE_SIGNAGE_WEB_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-web:latest"
PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
PULSE_SIGNAGE_SHARED_SECRET="" PULSE_SIGNAGE_SHARED_SECRET=""
# Database settings for the web, player, and bridge services # Database settings for the web, player, and bridge services
+1 -1
View File
@@ -1,5 +1,5 @@
# Shared application settings # Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest" PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
PULSE_SIGNAGE_SHARED_SECRET="" PULSE_SIGNAGE_SHARED_SECRET=""
# Player settings # Player settings
+6 -4
View File
@@ -113,7 +113,8 @@ Use this file as a starting point for the public compose stack.
Important values: Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run for all app services - `PULSE_SIGNAGE_WEB_IMAGE` - image to run for the web app and bridge services, typically `.../pulse-signage-web:latest`
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run for the player services, typically `.../pulse-signage-player:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests - `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
- `PLAYER_IDENTIFIER` - unique local player identifier - `PLAYER_IDENTIFIER` - unique local player identifier
- `DB_*` - MySQL credentials and database name for the stack - `DB_*` - MySQL credentials and database name for the stack
@@ -132,7 +133,7 @@ Use this file on a remote player device.
Important values: Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run on the device - `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run on the device, typically `.../pulse-signage-player:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment - `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
- `PLAYER_IDENTIFIER` - unique remote player identifier - `PLAYER_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_URL` - public URL for the remote player - `PLAYER_PUBLIC_URL` - public URL for the remote player
@@ -157,7 +158,8 @@ Leave it blank only if you intentionally want to run without request signing in
| Variable | Used By | Purpose | | Variable | Used By | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. | | `PULSE_SIGNAGE_WEB_IMAGE` | web, bridge | Docker image to run for the web app and bridge services. |
| `PULSE_SIGNAGE_PLAYER_IMAGE` | player, remote player | Docker image to run for the player services. |
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. | | `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. |
| `DB_HOST` | web, player, bridge | Database host name. | | `DB_HOST` | web, player, bridge | Database host name. |
| `DB_PORT` | web, player, bridge | Database port. | | `DB_PORT` | web, player, bridge | Database port. |
@@ -218,7 +220,7 @@ Each compose file creates its own named network:
- A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to. - A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to.
- The bridge service is the dashboard-facing command path for connected remote players. - The bridge service is the dashboard-facing command path for connected remote players.
- The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint. - The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint.
- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases. - The `PULSE_SIGNAGE_WEB_IMAGE` and `PULSE_SIGNAGE_PLAYER_IMAGE` tags default to the published `pulse-signage-web` and `pulse-signage-player` repositories with `latest` and `v1.2.3` style tags, but they can be overridden for local builds or custom releases.
## Recommended Setup ## Recommended Setup
+3 -3
View File
@@ -3,22 +3,22 @@ name: pulse-signage-remote
services: services:
player: player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped restart: unless-stopped
networks: networks:
- pulse_signage - pulse_signage
ports: ports:
- "8081:8081" - "8081:8081"
environment: environment:
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081} PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-} BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
volumes: volumes:
- pulse-signage:/app/media - pulse-signage:/app/media
command: ["node", "src/player.js"] command: ["node", "src/player.js"]
volumes: volumes:
pulse-signage: pulse-signage:
+3 -3
View File
@@ -2,7 +2,7 @@ name: pulse-signage
services: services:
web: web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped restart: unless-stopped
networks: networks:
- pulse_signage - pulse_signage
@@ -28,7 +28,7 @@ services:
condition: service_healthy condition: service_healthy
player: player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped restart: unless-stopped
networks: networks:
- pulse_signage - pulse_signage
@@ -52,7 +52,7 @@ services:
condition: service_healthy condition: service_healthy
player-bridge: player-bridge:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped restart: unless-stopped
networks: networks:
- pulse_signage - pulse_signage
+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_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
``` ```
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.5.9", "version": "2.6.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.5.9", "version": "2.6.15",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^137.0.0", "@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.6.10", "version": "2.6.21",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
+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_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 { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists'); 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 { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
+7 -7
View File
@@ -1,8 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js')); const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const QR_PNG_WIDTH = 2048; const QR_PNG_WIDTH = 2048;
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'); const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
const SYSTEM_CHROMIUM_PATHS = [ const SYSTEM_CHROMIUM_PATHS = [
@@ -13,11 +11,6 @@ const SYSTEM_CHROMIUM_PATHS = [
'/usr/local/bin/chromium', '/usr/local/bin/chromium',
'/snap/bin/chromium' '/snap/bin/chromium'
].filter(Boolean); ].filter(Boolean);
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
let qrBrowserPromise = null; let qrBrowserPromise = null;
function escapeXml(value) { function escapeXml(value) {
@@ -353,6 +346,13 @@ function getQrBrowser() {
} }
qrBrowserPromise = (async function () { qrBrowserPromise = (async function () {
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) { let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
return fs.existsSync(candidate); return fs.existsSync(candidate);
}) || ''; }) || '';
+43
View File
@@ -308,6 +308,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 || ''), 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() 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') { } else if (region.region_type === 'rss') {
const submitted = body[`region_text_${region.id}`]; const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
@@ -4,6 +4,23 @@ const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255; const NAME_MAX_LENGTH = 255;
const DESCRIPTION_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) { function normalizeDisplayMode(value) {
const mode = String(value || 'upcoming').trim().toLowerCase(); const mode = String(value || 'upcoming').trim().toLowerCase();
@@ -15,15 +32,15 @@ function normalizeDisplayMode(value) {
async function fetchTimetablesData(pool) { async function fetchTimetablesData(pool) {
const [timetableGroups] = await pool.query(` 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 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_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, (SELECT COUNT(*) FROM i_timetable_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 (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_schedule_groups g FROM i_timetable_groups g
ORDER BY g.modified_at DESC, g.id DESC ORDER BY g.modified_at DESC, g.id DESC
`); `);
const [timetableEntries] = await pool.query(` 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 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 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) { async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { 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, 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_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, (SELECT COUNT(*) FROM i_timetable_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 (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_schedule_groups g FROM i_timetable_groups g
ORDER BY g.modified_at DESC, g.id DESC`, 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'], searchColumns: ['g.name', 'g.short_description'],
searchTerm: searchTerm, searchTerm: searchTerm,
sortColumns: { sortColumns: {
name: 'g.name', name: 'g.name',
description: 'g.short_description', description: 'g.short_description',
timezone: 'g.timezone',
entries: 'entry_count', entries: 'entry_count',
next_start: 'next_start_datetime', next_start: 'next_start_datetime',
created: 'g.created_at', created: 'g.created_at',
@@ -77,7 +95,7 @@ async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKe
async function fetchTimetableGroupById(pool, id) { async function fetchTimetableGroupById(pool, id) {
const [rows] = await pool.query( 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] [id]
); );
@@ -87,7 +105,7 @@ async function fetchTimetableGroupById(pool, id) {
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) { async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
const [rows] = await pool.query( 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 `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 = ? WHERE schedule_group_id = ?
ORDER BY start_datetime ASC, id ASC`, ORDER BY start_datetime ASC, id ASC`,
[timetableGroupId] [timetableGroupId]
@@ -100,6 +118,7 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) {
const fallback = existingTimetableGroup || {}; const fallback = existingTimetableGroup || {};
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name'); 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 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) { if (!name) {
const error = new Error('Timetable group name is required.'); const error = new Error('Timetable group name is required.');
@@ -109,7 +128,8 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) {
return { return {
name: name, name: name,
shortDescription: shortDescription shortDescription: shortDescription,
timezone: timezone
}; };
} }
@@ -119,5 +139,6 @@ module.exports = {
fetchTimetableGroupsPage, fetchTimetableGroupsPage,
fetchTimetableGroupById, fetchTimetableGroupById,
fetchTimetableEntriesByGroupId, fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload buildTimetableGroupPayload,
normalizeTimeZone
}; };
+15 -6
View File
@@ -1,3 +1,6 @@
const { version: appVersion } = require('#root/package.json');
const { compareVersions, detectSchemaVersion, 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,11 @@ async function ensureSchema(pool, options) {
} }
try { try {
const currentVersion = await detectSchemaVersion(pool);
const updateRequired = compareVersions(currentVersion, appVersion) < 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,
@@ -238,10 +246,11 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` 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, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
short_description VARCHAR(255) NULL, short_description VARCHAR(255) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/London',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -250,7 +259,7 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` 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, id INT AUTO_INCREMENT PRIMARY KEY,
schedule_group_id INT NOT NULL, schedule_group_id INT NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
@@ -261,8 +270,8 @@ async function ensureSchema(pool, options) {
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE, CONSTRAINT fk_timetable_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_timetable_groups(id) ON DELETE CASCADE,
INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime) INDEX idx_timetable_entries_group_start (schedule_group_id, start_datetime)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
@@ -383,8 +392,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 () {
}); });
+277 -24
View File
@@ -1,4 +1,7 @@
const { version: appVersion } = require('#root/package.json'); 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 = [ 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. // 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'. // 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'))) { 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"); await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
} else { } else {
await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'"); await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'");
const [playerColumnNullableRows] = await pool.query( const [playerColumnNullableRows] = await pool.query(
`SELECT COUNT(*) AS nullable_count `SELECT COUNT(*) AS nullable_count
FROM information_schema.COLUMNS 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
WHERE TABLE_SCHEMA = DATABASE() WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens' 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) { if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) {
await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id'); 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. // 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')) { if (await columnExists(pool, 'c_template_regions', 'font_family')) {
await pool.query('ALTER TABLE c_template_regions DROP COLUMN 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('RENAME TABLE d_players_rebuild TO d_players');
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); 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 dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
await dropColumnIfExists(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; 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) { 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
@@ -512,6 +655,107 @@ function formatMigrationDateTime(value) {
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes; 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) { function formatMigrationTime(value) {
if (!value) { if (!value) {
return null; return null;
@@ -568,6 +812,8 @@ async function runMigrations(pool, options) {
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');
const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_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; let effectiveCurrentVersion = currentVersion;
if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) { if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) {
@@ -578,6 +824,10 @@ async function runMigrations(pool, options) {
effectiveCurrentVersion = '2.6.3'; effectiveCurrentVersion = '2.6.3';
} }
if (legacyTimetableGroupsPresent || legacyTimetableEntriesPresent) {
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
}
for (const migration of VERSIONED_MIGRATIONS) { for (const migration of VERSIONED_MIGRATIONS) {
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
await migration.run(pool); await migration.run(pool);
@@ -587,5 +837,8 @@ async function runMigrations(pool, options) {
module.exports = { module.exports = {
appVersion: appVersion, appVersion: appVersion,
runMigrations: runMigrations runMigrations: runMigrations,
detectSchemaVersion: detectSchemaVersion,
recordSchemaVersion: recordSchemaVersion,
compareVersions: compareVersions
}; };
+101 -8
View File
@@ -92,6 +92,20 @@ function resolveSnapshotUpstreamBaseUrl(player) {
return normalizeProxyBaseUrl(player && player.public_base_url) || null; return normalizeProxyBaseUrl(player && player.public_base_url) || null;
} }
function resolvePlayerSocketForDeviceId(playerSockets, deviceId) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
if (!normalizedDeviceId || !playerSockets || typeof playerSockets.get !== 'function') {
return null;
}
const socket = playerSockets.get(normalizedDeviceId);
if (!socket || socket.readyState !== WebSocket.OPEN) {
return null;
}
return socket;
}
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
const key = String(slug || '').trim(); const key = String(slug || '').trim();
if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') { if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') {
@@ -369,8 +383,8 @@ async function start() {
return relativePath; return relativePath;
} }
function sendPlayerCommand(commandPayload) { function sendPlayerCommand(commandPayload, deviceId) {
const socket = getConnectedPlayerSocket(); const socket = resolvePlayerSocketForDeviceId(playerSockets, deviceId);
if (!socket) { if (!socket) {
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' }); return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
} }
@@ -513,15 +527,24 @@ async function start() {
return res.status(400).json({ error: 'Filename is required' }); return res.status(400).json({ error: 'Filename is required' });
} }
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required.' });
}
const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const response = await sendPlayerCommand({ const response = await sendPlayerCommand({
command: 'media-put', command: 'media-put',
relativePath: relativePath, relativePath: relativePath,
bodyBase64: bodyBuffer.toString('base64') bodyBase64: bodyBuffer.toString('base64')
}); }, deviceId);
res.status(response.status || (response.ok ? 200 : 502)).json(response); res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) { } catch (error) {
logBridge('Player media upload failed', {
relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '',
error: error && error.message ? error.message : String(error)
});
next(error); next(error);
} }
}); });
@@ -533,13 +556,22 @@ async function start() {
return res.status(400).json({ error: 'Filename is required' }); return res.status(400).json({ error: 'Filename is required' });
} }
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required.' });
}
const response = await sendPlayerCommand({ const response = await sendPlayerCommand({
command: 'media-delete', command: 'media-delete',
relativePath: relativePath relativePath: relativePath
}); }, deviceId);
res.status(response.status || (response.ok ? 200 : 502)).json(response); res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) { } catch (error) {
logBridge('Player media delete failed', {
relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '',
error: error && error.message ? error.message : String(error)
});
next(error); next(error);
} }
}); });
@@ -1054,19 +1086,32 @@ async function start() {
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try { try {
logBridge('Relaying player media sync request to web');
const webBaseUrl = resolveWebBaseUrl(_req); const webBaseUrl = resolveWebBaseUrl(_req);
if (!webBaseUrl) { if (!webBaseUrl) {
return res.status(502).json({ error: 'Web base URL is not configured.' }); return res.status(502).json({ error: 'Web base URL is not configured.' });
} }
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? Object.assign({}, _req.body)
: {};
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, { const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, {
method: 'POST', method: 'POST',
headers: Object.assign({ headers: Object.assign({
Accept: 'application/json' Accept: 'application/json',
'Content-Type': 'application/json'
}, createRequestAuthHeaders({ }, createRequestAuthHeaders({
method: 'POST', method: 'POST',
pathname: '/api/internal/sync/player-media' pathname: '/api/internal/sync/player-media',
})) body: requestBody
})),
body: JSON.stringify(requestBody)
});
logBridge('Web player media sync response received', {
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
}); });
res.status(response.status); res.status(response.status);
@@ -1076,6 +1121,53 @@ async function start() {
} }
res.send(await response.text()); res.send(await response.text());
} catch (error) { } catch (error) {
logBridge('Player media sync relay failed', {
error: error && error.message ? error.message : String(error)
});
next(error);
}
});
app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) {
try {
logBridge('Relaying player font sync request to web');
const webBaseUrl = resolveWebBaseUrl(_req);
if (!webBaseUrl) {
return res.status(502).json({ error: 'Web base URL is not configured.' });
}
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? Object.assign({}, _req.body)
: {};
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-font`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json',
'Content-Type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: requestBody
})),
body: JSON.stringify(requestBody)
});
logBridge('Web player font sync response received', {
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
} catch (error) {
logBridge('Player font sync relay failed', {
error: error && error.message ? error.message : String(error)
});
next(error); next(error);
} }
}); });
@@ -1091,7 +1183,8 @@ module.exports = {
start: start, start: start,
resolveWebBaseUrl: resolveWebBaseUrl, resolveWebBaseUrl: resolveWebBaseUrl,
resolveScreenCommandTargets: resolveScreenCommandTargets, resolveScreenCommandTargets: resolveScreenCommandTargets,
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl,
resolvePlayerSocketForDeviceId: resolvePlayerSocketForDeviceId
}; };
if (require.main === module) { if (require.main === module) {
+177 -40
View File
@@ -22,15 +22,21 @@ async function start() {
const PORT = Number(process.env.PLAYER_PORT || 8081); const PORT = Number(process.env.PLAYER_PORT || 8081);
const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL); const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL);
const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, ''); const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, '');
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier(); const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000);
const ASSET_DIR = path.join(__dirname, 'player', 'public'); const ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media'); const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000); const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
const RECONNECT_SYNC_STALE_MS = 60 * 1000;
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE); const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
let thinClientSocket = null; let thinClientSocket = null;
let lastDisconnectAt = 0;
let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null;
let refreshThinClientRegistration = null;
const playerRuntime = createPlayerRuntime({ const playerRuntime = createPlayerRuntime({
pool: pool, pool: pool,
normalizeDeviceId: normalizeDeviceId, normalizeDeviceId: normalizeDeviceId,
@@ -66,6 +72,35 @@ async function start() {
let hasLoggedPlayerStartup = false; let hasLoggedPlayerStartup = false;
function normalizePlayerPublicBaseUrl(value) {
const normalized = String(value || '').trim().replace(/\/$/, '');
if (!normalized) {
return null;
}
try {
return new URL(normalized).origin.replace(/\/$/, '');
} catch (_error) {
return normalized;
}
}
function setPlayerPublicBaseUrl(value) {
const nextBaseUrl = normalizePlayerPublicBaseUrl(value);
if (!nextBaseUrl || nextBaseUrl === playerPublicBaseUrl) {
return;
}
playerPublicBaseUrl = nextBaseUrl;
if (typeof refreshThinClientRegistration === 'function') {
refreshThinClientRegistration();
}
}
function getPlayerPublicBaseUrl() {
return playerPublicBaseUrl;
}
function logPlayerStartup(connectionState) { function logPlayerStartup(connectionState) {
if (hasLoggedPlayerStartup) { if (hasLoggedPlayerStartup) {
return; return;
@@ -75,7 +110,7 @@ async function start() {
console.info('[player] startup', { console.info('[player] startup', {
mode: isRemotePlayer ? 'bridge client' : 'local', mode: isRemotePlayer ? 'bridge client' : 'local',
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false, connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
publicBaseUrl: PLAYER_PUBLIC_URL || null, publicBaseUrl: getPlayerPublicBaseUrl(),
bridgeBaseUrl: PLAYER_INTERNAL_URL || null, bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
}); });
@@ -99,29 +134,84 @@ async function start() {
} }
async function triggerWebMediaSync() { async function triggerWebMediaSync() {
if (!isRemotePlayer || !BRIDGE_PUBLIC_URL) { const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
if (!isRemotePlayer || !syncBaseUrl) {
return false; return false;
} }
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try { try {
const authHeaders = createRequestAuthHeaders({ const authHeaders = createRequestAuthHeaders({
method: 'POST', method: 'POST',
pathname: '/api/internal/sync/player-media' pathname: '/api/internal/sync/player-media',
body: requestBody
}); });
const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-media`, { const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-media`, {
method: 'POST', method: 'POST',
headers: Object.assign({ headers: Object.assign({
Accept: 'application/json' Accept: 'application/json',
}, authHeaders) 'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
}); });
return Boolean(response && response.ok); return Boolean(response && response.ok);
} catch (_error) { } catch (_error) {
console.warn('[player] Startup media sync failed');
return false;
}
}
async function triggerWebFontSync() {
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
if (!isRemotePlayer || !syncBaseUrl) {
return false;
}
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: requestBody
});
const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-font`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json',
'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
});
return Boolean(response && response.ok);
} catch (_error) {
console.warn('[player] Startup font sync failed');
return false; return false;
} }
} }
let webMediaSyncCompleted = false; let webMediaSyncCompleted = false;
let webFontSyncCompleted = false;
let webFontSyncTriggered = false;
function shouldTriggerReconnectSync() {
if (!lastDisconnectAt) {
return true;
}
return Date.now() - lastDisconnectAt >= RECONNECT_SYNC_STALE_MS;
}
async function handleThinClientCommand(socket, rawMessage) { async function handleThinClientCommand(socket, rawMessage) {
let payload = null; let payload = null;
@@ -211,7 +301,7 @@ async function start() {
common: common, common: common,
playerRuntime: playerRuntime, playerRuntime: playerRuntime,
onboardingStore: onboardingStore, onboardingStore: onboardingStore,
playerPublicBaseUrl: PLAYER_PUBLIC_URL, playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL, playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID playerDeviceId: PLAYER_DEVICE_ID
@@ -224,10 +314,10 @@ async function start() {
playerRuntime: playerRuntime, playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService, playerPlaylistService: playerPlaylistService,
rtmpStreamService: rtmpStreamService, rtmpStreamService: rtmpStreamService,
playerPublicBaseUrl: PLAYER_PUBLIC_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_URL, playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID playerDeviceId: PLAYER_DEVICE_ID,
onPlayerPublicBaseUrl: setPlayerPublicBaseUrl
}); });
function createThinClientWebSocketUrl() { function createThinClientWebSocketUrl() {
@@ -275,6 +365,54 @@ async function start() {
}); });
thinClientSocket = socket; thinClientSocket = socket;
function sendHeartbeat() {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL
}));
}
refreshThinClientRegistration = sendHeartbeat;
function triggerMediaSyncIfNeeded() {
if (webMediaSyncTriggered || webMediaSyncCompleted) {
return;
}
webMediaSyncTriggered = true;
triggerWebMediaSync().then(function (success) {
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
if (!success) {
webMediaSyncTriggered = false;
}
}).catch(function () {
webMediaSyncTriggered = false;
});
}
function triggerFontSyncIfNeeded() {
if (webFontSyncTriggered || webFontSyncCompleted) {
return;
}
webFontSyncTriggered = true;
triggerWebFontSync().then(function (success) {
webFontSyncCompleted = Boolean(success) || webFontSyncCompleted;
if (!success) {
webFontSyncTriggered = false;
}
}).catch(function () {
webFontSyncTriggered = false;
webFontSyncCompleted = false;
});
}
function sendSnapshot(slug) { function sendSnapshot(slug) {
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
return; return;
@@ -299,7 +437,7 @@ async function start() {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'register', type: 'register',
deviceId: PLAYER_DEVICE_ID, deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_URL, publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL internalBaseUrl: PLAYER_INTERNAL_URL
})); }));
@@ -307,38 +445,32 @@ async function start() {
sendSnapshot(slug); sendSnapshot(slug);
}); });
if (!webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
heartbeatTimer = setInterval(function () { heartbeatTimer = setInterval(function () {
if (!socket || socket.readyState !== WebSocket.OPEN) { sendHeartbeat();
return;
}
socket.send(JSON.stringify({
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_URL,
internalBaseUrl: PLAYER_INTERNAL_URL
}));
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
}, DB_SYNC_INTERVAL_MS); }, DB_SYNC_INTERVAL_MS);
}); });
socket.on('message', function (rawMessage) { socket.on('message', function (rawMessage) {
let parsedMessage = null;
try {
parsedMessage = JSON.parse(String(rawMessage || '{}'));
} catch (_error) {
parsedMessage = null;
}
if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') {
sendHeartbeat();
return;
}
if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') {
if (shouldTriggerReconnectSync()) {
triggerMediaSyncIfNeeded();
triggerFontSyncIfNeeded();
}
return;
}
handleThinClientCommand(socket, rawMessage).catch(function (error) { handleThinClientCommand(socket, rawMessage).catch(function (error) {
try { try {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
@@ -354,9 +486,14 @@ async function start() {
}); });
socket.on('close', function () { socket.on('close', function () {
lastDisconnectAt = Date.now();
webFontSyncTriggered = false;
webFontSyncCompleted = false;
webMediaSyncCompleted = false;
clearTimers(); clearTimers();
thinClientSocket = null; thinClientSocket = null;
reconnectTimer = setTimeout(connect, 5000); refreshThinClientRegistration = null;
reconnectTimer = setTimeout(connect, PLAYER_AGENT_RECONNECT_DELAY_MS);
}); });
socket.on('error', function () { socket.on('error', function () {
@@ -402,7 +539,7 @@ async function start() {
try { try {
await recordPlayerHeartbeat(pool, { await recordPlayerHeartbeat(pool, {
deviceId: PLAYER_DEVICE_ID, deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_URL, publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL internalBaseUrl: PLAYER_INTERNAL_URL
}).catch(function (error) { }).catch(function (error) {
console.error(error); console.error(error);
@@ -431,7 +568,7 @@ async function start() {
if (PLAYER_DEVICE_ID && !isRemotePlayer) { if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding'); const { upsertPlayerRegistration } = require('./player/onboarding');
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_URL, PLAYER_INTERNAL_URL).catch(function (error) { await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
console.error(error); console.error(error);
}); });
} }
+6 -5
View File
@@ -16,15 +16,16 @@ function normalizeDeviceId(value) {
} }
function getPublicBaseUrl(req, configuredUrl) { function getPublicBaseUrl(req, configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) {
return configured;
}
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0]; const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http'); const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0]; const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req.headers.host || '').trim(); const host = forwardedHost || String(req.headers.host || '').trim();
return `${protocol}://${host}`.replace(/\/$/, ''); if (host) {
return `${protocol}://${host}`.replace(/\/$/, '');
}
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
} }
function getRequestIp(req) { function getRequestIp(req) {
+2
View File
@@ -228,6 +228,7 @@ function renderRegion(region, regionContent) {
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups); var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, 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 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 height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1; var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
@@ -241,6 +242,7 @@ function renderRegion(region, regionContent) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, { 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 : '', 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 : '',
timeZone: timeZone,
group: group || {}, group: group || {},
entries: entries, entries: entries,
index: index + 1 index: index + 1
+3 -3
View File
@@ -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')
}; };
@@ -233,7 +233,7 @@ function renderTemplate(format, timeZone, date) {
var template = String(format || '').trim() || DEFAULT_FORMAT; var template = String(format || '').trim() || DEFAULT_FORMAT;
var values = getFormattedParts(timeZone, date); var values = getFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { 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 }) || '');
}); });
} }
+9 -1
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth'); const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { getPlayerPublicBaseUrl } = require('./onboarding');
const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -31,10 +32,10 @@ function registerPlayerRoutes(app, options) {
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null; const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) { if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.'); throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
@@ -296,6 +297,13 @@ function registerPlayerRoutes(app, options) {
}); });
app.get('/screen/:slug', function (req, res) { app.get('/screen/:slug', function (req, res) {
if (onPlayerPublicBaseUrl) {
try {
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
} catch (_error) {
}
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache'); res.set('Pragma', 'no-cache');
if (bridgeBaseUrl) { if (bridgeBaseUrl) {
+7 -4
View File
@@ -194,18 +194,21 @@ const PERMISSION_SECTIONS = [
} }
]; ];
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) { const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex) {
return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) { return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource, resourceIndex) {
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) { return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, actionIndex) {
return { return {
key: `${resource.key}.${action.key}`, key: `${resource.key}.${action.key}`,
name: resource.name, name: resource.name,
sectionOrder: section.order, sectionOrder: section.order,
sectionIndex: sectionIndex,
actionName: action.name, actionName: action.name,
permissionOrder: index + 1, permissionOrder: actionIndex + 1,
permissionIndex: actionIndex,
sectionName: section.sectionName, sectionName: section.sectionName,
resourceKey: resource.key, resourceKey: resource.key,
resourceOrder: resource.order, resourceOrder: resource.order,
resourceIndex: resourceIndex,
resourceName: resource.name, resourceName: resource.name,
actionKey: action.key, actionKey: action.key,
description: action.description description: action.description
+7 -1
View File
@@ -52,7 +52,13 @@ function registerStartupTasks(options) {
} }
async function initialize() { async function initialize() {
await loadTaskModules(backgroundTaskDirectory, options); const startupTaskFile = path.join(backgroundTaskDirectory, 'data-source-refresh.js');
const taskModule = require(startupTaskFile);
const exported = getTaskExport(taskModule);
if (typeof exported === 'function') {
await exported(options);
}
} }
return { return {
@@ -9,6 +9,7 @@ function registerFontSyncTask(options) {
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) { backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
const payload = task && task.payload ? task.payload : task || {}; const payload = task && task.payload ? task.payload : task || {};
const uploadDir = String(payload.uploadDir || '').trim(); const uploadDir = String(payload.uploadDir || '').trim();
const playerIdentifier = String(payload.playerIdentifier || payload.deviceId || '').trim();
const operations = Array.isArray(payload.operations) const operations = Array.isArray(payload.operations)
? payload.operations ? payload.operations
: Array.isArray(payload.uploadPaths) : Array.isArray(payload.uploadPaths)
@@ -28,9 +29,9 @@ function registerFontSyncTask(options) {
continue; continue;
} }
if (String(operation.type || '').trim().toLowerCase() === 'delete') { if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir); await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
} else { } else {
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir); await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
} }
} }
}); });
@@ -1,16 +1,40 @@
const { collectFontLibrarySyncOperations } = require('../../media/font-library'); const { collectFontLibrarySyncOperations } = require('../../media/font-library');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const TASK = { const TASK = {
key: 'initial-font-sync', key: 'initial-font-sync',
category: 'fonts' category: 'fonts'
}; };
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
return {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
playerActive: true
};
}
function registerInitialFontSyncTask(options) { function registerInitialFontSyncTask(options) {
const pool = options && options.pool;
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService; const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!pool || !backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.'); throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
} }
@@ -18,20 +42,45 @@ function registerInitialFontSyncTask(options) {
? uploadSyncService.getPlayerTaskMetadata() ? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({}); : Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) { return Promise.resolve(metadataPromise).then(async function () {
return backgroundTaskQueue.enqueueTask({ let players = [];
key: TASK.key, try {
title: 'Initial font sync', players = await fetchPlayerRegistrations(pool);
category: TASK.category, } catch (error) {
taskType: 'font-sync', console.warn('Unable to fetch player registrations for initial font sync:', error);
metadata: Object.assign({}, metadata || {}), return null;
payload: { }
mode: 'initial',
uploadDir: mediaDir, const livePlayers = Array.isArray(players)
operations: collectFontLibrarySyncOperations(mediaDir) ? players.filter(function (player) {
}, return isRecentPlayerRegistration(player, 60);
persist: true })
}); : [];
if (!livePlayers.length) {
return null;
}
const operations = collectFontLibrarySyncOperations(mediaDir);
return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial font sync',
category: TASK.category,
taskType: 'font-sync',
metadata: metadata,
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: operations,
playerIdentifier: metadata.playerIdentifier,
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
playerInternalBaseUrl: metadata.playerInternalBaseUrl
},
persist: true
});
}));
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial font sync:', error); console.warn('Unable to queue initial font sync:', error);
}); });
@@ -3,12 +3,37 @@ const TASK = {
category: 'media-sync', category: 'media-sync',
}; };
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
return {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
playerActive: true
};
}
function registerInitialMediaSyncTask(options) { function registerInitialMediaSyncTask(options) {
const pool = options && options.pool;
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService; const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!pool || !backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.'); throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
} }
@@ -16,19 +41,43 @@ function registerInitialMediaSyncTask(options) {
? uploadSyncService.getPlayerTaskMetadata() ? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({}); : Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) { return Promise.resolve(metadataPromise).then(async function () {
return backgroundTaskQueue.enqueueTask({ let players = [];
key: TASK.key, try {
title: 'Initial media sync', players = await fetchPlayerRegistrations(pool);
category: TASK.category, } catch (error) {
taskType: 'media-sync', console.warn('Unable to fetch player registrations for initial media sync:', error);
metadata: Object.assign({}, metadata || {}), return null;
payload: { }
mode: 'initial',
uploadDir: mediaDir const livePlayers = Array.isArray(players)
}, ? players.filter(function (player) {
persist: true return isRecentPlayerRegistration(player, 60);
}); })
: [];
if (!livePlayers.length) {
return null;
}
return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial media sync',
category: TASK.category,
taskType: 'media-sync',
metadata: metadata,
payload: {
mode: 'initial',
uploadDir: mediaDir,
playerIdentifier: metadata.playerIdentifier,
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
playerInternalBaseUrl: metadata.playerInternalBaseUrl
},
persist: true
});
}));
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial media sync:', error); console.warn('Unable to queue initial media sync:', error);
}); });
+182 -42
View File
@@ -36,6 +36,22 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, ''); return String(value || '').trim().replace(/\/$/, '');
} }
function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
const deviceId = String(playerIdentifier || '').trim();
if (!targetBaseUrl || !deviceId) {
return targetBaseUrl;
}
try {
const url = new URL(targetBaseUrl);
url.searchParams.set('deviceId', deviceId);
return url.toString().replace(/\/$/, '');
} catch (_error) {
return targetBaseUrl;
}
}
function normalizePlayerRowBaseUrl(player) { function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url); return normalizeBaseUrl(player && player.internal_base_url);
} }
@@ -74,8 +90,8 @@ function createUploadSyncService(options) {
throw new Error('createUploadSyncService requires the upload dependencies.'); throw new Error('createUploadSyncService requires the upload dependencies.');
} }
async function getPlayerInternalBaseUrl() { async function getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
const metadata = await getPlayerTaskMetadata(); const metadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
if (!metadata || metadata.playerActive === false) { if (!metadata || metadata.playerActive === false) {
return null; return null;
} }
@@ -83,11 +99,44 @@ function createUploadSyncService(options) {
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null; return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
} }
async function getPlayerTaskMetadata() { async function getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
if (playerTaskMetadata) { const normalizedPreferredPlayerIdentifier = String(preferredPlayerIdentifier || '').trim();
const normalizedPreferredPlayerInternalBaseUrl = String(preferredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
if (normalizedPreferredPlayerIdentifier && pool && typeof fetchPlayerRegistrations === 'function') {
try {
const players = await fetchPlayerRegistrations(pool);
const registeredPlayers = Array.isArray(players) ? players : [];
const exactPlayer = registeredPlayers.find(function (player) {
return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier;
}) || null;
if (exactPlayer) {
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null;
const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url);
const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim();
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null,
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
}
} catch (_error) {
}
}
if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) {
return playerTaskMetadata; return playerTaskMetadata;
} }
if (playerTaskMetadata && playerTaskMetadata.playerActive === false) {
playerTaskMetadata = null;
playerInternalBaseUrl = null;
}
if (playerTaskMetadataPromise) { if (playerTaskMetadataPromise) {
return playerTaskMetadataPromise; return playerTaskMetadataPromise;
} }
@@ -404,6 +453,23 @@ function createUploadSyncService(options) {
return Boolean(localUploadDir); 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) { function isPlayerUnavailableError(error) {
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase(); const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT'; return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
@@ -413,15 +479,33 @@ function createUploadSyncService(options) {
return Boolean(response) && Number(response.status) === 503; 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) { function queuePlayerUploadSync(operation) {
if (!operation || !operation.uploadPath) { if (!operation || !operation.uploadPath) {
return; 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', type: operation.type === 'delete' ? 'delete' : 'put',
uploadPath: normalizeUploadReference(operation.uploadPath), uploadPath: normalizeUploadReference(operation.uploadPath),
uploadDir: operation.uploadDir || null uploadDir: operation.uploadDir || null,
metadata: metadata,
playerIdentifier: playerIdentifier || null,
playerInternalBaseUrl: playerInternalBaseUrl || null
}); });
schedulePendingPlayerUploadSyncFlush(); schedulePendingPlayerUploadSyncFlush();
@@ -443,7 +527,7 @@ function createUploadSyncService(options) {
} }
} }
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false; return false;
} }
@@ -453,6 +537,7 @@ function createUploadSyncService(options) {
return false; return false;
} }
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const relativePath = getUploadRelativePath(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath); const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) { if (!relativePath || !sourcePath) {
@@ -474,7 +559,8 @@ function createUploadSyncService(options) {
pathname: `/api/media/${encodeURIComponent(relativePath)}`, pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer body: fileBuffer
}); });
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const response = await fetch(mediaUploadUrl, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
@@ -497,7 +583,7 @@ function createUploadSyncService(options) {
} }
} }
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false; return false;
} }
@@ -507,6 +593,7 @@ function createUploadSyncService(options) {
return false; return false;
} }
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const relativePath = getUploadRelativePath(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) { if (!relativePath) {
return false; return false;
@@ -516,7 +603,8 @@ function createUploadSyncService(options) {
method: 'DELETE', method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}` pathname: `/api/media/${encodeURIComponent(relativePath)}`
}); });
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const response = await fetch(mediaDeleteUrl, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -538,20 +626,26 @@ function createUploadSyncService(options) {
} }
} }
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) { async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
if (!shouldMirrorUploads(localUploadDir)) { if (!shouldMirrorUploads(localUploadDir)) {
return; return;
} }
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) { 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) { if (!success) {
queuePlayerUploadSync({ queuePlayerUploadSync({
type: 'put', type: 'put',
uploadPath: uniqueRefs[i], uploadPath: uniqueRefs[i],
uploadDir: localUploadDir uploadDir: localUploadDir,
playerIdentifier: preferredPlayerIdentifier,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
playerIdentifier: preferredPlayerIdentifier || null,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
} : null
}); });
} }
} }
@@ -669,10 +763,27 @@ function createUploadSyncService(options) {
return; return;
} }
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', { const players = await fetchLivePlayerRegistrations();
mode: 'playlist', if (!players.length) {
operation: operation 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() { async function flushPendingPlaylistUploadSyncs() {
@@ -733,44 +844,49 @@ function createUploadSyncService(options) {
} }
pendingPlayerUploadSyncFlushInFlight = (async function () { pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); const pendingEntries = Array.from(pendingPlayerUploadSyncs.entries());
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata const firstOperation = pendingEntries.length && pendingEntries[0] ? pendingEntries[0][1] : null;
? pendingEntries[0].metadata const summaryPlayerMetadata = firstOperation && firstOperation.metadata
: await getPlayerTaskMetadata(); ? firstOperation.metadata
if (playerMetadata && playerMetadata.playerActive === false) { : await getPlayerTaskMetadata(firstOperation && firstOperation.playerIdentifier, firstOperation && firstOperation.playerInternalBaseUrl);
pendingPlayerUploadSyncs.clear();
pendingPlayerUploadSyncRetryLogAt = 0;
return;
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl();
let successCount = 0; let successCount = 0;
let failureCount = 0; let failureCount = 0;
for (let i = 0; i < pendingEntries.length; i += 1) { 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; let success = false;
if (operation.type === 'delete') { if (operation.type === 'delete') {
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
} else { } else {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
} }
if (success) { if (success) {
successCount += 1; successCount += 1;
pendingPlayerUploadSyncs.delete(operation.uploadPath); pendingPlayerUploadSyncs.delete(pendingKey);
} else { } else {
failureCount += 1; failureCount += 1;
} }
} }
if (successCount) { 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) { if (failureCount) {
const now = Date.now(); const now = Date.now();
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) { if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
pendingPlayerUploadSyncRetryLogAt = now; 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) { } else if (!pendingPlayerUploadSyncs.size) {
pendingPlayerUploadSyncRetryLogAt = 0; pendingPlayerUploadSyncRetryLogAt = 0;
@@ -795,6 +911,8 @@ function createUploadSyncService(options) {
return; return;
} }
const playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
const data = await common.fetchAdminData(pool); const data = await common.fetchAdminData(pool);
const uploadRefs = new Set(); const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) { (data.slides || []).forEach(function (slide) {
@@ -812,7 +930,8 @@ function createUploadSyncService(options) {
queuePlayerUploadSync({ queuePlayerUploadSync({
type: 'put', type: 'put',
uploadPath: uploadPath, uploadPath: uploadPath,
uploadDir: uploadDir uploadDir: uploadDir,
metadata: playerMetadata
}); });
}); });
fontLibraryOperations.forEach(function (operation) { fontLibraryOperations.forEach(function (operation) {
@@ -823,7 +942,8 @@ function createUploadSyncService(options) {
queuePlayerUploadSync({ queuePlayerUploadSync({
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put', type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
uploadPath: operation.uploadPath, uploadPath: operation.uploadPath,
uploadDir: uploadDir uploadDir: uploadDir,
metadata: playerMetadata
}); });
}); });
await flushPendingPlayerUploadSyncs(); await flushPendingPlayerUploadSyncs();
@@ -834,14 +954,34 @@ function createUploadSyncService(options) {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload); const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) { if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir); await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
} }
if (operation.previousUploadRefs.length) { if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs); 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); 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) { if (operation.refreshScreenSlugs.length) {
@@ -870,7 +1010,7 @@ function createUploadSyncService(options) {
safePayload.operation = Object.assign({}, safePayload.operation); safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool; delete safePayload.operation.pool;
} }
const playerMetadata = await getPlayerTaskMetadata(); const playerMetadata = await getPlayerTaskMetadata(safePayload.playerIdentifier, safePayload.playerInternalBaseUrl);
const definition = { const definition = {
key: taskKey, key: taskKey,
+1 -1
View File
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
}); });
app.use(function (req, res, next) { app.use(function (req, res, next) {
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') { if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') {
return next(); return next();
} }
+32
View File
@@ -756,6 +756,38 @@
background: var(--bs-tertiary-bg); 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 { .api-source-section-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2,14 +2,17 @@
(function () { (function () {
var form = document.getElementById('timetable-group-form'); var form = document.getElementById('timetable-group-form');
var timezoneInput = document.getElementById('timetable-group-timezone');
var body = document.querySelector('[data-timetable-entries-body]'); var body = document.querySelector('[data-timetable-entries-body]');
var addButton = document.querySelector('[data-add-timetable-entry]'); var addButton = document.querySelector('[data-add-timetable-entry]');
var template = document.getElementById('timetable-entry-row-template'); var template = document.getElementById('timetable-entry-row-template');
if (!form || !body || !addButton || !template) { if (!form || !timezoneInput || !body || !addButton || !template) {
return; return;
} }
var DEFAULT_TIME_ZONE = 'Europe/London';
function markDirty() { function markDirty() {
form.dataset.dirty = 'true'; form.dataset.dirty = 'true';
} }
@@ -18,43 +21,150 @@
return String(value).padStart(2, '0'); 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())) { 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 '';
} }
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', 'T',
pad(date.getHours()), pad(parts.hour),
':', ':',
pad(date.getMinutes()) pad(parts.minute)
].join(''); ].join('');
} }
function parseUtcDateTimeLocalValue(value) { function getTimeZoneOffsetMillis(date, timeZone) {
var raw = String(value || '').trim(); var parts = getDateTimeParts(date, timeZone);
if (!raw) { if (!parts) {
return null; return 0;
} }
var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z'; var localAsUtc = Date.UTC(
var date = new Date(normalized); Number(parts.year) || 0,
return Number.isNaN(date.getTime()) ? null : date; (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(); var raw = String(value || '').trim();
if (!raw) { var isoDate;
return null; 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); var resolvedTimeZone = resolveTimeZone(timeZone);
return Number.isNaN(date.getTime()) ? null : date; 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) { function clearFieldValidity(input) {
@@ -82,17 +192,21 @@
} }
function validateEntryRow(row) { function validateEntryRow(row) {
if (!row) { var timezone = resolveTimeZone(timezoneInput.value);
return; var startInput;
} var endInput;
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
var startValue; var startValue;
var endValue; var endValue;
var startDate; var startDate;
var endDate; var endDate;
if (!row) {
return;
}
startInput = row.querySelector('[name="entry_start_datetime[]"]');
endInput = row.querySelector('[name="entry_end_datetime[]"]');
clearFieldValidity(endInput); clearFieldValidity(endInput);
if (!startInput || !endInput) { if (!startInput || !endInput) {
@@ -106,8 +220,8 @@
return; return;
} }
startDate = parseDateTimeLocalValue(startValue); startDate = parseDateTimeLocalAsUtc(startValue, timezone);
endDate = parseDateTimeLocalValue(endValue); endDate = parseDateTimeLocalAsUtc(endValue, timezone);
if (!startDate || !endDate) { if (!startDate || !endDate) {
return; return;
@@ -119,12 +233,15 @@
} }
function bindRowValidation(row) { function bindRowValidation(row) {
var startInput;
var endInput;
if (!row) { if (!row) {
return; return;
} }
var startInput = row.querySelector('[name="entry_start_datetime[]"]'); startInput = row.querySelector('[name="entry_start_datetime[]"]');
var endInput = row.querySelector('[name="entry_end_datetime[]"]'); endInput = row.querySelector('[name="entry_end_datetime[]"]');
if (!startInput || !endInput) { if (!startInput || !endInput) {
return; return;
@@ -142,32 +259,37 @@
validateEntryRow(row); validateEntryRow(row);
} }
function toUtcDateTimeLocalValue(value) { function syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone) {
var localDate = new Date(String(value || '').trim());
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
}
function syncRowValuesToLocal(row) {
if (!row) { if (!row) {
return; return;
} }
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) { ['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
var input = row.querySelector('[name="' + name + '"]'); var input = row.querySelector('[name="' + name + '"]');
if (!input || input.dataset.timetableTimezoneSynced === 'true') { var convertedValue;
if (!input) {
return; return;
} }
var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value)); convertedValue = convertDateTimeLocalValue(input.value, sourceTimeZone, targetTimeZone);
if (localValue) { if (convertedValue) {
input.value = localValue; 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) { function syncFormDataToUtc(formData) {
var rows = body.querySelectorAll('[data-timetable-entry-row]'); 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) { ['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
formData.delete(name); formData.delete(name);
}); });
@@ -182,8 +304,8 @@
formData.append('entry_id[]', idInput ? idInput.value : ''); formData.append('entry_id[]', idInput ? idInput.value : '');
formData.append('entry_title[]', titleInput ? titleInput.value : ''); formData.append('entry_title[]', titleInput ? titleInput.value : '');
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : ''); formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : ''); formData.append('entry_start_datetime[]', startInput ? convertDateTimeLocalValue(startInput.value, currentTimeZone, 'UTC') : '');
formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : ''); formData.append('entry_end_datetime[]', endInput ? convertDateTimeLocalValue(endInput.value, currentTimeZone, 'UTC') : '');
}); });
} }
@@ -210,9 +332,16 @@
markDirty(); 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) { body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
bindRemove(row); bindRemove(row);
syncRowValuesToLocal(row);
bindRowValidation(row); bindRowValidation(row);
}); });
@@ -220,5 +349,8 @@
syncFormDataToUtc(event.formData); syncFormDataToUtc(event.formData);
}); });
timezoneInput.addEventListener('change', handleTimezoneChange);
timezoneInput.addEventListener('input', handleTimezoneChange);
addButton.addEventListener('click', addRow); addButton.addEventListener('click', addRow);
}()); }());
+11 -1
View File
@@ -272,8 +272,18 @@
function buildPreviewRenderContext(region, card, existingContent, sources) { function buildPreviewRenderContext(region, card, existingContent, sources) {
var current = getCurrentConfig(region, 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 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 ? { 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, 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, item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number,
items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path
+11 -1
View File
@@ -277,8 +277,18 @@
function buildPreviewRenderContext(region, card, existingContent, feeds) { function buildPreviewRenderContext(region, card, existingContent, feeds) {
var current = getCurrentConfig(region, existingContent || {}); var current = getCurrentConfig(region, existingContent || {});
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; 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 ? { 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, 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 item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number
} : current; } : current;
+116 -11
View File
@@ -43,6 +43,52 @@
return group && Array.isArray(group.entries) ? group.entries : []; 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) { function toDate(value) {
if (!value) { if (!value) {
return null; return null;
@@ -119,9 +165,9 @@
var current = existingContent[region.region_key] || {}; var current = existingContent[region.region_key] || {};
return { return {
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id), 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', display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''), value: String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''),
max_items: Math.max(1, Number(current.max_items || 5)), 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_family: current.font_family || region.font_family || getDefaultStyle().font_family,
font_size: current.font_size || region.font_size || getDefaultStyle().font_size, font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
font_color: current.font_color || region.font_color || getDefaultStyle().font_color font_color: current.font_color || region.font_color || getDefaultStyle().font_color
@@ -143,14 +189,47 @@
}).join(''); }).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) { function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
var current = existingContent[regionId] || {}; var current = existingContent[regionId] || {};
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null; 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 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 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 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 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 || 5)); var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5));
var group = getGroupById(groupId, timetableGroups); var group = getGroupById(groupId, timetableGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups); var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
if (!entries.length) { if (!entries.length) {
@@ -169,10 +248,10 @@
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : []; 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 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 currentDisplayMode = String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5)); var currentMaxItems = Math.max(1, Number(current.max_items || current.timetable_max_items || 5));
var currentGroup = getGroupById(currentGroupId, timetableGroups); var currentGroup = getGroupById(currentGroupId, timetableGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups); var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
if (!currentEntries.length) { if (!currentEntries.length) {
@@ -219,7 +298,12 @@
'<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> 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>' + '</div>' +
'</div>' + '</div>' +
@@ -287,13 +371,29 @@
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null; 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 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 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 { return {
value: value, value: value,
style: getTextStyle(region, current), style: getTextStyle(region, current),
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_long: timezoneValues.tz_long,
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 : []
@@ -303,7 +403,7 @@
function renderPreview(region, regionContent, context) { function renderPreview(region, regionContent, context) {
var groups = context && context.timetableGroups ? context.timetableGroups : []; var groups = context && context.timetableGroups ? context.timetableGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {}); 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 groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
@@ -315,9 +415,14 @@
} }
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="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
var entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || '');
var timezoneValues = getTimezoneValues(group, entryDate);
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, { return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
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_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long,
group: group || {}, group: group || {},
entries: entries, entries: entries,
index: index + 1 index: index + 1
+11 -1
View File
@@ -84,7 +84,17 @@
} : {}; } : {};
return { 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, style: style,
existingContent: existingContent || {} existingContent: existingContent || {}
}; };
+6 -6
View File
@@ -156,19 +156,19 @@
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')
}; };
} }
function resolveTimeDatePlaceholder(values, expression) { function resolveTimeDatePlaceholder(values, expression, options) {
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') { 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(); var parsed = String(expression || '').trim();
@@ -200,7 +200,7 @@
'MMMM', 'MMMM',
'a', 'a',
'tz', 'tz',
'tz_short' 'tz_long'
]; ];
if (typeof placeholderChips.renderChips === 'function') { if (typeof placeholderChips.renderChips === 'function') {
@@ -220,7 +220,7 @@
var template = String(format || '').trim(); var template = String(format || '').trim();
var values = getFormattedParts(timeZone, date); var values = getFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { 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 }) || '');
}); });
} }
+177 -28
View File
@@ -75,36 +75,148 @@
return text; return text;
} }
function formatDateValue(value, pattern) { function getDefaultTimeZone() {
var date = value instanceof Date ? value : new Date(value); 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())) { if (Number.isNaN(date.getTime())) {
return ''; return '';
} }
var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm'; var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm';
var hours24 = date.getHours(); var parts = timeZone ? getDatePartsFromTimeZone(date, timeZone) : getDatePartsFromLocalTime(date);
var hours12 = hours24 % 12 || 12; var hours24 = parts.hour24;
var hours12 = parts.hour12;
var tokenMap = { var tokenMap = {
YYYY: String(date.getFullYear()), YYYY: parts.year,
YY: String(date.getFullYear()).slice(-2), YY: parts.year.slice(-2),
MMMM: monthNamesLong[date.getMonth()], MMMM: parts.monthLong,
MMM: monthNamesShort[date.getMonth()], MMM: parts.monthShort,
MM: padNumber(date.getMonth() + 1, 2), MM: parts.month,
M: String(date.getMonth() + 1), M: String(Number(parts.month) || 0),
DD: padNumber(date.getDate(), 2), DD: parts.day,
D: String(date.getDate()), D: String(Number(parts.day) || 0),
dddd: dayNamesLong[date.getDay()], dddd: parts.weekdayLong,
ddd: dayNamesShort[date.getDay()], ddd: parts.weekdayShort,
HH: padNumber(hours24, 2), HH: hours24,
H: String(hours24), H: String(Number(hours24) || 0),
hh: padNumber(hours12, 2), hh: hours12,
h: String(hours12), h: String(Number(hours12) || 0),
mm: padNumber(date.getMinutes(), 2), mm: parts.minute,
m: String(date.getMinutes()), m: String(Number(parts.minute) || 0),
ss: padNumber(date.getSeconds(), 2), ss: parts.second,
s: String(date.getSeconds()), s: String(Number(parts.second) || 0),
A: hours24 >= 12 ? 'PM' : 'AM', A: parts.dayPeriod,
a: hours24 >= 12 ? 'pm' : 'am' 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) { 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 text = String(value === undefined || value === null ? '' : value);
var name = String(transform && transform.name || '').trim().toLowerCase(); var name = String(transform && transform.name || '').trim().toLowerCase();
var args = Array.isArray(transform && transform.args) ? transform.args : []; var args = Array.isArray(transform && transform.args) ? transform.args : [];
@@ -132,18 +273,26 @@
} }
if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') { 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; return text;
} }
function resolvePlaceholderExpression(value, expression) { function resolvePlaceholderExpression(value, expression, options) {
var parsed = parsePlaceholderExpression(expression); var parsed = parsePlaceholderExpression(expression);
var resolved = resolvePath(value, parsed.path); var resolved = resolvePath(value, parsed.path);
parsed.transforms.forEach(function (transform) { parsed.transforms.forEach(function (transform) {
resolved = applyTransform(resolved, transform); resolved = applyTransform(resolved, transform, options || {});
}); });
return resolved; return resolved;
@@ -2,47 +2,79 @@
(function () { (function () {
var root = window; var root = window;
var PLACEHOLDERS = [ var FORMAT_PLACEHOLDERS = [
{ token: 'h', label: 'h' }, { token: 'h', output: '1-12', description: 'The hour, 12-hour clock' },
{ token: 'hh', label: 'hh' }, { token: 'hh', output: '01-12', description: 'The hour, 12-hour clock, 2-digits' },
{ token: 'm', label: 'm' }, { token: 'm', output: '0-59', description: 'The minute' },
{ token: 'mm', label: 'mm' }, { token: 'mm', output: '00-59', description: 'The minute, 2-digits' },
{ token: 's', label: 's' }, { token: 's', output: '0-59', description: 'The second' },
{ token: 'ss', label: 'ss' }, { token: 'ss', output: '00-59', description: 'The second, 2-digits' },
{ token: 'd', label: 'd' }, { token: 'A', output: 'AM/PM', description: 'Uppercase day period' },
{ token: 'dd', label: 'dd' }, { token: 'a', output: 'am/pm', description: 'Lowercase day period' },
{ token: 'M', label: 'M' }, { token: 'D', output: '1-31', description: 'The day of the month' },
{ token: 'MM', label: 'MM' }, { token: 'DD', output: '01-31', description: 'The day of the month, 2-digits' },
{ token: 'y', label: 'y' }, { token: 'ddd', output: 'Mon', description: 'The abbreviated weekday name' },
{ token: 'yyyy', label: 'yyyy' }, { token: 'dddd', output: 'Monday', description: 'The full weekday name' },
{ token: 'yy', label: 'yy' }, { token: 'M', output: '1-12', description: 'The month, beginning at 1' },
{ token: 'ddd', label: 'ddd' }, { token: 'MM', output: '01-12', description: 'The month, 2-digits' },
{ token: 'dddd', label: 'dddd' }, { token: 'MMM', output: 'Jan-Dec', description: 'The abbreviated month name' },
{ token: 'MMM', label: 'MMM' }, { token: 'MMMM', output: 'January-December', description: 'The full month name' },
{ token: 'MMMM', label: 'MMMM' }, { token: 'YY', output: '18', description: 'The two-digit year' },
{ token: 'a', label: 'a' }, { token: 'YYYY', output: '2018', description: 'The four-digit year' }
{ token: 'tz', label: 'tz' },
{ token: 'tz_short', label: 'tz_short' }
]; ];
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() { function getTokens() {
return PLACEHOLDERS.slice(); return PLACEHOLDERS.slice();
} }
function renderChips() { function getFormatTokens() {
if (root.placeholderChips && typeof root.placeholderChips.renderChips === 'function') { return FORMAT_PLACEHOLDERS.slice();
return root.placeholderChips.renderChips(PLACEHOLDERS.map(function (item) { }
return item.token;
}));
}
return PLACEHOLDERS.map(function (item) { function renderTable() {
return '<span class="chip">{{' + item.token + '}}</span>'; return '' +
}).join(''); '<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 = { root.timeDatePlaceholders = {
getTokens: getTokens, getTokens: getTokens,
renderChips: renderChips getFormatTokens: getFormatTokens,
renderTable: renderTable
}; };
}()); }());
+23 -8
View File
@@ -72,6 +72,18 @@ export function createSlideFormEditorController(options) {
return window.tinymce || null; 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() { function getThemeName() {
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase(); var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
if (theme === 'auto') { if (theme === 'auto') {
@@ -95,14 +107,19 @@ export function createSlideFormEditorController(options) {
} }
function syncEditorState(regionId, editor, shouldLock, hydrated) { 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 (editor && typeof editor.save === 'function') {
if (typeof editor.blur === 'function') {
editor.blur();
}
editor.save(); editor.save();
} }
var content = editor.getContent({ format: 'html' }); var content = sourceElm && sourceElm.value !== undefined ? String(sourceElm.value || '') : getEditorContentSafely(editor, fallbackContent);
content = isEmptyRichTextValue(content) ? '' : content; content = isEmptyRichTextValue(content) ? '' : content;
var hidden = getEditorHiddenInput(regionId);
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
if (hidden) { if (hidden) {
hidden.value = content; hidden.value = content;
@@ -211,7 +228,7 @@ export function createSlideFormEditorController(options) {
} }
editor.on('init', function () { 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; content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) { if (hidden) {
hidden.value = content; hidden.value = content;
@@ -405,14 +422,12 @@ export function createSlideFormEditorController(options) {
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) { var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) {
var regionId = holder.getAttribute('data-region-id'); var regionId = holder.getAttribute('data-region-id');
var editor = editorInstances.get(regionId); var editor = editorInstances.get(regionId);
var hidden = getEditorHiddenInput(regionId);
if (!editor || !hidden) { if (!editor) {
return Promise.resolve(); return Promise.resolve();
} }
hidden.value = editor.getContent({ format: 'html' }); syncEditorState(regionId, editor, false, true);
syncEditorFontSizeHidden(regionId);
return Promise.resolve(); return Promise.resolve();
}); });
+8
View File
@@ -840,6 +840,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
throw new Error('Wait for the image upload to finish before saving.'); throw new Error('Wait for the image upload to finish before saving.');
} }
await slideFormEditorController.syncEditors(); await slideFormEditorController.syncEditors();
await new Promise(function (resolve) {
window.requestAnimationFrame(function () {
resolve();
});
});
await new Promise(function (resolve) {
window.setTimeout(resolve, 0);
});
await syncRegionCards(); await syncRegionCards();
}, },
fallbackSuccessMessage: 'Saved slide.', fallbackSuccessMessage: 'Saved slide.',
+24 -4
View File
@@ -19,6 +19,11 @@
const LIST_PAGE_SIZE = 25; const LIST_PAGE_SIZE = 25;
function toSortIndex(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 999;
}
function slugifyRoleKey(name) { function slugifyRoleKey(name) {
const value = String(name || '').trim().toLowerCase(); const value = String(name || '').trim().toLowerCase();
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH); const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH);
@@ -73,8 +78,11 @@
resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'), resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
resourceName: definition ? definition.resourceName : permission.section_name, resourceName: definition ? definition.resourceName : permission.section_name,
categoryName: definition ? definition.sectionName : permission.section_name, categoryName: definition ? definition.sectionName : permission.section_name,
sectionIndex: definition ? toSortIndex(definition.sectionIndex) : 999,
sectionOrder: definition ? definition.sectionOrder : 999, sectionOrder: definition ? definition.sectionOrder : 999,
resourceIndex: definition ? toSortIndex(definition.resourceIndex) : 999,
resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999, resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999,
permissionIndex: definition ? toSortIndex(definition.permissionIndex) : 999,
permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999, permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999,
actionKey: definition ? definition.actionKey : 'read', actionKey: definition ? definition.actionKey : 'read',
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey), actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
@@ -103,7 +111,9 @@
id: sectionKey || 'permissions', id: sectionKey || 'permissions',
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(), title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
categoryName: String(permission.categoryName || '').trim(), categoryName: String(permission.categoryName || '').trim(),
sectionIndex: toSortIndex(permission.sectionIndex),
sectionOrder: Number(permission.sectionOrder) || 999, sectionOrder: Number(permission.sectionOrder) || 999,
resourceIndex: toSortIndex(permission.resourceIndex),
permissions: [] permissions: []
}; };
groupIndex.set(sectionKey, group); groupIndex.set(sectionKey, group);
@@ -114,8 +124,13 @@
groups.forEach(function (group) { groups.forEach(function (group) {
group.permissions.sort(function (left, right) { group.permissions.sort(function (left, right) {
const leftOrder = Number(left.permissionOrder) || 999; const leftIndex = toSortIndex(left.permissionIndex);
const rightOrder = Number(right.permissionOrder) || 999; const rightIndex = toSortIndex(right.permissionIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.permissionOrder);
const rightOrder = toSortIndex(right.permissionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -124,8 +139,13 @@
}); });
groups.sort(function (left, right) { groups.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999; const leftIndex = toSortIndex(left.sectionIndex);
const rightOrder = Number(right.sectionOrder) || 999; const rightIndex = toSortIndex(right.sectionIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.sectionOrder);
const rightOrder = toSortIndex(right.sectionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -9,7 +9,8 @@ function buildDuplicateTimetableGroup(timetableGroup, duplicateName) {
return Object.assign({}, timetableGroup, { return Object.assign({}, timetableGroup, {
id: null, id: null,
name: duplicateName, 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. // 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() { function buildDefaultTimetableGroup() {
return { return {
id: null, id: null,
name: '', name: '',
shortDescription: '' shortDescription: '',
timezone: 'Europe/London'
}; };
} }
function formatDateTimeLocalValue(value) { function formatDateTimeLocalValue(value, timeZone) {
if (!value) { if (!value) {
return ''; return '';
} }
@@ -18,22 +44,49 @@ function formatDateTimeLocalValue(value) {
return ''; return '';
} }
const year = String(date.getFullYear()).padStart(4, '0'); const resolvedTimeZone = normalizeTimeZone(timeZone, 'Europe/London');
const month = String(date.getMonth() + 1).padStart(2, '0'); const parts = new Intl.DateTimeFormat('en-GB', {
const day = String(date.getDate()).padStart(2, '0'); timeZone: resolvedTimeZone,
const hours = String(date.getHours()).padStart(2, '0'); hour12: false,
const minutes = String(date.getMinutes()).padStart(2, '0'); year: 'numeric',
return `${year}-${month}-${day}T${hours}:${minutes}`; 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) { function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit, options) {
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {}); const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
viewTimetableGroup.timezone = normalizeTimeZone(viewTimetableGroup.timezone, 'Europe/London');
const viewOptions = options || {}; const viewOptions = options || {};
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
? timetableEntries.map(function (entry) { ? timetableEntries.map(function (entry) {
return Object.assign({}, entry, { return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime), startValue: formatDateTimeLocalValue(entry.start_datetime, viewTimetableGroup.timezone),
endValue: formatDateTimeLocalValue(entry.end_datetime) endValue: formatDateTimeLocalValue(entry.end_datetime, viewTimetableGroup.timezone)
}); });
}) })
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }]; : [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
@@ -46,6 +99,7 @@ function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, mess
messageVariant: viewOptions.messageVariant || '', messageVariant: viewOptions.messageVariant || '',
isEdit: Boolean(isEdit), isEdit: Boolean(isEdit),
timetableGroup: viewTimetableGroup, timetableGroup: viewTimetableGroup,
timeZoneOptions: COMMON_TIME_ZONES,
timetableEntries: viewTimetableEntries, timetableEntries: viewTimetableEntries,
inUse: Boolean(viewTimetableGroup.inUse), inUse: Boolean(viewTimetableGroup.inUse),
showSaveSecondaryActions: true, showSaveSecondaryActions: true,
+29 -6
View File
@@ -1,22 +1,45 @@
// Timetable group list page renderer. // Timetable group list page renderer.
const { renderView } = require('../../../view'); 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) { if (!value) {
return 'No entries'; return 'No entries';
} }
const label = typeof formatDashboardDate === 'function' const label = formatDateInTimeZone(value, timeZone) || String(value);
? formatDashboardDate(value)
: String(value);
return label || 'No entries'; 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 timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
const timeZone = normalizeTimeZone(timetableGroup.timezone, 'Europe/London');
return Object.assign({}, timetableGroup, { 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() : '' nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : ''
}); });
}); });
@@ -42,7 +42,7 @@ async function getDataSourceUsageMaps(pool, common) {
return timetableGroupIds; return timetableGroupIds;
} }
function readScheduleArrayFieldValue(body, keys) { function readTimetableArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys]; const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) { for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index]; const key = searchKeys[index];
@@ -57,17 +57,17 @@ function readScheduleArrayFieldValue(body, keys) {
return []; return [];
} }
function buildScheduleEntryRows(req, parseDateTimeLocal) { function buildTimetableEntryRows(req, parseDateTimeLocal) {
const body = req.body || {}; const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']); const ids = readTimetableArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']); const titles = readTimetableArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']); const descriptions = readTimetableArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']); const starts = readTimetableArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_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); 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]; })) { 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; error.statusCode = 400;
throw error; throw error;
} }
@@ -86,19 +86,19 @@ function buildScheduleEntryRows(req, parseDateTimeLocal) {
} }
if (!title) { 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; error.statusCode = 400;
throw error; throw error;
} }
if (!startDatetime) { 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; error.statusCode = 400;
throw error; throw error;
} }
if (endDatetime && endDatetime < startDatetime) { 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; error.statusCode = 400;
throw error; throw error;
} }
@@ -191,7 +191,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name); let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name);
let duplicateIndex = 2; 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 + ')'; duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name) + ' (' + duplicateIndex + ')';
duplicateIndex += 1; duplicateIndex += 1;
} }
@@ -207,16 +207,16 @@ module.exports = function registerTimetableRoutes(app, deps) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
const payload = common.buildTimetableGroupPayload(req, null); 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.')); 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); const actorId = getAuditUserId(req);
await connection.beginTransaction(); await connection.beginTransaction();
const [result] = await connection.query( const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)', 'INSERT INTO i_timetable_groups (name, short_description, timezone, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId] [payload.name, payload.shortDescription || null, payload.timezone, actorId, actorId]
); );
if (entryRows.length) { if (entryRows.length) {
@@ -233,7 +233,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
}); });
await connection.query( 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] [insertRows]
); );
} }
@@ -265,18 +265,18 @@ module.exports = function registerTimetableRoutes(app, deps) {
} }
const payload = common.buildTimetableGroupPayload(req, timetableGroup); 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.')); 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); const actorId = getAuditUserId(req);
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query( await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?', 'UPDATE i_timetable_groups SET name = ?, short_description = ?, timezone = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, timetableGroup.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) { if (entryRows.length) {
const insertRows = entryRows.map(function (entry) { const insertRows = entryRows.map(function (entry) {
@@ -292,7 +292,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
}); });
await connection.query( 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] [insertRows]
); );
} }
@@ -330,7 +330,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
await connection.beginTransaction(); 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(); await connection.commit();
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
+63 -6
View File
@@ -1,6 +1,7 @@
// Internal sync trigger routes for player-driven queue flushes. // Internal sync trigger routes for player-driven queue flushes.
const { verifyRequestAuth } = require('#src/request-auth'); const { verifyRequestAuth } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
function requireRequestAuth(req, res, next) { function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) { if (!verifyRequestAuth(req)) {
@@ -11,21 +12,77 @@ function requireRequestAuth(req, res, next) {
} }
module.exports = function registerInternalSyncRoutes(app, deps) { module.exports = function registerInternalSyncRoutes(app, deps) {
const backgroundTaskQueue = deps && deps.backgroundTaskQueue;
const uploadSyncService = deps && deps.uploadSyncService; const uploadSyncService = deps && deps.uploadSyncService;
const mediaDir = String(deps && deps.mediaDir || '').trim(); const mediaDir = String(deps && deps.mediaDir || '').trim();
if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) { if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function' || !uploadSyncService || !mediaDir) {
throw new Error('registerInternalSyncRoutes requires the sync dependencies.'); throw new Error('registerInternalSyncRoutes requires the sync dependencies.');
} }
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try { try {
await uploadSyncService.runMediaSyncTask({ const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
mode: 'initial', ? _req.body
uploadDir: mediaDir : {};
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const task = await backgroundTaskQueue.enqueueTask({
key: 'player-media-sync',
title: 'Player media sync',
category: 'media-sync',
taskType: 'media-sync',
metadata: {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
},
payload: {
mode: 'initial',
uploadDir: mediaDir,
playerIdentifier: playerIdentifier,
playerPublicBaseUrl: playerPublicBaseUrl,
playerInternalBaseUrl: playerInternalBaseUrl
}
}); });
await uploadSyncService.flushPendingPlayerUploadSyncs(); res.status(202).json({ ok: true, queued: true, task: task });
res.json({ ok: true }); } catch (error) {
next(error);
}
});
app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) {
try {
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? _req.body
: {};
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const operations = collectFontLibrarySyncOperations(mediaDir);
const task = await backgroundTaskQueue.enqueueTask({
key: 'player-font-sync',
title: 'Player font sync',
category: 'fonts',
taskType: 'font-sync',
metadata: {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
},
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: operations,
playerIdentifier: playerIdentifier,
playerPublicBaseUrl: playerPublicBaseUrl,
playerInternalBaseUrl: playerInternalBaseUrl
}
});
res.status(202).json({ ok: true, queued: true, task: task });
} catch (error) { } catch (error) {
next(error); next(error);
} }
+1
View File
@@ -25,6 +25,7 @@ function registerRoutes(app, deps) {
registerSignageRoutes(app, deps); registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps); registerSettingsAndContentRoutes(app, deps);
registerInternalSyncRoutes(app, { registerInternalSyncRoutes(app, {
backgroundTaskQueue: deps.backgroundTaskQueue,
uploadSyncService: deps.uploadSyncService, uploadSyncService: deps.uploadSyncService,
mediaDir: deps.mediaDir mediaDir: deps.mediaDir
}); });
@@ -167,6 +167,31 @@ function buildTaskPlayerLabel(metadata) {
return ''; 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) { function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || []; const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
@@ -262,7 +287,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
const visibleTasksWithSources = visibleTasks.map(function (task) { const visibleTasksWithSources = visibleTasks.map(function (task) {
const playerLabel = buildTaskPlayerLabel(task.metadata); 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, { return Object.assign({}, task, {
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter), sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
@@ -4,6 +4,11 @@ function normalizeSectionId(value) {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'); return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
} }
function toSortIndex(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 999;
}
function buildPermissionSections(permissionGroups) { function buildPermissionSections(permissionGroups) {
const sections = []; const sections = [];
const sectionIndex = new Map(); const sectionIndex = new Map();
@@ -23,7 +28,8 @@ function buildPermissionSections(permissionGroups) {
sectionIndex.set(sectionKey, { sectionIndex.set(sectionKey, {
id: sectionKey, id: sectionKey,
title: sectionTitle, title: sectionTitle,
sectionOrder: Number(group && group.sectionOrder) || 999, sourceIndex: toSortIndex(group && group.sectionIndex),
sectionOrder: toSortIndex(group && group.sectionOrder),
groups: [] groups: []
}); });
sections.push(sectionIndex.get(sectionKey)); sections.push(sectionIndex.get(sectionKey));
@@ -47,8 +53,14 @@ function buildPermissionSections(permissionGroups) {
}); });
section.groups.sort(function (left, right) { section.groups.sort(function (left, right) {
const leftOrder = Number(left.resourceOrder) || 999; const leftIndex = toSortIndex(left.resourceIndex);
const rightOrder = Number(right.resourceOrder) || 999; const rightIndex = toSortIndex(right.resourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.resourceOrder);
const rightOrder = toSortIndex(right.resourceOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -65,8 +77,14 @@ function buildPermissionSections(permissionGroups) {
}); });
sections.sort(function (left, right) { sections.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999; const leftIndex = toSortIndex(left.sourceIndex);
const rightOrder = Number(right.sectionOrder) || 999; const rightIndex = toSortIndex(right.sourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.sectionOrder);
const rightOrder = toSortIndex(right.sectionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -134,5 +152,6 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
module.exports = { module.exports = {
buildRbacAddViewModel: buildRbacAddViewModel, buildRbacAddViewModel: buildRbacAddViewModel,
buildRbacEditViewModel: buildRbacEditViewModel buildRbacEditViewModel: buildRbacEditViewModel,
buildPermissionSections: buildPermissionSections
}; };
+21 -9
View File
@@ -10,15 +10,29 @@
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Timetable group details</h3> <h3 class="card-title">Timetable group details</h3>
</div> </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="row g-3">
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<label for="timetable-group-name" class="form-label">Name</label> <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 /> <input id="timetable-group-name" name="name" class="form-control" value="{{timetableGroup.name}}" maxlength="128" data-limit-text-length required />
</div> </div>
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
<label for="timetable-group-short-description" class="form-label">Short description</label> <div class="row g-3">
<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="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> </div>
</div> </div>
@@ -32,9 +46,9 @@
<h3 class="card-title mb-0 flex-grow-1">Entries</h3> <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> <button type="button" class="btn btn-primary btn-sm ms-auto" data-add-timetable-entry>Add entry</button>
</div> </div>
<div class="card-body d-grid"> <div class="card-body p-0 timetable-entries-table-shell">
<div class="table-responsive"> <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> <thead>
<tr> <tr>
<th style="width: 18rem;">Title</th> <th style="width: 18rem;">Title</th>
@@ -59,10 +73,9 @@
</td> </td>
<td> <td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" /> <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>
<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> </td>
</tr> </tr>
{{/each}} {{/each}}
@@ -87,10 +100,9 @@
</td> </td>
<td> <td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" /> <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>
<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> </td>
</tr> </tr>
</template> </template>
@@ -24,6 +24,7 @@
<tr> <tr>
<th data-table-sort-key="name">Name</th> <th data-table-sort-key="name">Name</th>
<th data-table-sort-key="description">Description</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="entries">Entries</th>
<th data-table-sort-key="next_start">Next start</th> <th data-table-sort-key="next_start">Next start</th>
<th>Actions</th> <th>Actions</th>
@@ -35,10 +36,11 @@
<tr data-table-search-row> <tr data-table-search-row>
<td data-label="Name">{{name}}</td> <td data-label="Name">{{name}}</td>
<td data-label="Description" class="text-break">{{short_description}}</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="Entries">{{entry_count}}</td>
<td data-label="Next start"> <td data-label="Next start">
{{#if nextStartValue}} {{#if nextStartValue}}
<time data-local-datetime datetime="{{nextStartValue}}">{{nextStartLabel}}</time> <time datetime="{{nextStartValue}}">{{nextStartLabel}}</time>
{{else}} {{else}}
{{nextStartLabel}} {{nextStartLabel}}
{{/if}} {{/if}}
@@ -69,7 +71,7 @@
</tr> </tr>
{{/each}} {{/each}}
{{else}} {{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}} {{/if}}
</tbody> </tbody>
</table> </table>
+2 -1
View File
@@ -6,6 +6,7 @@
</div> </div>
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}> <form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
<input type="hidden" name="permissions_present" value="1" />
<input type="hidden" name="users_present" value="1" /> <input type="hidden" name="users_present" value="1" />
<div class="row g-3"> <div class="row g-3">
@@ -94,7 +95,7 @@
<span class="fw-semibold">{{title}}</span> <span class="fw-semibold">{{title}}</span>
</button> </button>
</h2> </h2>
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}" data-bs-parent="#role-permissions-accordion"> <div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}">
<div class="accordion-body p-0"> <div class="accordion-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;"> <table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;">
+5 -4
View File
@@ -43,9 +43,9 @@
</section> </section>
{{#if (hasPermission currentUser "dashboard.allow")}} {{#if (hasPermission currentUser "dashboard.allow")}}
<div class="row pb-4"> <div class="row">
<div class="col-12 col-xl-8"> <div class="col-12 col-xl-8">
<div class="card card-outline card-primary dashboard-actions-card"> <div class="card card-outline card-primary dashboard-actions-card pb-4">
<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>
@@ -109,7 +109,7 @@
</div> </div>
</div> </div>
<div class="col-12 col-xl-4"> <div class="col-12 col-xl-4">
<div class="card card-outline card-secondary dashboard-launcher-card h-100"> <div class="card card-outline card-secondary dashboard-launcher-card h-100 pb-4">
<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>
@@ -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 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>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>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> </ul>
<div class="mt-3 pt-3 border-top"> <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> <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>
+14
View File
@@ -23,6 +23,18 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
playerLabel: 'Player Alpha' 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', title: 'Older task',
key: 'older-key', key: 'older-key',
@@ -73,6 +85,8 @@ 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-confirm-message="Clear finished background tasks\?"/);
assert.match(queueHtml, /data-local-datetime-format="24h"/); assert.match(queueHtml, /data-local-datetime-format="24h"/);
assert.match(queueHtml, /Player Alpha:secret-key/); 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.doesNotMatch(queueKeySearchHtml, /Example task/);
assert.match(queueDateSearchHtml, /Example task/); assert.match(queueDateSearchHtml, /Example task/);
assert.match(scheduledHtml, /data-local-datetime-format="24h"/); assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
+8 -8
View File
@@ -17,7 +17,7 @@ function createHandlers() {
return { app, handlers }; 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(); const { app, handlers } = createHandlers();
registerClientsRoutes(app, { 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 JSON.stringify(data.clients.map(function (client) {
return { return {
client_name: client.client_name, 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 () => ({ buildDashboardState: async () => ({
screens: [], screens: [],
clients: [ clients: [
{ client_name: 'Beta', clientIp: '10.0.0.9' }, { client_name: 'Beta', clientId: 'beta-1' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' }, { client_name: 'Alpha', clientId: 'alpha-1' },
{ client_name: 'Alpha', clientIp: '10.0.0.2' } { client_name: 'Alpha', clientId: 'alpha-2' }
] ]
}), }),
requirePermission() { requirePermission() {
@@ -76,9 +76,9 @@ test('clients list defaults to client then ip ordering', async () => {
}, response, () => {}); }, response, () => {});
assert.deepEqual(JSON.parse(response.body), [ assert.deepEqual(JSON.parse(response.body), [
{ client_name: 'Alpha', clientIp: '10.0.0.2' }, { client_name: 'Alpha', clientId: 'alpha-1' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' }, { client_name: 'Alpha', clientId: 'alpha-2' },
{ client_name: 'Beta', clientIp: '10.0.0.9' } { client_name: 'Beta', clientId: 'beta-1' }
]); ]);
}); });
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { registerInitialFontSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/font-sync');
test('initial font sync queues a separate task for each live player', async () => {
const queuedTasks = [];
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
await registerInitialFontSyncTask({
pool: {
async query() {
return [[
{
identifier: 'player-one',
public_base_url: 'https://player-one.example',
internal_base_url: 'http://player-one:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-two',
public_base_url: 'https://player-two.example',
internal_base_url: 'http://player-two:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-stale',
public_base_url: 'https://player-stale.example',
internal_base_url: 'http://player-stale:8081',
last_seen_at: staleLastSeenAt
}
]];
}
},
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return definition;
}
},
mediaDir: 'e:/Projects Git/pulse-signage/media',
uploadSyncService: {
async getPlayerTaskMetadata() {
return {};
}
}
});
assert.equal(queuedTasks.length, 2);
assert.deepEqual(queuedTasks.map(function (task) {
return task.metadata.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.deepEqual(queuedTasks.map(function (task) {
return task.payload.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.ok(queuedTasks.every(function (task) {
return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync';
}));
});
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { registerInitialMediaSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/media-sync');
test('initial media sync queues a separate task for each live player', async () => {
const queuedTasks = [];
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
await registerInitialMediaSyncTask({
pool: {
async query() {
return [[
{
identifier: 'player-one',
public_base_url: 'https://player-one.example',
internal_base_url: 'http://player-one:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-two',
public_base_url: 'https://player-two.example',
internal_base_url: 'http://player-two:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-stale',
public_base_url: 'https://player-stale.example',
internal_base_url: 'http://player-stale:8081',
last_seen_at: staleLastSeenAt
}
]];
}
},
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return definition;
}
},
mediaDir: 'e:/Projects Git/pulse-signage/media',
uploadSyncService: {
async getPlayerTaskMetadata() {
return {};
}
}
});
assert.equal(queuedTasks.length, 2);
assert.deepEqual(queuedTasks.map(function (task) {
return task.metadata.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.deepEqual(queuedTasks.map(function (task) {
return task.payload.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.ok(queuedTasks.every(function (task) {
return task.key === 'initial-media-sync' && task.title === 'Initial media sync' && task.taskType === 'media-sync';
}));
});
+158
View File
@@ -0,0 +1,158 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
require('../src/common');
const { createRequestAuthHeaders } = require('../src/request-auth');
const registerInternalSyncRoutes = require('../src/web/routes/internal/sync');
test('player media sync requests are queued as background tasks', async () => {
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret';
const queuedTasks = [];
const app = express();
app.use(express.json());
registerInternalSyncRoutes(app, {
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return {
id: 42,
key: definition.key,
taskType: definition.taskType,
title: definition.title,
category: definition.category,
status: 'queued',
createdAt: new Date().toISOString(),
startedAt: '',
finishedAt: '',
errorMessage: '',
attempts: 0,
metadata: definition.metadata,
payload: definition.payload
};
}
},
uploadSyncService: {},
mediaDir: 'e:/Projects Git/pulse-signage/media'
});
app.use(function (error, _req, res, _next) {
res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) });
});
const server = app.listen(0);
try {
const address = server.address();
const body = {
deviceId: 'player-remote',
playerPublicBaseUrl: 'https://player.example.test',
playerInternalBaseUrl: 'http://player-bridge:8090'
};
const headers = Object.assign({
'content-type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media',
body: body
}));
const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-media`, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
});
assert.equal(response.status, 202);
assert.equal(queuedTasks.length, 1);
assert.equal(queuedTasks[0].key, 'player-media-sync');
assert.equal(queuedTasks[0].taskType, 'media-sync');
assert.equal(queuedTasks[0].payload.mode, 'initial');
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
} finally {
await new Promise(function (resolve) {
server.close(resolve);
});
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
test('player font sync requests are queued as background tasks', async () => {
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret';
const queuedTasks = [];
const app = express();
app.use(express.json());
registerInternalSyncRoutes(app, {
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return {
id: 43,
key: definition.key,
taskType: definition.taskType,
title: definition.title,
category: definition.category,
status: 'queued',
createdAt: new Date().toISOString(),
startedAt: '',
finishedAt: '',
errorMessage: '',
attempts: 0,
metadata: definition.metadata,
payload: definition.payload
};
}
},
uploadSyncService: {},
mediaDir: 'e:/Projects Git/pulse-signage/media'
});
app.use(function (error, _req, res, _next) {
res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) });
});
const server = app.listen(0);
try {
const address = server.address();
const body = {
deviceId: 'player-remote',
playerPublicBaseUrl: 'https://player.example.test',
playerInternalBaseUrl: 'http://player-bridge:8090'
};
const headers = Object.assign({
'content-type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: body
}));
const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-font`, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
});
assert.equal(response.status, 202);
assert.equal(queuedTasks.length, 1);
assert.equal(queuedTasks[0].key, 'player-font-sync');
assert.equal(queuedTasks[0].taskType, 'font-sync');
assert.equal(queuedTasks[0].payload.mode, 'initial');
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
} finally {
await new Promise(function (resolve) {
server.close(resolve);
});
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
+13 -1
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
require('../src/common'); require('../src/common');
const originalWebBaseUrl = process.env.WEB_INTERNAL_URL; const originalWebBaseUrl = process.env.WEB_INTERNAL_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl } = require('../src/player-bridge/index'); const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl, resolvePlayerSocketForDeviceId } = require('../src/player-bridge/index');
test.after(() => { test.after(() => {
if (originalWebBaseUrl === undefined) { if (originalWebBaseUrl === undefined) {
@@ -88,3 +88,15 @@ test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for rem
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com' internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}), 'https://remote-player.example'); }), 'https://remote-player.example');
}); });
test('resolvePlayerSocketForDeviceId only returns the exact connected player socket', () => {
const playerSocketA = { readyState: 1 };
const playerSocketB = { readyState: 1 };
const playerSockets = new Map([
['player-a', playerSocketA],
['player-b', playerSocketB]
]);
assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-b'), playerSocketB);
assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-missing'), null);
});
+40
View File
@@ -97,6 +97,46 @@ function createPlayerRouteOptions(overrides) {
}, overrides); }, overrides);
} }
test('screen route reports the request origin for the player public base url', async () => {
const { app, handlers } = createAppAndHandlers();
let reportedBaseUrl = null;
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: { renderPlayerPage() { return '<html></html>'; } },
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist() {
return {
screen: { id: 7, slug: 'test2' },
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'abc123'
};
}
},
onPlayerPublicBaseUrl(value) {
reportedBaseUrl = value;
}
})
});
const handler = handlers['/screen/:slug'];
const res = createResponse();
await handler({
params: { slug: 'test2' },
headers: { host: '127.0.0.1:8080' },
socket: { encrypted: false }
}, res);
assert.equal(reportedBaseUrl, 'http://127.0.0.1:8080');
});
test('screen route falls back to offline rendering when playlist build fails', async () => { test('screen route falls back to offline rendering when playlist build fails', async () => {
const { app, handlers } = createAppAndHandlers(); const { app, handlers } = createAppAndHandlers();
const renderCalls = []; const renderCalls = [];
+42
View File
@@ -4,14 +4,17 @@ const fs = require('node:fs');
const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8'); const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8');
const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8'); const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8');
const { buildPermissionSections } = require('../src/web/routes/settings/rbac/form-view-model');
const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8'); const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8');
const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8'); const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
test('rbac form exposes bulk permission controls', () => { test('rbac form exposes bulk permission controls', () => {
assert.match(rbacFormTemplate, /name="permissions_present" value="1"/);
assert.match(rbacFormTemplate, /accordion accordion-flush/); assert.match(rbacFormTemplate, /accordion accordion-flush/);
assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/); assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/);
assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/); assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/);
assert.match(rbacFormTemplate, /accordion-body p-0/); assert.match(rbacFormTemplate, /accordion-body p-0/);
assert.ok(!rbacFormTemplate.includes('data-bs-parent="#role-permissions-accordion"'));
assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/); assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/);
assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/); assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/);
assert.match(rbacFormTemplate, /class="text-center"/); assert.match(rbacFormTemplate, /class="text-center"/);
@@ -51,6 +54,45 @@ test('rbac permissions script supports bulk permission selection', () => {
assert.ok(!rbacPermissionsScript.includes('data-permission-section-select-none')); assert.ok(!rbacPermissionsScript.includes('data-permission-section-select-none'));
}); });
test('rbac accordion sections follow source order', () => {
const sections = buildPermissionSections([
{
categoryName: 'Settings',
sectionIndex: 3,
sectionOrder: 40,
resourceIndex: 1,
resourceOrder: 20,
title: 'Later resource',
permissions: []
},
{
categoryName: 'Main navigation',
sectionIndex: 0,
sectionOrder: 10,
resourceIndex: 1,
resourceOrder: 20,
title: 'Clients',
permissions: []
},
{
categoryName: 'Main navigation',
sectionIndex: 0,
sectionOrder: 10,
resourceIndex: 0,
resourceOrder: 10,
title: 'Dashboard',
permissions: []
}
]);
assert.deepEqual(sections.map(function (section) {
return section.title;
}), ['Main navigation', 'Settings']);
assert.deepEqual(sections[0].groups.map(function (group) {
return group.title;
}), ['Dashboard', 'Clients']);
});
test('rbac duplicate route exists', () => { test('rbac duplicate route exists', () => {
const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8'); const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8');
const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8'); const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8');
+43 -21
View File
@@ -97,26 +97,9 @@ function createRow(startValue, endValue) {
return row; 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', () => { 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 = { const body = {
rows: [row], rows: [row],
querySelectorAll(selector) { querySelectorAll(selector) {
@@ -140,7 +123,10 @@ test('timetable group form highlights an end time that is too early', () => {
}; };
const form = { const form = {
dataset: {}, dataset: {},
addEventListener() {} listeners: Object.create(null),
addEventListener(type, handler) {
this.listeners[type] = handler;
}
}; };
const templateRow = createRow('', ''); const templateRow = createRow('', '');
const template = { const template = {
@@ -161,6 +147,9 @@ test('timetable group form highlights an end time that is too early', () => {
if (id === 'timetable-group-form') { if (id === 'timetable-group-form') {
return form; return form;
} }
if (id === 'timetable-group-timezone') {
return timezoneInput;
}
if (id === 'timetable-entry-row-template') { if (id === 'timetable-entry-row-template') {
return 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'); const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath }); 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.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.classList.contains('is-invalid'), true);
assert.equal(row.endInput.attributes['aria-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' }); row.endInput.dispatchEvent({ type: 'input' });
assert.equal(row.endInput.validityMessage, ''); assert.equal(row.endInput.validityMessage, '');
assert.equal(row.endInput.classList.contains('is-invalid'), false); assert.equal(row.endInput.classList.contains('is-invalid'), false);
assert.equal(Object.prototype.hasOwnProperty.call(row.endInput.attributes, 'aria-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/schedule.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-10T10:00:00.000Z',
end_datetime: '2026-08-10T11: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-10T10:00:00.000Z',
end_datetime: '2026-08-10T11:00:00.000Z'
}
]
}
]
}
);
assert.match(preview, /local/);
assert.equal(calls.length > 0 ? Boolean(calls[0].options && calls[0].options.timeZone) : false, false);
});
+42
View File
@@ -0,0 +1,42 @@
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);
});
+5 -2
View File
@@ -14,7 +14,8 @@ test('timetable duplicate helper copies group and entry fields', () => {
const group = { const group = {
id: 5, id: 5,
name: 'Morning Shift', name: 'Morning Shift',
short_description: 'Weekday events' short_description: 'Weekday events',
timezone: 'Europe/Berlin'
}; };
const entry = { const entry = {
id: 12, 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.name, 'Copy of Morning Shift');
assert.equal(duplicateGroup.short_description, 'Weekday events'); assert.equal(duplicateGroup.short_description, 'Weekday events');
assert.equal(duplicateGroup.shortDescription, 'Weekday events'); assert.equal(duplicateGroup.shortDescription, 'Weekday events');
assert.equal(duplicateGroup.timezone, 'Europe/Berlin');
assert.equal(duplicateEntry.id, null); assert.equal(duplicateEntry.id, null);
assert.equal(duplicateEntry.schedule_group_id, null); assert.equal(duplicateEntry.schedule_group_id, null);
assert.equal(duplicateEntry.title, 'Opening'); 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', () => { test('timetable duplicate form model keeps info toast variant and secondary actions', () => {
const model = buildTimetableGroupFormViewModel( 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.', 'Review the copied values and save when ready.',
{ permissionKeys: ['timetables.create'] }, { 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.messageVariant, 'info');
assert.equal(model.showSaveSecondaryActions, true); assert.equal(model.showSaveSecondaryActions, true);
assert.equal(model.timetableGroup.timezone, 'Europe/Berlin');
}); });
test('timetable list template includes duplicate action', () => { 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');
});
+187 -1
View File
@@ -99,7 +99,7 @@ test('fqdn player registration wins over a local configured player target for me
assert.equal(success, true); assert.equal(success, true);
assert.equal(fetchCalls.length, 1); assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin'); assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin?deviceId=player-remote');
} finally { } finally {
global.fetch = originalFetch; global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true }); fs.rmSync(uploadDir, { recursive: true, force: true });
@@ -275,3 +275,189 @@ test('stale player registrations stop media sync retries and warnings', async ()
fs.rmSync(uploadDir, { recursive: true, force: true }); 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 });
}
});
+44
View File
@@ -0,0 +1,44 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const registerMiddleware = require('../src/web/middleware');
test('web middleware bypasses auth for internal player sync routes', () => {
const calls = [];
const app = {
middlewares: [],
use(...args) {
this.middlewares.push(args);
}
};
registerMiddleware(app, {
pool: {},
loadCurrentUser: async () => null,
requireAuth(req, _res, next) {
calls.push(req.path);
next();
},
MEDIA_DIR: path.join(process.cwd(), 'media'),
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
});
const authGate = app.middlewares[app.middlewares.length - 1][0];
const nextCalls = [];
authGate({ path: '/api/internal/sync/player-media' }, {}, function () {
nextCalls.push('media');
});
authGate({ path: '/api/internal/sync/player-font' }, {}, function () {
nextCalls.push('font');
});
authGate({ path: '/dashboard' }, {}, function () {
nextCalls.push('dashboard');
});
assert.deepEqual(nextCalls, ['media', 'font', 'dashboard']);
assert.deepEqual(calls, ['/dashboard']);
});