Release 2.22.1: preserve playlist row identities
This commit is contained in:
1 parent
c40bc6844a
commit
9b87ad4721
10 files changed
+314
-52
No files matched your search
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
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.22.1 - 2026-10-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Playlist saves now update existing slides, groups, and schedule rules in place, preserving their database IDs.
|
||||||
|
- Playlist group schedule rules remain visible in the editor after refreshing the page.
|
||||||
|
|
||||||
## 2.22.0 - 2026-10-27
|
## 2.22.0 - 2026-10-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.22.0",
|
"version": "2.22.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.22.0",
|
"version": "2.22.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.22.0",
|
"version": "2.22.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.22.0",
|
"version": "2.22.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.7.13",
|
"@better-auth/passkey": "^1.7.13",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.22.0",
|
"version": "2.22.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -160,6 +160,42 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveScheduleRulesInPlace(connection, ownerColumn, ownerId, scheduleRules, existingRules, actorId) {
|
||||||
|
const rules = Array.isArray(scheduleRules) ? scheduleRules : [];
|
||||||
|
const existing = Array.isArray(existingRules) ? existingRules : [];
|
||||||
|
for (let index = 0; index < rules.length; index += 1) {
|
||||||
|
const rule = rules[index];
|
||||||
|
const existingRule = existing[index];
|
||||||
|
const position = Number.isInteger(Number(rule.position)) ? Number(rule.position) : index;
|
||||||
|
const startDatetime = rule.startDatetime || rule.start_datetime || null;
|
||||||
|
const endDatetime = rule.endDatetime || rule.end_datetime || null;
|
||||||
|
const startTime = rule.startTime || rule.start_time || null;
|
||||||
|
const endTime = rule.endTime || rule.end_time || null;
|
||||||
|
const daysJson = rule.daysJson !== undefined
|
||||||
|
? rule.daysJson
|
||||||
|
: rule.days && rule.days.length
|
||||||
|
? JSON.stringify(rule.days)
|
||||||
|
: rule.schedule_days_json || null;
|
||||||
|
if (existingRule) {
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE c_playlist_schedule_rules SET position = ?, start_datetime = ?, end_datetime = ?, start_time = ?, end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND ${ownerColumn} = ?`,
|
||||||
|
[position, startDatetime, endDatetime, startTime, endTime, daysJson, actorId, existingRule.id, ownerId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO c_playlist_schedule_rules (${ownerColumn}, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[ownerId, position, startDatetime, endDatetime, startTime, endTime, daysJson, actorId, actorId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const removedRule of existing.slice(rules.length)) {
|
||||||
|
await connection.query(
|
||||||
|
`DELETE FROM c_playlist_schedule_rules WHERE id = ? AND ${ownerColumn} = ?`,
|
||||||
|
[removedRule.id, ownerId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createValidationError(message) {
|
function createValidationError(message) {
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -402,45 +438,132 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
? Number(playlist.canvas_id)
|
? Number(playlist.canvas_id)
|
||||||
: requestedCanvasId || selectedCanvasId || null;
|
: requestedCanvasId || selectedCanvasId || null;
|
||||||
|
|
||||||
|
let existingPlaylistSlides = [];
|
||||||
|
let existingPlaylistGroups = [];
|
||||||
|
let existingScheduleRules = [];
|
||||||
|
if (shouldUpdatePlaylist) {
|
||||||
|
[existingPlaylistSlides] = await connection.query(
|
||||||
|
'SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides ps WHERE ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC FOR UPDATE',
|
||||||
|
[playlist.id]
|
||||||
|
);
|
||||||
|
[existingPlaylistGroups] = await connection.query(
|
||||||
|
'SELECT id, group_key FROM c_playlist_groups WHERE playlist_id = ? FOR UPDATE',
|
||||||
|
[playlist.id]
|
||||||
|
);
|
||||||
|
const ownerFilters = [];
|
||||||
|
const ownerParams = [];
|
||||||
|
if (existingPlaylistSlides.length) {
|
||||||
|
ownerFilters.push('r.playlist_slide_id IN (?)');
|
||||||
|
ownerParams.push(existingPlaylistSlides.map(function (item) { return item.id; }));
|
||||||
|
}
|
||||||
|
if (existingPlaylistGroups.length) {
|
||||||
|
ownerFilters.push('r.playlist_group_id IN (?)');
|
||||||
|
ownerParams.push(existingPlaylistGroups.map(function (group) { return group.id; }));
|
||||||
|
}
|
||||||
|
if (ownerFilters.length) {
|
||||||
|
[existingScheduleRules] = await connection.query(
|
||||||
|
`SELECT r.id, r.playlist_slide_id, r.playlist_group_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json FROM c_playlist_schedule_rules r WHERE ${ownerFilters.join(' OR ')} ORDER BY r.playlist_slide_id ASC, r.playlist_group_id ASC, r.position ASC, r.id ASC FOR UPDATE`,
|
||||||
|
ownerParams
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSlidesById = new Map(existingPlaylistSlides.map(function (item) { return [Number(item.id), item]; }));
|
||||||
|
const existingSlidesBySlideId = new Map();
|
||||||
|
existingPlaylistSlides.forEach(function (item) {
|
||||||
|
const slideId = Number(item.slide_id);
|
||||||
|
if (!existingSlidesBySlideId.has(slideId)) existingSlidesBySlideId.set(slideId, []);
|
||||||
|
existingSlidesBySlideId.get(slideId).push(item);
|
||||||
|
});
|
||||||
|
const retainedSlideIds = new Set();
|
||||||
|
if (shouldUpdatePlaylist) {
|
||||||
|
normalizedSlides.forEach(function (item) {
|
||||||
|
const rowId = /^\d+$/.test(item.rowKey) ? Number(item.rowKey) : null;
|
||||||
|
let existing = rowId ? existingSlidesById.get(rowId) : null;
|
||||||
|
if (rowId && (!existing || Number(existing.slide_id) !== item.slideId)) {
|
||||||
|
throw createValidationError('Playlist slide data is invalid.');
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
existing = (existingSlidesBySlideId.get(item.slideId) || []).find(function (candidate) {
|
||||||
|
return !retainedSlideIds.has(Number(candidate.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
const existingId = Number(existing.id);
|
||||||
|
if (retainedSlideIds.has(existingId)) {
|
||||||
|
throw createValidationError('Playlist slide data is invalid.');
|
||||||
|
}
|
||||||
|
item.existingPlaylistSlideId = existingId;
|
||||||
|
retainedSlideIds.add(existingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldUpdatePlaylist) {
|
if (shouldUpdatePlaylist) {
|
||||||
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]);
|
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
|
||||||
await connection.query('DELETE FROM c_playlist_groups WHERE playlist_id = ?', [playlist.id]);
|
|
||||||
const groupIdsByKey = new Map();
|
const groupIdsByKey = new Map();
|
||||||
|
const existingGroupsByKey = new Map(existingPlaylistGroups.map(function (group) { return [String(group.group_key), group]; }));
|
||||||
|
const retainedGroupIds = new Set();
|
||||||
|
const existingRulesByOwner = new Map();
|
||||||
|
existingScheduleRules.forEach(function (rule) {
|
||||||
|
const owner = rule.playlist_group_id !== null && rule.playlist_group_id !== undefined
|
||||||
|
? 'group:' + Number(rule.playlist_group_id)
|
||||||
|
: 'slide:' + Number(rule.playlist_slide_id);
|
||||||
|
if (!existingRulesByOwner.has(owner)) existingRulesByOwner.set(owner, []);
|
||||||
|
existingRulesByOwner.get(owner).push(rule);
|
||||||
|
});
|
||||||
for (const [groupKey, settings] of groupSettings) {
|
for (const [groupKey, settings] of groupSettings) {
|
||||||
|
const existingGroup = existingGroupsByKey.get(groupKey);
|
||||||
|
let groupId;
|
||||||
|
if (existingGroup) {
|
||||||
|
groupId = Number(existingGroup.id);
|
||||||
|
await connection.query(
|
||||||
|
'UPDATE c_playlist_groups SET name = ?, pick_count = ?, shuffle = ?, mode = ?, repeat_count = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||||
|
[settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, groupId, playlist.id]
|
||||||
|
);
|
||||||
|
retainedGroupIds.add(groupId);
|
||||||
|
} else {
|
||||||
const [groupResult] = await connection.query(
|
const [groupResult] = await connection.query(
|
||||||
'INSERT INTO c_playlist_groups (playlist_id, group_key, name, pick_count, shuffle, mode, repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO c_playlist_groups (playlist_id, group_key, name, pick_count, shuffle, mode, repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[playlist.id, groupKey, settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, actorId]
|
[playlist.id, groupKey, settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, actorId]
|
||||||
);
|
);
|
||||||
const groupId = Number(groupResult && groupResult.insertId);
|
groupId = Number(groupResult && groupResult.insertId);
|
||||||
|
}
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
throw new Error('Could not create playlist group ' + groupKey + '.');
|
throw new Error('Could not create playlist group ' + groupKey + '.');
|
||||||
}
|
}
|
||||||
groupIdsByKey.set(groupKey, groupId);
|
groupIdsByKey.set(groupKey, groupId);
|
||||||
for (let ruleIndex = 0; ruleIndex < settings.scheduleRules.length; ruleIndex += 1) {
|
await saveScheduleRulesInPlace(connection, 'playlist_group_id', groupId, settings.scheduleRules, existingRulesByOwner.get('group:' + groupId), actorId);
|
||||||
const rule = settings.scheduleRules[ruleIndex];
|
|
||||||
await connection.query(
|
|
||||||
'INSERT INTO c_playlist_schedule_rules (playlist_group_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[groupId, ruleIndex, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.days && rule.days.length ? JSON.stringify(rule.days) : null, actorId, actorId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||||
const item = normalizedSlides[i];
|
const item = normalizedSlides[i];
|
||||||
|
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
|
||||||
|
const groupId = item.groupKey ? groupIdsByKey.get(item.groupKey) : null;
|
||||||
|
let playlistSlideId = item.existingPlaylistSlideId;
|
||||||
|
if (playlistSlideId) {
|
||||||
|
await connection.query(
|
||||||
|
'UPDATE c_playlist_slides SET slide_id = ?, position = ?, duration_seconds = ?, use_video_duration = ?, disable_audio = ?, group_id = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||||
|
[item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, groupId, actorId, playlistSlideId, playlist.id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
const [insertResult] = await connection.query(
|
const [insertResult] = await connection.query(
|
||||||
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, item.groupKey ? groupIdsByKey.get(item.groupKey) : null, actorId, actorId]
|
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, groupId, actorId, actorId]
|
||||||
);
|
|
||||||
const insertedPlaylistSlideId = Number(insertResult.insertId);
|
|
||||||
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
|
|
||||||
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
|
|
||||||
const rule = itemRules[ruleIndex];
|
|
||||||
await connection.query(
|
|
||||||
'INSERT INTO c_playlist_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId]
|
|
||||||
);
|
);
|
||||||
|
playlistSlideId = Number(insertResult.insertId);
|
||||||
|
}
|
||||||
|
await saveScheduleRulesInPlace(connection, 'playlist_slide_id', playlistSlideId, itemRules, existingRulesByOwner.get('slide:' + playlistSlideId), actorId);
|
||||||
|
}
|
||||||
|
for (const existingSlide of existingPlaylistSlides) {
|
||||||
|
if (!retainedSlideIds.has(Number(existingSlide.id))) {
|
||||||
|
await connection.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [existingSlide.id, playlist.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const existingGroup of existingPlaylistGroups) {
|
||||||
|
if (!retainedGroupIds.has(Number(existingGroup.id))) {
|
||||||
|
await connection.query('DELETE FROM c_playlist_groups WHERE id = ? AND playlist_id = ?', [existingGroup.id, playlist.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +660,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await connection.beginTransaction();
|
||||||
const previousItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
const previousItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
||||||
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
|
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
|
||||||
const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
||||||
|
|||||||
@@ -190,7 +190,11 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
|
|||||||
}
|
}
|
||||||
return a.id - b.id;
|
return a.id - b.id;
|
||||||
})
|
})
|
||||||
.map((item, index, items) => Object.assign({}, item, {
|
.map((item, index, items) => {
|
||||||
|
const groupScheduleRules = (Array.isArray(item.groupScheduleRules) ? item.groupScheduleRules : []).map((rule) => Object.assign({}, rule, {
|
||||||
|
days: normalizeDayValues(rule.days || rule.schedule_days_json)
|
||||||
|
}));
|
||||||
|
return Object.assign({}, item, {
|
||||||
row_key: String(item.row_key || item.id || ''),
|
row_key: String(item.row_key || item.id || ''),
|
||||||
displayOrder: index + 1,
|
displayOrder: index + 1,
|
||||||
isFirst: index === 0,
|
isFirst: index === 0,
|
||||||
@@ -209,12 +213,12 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
|
|||||||
groupShuffle: Boolean(Number(item.group_shuffle)),
|
groupShuffle: Boolean(Number(item.group_shuffle)),
|
||||||
groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select',
|
groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select',
|
||||||
groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)),
|
groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)),
|
||||||
groupScheduleRules: (Array.isArray(item.groupScheduleRules) ? item.groupScheduleRules : []).map((rule) => Object.assign({}, rule, {
|
groupScheduleRules: groupScheduleRules,
|
||||||
days: normalizeDayValues(rule.days || rule.schedule_days_json)
|
groupScheduleRulesJson: JSON.stringify(groupScheduleRules),
|
||||||
})),
|
|
||||||
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
|
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
|
||||||
scheduleSummary: scheduleSummary(item)
|
scheduleSummary: scheduleSummary(item)
|
||||||
}));
|
});
|
||||||
|
});
|
||||||
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
||||||
const storedCanvasId = Number(viewPlaylist.canvas_id) || null;
|
const storedCanvasId = Number(viewPlaylist.canvas_id) || null;
|
||||||
const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0)));
|
const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0)));
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<input type="hidden" name="group_shuffle[]" value="{{#if slide.groupShuffle}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-group-shuffle-value />
|
<input type="hidden" name="group_shuffle[]" value="{{#if slide.groupShuffle}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-group-shuffle-value />
|
||||||
<input type="hidden" name="group_mode[]" value="{{#if slide.groupMode}}{{slide.groupMode}}{{else}}select{{/if}}" form="playlist-edit-form" data-playlist-group-mode-value />
|
<input type="hidden" name="group_mode[]" value="{{#if slide.groupMode}}{{slide.groupMode}}{{else}}select{{/if}}" form="playlist-edit-form" data-playlist-group-mode-value />
|
||||||
<input type="hidden" name="group_repeat_count[]" value="{{#if slide.groupRepeatCount}}{{slide.groupRepeatCount}}{{else}}1{{/if}}" form="playlist-edit-form" data-playlist-group-repeat-count-value />
|
<input type="hidden" name="group_repeat_count[]" value="{{#if slide.groupRepeatCount}}{{slide.groupRepeatCount}}{{else}}1{{/if}}" form="playlist-edit-form" data-playlist-group-repeat-count-value />
|
||||||
<input type="hidden" name="group_schedule_rules_json[]" value="{{#if slide.groupScheduleRules}}{{json slide.groupScheduleRules}}{{else}}[]{{/if}}" form="playlist-edit-form" data-playlist-group-schedule-rules />
|
<input type="hidden" name="group_schedule_rules_json[]" value="{{slide.groupScheduleRulesJson}}" form="playlist-edit-form" data-playlist-group-schedule-rules />
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Rules">
|
<td data-label="Rules">
|
||||||
<div class="playlist-schedule-summary"><span class="chip" data-schedule-summary-chip>{{slide.scheduleSummary}}</span></div>
|
<div class="playlist-schedule-summary"><span class="chip" data-schedule-summary-chip>{{slide.scheduleSummary}}</span></div>
|
||||||
|
|||||||
@@ -41,6 +41,32 @@ test('new playlist rows receive preview URLs, titles and canvas dimensions', ()
|
|||||||
assert.equal(attrs['aria-label'], 'Preview slide: Portrait');
|
assert.equal(attrs['aria-label'], 'Preview slide: Portrait');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playlist group rule JSON is escaped in the hidden form field and remains intact', () => {
|
||||||
|
const { renderView } = require('../src/web/view');
|
||||||
|
const model = buildPlaylistFormViewModel({ id: 3, name: 'Group rules' }, {
|
||||||
|
playlistSlides: [{
|
||||||
|
id: 12,
|
||||||
|
playlist_id: 3,
|
||||||
|
slide_id: 22,
|
||||||
|
position: 0,
|
||||||
|
group_id: 5,
|
||||||
|
group_key: 'events',
|
||||||
|
group_name: 'Events',
|
||||||
|
group_pick_count: 1,
|
||||||
|
groupScheduleRules: [{ id: 71, playlist_group_id: 5, position: 0, start_time: '09:00:00', end_time: '17:00:00', schedule_days_json: '[1,3]' }],
|
||||||
|
scheduleRules: []
|
||||||
|
}],
|
||||||
|
slides: [],
|
||||||
|
canvasSizes: []
|
||||||
|
}, '', null, { isEdit: true });
|
||||||
|
const html = renderView('playlists/form', model);
|
||||||
|
const hiddenRuleField = html.match(/<input[^>]+data-playlist-group-schedule-rules[^>]+>/);
|
||||||
|
|
||||||
|
assert.ok(hiddenRuleField);
|
||||||
|
assert.match(hiddenRuleField[0], /value="\[\{"id":71,/);
|
||||||
|
assert.doesNotMatch(hiddenRuleField[0], /value="\[\{"id/);
|
||||||
|
});
|
||||||
|
|
||||||
test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => {
|
test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => {
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|||||||
+123
-22
@@ -54,14 +54,9 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
|||||||
calls.push('update-playlist');
|
calls.push('update-playlist');
|
||||||
return [{ affectedRows: 1 }];
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
|
if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id')) return [[]];
|
||||||
calls.push('delete-slides');
|
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]];
|
||||||
return [{ affectedRows: 1 }];
|
if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [[]];
|
||||||
}
|
|
||||||
if (sql.startsWith('DELETE FROM c_playlist_groups')) {
|
|
||||||
calls.push('delete-groups');
|
|
||||||
return [{ affectedRows: 1 }];
|
|
||||||
}
|
|
||||||
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
|
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
|
||||||
return [[{ id: 11, canvas_size_id: 7 }]];
|
return [[{ id: 11, canvas_size_id: 7 }]];
|
||||||
}
|
}
|
||||||
@@ -171,12 +166,8 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
|||||||
[71, 1, null, '2026-10-31T18:00', null, null, null, 12, 12]
|
[71, 1, null, '2026-10-31T18:00', null, null, null, 12, 12]
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'delete-slides',
|
|
||||||
'delete-groups',
|
|
||||||
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
||||||
'update-playlist',
|
'update-playlist',
|
||||||
'delete-slides',
|
|
||||||
'delete-groups',
|
|
||||||
'insert-group',
|
'insert-group',
|
||||||
'insert-group-rule',
|
'insert-group-rule',
|
||||||
'insert-group-rule',
|
'insert-group-rule',
|
||||||
@@ -220,24 +211,134 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playlist save updates retained slide, group, and schedule rows in place', async () => {
|
||||||
|
const writes = [];
|
||||||
|
let items = [
|
||||||
|
{ id: 101, slide_id: 11, group_id: 201, duration_seconds: 10, position: 0, use_video_duration: 0, disable_audio: 1 },
|
||||||
|
{ id: 102, slide_id: 12, group_id: null, duration_seconds: 15, position: 1, use_video_duration: 0, disable_audio: 1 }
|
||||||
|
];
|
||||||
|
let groups = [{ id: 201, group_key: 'news' }];
|
||||||
|
let rules = [
|
||||||
|
{ id: 301, playlist_slide_id: null, playlist_group_id: 201, position: 0, start_datetime: null, end_datetime: null, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' },
|
||||||
|
{ id: 302, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '09:00:00', end_time: '18:00:00', schedule_days_json: null },
|
||||||
|
{ id: 303, playlist_slide_id: 102, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '10:00:00', end_time: '19:00:00', schedule_days_json: null }
|
||||||
|
];
|
||||||
|
const connection = {
|
||||||
|
async beginTransaction() {}, async commit() {}, async rollback() {}, release() {},
|
||||||
|
async query(sql, params) {
|
||||||
|
if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)];
|
||||||
|
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [structuredClone(groups)];
|
||||||
|
if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [structuredClone(rules)];
|
||||||
|
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }, { id: 13, canvas_size_id: 7 }]];
|
||||||
|
if (sql.startsWith('UPDATE c_playlists SET')) return [{ affectedRows: 1 }];
|
||||||
|
if (sql.startsWith('UPDATE c_playlist_groups SET')) {
|
||||||
|
writes.push({ kind: 'update-group', id: params[6], name: params[0] });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('UPDATE c_playlist_slides SET')) {
|
||||||
|
writes.push({ kind: 'update-slide', id: params[7], duration: params[2] });
|
||||||
|
items = items.map(item => item.id === params[7] ? Object.assign({}, item, {
|
||||||
|
slide_id: params[0], position: params[1], duration_seconds: params[2],
|
||||||
|
use_video_duration: params[3], disable_audio: params[4], group_id: params[5]
|
||||||
|
}) : item);
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) {
|
||||||
|
writes.push({ kind: 'update-rule', id: params[7] });
|
||||||
|
rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, {
|
||||||
|
position: params[0], start_datetime: params[1], end_datetime: params[2],
|
||||||
|
start_time: params[3], end_time: params[4], schedule_days_json: params[5]
|
||||||
|
}) : rule);
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
||||||
|
writes.push({ kind: 'insert-slide', slideId: params[1] });
|
||||||
|
items.push({ id: 103, slide_id: params[1], group_id: params[6], duration_seconds: params[3], position: params[2] });
|
||||||
|
return [{ insertId: 103 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
|
||||||
|
writes.push({ kind: 'delete-slide', id: params[0] });
|
||||||
|
items = items.filter(item => item.id !== params[0]);
|
||||||
|
rules = rules.filter(rule => rule.playlist_slide_id !== params[0]);
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const { app, handlers } = createAppAndHandlers();
|
||||||
|
registerPlaylistRoutes(app, {
|
||||||
|
pool: { async getConnection() { return connection; } },
|
||||||
|
common: {
|
||||||
|
async fetchDuplicateName() { return null; },
|
||||||
|
async fetchPlaylistById() { return { id: 4, name: 'Loop', canvas_id: 7 }; }
|
||||||
|
},
|
||||||
|
readArrayField(body, keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Object.hasOwn(body, key)) return Array.isArray(body[key]) ? body[key] : [body[key]];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
fetchScreensByPlaylistId: async () => [], getAuditUserId: () => 12,
|
||||||
|
redirectAfterSave(_req, res, url) { res.redirect(url); }, notifyPlayerScreens: async () => {}, broadcastDashboardState: async () => {},
|
||||||
|
requirePermission: () => () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = createResponse();
|
||||||
|
await handlers['/playlists/:id'].at(-1)({
|
||||||
|
params: { id: '4' },
|
||||||
|
body: {
|
||||||
|
name: 'Loop',
|
||||||
|
slide_id: ['11', '13'], row_key: ['101', 'new-slide'],
|
||||||
|
duration_seconds: ['20', '12'], use_video_duration: ['0', '0'], disable_audio: ['1', '1'],
|
||||||
|
group_key: ['news', ''], group_name: ['News updated', ''], group_pick_count: ['1', ''], group_shuffle: ['0', ''],
|
||||||
|
group_mode: ['repeat', ''], group_repeat_count: ['3', ''],
|
||||||
|
group_schedule_rules_json: [JSON.stringify([{ start_time: '08:30', end_time: '17:30', days: [1, 2] }]), ''],
|
||||||
|
schedule_rule_row_key: ['101'], schedule_rule_start_time: ['09:30'],
|
||||||
|
schedule_rule_end_time: ['18:30'], schedule_rule_days_csv: ['']
|
||||||
|
}
|
||||||
|
}, response, error => { throw error; });
|
||||||
|
|
||||||
|
assert.equal(response.redirectedTo, '/playlists/4/edit', response.statusCode + ': ' + String(response.body || ''));
|
||||||
|
assert.deepEqual(writes, [
|
||||||
|
{ kind: 'update-group', id: 201, name: 'News updated' },
|
||||||
|
{ kind: 'update-rule', id: 301 },
|
||||||
|
{ kind: 'update-slide', id: 101, duration: 20 },
|
||||||
|
{ kind: 'update-rule', id: 302 },
|
||||||
|
{ kind: 'insert-slide', slideId: 13 },
|
||||||
|
{ kind: 'delete-slide', id: 102 }
|
||||||
|
]);
|
||||||
|
assert.deepEqual(items.map(item => item.id), [101, 103]);
|
||||||
|
assert.deepEqual(rules.map(rule => rule.id), [301, 302]);
|
||||||
|
});
|
||||||
|
|
||||||
test('playlist audit compares persisted items and schedules without recreated row IDs', async () => {
|
test('playlist audit compares persisted items and schedules without recreated row IDs', async () => {
|
||||||
let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10 }];
|
let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10, position: 0, use_video_duration: 0, disable_audio: 1 }];
|
||||||
let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
|
let rules = [{ id: 301, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
|
||||||
const events = [];
|
const events = [];
|
||||||
const connection = {
|
const connection = {
|
||||||
async commit() {}, async rollback() {}, release() {},
|
async beginTransaction() {}, async commit() {}, async rollback() {}, release() {},
|
||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
|
if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)];
|
||||||
if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)];
|
if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)];
|
||||||
|
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]];
|
||||||
if (sql.includes('FROM c_playlist_schedule_rules r')) return [structuredClone(rules)];
|
if (sql.includes('FROM c_playlist_schedule_rules r')) return [structuredClone(rules)];
|
||||||
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]];
|
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]];
|
||||||
if (sql.startsWith('DELETE FROM c_playlist_slides')) { items = []; rules = []; }
|
if (sql.startsWith('UPDATE c_playlist_slides SET')) {
|
||||||
if (sql.startsWith('DELETE FROM c_playlist_groups')) return [{ affectedRows: 0 }];
|
items = items.map(item => item.id === params[7] ? Object.assign({}, item, {
|
||||||
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
slide_id: params[0], position: params[1], duration_seconds: params[2],
|
||||||
items = [{ id: 202, slide_id: params[1], group_id: params[6], duration_seconds: params[3] }];
|
use_video_duration: params[3], disable_audio: params[4], group_id: params[5]
|
||||||
return [{ insertId: 202 }];
|
}) : item);
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) {
|
if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) {
|
||||||
rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] });
|
rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, {
|
||||||
|
position: params[0], start_datetime: params[1], end_datetime: params[2],
|
||||||
|
start_time: params[3] + ':00', end_time: params[4] + ':00', schedule_days_json: params[5]
|
||||||
|
}) : rule);
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
|
||||||
|
items = items.filter(item => item.id !== params[0]);
|
||||||
|
rules = rules.filter(rule => rule.playlist_slide_id !== params[0]);
|
||||||
}
|
}
|
||||||
return [[]];
|
return [[]];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user