diff --git a/src/common.js b/src/common.js index f53fd6d..bdd3755 100644 --- a/src/common.js +++ b/src/common.js @@ -59,6 +59,12 @@ module.exports = { getSortQuery: listQuery.getSortQuery, getSortDirectionQuery: listQuery.getSortDirectionQuery, fetchPlaylistById: data.fetchPlaylistById, + normalizeDisplayMode: data.normalizeDisplayMode, + fetchSchedulesData: data.fetchSchedulesData, + fetchScheduleGroupsPage: data.fetchScheduleGroupsPage, + fetchScheduleGroupById: data.fetchScheduleGroupById, + fetchScheduleEntriesByGroupId: data.fetchScheduleEntriesByGroupId, + buildScheduleGroupPayload: data.buildScheduleGroupPayload, fetchApiSourcesData: data.fetchApiSourcesData, fetchApiSourcesPage: data.fetchApiSourcesPage, fetchApiSourceById: data.fetchApiSourceById, diff --git a/src/data/index.js b/src/data/index.js index c34161c..facd778 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -4,6 +4,7 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements'); const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons'); const { fetchPlaylistById } = require('./playlists'); +const { normalizeDisplayMode, fetchSchedulesData, fetchScheduleGroupsPage, fetchScheduleGroupById, fetchScheduleEntriesByGroupId, buildScheduleGroupPayload } = require('./schedules'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens'); @@ -36,6 +37,12 @@ module.exports = { fetchActiveAnnouncement, buildAnnouncementPayload, fetchPlaylistById, + normalizeDisplayMode, + fetchSchedulesData, + fetchScheduleGroupsPage, + fetchScheduleGroupById, + fetchScheduleEntriesByGroupId, + buildScheduleGroupPayload, fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, diff --git a/src/data/schedules.js b/src/data/schedules.js new file mode 100644 index 0000000..79217b0 --- /dev/null +++ b/src/data/schedules.js @@ -0,0 +1,120 @@ +// Schedule group and entry data access helpers. + +const { fetchPagedRows } = require('./utils'); + +function normalizeDisplayMode(value) { + const mode = String(value || 'upcoming').trim().toLowerCase(); + if (mode === 'current' || mode === 'both') { + return mode; + } + return 'upcoming'; +} + +async function fetchSchedulesData(pool) { + const [scheduleGroups] = await pool.query(` + SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, + (SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, + (SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime + FROM i_schedule_groups g + ORDER BY g.modified_at DESC, g.id DESC + `); + const [scheduleEntries] = await pool.query(` + SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by + FROM i_schedule_entries + ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC + `); + + const entriesByGroupId = new Map(); + scheduleEntries.forEach(function (entry) { + const groupId = Number(entry.schedule_group_id); + if (!entriesByGroupId.has(groupId)) { + entriesByGroupId.set(groupId, []); + } + entriesByGroupId.get(groupId).push(entry); + }); + + const groups = scheduleGroups.map(function (group) { + return Object.assign({}, group, { + entries: entriesByGroupId.get(Number(group.id)) || [] + }); + }); + + return { + scheduleGroups: groups, + scheduleEntries: scheduleEntries + }; +} + +async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { + const paged = await fetchPagedRows(pool, { + selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, + (SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, + (SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime + FROM i_schedule_groups g + ORDER BY g.modified_at DESC, g.id DESC`, + countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups', + searchColumns: ['g.name', 'g.short_description'], + searchTerm: searchTerm, + sortColumns: { + name: 'g.name', + description: 'g.short_description', + entries: 'entry_count', + next_start: 'next_start_datetime', + created: 'g.created_at', + modified: 'g.modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, + page: page, + pageSize: pageSize + }); + + return Object.assign({ scheduleGroups: paged.rows }, paged); +} + +async function fetchScheduleGroupById(pool, id) { + const [rows] = await pool.query( + 'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?', + [id] + ); + + return rows[0] || null; +} + +async function fetchScheduleEntriesByGroupId(pool, scheduleGroupId) { + const [rows] = await pool.query( + `SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by + FROM i_schedule_entries + WHERE schedule_group_id = ? + ORDER BY start_datetime ASC, id ASC`, + [scheduleGroupId] + ); + + return rows; +} + +function buildScheduleGroupPayload(req, existingScheduleGroup) { + const fallback = existingScheduleGroup || {}; + const name = String(req.body.name || fallback.name || '').trim(); + const shortDescription = String(req.body.short_description || req.body.shortDescription || fallback.short_description || '').trim(); + + if (!name) { + const error = new Error('Schedule group name is required.'); + error.statusCode = 400; + throw error; + } + + return { + name: name, + shortDescription: shortDescription + }; +} + +module.exports = { + normalizeDisplayMode, + fetchSchedulesData, + fetchScheduleGroupsPage, + fetchScheduleGroupById, + fetchScheduleEntriesByGroupId, + buildScheduleGroupPayload +}; \ No newline at end of file diff --git a/src/db/common.js b/src/db/common.js index f1b25ea..40b5040 100644 --- a/src/db/common.js +++ b/src/db/common.js @@ -7,6 +7,7 @@ function createPool() { user: process.env.DB_USER || 'signage_user', password: process.env.DB_PASSWORD || 'signage_password', database: process.env.DB_NAME || 'signage', + timezone: 'Z', waitForConnections: true, connectionLimit: 10, namedPlaceholders: true diff --git a/src/db/index.js b/src/db/index.js index 191e5b1..1ea2f1b 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -206,6 +206,35 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS i_schedule_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + short_description VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS i_schedule_entries ( + id INT AUTO_INCREMENT PRIMARY KEY, + schedule_group_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + short_description VARCHAR(255) NULL, + start_datetime DATETIME NOT NULL, + end_datetime DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE, + INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS d_onboarding_devices ( device_id VARCHAR(128) PRIMARY KEY, diff --git a/src/player/playlist.js b/src/player/playlist.js index b6ed251..9bc22b3 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -60,7 +60,7 @@ function createPlayerPlaylistService(options) { try { const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]); if (!screenRows.length) { - return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] }; + return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], scheduleGroups: [] }; } const screen = screenRows[0]; @@ -69,6 +69,9 @@ function createPlayerPlaylistService(options) { screen: screen, playlist: null, slides: [], + rssFeeds: [], + apiSources: [], + scheduleGroups: [], revision: getPlaylistRevision(screen, null, [], [], [], [], []) }; await writeSnapshot(slug, payloadWithoutPlaylist); @@ -184,8 +187,14 @@ function createPlayerPlaylistService(options) { }); } - const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources); - const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision }; + let scheduleGroups = []; + if (typeof common.fetchSchedulesData === 'function') { + const scheduleData = await common.fetchSchedulesData(pool); + scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : []; + } + + const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups); + const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleGroups, revision: revision }; await writeSnapshot(slug, payload); return payload; } catch (error) { @@ -202,7 +211,7 @@ function createPlayerPlaylistService(options) { hash.update('\0'); } - function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) { + function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups) { const hash = crypto.createHash('sha1'); updatePlaylistRevisionHash(hash, screen && screen.id); @@ -259,6 +268,7 @@ function createPlayerPlaylistService(options) { updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || [])); updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); + updatePlaylistRevisionHash(hash, JSON.stringify(scheduleGroups || [])); return hash.digest('hex'); } diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index d1f35af..66fd1b2 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -463,29 +463,6 @@ body.screen-blackout #app { font-size: 0.9rem; } -.template-region table, -.template-region th, -.template-region td { - border: 1px solid rgba(255, 255, 255, 0.35); - border-collapse: collapse; -} - -.template-region table { - width: 100%; - border-spacing: 0; -} - -.template-region th, -.template-region td { - padding: 0.35em 0.5em; - text-align: left; - vertical-align: top; -} - -.template-region th { - font-weight: 700; -} - .webpage-preloads { position: fixed; width: 1px; @@ -503,3 +480,20 @@ body.screen-blackout #app { border: 0; display: block; } + +.template-region table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; +} + +.template-region th, +.template-region td { + padding: 0.35em 0.5em; + text-align: left; + vertical-align: top; +} + +.template-region th { + font-weight: 700; +} diff --git a/src/player/public/sw.js b/src/player/public/sw.js index 899d9a8..459c841 100644 --- a/src/player/public/sw.js +++ b/src/player/public/sw.js @@ -1,6 +1,6 @@ // Service worker cache strategy for player pages, assets, media, and playlists. -const CACHE_VERSION = 'v36'; +const CACHE_VERSION = 'v38'; const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`; const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`; const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`; @@ -16,6 +16,20 @@ function normalizeRequest(request) { }); } +function shouldBypassCache(request) { + if (!request) { + return false; + } + + if (request.cache === 'reload' || request.cache === 'no-store') { + return true; + } + + const cacheControl = String(request.headers.get('cache-control') || '').toLowerCase(); + const pragma = String(request.headers.get('pragma') || '').toLowerCase(); + return cacheControl.includes('no-cache') || cacheControl.includes('max-age=0') || pragma.includes('no-cache'); +} + async function cacheResponse(cacheName, request, response, cacheKeyRequest) { if (!response || !response.ok) { return; @@ -126,6 +140,11 @@ self.addEventListener('fetch', function (event) { return; } + if (shouldBypassCache(request)) { + event.respondWith(networkOnly(request)); + return; + } + if (url.pathname.startsWith('/assets/')) { event.respondWith(cacheFirst(request, ASSET_CACHE)); return; diff --git a/src/player/regions/schedule.js b/src/player/regions/schedule.js new file mode 100644 index 0000000..f948a14 --- /dev/null +++ b/src/player/regions/schedule.js @@ -0,0 +1,246 @@ +// Schedule region rendering for live playback. + +var registry = window.pulsePlayerRegionTypes; + +function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .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(/ \ No newline at end of file diff --git a/src/web/views/data-sources/schedules/list.hbs b/src/web/views/data-sources/schedules/list.hbs new file mode 100644 index 0000000..91836a2 --- /dev/null +++ b/src/web/views/data-sources/schedules/list.hbs @@ -0,0 +1,75 @@ +
Store grouped events with start and end times so a schedule region can show what is coming up next.
+| Name | +Description | +Entries | +Next start | +Actions | +
|---|---|---|---|---|
| {{name}} | +{{short_description}} | +{{entry_count}} | ++ {{#if nextStartValue}} + + {{else}} + {{nextStartLabel}} + {{/if}} + | +
+ {{#if (anyPermission ../currentUser 'schedules.update' 'schedules.delete')}}
+
+ {{#if (hasPermission ../currentUser 'schedules.update')}}
+ Edit
+ {{/if}}
+ {{#if (hasPermission ../currentUser 'schedules.delete')}}
+
+ {{/if}}
+
+ {{else}}
+ -
+ {{/if}}
+ |
+
| No schedule groups yet. | ||||
Schedules
+ +