255 lines
9.6 KiB
JavaScript
255 lines
9.6 KiB
JavaScript
// Timetable 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, '"')
|
|
.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) {
|
|
var source = String(html || '');
|
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
|
if (!entry || typeof entry !== 'object') {
|
|
return '';
|
|
}
|
|
|
|
if (!window.placeholderUtils || typeof window.placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof window.placeholderUtils.formatPlaceholderValue !== 'function') {
|
|
return '';
|
|
}
|
|
|
|
return escapeHtml(window.placeholderUtils.formatPlaceholderValue(window.placeholderUtils.resolvePlaceholderExpression(entry, expression)));
|
|
});
|
|
}
|
|
|
|
function renderTemplate(template, context) {
|
|
var source = String(template || '');
|
|
if (!source) {
|
|
return '';
|
|
}
|
|
return substituteTimetableVariables(source, context);
|
|
}
|
|
|
|
function getTimetableGroups() {
|
|
return Array.isArray(initialData && initialData.timetableGroups) ? initialData.timetableGroups : [];
|
|
}
|
|
|
|
function getGroupById(groupId, groups) {
|
|
var normalizedId = Number(groupId || 0);
|
|
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
|
|
return Number(group.id) === normalizedId;
|
|
}) || null;
|
|
}
|
|
|
|
function getEntries(groupId, groups) {
|
|
var group = getGroupById(groupId, groups);
|
|
return group && Array.isArray(group.entries) ? group.entries : [];
|
|
}
|
|
|
|
function toDate(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
var date = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
function isUpcoming(entry, now) {
|
|
var start = toDate(entry && entry.start_datetime);
|
|
return Boolean(start && now < start);
|
|
}
|
|
|
|
function isLive(entry, now) {
|
|
var start = toDate(entry && entry.start_datetime);
|
|
var end = toDate(entry && entry.end_datetime);
|
|
return Boolean(start && end && now >= start && now < end);
|
|
}
|
|
|
|
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
|
var now = new Date();
|
|
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
|
|
var leftStart = toDate(left && left.start_datetime);
|
|
var rightStart = toDate(right && right.start_datetime);
|
|
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
|
});
|
|
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
|
|
|
|
entries = entries.filter(function (entry) {
|
|
if (mode === 'current') {
|
|
return isLive(entry, now);
|
|
}
|
|
if (mode === 'both') {
|
|
return isUpcoming(entry, now) || isLive(entry, now);
|
|
}
|
|
return isUpcoming(entry, now);
|
|
});
|
|
|
|
if (!entries.length && mode === 'upcoming') {
|
|
entries = getEntries(groupId, groups).filter(function (entry) {
|
|
return isLive(entry, now);
|
|
});
|
|
}
|
|
|
|
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
var date = toDate(value);
|
|
if (!date) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: 'numeric',
|
|
minute: '2-digit'
|
|
}).format(date);
|
|
} catch (_error) {
|
|
return date.toLocaleString();
|
|
}
|
|
}
|
|
|
|
function getDefaultStyle() {
|
|
return {
|
|
font_family: 'Arial',
|
|
font_size: 28,
|
|
font_color: '#000000'
|
|
};
|
|
}
|
|
|
|
function getTextStyle(region, regionContent) {
|
|
var current = regionContent && typeof regionContent === 'object' ? regionContent : {};
|
|
var defaultStyle = getDefaultStyle();
|
|
return {
|
|
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
|
|
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
|
|
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
|
|
};
|
|
}
|
|
|
|
function renderRegion(region, regionContent) {
|
|
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
|
|
var style = getTextStyle(region, regionContent || {});
|
|
var groups = getTimetableGroups();
|
|
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
|
|
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
|
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
|
var group = getGroupById(groupId, 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 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 contentStyle = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + canvasScale + ');transform-origin:top left;' + (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) + ';' : '');
|
|
|
|
if (!value) {
|
|
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;"></div></div>';
|
|
}
|
|
|
|
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;">' + entries.map(function (entry, index) {
|
|
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 : '',
|
|
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
|
timeZone: timeZone,
|
|
group: group || {},
|
|
entries: entries,
|
|
index: index + 1
|
|
}))) + '</div>';
|
|
}).join('') + '</div></div>';
|
|
}
|
|
|
|
registry.register('timetable', {
|
|
renderRegion: renderRegion
|
|
}); |