Release v2.14.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-11 18:54:43 +01:00
1 parent 635e1a9942
commit c3299abaac
174 files changed
+4483 -1687

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
const { fetchPagedRows } = require('./utils');
const { fetchPagedRows } = require('#src/data/utils');
async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
+3 -3
View File
@@ -1,12 +1,12 @@
// Announcement data access helpers.
const { fetchPagedRows } = require('./utils');
const { fetchPagedRows } = require('#src/data/utils');
const {
ANNOUNCEMENT_ICON_KEYS,
DEFAULT_ANNOUNCEMENT_ICON,
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
} = require('./announcement-icons');
const { validateMaxLength } = require('./utils');
} = require('#src/data/announcement-icons');
const { validateMaxLength } = require('#src/data/utils');
const SHORT_LABEL_MAX_LENGTH = 255;
+1 -1
View File
@@ -2,7 +2,7 @@
const http = require('http');
const https = require('https');
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
// Application-wide settings defaults and persistence helpers.
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('#src/data/announcement-icons');
const SETTING_DEFINITIONS = [
{ key: 'app.name', type: 'string', defaultValue: 'Pulse Signage' },
+1 -1
View File
@@ -48,7 +48,7 @@ const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({
moveclient: 'Move client',
setclientname: 'Rename client'
});
const { fetchAppSettings } = require('./app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
function formatUserAgentLabel(userAgent) {
const value = String(userAgent || '').trim();
+1 -1
View File
@@ -1,6 +1,6 @@
// Canvas size data access and pagination helpers.
const { fetchPagedRows } = require('./utils');
const { fetchPagedRows } = require('#src/data/utils');
const MAX_CANVAS_SIZE_DIMENSION = 16384;
async function fetchCanvasSizesData(pool) {
+14 -14
View File
@@ -1,19 +1,19 @@
// Aggregated exports for the shared data access layer.
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('./weather');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
const { fetchPlayerRegistrations } = require('./player-registry');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides');
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('#src/data/admin');
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('#src/data/announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('#src/data/announcement-icons');
const { fetchPlaylistById } = require('#src/data/playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('#src/data/timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('#src/data/api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('#src/data/rss-feeds');
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('#src/data/weather');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('#src/data/screens');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('#src/data/templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('#src/data/canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('#src/data/slides');
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('#src/data/utils');
module.exports = {
slugify,
+33
View File
@@ -1,4 +1,6 @@
// Persistent player registration and heartbeat helpers shared by the web app and bridge.
// These utilities intentionally keep URL normalization and heartbeat staleness rules in one place
// so the web routing logic and media sync tasks all make the same decision about a live player.
function normalizeDeviceId(value) {
return String(value || '')
@@ -11,6 +13,34 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
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 normalizeIdentifier(value) {
return String(value || '').trim().slice(0, 255);
}
@@ -143,6 +173,9 @@ async function recordPlayerHeartbeat(pool, options) {
}
module.exports = {
normalizeBaseUrl: normalizeBaseUrl,
isLocalLikeBaseUrl: isLocalLikeBaseUrl,
isRecentPlayerRegistration: isRecentPlayerRegistration,
normalizeDeviceId: normalizeDeviceId,
normalizeIdentifier: normalizeIdentifier,
getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier,
+1 -1
View File
@@ -2,7 +2,7 @@
const http = require('http');
const https = require('https');
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
+61 -4
View File
@@ -1,10 +1,10 @@
// Slide data access helpers, including rich-text normalization and payload building.
const { fetchTemplateById } = require('./templates');
const { parseJsonSafe, validateMaxLength } = require('./utils');
const { fetchTemplateById } = require('#src/data/templates');
const { parseJsonSafe, validateMaxLength } = require('#src/data/utils');
const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('./qr-code');
const { buildQrCodeContent } = require('#src/data/qr-code');
const DEFAULT_FONT_SIZE = 32;
@@ -474,7 +474,64 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
font_size: style.font_size,
font_color: style.font_color
};
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather'].includes(String(region.region_type || '').trim())) {
} else if (region.region_type === 'chart') {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
let manualRows = [];
let datasets = [];
try {
const parsedRows = JSON.parse(String(body[`region_chart_manual_rows_${region.id}`] || '[]'));
manualRows = Array.isArray(parsedRows) ? parsedRows.slice(0, 500) : [];
} catch (_error) {
manualRows = Array.isArray(current.manual_rows) ? current.manual_rows : [];
}
try {
const parsedDatasets = JSON.parse(String(body[`region_chart_datasets_${region.id}`] || '[]'));
datasets = Array.isArray(parsedDatasets) ? parsedDatasets.slice(0, 12) : [];
} catch (_error) {
datasets = Array.isArray(current.datasets) ? current.datasets : [];
}
const colors = String(body[`region_chart_colors_${region.id}`] === undefined ? (Array.isArray(current.colors) ? current.colors.join(',') : '') : body[`region_chart_colors_${region.id}`] || '')
.split(',')
.map((color) => String(color || '').trim())
.filter((color) => /^#[0-9a-fA-F]{3,8}$/.test(color))
.slice(0, 24);
const chartSourceKind = String(body[`region_chart_source_kind_${region.id}`] || current.source_kind || 'manual') === 'api' ? 'api' : 'manual';
const chartOptions = {
legend: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic')) ? String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic') : 'automatic',
begin_at_zero: body[`region_chart_begin_at_zero_control_${region.id}`] === undefined ? current.chart_options?.begin_at_zero !== false : body[`region_chart_begin_at_zero_${region.id}`] === 'on',
y_axis_min: String(body[`region_chart_y_axis_min_${region.id}`] === undefined ? current.chart_options?.y_axis_min || '' : body[`region_chart_y_axis_min_${region.id}`] || '').trim(),
y_axis_max: String(body[`region_chart_y_axis_max_${region.id}`] === undefined ? current.chart_options?.y_axis_max || '' : body[`region_chart_y_axis_max_${region.id}`] || '').trim(),
line_points: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic')) ? String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic') : 'automatic',
line_fill: body[`region_chart_line_fill_${region.id}`] === 'on',
bar_orientation: String(body[`region_chart_bar_orientation_${region.id}`] || current.chart_options?.bar_orientation || 'vertical') === 'horizontal' ? 'horizontal' : 'vertical',
value_format: ['number', 'percent', 'currency'].includes(String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number')) ? String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number') : 'number',
decimal_places: Math.max(0, Math.min(4, Number(body[`region_chart_decimal_places_${region.id}`] === undefined ? current.chart_options?.decimal_places || 0 : body[`region_chart_decimal_places_${region.id}`] || 0) || 0)),
text_size: Math.max(8, Math.min(48, Number(body[`region_chart_text_size_${region.id}`] === undefined ? current.chart_options?.text_size || 12 : body[`region_chart_text_size_${region.id}`] || 12) || 12)),
grid_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color || '')) ? String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color) : '#000000',
text_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color || '')) ? String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color) : '#000000'
};
const sourceOptions = {
sort: ['none', 'reverse-original', 'value-ascending', 'value-descending'].includes(String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none')) ? String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none') : 'none',
limit: Math.max(0, Math.min(500, Number(body[`region_chart_source_limit_${region.id}`] === undefined ? current.source_options?.limit || 0 : body[`region_chart_source_limit_${region.id}`] || 0) || 0)),
missing: ['skip', 'zero', 'gap'].includes(String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap')) ? String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap') : 'gap'
};
content[region.region_key] = {
type: 'chart',
chart_type: ['line', 'bar', 'doughnut', 'pie'].includes(String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar')) ? String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar') : 'bar',
source_kind: chartSourceKind,
source_id: chartSourceKind === 'api' ? (body[`region_chart_source_id_${region.id}`] === undefined ? (current.source_id || null) : (body[`region_chart_source_id_${region.id}`] === '' ? null : Number(body[`region_chart_source_id_${region.id}`]))) : null,
items_path: String(body[`region_chart_items_path_${region.id}`] === undefined ? current.items_path || '' : body[`region_chart_items_path_${region.id}`] || '').trim(),
label_path: String(body[`region_chart_label_path_${region.id}`] === undefined ? current.label_path || '' : body[`region_chart_label_path_${region.id}`] || '').trim(),
value_path: String(body[`region_chart_value_path_${region.id}`] === undefined ? current.value_path || '' : body[`region_chart_value_path_${region.id}`] || '').trim(),
dataset_label: String(body[`region_chart_dataset_label_${region.id}`] === undefined ? current.dataset_label || 'Value' : body[`region_chart_dataset_label_${region.id}`] || 'Value').trim() || 'Value',
color: /^#[0-9a-fA-F]{3,8}$/.test(String(body[`region_chart_color_${region.id}`] || current.color || '')) ? String(body[`region_chart_color_${region.id}`]) : (current.color || '#36a269'),
datasets,
colors,
manual_rows: manualRows,
chart_options: chartOptions,
source_options: sourceOptions
};
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather', 'chart'].includes(String(region.region_type || '').trim())) {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
const suffix = '_' + region.id;
const generic = {};
+4 -3
View File
@@ -1,7 +1,7 @@
// Template data access helpers and region normalization logic.
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
const animationPresets = require('../web/public/js/templates/animation-presets');
const { parseJsonSafe, readFormArray, validateMaxLength } = require('#src/data/utils');
const animationPresets = require('#src/web/public/js/templates/animation-presets');
const TEMPLATE_NAME_MAX_LENGTH = 255;
const REGION_NAME_MAX_LENGTH = 255;
@@ -49,7 +49,8 @@ function normalizeBackgroundGradient(value) {
function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim();
return rawType || 'text';
const normalizedType = rawType.toLowerCase();
return ['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather', 'timetable', 'time-date', 'chart'].includes(normalizedType) ? rawType : 'text';
}
function normalizeTemplateRegionLockRatio(value) {
+1 -1
View File
@@ -1,6 +1,6 @@
// Timetable group and entry data access helpers.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const NAME_MAX_LENGTH = 255;
const DESCRIPTION_MAX_LENGTH = 255;
+2 -2
View File
@@ -1,7 +1,7 @@
// Weather location data access and form normalization.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchAppSettings } = require('./app-settings');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const { fetchAppSettings } = require('#src/data/app-settings');
const NAME_MAX_LENGTH = 255;
const LOCATION_MAX_LENGTH = 255;