Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ec276317 | ||
|
|
30f5ed11b8 | ||
|
|
d6a8b45357 | ||
|
|
f9425fc640 | ||
|
|
4491c15215 | ||
|
|
08b06941a4 | ||
|
|
c0c05f76e3 | ||
|
|
78a34e4105 | ||
|
|
3c3864e6ac | ||
|
|
6d8bf0f5f0 | ||
|
|
c36b9122c9 | ||
|
|
39f2098ffe | ||
|
|
459f84ed94 | ||
|
|
49c72923b4 | ||
|
|
6c28a1c028 | ||
|
|
c5172af62d | ||
|
|
cd8e333afd | ||
|
|
c3b5a0053c | ||
|
|
38565a533d | ||
|
|
5a08ccddbb | ||
|
|
2c97fe81d1 |
@@ -1,5 +1,7 @@
|
|||||||
*
|
*
|
||||||
!package.json
|
!package.json
|
||||||
|
!build/
|
||||||
|
!build/**
|
||||||
!src/
|
!src/
|
||||||
!src/**
|
!src/**
|
||||||
!scripts/
|
!scripts/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## Versioning and releases
|
## Versioning and releases
|
||||||
|
|
||||||
- Treat `package.json` as the source of truth for the application version.
|
- Treat `package.json` as the source of truth for the application version.
|
||||||
|
- Keep `package.json`, `build/package.player.json`, and `build/package.web.json` on the same version number.
|
||||||
- When the app version changes, update `CHANGELOG.md` in the same change.
|
- When the app version changes, update `CHANGELOG.md` in the same change.
|
||||||
- Keep database migration versions aligned with the release they actually belong to.
|
- Keep database migration versions aligned with the release they actually belong to.
|
||||||
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
|
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
|
||||||
|
|||||||
@@ -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 }}
|
||||||
+146
@@ -2,6 +2,152 @@
|
|||||||
|
|
||||||
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.7.0 - 2026-08-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The WYSIWYG editor now supports adding small images.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The WYSIWYG image insertion flow also received a small code cleanup to simplify the related helper logic.
|
||||||
|
- The default table formatting has been applied.
|
||||||
|
- Timetable regions now use the renamed helpers end to end in the editor and player, including timezone-aware rendering for timetable entry placeholders.
|
||||||
|
|
||||||
|
## 2.6.27 - 2026-08-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Scheduled task intervals now display the most appropriate exact unit, such as seconds, minutes, hours, or days, while keeping the sort order numeric.
|
||||||
|
|
||||||
|
## 2.6.26 - 2026-08-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- API sources and RSS feeds now accept hours as an update interval unit, and the list and background task scheduling paths now format and convert that unit correctly.
|
||||||
|
|
||||||
|
## 2.6.25 - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Screen group edit now keeps the slug locked after creation, so existing screen group URLs remain stable.
|
||||||
|
|
||||||
|
## 2.6.24 - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Deferred playlist updates now keep the current slide index when the next playlist snapshot is applied, so playback no longer jumps back to the first slide mid-cycle.
|
||||||
|
|
||||||
|
## 2.6.23 - 2026-08-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Dashboard quick-action and kiosk-launcher cards now use row spacing instead of extra card padding, so the layout stays consistent at large widths.
|
||||||
|
- API and RSS refresh jobs now notify affected player screens only when the refreshed data actually changes, so unchanged polls no longer trigger redundant player refreshes.
|
||||||
|
|
||||||
|
## 2.6.22 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Startup now logs the previously detected schema version, the current app version, and whether pending migrations exist.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Direct-to-URL player sessions now generate and persist a stable onboarding device id in session storage, so connected screens can still be moved and renamed independently without going through the onboarding flow first.
|
||||||
|
- The connected-clients move action now tolerates rows that only have a live connection id, which keeps move operations working for screens that skipped onboarding.
|
||||||
|
|
||||||
|
## 2.6.21 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Startup now logs the previously detected schema version, the current app version, and whether migrations are required.
|
||||||
|
- The schema documentation now reflects the timetable table rename, the new `o_app_state` table, and startup version tracking.
|
||||||
|
- Timetable timezone placeholders now use `tz` for the short zone name and `tz_long` for the full IANA zone.
|
||||||
|
|
||||||
|
## 2.6.20 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Startup now logs the previously detected schema version, the current app version, and whether migrations are required.
|
||||||
|
|
||||||
|
## 2.6.19 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Timetable editor helpers, routes, and table layout now use timetable-specific naming and tighter card/table styling.
|
||||||
|
- Connected clients now sort by client identity fields instead of the old IP-based ordering assumption.
|
||||||
|
|
||||||
|
## 2.6.18 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Timetable tables were renamed from the old `schedule` names to `timetable` names, and existing databases now rename those tables during migration.
|
||||||
|
- The timetable group editor now uses timetable-specific naming in its shared helpers and keeps the entries table aligned with the standard admin card/table layout.
|
||||||
|
|
||||||
|
## 2.6.17 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Existing timetable groups and entries are now migrated to Europe/London, and timetable dates are rewritten to UTC using that source timezone so the wall-clock meaning stays intact.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- New timetable groups now default to Europe/London so the timetable editor and saved data start from the same timezone assumption as the migrated rows.
|
||||||
|
|
||||||
|
## 2.6.16 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Timetable groups now store an IANA time zone and render their entry datetimes in that timetable time zone, so schedules keep the same wall-clock meaning when they are edited from another country.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Timetable entry date inputs now round-trip through the timetable time zone instead of the browser locale, so saving from Florida while targeting Germany keeps the intended local times.
|
||||||
|
|
||||||
|
## 2.6.15 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slide update media sync on the player now removes uploads that were removed from the slide, so player storage stays aligned with the current slide content.
|
||||||
|
- Routine player and player-bridge media upload/delete logs were removed to keep remote player and bridge operation quieter.
|
||||||
|
- Background task descriptions no longer repeat the player slug when the task key already ends with that player name.
|
||||||
|
|
||||||
|
## 2.6.14 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slide updates and template deletes now fan out media sync work across all live players instead of targeting only one player.
|
||||||
|
|
||||||
|
## 2.6.13 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The onboarding landing page and form no longer restore a previously selected screen, so the screen picker always starts clean while still keeping the saved client name.
|
||||||
|
|
||||||
## 2.6.9 - 2026-08-08
|
## 2.6.9 - 2026-08-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
-20
@@ -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"]
|
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "pulse-signage-player",
|
||||||
|
"version": "2.7.0",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "pulse-signage-web",
|
||||||
|
"version": "2.7.0",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.9",
|
"version": "2.7.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ const ITEMS_PATH_MAX_LENGTH = 255;
|
|||||||
|
|
||||||
function normalizeUpdateIntervalUnit(value) {
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
const unit = String(value || '').trim().toLowerCase();
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
return 'minutes';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAuthMethod(value) {
|
function normalizeAuthMethod(value) {
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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);
|
||||||
}) || '';
|
}) || '';
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ const URL_MAX_LENGTH = 1024;
|
|||||||
|
|
||||||
function normalizeUpdateIntervalUnit(value) {
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
const unit = String(value || '').trim().toLowerCase();
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
return 'minutes';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRssFeedsData(pool) {
|
async function fetchRssFeedsData(pool) {
|
||||||
|
|||||||
+112
-6
@@ -6,27 +6,90 @@ const { parseJsonSafe, validateMaxLength } = require('./utils');
|
|||||||
const TITLE_MAX_LENGTH = 255;
|
const TITLE_MAX_LENGTH = 255;
|
||||||
const { buildQrCodeContent } = require('./qr-code');
|
const { buildQrCodeContent } = require('./qr-code');
|
||||||
|
|
||||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
|
||||||
const DEFAULT_FONT_SIZE = 32;
|
const DEFAULT_FONT_SIZE = 32;
|
||||||
|
|
||||||
|
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||||
|
|
||||||
|
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||||
|
const allowedAttributes = {
|
||||||
|
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||||
|
blockquote: ['class', 'style'],
|
||||||
|
div: ['class', 'style'],
|
||||||
|
figure: ['class', 'style'],
|
||||||
|
figcaption: ['class', 'style'],
|
||||||
|
h1: ['class', 'style'],
|
||||||
|
h2: ['class', 'style'],
|
||||||
|
h3: ['class', 'style'],
|
||||||
|
h4: ['class', 'style'],
|
||||||
|
h5: ['class', 'style'],
|
||||||
|
h6: ['class', 'style'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||||
|
li: ['class', 'style'],
|
||||||
|
ol: ['class', 'style', 'start'],
|
||||||
|
p: ['class', 'style'],
|
||||||
|
pre: ['class', 'style'],
|
||||||
|
span: ['class', 'style'],
|
||||||
|
table: ['class', 'style'],
|
||||||
|
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||||
|
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||||
|
tr: ['class', 'style'],
|
||||||
|
ul: ['class', 'style']
|
||||||
|
};
|
||||||
|
const allowed = allowedAttributes[tagName] || [];
|
||||||
|
if (!allowed.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = [];
|
||||||
|
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||||
|
const lowerKey = String(key || '').toLowerCase();
|
||||||
|
if (!allowed.includes(lowerKey)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||||
|
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (lowerKey === 'target') {
|
||||||
|
const targetValue = String(value || '').trim();
|
||||||
|
if (targetValue === '_blank') {
|
||||||
|
attrs.push(' target="_blank"');
|
||||||
|
if (!attrs.includes(' rel="noreferrer noopener"')) {
|
||||||
|
attrs.push(' rel="noreferrer noopener"');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attrs.push(' ' + lowerKey + '="' + String(value || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') + '"');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return attrs.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
function sanitizeRichText(html) {
|
||||||
let output = String(html || '');
|
let output = String(html || '');
|
||||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||||
return output.replace(/<[^>]+>/g, (tag) => {
|
return output.replace(/<[^>]+>/g, (tag) => {
|
||||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const closing = Boolean(match[1]);
|
const closing = Boolean(match[1]);
|
||||||
const name = String(match[2] || '').toLowerCase();
|
const name = String(match[2] || '').toLowerCase();
|
||||||
|
const attrText = String(match[3] || '');
|
||||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (name === 'br') {
|
if (closing) {
|
||||||
return '<br>';
|
return `</${name}>`;
|
||||||
}
|
}
|
||||||
return closing ? `</${name}>` : `<${name}>`;
|
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +371,49 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
|||||||
value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''),
|
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] : {};
|
||||||
@@ -387,7 +493,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
|||||||
const style = getTextRegionStyle(body, region, existingContent);
|
const style = getTextRegionStyle(body, region, existingContent);
|
||||||
content[region.region_key] = {
|
content[region.region_key] = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))),
|
value: sanitizeRichText(stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || '')))),
|
||||||
font_family: style.font_family,
|
font_family: style.font_family,
|
||||||
font_size: style.font_size,
|
font_size: style.font_size,
|
||||||
font_color: style.font_color
|
font_color: style.font_color
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
+16
-6
@@ -1,3 +1,6 @@
|
|||||||
|
const { version: appVersion } = require('#root/package.json');
|
||||||
|
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
|
||||||
|
|
||||||
// Snapshot only: keep this file aligned with the current schema state.
|
// Snapshot only: keep this file aligned with the current schema state.
|
||||||
async function ensureSchema(pool, options) {
|
async function ensureSchema(pool, options) {
|
||||||
const schemaLockName = 'pulse_signage_schema_lock';
|
const schemaLockName = 'pulse_signage_schema_lock';
|
||||||
@@ -12,6 +15,12 @@ async function ensureSchema(pool, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentVersion = await detectSchemaVersion(pool);
|
||||||
|
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: currentVersion });
|
||||||
|
const updateRequired = pendingMigrations.length > 0;
|
||||||
|
|
||||||
|
console.info('[schema] previous=' + currentVersion + ' current=' + appVersion + ' update=' + (updateRequired ? 'yes' : 'no'));
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -238,10 +247,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 +260,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 +271,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 +393,8 @@ async function ensureSchema(pool, options) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const { runMigrations } = require('./migrations');
|
await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion }));
|
||||||
await runMigrations(pool, options);
|
await recordSchemaVersion(pool, appVersion);
|
||||||
} finally {
|
} finally {
|
||||||
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
|
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
|
||||||
});
|
});
|
||||||
|
|||||||
+290
-30
@@ -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;
|
||||||
@@ -562,12 +806,13 @@ function compareVersions(leftVersion, rightVersion) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMigrations(pool, options) {
|
async function getPendingMigrations(pool, options) {
|
||||||
// Only run migrations that are newer than the installed schema version and not beyond the app version.
|
|
||||||
const targetVersion = String(appVersion || '0.0.0').trim();
|
const targetVersion = String(appVersion || '0.0.0').trim();
|
||||||
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
|
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
|
||||||
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
|
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
|
||||||
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,14 +823,29 @@ async function runMigrations(pool, options) {
|
|||||||
effectiveCurrentVersion = '2.6.3';
|
effectiveCurrentVersion = '2.6.3';
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const migration of VERSIONED_MIGRATIONS) {
|
if (legacyTimetableGroupsPresent || legacyTimetableEntriesPresent) {
|
||||||
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
|
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
|
||||||
await migration.run(pool);
|
}
|
||||||
}
|
|
||||||
|
return VERSIONED_MIGRATIONS.filter(function (migration) {
|
||||||
|
return compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigrations(pool, options) {
|
||||||
|
// Only run migrations that are newer than the installed schema version and not beyond the app version.
|
||||||
|
const pendingMigrations = await getPendingMigrations(pool, options);
|
||||||
|
|
||||||
|
for (const migration of pendingMigrations) {
|
||||||
|
await migration.run(pool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
runMigrations: runMigrations
|
getPendingMigrations: getPendingMigrations,
|
||||||
|
runMigrations: runMigrations,
|
||||||
|
detectSchemaVersion: detectSchemaVersion,
|
||||||
|
recordSchemaVersion: recordSchemaVersion,
|
||||||
|
compareVersions: compareVersions
|
||||||
};
|
};
|
||||||
|
|||||||
+101
-8
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -56,10 +56,8 @@
|
|||||||
loadScreens().then(function () {
|
loadScreens().then(function () {
|
||||||
try {
|
try {
|
||||||
var storedClientName = getSessionStorageItem(clientNameKey) || "";
|
var storedClientName = getSessionStorageItem(clientNameKey) || "";
|
||||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
|
||||||
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
|
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
|
||||||
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
|
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
|
||||||
if (screenSelect && storedScreenSlug) { screenSelect.value = storedScreenSlug; }
|
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
});
|
});
|
||||||
form.addEventListener("submit", function (event) {
|
form.addEventListener("submit", function (event) {
|
||||||
|
|||||||
@@ -132,14 +132,11 @@
|
|||||||
}
|
}
|
||||||
loadScreens().then(function () {
|
loadScreens().then(function () {
|
||||||
try {
|
try {
|
||||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
|
||||||
var storedClientName = getSessionStorageItem(clientNameKey) || "";
|
var storedClientName = getSessionStorageItem(clientNameKey) || "";
|
||||||
if (!storedClientName && storedScreenSlug) { storedClientName = getSessionStorageItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
|
|
||||||
if (storedClientName && localForm) {
|
if (storedClientName && localForm) {
|
||||||
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
||||||
if (clientNameInput) { clientNameInput.value = storedClientName; }
|
if (clientNameInput) { clientNameInput.value = storedClientName; }
|
||||||
}
|
}
|
||||||
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
|
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
});
|
});
|
||||||
redirectIfOnboarded(deviceId).then(function (redirected) {
|
redirectIfOnboarded(deviceId).then(function (redirected) {
|
||||||
|
|||||||
@@ -23,7 +23,12 @@
|
|||||||
function getOnboardingDeviceId() {
|
function getOnboardingDeviceId() {
|
||||||
try {
|
try {
|
||||||
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
|
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
|
||||||
return String(storedDeviceId || '').trim();
|
if (storedDeviceId) {
|
||||||
|
return String(storedDeviceId || '').trim();
|
||||||
|
}
|
||||||
|
var nextDeviceId = window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'device-' + Date.now() + '-' + Math.random().toString(16).slice(2);
|
||||||
|
setSessionStorageItem(onboardingDeviceIdStorageKey, nextDeviceId);
|
||||||
|
return String(nextDeviceId || '').trim();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
|
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
|
<link rel="stylesheet" href="/assets/css/player.css?v=37" />
|
||||||
{{{STYLESHEETS}}}
|
{{{STYLESHEETS}}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{BODY_CLASS}}">
|
<body class="{{BODY_CLASS}}">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #111;
|
background: #0a0a0a;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ body.thumbnail-preview .player-offline-banner {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #111;
|
background: #0a0a0a;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,15 +294,6 @@ body.screen-blackout #app {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide img,
|
|
||||||
.slide video,
|
|
||||||
.slide iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 5%;
|
left: 5%;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function applyPendingPlaylistUpdate() {
|
|||||||
if (!pendingPlaylistUpdate) {
|
if (!pendingPlaylistUpdate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
var nextIndex = Number(index || 0);
|
||||||
slides = pendingPlaylistUpdate.slides;
|
slides = pendingPlaylistUpdate.slides;
|
||||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||||
@@ -80,7 +81,11 @@ function applyPendingPlaylistUpdate() {
|
|||||||
templateLayoutCache = Object.create(null);
|
templateLayoutCache = Object.create(null);
|
||||||
templateRenderPlanCache = Object.create(null);
|
templateRenderPlanCache = Object.create(null);
|
||||||
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
index = 0;
|
const activeSlides = getCurrentActiveSlides();
|
||||||
|
if (!Number.isFinite(nextIndex) || nextIndex < 0) {
|
||||||
|
nextIndex = 0;
|
||||||
|
}
|
||||||
|
index = activeSlides.length ? Math.min(nextIndex, activeSlides.length - 1) : 0;
|
||||||
logDebug('Applied updated playlist on slide transition.');
|
logDebug('Applied updated playlist on slide transition.');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,15 @@ function refresh() {
|
|||||||
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||||
|
if (window.initialData && typeof window.initialData === 'object') {
|
||||||
|
window.initialData.screen = data.screen || window.initialData.screen || null;
|
||||||
|
window.initialData.playlist = data.playlist || null;
|
||||||
|
window.initialData.slides = nextSlides;
|
||||||
|
window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : [];
|
||||||
|
window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : [];
|
||||||
|
window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : [];
|
||||||
|
window.initialData.revision = nextSignature;
|
||||||
|
}
|
||||||
savePlaylistSnapshot({
|
savePlaylistSnapshot({
|
||||||
slides: nextSlides,
|
slides: nextSlides,
|
||||||
signature: nextSignature,
|
signature: nextSignature,
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
|||||||
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||||
|
|
||||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||||
const allowedAttributes = {
|
const allowedAttributes = {
|
||||||
@@ -349,14 +349,19 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
h4: ['class', 'style'],
|
h4: ['class', 'style'],
|
||||||
h5: ['class', 'style'],
|
h5: ['class', 'style'],
|
||||||
h6: ['class', 'style'],
|
h6: ['class', 'style'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||||
|
col: ['class', 'style', 'span', 'width'],
|
||||||
|
colgroup: ['class', 'style', 'span'],
|
||||||
li: ['class', 'style'],
|
li: ['class', 'style'],
|
||||||
ol: ['class', 'style', 'start'],
|
ol: ['class', 'style', 'start'],
|
||||||
p: ['class', 'style'],
|
p: ['class', 'style'],
|
||||||
pre: ['class', 'style'],
|
pre: ['class', 'style'],
|
||||||
span: ['class', 'style'],
|
span: ['class', 'style'],
|
||||||
table: ['class', 'style'],
|
table: ['class', 'style'],
|
||||||
|
tbody: ['class', 'style'],
|
||||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||||
|
thead: ['class', 'style'],
|
||||||
tr: ['class', 'style'],
|
tr: ['class', 'style'],
|
||||||
ul: ['class', 'style']
|
ul: ['class', 'style']
|
||||||
};
|
};
|
||||||
@@ -365,6 +370,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tagName === 'img') {
|
||||||
|
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||||
|
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||||
|
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const attrs = [];
|
const attrs = [];
|
||||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||||
const lowerKey = String(key || '').toLowerCase();
|
const lowerKey = String(key || '').toLowerCase();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Time/date region rendering and live updates.
|
// Time/date region rendering and live updates.
|
||||||
|
|
||||||
var registry = window.pulsePlayerRegionTypes;
|
var registry = window.pulsePlayerRegionTypes;
|
||||||
|
var placeholderUtils = window.placeholderUtils || {};
|
||||||
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
|
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
|
||||||
var DEFAULT_STYLE = {
|
var DEFAULT_STYLE = {
|
||||||
font_family: 'Arial',
|
font_family: 'Arial',
|
||||||
@@ -9,15 +10,6 @@ var DEFAULT_STYLE = {
|
|||||||
};
|
};
|
||||||
var timeDateFormatterCache = Object.create(null);
|
var timeDateFormatterCache = Object.create(null);
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value === undefined || value === null ? '' : value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeTagAttributes(tagName, attrText) {
|
function sanitizeTagAttributes(tagName, attrText) {
|
||||||
var allowedAttributes = {
|
var allowedAttributes = {
|
||||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||||
@@ -133,7 +125,7 @@ function resolveTimeZone(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFormatter(key, options) {
|
function getTimeDateFormatter(key, options) {
|
||||||
if (!timeDateFormatterCache[key]) {
|
if (!timeDateFormatterCache[key]) {
|
||||||
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
|
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
|
||||||
}
|
}
|
||||||
@@ -141,10 +133,10 @@ function getFormatter(key, options) {
|
|||||||
return timeDateFormatterCache[key];
|
return timeDateFormatterCache[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFormattedParts(timeZone, date) {
|
function getTimeDateFormattedParts(timeZone, date) {
|
||||||
var targetDate = date instanceof Date ? date : new Date();
|
var targetDate = date instanceof Date ? date : new Date();
|
||||||
var resolvedTimeZone = resolveTimeZone(timeZone);
|
var resolvedTimeZone = resolveTimeZone(timeZone);
|
||||||
var numericParts = getFormatter('numeric:' + resolvedTimeZone, {
|
var numericParts = getTimeDateFormatter('numeric:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
hour12: false,
|
hour12: false,
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -154,29 +146,29 @@ function getFormattedParts(timeZone, date) {
|
|||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, {
|
var weekdayLong = getTimeDateFormatter('weekday-long:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
weekday: 'long'
|
weekday: 'long'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, {
|
var weekdayShort = getTimeDateFormatter('weekday-short:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
weekday: 'short'
|
weekday: 'short'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var monthLong = getFormatter('month-long:' + resolvedTimeZone, {
|
var monthLong = getTimeDateFormatter('month-long:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
month: 'long'
|
month: 'long'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var monthShort = getFormatter('month-short:' + resolvedTimeZone, {
|
var monthShort = getTimeDateFormatter('month-short:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
month: 'short'
|
month: 'short'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var ampm = getFormatter('ampm:' + resolvedTimeZone, {
|
var ampm = getTimeDateFormatter('ampm:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
hour12: true,
|
hour12: true,
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, {
|
var timezoneShort = getTimeDateFormatter('tz-short:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
@@ -213,27 +205,28 @@ 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')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTimeDatePlaceholder(values, expression) {
|
function resolveTimeDateTemplatePlaceholder(values, expression) {
|
||||||
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
|
var currentPlaceholderUtils = window.placeholderUtils || placeholderUtils || {};
|
||||||
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
|
if (typeof currentPlaceholderUtils.resolvePlaceholderExpression === 'function' && typeof currentPlaceholderUtils.formatPlaceholderValue === 'function') {
|
||||||
|
return currentPlaceholderUtils.formatPlaceholderValue(currentPlaceholderUtils.resolvePlaceholderExpression(values, expression));
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = String(expression || '').trim();
|
var parsed = String(expression || '').trim();
|
||||||
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
|
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTemplate(format, timeZone, date) {
|
function renderTimeDateTemplate(format, timeZone, date) {
|
||||||
var template = String(format || '').trim() || DEFAULT_FORMAT;
|
var template = String(format || '').trim() || DEFAULT_FORMAT;
|
||||||
var values = getFormattedParts(timeZone, date);
|
var values = getTimeDateFormattedParts(timeZone, date);
|
||||||
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
|
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
|
||||||
return String(resolveTimeDatePlaceholder(values, key) || '');
|
return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +250,7 @@ function renderTimeDateRegion(region, regionContent) {
|
|||||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||||
var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
|
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
|
||||||
var renderedText = renderTemplate(format, timeZone, new Date());
|
var renderedText = renderTimeDateTemplate(format, timeZone, new Date());
|
||||||
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
|
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +266,7 @@ function updateTimeDateRegion(element) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date()));
|
scaleWrapper.innerHTML = renderEditorJsContent(renderTimeDateTemplate(format, timeZone, new Date()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleTimeDateRegionUpdate(element) {
|
function scheduleTimeDateRegionUpdate(element) {
|
||||||
|
|||||||
@@ -2,101 +2,6 @@
|
|||||||
|
|
||||||
var registry = window.pulsePlayerRegionTypes;
|
var registry = window.pulsePlayerRegionTypes;
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value === undefined || value === null ? '' : value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
|
||||||
var allowedAttributes = {
|
|
||||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
|
||||||
blockquote: ['class', 'style'],
|
|
||||||
col: ['class', 'style', 'span', 'width'],
|
|
||||||
colgroup: ['class', 'style', 'span'],
|
|
||||||
div: ['class', 'style'],
|
|
||||||
figure: ['class', 'style'],
|
|
||||||
figcaption: ['class', 'style'],
|
|
||||||
h1: ['class', 'style'],
|
|
||||||
h2: ['class', 'style'],
|
|
||||||
h3: ['class', 'style'],
|
|
||||||
h4: ['class', 'style'],
|
|
||||||
h5: ['class', 'style'],
|
|
||||||
h6: ['class', 'style'],
|
|
||||||
li: ['class', 'style'],
|
|
||||||
ol: ['class', 'style', 'start'],
|
|
||||||
p: ['class', 'style'],
|
|
||||||
pre: ['class', 'style'],
|
|
||||||
span: ['class', 'style'],
|
|
||||||
table: ['class', 'style'],
|
|
||||||
tbody: ['class', 'style'],
|
|
||||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
|
||||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
|
||||||
thead: ['class', 'style'],
|
|
||||||
tr: ['class', 'style'],
|
|
||||||
ul: ['class', 'style']
|
|
||||||
};
|
|
||||||
var allowed = allowedAttributes[tagName] || [];
|
|
||||||
if (!allowed.length) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
var attrs = [];
|
|
||||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
|
||||||
var lowerKey = String(key || '').toLowerCase();
|
|
||||||
if (allowed.indexOf(lowerKey) === -1) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
|
||||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (lowerKey === 'target') {
|
|
||||||
var targetValue = String(value || '').trim();
|
|
||||||
if (targetValue === '_blank') {
|
|
||||||
attrs.push(' target="_blank"');
|
|
||||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
|
||||||
attrs.push(' rel="noreferrer noopener"');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
return attrs.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
|
||||||
var output = String(html || '');
|
|
||||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
||||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
||||||
return output.replace(/<[^>]+>/g, function (tag) {
|
|
||||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
|
||||||
if (!match) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
var closing = Boolean(match[1]);
|
|
||||||
var name = String(match[2] || '').toLowerCase();
|
|
||||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
|
||||||
if (allowed.indexOf(name) === -1) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (closing) {
|
|
||||||
return '</' + name + '>';
|
|
||||||
}
|
|
||||||
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function substituteTimetableVariables(html, entry) {
|
function substituteTimetableVariables(html, entry) {
|
||||||
var source = String(html || '');
|
var source = String(html || '');
|
||||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||||
@@ -228,6 +133,7 @@ function renderRegion(region, regionContent) {
|
|||||||
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
var 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 +147,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
|
||||||
@@ -41,7 +41,7 @@ function sanitizeTextColor(value, fallback) {
|
|||||||
return fallback || '#000000';
|
return fallback || '#000000';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||||
|
|
||||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||||
const allowedAttributes = {
|
const allowedAttributes = {
|
||||||
@@ -56,6 +56,9 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
h4: ['class', 'style'],
|
h4: ['class', 'style'],
|
||||||
h5: ['class', 'style'],
|
h5: ['class', 'style'],
|
||||||
h6: ['class', 'style'],
|
h6: ['class', 'style'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||||
|
col: ['class', 'style', 'span', 'width'],
|
||||||
|
colgroup: ['class', 'style', 'span'],
|
||||||
li: ['class', 'style'],
|
li: ['class', 'style'],
|
||||||
ol: ['class', 'style', 'start'],
|
ol: ['class', 'style', 'start'],
|
||||||
p: ['class', 'style'],
|
p: ['class', 'style'],
|
||||||
@@ -72,6 +75,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tagName === 'img') {
|
||||||
|
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||||
|
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||||
|
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const attrs = [];
|
const attrs = [];
|
||||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||||
const lowerKey = String(key || '').toLowerCase();
|
const lowerKey = String(key || '').toLowerCase();
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ async function start() {
|
|||||||
mediaDir: webConfig.mediaDir,
|
mediaDir: webConfig.mediaDir,
|
||||||
backgroundTaskQueue: backgroundTaskQueue,
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
webBootstrap: webBootstrap,
|
webBootstrap: webBootstrap,
|
||||||
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
loadCurrentUser: loadCurrentUser,
|
loadCurrentUser: loadCurrentUser,
|
||||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||||
captureSlideThumbnail: captureSlideThumbnail,
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ function normalizeIntervalMs(value, unit) {
|
|||||||
if (normalizedUnit === 'seconds') {
|
if (normalizedUnit === 'seconds') {
|
||||||
return numericValue * 1000;
|
return numericValue * 1000;
|
||||||
}
|
}
|
||||||
|
if (normalizedUnit === 'hours') {
|
||||||
|
return numericValue * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
return numericValue * 60 * 1000;
|
return numericValue * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function registerDataSourceRefreshTask(options) {
|
|||||||
if (!apiSource) {
|
if (!apiSource) {
|
||||||
throw new Error('API source not found.');
|
throw new Error('API source not found.');
|
||||||
}
|
}
|
||||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null);
|
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null, options.notifyPlayerScreens);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceType === 'rss-feed') {
|
if (sourceType === 'rss-feed') {
|
||||||
@@ -32,7 +32,7 @@ function registerDataSourceRefreshTask(options) {
|
|||||||
if (!rssFeed) {
|
if (!rssFeed) {
|
||||||
throw new Error('RSS feed not found.');
|
throw new Error('RSS feed not found.');
|
||||||
}
|
}
|
||||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null, options.notifyPlayerScreens);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Unsupported data source refresh task.');
|
throw new Error('Unsupported data source refresh task.');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
|||||||
sourceName: apiSource.name
|
sourceName: apiSource.name
|
||||||
},
|
},
|
||||||
run: function () {
|
run: function () {
|
||||||
return refreshApiSource(pool, common, apiSource, null);
|
return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
|||||||
sourceName: rssFeed.name
|
sourceName: rssFeed.name
|
||||||
},
|
},
|
||||||
run: function () {
|
run: function () {
|
||||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -115,11 +115,11 @@ function createDataSourceTaskService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshApiSourceInBackground(apiSourceId, actorId) {
|
async function refreshApiSourceInBackground(apiSourceId, actorId) {
|
||||||
return refreshApiSource(pool, common, apiSourceId, actorId);
|
return refreshApiSource(pool, common, apiSourceId, actorId, options.notifyPlayerScreens);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId);
|
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, options.notifyPlayerScreens);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ function scheduleStartupDataSourceRefreshes(options) {
|
|||||||
|
|
||||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
||||||
return refreshApiSource(pool, common, apiSource, null);
|
return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,112 @@
|
|||||||
async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
|
||||||
|
const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||||
|
const slideIds = [];
|
||||||
|
const seenSlideIds = new Set();
|
||||||
|
|
||||||
|
slideRows.forEach(function (row) {
|
||||||
|
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
|
||||||
|
if (!content || typeof content !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [content];
|
||||||
|
while (stack.length) {
|
||||||
|
const value = stack.pop();
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(function (item) {
|
||||||
|
stack.push(item);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, slideMatchKey) && Number(value[slideMatchKey]) === Number(sourceId)) {
|
||||||
|
const slideId = Number(row.id);
|
||||||
|
if (Number.isFinite(slideId) && slideId > 0 && !seenSlideIds.has(slideId)) {
|
||||||
|
seenSlideIds.add(slideId);
|
||||||
|
slideIds.push(slideId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(value).forEach(function (key) {
|
||||||
|
stack.push(value[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!slideIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [screenRows] = await connection.query(
|
||||||
|
`SELECT DISTINCT s.slug
|
||||||
|
FROM d_screens s
|
||||||
|
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||||
|
WHERE ps.slide_id IN (?)
|
||||||
|
AND s.slug IS NOT NULL`,
|
||||||
|
[slideIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
return screenRows.map(function (row) {
|
||||||
|
return String(row.slug || '').trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyAffectedScreens(connection, common, notifyPlayerScreens, slideMatchKey, sourceId) {
|
||||||
|
if (typeof notifyPlayerScreens !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugs = await getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId);
|
||||||
|
if (!slugs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyPlayerScreens(slugs, 'refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshotValue(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasRssFeedChanged(connection, rssFeedId, items) {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT item_json
|
||||||
|
FROM i_rss_feed_items
|
||||||
|
WHERE rss_feed_id = ?
|
||||||
|
ORDER BY position ASC, id ASC`,
|
||||||
|
[rssFeedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSnapshots = (rows || []).map(function (row) {
|
||||||
|
return normalizeSnapshotValue(row && row.item_json);
|
||||||
|
});
|
||||||
|
const nextSnapshots = (Array.isArray(items) ? items : []).map(function (item) {
|
||||||
|
return normalizeSnapshotValue(JSON.stringify(item || {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentSnapshots.length !== nextSnapshots.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < currentSnapshots.length; index += 1) {
|
||||||
|
if (currentSnapshots[index] !== nextSnapshots[index]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasApiSourceChanged(apiSource, responseDetails) {
|
||||||
|
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
|
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
|
||||||
@@ -25,6 +133,14 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
|||||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
|
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
|
||||||
);
|
);
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
|
|
||||||
|
if (!pullError && hasApiSourceChanged(apiSource, responseDetails)) {
|
||||||
|
try {
|
||||||
|
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'source_id', apiSource.id);
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.warn('[data-source-refresh] Unable to notify players after API source refresh ' + apiSource.id + ':', notifyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
@@ -37,7 +153,7 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) {
|
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, notifyPlayerScreens) {
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
let updatedItems = [];
|
let updatedItems = [];
|
||||||
@@ -50,11 +166,20 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
|
|||||||
}
|
}
|
||||||
|
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
|
const rssFeedChanged = await hasRssFeedChanged(connection, rssFeedId, updatedItems);
|
||||||
if (typeof common.replaceRssFeedItems === 'function') {
|
if (typeof common.replaceRssFeedItems === 'function') {
|
||||||
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||||
}
|
}
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
|
|
||||||
|
if (!pullError && rssFeedChanged) {
|
||||||
|
try {
|
||||||
|
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'feed_id', rssFeedId);
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.warn('[data-source-refresh] Unable to notify players after RSS feed refresh ' + rssFeedId + ':', notifyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pullError) {
|
if (pullError) {
|
||||||
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasVisibleContent(html) {
|
function hasVisibleContent(html) {
|
||||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
var raw = String(html || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/<img\b/i.test(raw)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextRegionMarkup(region, regionContent) {
|
function buildTextRegionMarkup(region, regionContent) {
|
||||||
|
|||||||
@@ -83,7 +83,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasVisibleContent(html) {
|
function hasVisibleContent(html) {
|
||||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
var raw = String(html || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/<img\b/i.test(raw)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextRegionMarkup(region, regionContent) {
|
function buildTextRegionMarkup(region, regionContent) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async function initializeWebServer(options) {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
backgroundTaskQueue: backgroundTaskQueue,
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
notifyPlayerScreens: options && options.notifyPlayerScreens ? options.notifyPlayerScreens : null,
|
||||||
uploadSyncService: webBootstrap.uploadSyncService,
|
uploadSyncService: webBootstrap.uploadSyncService,
|
||||||
captureSlideThumbnail: captureSlideThumbnail,
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
mediaDir: mediaDir,
|
mediaDir: mediaDir,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -423,19 +423,6 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
|
||||||
var redirectUrl = submitterValue === 'close'
|
|
||||||
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
|
|
||||||
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
|
|
||||||
window.location.replace(redirectUrl);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
|
|
||||||
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseText = await response.text();
|
var responseText = await response.text();
|
||||||
var responseDocument = null;
|
var responseDocument = null;
|
||||||
try {
|
try {
|
||||||
@@ -456,6 +443,19 @@
|
|||||||
|
|
||||||
clearFormDirty(form);
|
clearFormDirty(form);
|
||||||
|
|
||||||
|
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||||
|
var redirectUrl = submitterValue === 'close'
|
||||||
|
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
|
||||||
|
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
|
||||||
|
window.location.replace(redirectUrl);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
|
||||||
|
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var successMessage = typeof settings.getSuccessMessage === 'function'
|
var successMessage = typeof settings.getSuccessMessage === 'function'
|
||||||
? settings.getSuccessMessage({
|
? settings.getSuccessMessage({
|
||||||
form: form,
|
form: form,
|
||||||
|
|||||||
@@ -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);
|
||||||
}());
|
}());
|
||||||
@@ -28,11 +28,19 @@
|
|||||||
var name = String(match[2] || '').toLowerCase();
|
var name = String(match[2] || '').toLowerCase();
|
||||||
var attrText = String(match[3] || '');
|
var attrText = String(match[3] || '');
|
||||||
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
|
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
|
||||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||||
if (allowed.indexOf(name) === -1) {
|
if (allowed.indexOf(name) === -1) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'img') {
|
||||||
|
var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||||
|
var srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||||
|
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (closing) {
|
if (closing) {
|
||||||
return '</' + name + '>';
|
return '</' + name + '>';
|
||||||
}
|
}
|
||||||
@@ -42,7 +50,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
function sanitizeRichText(html) {
|
||||||
return sanitizePreviewHtml(html);
|
var output = String(html || '');
|
||||||
|
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||||
|
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||||
|
return output.replace(/<[^>]+>/g, function (tag) {
|
||||||
|
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||||
|
if (!match) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var closing = Boolean(match[1]);
|
||||||
|
var name = String(match[2] || '').toLowerCase();
|
||||||
|
var attrText = String(match[3] || '');
|
||||||
|
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||||
|
if (allowed.indexOf(name) === -1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closing) {
|
||||||
|
return '</' + name + '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||||
|
var allowedAttributes = {
|
||||||
|
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||||
|
blockquote: ['class', 'style'],
|
||||||
|
div: ['class', 'style'],
|
||||||
|
figure: ['class', 'style'],
|
||||||
|
figcaption: ['class', 'style'],
|
||||||
|
h1: ['class', 'style'],
|
||||||
|
h2: ['class', 'style'],
|
||||||
|
h3: ['class', 'style'],
|
||||||
|
h4: ['class', 'style'],
|
||||||
|
h5: ['class', 'style'],
|
||||||
|
h6: ['class', 'style'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||||
|
col: ['class', 'style', 'span', 'width'],
|
||||||
|
colgroup: ['class', 'style', 'span'],
|
||||||
|
li: ['class', 'style'],
|
||||||
|
ol: ['class', 'style', 'start'],
|
||||||
|
p: ['class', 'style'],
|
||||||
|
pre: ['class', 'style'],
|
||||||
|
span: ['class', 'style'],
|
||||||
|
table: ['class', 'style'],
|
||||||
|
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||||
|
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||||
|
tr: ['class', 'style'],
|
||||||
|
ul: ['class', 'style']
|
||||||
|
};
|
||||||
|
var allowed = allowedAttributes[tagName] || [];
|
||||||
|
if (!allowed.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === 'img') {
|
||||||
|
var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
|
||||||
|
var srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
|
||||||
|
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var attrs = [];
|
||||||
|
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
||||||
|
var lowerKey = String(key || '').toLowerCase();
|
||||||
|
if (allowed.indexOf(lowerKey) === -1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||||
|
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (lowerKey === 'target') {
|
||||||
|
var targetValue = String(value || '').trim();
|
||||||
|
if (targetValue === '_blank') {
|
||||||
|
attrs.push(' target="_blank"');
|
||||||
|
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||||
|
attrs.push(' rel="noreferrer noopener"');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return attrs.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeTagAttributes(tagName, attrText) {
|
function sanitizeTagAttributes(tagName, attrText) {
|
||||||
@@ -64,6 +165,7 @@
|
|||||||
pre: ['class', 'style'],
|
pre: ['class', 'style'],
|
||||||
span: ['class', 'style'],
|
span: ['class', 'style'],
|
||||||
table: ['class', 'style'],
|
table: ['class', 'style'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||||
tr: ['class', 'style'],
|
tr: ['class', 'style'],
|
||||||
@@ -844,6 +946,7 @@
|
|||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
sanitizePreviewHtml: sanitizePreviewHtml,
|
sanitizePreviewHtml: sanitizePreviewHtml,
|
||||||
sanitizeRichText: sanitizeRichText,
|
sanitizeRichText: sanitizeRichText,
|
||||||
|
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
|
||||||
sanitizeFontFamily: sanitizeFontFamily,
|
sanitizeFontFamily: sanitizeFontFamily,
|
||||||
sanitizeTextColor: sanitizeTextColor,
|
sanitizeTextColor: sanitizeTextColor,
|
||||||
normalizeAcceptList: normalizeAcceptList,
|
normalizeAcceptList: normalizeAcceptList,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
function sanitizePreviewHtml(html) {
|
||||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
|
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFontFamily(value) {
|
function sanitizeFontFamily(value) {
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||||
}
|
}
|
||||||
if (description) {
|
if (description) {
|
||||||
summary.push('<div>' + sanitizeRichText(description) + '</div>');
|
summary.push('<div>' + sanitizePreviewHtml(description) + '</div>');
|
||||||
}
|
}
|
||||||
if (!summary.length) {
|
if (!summary.length) {
|
||||||
return '';
|
return '';
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
var renderedBody = sanitizeRichText(body);
|
var renderedBody = sanitizePreviewHtml(body);
|
||||||
return renderedBody ? '<div class="template-region api" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
return renderedBody ? '<div class="template-region api" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,8 +272,18 @@
|
|||||||
function buildPreviewRenderContext(region, card, existingContent, sources) {
|
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
|
||||||
|
|||||||
@@ -17,10 +17,6 @@
|
|||||||
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
|
||||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : sanitizePreviewHtml(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFontFamily(value) {
|
function sanitizeFontFamily(value) {
|
||||||
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
|
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
|
||||||
}
|
}
|
||||||
@@ -198,7 +194,7 @@
|
|||||||
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
||||||
}
|
}
|
||||||
if (item.description) {
|
if (item.description) {
|
||||||
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
|
summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
|
||||||
}
|
}
|
||||||
body = summaryParts.join('');
|
body = summaryParts.join('');
|
||||||
}
|
}
|
||||||
@@ -206,7 +202,7 @@
|
|||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
var renderedBody = sanitizeRichText(body);
|
var renderedBody = sanitizePreviewHtml(body);
|
||||||
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,8 +273,18 @@
|
|||||||
function buildPreviewRenderContext(region, card, existingContent, feeds) {
|
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;
|
||||||
|
|||||||
@@ -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 || {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }) || '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+127
-17
@@ -15,10 +15,6 @@
|
|||||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
|
||||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultStyle() {
|
function getDefaultStyle() {
|
||||||
return {
|
return {
|
||||||
font_family: DEFAULT_STYLE.font_family,
|
font_family: DEFAULT_STYLE.font_family,
|
||||||
@@ -43,6 +39,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 +161,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 +185,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 +244,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 +294,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 +367,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 +399,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,13 +411,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
|
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
|
||||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
|
var entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || '');
|
||||||
|
var timezoneValues = getTimezoneValues(group, entryDate);
|
||||||
|
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + (utils.sanitizeRichText ? utils.sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
|
||||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
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
|
||||||
}))) + '</div>';
|
}))) : escapeHtml(renderTemplate(value, Object.assign({}, entry || {}, {
|
||||||
|
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||||
|
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||||
|
tz: timezoneValues.tz,
|
||||||
|
tz_long: timezoneValues.tz_long,
|
||||||
|
timeZone: timezoneValues.tz_long,
|
||||||
|
group: group || {},
|
||||||
|
entries: entries,
|
||||||
|
index: index + 1
|
||||||
|
})))) + '</div>';
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
root.timeDatePlaceholders = {
|
root.timeDatePlaceholders = {
|
||||||
getTokens: getTokens,
|
getTokens: getTokens,
|
||||||
renderChips: renderChips
|
getFormatTokens: getFormatTokens,
|
||||||
|
renderTable: renderTable
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
@@ -11,6 +11,16 @@ export function createSlideFormEditorController(options) {
|
|||||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
|
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
var imageUploadUrl = String(settings.imageUploadUrl || '/slides/uploads').trim() || '/slides/uploads';
|
||||||
|
var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));
|
||||||
|
var imageUploadLimitLabel = String(settings.imageUploadLimitLabel || '').trim() || Math.max(1, Math.round(imageUploadMaxBytes / (1024 * 1024))) + ' MB';
|
||||||
|
var imageUploadContext = String(settings.imageUploadContext || 'wysiwyg').trim() || 'wysiwyg';
|
||||||
|
var imageUploadAllowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff'];
|
||||||
|
var imageUploadAllowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif', 'image/tiff'];
|
||||||
|
var imageUploadFileTypes = imageUploadAllowedExtensions.join(',');
|
||||||
|
var editorImageUploadPaths = new Set();
|
||||||
|
var committedEditorImageUploadPaths = new Set();
|
||||||
|
var pendingEditorImageUploadCleanupPaths = new Set();
|
||||||
var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () {
|
var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () {
|
||||||
return '#111111';
|
return '#111111';
|
||||||
};
|
};
|
||||||
@@ -29,6 +39,10 @@ export function createSlideFormEditorController(options) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (/<img\b/i.test(raw)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var stripped = raw
|
var stripped = raw
|
||||||
.replace(/<\s*br\s*\/?>/gi, '')
|
.replace(/<\s*br\s*\/?>/gi, '')
|
||||||
.replace(/<p[^>]*>(?:\s| |<br\s*\/?>)*<\/p>/gi, '')
|
.replace(/<p[^>]*>(?:\s| |<br\s*\/?>)*<\/p>/gi, '')
|
||||||
@@ -72,6 +86,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 +121,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;
|
||||||
@@ -192,6 +223,191 @@ export function createSlideFormEditorController(options) {
|
|||||||
: themeAssets.contentCss;
|
: themeAssets.contentCss;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUploadPath(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEditorImageUploadPaths(html) {
|
||||||
|
var matches = String(html || '').match(/\/media\/uploads\/[^^\s"'<>]+/g);
|
||||||
|
return matches ? Array.from(new Set(matches.map(normalizeUploadPath).filter(Boolean))) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCurrentEditorImageUploadPaths() {
|
||||||
|
var currentPaths = new Set();
|
||||||
|
|
||||||
|
editorInstances.forEach(function (editor, regionId) {
|
||||||
|
var hidden = getEditorHiddenInput(regionId);
|
||||||
|
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
|
||||||
|
var fallbackContent = hidden && hidden.value !== undefined ? hidden.value : (sourceElm && sourceElm.value !== undefined ? sourceElm.value : '');
|
||||||
|
collectEditorImageUploadPaths(getEditorContentSafely(editor, fallbackContent)).forEach(function (path) {
|
||||||
|
currentPaths.add(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUploadCleanupPaths() {
|
||||||
|
var currentPaths = collectCurrentEditorImageUploadPaths();
|
||||||
|
return Array.from(editorImageUploadPaths).filter(function (path) {
|
||||||
|
return !currentPaths.has(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommittedImageUploadCleanupPaths() {
|
||||||
|
var currentPaths = collectCurrentEditorImageUploadPaths();
|
||||||
|
return Array.from(committedEditorImageUploadPaths).filter(function (path) {
|
||||||
|
return !currentPaths.has(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingImageUploadPaths() {
|
||||||
|
return Array.from(editorImageUploadPaths).filter(function (path) {
|
||||||
|
return !committedEditorImageUploadPaths.has(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueImageUploadCleanupPaths(paths) {
|
||||||
|
Array.from(new Set((paths || []).map(normalizeUploadPath).filter(Boolean))).forEach(function (path) {
|
||||||
|
pendingEditorImageUploadCleanupPaths.add(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingImageUploadCleanupPaths() {
|
||||||
|
var currentPaths = collectCurrentEditorImageUploadPaths();
|
||||||
|
return Array.from(pendingEditorImageUploadCleanupPaths).filter(function (path) {
|
||||||
|
return !currentPaths.has(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markImageUploadsCommitted() {
|
||||||
|
committedEditorImageUploadPaths = collectCurrentEditorImageUploadPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllImageUploadPaths() {
|
||||||
|
return Array.from(editorImageUploadPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImageUploadPaths() {
|
||||||
|
editorImageUploadPaths.clear();
|
||||||
|
committedEditorImageUploadPaths.clear();
|
||||||
|
pendingEditorImageUploadCleanupPaths.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(fileName) {
|
||||||
|
var match = String(fileName || '').toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||||
|
return match ? String(match[1] || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUploadValidationMessage(blobInfo) {
|
||||||
|
var blob = blobInfo && typeof blobInfo.blob === 'function' ? blobInfo.blob() : null;
|
||||||
|
var fileName = blobInfo && typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || '') : '';
|
||||||
|
var mimeType = blob && blob.type ? String(blob.type || '').trim().toLowerCase() : '';
|
||||||
|
var extension = getFileExtension(fileName);
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
return 'No image file was provided.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(blob.size || 0) > imageUploadMaxBytes) {
|
||||||
|
return 'Image must be ' + imageUploadLimitLabel + ' or smaller. Larger images should use the dedicated Image region.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType && imageUploadAllowedMimeTypes.indexOf(mimeType) === -1) {
|
||||||
|
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mimeType && extension && imageUploadAllowedExtensions.indexOf(extension) === -1) {
|
||||||
|
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mimeType && !extension) {
|
||||||
|
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadEditorImage(blobInfo, progress) {
|
||||||
|
var validationError = getImageUploadValidationMessage(blobInfo);
|
||||||
|
if (validationError) {
|
||||||
|
return Promise.reject(new Error(validationError));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
var formData = new FormData();
|
||||||
|
var blob = blobInfo.blob();
|
||||||
|
var fileName = typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || 'image') : 'image';
|
||||||
|
|
||||||
|
formData.append('file', blob, fileName);
|
||||||
|
|
||||||
|
xhr.open('POST', imageUploadUrl, true);
|
||||||
|
xhr.responseType = 'text';
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||||
|
xhr.setRequestHeader('X-Upload-Context', imageUploadContext);
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function (event) {
|
||||||
|
if (!progress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event || !event.lengthComputable || !event.total) {
|
||||||
|
progress(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(Math.round((event.loaded / event.total) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = function () {
|
||||||
|
var responseText = String(xhr.responseText || '');
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
reject(new Error(responseText || 'Unable to upload image.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseText.trim().toLowerCase().indexOf('<!doctype html') === 0 || responseText.toLowerCase().indexOf('<html') !== -1) {
|
||||||
|
reject(new Error('Upload redirected to an HTML page. Please sign in again and retry.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = {};
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(responseText || '{}') || {};
|
||||||
|
} catch (_error) {
|
||||||
|
reject(new Error('Unable to parse the upload response.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.path) {
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress) {
|
||||||
|
progress(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
editorImageUploadPaths.add(String(payload.path || '').trim());
|
||||||
|
|
||||||
|
resolve(String(payload.path || ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function () {
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.ontimeout = function () {
|
||||||
|
reject(new Error('Upload timed out. Please try again.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function attachEditorEvents(regionId, editor) {
|
function attachEditorEvents(regionId, editor) {
|
||||||
var hidden = getEditorHiddenInput(regionId);
|
var hidden = getEditorHiddenInput(regionId);
|
||||||
var source = editor && editor.targetElm ? editor.targetElm : null;
|
var source = editor && editor.targetElm ? editor.targetElm : null;
|
||||||
@@ -211,7 +427,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;
|
||||||
@@ -288,16 +504,27 @@ export function createSlideFormEditorController(options) {
|
|||||||
menubar: false,
|
menubar: false,
|
||||||
branding: false,
|
branding: false,
|
||||||
promotion: false,
|
promotion: false,
|
||||||
|
relative_urls: false,
|
||||||
|
remove_script_host: false,
|
||||||
|
convert_urls: true,
|
||||||
paste_data_images: false,
|
paste_data_images: false,
|
||||||
plugins: 'lists code advlist fullscreen table',
|
automatic_uploads: true,
|
||||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
images_file_types: imageUploadFileTypes,
|
||||||
|
images_upload_handler: uploadEditorImage,
|
||||||
|
plugins: 'lists code advlist fullscreen table image',
|
||||||
|
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table image chip | fullscreen',
|
||||||
toolbar_mode: 'sliding',
|
toolbar_mode: 'sliding',
|
||||||
license_key: 'gpl',
|
license_key: 'gpl',
|
||||||
|
table_default_attributes: {
|
||||||
|
border: '1',
|
||||||
|
cellpadding: '0',
|
||||||
|
cellspacing: '0'
|
||||||
|
},
|
||||||
skin: themeAssets.skinName,
|
skin: themeAssets.skinName,
|
||||||
skin_url: themeAssets.skinUrl,
|
skin_url: themeAssets.skinUrl,
|
||||||
content_css: getContentCss(),
|
content_css: getContentCss(),
|
||||||
body_class: themeAssets.bodyClass,
|
body_class: themeAssets.bodyClass,
|
||||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; border-spacing: 0; width: 100%; } td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||||
font_family_formats: getFontFamilyFormats(),
|
font_family_formats: getFontFamilyFormats(),
|
||||||
font_size_input_default_unit: 'px',
|
font_size_input_default_unit: 'px',
|
||||||
invalid_elements: 'a',
|
invalid_elements: 'a',
|
||||||
@@ -377,6 +604,7 @@ export function createSlideFormEditorController(options) {
|
|||||||
if (editor.targetElm) {
|
if (editor.targetElm) {
|
||||||
editor.targetElm.value = editor.getContent({ format: 'html' });
|
editor.targetElm.value = editor.getContent({ format: 'html' });
|
||||||
}
|
}
|
||||||
|
markImageUploadsCommitted();
|
||||||
return editor;
|
return editor;
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.error('Failed to initialize TinyMCE.', error);
|
console.error('Failed to initialize TinyMCE.', error);
|
||||||
@@ -405,18 +633,19 @@ export function createSlideFormEditorController(options) {
|
|||||||
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) {
|
var 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(saves);
|
return Promise.all(saves).then(function (results) {
|
||||||
|
getCommittedImageUploadCleanupPaths();
|
||||||
|
return results;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyEditors() {
|
function destroyEditors() {
|
||||||
@@ -454,6 +683,14 @@ export function createSlideFormEditorController(options) {
|
|||||||
return {
|
return {
|
||||||
renderEditors: renderEditors,
|
renderEditors: renderEditors,
|
||||||
syncEditors: syncEditors,
|
syncEditors: syncEditors,
|
||||||
|
getImageUploadCleanupPaths: getImageUploadCleanupPaths,
|
||||||
|
getCommittedImageUploadCleanupPaths: getCommittedImageUploadCleanupPaths,
|
||||||
|
getPendingImageUploadPaths: getPendingImageUploadPaths,
|
||||||
|
getPendingImageUploadCleanupPaths: getPendingImageUploadCleanupPaths,
|
||||||
|
getAllImageUploadPaths: getAllImageUploadPaths,
|
||||||
|
queueImageUploadCleanupPaths: queueImageUploadCleanupPaths,
|
||||||
|
markImageUploadsCommitted: markImageUploadsCommitted,
|
||||||
|
clearImageUploadPaths: clearImageUploadPaths,
|
||||||
destroyEditors: function () {
|
destroyEditors: function () {
|
||||||
if (themeObserver) {
|
if (themeObserver) {
|
||||||
themeObserver.disconnect();
|
themeObserver.disconnect();
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
var uploadMaxLabel = '100 MB';
|
var uploadMaxLabel = '100 MB';
|
||||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||||
var uploadVideoMaxLabel = '1 GB';
|
var uploadVideoMaxLabel = '1 GB';
|
||||||
|
var wysiwygImageUploadMaxBytes = 2 * 1024 * 1024;
|
||||||
|
var wysiwygImageUploadLimitLabel = '2 MB';
|
||||||
var videoDurationCache = Object.create(null);
|
var videoDurationCache = Object.create(null);
|
||||||
var previewRenderFrame = 0;
|
var previewRenderFrame = 0;
|
||||||
var previewPopupWindow = null;
|
var previewPopupWindow = null;
|
||||||
@@ -106,6 +108,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||||
fontFamilyFormats: slideEditorData.fontFamilyFormats || '',
|
fontFamilyFormats: slideEditorData.fontFamilyFormats || '',
|
||||||
fontStylesheetHref: fontStylesheetHref,
|
fontStylesheetHref: fontStylesheetHref,
|
||||||
|
imageUploadMaxBytes: wysiwygImageUploadMaxBytes,
|
||||||
|
imageUploadLimitLabel: wysiwygImageUploadLimitLabel,
|
||||||
|
imageUploadContext: 'wysiwyg',
|
||||||
getRegionTypeModule: getRegionTypeModule,
|
getRegionTypeModule: getRegionTypeModule,
|
||||||
getEditorBackgroundColor: function () {
|
getEditorBackgroundColor: function () {
|
||||||
var template = getTemplateById(templateSelect.value);
|
var template = getTemplateById(templateSelect.value);
|
||||||
@@ -840,6 +845,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
throw new Error('Wait for the image upload to finish before saving.');
|
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.',
|
||||||
@@ -915,6 +928,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
if (regionMediaController) {
|
if (regionMediaController) {
|
||||||
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
|
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
|
||||||
}
|
}
|
||||||
|
if (slideFormEditorController && typeof slideFormEditorController.getCommittedImageUploadCleanupPaths === 'function' && regionMediaController) {
|
||||||
|
regionMediaController.queueUploadCleanup(slideFormEditorController.getCommittedImageUploadCleanupPaths());
|
||||||
|
}
|
||||||
|
if (slideFormEditorController && typeof slideFormEditorController.getImageUploadCleanupPaths === 'function' && regionMediaController) {
|
||||||
|
regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths());
|
||||||
|
}
|
||||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||||
previewPopupWindow.close();
|
previewPopupWindow.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'setclientname') {
|
if (command === 'setclientname') {
|
||||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
||||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return res.status(400).json({ error: 'Device ID is required' });
|
return res.status(400).json({ error: 'Device ID is required' });
|
||||||
@@ -344,7 +344,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'moveclient') {
|
if (command === 'moveclient') {
|
||||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
||||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||||
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 25;
|
const LIST_PAGE_SIZE = 25;
|
||||||
|
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -104,9 +105,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileMediaType(file) {
|
function getUploadedFileMediaType(file, uploadContext) {
|
||||||
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||||
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
||||||
|
const isWysiwyg = String(uploadContext || '').trim().toLowerCase() === 'wysiwyg';
|
||||||
|
|
||||||
|
if (isWysiwyg && mimeType.indexOf('image/') !== 0 && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
||||||
return 'video';
|
return 'video';
|
||||||
}
|
}
|
||||||
@@ -116,12 +123,20 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileLimitBytes(file) {
|
function getUploadedFileLimitBytes(file, uploadContext) {
|
||||||
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||||
|
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileLimitLabel(file) {
|
function getUploadedFileLimitLabel(file, uploadContext) {
|
||||||
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
|
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||||
|
return '10 MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeUploadedFile(file) {
|
async function removeUploadedFile(file) {
|
||||||
@@ -137,11 +152,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateUploadedFiles(files) {
|
async function validateUploadedFiles(files, uploadContext) {
|
||||||
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||||
for (let i = 0; i < list.length; i += 1) {
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
const file = list[i];
|
const file = list[i];
|
||||||
const mediaType = getUploadedFileMediaType(file);
|
const mediaType = getUploadedFileMediaType(file, uploadContext);
|
||||||
if (!mediaType) {
|
if (!mediaType) {
|
||||||
await removeUploadedFile(file);
|
await removeUploadedFile(file);
|
||||||
const error = new Error('Unsupported upload type.');
|
const error = new Error('Unsupported upload type.');
|
||||||
@@ -150,9 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
|
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
|
||||||
await removeUploadedFile(file);
|
await removeUploadedFile(file);
|
||||||
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.');
|
const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
|
||||||
|
? 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'
|
||||||
|
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext) + ' or smaller.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
error.expose = true;
|
error.expose = true;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -376,7 +393,9 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
return res.status(400).json({ error: 'No file was uploaded.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await validateUploadedFiles([req.file]);
|
const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
await validateUploadedFiles([req.file], uploadContext);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
path: '/media/uploads/' + req.file.filename,
|
path: '/media/uploads/' + req.file.filename,
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
return text.slice(0, limit);
|
return text.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value) {
|
|
||||||
return String(value || '').trim().replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExplicitPlayerBaseUrl(value) {
|
function normalizeExplicitPlayerBaseUrl(value) {
|
||||||
return String(value || '').trim().replace(/\/$/, '');
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
@@ -246,39 +242,24 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||||
return res.status(400).send('A screen with that name already exists.');
|
return res.status(400).send('A screen with that name already exists.');
|
||||||
}
|
}
|
||||||
const slugInput = typeof common.validateMaxLength === 'function'
|
|
||||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
|
||||||
: readLimitedText(req.body.slug || '', 255);
|
|
||||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||||
const previousPlaylistId = screen.playlist_id;
|
const previousPlaylistId = screen.playlist_id;
|
||||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
const slug = String(screen.slug || '').trim();
|
||||||
const previousSlug = String(screen.slug || '').trim();
|
const previousSlug = String(screen.slug || '').trim();
|
||||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
}
|
}
|
||||||
if (previousSlug && previousSlug !== slug) {
|
if (previousSlug && previousSlug !== slug) {
|
||||||
const previousScreenTargets = await pool.query(
|
const previousConnections = await fetchLiveConnectionsForScreen(previousSlug);
|
||||||
`SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url
|
const redirectPayload = {
|
||||||
FROM d_screens s
|
command: 'redirect',
|
||||||
LEFT JOIN d_players p ON p.device_id = s.player_id
|
url: `/screen/${encodeURIComponent(slug)}`
|
||||||
WHERE s.slug = ?
|
};
|
||||||
LIMIT 1`,
|
if (previousConnections.length) {
|
||||||
[previousSlug]
|
await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload);
|
||||||
);
|
|
||||||
const previousTargetRow = previousScreenTargets[0] && previousScreenTargets[0][0] || null;
|
|
||||||
const previousInternalBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.internal_base_url) || '';
|
|
||||||
const previousPublicBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.public_base_url) || '';
|
|
||||||
if (previousInternalBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
|
||||||
await forwardPlayerCommandToBaseUrl(previousInternalBaseUrl, previousSlug, {
|
|
||||||
command: 'redirect',
|
|
||||||
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await forwardPlayerCommand(previousSlug, {
|
await forwardPlayerCommand(previousSlug, redirectPayload);
|
||||||
command: 'redirect',
|
|
||||||
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ function toIsoTimestamp(value) {
|
|||||||
|
|
||||||
function formatIntervalLabel(interval, unit) {
|
function formatIntervalLabel(interval, unit) {
|
||||||
const value = Math.max(1, Number(interval) || 0);
|
const value = Math.max(1, Number(interval) || 0);
|
||||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||||
if (normalizedUnit === 'seconds') {
|
if (normalizedUnit === 'seconds') {
|
||||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||||
}
|
}
|
||||||
|
if (normalizedUnit === 'hours') {
|
||||||
|
return value === 1 ? 'Every hour' : `Every ${value} hours`;
|
||||||
|
}
|
||||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
|||||||
itemsPath: apiSource.items_path || '',
|
itemsPath: apiSource.items_path || '',
|
||||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
: apiSource.update_interval_unit === 'hours'
|
||||||
|
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every hour' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} hours`)
|
||||||
|
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||||
inUse: usageIds.has(Number(apiSource.id))
|
inUse: usageIds.has(Number(apiSource.id))
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ const { renderView } = require('../../../view');
|
|||||||
|
|
||||||
function formatIntervalLabel(interval, unit) {
|
function formatIntervalLabel(interval, unit) {
|
||||||
const value = Math.max(1, Number(interval) || 0);
|
const value = Math.max(1, Number(interval) || 0);
|
||||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||||
if (normalizedUnit === 'seconds') {
|
if (normalizedUnit === 'seconds') {
|
||||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||||
}
|
}
|
||||||
|
if (normalizedUnit === 'hours') {
|
||||||
|
return value === 1 ? 'Every hour' : `Every ${value} hours`;
|
||||||
|
}
|
||||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const DATE_FILTER_FIELDS = {
|
|||||||
|
|
||||||
function formatIntervalLabel(intervalMs) {
|
function formatIntervalLabel(intervalMs) {
|
||||||
const value = Math.max(1, Number(intervalMs) || 0);
|
const value = Math.max(1, Number(intervalMs) || 0);
|
||||||
|
if (value % 86400000 === 0) {
|
||||||
|
const days = Math.max(1, value / 86400000);
|
||||||
|
return days === 1 ? 'Every day' : `Every ${days} days`;
|
||||||
|
}
|
||||||
|
if (value % 3600000 === 0) {
|
||||||
|
const hours = Math.max(1, value / 3600000);
|
||||||
|
return hours === 1 ? 'Every hour' : `Every ${hours} hours`;
|
||||||
|
}
|
||||||
if (value % 60000 === 0) {
|
if (value % 60000 === 0) {
|
||||||
const minutes = Math.max(1, value / 60000);
|
const minutes = Math.max(1, value / 60000);
|
||||||
return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`;
|
return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`;
|
||||||
@@ -167,6 +175,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 +295,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
|
||||||
};
|
};
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
||||||
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||||
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||||
|
<option value="hours" {{#if (eq apiSource.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||||
|
<option value="hours" {{#if (eq rssFeed.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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%;">
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
</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 mb-4">
|
||||||
<div class="card card-outline card-primary dashboard-actions-card">
|
<div class="card card-outline card-primary dashboard-actions-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
@@ -108,8 +108,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-xl-4">
|
<div class="col-12 col-xl-4 mb-4">
|
||||||
<div class="card card-outline card-secondary dashboard-launcher-card h-100">
|
<div class="card card-outline card-secondary dashboard-launcher-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Kiosk launchers</h3>
|
<h3 class="card-title">Kiosk launchers</h3>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="screen-slug" class="form-label">Slug</label>
|
<label for="screen-slug" class="form-label">Slug</label>
|
||||||
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" />
|
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" {{#if isEdit}}disabled aria-disabled="true" title="Slug cannot be changed after creation"{{/if}} />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label for="screen-playlist" class="form-label">Playlist</label>
|
<label for="screen-playlist" class="form-label">Playlist</label>
|
||||||
|
|||||||
@@ -8,4 +8,11 @@ test('async save errors keep validation failures as warning toasts', () => {
|
|||||||
assert.ok(adminPageScript.includes('function isWarningSaveError(error)'));
|
assert.ok(adminPageScript.includes('function isWarningSaveError(error)'));
|
||||||
assert.ok(adminPageScript.includes('error.status = response.status;'));
|
assert.ok(adminPageScript.includes('error.status = response.status;'));
|
||||||
assert.ok(adminPageScript.includes('var variant = isWarningSaveError(error) ? \'warning\' : \'danger\';'));
|
assert.ok(adminPageScript.includes('var variant = isWarningSaveError(error) ? \'warning\' : \'danger\';'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('async save runs success hooks before redirecting close or new saves', () => {
|
||||||
|
assert.ok(adminPageScript.includes('var responseText = await response.text();'));
|
||||||
|
assert.ok(adminPageScript.includes('if (typeof settings.afterSuccess === \'function\')'));
|
||||||
|
assert.ok(adminPageScript.includes('clearFormDirty(form);'));
|
||||||
|
assert.ok(adminPageScript.includes("if (submitterValue === 'close' || submitterValue === 'new')"));
|
||||||
});
|
});
|
||||||
@@ -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,8 +85,50 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
|||||||
assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/);
|
assert.match(queueHtml, /data-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"/);
|
||||||
assert.match(scheduledHtml, /Player:\s+Player Beta/);
|
assert.match(scheduledHtml, /Player:\s+Player Beta/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduled task interval labels use the largest exact unit while sorting stays numeric', () => {
|
||||||
|
const scheduledHtml = renderBackgroundTasksScheduledPage(
|
||||||
|
{
|
||||||
|
recurringTasks: [
|
||||||
|
{
|
||||||
|
title: 'Hourly task',
|
||||||
|
key: 'hourly',
|
||||||
|
intervalMs: 7200000,
|
||||||
|
metadata: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Second task',
|
||||||
|
key: 'seconds',
|
||||||
|
intervalMs: 3000,
|
||||||
|
metadata: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Minute task',
|
||||||
|
key: 'minutes',
|
||||||
|
intervalMs: 60000,
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sort: 'interval',
|
||||||
|
direction: 'asc',
|
||||||
|
summary: { counts: {}, total: 3, activeCount: 0, scheduledCount: 3 }
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
{
|
||||||
|
permissions: ['scheduled-tasks.allow']
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(scheduledHtml, /Every 3 seconds/);
|
||||||
|
assert.match(scheduledHtml, /Every minute/);
|
||||||
|
assert.match(scheduledHtml, /Every 2 hours/);
|
||||||
|
assert.ok(scheduledHtml.indexOf('Every 3 seconds') < scheduledHtml.indexOf('Every minute'));
|
||||||
|
assert.ok(scheduledHtml.indexOf('Every minute') < scheduledHtml.indexOf('Every 2 hours'));
|
||||||
});
|
});
|
||||||
@@ -292,3 +292,127 @@ test('slide upload cleanup route removes unused uploads', async () => {
|
|||||||
assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads');
|
assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads');
|
||||||
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']);
|
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('wysiwyg image uploads are capped below the dedicated image region limit', async () => {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
get(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
},
|
||||||
|
post(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
pool: {
|
||||||
|
async query() {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
fetchTemplatesData: async () => ({}),
|
||||||
|
fetchRssFeedsData: async () => ({ rssFeeds: [] }),
|
||||||
|
fetchApiSourcesData: async () => ({ apiSources: [] }),
|
||||||
|
fetchTimetablesData: async () => ({ timetableGroups: [] }),
|
||||||
|
parseJsonSafe: () => null,
|
||||||
|
fetchRssFeedItemsByFeedId: async () => [],
|
||||||
|
normalizeRssFeedItem: (item) => item,
|
||||||
|
fetchSlidesPage: async () => ({}),
|
||||||
|
fetchSlideById: async () => null,
|
||||||
|
fetchTemplatesPage: async () => ({}),
|
||||||
|
fetchTemplateById: async () => null,
|
||||||
|
fetchCanvasSizesPage: async () => ({}),
|
||||||
|
fetchCanvasSizeById: async () => null,
|
||||||
|
getSearchQuery: () => '',
|
||||||
|
getSortQuery: () => '',
|
||||||
|
getSortDirectionQuery: () => 'asc',
|
||||||
|
fetchDuplicateName: async () => null,
|
||||||
|
buildCanvasSizePayload
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
renderCanvasSizesPage() { return ''; },
|
||||||
|
renderCanvasSizeEditPage() { return ''; },
|
||||||
|
renderCanvasSizeAddPage() { return ''; },
|
||||||
|
renderSlideAddPage() { return ''; },
|
||||||
|
renderSlideEditPage() { return ''; },
|
||||||
|
renderTemplatesPage() { return ''; },
|
||||||
|
renderTemplateAddPage() { return ''; },
|
||||||
|
renderTemplateEditPage() { return ''; }
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
any() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
single() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAuthMessageCookie() {},
|
||||||
|
fetchScreensBySlideId: async () => [],
|
||||||
|
fetchScreensByTemplateId: async () => [],
|
||||||
|
collectUploadReferencesFromSlide: () => [],
|
||||||
|
collectUploadReferencesFromTemplate: () => [],
|
||||||
|
collectUploadReferencesFromPayload: () => [],
|
||||||
|
removeUnusedUploadFiles: async () => {},
|
||||||
|
syncPlaylistUploadsOnChange: async () => {},
|
||||||
|
getAuditUserId: () => 1,
|
||||||
|
redirectAfterSave: () => {},
|
||||||
|
notifyPlayerScreens: async () => 0,
|
||||||
|
broadcastDashboardState: async () => {},
|
||||||
|
backgroundTaskQueue: { enqueueTask: async () => null },
|
||||||
|
getSlideDeleteBlockMessage: async () => '',
|
||||||
|
getTemplateDeleteBlockMessage: async () => '',
|
||||||
|
getCanvasSizeDeleteBlockMessage: async () => '',
|
||||||
|
requirePermission() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hasAnyPermission: () => true,
|
||||||
|
uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads'
|
||||||
|
};
|
||||||
|
|
||||||
|
registerContentRoutes(app, deps);
|
||||||
|
|
||||||
|
const routeHandlers = handlers['/slides/uploads'];
|
||||||
|
assert.ok(Array.isArray(routeHandlers));
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
file: {
|
||||||
|
filename: 'wysiwyg-large.png',
|
||||||
|
originalname: 'wysiwyg-large.png',
|
||||||
|
mimetype: 'image/png',
|
||||||
|
size: 11 * 1024 * 1024
|
||||||
|
},
|
||||||
|
get(headerName) {
|
||||||
|
return headerName === 'X-Upload-Context' ? 'wysiwyg' : '';
|
||||||
|
},
|
||||||
|
currentUser: { id: 1, permissions: ['slides.create'] }
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
statusCode: 0,
|
||||||
|
body: '',
|
||||||
|
json(body) {
|
||||||
|
this.body = body;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let nextError = null;
|
||||||
|
|
||||||
|
await routeHandlers[2](req, res, (error) => {
|
||||||
|
nextError = error || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(nextError);
|
||||||
|
assert.equal(nextError.statusCode, 400);
|
||||||
|
assert.equal(nextError.message, 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { buildApiSourcePayload } = require('../src/data/api-sources');
|
||||||
|
const { buildRssFeedPayload } = require('../src/data/rss-feeds');
|
||||||
|
const { normalizeIntervalMs } = require('../src/web/lib/background-tasks/queue');
|
||||||
|
|
||||||
|
test('data source payloads accept hours as an update interval unit', () => {
|
||||||
|
const apiPayload = buildApiSourcePayload({
|
||||||
|
body: {
|
||||||
|
name: 'API source',
|
||||||
|
api_url: 'https://example.com/api',
|
||||||
|
update_interval_value: '2',
|
||||||
|
update_interval_unit: 'hours'
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
const rssPayload = buildRssFeedPayload({
|
||||||
|
body: {
|
||||||
|
name: 'RSS feed',
|
||||||
|
feed_url: 'https://example.com/feed.xml',
|
||||||
|
update_interval_value: '3',
|
||||||
|
update_interval_unit: 'hours',
|
||||||
|
item_limit: '5'
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
assert.equal(apiPayload.updateIntervalUnit, 'hours');
|
||||||
|
assert.equal(apiPayload.updateIntervalValue, 2);
|
||||||
|
assert.equal(rssPayload.updateIntervalUnit, 'hours');
|
||||||
|
assert.equal(rssPayload.updateIntervalValue, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background task interval conversion supports hours', () => {
|
||||||
|
assert.equal(normalizeIntervalMs(2, 'hours'), 7_200_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data source forms expose hours as an interval option', () => {
|
||||||
|
const apiTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'api-sources', 'form.hbs'), 'utf8');
|
||||||
|
const rssTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(apiTemplate, /value="hours"[\s\S]*>Hours<\/option>/);
|
||||||
|
assert.match(rssTemplate, /value="hours"[\s\S]*>Hours<\/option>/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { refreshApiSource, refreshRssFeed } = require('../src/web/lib/data-source-refresh');
|
||||||
|
|
||||||
|
function createConnection(options) {
|
||||||
|
const state = Object.assign({
|
||||||
|
rssItemSnapshots: [],
|
||||||
|
slideRows: [],
|
||||||
|
screenRows: []
|
||||||
|
}, options || {});
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async beginTransaction() {
|
||||||
|
calls.push(['beginTransaction']);
|
||||||
|
},
|
||||||
|
async commit() {
|
||||||
|
calls.push(['commit']);
|
||||||
|
},
|
||||||
|
async rollback() {
|
||||||
|
calls.push(['rollback']);
|
||||||
|
},
|
||||||
|
release() {
|
||||||
|
calls.push(['release']);
|
||||||
|
},
|
||||||
|
async query(sql, params) {
|
||||||
|
calls.push(['query', sql, params]);
|
||||||
|
|
||||||
|
if (sql.includes('SELECT item_json')) {
|
||||||
|
return [state.rssItemSnapshots.map(function (itemJson) {
|
||||||
|
return { item_json: itemJson };
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('SELECT id, content_json FROM c_slides')) {
|
||||||
|
return [state.slideRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('SELECT DISTINCT s.slug')) {
|
||||||
|
return [state.screenRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refreshApiSource notifies players only when the API snapshot changes', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-a' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const common = {
|
||||||
|
async fetchApiSourceResponse() {
|
||||||
|
return {
|
||||||
|
responseJson: JSON.stringify({ value: 'new' }, null, 2),
|
||||||
|
responseStatus: 200,
|
||||||
|
responseContentType: 'application/json'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshApiSource(pool, common, {
|
||||||
|
id: 7,
|
||||||
|
last_response_json: JSON.stringify({ value: 'old' }, null, 2)
|
||||||
|
}, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, [{ slugs: ['screen-a'], payload: 'refresh' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshApiSource skips notifications when the API snapshot is unchanged', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-a' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const snapshot = JSON.stringify({ value: 'same' }, null, 2);
|
||||||
|
const common = {
|
||||||
|
async fetchApiSourceResponse() {
|
||||||
|
return {
|
||||||
|
responseJson: snapshot,
|
||||||
|
responseStatus: 200,
|
||||||
|
responseContentType: 'application/json'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshApiSource(pool, common, {
|
||||||
|
id: 7,
|
||||||
|
last_response_json: snapshot
|
||||||
|
}, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshRssFeed notifies players only when the RSS items change', async () => {
|
||||||
|
const connection = createConnection({
|
||||||
|
rssItemSnapshots: [JSON.stringify({ title: 'Old item' })],
|
||||||
|
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-b' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const common = {
|
||||||
|
async fetchRssFeedItems() {
|
||||||
|
return [{ title: 'New item' }];
|
||||||
|
},
|
||||||
|
async replaceRssFeedItems() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, [{ slugs: ['screen-b'], payload: 'refresh' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshRssFeed skips notifications when the RSS items are unchanged', async () => {
|
||||||
|
const snapshot = JSON.stringify({ title: 'Same item' });
|
||||||
|
const connection = createConnection({
|
||||||
|
rssItemSnapshots: [snapshot],
|
||||||
|
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
|
||||||
|
screenRows: [{ slug: 'screen-b' }]
|
||||||
|
});
|
||||||
|
const notifyCalls = [];
|
||||||
|
const pool = {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const common = {
|
||||||
|
async fetchRssFeedItems() {
|
||||||
|
return [{ title: 'Same item' }];
|
||||||
|
},
|
||||||
|
async replaceRssFeedItems() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
|
||||||
|
notifyCalls.push({ slugs, payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(notifyCalls, []);
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
@@ -87,4 +87,16 @@ test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for rem
|
|||||||
public_base_url: 'https://remote-player.example',
|
public_base_url: 'https://remote-player.example',
|
||||||
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);
|
||||||
});
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user