Release v2.2.0
This commit is contained in:
Vendored
+93
-41
@@ -1,15 +1,17 @@
|
||||
// Web-to-player websocket bridge and dashboard state synchronizer.
|
||||
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/upload-sync');
|
||||
const { createUploadSyncService } = require('./lib/media');
|
||||
const { createRequestAuthHeaders } = require('../request-auth');
|
||||
|
||||
function createWebBootstrap(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||
const uploadDir = String(options && options.uploadDir || '').trim();
|
||||
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000);
|
||||
const dashboardRefreshIntervalMs = 5000;
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
@@ -24,9 +26,54 @@ function createWebBootstrap(options) {
|
||||
const playerSnapshotSockets = new Map();
|
||||
let dashboardRefreshInFlight = null;
|
||||
let broadcastDashboardState = null;
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
function getPlayerSnapshotSocketUrl(slug) {
|
||||
const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws'));
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
if (playerInternalBaseUrl) {
|
||||
return playerInternalBaseUrl;
|
||||
}
|
||||
|
||||
if (playerInternalBaseUrlPromise) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
playerInternalBaseUrlPromise = (async function () {
|
||||
if (!pool) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
||||
} catch (_error) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
})().then(function (baseUrl) {
|
||||
playerInternalBaseUrl = baseUrl || null;
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return playerInternalBaseUrl;
|
||||
}, function () {
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
});
|
||||
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function getPlayerSnapshotSocketUrl(slug) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const url = new URL(resolvedPlayerInternalBaseUrl.replace(/^http/, 'ws'));
|
||||
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
|
||||
url.search = '';
|
||||
return url.toString();
|
||||
@@ -53,47 +100,53 @@ function createWebBootstrap(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketUrl = getPlayerSnapshotSocketUrl(key);
|
||||
playerSnapshotSockets.set(key, null);
|
||||
const socketUrlPromise = getPlayerSnapshotSocketUrl(key);
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||
});
|
||||
const socket = new WebSocket(socketUrl, {
|
||||
headers: authHeaders
|
||||
});
|
||||
playerSnapshotSockets.set(key, socket);
|
||||
socketUrlPromise.then(function (socketUrl) {
|
||||
const socket = new WebSocket(socketUrl, {
|
||||
headers: authHeaders
|
||||
});
|
||||
playerSnapshotSockets.set(key, socket);
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data || '{}'));
|
||||
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
||||
return;
|
||||
socket.onmessage = function (event) {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data || '{}'));
|
||||
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
||||
return;
|
||||
}
|
||||
storePlayerSnapshot(key, payload.connections || []);
|
||||
if (broadcastDashboardState) {
|
||||
broadcastDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed player snapshot payloads.
|
||||
}
|
||||
storePlayerSnapshot(key, payload.connections || []);
|
||||
if (broadcastDashboardState) {
|
||||
broadcastDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed player snapshot payloads.
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
socket.onclose = function () {
|
||||
clearPlayerSnapshotSocket(key);
|
||||
setTimeout(function () {
|
||||
ensurePlayerSnapshotSubscription(key);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
}).catch(function (error) {
|
||||
clearPlayerSnapshotSocket(key);
|
||||
setTimeout(function () {
|
||||
ensurePlayerSnapshotSubscription(key);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
const dashboardStateService = createDashboardStateService({
|
||||
@@ -110,7 +163,7 @@ function createWebBootstrap(options) {
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerInternalBaseUrl: configuredPlayerInternalBaseUrl,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
@@ -123,7 +176,6 @@ function createWebBootstrap(options) {
|
||||
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
|
||||
async function sendDashboardStateToSocket(socket) {
|
||||
@@ -208,6 +260,7 @@ function createWebBootstrap(options) {
|
||||
|
||||
return {
|
||||
upload: upload,
|
||||
uploadSyncService: uploadSyncService,
|
||||
buildDashboardState: buildDashboardState,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
@@ -215,7 +268,6 @@ function createWebBootstrap(options) {
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
installDashboardWebsocket: installDashboardWebsocket
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// General-purpose helpers used across the admin UI.
|
||||
function registerGeneralHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('eq', function (left, right) {
|
||||
return left === right;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('gt', function (left, right) {
|
||||
return left > right;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('lt', function (left, right) {
|
||||
return left < right;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('json', function (value) {
|
||||
return new Handlebars.SafeString(JSON.stringify(value).replace(/</g, '\\u003c'));
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('truncateText', function (value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!text || !Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
});
|
||||
}
|
||||
|
||||
// URL helpers for generating links and external URL checks.
|
||||
function registerUrlHelpers(Handlebars) {
|
||||
|
||||
Handlebars.registerHelper('isExternalUrl', function (value) {
|
||||
return /^https?:\/\//i.test(String(value || ''));
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('assetHref', function (value) {
|
||||
const href = String(value || '').trim();
|
||||
if (!href) {
|
||||
return '';
|
||||
}
|
||||
if (/^https?:\/\//i.test(href) || href.startsWith('/')) {
|
||||
return href;
|
||||
}
|
||||
return `/assets/${href}`;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('playerUrl', function (slug) {
|
||||
const base = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
return `${base}/screen/${encodeURIComponent(slug)}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Permission helpers delegate to the RBAC layer.
|
||||
function registerPermissionHelpers(Handlebars, deps) {
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
|
||||
Handlebars.registerHelper('hasPermission', function (currentUser, permissionKey) {
|
||||
return hasPermission(currentUser, permissionKey);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('anyPermission', function (currentUser) {
|
||||
const args = Array.prototype.slice.call(arguments, 1);
|
||||
args.pop();
|
||||
return hasAnyPermission(currentUser, args);
|
||||
});
|
||||
}
|
||||
|
||||
function registerUserHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('userInitial', function (name) {
|
||||
const text = String(name || '').trim();
|
||||
return text ? text.charAt(0).toUpperCase() : 'A';
|
||||
});
|
||||
}
|
||||
|
||||
// Action button helpers keep form submit controls consistent.
|
||||
function registerActionHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('saveActionButtons', function (options) {
|
||||
const hash = options && options.hash ? options.hash : {};
|
||||
const formId = String(hash.formId || '').trim();
|
||||
const saveLabel = String(hash.saveLabel || 'Save');
|
||||
const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success');
|
||||
const saveActionName = String(hash.saveActionName || 'save_action');
|
||||
const saveActionValue = String(hash.saveActionValue || 'save');
|
||||
const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close');
|
||||
const closeValue = String(hash.saveAndCloseValue || 'close');
|
||||
const newLabel = String(hash.saveAndNewLabel || 'Save and New');
|
||||
const newValue = String(hash.saveAndNewValue || 'new');
|
||||
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
|
||||
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
|
||||
const ariaLabel = String(hash.ariaLabel || 'Save actions');
|
||||
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
|
||||
|
||||
if (!formId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const escape = Handlebars.escapeExpression;
|
||||
const formAttr = ` form="${escape(formId)}"`;
|
||||
|
||||
return new Handlebars.SafeString([
|
||||
'<div class="btn-group save-action-group" role="group">',
|
||||
`<button type="submit" class="${escape(saveButtonClass)}"${formAttr} name="${escape(saveActionName)}" value="${escape(saveActionValue)}">${escape(saveLabel)}</button>`,
|
||||
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
|
||||
hasSecondaryActions ? '<div class="dropdown-menu dropdown-menu-end">' : '',
|
||||
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(closeValue)}">${escape(closeLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '',
|
||||
hasSecondaryActions ? '</div>' : '',
|
||||
'</div>',
|
||||
].join(''));
|
||||
});
|
||||
}
|
||||
|
||||
// Shared partials are registered once at startup.
|
||||
function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||
}
|
||||
|
||||
// One entry point keeps Handlebars bootstrap centralized.
|
||||
function registerHandlebars(Handlebars, deps) {
|
||||
const viewsRoot = String(deps && deps.viewsRoot || '').trim();
|
||||
const hasPermission = deps && deps.hasPermission;
|
||||
const hasAnyPermission = deps && deps.hasAnyPermission;
|
||||
|
||||
if (!Handlebars || !viewsRoot || typeof hasPermission !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerHandlebars requires Handlebars, viewsRoot, hasPermission, and hasAnyPermission.');
|
||||
}
|
||||
|
||||
registerGeneralHelpers(Handlebars);
|
||||
registerUrlHelpers(Handlebars);
|
||||
registerPermissionHelpers(Handlebars, { hasPermission: hasPermission, hasAnyPermission: hasAnyPermission });
|
||||
registerUserHelpers(Handlebars);
|
||||
registerActionHelpers(Handlebars);
|
||||
registerPartials(Handlebars, viewsRoot);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerHandlebars: registerHandlebars
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Entry point for the web auth helper modules.
|
||||
|
||||
module.exports = {
|
||||
createSessionService: require('./session').createSessionService,
|
||||
rbacData: require('./rbac-data')
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
const { fetchPagedRows } = require('../../data/utils');
|
||||
// RBAC data access helpers for roles, permissions, and user-role links.
|
||||
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -1,10 +1,13 @@
|
||||
const { normalizePermissionKeys } = require('../../rbac');
|
||||
// Session cookie and current-user helpers for the web auth flow.
|
||||
|
||||
const { normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||
const hashSessionToken = options && options.hashSessionToken;
|
||||
const createSessionToken = options && options.createSessionToken;
|
||||
const authMessageCookieName = 'pulse_auth_message';
|
||||
|
||||
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
||||
throw new Error('createSessionService requires the session dependencies.');
|
||||
@@ -39,12 +42,37 @@ function createSessionService(options) {
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function appendCookieHeader(res, cookieValue) {
|
||||
const existingCookieHeader = res.getHeader('Set-Cookie');
|
||||
if (!existingCookieHeader) {
|
||||
res.setHeader('Set-Cookie', cookieValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const cookies = Array.isArray(existingCookieHeader) ? existingCookieHeader.slice() : [existingCookieHeader];
|
||||
cookies.push(cookieValue);
|
||||
res.setHeader('Set-Cookie', cookies);
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||
}
|
||||
|
||||
function setAuthMessageCookie(res, message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
|
||||
}
|
||||
|
||||
function consumeAuthMessageCookie(req, res) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const message = String(cookies[authMessageCookieName] || '').trim();
|
||||
if (message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req) {
|
||||
@@ -113,7 +141,8 @@ function createSessionService(options) {
|
||||
if (req.currentUser) {
|
||||
return next();
|
||||
}
|
||||
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -121,6 +150,8 @@ function createSessionService(options) {
|
||||
serializeCookie: serializeCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setSessionCookie: setSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
createUserSession: createUserSession,
|
||||
requireAuth: requireAuth
|
||||
@@ -1,93 +0,0 @@
|
||||
function registerBackgroundTaskHandlers(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const runMediaSyncTask = options && options.runMediaSyncTask;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim();
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) {
|
||||
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const templateId = Number(payload.templateId || 0);
|
||||
if (!Number.isFinite(templateId) || templateId <= 0) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
const slideId = Number(slide && slide.id || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
}
|
||||
|
||||
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerBackgroundTaskHandlers };
|
||||
@@ -1,774 +0,0 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function toIsoDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
function createBackgroundTaskQueue(options) {
|
||||
const pool = options && options.pool;
|
||||
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||
const taskHandlers = new Map();
|
||||
const tasksById = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
const taskIdToRecurringKey = new Map();
|
||||
const pendingIds = [];
|
||||
let nextTaskId = 1;
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
function createTaskCompletionController() {
|
||||
let resolveCompletion = null;
|
||||
let rejectCompletion = null;
|
||||
const completionPromise = new Promise(function (resolve, reject) {
|
||||
resolveCompletion = resolve;
|
||||
rejectCompletion = reject;
|
||||
});
|
||||
|
||||
return {
|
||||
promise: completionPromise,
|
||||
resolve: resolveCompletion,
|
||||
reject: rejectCompletion
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonValue(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
taskType: task.taskType || '',
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
startedAt: task.startedAt,
|
||||
finishedAt: task.finishedAt,
|
||||
errorMessage: task.errorMessage,
|
||||
attempts: task.attempts || 0,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload || null
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tasksById.get(numericTaskId) || null;
|
||||
}
|
||||
|
||||
function buildTaskFromRow(row) {
|
||||
const task = {
|
||||
id: Number(row.id),
|
||||
key: String(row.task_key || '').trim(),
|
||||
taskType: String(row.task_type || '').trim(),
|
||||
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || ''),
|
||||
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||
metadata: parseJsonValue(row.metadata_json, {}),
|
||||
payload: parseJsonValue(row.payload_json, null),
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null,
|
||||
persisted: true,
|
||||
run: typeof row.run === 'function' ? row.run : function () {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
return task;
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || 'general',
|
||||
title: task.title,
|
||||
category: task.category || 'general',
|
||||
status: task.status,
|
||||
payload_json: stringifyJsonValue(task.payload),
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
};
|
||||
}
|
||||
|
||||
async function persistTaskInsert(task) {
|
||||
if (!pool || !task.taskType) {
|
||||
return task;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO o_background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
category,
|
||||
status,
|
||||
payload_json,
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
]
|
||||
);
|
||||
|
||||
task.id = Number(result.insertId);
|
||||
task.persisted = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!pool || !task.persisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message,
|
||||
task.id
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [taskId]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM o_background_tasks ORDER BY id ASC'
|
||||
);
|
||||
|
||||
let highestTaskId = 0;
|
||||
for (const row of rows || []) {
|
||||
const task = buildTaskFromRow(row);
|
||||
if (!Number.isInteger(task.id) || task.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
highestTaskId = Math.max(highestTaskId, task.id);
|
||||
tasksById.set(task.id, task);
|
||||
|
||||
if (task.status === 'running') {
|
||||
task.status = 'queued';
|
||||
task.startedAt = '';
|
||||
task.finishedAt = '';
|
||||
task.errorMessage = '';
|
||||
await pool.query(
|
||||
'UPDATE o_background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||
['queued', task.id]
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
pendingIds.push(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
nextTaskId = Math.max(nextTaskId, highestTaskId + 1);
|
||||
if (pendingIds.length) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleRecurringRun(job, delayMs) {
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||
job.timerId = setTimeout(function () {
|
||||
job.timerId = null;
|
||||
triggerRecurringJob(job.key);
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.activeTaskId && tasksById.has(job.activeTaskId)) {
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = -1;
|
||||
enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: job.taskType || '',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
}),
|
||||
run: job.run,
|
||||
persist: Boolean(job.taskType)
|
||||
}).then(function (task) {
|
||||
if (task && Number.isInteger(task.id)) {
|
||||
job.activeTaskId = task.id;
|
||||
taskIdToRecurringKey.set(task.id, job.key);
|
||||
} else {
|
||||
job.activeTaskId = null;
|
||||
}
|
||||
}).catch(function (error) {
|
||||
job.activeTaskId = null;
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
}
|
||||
|
||||
function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
triggerRecurringJob(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = null;
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = status;
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const nextTaskId = pendingIds.shift();
|
||||
if (!nextTaskId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const task = tasksById.get(nextTaskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
continue;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
task.status = 'running';
|
||||
task.startedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
task.attempts = Math.max(0, Number(task.attempts) || 0) + 1;
|
||||
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if (task.taskType) {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
return handler({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
payload: task.payload,
|
||||
metadata: task.metadata,
|
||||
attempts: task.attempts
|
||||
});
|
||||
}
|
||||
|
||||
return task.run({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.resolveCompletion === 'function') {
|
||||
task.resolveCompletion(buildSnapshot(task));
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, '');
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.catch(function (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.finally(function () {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType));
|
||||
const existingTask = normalizedKey
|
||||
? Array.from(tasksById.values()).find(function (task) {
|
||||
return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType);
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existingTask) {
|
||||
existingTask.title = normalizedTitle;
|
||||
existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general';
|
||||
existingTask.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
existingTask.taskType = normalizedTaskType || existingTask.taskType || '';
|
||||
existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload;
|
||||
existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run;
|
||||
existingTask.createdAt = toIsoDate(new Date());
|
||||
existingTask.errorMessage = '';
|
||||
existingTask.persisted = existingTask.persisted || shouldPersist;
|
||||
await persistTaskUpdate(existingTask);
|
||||
return buildSnapshot(existingTask);
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: shouldPersist ? 0 : nextTaskId,
|
||||
key: normalizedKey,
|
||||
taskType: normalizedTaskType,
|
||||
title: normalizedTitle,
|
||||
category: normalizeText(definition && definition.category) || 'general',
|
||||
status: 'queued',
|
||||
createdAt: toIsoDate(new Date()),
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
errorMessage: '',
|
||||
attempts: 0,
|
||||
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||
payload: definition && definition.payload !== undefined ? definition.payload : null,
|
||||
persisted: shouldPersist,
|
||||
run: typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
|
||||
if (shouldPersist) {
|
||||
await persistTaskInsert(task);
|
||||
} else {
|
||||
nextTaskId += 1;
|
||||
}
|
||||
|
||||
tasksById.set(task.id, task);
|
||||
pendingIds.push(task.id);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
function registerRecurringTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
if (!normalizedKey) {
|
||||
throw new Error('Recurring tasks require a key.');
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||
throw new Error('Recurring tasks require a valid interval.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||
key: normalizedKey,
|
||||
activeTaskId: null,
|
||||
lastRunAt: '',
|
||||
lastStatus: '',
|
||||
lastError: '',
|
||||
nextRunAt: '',
|
||||
timerId: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
clearRecurringTimer(job);
|
||||
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||
job.category = normalizeText(definition && definition.category) || 'general';
|
||||
job.intervalMs = intervalMs;
|
||||
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
job.enabled = definition && definition.enabled === false ? false : true;
|
||||
recurringJobsByKey.set(normalizedKey, job);
|
||||
|
||||
if (job.enabled) {
|
||||
scheduleRecurringRun(job, intervalMs);
|
||||
}
|
||||
|
||||
return buildRecurringSnapshot(job);
|
||||
}
|
||||
|
||||
function removeRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
const job = recurringJobsByKey.get(normalizedKey);
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
recurringJobsByKey.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecurringSnapshot(job) {
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: job.activeTaskId || null,
|
||||
createdAt: job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: job.lastRunAt || '',
|
||||
lastStatus: job.lastStatus || '',
|
||||
lastError: job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskSortTime(task) {
|
||||
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
||||
if (Number.isFinite(finishedTime)) {
|
||||
return finishedTime;
|
||||
}
|
||||
|
||||
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||
if (Number.isFinite(createdTime)) {
|
||||
return createdTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN;
|
||||
const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN;
|
||||
if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) {
|
||||
return rightStartedTime - leftStartedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) {
|
||||
return Number.isFinite(leftStartedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||
const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||
if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) {
|
||||
return rightCreatedTime - leftCreatedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) {
|
||||
return Number.isFinite(leftCreatedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
})
|
||||
.map(buildSnapshot);
|
||||
}
|
||||
|
||||
function listRecurringTasks() {
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(buildRecurringSnapshot);
|
||||
}
|
||||
|
||||
function clearFinishedTasks() {
|
||||
let removedCount = 0;
|
||||
Array.from(tasksById.values()).forEach(function (task) {
|
||||
if (task.status === 'running' || task.status === 'queued') {
|
||||
return;
|
||||
}
|
||||
tasksById.delete(task.id);
|
||||
removedCount += 1;
|
||||
persistTaskDelete(task.id).catch(function (error) {
|
||||
console.warn('Unable to delete finished task from persistence:', error);
|
||||
});
|
||||
});
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
function cancelTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
return false;
|
||||
}
|
||||
|
||||
task.status = 'canceled';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
const pendingIndex = pendingIds.indexOf(task.id);
|
||||
if (pendingIndex >= 0) {
|
||||
pendingIds.splice(pendingIndex, 1);
|
||||
}
|
||||
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const cancellationError = new Error('Task canceled.');
|
||||
cancellationError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(cancellationError);
|
||||
}
|
||||
|
||||
persistTaskUpdate(task).catch(function (error) {
|
||||
console.warn('Unable to persist canceled task:', error);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function retryTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata,
|
||||
run: task.run
|
||||
});
|
||||
}
|
||||
|
||||
function getSummary() {
|
||||
const counts = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
canceled: 0
|
||||
};
|
||||
|
||||
listTasks().forEach(function (task) {
|
||||
if (Object.prototype.hasOwnProperty.call(counts, task.status)) {
|
||||
counts[task.status] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: counts,
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: listTasks().length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: function (definition) {
|
||||
return enqueueTask(definition).then(function (snapshot) {
|
||||
const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null;
|
||||
if (!task || !task.completionPromise) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return task.completionPromise;
|
||||
});
|
||||
},
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: getTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
const { normalizeIntervalMs } = require('./background-task-queue');
|
||||
|
||||
function registerBackgroundTaskScheduling(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !mediaDir) {
|
||||
throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function syncRecurringRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function registerRecurringMaintenanceTasks() {
|
||||
async function runUnusedUploadSweep() {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'media-sync',
|
||||
intervalMs: 24 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: runUnusedUploadSweep
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleInitialDataSourceRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push({
|
||||
type: 'api-source',
|
||||
id: Number(apiSource.id),
|
||||
name: apiSource.name,
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push({
|
||||
type: 'rss-feed',
|
||||
id: Number(rssFeed.id),
|
||||
name: rssFeed.name,
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * dataSourceStartupRefreshStaggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await syncRecurringRefreshes();
|
||||
await registerRecurringMaintenanceTasks();
|
||||
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { registerBackgroundTaskScheduling };
|
||||
@@ -1,32 +0,0 @@
|
||||
const { registerBackgroundTaskHandlers } = require('./background-task-handlers');
|
||||
const { registerBackgroundTaskScheduling } = require('./background-task-scheduling');
|
||||
|
||||
function createBackgroundTaskSetup(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const uploadsDir = String(options && options.uploadsDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
|
||||
throw new Error('createBackgroundTaskSetup requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
registerBackgroundTaskHandlers(options);
|
||||
const backgroundTaskScheduling = registerBackgroundTaskScheduling(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createBackgroundTaskSetup };
|
||||
@@ -0,0 +1,99 @@
|
||||
// Shared loader for task folders under background-tasks.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function getTaskExport(taskModule) {
|
||||
return typeof taskModule === 'function'
|
||||
? taskModule
|
||||
: taskModule && typeof taskModule === 'object'
|
||||
? Object.values(taskModule).find(function (value) {
|
||||
return typeof value === 'function';
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
function loadTaskModules(directory, options) {
|
||||
const taskFiles = fs.readdirSync(directory)
|
||||
.filter(function (fileName) {
|
||||
return fileName.toLowerCase().endsWith('.js') && fileName !== 'index.js';
|
||||
})
|
||||
.sort(function (left, right) {
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return Promise.all(taskFiles.map(function (fileName) {
|
||||
const taskModule = require(path.join(directory, fileName));
|
||||
const exported = getTaskExport(taskModule);
|
||||
|
||||
if (typeof exported === 'function') {
|
||||
return exported(options);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
// Register ad hoc task handlers that execute queued background work.
|
||||
function registerAdhocTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-adhoc');
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.captureSlideThumbnail || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerAdhocTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
// Register one-time startup work that should run during boot only.
|
||||
function registerStartupTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-startup');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerStartupTasks requires the startup task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
// Register recurring maintenance and data-source refresh tasks.
|
||||
function registerScheduledTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-scheduled');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerScheduledTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeBackgroundTasks(options) {
|
||||
registerAdhocTasks(options);
|
||||
|
||||
const backgroundTaskStartup = registerStartupTasks(options);
|
||||
if (backgroundTaskStartup && typeof backgroundTaskStartup.initialize === 'function') {
|
||||
await backgroundTaskStartup.initialize();
|
||||
}
|
||||
|
||||
const backgroundTaskScheduling = registerScheduledTasks(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerAdhocTasks: registerAdhocTasks,
|
||||
registerStartupTasks: registerStartupTasks,
|
||||
registerScheduledTasks: registerScheduledTasks,
|
||||
initializeBackgroundTasks: initializeBackgroundTasks
|
||||
};
|
||||
@@ -0,0 +1,719 @@
|
||||
// Database-backed background task queue.
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function toIsoDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
function createBackgroundTaskQueue(options) {
|
||||
const pool = options && options.pool;
|
||||
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||
const taskHandlers = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
if (!pool) {
|
||||
throw new Error('createBackgroundTaskQueue requires a database pool.');
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: Number(task && task.id) || 0,
|
||||
key: String(task && task.key || '').trim(),
|
||||
taskType: String(task && task.taskType || '').trim(),
|
||||
title: String(task && task.title || 'Background task').trim() || 'Background task',
|
||||
category: String(task && task.category || 'general').trim() || 'general',
|
||||
status: String(task && task.status || 'queued').trim() || 'queued',
|
||||
createdAt: String(task && task.createdAt || ''),
|
||||
startedAt: String(task && task.startedAt || ''),
|
||||
finishedAt: String(task && task.finishedAt || ''),
|
||||
errorMessage: String(task && task.errorMessage || ''),
|
||||
attempts: Math.max(0, Number(task && task.attempts) || 0),
|
||||
metadata: task && task.metadata ? task.metadata : {},
|
||||
payload: task && task.payload !== undefined ? task.payload : null
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonValue(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function hydrateTaskRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
key: String(row.task_key || '').trim(),
|
||||
taskType: String(row.task_type || '').trim(),
|
||||
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || ''),
|
||||
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||
metadata: parseJsonValue(row.metadata_json, {}),
|
||||
payload: parseJsonValue(row.payload_json, null)
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || null,
|
||||
title: task.title,
|
||||
category: task.category || 'general',
|
||||
status: task.status,
|
||||
payload_json: stringifyJsonValue(task.payload),
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
};
|
||||
}
|
||||
|
||||
async function persistTaskInsert(task) {
|
||||
const normalizedTaskType = normalizeText(task && task.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(Object.assign({}, task, {
|
||||
taskType: normalizedTaskType
|
||||
}));
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO o_background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
category,
|
||||
status,
|
||||
payload_json,
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
]
|
||||
);
|
||||
|
||||
task.id = Number(result.insertId);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!task || !Number.isInteger(Number(task.id)) || Number(task.id) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message,
|
||||
Number(task.id)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [numericTaskId]);
|
||||
}
|
||||
|
||||
async function fetchTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[numericTaskId]
|
||||
);
|
||||
|
||||
return hydrateTaskRow(rows && rows[0]) || null;
|
||||
}
|
||||
|
||||
async function fetchQueuedTaskCount() {
|
||||
const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM o_background_tasks WHERE status = 'queued'`);
|
||||
return Number(rows && rows[0] && rows[0].count) || 0;
|
||||
}
|
||||
|
||||
async function claimNextTask() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [rows] = await connection.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
WHERE status = 'queued'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED`
|
||||
);
|
||||
|
||||
const row = rows && rows[0] ? rows[0] : null;
|
||||
if (!row) {
|
||||
await connection.commit();
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextAttempts = Math.max(0, Number(row.attempts) || 0) + 1;
|
||||
await connection.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'running', started_at = NOW(), attempts = ?, error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[nextAttempts, row.id]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
return hydrateTaskRow(Object.assign({}, row, {
|
||||
status: 'running',
|
||||
started_at: new Date(),
|
||||
attempts: nextAttempts,
|
||||
error_message: null
|
||||
}));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = String(status || '');
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
setTaskHandler('recurring-run', async function (task) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
throw new Error('Recurring task key is required.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || typeof job.run !== 'function') {
|
||||
throw new Error('Recurring task not found.');
|
||||
}
|
||||
|
||||
return job.run(task);
|
||||
});
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'queued', started_at = NULL, finished_at = NULL, error_message = NULL
|
||||
WHERE status = 'running'`
|
||||
);
|
||||
|
||||
if (await fetchQueuedTaskCount() > 0) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const task = await claimNextTask();
|
||||
if (!task) {
|
||||
break;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
await handler(task);
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
await persistTaskUpdate(task);
|
||||
await syncRecurringTaskState(task, task.status, '');
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (persistError) {
|
||||
console.error(persistError);
|
||||
}
|
||||
await syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
} finally {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
}
|
||||
})().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: 0,
|
||||
key: normalizedKey,
|
||||
taskType: normalizedTaskType,
|
||||
title: normalizedTitle,
|
||||
category: normalizeText(definition && definition.category) || 'general',
|
||||
status: 'queued',
|
||||
createdAt: toIsoDate(new Date()),
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
errorMessage: '',
|
||||
attempts: 0,
|
||||
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||
payload: definition && definition.payload !== undefined ? definition.payload : null
|
||||
};
|
||||
|
||||
await persistTaskInsert(task);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
async function enqueueTaskAndWait(definition) {
|
||||
const snapshot = await enqueueTask(definition);
|
||||
const taskId = snapshot && snapshot.id ? snapshot.id : null;
|
||||
if (!taskId) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const currentTask = await fetchTaskById(taskId);
|
||||
if (!currentTask) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if (currentTask.status !== 'queued' && currentTask.status !== 'running') {
|
||||
return buildSnapshot(currentTask);
|
||||
}
|
||||
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRecurringRun(job, delayMs) {
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||
job.timerId = setTimeout(function () {
|
||||
job.timerId = null;
|
||||
triggerRecurringJob(job.key).catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
async function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: 'recurring-run',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
})
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return triggerRecurringJob(normalizedKey);
|
||||
}
|
||||
|
||||
function registerRecurringTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
if (!normalizedKey) {
|
||||
throw new Error('Recurring tasks require a key.');
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||
throw new Error('Recurring tasks require a valid interval.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||
key: normalizedKey,
|
||||
lastRunAt: '',
|
||||
lastStatus: '',
|
||||
lastError: '',
|
||||
nextRunAt: '',
|
||||
timerId: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
clearRecurringTimer(job);
|
||||
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||
job.category = normalizeText(definition && definition.category) || 'general';
|
||||
job.intervalMs = intervalMs;
|
||||
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
job.payload = definition && definition.payload ? definition.payload : null;
|
||||
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
job.enabled = definition && definition.enabled === false ? false : true;
|
||||
recurringJobsByKey.set(normalizedKey, job);
|
||||
|
||||
if (job.enabled) {
|
||||
scheduleRecurringRun(job, intervalMs);
|
||||
}
|
||||
|
||||
return buildRecurringSnapshot(job);
|
||||
}
|
||||
|
||||
function removeRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
const job = recurringJobsByKey.get(normalizedKey);
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
recurringJobsByKey.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecurringSnapshot(job, state) {
|
||||
const latestState = state || null;
|
||||
const activeTaskId = latestState && (latestState.status === 'queued' || latestState.status === 'running')
|
||||
? latestState.id
|
||||
: null;
|
||||
const lastRunAt = latestState
|
||||
? latestState.finishedAt || latestState.startedAt || latestState.createdAt || ''
|
||||
: job.lastRunAt || '';
|
||||
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: activeTaskId,
|
||||
createdAt: latestState ? latestState.createdAt || '' : job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: lastRunAt || '',
|
||||
lastStatus: latestState ? latestState.status || '' : job.lastStatus || '',
|
||||
lastError: latestState ? latestState.errorMessage || '' : job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRecurringTaskStates() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, status, created_at, started_at, finished_at, error_message, metadata_json
|
||||
FROM o_background_tasks
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.recurringKey')) IS NOT NULL
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
const statesByKey = new Map();
|
||||
for (const row of rows || []) {
|
||||
const metadata = parseJsonValue(row.metadata_json, {});
|
||||
const recurringKey = normalizeText(metadata && metadata.recurringKey);
|
||||
if (!recurringKey || statesByKey.has(recurringKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
statesByKey.set(recurringKey, {
|
||||
id: Number(row.id),
|
||||
status: String(row.status || ''),
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || '')
|
||||
});
|
||||
}
|
||||
|
||||
return statesByKey;
|
||||
}
|
||||
|
||||
async function listTasks() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'running' THEN 0
|
||||
WHEN 'queued' THEN 1
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'canceled' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
COALESCE(started_at, created_at) DESC,
|
||||
id DESC`
|
||||
);
|
||||
|
||||
return (rows || []).map(hydrateTaskRow);
|
||||
}
|
||||
|
||||
async function listRecurringTasks() {
|
||||
const statesByKey = await fetchRecurringTaskStates();
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(function (job) {
|
||||
return buildRecurringSnapshot(job, statesByKey.get(job.key) || null);
|
||||
});
|
||||
}
|
||||
|
||||
async function clearFinishedTasks() {
|
||||
const [result] = await pool.query(
|
||||
`DELETE FROM o_background_tasks
|
||||
WHERE status IN ('completed', 'failed', 'canceled')`
|
||||
);
|
||||
return Number(result && result.affectedRows) || 0;
|
||||
}
|
||||
|
||||
async function cancelTask(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'canceled',
|
||||
finished_at = NOW(),
|
||||
error_message = 'Task canceled.'
|
||||
WHERE id = ?
|
||||
AND status = 'queued'`,
|
||||
[numericTaskId]
|
||||
);
|
||||
return Number(result && result.affectedRows) > 0;
|
||||
}
|
||||
|
||||
async function retryTask(taskId) {
|
||||
const task = await fetchTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload
|
||||
});
|
||||
}
|
||||
|
||||
async function getSummary() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
SUM(status = 'queued') AS queued,
|
||||
SUM(status = 'running') AS running,
|
||||
SUM(status = 'completed') AS completed,
|
||||
SUM(status = 'failed') AS failed,
|
||||
SUM(status = 'canceled') AS canceled,
|
||||
COUNT(*) AS total
|
||||
FROM o_background_tasks`
|
||||
);
|
||||
const row = rows && rows[0] ? rows[0] : {};
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: {
|
||||
queued: Number(row.queued) || 0,
|
||||
running: Number(row.running) || 0,
|
||||
completed: Number(row.completed) || 0,
|
||||
failed: Number(row.failed) || 0,
|
||||
canceled: Number(row.canceled) || 0
|
||||
},
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: Number(row.total) || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: enqueueTaskAndWait,
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: fetchTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'data-source-refresh'
|
||||
};
|
||||
|
||||
function registerDataSourceRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('registerDataSourceRefreshTask requires the data source refresh dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
// Resolve the source record at execution time so stale queued tasks fail cleanly.
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerDataSourceRefreshTask };
|
||||
@@ -0,0 +1,39 @@
|
||||
function registerFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
|
||||
if (!backgroundTaskQueue || !uploadSyncService) {
|
||||
throw new Error('registerFontSyncTask requires the font sync dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : task || {};
|
||||
const uploadDir = String(payload.uploadDir || '').trim();
|
||||
const operations = Array.isArray(payload.operations)
|
||||
? payload.operations
|
||||
: Array.isArray(payload.uploadPaths)
|
||||
? payload.uploadPaths.map(function (uploadPath) {
|
||||
return { type: String(payload.action || 'put').trim().toLowerCase(), uploadPath: uploadPath };
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!uploadDir || !operations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < operations.length; i += 1) {
|
||||
const operation = operations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir);
|
||||
} else {
|
||||
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSyncTask };
|
||||
@@ -0,0 +1,20 @@
|
||||
const TASK = {
|
||||
taskType: 'media-sync'
|
||||
};
|
||||
|
||||
function registerMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const runMediaSyncTask = uploadSyncService && uploadSyncService.runMediaSyncTask;
|
||||
|
||||
if (!backgroundTaskQueue || typeof runMediaSyncTask !== 'function') {
|
||||
throw new Error('registerMediaSyncTask requires the media sync dependencies.');
|
||||
}
|
||||
|
||||
// Media sync is just a pass-through to the shared media sync runner.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerMediaSyncTask };
|
||||
@@ -0,0 +1,51 @@
|
||||
const TASK = {
|
||||
taskType: 'slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Refresh the slide thumbnail for one slide id.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,65 @@
|
||||
const TASK = {
|
||||
taskType: 'template-slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Walk every slide in the template and regenerate its thumbnail.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const templateId = Number(payload.templateId || 0);
|
||||
if (!Number.isFinite(templateId) || templateId <= 0) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
const slideId = Number(slide && slide.id || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
}
|
||||
|
||||
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerTemplateSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,139 @@
|
||||
const TASK = {
|
||||
key: 'recurring-data-source-refreshes',
|
||||
title: 'Recurring data source refreshes',
|
||||
category: 'data-source',
|
||||
trigger: 'scheduled recurring task definitions from the database',
|
||||
purpose: 'keep API sources and RSS feeds refreshed on their configured intervals.',
|
||||
taskType: 'data-source-refresh',
|
||||
intervalMs: null
|
||||
};
|
||||
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
function registerRecurringDataSourceRefreshes(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('registerRecurringDataSourceRefreshes requires the recurring refresh dependencies.');
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function createDataSourceTaskService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('createDataSourceTaskService requires the data source task dependencies.');
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
async function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = await backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, actorId) {
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId);
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId);
|
||||
}
|
||||
|
||||
return {
|
||||
formatRecurringKey: formatRecurringKey,
|
||||
buildRecurringTitle: buildRecurringTitle,
|
||||
registerRecurringRefresh: registerRecurringRefresh,
|
||||
removeRecurringRefresh: removeRecurringRefresh,
|
||||
getTaskStatusById: getTaskStatusById,
|
||||
refreshApiSourceInBackground: refreshApiSourceInBackground,
|
||||
refreshRssFeedInBackground: refreshRssFeedInBackground
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerRecurringDataSourceRefreshes: registerRecurringDataSourceRefreshes,
|
||||
createDataSourceTaskService: createDataSourceTaskService
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
const TASK = {
|
||||
key: 'font-sweep',
|
||||
title: 'Font sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'reconcile managed fonts on the player and remove stale font files.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
const {
|
||||
collectFontLibraryDirectoryUploadPaths,
|
||||
collectFontLibrarySyncOperations
|
||||
} = require('../../media/font-library');
|
||||
|
||||
function registerFontSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
|
||||
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
|
||||
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
||||
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
||||
return operation && operation.uploadPath ? operation.uploadPath : '';
|
||||
}).filter(Boolean));
|
||||
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
|
||||
|
||||
for (let i = 0; i < desiredOperations.length; i += 1) {
|
||||
const operation = desiredOperations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
} else {
|
||||
await pushUploadFileToPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentUploadPaths.length; i += 1) {
|
||||
const uploadPath = String(currentUploadPaths[i] || '').trim();
|
||||
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSweepTask };
|
||||
@@ -0,0 +1,42 @@
|
||||
const TASK = {
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'remove uploaded media files that are no longer referenced.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
function registerUnusedUploadSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService && uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = uploadSyncService && uploadSyncService.removeUnusedUploadFiles;
|
||||
const pool = options && options.pool;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof collectUploadPathsFromDirectory !== 'function' || typeof removeUnusedUploadFiles !== 'function' || !pool || !mediaDir) {
|
||||
throw new Error('registerUnusedUploadSweepTask requires the unused upload sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerUnusedUploadSweepTask };
|
||||
@@ -0,0 +1,76 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
key: 'startup-data-source-refresh',
|
||||
category: 'data-source',
|
||||
};
|
||||
|
||||
function scheduleStartupDataSourceRefreshes(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('scheduleStartupDataSourceRefreshes requires the startup refresh dependencies.');
|
||||
}
|
||||
|
||||
const staggerMs = Math.max(100, Number(dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
function buildStartupSource(type, id, name, run) {
|
||||
return {
|
||||
type: type,
|
||||
id: Number(id),
|
||||
name: name,
|
||||
run: run
|
||||
};
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}));
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}));
|
||||
});
|
||||
|
||||
// Stagger startup refreshes to avoid a burst against the DB/player.
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * staggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key + ':' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: TASK.category,
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
module.exports = { scheduleStartupDataSourceRefreshes };
|
||||
@@ -0,0 +1,32 @@
|
||||
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
|
||||
|
||||
const TASK = {
|
||||
key: 'initial-font-sync',
|
||||
category: 'fonts'
|
||||
};
|
||||
|
||||
function registerInitialFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial font sync',
|
||||
category: TASK.category,
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir,
|
||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial font sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialFontSyncTask };
|
||||
@@ -0,0 +1,29 @@
|
||||
const TASK = {
|
||||
key: 'initial-media-sync',
|
||||
category: 'media-sync',
|
||||
};
|
||||
|
||||
function registerInitialMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial media sync',
|
||||
category: TASK.category,
|
||||
taskType: 'media-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial media sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialMediaSyncTask };
|
||||
@@ -0,0 +1,30 @@
|
||||
const path = require('path');
|
||||
|
||||
function createWebConfig() {
|
||||
const mediaDir = path.join(__dirname, '..', '..', '..', 'media');
|
||||
const uploadsDir = path.join(mediaDir, 'uploads');
|
||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||
const assetDir = path.join(__dirname, '..', 'public');
|
||||
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
|
||||
const sessionCookieName = 'digital_signage_session';
|
||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
||||
const port = Number(process.env.WEB_PORT || 8080);
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||
|
||||
return {
|
||||
port: port,
|
||||
mediaDir: mediaDir,
|
||||
uploadsDir: uploadsDir,
|
||||
thumbnailsDir: thumbnailsDir,
|
||||
assetDir: assetDir,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||
sessionCookieName: sessionCookieName,
|
||||
sessionMaxAgeMs: sessionMaxAgeMs,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWebConfig };
|
||||
@@ -1,21 +1,24 @@
|
||||
// Helpers for normalizing dashboard state and date values.
|
||||
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) {
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
|
||||
return (screens || []).map(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return Object.assign({}, screen, {
|
||||
client_name: onboardingNameBySlug[screen.slug] || null,
|
||||
player_connection_count: connectionState.count || 0,
|
||||
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
|
||||
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [],
|
||||
player_url: String(playerUrlsBySlug && playerUrlsBySlug[screen.slug] || '').trim() || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) {
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate) {
|
||||
return (screens || []).flatMap(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return (connectionState.connections || []).map(function (connection) {
|
||||
@@ -27,7 +30,7 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
||||
playlist_name: screen.playlist_name || null,
|
||||
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
||||
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
player_url: screen.player_url || String(connection.page || '').trim() || null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +54,6 @@ function createDashboardStateService(options) {
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const playerSnapshotSockets = options && options.playerSnapshotSockets;
|
||||
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
|
||||
if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') {
|
||||
@@ -61,6 +63,9 @@ function createDashboardStateService(options) {
|
||||
async function buildDashboardState() {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const screensData = data.screens || [];
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
});
|
||||
@@ -94,14 +99,14 @@ function createDashboardStateService(options) {
|
||||
}
|
||||
});
|
||||
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug)
|
||||
.map(function (screen) {
|
||||
return Object.assign({}, screen, {
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
player_url: screen.player_url || null
|
||||
});
|
||||
})
|
||||
.sort(compareScreenNames);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate);
|
||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||
return socket && socket.readyState === WebSocket.OPEN;
|
||||
});
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
|
||||
? apiSourceOrId
|
||||
: typeof common.fetchApiSourceById === 'function'
|
||||
? await common.fetchApiSourceById(pool, Number(apiSourceOrId))
|
||||
: null;
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await common.fetchApiSourceResponse(apiUrl);
|
||||
responseDetails = await common.fetchApiSourceResponse(apiSource);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
@@ -13,7 +22,7 @@ async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for dashboard date formatting, uploads, and query parsing.
|
||||
|
||||
const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
// Shared helpers for parsing and sorting web list query state.
|
||||
|
||||
function parsePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function getSearchQuery(req) {
|
||||
return String(req && req.query && req.query.search || '').trim();
|
||||
}
|
||||
|
||||
function getSortQuery(req) {
|
||||
return String(req && req.query && req.query.sort || '').trim();
|
||||
}
|
||||
|
||||
function getSortDirectionQuery(req) {
|
||||
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function normalizeSortDirection(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
@@ -81,7 +95,10 @@ function createSearchMatcher(searchTerm, fields) {
|
||||
|
||||
module.exports = {
|
||||
parsePageNumber: parsePageNumber,
|
||||
getSearchQuery: getSearchQuery,
|
||||
getSortQuery: getSortQuery,
|
||||
normalizeSortDirection: normalizeSortDirection,
|
||||
getSortDirectionQuery: getSortDirectionQuery,
|
||||
getComparableSortValue: getComparableSortValue,
|
||||
compareSortValues: compareSortValues,
|
||||
sortRows: sortRows,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
function createNotifyPlayerScreens(forwardPlayerCommand) {
|
||||
if (typeof forwardPlayerCommand !== 'function') {
|
||||
throw new Error('createNotifyPlayerScreens requires a player command sender.');
|
||||
}
|
||||
|
||||
return function notifyPlayerScreens(slugs, commandOrPayload) {
|
||||
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
|
||||
return String(slug || '').trim();
|
||||
}).filter(Boolean)));
|
||||
|
||||
if (!uniqueSlugs.length) {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
return Promise.allSettled(uniqueSlugs.map(function (slug) {
|
||||
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
|
||||
})).then(function (results) {
|
||||
return results.filter(function (result) {
|
||||
return result.status === 'fulfilled';
|
||||
}).length;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createNotifyPlayerScreens };
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for building paginated list response state.
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
@@ -89,6 +91,15 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
|
||||
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
|
||||
|
||||
let normalizedItemLabel = String(itemLabel || 'items');
|
||||
const total = Number(totalItems) || 0;
|
||||
|
||||
if (total === 1 && normalizedItemLabel.endsWith('s')) {
|
||||
normalizedItemLabel = normalizedItemLabel.slice(0, -1);
|
||||
} else if (total > 1 && !normalizedItemLabel.endsWith('s')) {
|
||||
normalizedItemLabel += 's';
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: safeCurrentPage,
|
||||
totalPages: totalPages,
|
||||
@@ -107,7 +118,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
pages: pages,
|
||||
pageSize: normalizedPageSize,
|
||||
pageParam: normalizedPageParam,
|
||||
itemLabel: String(itemLabel || 'items'),
|
||||
itemLabel: String(normalizedItemLabel || 'item'),
|
||||
ariaLabel: String(ariaLabel || 'Pagination')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const pool = options && options.pool;
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const common = options && options.common;
|
||||
|
||||
if (!playerInternalBaseUrl || !common) {
|
||||
if (!common) {
|
||||
throw new Error('createPlayerActionService requires the player action dependencies.');
|
||||
}
|
||||
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
if (playerInternalBaseUrl) {
|
||||
return playerInternalBaseUrl;
|
||||
}
|
||||
|
||||
if (playerInternalBaseUrlPromise) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
playerInternalBaseUrlPromise = (async function () {
|
||||
if (!pool) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
||||
} catch (_error) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
})().then(function (baseUrl) {
|
||||
playerInternalBaseUrl = baseUrl || null;
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return playerInternalBaseUrl;
|
||||
}, function () {
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
});
|
||||
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
@@ -20,7 +62,12 @@ function createPlayerActionService(options) {
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -42,12 +89,50 @@ function createPlayerActionService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
async function forwardAnnouncementRefresh(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
|
||||
body: { command: 'announcement-refresh' }
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify({ command: 'announcement-refresh' })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
async function getScreenConnections(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -110,6 +195,7 @@ function createPlayerActionService(options) {
|
||||
|
||||
return {
|
||||
forwardPlayerCommand: forwardPlayerCommand,
|
||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||
getScreenConnections: getScreenConnections,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
||||
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
||||
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
||||
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
|
||||
|
||||
function withAssetVersion(scriptPath, assetVersion) {
|
||||
if (!assetVersion) {
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
return scriptPath + (String(scriptPath).indexOf('?') === -1 ? '?v=' : '&v=') + encodeURIComponent(String(assetVersion));
|
||||
}
|
||||
|
||||
function getRegionModuleScripts(assetVersion) {
|
||||
if (!fs.existsSync(REGION_TYPE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(REGION_TYPE_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.js'))
|
||||
.map((entry) => withAssetVersion(`js/regions/type/${entry.name}`, assetVersion))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getRegionEditorScripts(assetVersion) {
|
||||
return REGION_CORE_SCRIPTS.map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRegionModuleScripts,
|
||||
getRegionEditorScripts,
|
||||
withAssetVersion
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const sharp = require('sharp');
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
const {
|
||||
escapeHtml,
|
||||
mediaKind,
|
||||
renderEditorJsContent,
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
sanitizeTextColor
|
||||
} = require('../../player/render-helpers');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const PLAYER_VIEWPORT = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 1
|
||||
};
|
||||
const THUMBNAIL_MAX_SIZE = {
|
||||
width: 480,
|
||||
height: 270
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return String(baseUrl || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function resolveAssetUrl(baseUrl, value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
|
||||
return raw;
|
||||
}
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!normalizedBaseUrl) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return normalizedBaseUrl + raw;
|
||||
}
|
||||
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function getCanvasSize(slide) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
return {
|
||||
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function getRegionContent(slide, region) {
|
||||
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
||||
return content && typeof content === 'object' ? content : { value: content };
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
||||
if (!hasVisibleContent(renderedBody)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||
}
|
||||
|
||||
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
|
||||
if (regionType === 'image') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'video') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'webpage') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'html') {
|
||||
const html = String(rawValue || '').trim();
|
||||
return html
|
||||
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'rtmp') {
|
||||
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
||||
}
|
||||
|
||||
return buildTextRegionMarkup(region, regionContent);
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
if (usingSystemChromium) {
|
||||
return puppeteer.launch({
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
],
|
||||
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
||||
executablePath: executablePath,
|
||||
headless: true
|
||||
});
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: 'shell'
|
||||
});
|
||||
}
|
||||
|
||||
async function captureSlideThumbnail(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
|
||||
const slideId = Number(options && options.slideId || 0);
|
||||
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
|
||||
|
||||
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
|
||||
}
|
||||
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
throw new Error('Slide not found.');
|
||||
}
|
||||
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||
const thumbnailPath = thumbnailRelativePath;
|
||||
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
async function waitForThumbnailRender(page) {
|
||||
await page.waitForFunction(function () {
|
||||
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(function () {
|
||||
var canvas = document.querySelector('.slide-canvas');
|
||||
if (!canvas) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
|
||||
return images.every(function (image) {
|
||||
return image.complete && typeof image.naturalWidth === 'number';
|
||||
});
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.evaluate(async function () {
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch (_error) {
|
||||
// Ignore font readiness failures and fall back to the rendered frame.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluate(function () {
|
||||
return new Promise(function (resolve) {
|
||||
window.requestAnimationFrame(function () {
|
||||
window.requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||
const previewUrl = baseUrl + previewPath;
|
||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: previewPath
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await waitForThumbnailRender(page);
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
}
|
||||
await canvas.screenshot({ path: fullSizePath });
|
||||
} finally {
|
||||
await page.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
await sharp(fullSizePath)
|
||||
.resize({
|
||||
width: THUMBNAIL_MAX_SIZE.width,
|
||||
height: THUMBNAIL_MAX_SIZE.height,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.png()
|
||||
.toFile(thumbnailTempPath);
|
||||
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||
return {
|
||||
slideId: slide.id,
|
||||
thumbnailPath: thumbnailPath,
|
||||
filePath: filePath,
|
||||
fullSizePath: fullSizePath,
|
||||
mediaKind: mediaKind('')
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureSlideThumbnail: captureSlideThumbnail
|
||||
};
|
||||
@@ -1,721 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return path.resolve(String(uploadDir || '').trim());
|
||||
}
|
||||
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
let playerUploadSyncMode = null;
|
||||
let playerUploadSyncModePromise = null;
|
||||
const pendingPlayerUploadSyncs = new Map();
|
||||
let pendingPlayerUploadSyncFlushTimer = null;
|
||||
let pendingPlayerUploadSyncFlushInFlight = null;
|
||||
const pendingPlaylistUploadSyncs = new Map();
|
||||
let pendingPlaylistUploadSyncFlushTimer = null;
|
||||
let pendingPlaylistUploadSyncFlushInFlight = null;
|
||||
|
||||
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
|
||||
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||
}
|
||||
|
||||
function createUploadMiddleware(uploadDir) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (_req, _file, cb) {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (_req, file, cb) {
|
||||
const safeExt = path.extname(file.originalname || '').toLowerCase();
|
||||
const stamp = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
cb(null, `${stamp}${safeExt}`);
|
||||
}
|
||||
});
|
||||
return multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: MAX_UPLOAD_BYTES
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUploadReference(uploadPath) {
|
||||
const value = String(uploadPath || '').trim();
|
||||
if (!value || !value.startsWith('/media/')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getUploadRelativePath(uploadPath) {
|
||||
const value = normalizeUploadReference(uploadPath);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value.replace(/^\/media\//, '');
|
||||
}
|
||||
|
||||
function resolveUploadFilePath(uploadDir, uploadPath) {
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||
if (!normalizedUploadDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaRoot = path.basename(normalizedUploadDir) === 'uploads'
|
||||
? path.dirname(normalizedUploadDir)
|
||||
: normalizedUploadDir;
|
||||
|
||||
if (relativePath.startsWith('uploads/')) {
|
||||
return path.join(mediaRoot, relativePath);
|
||||
}
|
||||
|
||||
return path.join(mediaRoot, relativePath);
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromValue(value, refs) {
|
||||
if (!value) {
|
||||
return refs;
|
||||
}
|
||||
const stack = [value];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
if (Array.isArray(current)) {
|
||||
current.forEach(function (item) {
|
||||
stack.push(item);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (current && typeof current === 'object') {
|
||||
Object.keys(current).forEach(function (key) {
|
||||
stack.push(current[key]);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof current === 'string') {
|
||||
const reference = normalizeUploadReference(current);
|
||||
if (reference) {
|
||||
refs.add(reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromSlide(slide) {
|
||||
const refs = new Set();
|
||||
if (!slide) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromTemplate(template) {
|
||||
const refs = new Set();
|
||||
if (!template) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(template.background_image_path, refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectUploadReferencesFromPayload(payload) {
|
||||
const refs = new Set();
|
||||
if (!payload) {
|
||||
return refs;
|
||||
}
|
||||
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
|
||||
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
async function countUploadReferences(pool, uploadPath) {
|
||||
const [slideRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count
|
||||
FROM c_slides
|
||||
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||
[uploadPath]
|
||||
);
|
||||
const [thumbnailRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
|
||||
[uploadPath]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
[uploadPath]
|
||||
);
|
||||
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
|
||||
}
|
||||
|
||||
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
|
||||
const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniquePaths.length; i += 1) {
|
||||
const uploadPath = uniquePaths[i];
|
||||
const referenceCount = await countUploadReferences(pool, uploadPath);
|
||||
if (referenceCount > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
console.warn('Unable to remove unused upload file:', filePath, error);
|
||||
}
|
||||
}
|
||||
queuePlayerUploadSync({
|
||||
type: 'delete',
|
||||
uploadPath: uploadPath,
|
||||
uploadDir: uploadDir
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectUploadPathsFromDirectory(uploadDir) {
|
||||
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||
if (!normalizedUploadDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const uploadPaths = [];
|
||||
|
||||
async function walkDirectory(currentDir, relativeDir) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
console.warn('Unable to read upload directory:', currentDir, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryName = String(entry && entry.name || '').trim();
|
||||
if (!entryName || entryName === '.' || entryName === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
||||
const nextAbsolutePath = path.join(currentDir, entryName);
|
||||
|
||||
if (entry.isDirectory && entry.isDirectory()) {
|
||||
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile && !entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
await walkDirectory(normalizedUploadDir, '');
|
||||
return uploadPaths;
|
||||
}
|
||||
|
||||
async function getPlayerUploadSyncMode(localUploadDir) {
|
||||
if (playerUploadSyncMode) {
|
||||
return playerUploadSyncMode;
|
||||
}
|
||||
if (playerUploadSyncModePromise) {
|
||||
return playerUploadSyncModePromise;
|
||||
}
|
||||
|
||||
playerUploadSyncModePromise = (async function () {
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
|
||||
if (!playerUploadDir) {
|
||||
return null;
|
||||
}
|
||||
return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different';
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
})().then(function (mode) {
|
||||
if (mode) {
|
||||
playerUploadSyncMode = mode;
|
||||
}
|
||||
playerUploadSyncModePromise = null;
|
||||
return mode;
|
||||
}, function () {
|
||||
playerUploadSyncModePromise = null;
|
||||
return null;
|
||||
});
|
||||
|
||||
return playerUploadSyncModePromise;
|
||||
}
|
||||
|
||||
async function shouldMirrorUploads(localUploadDir) {
|
||||
return Boolean(localUploadDir);
|
||||
}
|
||||
|
||||
function queuePlayerUploadSync(operation) {
|
||||
if (!operation || !operation.uploadPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
|
||||
type: operation.type === 'delete' ? 'delete' : 'put',
|
||||
uploadPath: normalizeUploadReference(operation.uploadPath),
|
||||
uploadDir: operation.uploadDir || null
|
||||
});
|
||||
|
||||
schedulePendingPlayerUploadSyncFlush();
|
||||
}
|
||||
|
||||
function schedulePendingPlayerUploadSyncFlush() {
|
||||
if (pendingPlayerUploadSyncFlushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPlayerUploadSyncFlushTimer = setTimeout(function () {
|
||||
pendingPlayerUploadSyncFlushTimer = null;
|
||||
flushPendingPlayerUploadSyncs().catch(function (error) {
|
||||
console.warn('Unable to flush pending upload syncs:', error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||
if (!relativePath || !sourcePath) {
|
||||
return false;
|
||||
}
|
||||
let fileBuffer = null;
|
||||
try {
|
||||
fileBuffer = await fs.promises.readFile(sourcePath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
console.warn('Unable to read upload for player sync:', sourcePath, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'PUT',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...authHeaders
|
||||
},
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = getUploadRelativePath(uploadPath);
|
||||
if (!relativePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
|
||||
if (!(await shouldMirrorUploads(localUploadDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
|
||||
if (!success) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uniqueRefs[i],
|
||||
uploadDir: localUploadDir
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
|
||||
return queueMediaSyncTask('media-sync:initial', 'Initial media sync', {
|
||||
mode: 'initial',
|
||||
uploadDir: localUploadDir
|
||||
});
|
||||
}
|
||||
|
||||
function getVisibleCurrentSlideIds() {
|
||||
const visibleSlideIds = new Set();
|
||||
playerSnapshotCache.forEach(function (snapshot) {
|
||||
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
|
||||
connections.forEach(function (connection) {
|
||||
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
|
||||
? connection.currentSlide
|
||||
: null;
|
||||
const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
|
||||
? String(currentSlide.id).trim()
|
||||
: '';
|
||||
if (slideId) {
|
||||
visibleSlideIds.add(slideId);
|
||||
}
|
||||
});
|
||||
});
|
||||
return visibleSlideIds;
|
||||
}
|
||||
|
||||
function isScreenRefreshBlocked(screenSlug, blockedSlideIds, screenSlideCounts) {
|
||||
const slideIds = Array.isArray(blockedSlideIds)
|
||||
? blockedSlideIds.map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
if (!slideIds.length) {
|
||||
return false;
|
||||
}
|
||||
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||
const slideCount = screenSlideCounts && Object.prototype.hasOwnProperty.call(screenSlideCounts, normalizedScreenSlug)
|
||||
? Number(screenSlideCounts[normalizedScreenSlug])
|
||||
: null;
|
||||
if (Number.isFinite(slideCount) && slideCount <= 1) {
|
||||
return false;
|
||||
}
|
||||
const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim());
|
||||
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
|
||||
return connections.some(function (connection) {
|
||||
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
|
||||
? connection.currentSlide
|
||||
: null;
|
||||
const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
|
||||
? String(currentSlide.id).trim()
|
||||
: '';
|
||||
return currentSlideId && slideIds.includes(currentSlideId);
|
||||
});
|
||||
}
|
||||
|
||||
function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds, screenSlideCounts) {
|
||||
const ready = [];
|
||||
const blocked = [];
|
||||
Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) {
|
||||
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||
if (!normalizedScreenSlug) {
|
||||
return;
|
||||
}
|
||||
if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds, screenSlideCounts)) {
|
||||
blocked.push(normalizedScreenSlug);
|
||||
} else {
|
||||
ready.push(normalizedScreenSlug);
|
||||
}
|
||||
});
|
||||
return { ready: ready, blocked: blocked };
|
||||
}
|
||||
|
||||
function normalizePlaylistUploadSyncOperation(options) {
|
||||
return {
|
||||
key: String(options && options.key ? options.key : '').trim(),
|
||||
pool: options && options.pool ? options.pool : null,
|
||||
localUploadDir: options && options.localUploadDir ? options.localUploadDir : null,
|
||||
previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])),
|
||||
nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])),
|
||||
blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean),
|
||||
refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean)
|
||||
,
|
||||
screenSlideCounts: options && options.screenSlideCounts && typeof options.screenSlideCounts === 'object'
|
||||
? options.screenSlideCounts
|
||||
: {}
|
||||
};
|
||||
}
|
||||
|
||||
function queuePlaylistUploadSync(operation) {
|
||||
if (!operation || !operation.key) {
|
||||
return;
|
||||
}
|
||||
pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation));
|
||||
schedulePendingPlaylistUploadSyncFlush();
|
||||
}
|
||||
|
||||
function schedulePendingPlaylistUploadSyncFlush() {
|
||||
if (pendingPlaylistUploadSyncFlushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPlaylistUploadSyncFlushTimer = setTimeout(function () {
|
||||
pendingPlaylistUploadSyncFlushTimer = null;
|
||||
flushPendingPlaylistUploadSyncs().catch(function (error) {
|
||||
console.warn('Unable to flush pending playlist upload syncs:', error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function syncPlaylistUploadsOnChange(options) {
|
||||
const operation = normalizePlaylistUploadSyncOperation(options);
|
||||
if (!operation.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
||||
mode: 'playlist',
|
||||
operation: operation
|
||||
});
|
||||
}
|
||||
|
||||
async function flushPendingPlaylistUploadSyncs() {
|
||||
if (pendingPlaylistUploadSyncFlushInFlight) {
|
||||
return pendingPlaylistUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
if (!pendingPlaylistUploadSyncs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingPlaylistUploadSyncFlushInFlight = (async function () {
|
||||
const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values());
|
||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||
const operation = pendingEntries[i];
|
||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||
if (!refreshTargets.ready.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (refreshTargets.ready.length) {
|
||||
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||
}
|
||||
|
||||
pendingPlaylistUploadSyncs.delete(operation.key);
|
||||
}
|
||||
})().finally(function () {
|
||||
pendingPlaylistUploadSyncFlushInFlight = null;
|
||||
if (pendingPlaylistUploadSyncs.size) {
|
||||
schedulePendingPlaylistUploadSyncFlush();
|
||||
}
|
||||
});
|
||||
|
||||
return pendingPlaylistUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
async function flushPendingPlayerUploadSyncs() {
|
||||
if (pendingPlayerUploadSyncFlushInFlight) {
|
||||
return pendingPlayerUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
if (!pendingPlayerUploadSyncs.size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingPlayerUploadSyncFlushInFlight = (async function () {
|
||||
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
|
||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||
const operation = pendingEntries[i];
|
||||
let success = false;
|
||||
if (operation.type === 'delete') {
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
|
||||
} else {
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
|
||||
}
|
||||
if (success) {
|
||||
pendingPlayerUploadSyncs.delete(operation.uploadPath);
|
||||
}
|
||||
}
|
||||
})().finally(function () {
|
||||
pendingPlayerUploadSyncFlushInFlight = null;
|
||||
if (pendingPlayerUploadSyncs.size) {
|
||||
schedulePendingPlayerUploadSyncFlush();
|
||||
}
|
||||
});
|
||||
|
||||
return pendingPlayerUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
async function runMediaSyncTask(payload) {
|
||||
const taskPayload = payload || {};
|
||||
const mode = String(taskPayload.mode || '').trim();
|
||||
|
||||
if (mode === 'initial') {
|
||||
const uploadDir = String(taskPayload.uploadDir || '').trim();
|
||||
if (!(await shouldMirrorUploads(uploadDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const uploadRefs = new Set();
|
||||
(data.slides || []).forEach(function (slide) {
|
||||
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
(data.templates || []).forEach(function (template) {
|
||||
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uploadPath,
|
||||
uploadDir: uploadDir
|
||||
});
|
||||
});
|
||||
await flushPendingPlayerUploadSyncs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'playlist') {
|
||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
if (operation.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
return !nextUploadRefSet.has(reference);
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.refreshScreenSlugs.length) {
|
||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||
if (refreshTargets.ready.length) {
|
||||
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||
}
|
||||
refreshTargets.blocked.forEach(function (screenSlug) {
|
||||
queuePlaylistUploadSync({
|
||||
key: operation.key + ':refresh:' + screenSlug,
|
||||
blockedSlideIds: operation.blockedSlideIds,
|
||||
refreshScreenSlugs: [screenSlug]
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Unknown media sync task mode.');
|
||||
}
|
||||
|
||||
async function queueMediaSyncTask(taskKey, title, payload) {
|
||||
const safePayload = Object.assign({}, payload || {});
|
||||
delete safePayload.pool;
|
||||
if (safePayload.operation && typeof safePayload.operation === 'object') {
|
||||
safePayload.operation = Object.assign({}, safePayload.operation);
|
||||
delete safePayload.operation.pool;
|
||||
}
|
||||
|
||||
const definition = {
|
||||
key: taskKey,
|
||||
title: title,
|
||||
category: 'media-sync',
|
||||
taskType: 'media-sync',
|
||||
payload: safePayload,
|
||||
persist: true
|
||||
};
|
||||
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTaskAndWait === 'function') {
|
||||
return backgroundTaskQueue.enqueueTaskAndWait(definition);
|
||||
}
|
||||
|
||||
return runMediaSyncTask(safePayload);
|
||||
}
|
||||
|
||||
return {
|
||||
createUploadMiddleware: createUploadMiddleware,
|
||||
normalizeUploadReference: normalizeUploadReference,
|
||||
collectUploadReferencesFromValue: collectUploadReferencesFromValue,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
countUploadReferences: countUploadReferences,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
||||
shouldMirrorUploads: shouldMirrorUploads,
|
||||
queuePlayerUploadSync: queuePlayerUploadSync,
|
||||
schedulePendingPlayerUploadSyncFlush: schedulePendingPlayerUploadSyncFlush,
|
||||
pushUploadFileToPlayer: pushUploadFileToPlayer,
|
||||
removeUploadFileFromPlayer: removeUploadFileFromPlayer,
|
||||
syncUploadRefsToPlayer: syncUploadRefsToPlayer,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
getVisibleCurrentSlideIds: getVisibleCurrentSlideIds,
|
||||
isScreenRefreshBlocked: isScreenRefreshBlocked,
|
||||
splitRefreshScreenSlugsByVisibility: splitRefreshScreenSlugsByVisibility,
|
||||
normalizePlaylistUploadSyncOperation: normalizePlaylistUploadSyncOperation,
|
||||
queuePlaylistUploadSync: queuePlaylistUploadSync,
|
||||
schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
|
||||
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
queueMediaSyncTask: queueMediaSyncTask
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createUploadSyncService };
|
||||
@@ -0,0 +1,34 @@
|
||||
async function initializeWebServer(options) {
|
||||
const common = options && options.common;
|
||||
const pool = options && options.pool;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const webBootstrap = options && options.webBootstrap;
|
||||
const loadCurrentUser = options && options.loadCurrentUser;
|
||||
const initializeBackgroundTasks = options && options.initializeBackgroundTasks;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
const server = options && options.server;
|
||||
|
||||
if (!common || !pool || !mediaDir || !backgroundTaskQueue || !webBootstrap || typeof loadCurrentUser !== 'function' || typeof initializeBackgroundTasks !== 'function' || typeof captureSlideThumbnail !== 'function' || !server) {
|
||||
throw new Error('initializeWebServer requires the web startup dependencies.');
|
||||
}
|
||||
|
||||
await common.ensureSchema(pool, { mediaDir: mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
await backgroundTaskQueue.initialize();
|
||||
|
||||
await initializeBackgroundTasks({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
uploadSyncService: webBootstrap.uploadSyncService,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
mediaDir: mediaDir,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
});
|
||||
|
||||
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
|
||||
}
|
||||
|
||||
module.exports = { initializeWebServer };
|
||||
@@ -0,0 +1,38 @@
|
||||
// Shared web middleware for auth, media, and upload handling.
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
|
||||
module.exports = function registerMiddleware(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const loadCurrentUser = deps.loadCurrentUser;
|
||||
const requireAuth = deps.requireAuth;
|
||||
const mediaDir = deps.MEDIA_DIR;
|
||||
const uploadsDir = deps.UPLOADS_DIR;
|
||||
const thumbnailsDir = deps.THUMBNAILS_DIR;
|
||||
const assetDir = deps.ASSET_DIR;
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
fs.mkdirSync(thumbnailsDir, { recursive: true });
|
||||
|
||||
app.use(async function (req, _res, next) {
|
||||
try {
|
||||
req.currentUser = await loadCurrentUser(pool, req);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// Error page renderer for the web UI.
|
||||
|
||||
const { renderView } = require('../view');
|
||||
|
||||
function getErrorCopy(statusCode, message) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Page route-to-view mapping for the web UI.
|
||||
|
||||
const path = require('path');
|
||||
|
||||
function routePath(...segments) {
|
||||
@@ -25,6 +27,9 @@ module.exports = {
|
||||
renderScreensPage: require(routePath('signage', 'screens', 'list')),
|
||||
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
|
||||
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
|
||||
renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')),
|
||||
renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')),
|
||||
renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')),
|
||||
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
|
||||
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
|
||||
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
|
||||
@@ -33,6 +38,7 @@ module.exports = {
|
||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
||||
renderFontsPage: require(routePath('settings', 'fonts', 'list')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
|
||||
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
|
||||
renderErrorPage: require('./error'),
|
||||
|
||||
+433
-228
@@ -180,6 +180,141 @@
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.announcement-icon-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-height: calc(1.5em + 0.75rem + 2px);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(var(--bs-primary-rgb), 0.1);
|
||||
color: var(--bs-primary);
|
||||
font-size: 1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__caret {
|
||||
flex: 0 0 auto;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 0.5rem);
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
width: min(100%, 28rem);
|
||||
max-width: calc(100vw - 1rem);
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 1rem;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__menu.is-open-above {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option:hover,
|
||||
.announcement-icon-picker__option:focus-visible {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.7);
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option.is-selected {
|
||||
border-color: var(--bs-primary);
|
||||
background: rgba(var(--bs-primary-rgb), 0.12);
|
||||
color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option i {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.announcement-icon-picker__menu {
|
||||
width: calc(100vw - 2rem);
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.fonts-table-responsive {
|
||||
border-bottom-left-radius: var(--bs-card-border-radius);
|
||||
border-bottom-right-radius: var(--bs-card-border-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.background-tasks-task-tools {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
@@ -194,6 +329,69 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.background-tasks-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-pagination-page .app-main {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.table-pagination-page .app-content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-pagination-page .app-content .container-fluid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.background-tasks-page .app-content .container-fluid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.background-tasks-page .app-main {
|
||||
height: 100%;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.background-tasks-page .app-content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-table-pagination-card] {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: var(--table-pagination-card-max-height, var(--background-tasks-task-card-max-height, calc(100dvh - 12rem)));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-table-pagination-card] > .card-body.table-responsive {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[data-table-pagination-card] > .card-footer {
|
||||
margin-top: auto;
|
||||
background: var(--bs-body-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.admin-form-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -202,6 +400,20 @@
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.api-source-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(13, 110, 253, 0.08), rgba(32, 201, 151, 0.08));
|
||||
@@ -468,6 +680,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip[data-time-date-placeholder-chip] {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
@@ -532,281 +749,179 @@ td[data-label="Slides"] {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-editor,
|
||||
.template-field-card .ck.ck-editor {
|
||||
--ck-custom-background: var(--bs-body-bg);
|
||||
--ck-custom-foreground: var(--bs-body-color);
|
||||
--ck-custom-border: var(--bs-border-color);
|
||||
--ck-color-base-foreground: var(--bs-tertiary-bg);
|
||||
--ck-color-base-background: var(--bs-body-bg);
|
||||
--ck-color-base-border: var(--bs-border-color);
|
||||
--ck-color-base-text: var(--bs-body-color);
|
||||
--ck-color-base-action: var(--bs-primary);
|
||||
--ck-color-base-focus: var(--bs-primary);
|
||||
--ck-color-base-active: var(--bs-primary);
|
||||
--ck-color-base-active-focus: var(--bs-primary);
|
||||
--ck-color-base-error: var(--bs-danger);
|
||||
--ck-color-focus-border: rgba(var(--bs-primary-rgb), 1);
|
||||
--ck-color-focus-outer-shadow: rgba(var(--bs-primary-rgb), 0.18);
|
||||
--ck-color-focus-disabled-shadow: rgba(var(--bs-secondary-rgb), 0.14);
|
||||
--ck-color-focus-error-shadow: rgba(var(--bs-danger-rgb), 0.18);
|
||||
--ck-color-shadow-drop: rgba(0, 0, 0, 0.16);
|
||||
--ck-color-shadow-drop-active: rgba(0, 0, 0, 0.22);
|
||||
--ck-color-shadow-inner: rgba(0, 0, 0, 0.08);
|
||||
--ck-color-button-default-background: transparent;
|
||||
--ck-color-button-default-hover-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-default-active-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-default-disabled-background: transparent;
|
||||
--ck-color-button-on-background: rgba(var(--bs-primary-rgb), 0.12);
|
||||
--ck-color-button-on-hover-background: rgba(var(--bs-primary-rgb), 0.16);
|
||||
--ck-color-button-on-active-background: rgba(var(--bs-primary-rgb), 0.16);
|
||||
--ck-color-button-on-disabled-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-on-color: var(--bs-primary);
|
||||
--ck-color-button-action-background: var(--bs-primary);
|
||||
--ck-color-button-action-hover-background: var(--bs-primary);
|
||||
--ck-color-button-action-active-background: var(--bs-primary);
|
||||
--ck-color-button-action-disabled-background: var(--bs-primary);
|
||||
--ck-color-button-action-text: var(--bs-body-bg);
|
||||
--ck-color-switch-button-off-background: var(--bs-secondary-color);
|
||||
--ck-color-switch-button-off-hover-background: var(--bs-secondary-color);
|
||||
--ck-color-switch-button-on-background: var(--bs-primary);
|
||||
--ck-color-switch-button-on-hover-background: var(--bs-primary);
|
||||
--ck-color-switch-button-inner-background: var(--bs-body-bg);
|
||||
--ck-color-switch-button-inner-shadow: rgba(0, 0, 0, 0.08);
|
||||
--ck-color-dropdown-panel-background: var(--bs-body-bg);
|
||||
--ck-color-dropdown-panel-border: var(--bs-border-color);
|
||||
--ck-color-dialog-background: var(--bs-body-bg);
|
||||
--ck-color-dialog-form-header-border: var(--bs-border-color);
|
||||
--ck-color-list-button-on-background: rgba(var(--bs-primary-rgb), 0.12);
|
||||
--ck-color-list-button-on-text: var(--bs-primary);
|
||||
--ck-color-input-background: var(--bs-body-bg);
|
||||
--ck-color-input-border: var(--bs-border-color);
|
||||
--ck-color-input-text: var(--bs-body-color);
|
||||
--ck-color-input-disabled-background: var(--bs-secondary-bg);
|
||||
--ck-color-input-disabled-border: var(--bs-border-color);
|
||||
--ck-color-input-disabled-text: var(--bs-secondary-color);
|
||||
--ck-color-toolbar-background: var(--bs-tertiary-bg);
|
||||
--ck-color-toolbar-border: var(--bs-border-color);
|
||||
--ck-color-toolbar-box-shadow: none;
|
||||
--ck-color-image-caption-background: var(--bs-secondary-bg);
|
||||
--ck-color-image-caption-text: var(--bs-secondary-color);
|
||||
--ck-color-resizer: var(--bs-primary);
|
||||
--ck-color-resizer-tooltip-background: var(--bs-dark);
|
||||
--ck-color-resizer-tooltip-text: var(--bs-light);
|
||||
border-radius: var(--bs-border-radius);
|
||||
overflow: hidden;
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
.slide-form-stack .editor-holder,
|
||||
.template-field-card .editor-holder {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-editor__top .ck-sticky-panel__content,
|
||||
.template-field-card .ck.ck-editor__top .ck-sticky-panel__content {
|
||||
.slide-form-stack .editor-holder textarea,
|
||||
.template-field-card .editor-holder textarea {
|
||||
width: 100%;
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.slide-form-stack .tox,
|
||||
.template-field-card .tox {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
overflow: visible !important;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.slide-form-stack .tox.tox-tinymce,
|
||||
.template-field-card .tox.tox-tinymce {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.slide-form-stack .tox.tox-edit-focus,
|
||||
.template-field-card .tox.tox-edit-focus {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.22) !important;
|
||||
box-shadow: 0 0 0 0.05rem rgba(var(--bs-primary-rgb), 0.05) !important;
|
||||
}
|
||||
|
||||
.slide-form-stack .tox .tox-edit-area::before,
|
||||
.template-field-card .tox .tox-edit-area::before {
|
||||
border: 1px solid rgba(var(--bs-primary-rgb), 0.08) !important;
|
||||
}
|
||||
|
||||
.slide-form-stack .tox.tox-edit-focus .tox-edit-area::before,
|
||||
.template-field-card .tox.tox-edit-focus .tox-edit-area::before {
|
||||
opacity: 0.35 !important;
|
||||
}
|
||||
|
||||
.slide-form-stack .tox .tox-editor-header,
|
||||
.template-field-card .tox .tox-editor-header {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar,
|
||||
.template-field-card .ck.ck-toolbar {
|
||||
.slide-form-stack .tox .tox-toolbar,
|
||||
.template-field-card .tox .tox-toolbar {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-toolbar__items,
|
||||
.template-field-card .ck.ck-toolbar .ck-toolbar__items {
|
||||
gap: 0.15rem;
|
||||
.slide-form-stack .tox .tox-toolbar__group,
|
||||
.template-field-card .tox .tox-toolbar__group {
|
||||
border-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-button,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-dropdown__button,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action,
|
||||
.template-field-card .ck.ck-toolbar .ck-button,
|
||||
.template-field-card .ck.ck-toolbar .ck-dropdown__button,
|
||||
.template-field-card .ck.ck-toolbar .ck-splitbutton__action {
|
||||
border-radius: 0.5rem;
|
||||
.slide-form-stack .tox .tox-tbtn,
|
||||
.template-field-card .tox .tox-tbtn {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-button:hover,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-dropdown__button:hover,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action:hover,
|
||||
.template-field-card .ck.ck-toolbar .ck-button:hover,
|
||||
.template-field-card .ck.ck-toolbar .ck-dropdown__button:hover,
|
||||
.template-field-card .ck.ck-toolbar .ck-splitbutton__action:hover {
|
||||
.slide-form-stack .tox .tox-tbtn:hover,
|
||||
.template-field-card .tox .tox-tbtn:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-button.ck-on,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-dropdown__button.ck-on,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action.ck-on,
|
||||
.template-field-card .ck.ck-toolbar .ck-button.ck-on,
|
||||
.template-field-card .ck.ck-toolbar .ck-dropdown__button.ck-on,
|
||||
.template-field-card .ck.ck-toolbar .ck-splitbutton__action.ck-on {
|
||||
.slide-form-stack .tox .tox-tbtn--enabled,
|
||||
.template-field-card .tox .tox-tbtn--enabled {
|
||||
background: rgba(var(--bs-primary-rgb), 0.12);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-button.ck-disabled,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-dropdown__button.ck-disabled,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action.ck-disabled,
|
||||
.template-field-card .ck.ck-toolbar .ck-button.ck-disabled,
|
||||
.template-field-card .ck.ck-toolbar .ck-dropdown__button.ck-disabled,
|
||||
.template-field-card .ck.ck-toolbar .ck-splitbutton__action.ck-disabled {
|
||||
.slide-form-stack .tox .tox-tbtn--disabled,
|
||||
.template-field-card .tox .tox-tbtn--disabled {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-font-size-input,
|
||||
.template-field-card .ck.ck-toolbar .ck-font-size-input {
|
||||
flex: 0 0 5% !important;
|
||||
width: 5% !important;
|
||||
max-width: 5% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-dropdown__panel,
|
||||
.slide-form-stack .ck.ck-list__panel,
|
||||
.slide-form-stack .ck.ck-balloon-panel,
|
||||
.template-field-card .ck.ck-dropdown__panel,
|
||||
.template-field-card .ck.ck-list__panel,
|
||||
.template-field-card .ck.ck-balloon-panel {
|
||||
border-color: var(--bs-border-color);
|
||||
box-shadow: var(--bs-box-shadow);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-list__item .ck-button,
|
||||
.template-field-card .ck.ck-list__item .ck-button {
|
||||
.slide-form-stack .tox .tox-edit-area__iframe,
|
||||
.template-field-card .tox .tox-edit-area__iframe {
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-list__item .ck-button:hover,
|
||||
.template-field-card .ck.ck-list__item .ck-button:hover {
|
||||
.slide-form-stack .tox .tox-edit-area,
|
||||
.template-field-card .tox .tox-edit-area {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.slide-form-stack .tox .tox-edit-area__iframe,
|
||||
.template-field-card .tox .tox-edit-area__iframe {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.slide-form-stack .tox .tox-statusbar,
|
||||
.template-field-card .tox .tox-statusbar {
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.slide-form-stack .tox .tox-menu,
|
||||
.template-field-card .tox .tox-menu,
|
||||
.slide-form-stack .tox .tox-collection,
|
||||
.template-field-card .tox .tox-collection {
|
||||
background: var(--bs-body-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .tox .tox-collection__item,
|
||||
.template-field-card .tox .tox-collection__item {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .tox .tox-collection__item:hover,
|
||||
.template-field-card .tox .tox-collection__item:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-dropdown__panel,
|
||||
.slide-form-stack .ck.ck-list__panel,
|
||||
.slide-form-stack .ck.ck-balloon-panel,
|
||||
.slide-form-stack .ck.ck-toolbar .ck-dropdown__panel,
|
||||
.template-field-card .ck.ck-dropdown__panel,
|
||||
.template-field-card .ck.ck-list__panel,
|
||||
.template-field-card .ck.ck-balloon-panel,
|
||||
.template-field-card .ck.ck-toolbar .ck-dropdown__panel {
|
||||
.slide-form-stack .tox .tox-input,
|
||||
.template-field-card .tox .tox-input {
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-input-text,
|
||||
.slide-form-stack .ck.ck-list__item-text,
|
||||
.slide-form-stack .ck.ck-list__item-label,
|
||||
.slide-form-stack .ck.ck-dropdown__panel .ck-button__label,
|
||||
.template-field-card .ck.ck-input-text,
|
||||
.template-field-card .ck.ck-list__item-text,
|
||||
.template-field-card .ck.ck-list__item-label,
|
||||
.template-field-card .ck.ck-dropdown__panel .ck-button__label {
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-input-text,
|
||||
.template-field-card .ck.ck-input-text {
|
||||
background: var(--bs-body-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-input-text:focus,
|
||||
.template-field-card .ck.ck-input-text:focus {
|
||||
.slide-form-stack .tox .tox-input:focus,
|
||||
.template-field-card .tox .tox-input:focus {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-editor__main > .ck-editor__editable,
|
||||
.slide-form-stack .ck.ck-editor__editable_inline,
|
||||
.template-field-card .ck.ck-editor__main > .ck-editor__editable,
|
||||
.template-field-card .ck.ck-editor__editable_inline {
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
border: 0;
|
||||
.slide-form-stack .tox .tox-edit-area__iframe,
|
||||
.template-field-card .tox .tox-edit-area__iframe {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-editor__main > .ck-editor__editable.ck-focused,
|
||||
.slide-form-stack .ck.ck-editor__editable_inline.ck-focused,
|
||||
.template-field-card .ck.ck-editor__main > .ck-editor__editable.ck-focused,
|
||||
.template-field-card .ck.ck-editor__editable_inline.ck-focused {
|
||||
box-shadow: inset 0 0 0 1px var(--bs-primary), 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-editor__editable_inline,
|
||||
.template-field-card .ck-editor__editable_inline {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-content,
|
||||
.template-field-card .ck-content {
|
||||
.slide-form-stack .tox-content,
|
||||
.template-field-card .tox-content {
|
||||
font-family: inherit;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-content h1,
|
||||
.slide-form-stack .ck-content h2,
|
||||
.slide-form-stack .ck-content h3,
|
||||
.slide-form-stack .ck-content h4,
|
||||
.slide-form-stack .ck-content h5,
|
||||
.slide-form-stack .ck-content h6,
|
||||
.template-field-card .ck-content h1,
|
||||
.template-field-card .ck-content h2,
|
||||
.template-field-card .ck-content h3,
|
||||
.template-field-card .ck-content h4,
|
||||
.template-field-card .ck-content h5,
|
||||
.template-field-card .ck-content h6 {
|
||||
.slide-form-stack .tox-content h1,
|
||||
.slide-form-stack .tox-content h2,
|
||||
.slide-form-stack .tox-content h3,
|
||||
.slide-form-stack .tox-content h4,
|
||||
.slide-form-stack .tox-content h5,
|
||||
.slide-form-stack .tox-content h6,
|
||||
.template-field-card .tox-content h1,
|
||||
.template-field-card .tox-content h2,
|
||||
.template-field-card .tox-content h3,
|
||||
.template-field-card .tox-content h4,
|
||||
.template-field-card .tox-content h5,
|
||||
.template-field-card .tox-content h6 {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-content p,
|
||||
.template-field-card .ck-content p {
|
||||
.slide-form-stack .tox-content p,
|
||||
.template-field-card .tox-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__panel,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-balloon-panel {
|
||||
html[data-bs-theme='dark'] .slide-form-stack .tox,
|
||||
html[data-bs-theme='dark'] .template-field-card .tox,
|
||||
html[data-bs-theme='dark'] .slide-form-stack .tox .tox-menu,
|
||||
html[data-bs-theme='dark'] .template-field-card .tox .tox-menu,
|
||||
html[data-bs-theme='dark'] .slide-form-stack .tox .tox-collection,
|
||||
html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
background: var(--bs-body-bg) !important;
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-text,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button:hover {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
|
||||
.slide-preview-dimensions-chip {
|
||||
display: inline-flex;
|
||||
@@ -1137,7 +1252,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
|
||||
.template-designer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||
grid-template-columns: minmax(0, 1fr) 25rem;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -1435,7 +1550,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
}
|
||||
|
||||
.template-field-card {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.template-field-card .card-header,
|
||||
@@ -1444,11 +1560,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
border-bottom: 1px solid var(--bs-border-color-translucent);
|
||||
}
|
||||
|
||||
.template-field-card .card-body,
|
||||
.region-item .card-body {
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.template-field-card label,
|
||||
.region-item label {
|
||||
margin-bottom: 0;
|
||||
@@ -1465,11 +1576,105 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template-field-card .ckeditor-holder {
|
||||
.template-field-card .editor-holder {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .ck-editor__editable_inline {
|
||||
.announcement-color-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 5.5rem;
|
||||
height: calc(2.375rem + 2px);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.announcement-icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 5.5rem;
|
||||
height: calc(2.375rem + 2px);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: var(--bs-tertiary-bg);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.announcement-list-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.api-region-placeholder-section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.api-region-placeholder-title {
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.api-region-sample-accordion {
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.85rem;
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.api-region-sample-accordion > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.api-region-sample-accordion > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-region-sample-body {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.api-region-sample-meta {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.api-region-sample-preview {
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color-translucent);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .tox-edit-area__iframe {
|
||||
min-height: 32rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,37 @@
|
||||
}
|
||||
|
||||
function initAsyncCommandForms() {
|
||||
function getAnnouncementActionState(actionPath) {
|
||||
var isPlay = /\/play$/.test(actionPath);
|
||||
return {
|
||||
nextActionPath: actionPath.replace(/\/(?:play|stop)$/, isPlay ? '/stop' : '/play'),
|
||||
nextLabel: isPlay ? 'Stop' : 'Play',
|
||||
nextIcon: isPlay ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
nextClassName: isPlay ? 'btn-outline-warning' : 'btn-outline-success',
|
||||
nextConfirmMessage: isPlay
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?'
|
||||
};
|
||||
}
|
||||
|
||||
function updateAnnouncementActionButton(form, actionPath) {
|
||||
if (!form) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var state = getAnnouncementActionState(actionPath);
|
||||
var visibleButton = form.querySelector('button[type="submit"]') || (form.id ? document.querySelector('button[form="' + form.id + '"]') : null);
|
||||
if (!visibleButton) {
|
||||
return false;
|
||||
}
|
||||
|
||||
form.action = state.nextActionPath;
|
||||
form.setAttribute('data-confirm-message', state.nextConfirmMessage);
|
||||
visibleButton.className = visibleButton.className.replace(/btn-outline-(success|warning)/g, state.nextClassName);
|
||||
visibleButton.innerHTML = '<i class="bi ' + state.nextIcon + ' me-1" aria-hidden="true"></i>' + state.nextLabel;
|
||||
return true;
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
|
||||
@@ -97,6 +128,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var actionPath = '';
|
||||
try {
|
||||
actionPath = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionPath = String(form.action || '');
|
||||
}
|
||||
var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath);
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
form.dataset.busy = 'true';
|
||||
@@ -116,6 +155,11 @@
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (shouldReloadAfterSuccess && response && response.ok) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
@@ -426,6 +470,52 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderJsonPrimitive(value) {
|
||||
if (value === null) {
|
||||
return '<span class="text-body-secondary">null</span>';
|
||||
}
|
||||
if (value === true || value === false) {
|
||||
return '<span class="text-success">' + String(value) + '</span>';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return '<span class="text-info">' + escapeHtml(String(value)) + '</span>';
|
||||
}
|
||||
return '<span class="text-body">"' + escapeHtml(String(value)) + '"</span>';
|
||||
}
|
||||
|
||||
function renderJsonNode(key, value, isRoot) {
|
||||
var isArray = Array.isArray(value);
|
||||
var isObject = value && typeof value === 'object' && !isArray;
|
||||
|
||||
if (!isArray && !isObject) {
|
||||
var primitiveLabel = isRoot ? 'value' : String(key);
|
||||
return '<div class="json-tree-leaf"><span class="json-tree-key">' + escapeHtml(primitiveLabel) + '</span><span class="mx-1">:</span>' + renderJsonPrimitive(value) + '</div>';
|
||||
}
|
||||
|
||||
var entries = isArray
|
||||
? value.map(function (entry, index) {
|
||||
return renderJsonNode('[' + index + ']', entry, false);
|
||||
}).join('')
|
||||
: Object.keys(value).map(function (childKey) {
|
||||
return renderJsonNode(childKey, value[childKey], false);
|
||||
}).join('');
|
||||
|
||||
var summaryLabel = isRoot ? (isArray ? 'Array' : 'Object') : String(key);
|
||||
var summaryMeta = isArray ? '[' + value.length + ']' : '{' + Object.keys(value).length + '}';
|
||||
return '' +
|
||||
'<details class="json-tree-node" open>' +
|
||||
'<summary><span class="json-tree-key">' + escapeHtml(summaryLabel) + '</span><span class="mx-1">:</span><span class="text-body-secondary">' + escapeHtml(summaryMeta) + '</span></summary>' +
|
||||
'<div class="ms-3 ps-3 border-start">' + entries + '</div>' +
|
||||
'</details>';
|
||||
}
|
||||
|
||||
function renderJsonTree(value) {
|
||||
if (Array.isArray(value) || (value && typeof value === 'object')) {
|
||||
return renderJsonNode('', value, true);
|
||||
}
|
||||
return renderJsonNode('value', value, true);
|
||||
}
|
||||
|
||||
function initJsonTogglePanels(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
|
||||
@@ -436,8 +526,8 @@
|
||||
}
|
||||
|
||||
var card = panel.closest ? panel.closest('.card') : null;
|
||||
var button = card ? card.querySelector('[data-json-toggle]') : null;
|
||||
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
|
||||
var collapseButton = card ? card.querySelector('[data-json-toggle-collapse-all]') : null;
|
||||
var expandButton = card ? card.querySelector('[data-json-toggle-expand-all]') : null;
|
||||
var sourceNode = panel.querySelector('[data-json-toggle-source]');
|
||||
var rawJson = '';
|
||||
try {
|
||||
@@ -463,32 +553,47 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var compactJson = JSON.stringify(parsedJson);
|
||||
var formattedJson = JSON.stringify(parsedJson, null, 2);
|
||||
var isFormatted = true;
|
||||
var formattedTree = renderJsonTree(parsedJson);
|
||||
|
||||
function syncButtonLabel() {
|
||||
if (!button || !label) {
|
||||
return;
|
||||
function setAllSectionsExpanded(expanded) {
|
||||
var details = output.querySelectorAll('details');
|
||||
Array.prototype.forEach.call(details, function (detail) {
|
||||
detail.open = expanded;
|
||||
});
|
||||
|
||||
if (collapseButton) {
|
||||
collapseButton.disabled = !details.length || !expanded;
|
||||
}
|
||||
if (expandButton) {
|
||||
expandButton.disabled = !details.length || expanded;
|
||||
}
|
||||
label.textContent = isFormatted
|
||||
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
|
||||
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
|
||||
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function syncOutput() {
|
||||
output.textContent = isFormatted ? formattedJson : compactJson;
|
||||
syncButtonLabel();
|
||||
function syncButtons() {
|
||||
if (collapseButton) {
|
||||
collapseButton.setAttribute('aria-pressed', 'false');
|
||||
}
|
||||
if (expandButton) {
|
||||
expandButton.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
output.textContent = formattedJson;
|
||||
syncButtonLabel();
|
||||
output.innerHTML = formattedTree;
|
||||
syncButtons();
|
||||
setAllSectionsExpanded(true);
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', function () {
|
||||
isFormatted = !isFormatted;
|
||||
syncOutput();
|
||||
if (collapseButton) {
|
||||
collapseButton.addEventListener('click', function () {
|
||||
setAllSectionsExpanded(false);
|
||||
syncButtons();
|
||||
});
|
||||
}
|
||||
|
||||
if (expandButton) {
|
||||
expandButton.addEventListener('click', function () {
|
||||
setAllSectionsExpanded(true);
|
||||
syncButtons();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
function updateAnnouncementDurationFields() {
|
||||
var modeSelect = document.getElementById('announcement-duration-mode');
|
||||
var durationFields = document.querySelector('[data-announcement-duration-value-fields]');
|
||||
if (!modeSelect || !durationFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
var durationValueInput = document.getElementById('announcement-duration-value');
|
||||
var durationUnitSelect = document.getElementById('announcement-duration-unit');
|
||||
var isDurationMode = String(modeSelect.value || '').trim() === 'duration';
|
||||
|
||||
durationFields.hidden = !isDurationMode;
|
||||
if (durationValueInput) {
|
||||
durationValueInput.disabled = !isDurationMode;
|
||||
}
|
||||
if (durationUnitSelect) {
|
||||
durationUnitSelect.disabled = !isDurationMode;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnnouncementColorPreview() {
|
||||
var colorSelect = document.getElementById('announcement-color');
|
||||
var preview = document.querySelector('[data-announcement-color-preview]');
|
||||
if (!colorSelect || !preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedOption = colorSelect.options[colorSelect.selectedIndex] || null;
|
||||
var colorKey = String(colorSelect.value || '').trim().toLowerCase() || 'primary';
|
||||
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || colorKey).trim() : colorKey;
|
||||
|
||||
preview.className = 'announcement-color-preview text-bg-' + colorKey;
|
||||
preview.setAttribute('aria-label', label);
|
||||
preview.setAttribute('title', label);
|
||||
preview.textContent = '';
|
||||
}
|
||||
|
||||
function updateAnnouncementIconPreview() {
|
||||
var iconSelect = document.getElementById('announcement-icon');
|
||||
var preview = document.querySelector('[data-announcement-icon-preview]');
|
||||
if (!iconSelect || !preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedOption = iconSelect.options[iconSelect.selectedIndex] || null;
|
||||
var iconKey = selectedOption ? String(selectedOption.value || '').trim().toLowerCase() : '';
|
||||
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || iconKey).trim() : iconKey;
|
||||
|
||||
preview.className = 'announcement-icon-preview';
|
||||
preview.innerHTML = '<i class="bi bi-' + iconKey + '" aria-hidden="true"></i>';
|
||||
preview.setAttribute('aria-label', label);
|
||||
preview.setAttribute('title', label);
|
||||
}
|
||||
|
||||
function positionAnnouncementIconPicker() {
|
||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
var shell = document.querySelector('[data-announcement-icon-picker-shell]');
|
||||
if (!picker || picker.hidden || !toggle || !shell) {
|
||||
return;
|
||||
}
|
||||
|
||||
var padding = 8;
|
||||
var toggleRect = toggle.getBoundingClientRect();
|
||||
var menuRect = picker.getBoundingClientRect();
|
||||
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || toggleRect.bottom;
|
||||
var placementAbove = false;
|
||||
var spaceBelow = viewportHeight - toggleRect.bottom - padding;
|
||||
var spaceAbove = toggleRect.top - padding;
|
||||
|
||||
if (menuRect.height > spaceBelow && spaceAbove > spaceBelow) {
|
||||
placementAbove = true;
|
||||
}
|
||||
|
||||
picker.classList.toggle('is-open-above', placementAbove);
|
||||
}
|
||||
|
||||
function closeAnnouncementIconPicker() {
|
||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
picker.hidden = true;
|
||||
picker.classList.remove('is-open-above');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function openAnnouncementIconPicker() {
|
||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
picker.hidden = false;
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
|
||||
window.requestAnimationFrame(positionAnnouncementIconPicker);
|
||||
} else {
|
||||
positionAnnouncementIconPicker();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnnouncementIconPickerSelection() {
|
||||
var iconSelect = document.getElementById('announcement-icon');
|
||||
var previewButton = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
||||
if (!iconSelect || !previewButton || !picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedOption = iconSelect.options[iconSelect.selectedIndex] || null;
|
||||
var selectedValue = selectedOption ? String(selectedOption.value || '').trim().toLowerCase() : '';
|
||||
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || selectedValue).trim() : selectedValue;
|
||||
var icon = previewButton.querySelector('[data-announcement-icon-picker-icon]');
|
||||
var text = previewButton.querySelector('[data-announcement-icon-picker-label]');
|
||||
|
||||
previewButton.setAttribute('aria-label', label);
|
||||
previewButton.setAttribute('title', label);
|
||||
if (icon) {
|
||||
icon.className = 'bi bi-' + selectedValue;
|
||||
}
|
||||
if (text) {
|
||||
text.textContent = label;
|
||||
}
|
||||
|
||||
picker.querySelectorAll('[data-announcement-icon-option]').forEach(function (button) {
|
||||
var isSelected = String(button.getAttribute('data-icon-key') || '').trim().toLowerCase() === selectedValue;
|
||||
button.classList.toggle('is-selected', isSelected);
|
||||
button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function setAnnouncementIconValue(iconKey) {
|
||||
var iconSelect = document.getElementById('announcement-icon');
|
||||
if (!iconSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = String(iconKey || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
iconSelect.value = normalized;
|
||||
updateAnnouncementIconPreview();
|
||||
updateAnnouncementIconPickerSelection();
|
||||
}
|
||||
|
||||
function initAnnouncementForm() {
|
||||
var modeSelect = document.getElementById('announcement-duration-mode');
|
||||
var colorSelect = document.getElementById('announcement-color');
|
||||
var iconSelect = document.getElementById('announcement-icon');
|
||||
var iconPickerToggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
var iconPicker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var iconPickerClose = document.querySelector('[data-announcement-icon-picker-close]');
|
||||
|
||||
if (modeSelect) {
|
||||
modeSelect.addEventListener('change', updateAnnouncementDurationFields);
|
||||
updateAnnouncementDurationFields();
|
||||
}
|
||||
|
||||
if (colorSelect) {
|
||||
colorSelect.addEventListener('change', updateAnnouncementColorPreview);
|
||||
updateAnnouncementColorPreview();
|
||||
}
|
||||
|
||||
if (iconSelect) {
|
||||
iconSelect.addEventListener('change', updateAnnouncementIconPreview);
|
||||
updateAnnouncementIconPreview();
|
||||
}
|
||||
|
||||
if (iconPickerToggle) {
|
||||
iconPickerToggle.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
var isExpanded = iconPicker && !iconPicker.hidden;
|
||||
if (isExpanded) {
|
||||
closeAnnouncementIconPicker();
|
||||
} else {
|
||||
openAnnouncementIconPicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (iconPickerClose) {
|
||||
iconPickerClose.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
closeAnnouncementIconPicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (iconPicker) {
|
||||
iconPicker.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-announcement-icon-option]') : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setAnnouncementIconValue(button.getAttribute('data-icon-key'));
|
||||
closeAnnouncementIconPicker();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', positionAnnouncementIconPicker);
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
if (!picker || picker.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (picker.contains(event.target) || (toggle && toggle.contains(event.target))) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeAnnouncementIconPicker();
|
||||
});
|
||||
|
||||
updateAnnouncementIconPickerSelection();
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAnnouncementForm, { once: true });
|
||||
} else {
|
||||
initAnnouncementForm();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// API source form visibility helpers.
|
||||
|
||||
(function () {
|
||||
var form = document.getElementById('api-source-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
var methodSelect = form.querySelector('[data-api-source-auth-method]');
|
||||
var authDetailsSection = form.querySelector('[data-api-source-auth-details-section]');
|
||||
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]'));
|
||||
|
||||
function updatePanels() {
|
||||
var method = String(methodSelect && methodSelect.value || 'none').trim();
|
||||
var hasAuth = method !== 'none';
|
||||
|
||||
if (authDetailsSection) {
|
||||
authDetailsSection.hidden = !hasAuth;
|
||||
}
|
||||
|
||||
panels.forEach(function (panel) {
|
||||
var panelMethod = String(panel.getAttribute('data-api-source-auth-panel') || '').trim();
|
||||
panel.hidden = !hasAuth || panelMethod !== method;
|
||||
});
|
||||
}
|
||||
|
||||
if (methodSelect) {
|
||||
methodSelect.addEventListener('change', updatePanels);
|
||||
}
|
||||
|
||||
updatePanels();
|
||||
}());
|
||||
@@ -1,3 +1,5 @@
|
||||
// Bootstrap modal convenience wrapper for the browser scripts.
|
||||
|
||||
(function () {
|
||||
function getOrCreate(modalElement) {
|
||||
if (!modalElement || !window.bootstrap || !window.bootstrap.Modal) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// RBAC permission matrix helpers for checkbox groups.
|
||||
|
||||
(function () {
|
||||
function getGroupCheckboxes(group) {
|
||||
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Shared registry for region-type helpers used by the slide and template editors.
|
||||
|
||||
(function () {
|
||||
var root = window;
|
||||
var registry = root.pulseRegionTypes && typeof root.pulseRegionTypes === 'object' ? root.pulseRegionTypes : {};
|
||||
|
||||
function normalizeType(type) {
|
||||
return String(type || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseLockRatio(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!/^[0-9]+\s*:[0-9]+$/.test(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = raw.replace(/\s+/g, '').split(':');
|
||||
var width = Number(parts[0]);
|
||||
var height = Number(parts[1]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { label: parts[0] + ':' + parts[1], ratio: width / height };
|
||||
}
|
||||
|
||||
function getFallbackDefaultSize(regionType, lockRatio) {
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
var baseWidth = 300;
|
||||
return {
|
||||
width: Math.max(12, Math.round(baseWidth)),
|
||||
height: Math.max(12, Math.round(baseWidth / ratio.ratio))
|
||||
};
|
||||
}
|
||||
|
||||
var baseHeight = 240;
|
||||
return {
|
||||
width: Math.max(12, Math.round(baseHeight * ratio.ratio)),
|
||||
height: Math.max(12, Math.round(baseHeight))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: 300,
|
||||
height: 120
|
||||
};
|
||||
}
|
||||
|
||||
function register(type, definition) {
|
||||
registry[normalizeType(type)] = definition || {};
|
||||
return registry[normalizeType(type)];
|
||||
}
|
||||
|
||||
function get(type) {
|
||||
return registry[normalizeType(type)] || null;
|
||||
}
|
||||
|
||||
function list() {
|
||||
return Object.keys(registry).map(function (type) {
|
||||
return {
|
||||
type: type,
|
||||
definition: registry[type] || {}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
var definition = get(regionType);
|
||||
if (definition && typeof definition.getDefaultRegionSize === 'function') {
|
||||
return definition.getDefaultRegionSize(lockRatio);
|
||||
}
|
||||
|
||||
return getFallbackDefaultSize(normalizeType(regionType), lockRatio);
|
||||
}
|
||||
|
||||
function getDefaultRegionStyle(regionType) {
|
||||
var definition = get(regionType);
|
||||
if (definition && typeof definition.getDefaultStyle === 'function') {
|
||||
return definition.getDefaultStyle();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
var definition = get(regionType);
|
||||
if (definition && definition.label) {
|
||||
return String(definition.label);
|
||||
}
|
||||
|
||||
var normalized = normalizeType(regionType);
|
||||
if (!normalized) {
|
||||
return 'Region';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[_-]+/)
|
||||
.filter(function (segment) { return segment; })
|
||||
.map(function (segment) {
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
})
|
||||
.join(' ') || 'Region';
|
||||
}
|
||||
|
||||
root.pulseRegionTypes = {
|
||||
register: register,
|
||||
get: get,
|
||||
list: list,
|
||||
getDefaultRegionSize: getDefaultRegionSize,
|
||||
getDefaultRegionStyle: getDefaultRegionStyle,
|
||||
getRegionChipLabel: getRegionChipLabel
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,772 @@
|
||||
// Shared browser utilities for region helper modules.
|
||||
|
||||
(function () {
|
||||
function escapeHtml(value) {
|
||||
if (window.webUiHelpers && typeof window.webUiHelpers.escapeHtml === 'function') {
|
||||
return window.webUiHelpers.escapeHtml(value);
|
||||
}
|
||||
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function 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 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'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
|
||||
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return sanitizePreviewHtml(html);
|
||||
}
|
||||
|
||||
function sanitizeTagAttributes(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'],
|
||||
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 '';
|
||||
}
|
||||
|
||||
var attrs = [];
|
||||
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 sanitizeFontFamily(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value) {
|
||||
return String(value || '').trim() || '#000000';
|
||||
}
|
||||
|
||||
function normalizeAcceptList(value) {
|
||||
return Array.isArray(value) ? value : String(value || '').split(',').map(function (item) {
|
||||
return String(item || '').trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function fileMatchesAccept(file, acceptValue) {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var acceptList = normalizeAcceptList(acceptValue).map(function (item) {
|
||||
return item.toLowerCase();
|
||||
});
|
||||
|
||||
if (!acceptList.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var fileName = String(file.name || '').toLowerCase();
|
||||
var fileType = String(file.type || '').toLowerCase();
|
||||
|
||||
return acceptList.some(function (rule) {
|
||||
if (rule === '*/*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rule.charAt(0) === '.') {
|
||||
return fileName.endsWith(rule);
|
||||
}
|
||||
|
||||
if (rule.endsWith('/*')) {
|
||||
return fileType.indexOf(rule.slice(0, -1)) === 0;
|
||||
}
|
||||
|
||||
return fileType === rule;
|
||||
});
|
||||
}
|
||||
|
||||
function bindRegionMediaRemoveControls(templateFields, options) {
|
||||
var callbacks = options || {};
|
||||
|
||||
if (!templateFields || templateFields.dataset.regionMediaRemoveBound === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
templateFields.dataset.regionMediaRemoveBound = '1';
|
||||
|
||||
function getCard(regionId) {
|
||||
if (typeof callbacks.getCardByRegionId === 'function') {
|
||||
return callbacks.getCardByRegionId(regionId);
|
||||
}
|
||||
|
||||
return regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
}
|
||||
|
||||
function getMediaType(button, card) {
|
||||
if (typeof callbacks.getMediaType === 'function') {
|
||||
return callbacks.getMediaType(card, button);
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute('data-remove-region-video')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute('data-remove-region-image')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (card && card.dataset && String(card.dataset.regionMediaType || '').trim()) {
|
||||
return String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
}
|
||||
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function getExistingPrefix(mediaType) {
|
||||
if (typeof callbacks.getExistingMediaPrefix === 'function') {
|
||||
return callbacks.getExistingMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
}
|
||||
|
||||
function getMediaPrefix(mediaType) {
|
||||
if (typeof callbacks.getMediaPrefix === 'function') {
|
||||
return callbacks.getMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
}
|
||||
|
||||
function getVideoDurationHiddenInput(regionId) {
|
||||
if (typeof callbacks.getVideoDurationHiddenInput === 'function') {
|
||||
return callbacks.getVideoDurationHiddenInput(regionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleRemove(button) {
|
||||
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video');
|
||||
var card = getCard(regionId);
|
||||
var mediaType = getMediaType(button, card);
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingPrefix(mediaType) + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="' + getMediaPrefix(mediaType) + regionId + '"]');
|
||||
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||
|
||||
if (input && input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath && typeof callbacks.queueUploadCleanup === 'function') {
|
||||
callbacks.queueUploadCleanup([uploadPath]);
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.value = '';
|
||||
delete input.dataset.uploadedPath;
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = '';
|
||||
}
|
||||
|
||||
var durationHidden = getVideoDurationHiddenInput(regionId);
|
||||
if (durationHidden) {
|
||||
durationHidden.value = '';
|
||||
}
|
||||
|
||||
if (typeof callbacks.renderPreview === 'function') {
|
||||
callbacks.renderPreview(card, '');
|
||||
}
|
||||
|
||||
if (typeof callbacks.markEdited === 'function') {
|
||||
callbacks.markEdited();
|
||||
}
|
||||
|
||||
if (typeof callbacks.requestPreviewRender === 'function') {
|
||||
callbacks.requestPreviewRender();
|
||||
}
|
||||
}
|
||||
|
||||
templateFields.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video]') : null;
|
||||
|
||||
if (!button || !templateFields.contains(button)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemove(button);
|
||||
});
|
||||
|
||||
templateFields.addEventListener('keydown', function (event) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video]') : null;
|
||||
|
||||
if (!button || !templateFields.contains(button)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
handleRemove(button);
|
||||
});
|
||||
}
|
||||
|
||||
function createRegionMediaUploadController(templateFields, options) {
|
||||
var callbacks = options || {};
|
||||
|
||||
if (!templateFields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCard(regionId) {
|
||||
if (typeof callbacks.getCardByRegionId === 'function') {
|
||||
return callbacks.getCardByRegionId(regionId);
|
||||
}
|
||||
|
||||
return regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
}
|
||||
|
||||
function getMediaTypeFromInput(input) {
|
||||
if (typeof callbacks.getMediaTypeFromInput === 'function') {
|
||||
return callbacks.getMediaTypeFromInput(input);
|
||||
}
|
||||
|
||||
return input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
|
||||
}
|
||||
|
||||
function getMediaPrefix(mediaType) {
|
||||
if (typeof callbacks.getMediaPrefix === 'function') {
|
||||
return callbacks.getMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
}
|
||||
|
||||
function getExistingMediaPrefix(mediaType) {
|
||||
if (typeof callbacks.getExistingMediaPrefix === 'function') {
|
||||
return callbacks.getExistingMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
}
|
||||
|
||||
function getUploadZone(input) {
|
||||
if (typeof callbacks.getUploadZone === 'function') {
|
||||
return callbacks.getUploadZone(input);
|
||||
}
|
||||
|
||||
return input ? input.closest('[data-region-upload-zone]') : null;
|
||||
}
|
||||
|
||||
function getUploadProgressNode(zone) {
|
||||
return zone ? zone.querySelector('[data-region-upload-progress]') : null;
|
||||
}
|
||||
|
||||
function getUploadProgressBar(zone) {
|
||||
return zone ? zone.querySelector('[data-region-upload-progress-bar]') : null;
|
||||
}
|
||||
|
||||
function getUploadProgressText(zone) {
|
||||
return zone ? zone.querySelector('[data-region-upload-progress-text]') : null;
|
||||
}
|
||||
|
||||
function setUploadProgress(zone, percent, text, isIndeterminate) {
|
||||
var progress = getUploadProgressNode(zone);
|
||||
var bar = getUploadProgressBar(zone);
|
||||
var progressText = getUploadProgressText(zone);
|
||||
var normalizedPercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
|
||||
|
||||
if (!progress || !bar) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = text || 'Uploading...';
|
||||
}
|
||||
|
||||
progress.hidden = false;
|
||||
bar.style.width = (isIndeterminate ? 100 : normalizedPercent) + '%';
|
||||
bar.textContent = isIndeterminate ? 'Uploading...' : normalizedPercent + '%';
|
||||
bar.setAttribute('aria-valuenow', String(isIndeterminate ? 100 : normalizedPercent));
|
||||
bar.classList.toggle('progress-bar-striped', Boolean(isIndeterminate));
|
||||
bar.classList.toggle('progress-bar-animated', Boolean(isIndeterminate));
|
||||
}
|
||||
|
||||
function setUploadState(input, isUploading, percent, text, isIndeterminate) {
|
||||
var zone = getUploadZone(input);
|
||||
var progress = getUploadProgressNode(zone);
|
||||
|
||||
if (!zone || !progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
zone.classList.toggle('is-uploading', Boolean(isUploading));
|
||||
zone.setAttribute('aria-busy', isUploading ? 'true' : 'false');
|
||||
input.disabled = Boolean(isUploading);
|
||||
|
||||
if (isUploading) {
|
||||
setUploadProgress(zone, percent, text, isIndeterminate);
|
||||
return;
|
||||
}
|
||||
|
||||
progress.hidden = true;
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setInputFile(input, file) {
|
||||
if (!input || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
var dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
input.files = dataTransfer.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function queueUploadCleanup(uploadPaths) {
|
||||
var normalizedPaths = Array.from(new Set((uploadPaths || []).map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean)));
|
||||
|
||||
if (!normalizedPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof callbacks.queueUploadCleanup === 'function') {
|
||||
callbacks.queueUploadCleanup(normalizedPaths);
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = JSON.stringify({ uploadPaths: normalizedPaths });
|
||||
var url = String(callbacks.cleanupUrl || '/slides/uploads/cleanup');
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
try {
|
||||
navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' }));
|
||||
return;
|
||||
} catch (_error) {
|
||||
// Fall back to fetch below.
|
||||
}
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: payload,
|
||||
credentials: 'same-origin',
|
||||
keepalive: true
|
||||
}).catch(function () {
|
||||
// Best-effort cleanup only.
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingUploadCleanupPaths() {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]')).map(function (input) {
|
||||
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(input.dataset.uploadedPath || '').trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function clearPendingUploadCleanupPaths() {
|
||||
Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]')).forEach(function (input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFile(input, file) {
|
||||
if (!input || !file) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
var mediaType = getMediaTypeFromInput(input);
|
||||
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
|
||||
var card = getCard(regionId);
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||
var zone = getUploadZone(input);
|
||||
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
|
||||
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
|
||||
var previousNeedsCleanup = String(input.dataset.uploadedNeedsCleanup || '') === '1';
|
||||
var uploadUrl = String(callbacks.uploadUrl || '/slides/uploads');
|
||||
var uploadTimeoutMs = Math.max(1, Number(callbacks.uploadTimeoutMs || 30000));
|
||||
|
||||
input.dataset.uploadToken = uploadToken;
|
||||
setUploadState(input, true, 0, 'Uploading...', true);
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
var formData = new FormData();
|
||||
formData.append('file', file, file.name || 'upload');
|
||||
|
||||
xhr.open('POST', uploadUrl, true);
|
||||
xhr.responseType = 'text';
|
||||
xhr.withCredentials = true;
|
||||
xhr.timeout = uploadTimeoutMs;
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.lengthComputable || !event.total) {
|
||||
setUploadProgress(zone, 100, 'Uploading...', true);
|
||||
return;
|
||||
}
|
||||
|
||||
var percent = Math.round((event.loaded / event.total) * 100);
|
||||
setUploadProgress(zone, percent, 'Uploading ' + percent + '%', false);
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = {
|
||||
ok: xhr.status >= 200 && xhr.status < 300,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
responseText: xhr.responseText || ''
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
var error = new Error(response.responseText || 'Unable to upload media.');
|
||||
error.status = response.status;
|
||||
error.responseText = response.responseText;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(response.responseText || '').trim().toLowerCase().indexOf('<!doctype html') === 0 || String(response.responseText || '').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(response.responseText || '{}') || {};
|
||||
} catch (_error) {
|
||||
reject(new Error('Unable to parse the upload response.'));
|
||||
return;
|
||||
}
|
||||
|
||||
var uploadedPath = String(payload.path || '').trim();
|
||||
if (!uploadedPath) {
|
||||
reject(new Error('Unable to upload media.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = uploadedPath;
|
||||
}
|
||||
input.dataset.uploadedPath = uploadedPath;
|
||||
input.dataset.uploadedNeedsCleanup = '1';
|
||||
input.value = '';
|
||||
input.setCustomValidity('');
|
||||
|
||||
if (typeof callbacks.renderPreview === 'function') {
|
||||
callbacks.renderPreview(card, uploadedPath);
|
||||
}
|
||||
|
||||
if (typeof callbacks.syncDuration === 'function') {
|
||||
callbacks.syncDuration(card, uploadedPath);
|
||||
}
|
||||
|
||||
if (typeof callbacks.markEdited === 'function') {
|
||||
callbacks.markEdited();
|
||||
}
|
||||
|
||||
if (typeof callbacks.requestPreviewRender === 'function') {
|
||||
callbacks.requestPreviewRender();
|
||||
}
|
||||
|
||||
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
|
||||
queueUploadCleanup([previousUploadedPath]);
|
||||
}
|
||||
|
||||
setUploadState(input, false);
|
||||
resolve(payload);
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Unable to upload media.'));
|
||||
};
|
||||
|
||||
xhr.onabort = function () {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Unable to upload media.'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = function () {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Upload timed out. Please try again.'));
|
||||
};
|
||||
|
||||
xhr.onloadend = function () {
|
||||
if (input.dataset.uploadToken === uploadToken) {
|
||||
delete input.dataset.uploadToken;
|
||||
setUploadState(input, false);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}).catch(function (error) {
|
||||
if (input.dataset.uploadToken === uploadToken) {
|
||||
delete input.dataset.uploadToken;
|
||||
setUploadState(input, false);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelection(input, file) {
|
||||
if (typeof callbacks.handleMediaSelection !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return callbacks.handleMediaSelection(input, file, {
|
||||
setInputFile: setInputFile,
|
||||
uploadFile: uploadFile,
|
||||
getMediaTypeFromInput: getMediaTypeFromInput,
|
||||
getExistingMediaPrefix: getExistingMediaPrefix,
|
||||
getMediaPrefix: getMediaPrefix,
|
||||
getUploadZone: getUploadZone,
|
||||
queueUploadCleanup: queueUploadCleanup,
|
||||
getPendingUploadCleanupPaths: getPendingUploadCleanupPaths,
|
||||
clearPendingUploadCleanupPaths: clearPendingUploadCleanupPaths
|
||||
});
|
||||
}
|
||||
|
||||
if (!templateFields.dataset.regionMediaUploadBound) {
|
||||
templateFields.dataset.regionMediaUploadBound = '1';
|
||||
|
||||
templateFields.addEventListener('change', function (event) {
|
||||
var input = event.target && event.target.matches && event.target.matches('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]') ? event.target : null;
|
||||
|
||||
if (!input || !templateFields.contains(input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var file = input.files && input.files.length ? input.files[0] : null;
|
||||
var hookResult = handleSelection(input, file);
|
||||
|
||||
if (hookResult === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hookResult && typeof hookResult.then === 'function') {
|
||||
hookResult.catch(function (error) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
input.value = '';
|
||||
if (typeof callbacks.onUploadError === 'function') {
|
||||
callbacks.onUploadError(error, input);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
uploadFile(input, file).catch(function (error) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
input.value = '';
|
||||
if (typeof callbacks.onUploadError === 'function') {
|
||||
callbacks.onUploadError(error, input);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
templateFields.addEventListener('dragover', function (event) {
|
||||
var zone = event.target && event.target.closest ? event.target.closest('[data-region-upload-zone]') : null;
|
||||
if (!zone || !templateFields.contains(zone)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
zone.classList.add('is-dragover');
|
||||
});
|
||||
|
||||
templateFields.addEventListener('dragleave', function (event) {
|
||||
var zone = event.target && event.target.closest ? event.target.closest('[data-region-upload-zone]') : null;
|
||||
if (!zone || !templateFields.contains(zone)) {
|
||||
return;
|
||||
}
|
||||
|
||||
zone.classList.remove('is-dragover');
|
||||
});
|
||||
|
||||
templateFields.addEventListener('drop', function (event) {
|
||||
var zone = event.target && event.target.closest ? event.target.closest('[data-region-upload-zone]') : null;
|
||||
|
||||
if (!zone || !templateFields.contains(zone)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
zone.classList.remove('is-dragover');
|
||||
|
||||
var input = zone.querySelector('input[type="file"]');
|
||||
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
|
||||
var file = files && files.length ? files[0] : null;
|
||||
|
||||
if (!input || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hookResult = handleSelection(input, file);
|
||||
|
||||
if (hookResult === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hookResult && typeof hookResult.then === 'function') {
|
||||
hookResult.catch(function (error) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
input.value = '';
|
||||
if (typeof callbacks.onUploadError === 'function') {
|
||||
callbacks.onUploadError(error, input);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setInputFile(input, file);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
uploadFile: uploadFile,
|
||||
queueUploadCleanup: queueUploadCleanup,
|
||||
getPendingUploadCleanupPaths: getPendingUploadCleanupPaths,
|
||||
clearPendingUploadCleanupPaths: clearPendingUploadCleanupPaths,
|
||||
setInputFile: setInputFile
|
||||
};
|
||||
}
|
||||
|
||||
window.pulseRegionUtils = {
|
||||
escapeHtml: escapeHtml,
|
||||
sanitizePreviewHtml: sanitizePreviewHtml,
|
||||
sanitizeRichText: sanitizeRichText,
|
||||
sanitizeFontFamily: sanitizeFontFamily,
|
||||
sanitizeTextColor: sanitizeTextColor,
|
||||
normalizeAcceptList: normalizeAcceptList,
|
||||
fileMatchesAccept: fileMatchesAccept,
|
||||
bindRegionMediaRemoveControls: bindRegionMediaRemoveControls,
|
||||
createRegionMediaUploadController: createRegionMediaUploadController
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,309 @@
|
||||
// API region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
|
||||
function escapeHtml(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 sanitizeFontFamily(value) {
|
||||
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value) {
|
||||
return utils.sanitizeTextColor ? utils.sanitizeTextColor(value) : String(value || '').trim() || '#000000';
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw.replace(/px$/, '')))));
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw))));
|
||||
}
|
||||
return '32';
|
||||
}
|
||||
|
||||
function getSourceById(sourceId, sources) {
|
||||
var normalizedId = Number(sourceId || 0);
|
||||
return (Array.isArray(sources) ? sources : []).find(function (source) {
|
||||
return Number(source.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getSourceItemsPath(source, overridePath) {
|
||||
if (overridePath === undefined || overridePath === null) {
|
||||
return String(source && (source.items_path || source.itemsPath) || '').trim();
|
||||
}
|
||||
|
||||
return String(overridePath || '').trim();
|
||||
}
|
||||
|
||||
function getItems(sourceId, sources, itemsPathOverride) {
|
||||
var source = getSourceById(sourceId, sources);
|
||||
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
|
||||
var itemsPath = getSourceItemsPath(source, itemsPathOverride);
|
||||
if (itemsPath !== undefined && itemsPath !== null && itemsPath !== '') {
|
||||
var current = responseJson;
|
||||
String(itemsPath).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
return Array.isArray(current) ? current : [];
|
||||
}
|
||||
if (Array.isArray(responseJson)) {
|
||||
return responseJson;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.items)) {
|
||||
return responseJson.items;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.results)) {
|
||||
return responseJson.results;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.data)) {
|
||||
return responseJson.data;
|
||||
}
|
||||
return responseJson ? [responseJson] : [];
|
||||
}
|
||||
|
||||
function getItemCount(sourceId, sources, itemsPathOverride) {
|
||||
return getItems(sourceId, sources, itemsPathOverride).length;
|
||||
}
|
||||
|
||||
function getCurrentConfig(region, existingContent, sources) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var source = getSourceById(current.source_id, sources);
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
source_id: current.source_id === undefined || current.source_id === null || current.source_id === '' ? '' : Number(current.source_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
items_path: Object.prototype.hasOwnProperty.call(current, 'items_path') ? String(current.items_path === undefined || current.items_path === null ? '' : current.items_path) : undefined,
|
||||
default_items_path: String(source && (source.items_path || source.itemsPath) || '').trim(),
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
function substituteVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
|
||||
});
|
||||
}
|
||||
|
||||
function getItem(sourceId, itemNumber, sources, itemsPathOverride) {
|
||||
var items = getItems(sourceId, sources, itemsPathOverride);
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function getPreviewFallback(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
var title = String(item.title || item.name || '').trim();
|
||||
var description = String(item.description || item.summary || item.text || '').trim();
|
||||
var summary = [];
|
||||
if (title) {
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<div>' + sanitizeRichText(description) + '</div>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderPreview(region, regionContent, context) {
|
||||
var existingContent = context && context.existingContent ? context.existingContent : {};
|
||||
var sources = context && context.sources ? context.sources : [];
|
||||
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
var config = getCurrentConfig(region, existingContent, sources);
|
||||
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : config.source_id;
|
||||
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : config.item_number;
|
||||
var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : (config.items_path !== undefined ? config.items_path : config.default_items_path);
|
||||
var itemCount = getItemCount(sourceId, sources, itemsPath);
|
||||
var itemNumberMax = Math.max(1, Number(itemCount || 1));
|
||||
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||
var item = getItem(sourceId, itemNumber, sources, itemsPath);
|
||||
var body = item ? substituteVariables(content, item) : '';
|
||||
|
||||
if (!body && item) {
|
||||
body = getPreviewFallback(item);
|
||||
}
|
||||
|
||||
body = String(body || '')
|
||||
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
|
||||
.replace(/<details[^>]*class="[^"]*api-region-sample-accordion[^"]*"[^>]*>[\s\S]*?<\/details>/gi, '');
|
||||
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = sanitizeRichText(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>' : '';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var sourceOptions = String(context.sourceOptions || '');
|
||||
var itemsPathValue = context.itemsPath !== undefined
|
||||
? String(context.itemsPath === null ? '' : context.itemsPath)
|
||||
: String(config.default_items_path || '');
|
||||
var sampleDataPreview = String(context.sampleDataPreview || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<span class="chip">API</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3 pb-0">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3 align-items-end">' +
|
||||
'<div class="col-12 col-lg-5">' +
|
||||
'<label class="form-label" for="region_api_source_id_' + region.id + '">API source</label>' +
|
||||
'<select id="region_api_source_id_' + region.id + '" name="region_api_source_id_' + region.id + '" class="form-select">' +
|
||||
'<option value="">Select a source</option>' +
|
||||
sourceOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-lg-2">' +
|
||||
'<label class="form-label" for="region_api_item_number_' + region.id + '">Item number</label>' +
|
||||
'<input id="region_api_item_number_' + region.id + '" type="number" min="1" step="1" name="region_api_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-lg-5">' +
|
||||
'<label class="form-label" for="region_api_items_path_' + region.id + '">Items path override</label>' +
|
||||
'<div class="input-group flex-nowrap">' +
|
||||
'<input id="region_api_items_path_' + region.id + '" type="text" name="region_api_items_path_' + region.id + '" class="form-control" value="' + escapeHtml(itemsPathValue) + '" placeholder="Leave blank to use the base/root JSON" />' +
|
||||
'<button type="button" class="btn btn-outline-secondary text-nowrap" data-api-items-path-reset data-api-default-items-path="' + escapeHtml(config.default_items_path || '') + '">Reset</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use transforms like {{name.upper()}}, {{name.title()}}, or {{name.lower()}} on leaf fields.</div>' +
|
||||
'<div class="api-region-placeholder-section">' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
||||
'<summary>Data</summary>' +
|
||||
'<div class="api-region-sample-body" data-api-sample-data-panel>' + sampleDataPreview + '</div>' +
|
||||
'</details>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context, existingContent, sources) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var sourceOptions = String(context.sourceOptions || '');
|
||||
var itemsPathValue = context.itemsPath !== undefined
|
||||
? String(context.itemsPath === null ? '' : context.itemsPath)
|
||||
: String(config.default_items_path || '');
|
||||
var sampleDataPreview = String(context.sampleDataPreview || '');
|
||||
|
||||
return {
|
||||
region: region,
|
||||
current: current,
|
||||
config: config,
|
||||
fontSize: context.fontSize || '',
|
||||
itemsPath: itemsPathValue,
|
||||
sampleDataPreview: sampleDataPreview,
|
||||
placeholderChips: placeholderChips,
|
||||
sourceOptions: sourceOptions,
|
||||
existingContent: existingContent || {},
|
||||
sources: Array.isArray(sources) ? sources : []
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, 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 content = card && card.querySelector ? {
|
||||
value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')),
|
||||
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,
|
||||
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
|
||||
} : current;
|
||||
|
||||
return {
|
||||
value: content.value,
|
||||
source_id: content.source_id,
|
||||
item_number: content.item_number,
|
||||
items_path: content.items_path,
|
||||
existingContent: existingContent || {},
|
||||
sources: Array.isArray(sources) ? sources : []
|
||||
};
|
||||
}
|
||||
|
||||
registry.register('api', {
|
||||
label: 'API',
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 240 };
|
||||
},
|
||||
getSourceById: getSourceById,
|
||||
getItems: getItems,
|
||||
getItemCount: getItemCount,
|
||||
getCurrentConfig: getCurrentConfig,
|
||||
getItem: getItem,
|
||||
getPreviewFallback: getPreviewFallback,
|
||||
substituteVariables: substituteVariables,
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,46 @@
|
||||
// HTML region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function renderPreview(value) {
|
||||
var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
|
||||
var html = String(content ? content.value : value || '').trim();
|
||||
if (!html) {
|
||||
return '<div class="slide-preview-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">HTML</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">HTML content' +
|
||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
registry.register('html', {
|
||||
label: 'HTML',
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 240 };
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,150 @@
|
||||
// Image region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function showUploadWarning(message) {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(message, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(message);
|
||||
}
|
||||
|
||||
function renderPreview(region, value) {
|
||||
var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
|
||||
var src = String(content ? content.value : value || '').trim();
|
||||
if (!src) {
|
||||
return '<div class="slide-preview-placeholder">Image</div>';
|
||||
}
|
||||
return '<img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var regionRatio = String(context.regionRatio || '1:1');
|
||||
var uploadConfig = context.uploadConfig || {};
|
||||
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB');
|
||||
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp');
|
||||
var uploadHelpText = String(uploadConfig.helpText || 'PNG, JPG, GIF, or WebP');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="image">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Image</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
|
||||
return {
|
||||
region: context.region,
|
||||
current: String(context.current || ''),
|
||||
regionRatio: String(context.regionRatio || '1:1'),
|
||||
uploadConfig: {
|
||||
accept: Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
limitBytes: defaultConfig.limitBytes,
|
||||
limitLabel: defaultConfig.limitLabel || context.uploadMaxLabel || '100 MB',
|
||||
helpText: defaultConfig.helpText || 'PNG, JPG, GIF, or WebP'
|
||||
},
|
||||
uploadMaxLabel: context.uploadMaxLabel || '100 MB'
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent) {
|
||||
var current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
var imageHidden = card && card.querySelector ? card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]') : null;
|
||||
var imageFileInput = card && card.querySelector ? card.querySelector('input[type="file"][name="region_image_' + region.id + '"]') : null;
|
||||
|
||||
return {
|
||||
value: imageFileInput && imageFileInput.dataset.previewUrl ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : (current && current.value !== undefined ? current.value : '')),
|
||||
existingContent: existingContent || {}
|
||||
};
|
||||
}
|
||||
|
||||
function handleMediaInputChange(context) {
|
||||
if (!context || !context.file || typeof context.uploadFile !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var uploadConfig = context.uploadConfig || {};
|
||||
var accept = uploadConfig.accept || ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
var limitBytes = Number(uploadConfig.limitBytes || 100 * 1024 * 1024);
|
||||
|
||||
if (!utils.fileMatchesAccept || !utils.fileMatchesAccept(context.file, accept)) {
|
||||
showUploadWarning('This image region accepts PNG, JPG, GIF, or WebP files.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number(context.file.size || 0) > limitBytes) {
|
||||
showUploadWarning('File must be ' + String(uploadConfig.limitLabel || '100 MB') + ' or smaller.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return context.uploadFile(context.file);
|
||||
}
|
||||
|
||||
registry.register('image', {
|
||||
label: 'Image',
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 240 };
|
||||
},
|
||||
getMediaUploadConfig: function (context) {
|
||||
var defaultConfig = context && context.defaultConfig ? context.defaultConfig : {};
|
||||
|
||||
return {
|
||||
accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
limitBytes: defaultConfig.limitBytes,
|
||||
limitLabel: defaultConfig.limitLabel,
|
||||
helpText: 'PNG, JPG, GIF, or WebP'
|
||||
};
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext,
|
||||
handleMediaInputChange: handleMediaInputChange
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,299 @@
|
||||
// RSS region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
var DEFAULT_STYLE = {
|
||||
font_family: 'Arial',
|
||||
font_size: 32,
|
||||
font_color: '#000000'
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(html) {
|
||||
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : sanitizePreviewHtml(html);
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value) {
|
||||
return utils.sanitizeTextColor ? utils.sanitizeTextColor(value) : String(value || '').trim() || '#000000';
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw.replace(/px$/, '')))));
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw))));
|
||||
}
|
||||
return '32';
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: DEFAULT_STYLE.font_family,
|
||||
font_size: DEFAULT_STYLE.font_size,
|
||||
font_color: DEFAULT_STYLE.font_color
|
||||
};
|
||||
}
|
||||
|
||||
function getFeedById(feedId, feeds) {
|
||||
var normalizedId = Number(feedId || 0);
|
||||
return (Array.isArray(feeds) ? feeds : []).find(function (feed) {
|
||||
return Number(feed.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getFieldList(feedId, feeds) {
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
|
||||
var sampleJson = sampleItem && sampleItem.itemJson && typeof sampleItem.itemJson === 'object' ? sampleItem.itemJson : null;
|
||||
|
||||
function walkFields(value, prefix, output) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
if (key === 'rawXml') {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextPath = prefix ? prefix + '.' + key : key;
|
||||
var nextValue = value[key];
|
||||
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
|
||||
walkFields(nextValue, nextPath, output);
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.indexOf(nextPath) === -1) {
|
||||
output.push(nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fields = [];
|
||||
walkFields(sampleJson, '', fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getCurrentConfig(region, existingContent) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
feed_id: current.feed_id === undefined || current.feed_id === null || current.feed_id === '' ? '' : Number(current.feed_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePath(value, path) {
|
||||
var current = value;
|
||||
if (!path) {
|
||||
return current;
|
||||
}
|
||||
String(path).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
return current === undefined || current === null ? '' : current;
|
||||
}
|
||||
|
||||
function substituteVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
|
||||
return escapeHtml(resolvePath(item, key || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function getItem(feedId, itemNumber, feeds) {
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function getPreviewFallback(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
|
||||
var title = String(item.title || '').trim();
|
||||
var description = String(item.description || '').trim();
|
||||
var summary = [];
|
||||
if (title) {
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.value !== undefined) {
|
||||
return value.value;
|
||||
}
|
||||
if (value.text !== undefined) {
|
||||
return value.text;
|
||||
}
|
||||
if (value.html !== undefined) {
|
||||
return value.html;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderPreview(region, regionContent, context) {
|
||||
var existingContent = context && context.existingContent ? context.existingContent : {};
|
||||
var feeds = context && context.feeds ? context.feeds : [];
|
||||
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
var config = getCurrentConfig(region, existingContent);
|
||||
var defaultStyle = getDefaultStyle();
|
||||
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : config.feed_id;
|
||||
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : config.item_number;
|
||||
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||
var item = getItem(feedId, itemNumber, feeds);
|
||||
var body = item ? substituteVariables(content, item) : '';
|
||||
|
||||
if (!body && item) {
|
||||
var summaryParts = [];
|
||||
if (item.title) {
|
||||
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
||||
}
|
||||
if (item.description) {
|
||||
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
|
||||
}
|
||||
body = summaryParts.join('');
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = sanitizeRichText(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>' : '';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var feedOptions = String(context.feedOptions || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<span class="chip">RSS</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3 align-items-end">' +
|
||||
'<div class="col-12 col-md-8">' +
|
||||
'<label class="form-label" for="region_rss_feed_id_' + region.id + '">RSS feed</label>' +
|
||||
'<select id="region_rss_feed_id_' + region.id + '" name="region_rss_feed_id_' + region.id + '" class="form-select">' +
|
||||
'<option value="">Select a feed</option>' +
|
||||
feedOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-md-4">' +
|
||||
'<label class="form-label" for="region_rss_item_number_' + region.id + '">Entry number</label>' +
|
||||
'<input id="region_rss_item_number_' + region.id + '" type="number" min="1" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use placeholders like {{title.upper()}}, {{title.title()}}, or {{title.lower()}}. Available placeholders:</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context, existingContent, feeds) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var feedOptions = String(context.feedOptions || '');
|
||||
|
||||
return {
|
||||
region: region,
|
||||
current: current,
|
||||
config: config,
|
||||
fontSize: context.fontSize || '',
|
||||
placeholderChips: placeholderChips,
|
||||
feedOptions: feedOptions,
|
||||
existingContent: existingContent || {},
|
||||
feeds: Array.isArray(feeds) ? feeds : []
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent, feeds) {
|
||||
var current = getCurrentConfig(region, existingContent || {});
|
||||
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
|
||||
var content = card && card.querySelector ? {
|
||||
value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')),
|
||||
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
|
||||
} : current;
|
||||
|
||||
return {
|
||||
value: content.value,
|
||||
feed_id: content.feed_id,
|
||||
item_number: content.item_number,
|
||||
existingContent: existingContent || {},
|
||||
feeds: Array.isArray(feeds) ? feeds : []
|
||||
};
|
||||
}
|
||||
|
||||
registry.register('rss', {
|
||||
label: 'RSS',
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 240 };
|
||||
},
|
||||
getFieldList: getFieldList,
|
||||
getCurrentConfig: getCurrentConfig,
|
||||
getItem: getItem,
|
||||
getPreviewFallback: getPreviewFallback,
|
||||
substituteVariables: substituteVariables,
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,52 @@
|
||||
// RTMP region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function renderPreview(value, disableAudio) {
|
||||
var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
|
||||
var src = String(content ? content.value : value || '').trim();
|
||||
var label = src ? 'RTMP' : 'RTMP stream';
|
||||
if (disableAudio) {
|
||||
label += ' (muted)';
|
||||
}
|
||||
return '<div class="slide-preview-placeholder" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;">' + label + '</div>';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var disableAudio = context.disableAudio === undefined ? true : Boolean(context.disableAudio);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">RTMP</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">RTMP URL' +
|
||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="rtmp://example.com/live/stream" />' +
|
||||
'</label>' +
|
||||
'<label class="form-check m-0">' +
|
||||
'<input class="form-check-input" type="checkbox" name="region_disable_audio_' + region.id + '" value="1"' + (disableAudio ? ' checked' : '') + ' />' +
|
||||
'<span class="form-check-label">Disable audio</span>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">Player controls stay hidden and the stream is not interactive.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
registry.register('rtmp', {
|
||||
label: 'RTMP',
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 240 };
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,104 @@
|
||||
// Text region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
var DEFAULT_STYLE = {
|
||||
font_family: 'Arial',
|
||||
font_size: 32,
|
||||
font_color: '#000000'
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(html) {
|
||||
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: DEFAULT_STYLE.font_family,
|
||||
font_size: DEFAULT_STYLE.font_size,
|
||||
font_color: DEFAULT_STYLE.font_color
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview(region, value, styleOrContext) {
|
||||
var rawValue = value && typeof value === 'object' && value.value !== undefined ? value.value : value;
|
||||
var raw = String(rawValue || '');
|
||||
if (!raw) {
|
||||
return '<div class="slide-preview-placeholder">Empty text</div>';
|
||||
}
|
||||
|
||||
var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {};
|
||||
var fontFamily = style && style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
||||
var color = style && style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
|
||||
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + sanitizePreviewHtml(raw) + '</div>';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var style = context.style || {};
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<span class="chip">Text</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="editor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
return {
|
||||
region: context.region,
|
||||
current: String(context.current || ''),
|
||||
style: context.style || {}
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent) {
|
||||
var current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
var textarea = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
|
||||
var hidden = 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 defaultStyle = getDefaultStyle();
|
||||
var style = current && typeof current === 'object' ? {
|
||||
font_family: current.font_family || region.font_family || defaultStyle.font_family,
|
||||
font_size: current.font_size || defaultStyle.font_size,
|
||||
font_color: current.font_color || region.font_color || defaultStyle.font_color
|
||||
} : {};
|
||||
|
||||
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 : '')))),
|
||||
style: style,
|
||||
existingContent: existingContent || {}
|
||||
};
|
||||
}
|
||||
|
||||
registry.register('text', {
|
||||
label: 'Text',
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 300, height: 120 };
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,357 @@
|
||||
// Time/date region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var placeholderChips = window.placeholderChips || {};
|
||||
var DEFAULT_STYLE = {
|
||||
font_family: 'Arial',
|
||||
font_size: 32,
|
||||
font_color: '#000000'
|
||||
};
|
||||
var 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'
|
||||
];
|
||||
var formatterCache = Object.create(null);
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(html) {
|
||||
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||
}
|
||||
|
||||
function normalizeEditorText(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: DEFAULT_STYLE.font_family,
|
||||
font_size: DEFAULT_STYLE.font_size,
|
||||
font_color: DEFAULT_STYLE.font_color
|
||||
};
|
||||
}
|
||||
|
||||
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 getFormatter(cacheKey, options) {
|
||||
if (!formatterCache[cacheKey]) {
|
||||
formatterCache[cacheKey] = new Intl.DateTimeFormat('en-GB', options);
|
||||
}
|
||||
|
||||
return formatterCache[cacheKey];
|
||||
}
|
||||
|
||||
function getFormattedParts(timeZone, date) {
|
||||
var targetDate = date instanceof Date ? date : new Date();
|
||||
var resolvedTimeZone = resolveTimeZone(timeZone);
|
||||
var numericParts = getFormatter('numeric:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).formatToParts(targetDate);
|
||||
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
weekday: 'long'
|
||||
}).formatToParts(targetDate);
|
||||
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
weekday: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
var monthLong = getFormatter('month-long:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
month: 'long'
|
||||
}).formatToParts(targetDate);
|
||||
var monthShort = getFormatter('month-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
month: 'short'
|
||||
}).formatToParts(targetDate);
|
||||
var ampm = getFormatter('ampm:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
hour12: true,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).formatToParts(targetDate);
|
||||
|
||||
function getPart(parts, type) {
|
||||
var match = parts.find(function (part) {
|
||||
return part && part.type === type;
|
||||
});
|
||||
return match ? String(match.value || '') : '';
|
||||
}
|
||||
|
||||
function toTitleCase(value) {
|
||||
return String(value || '').toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
|
||||
return String(letter || '').toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
h: String(Number(getPart(numericParts, 'hour')) || 0),
|
||||
hh: getPart(numericParts, 'hour'),
|
||||
m: String(Number(getPart(numericParts, 'minute')) || 0),
|
||||
mm: getPart(numericParts, 'minute'),
|
||||
s: String(Number(getPart(numericParts, 'second')) || 0),
|
||||
ss: getPart(numericParts, 'second'),
|
||||
d: String(Number(getPart(numericParts, 'day')) || 0),
|
||||
dd: getPart(numericParts, 'day'),
|
||||
M: String(Number(getPart(numericParts, 'month')) || 0),
|
||||
MM: getPart(numericParts, 'month'),
|
||||
y: String(Number(getPart(numericParts, 'year')) || 0),
|
||||
yyyy: getPart(numericParts, 'year'),
|
||||
yy: String(Number(String(getPart(numericParts, 'year')).slice(-2)) || 0).toString().padStart(2, '0'),
|
||||
ddd: toTitleCase(getPart(weekdayShort, 'weekday')),
|
||||
dddd: toTitleCase(getPart(weekdayLong, 'weekday')),
|
||||
MMM: toTitleCase(getPart(monthShort, 'month')),
|
||||
MMMM: toTitleCase(getPart(monthLong, 'month')),
|
||||
a: toTitleCase(getPart(ampm, 'dayPeriod')),
|
||||
tz: resolvedTimeZone,
|
||||
tz_short: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
|
||||
timeZone: resolvedTimeZone,
|
||||
timeZoneName: 'short'
|
||||
}).formatToParts(targetDate), 'timeZoneName'),
|
||||
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
|
||||
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTimeDatePlaceholder(values, expression) {
|
||||
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
|
||||
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
|
||||
}
|
||||
|
||||
var parsed = String(expression || '').trim();
|
||||
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
|
||||
}
|
||||
|
||||
function renderTimeDatePlaceholderChips() {
|
||||
if (window.timeDatePlaceholders && typeof window.timeDatePlaceholders.renderChips === 'function') {
|
||||
return window.timeDatePlaceholders.renderChips();
|
||||
}
|
||||
|
||||
var tokens = [
|
||||
'h',
|
||||
'hh',
|
||||
'm',
|
||||
'mm',
|
||||
's',
|
||||
'ss',
|
||||
'd',
|
||||
'dd',
|
||||
'M',
|
||||
'MM',
|
||||
'y',
|
||||
'yyyy',
|
||||
'yy',
|
||||
'ddd',
|
||||
'dddd',
|
||||
'MMM',
|
||||
'MMMM',
|
||||
'a',
|
||||
'tz',
|
||||
'tz_short'
|
||||
];
|
||||
|
||||
if (typeof placeholderChips.renderChips === 'function') {
|
||||
return placeholderChips.renderChips(tokens);
|
||||
}
|
||||
|
||||
return tokens.map(function (token) {
|
||||
return '<span class="chip">{{' + token + '}}</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getEditorContentStyle() {
|
||||
return 'body { text-align: center; }';
|
||||
}
|
||||
|
||||
function renderTemplate(format, timeZone, date) {
|
||||
var template = String(format || '').trim();
|
||||
var values = getFormattedParts(timeZone, date);
|
||||
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
|
||||
return String(resolveTimeDatePlaceholder(values, key) || '');
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentValue(region, existingContent) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
value: current.value !== undefined && current.value !== null ? String(current.value) : (current.text !== undefined && current.text !== null ? String(current.text) : ''),
|
||||
timezone: current.timezone !== undefined ? current.timezone : (current.time_zone !== undefined ? current.time_zone : getDefaultTimeZone())
|
||||
};
|
||||
}
|
||||
|
||||
function getTextStyle(region, current) {
|
||||
var defaultStyle = getDefaultStyle();
|
||||
return {
|
||||
font_family: current.font_family || region.font_family || defaultStyle.font_family,
|
||||
font_size: current.font_size || defaultStyle.font_size,
|
||||
font_color: current.font_color || region.font_color || defaultStyle.font_color
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview(region, regionContent, style, scale) {
|
||||
var current = regionContent && typeof regionContent === 'object' ? regionContent : {};
|
||||
var format = current.value !== undefined && current.value !== null
|
||||
? String(current.value)
|
||||
: (current.text !== undefined && current.text !== null ? String(current.text) : '');
|
||||
var timeZone = resolveTimeZone(current.timezone || current.time_zone || '');
|
||||
var textStyle = getTextStyle(region, current);
|
||||
var fontFamily = textStyle.font_family ? 'font-family:' + escapeHtml(textStyle.font_family) + ';' : '';
|
||||
var fontSize = textStyle.font_size ? 'font-size:' + Math.max(1, Math.round(Number(textStyle.font_size))) + 'px;' : '';
|
||||
var fontColor = textStyle.font_color ? 'color:' + escapeHtml(textStyle.font_color) + ';' : '';
|
||||
var rendered = renderTemplate(format, timeZone, new Date());
|
||||
var previewScale = Math.max(0.01, Number(scale || 1) || 1);
|
||||
var previewWidth = Math.max(1, Math.round(Number(region && (region.width || region.pixelWidth) ? (region.width || region.pixelWidth) : 0) || 1));
|
||||
var previewHeight = Math.max(1, Math.round(Number(region && (region.height || region.pixelHeight) ? (region.height || region.pixelHeight) : 0) || 1));
|
||||
var contentStyle = 'width:' + previewWidth + 'px;height:' + previewHeight + 'px;transform:scale(' + previewScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;font-variant-numeric:tabular-nums;';
|
||||
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="width:100%;height:100%;overflow:hidden;"><div class="template-region-text-scale" style="' + contentStyle + '">' + sanitizePreviewHtml(rendered) + '</div></div>';
|
||||
}
|
||||
|
||||
function buildTimezoneOptionsMarkup(selectedTimeZone) {
|
||||
var supportedValues = COMMON_TIME_ZONES;
|
||||
if (Intl.supportedValuesOf) {
|
||||
try {
|
||||
supportedValues = Intl.supportedValuesOf('timeZone');
|
||||
} catch (_error) {
|
||||
supportedValues = COMMON_TIME_ZONES;
|
||||
}
|
||||
}
|
||||
|
||||
var seen = Object.create(null);
|
||||
var options = [];
|
||||
[selectedTimeZone].concat(supportedValues).forEach(function (timeZone) {
|
||||
var value = String(timeZone || '').trim();
|
||||
if (!value || seen[value]) {
|
||||
return;
|
||||
}
|
||||
seen[value] = true;
|
||||
options.push('<option value="' + escapeHtml(value) + '"></option>');
|
||||
});
|
||||
return options.join('');
|
||||
}
|
||||
|
||||
function renderPlaceholderChips() {
|
||||
return renderTimeDatePlaceholderChips();
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var value = current.value !== undefined && current.value !== null
|
||||
? String(current.value)
|
||||
: (current.text !== undefined && current.text !== null ? String(current.text) : '');
|
||||
var timeZone = resolveTimeZone(current.timezone || current.time_zone || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<span class="chip">Time / Date</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea id="region_time_date_text_' + region.id + '" class="editor-source form-control" rows="4" name="region_text_' + region.id + '">' + escapeHtml(value) + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<label class="form-label" for="region_time_date_timezone_' + region.id + '">Time zone</label>' +
|
||||
'<input id="region_time_date_timezone_' + region.id + '" type="text" name="region_timezone_' + region.id + '" class="form-control" value="' + escapeHtml(timeZone) + '" list="region_time_date_timezones_' + region.id + '" placeholder="Europe/London" autocomplete="off" />' +
|
||||
'<datalist id="region_time_date_timezones_' + region.id + '">' + buildTimezoneOptionsMarkup(timeZone) + '</datalist>' +
|
||||
'</div>' +
|
||||
'<div>' +
|
||||
'<div class="api-region-placeholder-title mb-2">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
var current = context.current || {};
|
||||
return {
|
||||
region: context.region,
|
||||
current: {
|
||||
value: current.value !== undefined && current.value !== null ? String(current.value) : (current.text !== undefined && current.text !== null ? String(current.text) : ''),
|
||||
timezone: resolveTimeZone(current.timezone || current.time_zone || '')
|
||||
},
|
||||
style: context.style || {}
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent) {
|
||||
var current = getCurrentValue(region, existingContent || {});
|
||||
var textarea = card && card.querySelector ? card.querySelector('textarea[name="region_text_' + region.id + '"]') : null;
|
||||
var timezoneInput = card && card.querySelector ? card.querySelector('input[name="region_timezone_' + region.id + '"]') : null;
|
||||
return {
|
||||
value: String(textarea && textarea.value !== undefined ? textarea.value : (current.value !== undefined && current.value !== null ? current.value : (current.text !== undefined && current.text !== null ? current.text : ''))),
|
||||
timezone: String(timezoneInput && timezoneInput.value !== undefined ? timezoneInput.value : (current.timezone || getDefaultTimeZone())),
|
||||
existingContent: existingContent || {},
|
||||
style: getTextStyle(region, current)
|
||||
};
|
||||
}
|
||||
|
||||
registry.register('time-date', {
|
||||
label: 'Time / Date',
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 180 };
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
getEditorContentStyle: getEditorContentStyle,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,181 @@
|
||||
// Video region helpers for editor previews and duration probing.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showUploadWarning(message) {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(message, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(message);
|
||||
}
|
||||
|
||||
function renderPreview(region, value) {
|
||||
var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
|
||||
var src = String(content ? content.value : value || '').trim();
|
||||
if (!src) {
|
||||
return '<div class="slide-preview-placeholder">Video</div>';
|
||||
}
|
||||
return '<video class="slide-preview-video" src="' + escapeHtml(src) + '" autoplay loop muted playsinline preload="metadata" style="width:100%;height:100%;object-fit:contain;display:block;"></video>';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var currentDuration = String(context.currentDuration || '');
|
||||
var uploadConfig = context.uploadConfig || {};
|
||||
var uploadVideoMaxLabel = String(uploadConfig.limitLabel || context.uploadVideoMaxLabel || '1 GB');
|
||||
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'video/mp4, video/webm, video/ogg');
|
||||
var uploadHelpText = String(uploadConfig.helpText || 'MP4, WebM, or Ogg');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="video">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Video</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
|
||||
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop a video here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
|
||||
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'<input type="hidden" name="existing_region_video_duration_' + region.id + '" value="' + escapeHtml(currentDuration) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
|
||||
return {
|
||||
region: context.region,
|
||||
current: String(context.current || ''),
|
||||
currentDuration: String(context.currentDuration || ''),
|
||||
uploadConfig: {
|
||||
accept: Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['video/mp4', 'video/webm', 'video/ogg'],
|
||||
limitBytes: defaultConfig.limitBytes,
|
||||
limitLabel: defaultConfig.limitLabel || context.uploadVideoMaxLabel || '1 GB',
|
||||
helpText: defaultConfig.helpText || 'MP4, WebM, or Ogg'
|
||||
},
|
||||
uploadVideoMaxLabel: context.uploadVideoMaxLabel || '1 GB'
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent) {
|
||||
var current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
var videoHidden = card && card.querySelector ? card.querySelector('input[type="hidden"][name="existing_region_video_' + region.id + '"]') : null;
|
||||
var videoFileInput = card && card.querySelector ? card.querySelector('input[type="file"][name="region_video_' + region.id + '"]') : null;
|
||||
|
||||
return {
|
||||
value: videoFileInput && videoFileInput.dataset.previewUrl ? videoFileInput.dataset.previewUrl : (videoHidden ? videoHidden.value : (current && current.value !== undefined ? current.value : '')),
|
||||
existingContent: existingContent || {}
|
||||
};
|
||||
}
|
||||
|
||||
function handleMediaInputChange(context) {
|
||||
if (!context || !context.file || typeof context.uploadFile !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var uploadConfig = context.uploadConfig || {};
|
||||
var accept = uploadConfig.accept || ['video/mp4', 'video/webm', 'video/ogg'];
|
||||
var limitBytes = Number(uploadConfig.limitBytes || 1024 * 1024 * 1024);
|
||||
|
||||
if (!utils.fileMatchesAccept || !utils.fileMatchesAccept(context.file, accept)) {
|
||||
showUploadWarning('This video region accepts MP4, WebM, or Ogg files.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number(context.file.size || 0) > limitBytes) {
|
||||
showUploadWarning('File must be ' + String(uploadConfig.limitLabel || '1 GB') + ' or smaller.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return context.uploadFile(context.file);
|
||||
}
|
||||
|
||||
function syncDuration(card, source, options) {
|
||||
var helpers = options || {};
|
||||
if (!card || typeof helpers.getHiddenInput !== 'function' || typeof helpers.loadDuration !== 'function') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
var hidden = helpers.getHiddenInput(card);
|
||||
if (!hidden) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
var mediaPath = String(source || '').trim();
|
||||
if (!mediaPath) {
|
||||
hidden.value = '';
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return helpers.loadDuration(mediaPath).then(function (duration) {
|
||||
hidden.value = String(duration);
|
||||
}).catch(function () {
|
||||
hidden.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('video', {
|
||||
label: 'Video',
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 240 };
|
||||
},
|
||||
getMediaUploadConfig: function (context) {
|
||||
var defaultConfig = context && context.defaultConfig ? context.defaultConfig : {};
|
||||
|
||||
return {
|
||||
accept: ['video/mp4', 'video/webm', 'video/ogg'],
|
||||
limitBytes: defaultConfig.limitBytes,
|
||||
limitLabel: defaultConfig.limitLabel,
|
||||
helpText: 'MP4, WebM, or Ogg'
|
||||
};
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext,
|
||||
handleMediaInputChange: handleMediaInputChange,
|
||||
syncDuration: syncDuration
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,46 @@
|
||||
// Webpage region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function renderPreview(value) {
|
||||
var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
|
||||
var src = String(content ? content.value : value || '').trim();
|
||||
if (!src) {
|
||||
return '<div class="slide-preview-placeholder">Webpage</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Webpage</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">Webpage URL' +
|
||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">The webpage will be loaded in an iframe.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
registry.register('webpage', {
|
||||
label: 'Webpage',
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 420, height: 240 };
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard
|
||||
});
|
||||
}());
|
||||
@@ -3,6 +3,41 @@
|
||||
return;
|
||||
}
|
||||
|
||||
function updateBackgroundTasksCardHeight() {
|
||||
var card = document.getElementById('background-tasks-task-card');
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cardRect = card.getBoundingClientRect();
|
||||
var bottomInset = 16;
|
||||
var availableHeight = Math.max(0, window.innerHeight - cardRect.top - bottomInset);
|
||||
card.style.setProperty('--background-tasks-task-card-max-height', availableHeight + 'px');
|
||||
}
|
||||
|
||||
var scheduleUpdate = window.requestAnimationFrame ? function () {
|
||||
window.requestAnimationFrame(updateBackgroundTasksCardHeight);
|
||||
} : updateBackgroundTasksCardHeight;
|
||||
|
||||
if (window.addEventListener) {
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
window.addEventListener('orientationchange', scheduleUpdate);
|
||||
window.addEventListener('load', scheduleUpdate);
|
||||
}
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
try {
|
||||
var summary = document.getElementById('background-tasks-summary');
|
||||
if (summary) {
|
||||
new ResizeObserver(scheduleUpdate).observe(summary);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore ResizeObserver setup failures.
|
||||
}
|
||||
}
|
||||
|
||||
scheduleUpdate();
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('message')) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Shared placeholder chip markup and copy-to-clipboard behavior.
|
||||
|
||||
(function () {
|
||||
var root = window;
|
||||
var CHIP_ATTR = 'data-placeholder-chip';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function copyTextToClipboard(text) {
|
||||
var value = String(text === undefined || text === null ? '' : text);
|
||||
if (!value) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function' && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(value).then(function () {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
var success = false;
|
||||
try {
|
||||
success = document.execCommand('copy');
|
||||
} catch (_error) {
|
||||
success = false;
|
||||
}
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
resolve(success);
|
||||
});
|
||||
}
|
||||
|
||||
function findChipElement(target) {
|
||||
if (!target || typeof target.closest !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return target.closest('[' + CHIP_ATTR + ']');
|
||||
}
|
||||
|
||||
function activateChipCopy(chip) {
|
||||
if (!chip) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return copyTextToClipboard(chip.getAttribute('data-copy-text') || chip.textContent || '');
|
||||
}
|
||||
|
||||
function handleChipInteraction(event) {
|
||||
var chip = findChipElement(event && event.target);
|
||||
if (!chip) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'keydown') {
|
||||
var key = String(event.key || '');
|
||||
if (key !== 'Enter' && key !== ' ') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
activateChipCopy(chip);
|
||||
}
|
||||
|
||||
function ensureCopyHandler() {
|
||||
if (root.__placeholderChipCopyHandlerInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.__placeholderChipCopyHandlerInstalled = true;
|
||||
document.addEventListener('click', handleChipInteraction, true);
|
||||
document.addEventListener('keydown', handleChipInteraction, true);
|
||||
}
|
||||
|
||||
function renderChip(token) {
|
||||
var copyText = '{{' + String(token || '') + '}}';
|
||||
var escapedCopyText = escapeHtml(copyText);
|
||||
ensureCopyHandler();
|
||||
|
||||
return '<span class="chip" ' + CHIP_ATTR + '="1" data-copy-text="' + escapedCopyText + '" role="button" tabindex="0" title="Click to copy ' + escapedCopyText + '" aria-label="Copy ' + escapedCopyText + '">' + escapedCopyText + '</span>';
|
||||
}
|
||||
|
||||
function renderChips(tokens) {
|
||||
var list = Array.isArray(tokens) ? tokens : [];
|
||||
return list.map(function (token) {
|
||||
return renderChip(token);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
root.placeholderChips = {
|
||||
renderChip: renderChip,
|
||||
renderChips: renderChips,
|
||||
copyTextToClipboard: copyTextToClipboard,
|
||||
ensureCopyHandler: ensureCopyHandler
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,131 @@
|
||||
// Shared placeholder parsing and resolution helpers for browser-rendered API content.
|
||||
|
||||
(function () {
|
||||
var root = window;
|
||||
var transformPattern = /^(upper|lower|title)\(\)$/i;
|
||||
|
||||
function resolvePath(value, path) {
|
||||
var current = value;
|
||||
if (!path) {
|
||||
return current;
|
||||
}
|
||||
|
||||
String(path).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
current = current[segment];
|
||||
});
|
||||
|
||||
return current === undefined || current === null ? '' : current;
|
||||
}
|
||||
|
||||
function parsePlaceholderExpression(expression) {
|
||||
var raw = String(expression || '').trim();
|
||||
var segments = raw ? raw.split('.') : [];
|
||||
var transforms = [];
|
||||
|
||||
while (segments.length) {
|
||||
var candidate = String(segments[segments.length - 1] || '').trim();
|
||||
if (!transformPattern.test(candidate)) {
|
||||
break;
|
||||
}
|
||||
|
||||
transforms.unshift(candidate.replace(/\(\)$/g, '').toLowerCase());
|
||||
segments.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
path: segments.join('.'),
|
||||
transforms: transforms
|
||||
};
|
||||
}
|
||||
|
||||
function applyTransform(value, transform) {
|
||||
var text = String(value === undefined || value === null ? '' : value);
|
||||
|
||||
if (transform === 'lower') {
|
||||
return text.toLowerCase();
|
||||
}
|
||||
|
||||
if (transform === 'upper') {
|
||||
return text.toUpperCase();
|
||||
}
|
||||
|
||||
if (transform === 'title') {
|
||||
return text.toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
|
||||
return String(letter || '').toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function resolvePlaceholderExpression(value, expression) {
|
||||
var parsed = parsePlaceholderExpression(expression);
|
||||
var resolved = resolvePath(value, parsed.path);
|
||||
|
||||
parsed.transforms.forEach(function (transform) {
|
||||
resolved = applyTransform(resolved, transform);
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function formatPlaceholderValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function collectPlaceholderFieldPaths(value) {
|
||||
var output = [];
|
||||
|
||||
function walk(currentValue, prefix) {
|
||||
if (!currentValue || typeof currentValue !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(currentValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(currentValue).forEach(function (key) {
|
||||
var nextPath = prefix ? prefix + '.' + key : key;
|
||||
var nextValue = currentValue[key];
|
||||
|
||||
if (nextValue && typeof nextValue === 'object') {
|
||||
walk(nextValue, nextPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.indexOf(nextPath) === -1) {
|
||||
output.push(nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
walk(value, '');
|
||||
return output;
|
||||
}
|
||||
|
||||
root.placeholderUtils = {
|
||||
resolvePath: resolvePath,
|
||||
parsePlaceholderExpression: parsePlaceholderExpression,
|
||||
resolvePlaceholderExpression: resolvePlaceholderExpression,
|
||||
formatPlaceholderValue: formatPlaceholderValue,
|
||||
collectPlaceholderFieldPaths: collectPlaceholderFieldPaths
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,48 @@
|
||||
// Shared time/date placeholder definitions used by editor renderers.
|
||||
|
||||
(function () {
|
||||
var root = window;
|
||||
var PLACEHOLDERS = [
|
||||
{ token: 'h', label: 'h' },
|
||||
{ token: 'hh', label: 'hh' },
|
||||
{ token: 'm', label: 'm' },
|
||||
{ token: 'mm', label: 'mm' },
|
||||
{ token: 's', label: 's' },
|
||||
{ token: 'ss', label: 'ss' },
|
||||
{ token: 'd', label: 'd' },
|
||||
{ token: 'dd', label: 'dd' },
|
||||
{ token: 'M', label: 'M' },
|
||||
{ token: 'MM', label: 'MM' },
|
||||
{ token: 'y', label: 'y' },
|
||||
{ token: 'yyyy', label: 'yyyy' },
|
||||
{ token: 'yy', label: 'yy' },
|
||||
{ token: 'ddd', label: 'ddd' },
|
||||
{ token: 'dddd', label: 'dddd' },
|
||||
{ token: 'MMM', label: 'MMM' },
|
||||
{ token: 'MMMM', label: 'MMMM' },
|
||||
{ token: 'a', label: 'a' },
|
||||
{ token: 'tz', label: 'tz' },
|
||||
{ token: 'tz_short', label: 'tz_short' }
|
||||
];
|
||||
|
||||
function getTokens() {
|
||||
return PLACEHOLDERS.slice();
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
if (root.placeholderChips && typeof root.placeholderChips.renderChips === 'function') {
|
||||
return root.placeholderChips.renderChips(PLACEHOLDERS.map(function (item) {
|
||||
return item.token;
|
||||
}));
|
||||
}
|
||||
|
||||
return PLACEHOLDERS.map(function (item) {
|
||||
return '<span class="chip">{{' + item.token + '}}</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
root.timeDatePlaceholders = {
|
||||
getTokens: getTokens,
|
||||
renderChips: renderChips
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,332 @@
|
||||
export function createSlideFormEditorController(options) {
|
||||
var settings = options || {};
|
||||
var templateFields = settings.templateFields || null;
|
||||
var templateSelectorLock = settings.templateSelectorLock || null;
|
||||
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
|
||||
var defaultFontSize = Math.max(1, Number(settings.defaultFontSize || 32));
|
||||
var fontFamilyFormats = String(settings.fontFamilyFormats || '').trim();
|
||||
var fontStylesheetHref = String(settings.fontStylesheetHref || '').trim();
|
||||
var defaultEditorFontFamily = 'Arial, Helvetica, sans-serif';
|
||||
var editorInstances = new Map();
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
|
||||
return null;
|
||||
};
|
||||
var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () {
|
||||
return '#111111';
|
||||
};
|
||||
|
||||
function normalizeEditorData(value) {
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
function normalizeEditorMarkup(value) {
|
||||
return String(value === undefined || value === null ? '' : value).trim();
|
||||
}
|
||||
|
||||
function normalizeFontSizeValue(value) {
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw.replace(/px$/, '')))));
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw))));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getEditorHiddenInput(regionId) {
|
||||
if (!templateFields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
return card ? card.querySelector('input[type="hidden"][name="region_text_' + regionId + '"]') : null;
|
||||
}
|
||||
|
||||
function getEditorFontSizeHiddenInput(regionId) {
|
||||
if (!templateFields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null;
|
||||
}
|
||||
|
||||
function getEditorRegionType(card) {
|
||||
if (!card) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (card.dataset && String(card.dataset.regionMediaType || '').trim()) {
|
||||
return String(card.dataset.regionMediaType || '').trim();
|
||||
}
|
||||
|
||||
var regionTypeInput = card.querySelector ? card.querySelector('input[type="hidden"][name="region_type[]"]') : null;
|
||||
return String(regionTypeInput && regionTypeInput.value || '').trim();
|
||||
}
|
||||
|
||||
function getEditorContentStyle(regionType) {
|
||||
var module = getRegionTypeModule(regionType);
|
||||
if (module && typeof module.getEditorContentStyle === 'function') {
|
||||
return String(module.getEditorContentStyle() || '').trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function syncEditorFontSizeHidden(regionId) {
|
||||
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
|
||||
if (!fontSizeHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeHidden.value) || String(defaultFontSize);
|
||||
}
|
||||
|
||||
function getTinymce() {
|
||||
return window.tinymce || null;
|
||||
}
|
||||
|
||||
function getThemeName() {
|
||||
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
|
||||
if (theme === 'auto') {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function getTinyMceThemeAssets() {
|
||||
var themeName = getThemeName();
|
||||
var skinName = themeName === 'dark' ? 'oxide-dark' : 'oxide';
|
||||
var skinBase = '/assets/vendor/tinymce/skins/ui/' + skinName;
|
||||
return {
|
||||
themeName: themeName,
|
||||
skinName: skinName,
|
||||
skinUrl: skinBase,
|
||||
contentCss: skinBase + '/content.css',
|
||||
bodyClass: themeName === 'dark' ? 'tinymce-theme-dark' : 'tinymce-theme-light'
|
||||
};
|
||||
}
|
||||
|
||||
function getEditorBackgroundColorValue() {
|
||||
var value = String(getEditorBackgroundColor() || '').trim();
|
||||
return value || '#111111';
|
||||
}
|
||||
|
||||
function getFontFamilyFormats() {
|
||||
if (fontFamilyFormats) {
|
||||
return fontFamilyFormats;
|
||||
}
|
||||
|
||||
return [
|
||||
'Default=inherit',
|
||||
'Arial=Arial,Helvetica,sans-serif',
|
||||
'Comic Sans MS=Comic Sans MS,cursive,sans-serif',
|
||||
'Courier New=Courier New,Courier,monospace',
|
||||
'Georgia=Georgia,serif',
|
||||
'Helvetica=Helvetica,Arial,sans-serif',
|
||||
'Impact=Impact,Charcoal,sans-serif',
|
||||
'Lucida Sans Unicode=Lucida Sans Unicode,Lucida Grande,sans-serif',
|
||||
'Palatino Linotype=Palatino Linotype,Book Antiqua,Palatino,serif',
|
||||
'Tahoma=Tahoma,Geneva,sans-serif',
|
||||
'Times New Roman=Times New Roman,Times,serif',
|
||||
'Trebuchet MS=Trebuchet MS,Helvetica,sans-serif',
|
||||
'Verdana=Verdana,Geneva,sans-serif'
|
||||
].join(';');
|
||||
}
|
||||
|
||||
function getContentCss() {
|
||||
var themeAssets = getTinyMceThemeAssets();
|
||||
return fontStylesheetHref
|
||||
? [themeAssets.contentCss, fontStylesheetHref]
|
||||
: themeAssets.contentCss;
|
||||
}
|
||||
|
||||
function attachEditorEvents(regionId, editor) {
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var source = editor && editor.targetElm ? editor.targetElm : null;
|
||||
|
||||
function syncState() {
|
||||
var currentEditor = editorInstances.get(regionId);
|
||||
if (currentEditor !== editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof editor.save === 'function') {
|
||||
editor.save();
|
||||
}
|
||||
var content = editor.getContent({ format: 'html' });
|
||||
if (hidden) {
|
||||
hidden.value = content;
|
||||
}
|
||||
if (source) {
|
||||
source.value = content;
|
||||
}
|
||||
if (templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
|
||||
templateSelectorLock.markEdited();
|
||||
}
|
||||
requestPreviewRender();
|
||||
}
|
||||
|
||||
editor.on('init', function () {
|
||||
var content = editor.getContent({ format: 'html' });
|
||||
if (hidden) {
|
||||
hidden.value = content;
|
||||
}
|
||||
if (source) {
|
||||
source.value = content;
|
||||
}
|
||||
syncEditorFontSizeHidden(regionId);
|
||||
syncState();
|
||||
});
|
||||
|
||||
['change', 'keyup', 'undo', 'redo', 'SetContent', 'Paste', 'input', 'NodeChange'].forEach(function (eventName) {
|
||||
editor.on(eventName, syncState);
|
||||
});
|
||||
}
|
||||
|
||||
function createEditorForHolder(holder) {
|
||||
if (!holder) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
var regionId = holder.getAttribute('data-region-id');
|
||||
var card = holder.closest ? holder.closest('[data-region-id]') : null;
|
||||
var source = holder.querySelector('.editor-source');
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var tinymce = getTinymce();
|
||||
var themeAssets = getTinyMceThemeAssets();
|
||||
var editorContentStyle = getEditorContentStyle(getEditorRegionType(card));
|
||||
|
||||
if (!source || !tinymce || typeof tinymce.init !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
source.value = normalizeEditorMarkup(normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : '')));
|
||||
|
||||
if (!source.id) {
|
||||
source.id = 'slide-editor-region-' + regionId;
|
||||
}
|
||||
|
||||
return tinymce.init({
|
||||
target: source,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
statusbar: true,
|
||||
resize: true,
|
||||
plugins: 'lists link code advlist fullscreen',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist | fullscreen',
|
||||
toolbar_mode: 'sliding',
|
||||
license_key: 'gpl',
|
||||
skin: themeAssets.skinName,
|
||||
skin_url: themeAssets.skinUrl,
|
||||
content_css: getContentCss(),
|
||||
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; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
font_family_formats: getFontFamilyFormats(),
|
||||
font_size_input_default_unit: 'px',
|
||||
forced_root_block: 'p',
|
||||
force_br_newlines: false,
|
||||
newline_behavior: 'default',
|
||||
setup: function (editor) {
|
||||
editorInstances.set(regionId, editor);
|
||||
attachEditorEvents(regionId, editor);
|
||||
}
|
||||
}).then(function (editors) {
|
||||
var editor = Array.isArray(editors) && editors.length ? editors[0] : tinymce.get(source.id);
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
editorInstances.set(regionId, editor);
|
||||
if (editor.targetElm) {
|
||||
editor.targetElm.value = editor.getContent({ format: 'html' });
|
||||
}
|
||||
return editor;
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to initialize TinyMCE.', error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function renderEditors() {
|
||||
if (!templateFields) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
watchThemeChanges();
|
||||
|
||||
var holders = Array.prototype.slice.call(templateFields.querySelectorAll('.editor-holder'));
|
||||
return Promise.all(holders.map(function (holder) {
|
||||
return createEditorForHolder(holder);
|
||||
}));
|
||||
}
|
||||
|
||||
function syncEditors() {
|
||||
if (!templateFields) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) {
|
||||
var regionId = holder.getAttribute('data-region-id');
|
||||
var editor = editorInstances.get(regionId);
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
|
||||
if (!editor || !hidden) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
hidden.value = editor.getContent({ format: 'html' });
|
||||
syncEditorFontSizeHidden(regionId);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return Promise.all(saves);
|
||||
}
|
||||
|
||||
function destroyEditors() {
|
||||
editorInstances.forEach(function (editor) {
|
||||
if (editor && typeof editor.remove === 'function') {
|
||||
editor.remove();
|
||||
}
|
||||
});
|
||||
editorInstances.clear();
|
||||
|
||||
var tinymce = getTinymce();
|
||||
if (tinymce && typeof tinymce.remove === 'function') {
|
||||
tinymce.remove();
|
||||
}
|
||||
}
|
||||
|
||||
var themeObserver = null;
|
||||
|
||||
function watchThemeChanges() {
|
||||
if (themeObserver || !window.MutationObserver || !templateFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
themeObserver = new MutationObserver(function () {
|
||||
destroyEditors();
|
||||
renderEditors();
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-bs-theme']
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
renderEditors: renderEditors,
|
||||
syncEditors: syncEditors,
|
||||
destroyEditors: function () {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
themeObserver = null;
|
||||
}
|
||||
destroyEditors();
|
||||
},
|
||||
watchThemeChanges: watchThemeChanges
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
export function createSlideFormPreviewHelpers(options) {
|
||||
var settings = options || {};
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
|
||||
var escapeHtml = typeof settings.escapeHtml === 'function'
|
||||
? settings.escapeHtml
|
||||
: function (value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
function sanitizeTagAttributes(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'],
|
||||
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 '';
|
||||
}
|
||||
|
||||
var attrs = [];
|
||||
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 sanitizePreviewHtml(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
output = 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', '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 + sanitizeTagAttributes(name, attrText) + '>';
|
||||
});
|
||||
|
||||
return output.replace(/<p\b([^>]*)>/gi, function (tag, attrText) {
|
||||
if (/\bstyle\s*=\s*/i.test(attrText)) {
|
||||
return tag.replace(/\bstyle\s*=\s*("([^"]*)"|'([^']*)')/i, function (full, quoted, doubleQuoted, singleQuoted) {
|
||||
var existingStyle = String(doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : '');
|
||||
if (/\bmargin\s*:/i.test(existingStyle)) {
|
||||
return full;
|
||||
}
|
||||
return 'style="margin:1em 0;' + existingStyle + '"';
|
||||
});
|
||||
}
|
||||
|
||||
return '<p' + attrText + ' style="margin:1em 0;">';
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreviewListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderPreviewListItem(child, tag); }).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizePreviewHtml(content || '') + nested + '</li>';
|
||||
}
|
||||
|
||||
return '<li>' + sanitizePreviewHtml(item || '') + '</li>';
|
||||
}
|
||||
|
||||
function renderPreviewTable(data) {
|
||||
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
var hasHeadings = Boolean(data.withHeadings);
|
||||
var body = rows.map(function (row, rowIndex) {
|
||||
var cells = Array.isArray(row) ? row : [];
|
||||
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizePreviewHtml(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="slide-preview-table">' + body + '</table>';
|
||||
}
|
||||
|
||||
function renderPreviewBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
if (block.type === 'header') {
|
||||
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizePreviewHtml(block.data.text || '') + '</h' + level + '>';
|
||||
}
|
||||
if (block.type === 'list') {
|
||||
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
var items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderPreviewListItem(item, tag); }).join('') + '</' + tag + '>';
|
||||
}
|
||||
if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
}
|
||||
if (block.type === 'code') {
|
||||
return '<pre><code>' + sanitizePreviewHtml(block.data.code || '') + '</code></pre>';
|
||||
}
|
||||
if (block.type === 'table') {
|
||||
return renderPreviewTable(block.data);
|
||||
}
|
||||
if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizePreviewHtml(block.data.text || '') + '</p>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderPreviewText(value) {
|
||||
var rawValue = value;
|
||||
if (rawValue && typeof rawValue === 'object') {
|
||||
if (rawValue.value !== undefined) {
|
||||
rawValue = rawValue.value;
|
||||
} else if (rawValue.text !== undefined) {
|
||||
rawValue = rawValue.text;
|
||||
} else if (rawValue.html !== undefined) {
|
||||
rawValue = rawValue.html;
|
||||
} else {
|
||||
try {
|
||||
rawValue = JSON.stringify(rawValue);
|
||||
} catch (_error) {
|
||||
rawValue = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
var raw = String(rawValue || '');
|
||||
if (!raw) {
|
||||
return '<div class="slide-preview-placeholder">Empty text</div>';
|
||||
}
|
||||
return sanitizePreviewHtml(raw);
|
||||
}
|
||||
|
||||
function renderPreviewTextRegion(region, value, style, scale) {
|
||||
var module = getRegionTypeModule('text');
|
||||
if (module && typeof module.renderPreview === 'function') {
|
||||
return module.renderPreview(region, value, style, scale);
|
||||
}
|
||||
|
||||
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
||||
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
|
||||
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + renderPreviewText(value) + '</div>';
|
||||
}
|
||||
|
||||
function startPreviewVideoPlayback(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var videos = root.querySelectorAll('video.slide-preview-video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
function tryPlay() {
|
||||
try {
|
||||
video.load();
|
||||
} catch (_error) {
|
||||
// Ignore load failures and try to play anyway.
|
||||
}
|
||||
|
||||
var playPromise = video.play && video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (video.readyState >= 2) {
|
||||
tryPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', tryPlay, { once: true });
|
||||
video.addEventListener('loadedmetadata', tryPlay, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sanitizePreviewHtml: sanitizePreviewHtml,
|
||||
sanitizeTagAttributes: sanitizeTagAttributes,
|
||||
renderPreviewListItem: renderPreviewListItem,
|
||||
renderPreviewTable: renderPreviewTable,
|
||||
renderPreviewBlock: renderPreviewBlock,
|
||||
renderPreviewText: renderPreviewText,
|
||||
renderPreviewTextRegion: renderPreviewTextRegion,
|
||||
startPreviewVideoPlayback: startPreviewVideoPlayback
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
// Region-specific helpers for slide forms.
|
||||
|
||||
import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-preview.js';
|
||||
|
||||
export function createSlideFormRegionHelpers(options) {
|
||||
var settings = options || {};
|
||||
var templateFields = settings.templateFields || null;
|
||||
var existingContent = settings.existingContent || {};
|
||||
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
|
||||
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var placeholderChips = window.placeholderChips || {};
|
||||
var escapeHtml = typeof settings.escapeHtml === 'function'
|
||||
? settings.escapeHtml
|
||||
: function (value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
var previewHelpers = createSlideFormPreviewHelpers({
|
||||
getRegionTypeModule: getRegionTypeModule,
|
||||
escapeHtml: escapeHtml
|
||||
});
|
||||
var defaultFontSize = Math.max(1, Number(settings.defaultFontSize || 32));
|
||||
var uploadMaxLabel = String(settings.uploadMaxLabel || '100 MB');
|
||||
var uploadVideoMaxLabel = String(settings.uploadVideoMaxLabel || '1 GB');
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw.replace(/px$/, '')))));
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw))));
|
||||
}
|
||||
return String(defaultFontSize);
|
||||
}
|
||||
|
||||
var sanitizePreviewHtml = previewHelpers.sanitizePreviewHtml;
|
||||
var sanitizeTagAttributes = previewHelpers.sanitizeTagAttributes;
|
||||
var renderPreviewListItem = previewHelpers.renderPreviewListItem;
|
||||
var renderPreviewTable = previewHelpers.renderPreviewTable;
|
||||
var renderPreviewBlock = previewHelpers.renderPreviewBlock;
|
||||
var renderPreviewText = previewHelpers.renderPreviewText;
|
||||
var renderPreviewTextRegion = previewHelpers.renderPreviewTextRegion;
|
||||
var startPreviewVideoPlayback = previewHelpers.startPreviewVideoPlayback;
|
||||
|
||||
function getCurrentTextStyle(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var textRegion = getRegionTypeModule('text');
|
||||
var defaultStyle = window.pulseRegionTypes && typeof window.pulseRegionTypes.getDefaultRegionStyle === 'function'
|
||||
? window.pulseRegionTypes.getDefaultRegionStyle('text')
|
||||
: textRegion && typeof textRegion.getDefaultStyle === 'function'
|
||||
? textRegion.getDefaultStyle()
|
||||
: {
|
||||
font_family: 'Arial',
|
||||
font_size: defaultFontSize,
|
||||
font_color: '#000000'
|
||||
};
|
||||
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 || defaultStyle.font_size || defaultFontSize)),
|
||||
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function getRssFeedById(feedId) {
|
||||
var normalizedId = Number(feedId || 0);
|
||||
return rssFeeds.find(function (feed) {
|
||||
return Number(feed.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getRssFieldList(feedId) {
|
||||
var module = getRegionTypeModule('rss');
|
||||
if (module && typeof module.getFieldList === 'function') {
|
||||
return module.getFieldList(feedId, rssFeeds);
|
||||
}
|
||||
|
||||
var feed = getRssFeedById(feedId);
|
||||
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
|
||||
var sampleJson = sampleItem && sampleItem.itemJson && typeof sampleItem.itemJson === 'object' ? sampleItem.itemJson : null;
|
||||
|
||||
function walkFields(value, prefix, output) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
if (key === 'rawXml') {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextPath = prefix ? prefix + '.' + key : key;
|
||||
var nextValue = value[key];
|
||||
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
|
||||
walkFields(nextValue, nextPath, output);
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.indexOf(nextPath) === -1) {
|
||||
output.push(nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fields = [];
|
||||
walkFields(sampleJson, '', fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel) {
|
||||
var fields = Array.isArray(fieldList) ? fieldList : [];
|
||||
var visibleFields = fields.slice(0, 8);
|
||||
var overflowFields = fields.slice(visibleFields.length);
|
||||
|
||||
if (!fields.length) {
|
||||
return '<span class="muted slide-image-file">' + escapeHtml(emptyLabel) + '</span>';
|
||||
}
|
||||
|
||||
var visibleMarkup = visibleFields.map(function (field) {
|
||||
return typeof placeholderChips.renderChip === 'function'
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
|
||||
if (!overflowFields.length) {
|
||||
return visibleMarkup;
|
||||
}
|
||||
|
||||
return '' +
|
||||
visibleMarkup +
|
||||
'<details class="w-100 mt-2" data-placeholder-chips-more>' +
|
||||
'<summary class="small text-body-secondary">Show ' + escapeHtml(overflowFields.length) + ' more fields</summary>' +
|
||||
'<div class="d-flex flex-wrap gap-2 mt-2">' + overflowFields.map(function (field) {
|
||||
return typeof placeholderChips.renderChip === 'function'
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('') + '</div>' +
|
||||
'</details>';
|
||||
}
|
||||
|
||||
function truncateJsonPreview(value, maxLength) {
|
||||
var text = '';
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2);
|
||||
} catch (_error) {
|
||||
text = String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var limit = Math.max(200, Number(maxLength || 1100));
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text.slice(0, limit).replace(/\s+$/, '') + '\n...';
|
||||
}
|
||||
|
||||
function buildApiSampleDataMarkup(sourceId, itemNumber, itemsPathOverride) {
|
||||
var source = getApiSourceById(sourceId);
|
||||
if (!source) {
|
||||
return '<div class="api-region-sample-meta">Select an API source to preview sample data.</div>';
|
||||
}
|
||||
|
||||
var item = getApiSourceItems(sourceId, itemsPathOverride)[Math.max(0, Math.max(1, Number(itemNumber || 1)) - 1)] || null;
|
||||
if (!item) {
|
||||
return '<div class="api-region-sample-meta">No sample item is available for the current selection.</div>';
|
||||
}
|
||||
|
||||
return '<pre class="api-region-sample-preview" data-api-sample-data-output>' + escapeHtml(truncateJsonPreview(item, 1100)) + '</pre>';
|
||||
}
|
||||
|
||||
function updatePlaceholderChipList(card, fieldList, emptyLabel) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = card.querySelector('[data-placeholder-chips]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
var chips = Array.isArray(fieldList) ? fieldList.map(function (field) {
|
||||
return typeof placeholderChips.renderChip === 'function'
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('') : '';
|
||||
|
||||
container.innerHTML = chips || '<span class="muted slide-image-file">' + escapeHtml(emptyLabel) + '</span>';
|
||||
}
|
||||
|
||||
function updateRssPlaceholderChips(regionId, feedId) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
|
||||
}
|
||||
|
||||
function getCurrentRssConfig(region) {
|
||||
var module = getRegionTypeModule('rss');
|
||||
if (module && typeof module.getCurrentConfig === 'function') {
|
||||
return module.getCurrentConfig(region, existingContent);
|
||||
}
|
||||
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
feed_id: current.feed_id === undefined || current.feed_id === null || current.feed_id === '' ? '' : Number(current.feed_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePath(value, path) {
|
||||
var current = value;
|
||||
if (!path) {
|
||||
return current;
|
||||
}
|
||||
String(path).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
return current === undefined || current === null ? '' : current;
|
||||
}
|
||||
|
||||
function substituteRssVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
|
||||
return escapeHtml(resolvePath(item, key || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function getRssItem(feedId, itemNumber) {
|
||||
var module = getRegionTypeModule('rss');
|
||||
if (module && typeof module.getItem === 'function') {
|
||||
return module.getItem(feedId, itemNumber, rssFeeds);
|
||||
}
|
||||
|
||||
var feed = getRssFeedById(feedId);
|
||||
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function getRssPreviewFallback(item) {
|
||||
var module = getRegionTypeModule('rss');
|
||||
if (module && typeof module.getPreviewFallback === 'function') {
|
||||
return module.getPreviewFallback(item);
|
||||
}
|
||||
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
|
||||
var title = String(item.title || '').trim();
|
||||
var description = String(item.description || '').trim();
|
||||
var summary = [];
|
||||
if (title) {
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function getApiSourceById(sourceId) {
|
||||
var normalizedId = Number(sourceId || 0);
|
||||
return apiSources.find(function (source) {
|
||||
return Number(source.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getApiSourceItemsPath(source, overridePath) {
|
||||
if (overridePath === undefined || overridePath === null) {
|
||||
return String(source && (source.items_path || source.itemsPath) || '').trim();
|
||||
}
|
||||
|
||||
return String(overridePath || '').trim();
|
||||
}
|
||||
|
||||
function getApiSourceItems(sourceId, itemsPathOverride) {
|
||||
var source = getApiSourceById(sourceId);
|
||||
var module = getRegionTypeModule('api');
|
||||
if (module && typeof module.getItems === 'function') {
|
||||
return module.getItems(sourceId, apiSources, itemsPathOverride);
|
||||
}
|
||||
|
||||
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
|
||||
var itemsPath = getApiSourceItemsPath(source, itemsPathOverride);
|
||||
if (itemsPath !== undefined && itemsPath !== null && itemsPath !== '') {
|
||||
var current = responseJson;
|
||||
String(itemsPath).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
return Array.isArray(current) ? current : [];
|
||||
}
|
||||
if (Array.isArray(responseJson)) {
|
||||
return responseJson;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.items)) {
|
||||
return responseJson.items;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.results)) {
|
||||
return responseJson.results;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.data)) {
|
||||
return responseJson.data;
|
||||
}
|
||||
return responseJson ? [responseJson] : [];
|
||||
}
|
||||
|
||||
function getApiItemCount(sourceId, itemsPathOverride) {
|
||||
var module = getRegionTypeModule('api');
|
||||
if (module && typeof module.getItemCount === 'function') {
|
||||
return module.getItemCount(sourceId, apiSources, itemsPathOverride);
|
||||
}
|
||||
|
||||
return getApiSourceItems(sourceId, itemsPathOverride).length;
|
||||
}
|
||||
|
||||
function getApiFieldList(sourceId, itemsPathOverride) {
|
||||
var sampleItem = getApiSourceItems(sourceId, itemsPathOverride)[0] || null;
|
||||
if (typeof placeholderUtils.collectPlaceholderFieldPaths === 'function') {
|
||||
return placeholderUtils.collectPlaceholderFieldPaths(sampleItem && typeof sampleItem === 'object' ? sampleItem : null);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function updateApiPlaceholderChips(regionId, sourceId, itemsPathOverride) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = card.querySelector('[data-placeholder-chips]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.');
|
||||
}
|
||||
|
||||
function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = card.querySelector('[data-api-sample-data-panel]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = buildApiSampleDataMarkup(sourceId, itemNumber, itemsPathOverride);
|
||||
}
|
||||
|
||||
function updateApiItemNumberLimit(regionId, sourceId, itemsPathOverride) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var input = card.querySelector('input[name="region_api_item_number_' + regionId + '"]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var itemCount = Math.max(1, Number(getApiItemCount(sourceId, itemsPathOverride) || 1));
|
||||
input.setAttribute('max', String(itemCount));
|
||||
if (Number(input.value || 0) > itemCount) {
|
||||
input.value = String(itemCount);
|
||||
}
|
||||
}
|
||||
|
||||
function clampApiItemNumber(regionId, sourceId, itemNumber, itemsPathOverride) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var input = card.querySelector('input[name="region_api_item_number_' + regionId + '"]');
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var itemCount = Math.max(1, Number(getApiItemCount(sourceId, itemsPathOverride) || 1));
|
||||
var parsedValue = Math.max(1, Number(itemNumber || 1));
|
||||
var clampedValue = Math.min(itemCount, Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : 1);
|
||||
input.setAttribute('max', String(itemCount));
|
||||
input.value = String(clampedValue);
|
||||
return String(clampedValue);
|
||||
}
|
||||
|
||||
function getCurrentApiConfig(region) {
|
||||
var module = getRegionTypeModule('api');
|
||||
if (module && typeof module.getCurrentConfig === 'function') {
|
||||
return module.getCurrentConfig(region, existingContent, apiSources);
|
||||
}
|
||||
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var source = getApiSourceById(current.source_id);
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
source_id: current.source_id === undefined || current.source_id === null || current.source_id === '' ? '' : Number(current.source_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
items_path: Object.prototype.hasOwnProperty.call(current, 'items_path') ? String(current.items_path === undefined || current.items_path === null ? '' : current.items_path) : undefined,
|
||||
default_items_path: String(source && (source.items_path || source.itemsPath) || '').trim(),
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
function reduceAspectRatio(width, height) {
|
||||
var a = Math.max(1, Math.round(Number(width || 0) || 1));
|
||||
var b = Math.max(1, Math.round(Number(height || 0) || 1));
|
||||
var x = a;
|
||||
var y = b;
|
||||
|
||||
while (y !== 0) {
|
||||
var remainder = x % y;
|
||||
x = y;
|
||||
y = remainder;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round(a / x)) + ':' + Math.max(1, Math.round(b / x));
|
||||
}
|
||||
|
||||
function getCurrentRegionValue(region) {
|
||||
var current = existingContent[region.region_key];
|
||||
return current && current.value !== undefined ? current.value : '';
|
||||
}
|
||||
|
||||
function getCurrentTimeDateConfig(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
value: current.value !== undefined ? current.value : '',
|
||||
timezone: current.timezone !== undefined ? current.timezone : (current.time_zone !== undefined ? current.time_zone : '')
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentRegionVideoDuration(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000;
|
||||
return Number.isFinite(duration) && duration > 0 ? String(duration) : '';
|
||||
}
|
||||
|
||||
function buildRegionEditorCardContext(region) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (module && typeof module.buildEditorCardContext === 'function') {
|
||||
return module.buildEditorCardContext({
|
||||
region: region,
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
currentDuration: getCurrentRegionVideoDuration(region),
|
||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : getCurrentRssConfig(region),
|
||||
style: getCurrentTextStyle(region),
|
||||
fontSize: getCurrentTextStyle(region).font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
var selected = Number(feed.id) === Number(getCurrentRssConfig(region).feed_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(feed.id) + '"' + selected + '>' + escapeHtml(feed.name || ('Feed ' + feed.id)) + '</option>';
|
||||
}).join(''),
|
||||
sourceOptions: apiSources.map(function (source) {
|
||||
var selected = Number(source.id) === Number(getCurrentApiConfig(region).source_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
||||
}).join(''),
|
||||
placeholderChips: region.region_type === 'rss'
|
||||
? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('')
|
||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
|
||||
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path)
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
var rssConfig = getCurrentRssConfig(region);
|
||||
var apiConfig = getCurrentApiConfig(region);
|
||||
var currentContent = existingContent[region.region_key] || {};
|
||||
var apiItemsPath = apiConfig.items_path !== undefined ? apiConfig.items_path : apiConfig.default_items_path;
|
||||
|
||||
return {
|
||||
region: region,
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' ? currentContent : getCurrentRegionValue(region),
|
||||
currentDuration: getCurrentRegionVideoDuration(region),
|
||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
disableAudio: currentContent.disable_audio,
|
||||
config: region.region_type === 'api' ? apiConfig : rssConfig,
|
||||
style: textStyle,
|
||||
fontSize: textStyle.font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
var selected = Number(feed.id) === Number(rssConfig.feed_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(feed.id) + '"' + selected + '>' + escapeHtml(feed.name || ('Feed ' + feed.id)) + '</option>';
|
||||
}).join(''),
|
||||
sourceOptions: apiSources.map(function (source) {
|
||||
var selected = Number(source.id) === Number(apiConfig.source_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
||||
}).join(''),
|
||||
placeholderChips: region.region_type === 'rss'
|
||||
? getRssFieldList(rssConfig.feed_id).map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('')
|
||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
|
||||
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath)
|
||||
};
|
||||
}
|
||||
|
||||
function getRegionEditorCardHtml(region) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (!module || typeof module.renderEditorCard !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return module.renderEditorCard(buildRegionEditorCardContext(region));
|
||||
}
|
||||
|
||||
function getPreviewRegionContent(card, region) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (module && typeof module.buildPreviewRenderContext === 'function') {
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
|
||||
}
|
||||
|
||||
var current = existingContent[region.region_key];
|
||||
var content = current && typeof current === 'object' ? Object.assign({}, current) : {};
|
||||
if (!card) {
|
||||
return content;
|
||||
}
|
||||
|
||||
var hiddenInput = card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]');
|
||||
var textAreaInput = card.querySelector('textarea[name="region_text_' + region.id + '"]');
|
||||
var htmlInput = card.querySelector('textarea[name="region_html_' + region.id + '"]');
|
||||
var timeDateTimezoneInput = card.querySelector('input[name="region_timezone_' + region.id + '"]');
|
||||
var imageHidden = card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
|
||||
var imageFileInput = card.querySelector('input[type="file"][name="region_image_' + region.id + '"]');
|
||||
var videoHidden = card.querySelector('input[type="hidden"][name="existing_region_video_' + region.id + '"]');
|
||||
var videoFileInput = card.querySelector('input[type="file"][name="region_video_' + region.id + '"]');
|
||||
var webpageInput = card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
|
||||
var rtmpInput = card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
|
||||
var disableAudioInput = card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
|
||||
var rssFeedInput = card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]');
|
||||
var rssItemInput = card.querySelector('input[name="region_rss_item_number_' + region.id + '"]');
|
||||
var apiSourceInput = card.querySelector('select[name="region_api_source_id_' + region.id + '"]');
|
||||
var apiItemInput = card.querySelector('input[name="region_api_item_number_' + region.id + '"]');
|
||||
var apiItemsPathInput = card.querySelector('input[name="region_api_items_path_' + region.id + '"]');
|
||||
|
||||
if (region.region_type === 'image') {
|
||||
content.value = (imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : content.value || '');
|
||||
} else if (region.region_type === 'video') {
|
||||
content.value = (videoFileInput && videoFileInput.dataset.previewUrl) ? videoFileInput.dataset.previewUrl : (videoHidden ? videoHidden.value : content.value || '');
|
||||
} else if (region.region_type === 'webpage') {
|
||||
content.value = webpageInput ? webpageInput.value : (content.value || '');
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
content.value = rtmpInput ? rtmpInput.value : (content.value || '');
|
||||
content.disable_audio = disableAudioInput ? disableAudioInput.checked : (content.disable_audio === undefined ? true : Boolean(content.disable_audio));
|
||||
} else if (region.region_type === 'html') {
|
||||
content.value = htmlInput ? htmlInput.value : (content.value || '');
|
||||
} else if (region.region_type === 'time-date') {
|
||||
var timeDateCurrent = getCurrentTimeDateConfig(region);
|
||||
content.value = textAreaInput ? textAreaInput.value : (timeDateCurrent.value || '');
|
||||
content.timezone = timeDateTimezoneInput ? timeDateTimezoneInput.value : (timeDateCurrent.timezone || '');
|
||||
} else if (region.region_type === 'rss') {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
content.feed_id = rssFeedInput ? rssFeedInput.value : content.feed_id;
|
||||
content.item_number = rssItemInput ? rssItemInput.value : content.item_number;
|
||||
} else if (region.region_type === 'api') {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
|
||||
content.item_number = apiItemInput ? apiItemInput.value : content.item_number;
|
||||
content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path;
|
||||
} else {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (module && typeof module.buildPreviewRenderContext === 'function') {
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
var previewContent = getPreviewRegionContent(card, region);
|
||||
return {
|
||||
value: previewContent.value,
|
||||
timezone: previewContent.timezone,
|
||||
style: textStyle,
|
||||
existingContent: existingContent,
|
||||
feeds: rssFeeds,
|
||||
sources: apiSources,
|
||||
disable_audio: previewContent.disable_audio === undefined ? true : Boolean(previewContent.disable_audio),
|
||||
feed_id: previewContent.feed_id,
|
||||
item_number: previewContent.item_number,
|
||||
source_id: previewContent.source_id,
|
||||
items_path: previewContent.items_path,
|
||||
font_size: textStyle.font_size,
|
||||
font_color: textStyle.font_color,
|
||||
font_family: textStyle.font_family
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sanitizePreviewHtml: sanitizePreviewHtml,
|
||||
sanitizeTagAttributes: sanitizeTagAttributes,
|
||||
getCurrentTextStyle: getCurrentTextStyle,
|
||||
getRssFieldList: getRssFieldList,
|
||||
updateRssPlaceholderChips: updateRssPlaceholderChips,
|
||||
getCurrentRssConfig: getCurrentRssConfig,
|
||||
getApiFieldList: getApiFieldList,
|
||||
updateApiPlaceholderChips: updateApiPlaceholderChips,
|
||||
updateApiSampleDataPanel: updateApiSampleDataPanel,
|
||||
updateApiItemNumberLimit: updateApiItemNumberLimit,
|
||||
getCurrentApiConfig: getCurrentApiConfig,
|
||||
reduceAspectRatio: reduceAspectRatio,
|
||||
renderPreviewTextRegion: renderPreviewTextRegion,
|
||||
startPreviewVideoPlayback: startPreviewVideoPlayback,
|
||||
getRegionEditorCardHtml: getRegionEditorCardHtml,
|
||||
buildRegionEditorCardContext: buildRegionEditorCardContext,
|
||||
getPreviewRegionContent: getPreviewRegionContent,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext,
|
||||
getCurrentRegionValue: getCurrentRegionValue,
|
||||
getCurrentRegionVideoDuration: getCurrentRegionVideoDuration
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Template selection lock controller for slide forms.
|
||||
|
||||
export function createTemplateSelectorLockController(templateSelect) {
|
||||
var locked = false;
|
||||
var armed = false;
|
||||
|
||||
+264
-1787
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
// Slide image cropper modal wiring and file replacement helpers.
|
||||
|
||||
(function () {
|
||||
var templateFields = document.getElementById('template-fields');
|
||||
var modal = document.getElementById('slide-image-cropper-modal');
|
||||
|
||||
@@ -15,6 +15,35 @@
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(container);
|
||||
}
|
||||
|
||||
syncTablePaginationCards(container);
|
||||
syncTablePaginationPageClass();
|
||||
}
|
||||
|
||||
function syncTablePaginationCards(root) {
|
||||
if (!root || !root.querySelectorAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(root.querySelectorAll('[data-table-pagination-card]'), function (card) {
|
||||
if (!card || !card.getBoundingClientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cardRect = card.getBoundingClientRect();
|
||||
var bottomInset = 16;
|
||||
var availableHeight = Math.max(0, window.innerHeight - cardRect.top - bottomInset);
|
||||
card.style.removeProperty('--table-pagination-card-height');
|
||||
card.style.setProperty('--table-pagination-card-max-height', availableHeight + 'px');
|
||||
});
|
||||
}
|
||||
|
||||
function syncTablePaginationPageClass() {
|
||||
if (!document || !document.body || !document.body.classList) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.toggle('table-pagination-page', !!document.querySelector('[data-table-pagination-card]'));
|
||||
}
|
||||
|
||||
function focusSearchInput(input) {
|
||||
@@ -163,8 +192,21 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
syncTablePaginationCards(scope);
|
||||
}
|
||||
|
||||
if (window.addEventListener) {
|
||||
window.addEventListener('resize', function () {
|
||||
syncTablePaginationCards(document);
|
||||
});
|
||||
window.addEventListener('orientationchange', function () {
|
||||
syncTablePaginationCards(document);
|
||||
});
|
||||
}
|
||||
|
||||
window.initTableSearches = initTableSearches;
|
||||
window.replaceTableResults = replaceTableResults;
|
||||
syncTablePaginationCards(document);
|
||||
syncTablePaginationPageClass();
|
||||
}());
|
||||
@@ -1,3 +1,5 @@
|
||||
// Table sorting helpers for clickable list headers.
|
||||
|
||||
(function () {
|
||||
function getTableSortState(table) {
|
||||
if (!table._tableSortState) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared template designer utility helpers.
|
||||
|
||||
(function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
@@ -43,8 +45,12 @@
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
if (window.pulseRegionTypes && typeof window.pulseRegionTypes.getDefaultRegionSize === 'function') {
|
||||
return window.pulseRegionTypes.getDefaultRegionSize(regionType, lockRatio);
|
||||
}
|
||||
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
var locked = regionType === 'image' || regionType === 'video' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
|
||||
var locked = false;
|
||||
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
@@ -89,7 +95,6 @@
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
@@ -108,7 +113,6 @@
|
||||
syncRegionIdentity(card, values.label);
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Template designer bootstrap and form wiring.
|
||||
|
||||
(function () {
|
||||
var dataElement = document.getElementById('template-editor-data');
|
||||
if (!dataElement) {
|
||||
@@ -33,6 +35,7 @@
|
||||
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
|
||||
var addRegionButton = document.getElementById('add-region-button');
|
||||
var regionAddModal = document.getElementById('region-add-modal');
|
||||
var regionAddOptions = document.getElementById('region-add-options');
|
||||
var regionCardTemplate = document.getElementById('region-card-template');
|
||||
var regionsJsonInput = document.getElementById('regions-json');
|
||||
var templateForm = document.getElementById('template-form');
|
||||
@@ -101,7 +104,7 @@
|
||||
return utils.getDefaultRegionSize(regionType, lockRatio);
|
||||
}
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss';
|
||||
var locked = false;
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
var baseWidth = locked ? 420 : 300;
|
||||
@@ -113,6 +116,41 @@
|
||||
return { width: locked ? 420 : 300, height: locked ? 240 : 120 };
|
||||
}
|
||||
|
||||
function getRegionTypeEntries() {
|
||||
if (window.pulseRegionTypes && typeof window.pulseRegionTypes.list === 'function') {
|
||||
return window.pulseRegionTypes.list();
|
||||
}
|
||||
|
||||
return ['api', 'html', 'image', 'rss', 'rtmp', 'text', 'time-date', 'video', 'webpage'].map(function (type) {
|
||||
return { type: type, definition: window.pulseRegionTypes && typeof window.pulseRegionTypes.get === 'function' ? window.pulseRegionTypes.get(type) || {} : {} };
|
||||
});
|
||||
}
|
||||
|
||||
function formatRegionTypeLabel(regionType) {
|
||||
return String(regionType || '')
|
||||
.trim()
|
||||
.replace(/(^|[_-])(\w)/g, function (_match, _prefix, letter) {
|
||||
return String(letter || '').toUpperCase();
|
||||
}) || 'Region';
|
||||
}
|
||||
|
||||
function renderAddRegionOptions() {
|
||||
if (!regionAddOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
regionAddOptions.innerHTML = getRegionTypeEntries().map(function (entry) {
|
||||
var type = String(entry && entry.type ? entry.type : '').trim();
|
||||
if (!type) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var definition = entry.definition || {};
|
||||
var label = definition.label || formatRegionTypeLabel(type);
|
||||
return '<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="' + escapeHtml(type) + '">' + escapeHtml(label) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getCards() {
|
||||
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
|
||||
}
|
||||
@@ -179,7 +217,6 @@
|
||||
region_key: getRegionName(card),
|
||||
label: getRegionName(card),
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
@@ -202,7 +239,6 @@
|
||||
syncRegionIdentity(card, values.label);
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
@@ -328,6 +364,10 @@
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
if (window.pulseRegionTypes && typeof window.pulseRegionTypes.getRegionChipLabel === 'function') {
|
||||
return window.pulseRegionTypes.getRegionChipLabel(regionType);
|
||||
}
|
||||
|
||||
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
@@ -335,7 +375,6 @@
|
||||
var chip = card.querySelector('[data-region-chip]');
|
||||
var title = card.querySelector('[data-region-title]');
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
|
||||
var regionTypeInput = card.querySelector('[name="region_type[]"]');
|
||||
var regionKeyInput = card.querySelector('[name="region_key[]"]');
|
||||
var regionLabelInput = card.querySelector('[name="region_label[]"]');
|
||||
@@ -349,9 +388,6 @@
|
||||
if (nameInput) {
|
||||
nameInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (fontFamilyInput) {
|
||||
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
||||
}
|
||||
if (regionTypeInput) {
|
||||
regionTypeInput.value = region.region_type || 'text';
|
||||
}
|
||||
@@ -577,7 +613,6 @@
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: type,
|
||||
font_family: type === 'text' || type === 'html' || type === 'rss' || type === 'api' ? 'Arial' : '',
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: size.width,
|
||||
@@ -753,19 +788,26 @@
|
||||
}
|
||||
|
||||
if (addRegionButton && regionAddModal) {
|
||||
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
|
||||
addRegionButton.addEventListener('click', function () {
|
||||
renderAddRegionOptions();
|
||||
openAddRegionModal();
|
||||
});
|
||||
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
if (regionAddOptions) {
|
||||
regionAddOptions.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-add-region-type]') : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionType = button.getAttribute('data-add-region-type');
|
||||
addRegion(createDefaultRegion(regionType));
|
||||
if (window.pulseModal) {
|
||||
window.pulseModal.hide(regionAddModal);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderAddRegionOptions();
|
||||
}
|
||||
backgroundInput.addEventListener('change', function () {
|
||||
var file = backgroundInput.files && backgroundInput.files[0];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Theme bootstrap script that applies the saved or preferred color scheme.
|
||||
|
||||
(function () {
|
||||
var storageKey = 'lte-theme';
|
||||
var theme = 'auto';
|
||||
var ckeditorThemeStyleId = 'ckeditor-dark-theme-overrides';
|
||||
|
||||
function getPreferredTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
@@ -11,57 +12,6 @@
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getOrCreateCkeditorThemeStyleElement() {
|
||||
var styleElement = document.getElementById(ckeditorThemeStyleId);
|
||||
|
||||
if (styleElement) {
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = ckeditorThemeStyleId;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
function syncCkeditorTheme(currentTheme) {
|
||||
var styleElement = getOrCreateCkeditorThemeStyleElement();
|
||||
|
||||
if (currentTheme !== 'dark') {
|
||||
styleElement.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
styleElement.textContent = [
|
||||
'.ck.ck-dropdown__panel,',
|
||||
'.ck.ck-list__panel,',
|
||||
'.ck.ck-list,',
|
||||
'.ck.ck-balloon-panel {',
|
||||
' background: var(--bs-body-bg) !important;',
|
||||
' background-color: var(--bs-body-bg) !important;',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-list .ck-list-item-button {',
|
||||
' background: transparent !important;',
|
||||
' background-color: transparent !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-list .ck-list-item-button:hover {',
|
||||
' background: var(--bs-secondary-bg) !important;',
|
||||
' background-color: var(--bs-secondary-bg) !important;',
|
||||
'}',
|
||||
'.ck.ck-color-grid,',
|
||||
'.ck.ck-color-grid__tile {',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-color-grid__tile {',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'auto') {
|
||||
@@ -73,5 +23,4 @@
|
||||
|
||||
document.documentElement.dataset.bsTheme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
syncCkeditorTheme(theme === 'auto' ? getPreferredTheme() : theme);
|
||||
}());
|
||||
+25
-11
@@ -1,4 +1,4 @@
|
||||
(function () {
|
||||
(function (root) {
|
||||
function getToastTitle(variant) {
|
||||
var textVariant = String(variant || '').trim().toLowerCase();
|
||||
if (textVariant === 'danger') {
|
||||
@@ -141,12 +141,17 @@
|
||||
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
var text = String(message || '').trim();
|
||||
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
|
||||
return 'danger';
|
||||
}
|
||||
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
|
||||
return 'warning';
|
||||
var rules = [
|
||||
{ variant: 'danger', patterns: [/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i, /\bdelete\b.*\b(?:before|first)\b/i, /\bstill (?:in use|linked|assigned|used)\b/i] },
|
||||
{ variant: 'warning', patterns: [/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i] }
|
||||
];
|
||||
|
||||
for (var i = 0; i < rules.length; i += 1) {
|
||||
if (rules[i].patterns.some(function (pattern) { return pattern.test(text); })) {
|
||||
return rules[i].variant;
|
||||
}
|
||||
}
|
||||
|
||||
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
|
||||
}
|
||||
|
||||
@@ -230,9 +235,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.dismissToast = dismissToast;
|
||||
window.showToast = showToast;
|
||||
window.initToast = initToast;
|
||||
root.dismissToast = dismissToast;
|
||||
root.showToast = showToast;
|
||||
root.initToast = initToast;
|
||||
root.getMessageVariant = getMessageVariant;
|
||||
|
||||
initToast();
|
||||
}());
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
getMessageVariant: getMessageVariant
|
||||
};
|
||||
}
|
||||
|
||||
if (root.document) {
|
||||
initToast();
|
||||
}
|
||||
}(typeof window !== 'undefined' ? window : globalThis));
|
||||
@@ -1,55 +0,0 @@
|
||||
Software License Agreement
|
||||
==========================
|
||||
|
||||
**CKEditor 5** (https://github.com/ckeditor/ckeditor5)<br>
|
||||
Copyright (c) 2003–2026, [CKSource Holding sp. z o.o.](https://cksource.com) All rights reserved.
|
||||
|
||||
Licensed under a dual-license model, this software is available under:
|
||||
|
||||
* the [GNU General Public License Version 2 or later](https://www.gnu.org/licenses/gpl.html) (see COPYING.GPL),
|
||||
* or commercial license terms from CKSource Holding sp. z o.o.
|
||||
|
||||
For more information, see: [https://ckeditor.com/legal/ckeditor-licensing-options](https://ckeditor.com/legal/ckeditor-licensing-options).
|
||||
|
||||
If you are using CKEditor under commercial terms, you are free to remove the COPYING.GPL file with the full copy of a GPL license.
|
||||
|
||||
Sources of Intellectual Property Included in CKEditor 5
|
||||
------------------------------------------------------------
|
||||
|
||||
Where not otherwise indicated, all CKEditor 5 content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor 5 will incorporate work done by developers outside of CKSource with their express permission.
|
||||
|
||||
The following libraries are included in CKEditor 5 under the [ISC license](https://opensource.org/licenses/ISC):
|
||||
|
||||
* hast-util-from-dom - Copyright (c) Keith McKnight <keith@mcknig.ht>.
|
||||
* rehype-dom-parse - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>.
|
||||
* rehype-dom-stringify - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>.
|
||||
|
||||
The following libraries are included in CKEditor 5 under the [MIT license](https://opensource.org/licenses/MIT):
|
||||
|
||||
* @types/color-convert - Copyright (c) Microsoft Corporation.
|
||||
* @types/hast - Copyright (c) Microsoft Corporation.
|
||||
* blurhash - Copyright (c) 2018 Wolt Enterprises.
|
||||
* color-convert - Copyright (c) 2011-2016 Heather Arthur <fayearthur@gmail.com> and Copyright (c) 2016-2021 Josh Junon <josh@junon.me>.
|
||||
* color-parse - Copyright (c) 2015 Dmitry Ivanov.
|
||||
* emojibase-data - Copyright (c) 2017-2019 Miles Johnson.
|
||||
* es-toolkit - Copyright (c) 2024 Viva Republica, Inc and Copyright OpenJS Foundation and other contributors.
|
||||
* fuzzysort - Copyright (c) 2018 Stephen Kamenar.
|
||||
* hast-util-to-html - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
|
||||
* hast-util-to-mdast - Copyright (c) Titus Wormer <tituswormer@gmail.com> and Copyright (c) Seth Vincent <sethvincent@gmail.com>.
|
||||
* hastscript - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
|
||||
* is-emoji-supported - Copyright (c) 2016-2020 Koala Interactive, Inc.
|
||||
* Regular expression for URL validation - Copyright (c) 2010-2018 Diego Perini.
|
||||
* rehype-remark - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
|
||||
* remark-breaks - Copyright (c) 2017 Titus Wormer <tituswormer@gmail.com>.
|
||||
* remark-gfm - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
|
||||
* remark-parse - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>.
|
||||
* remark-rehype - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
|
||||
* remark-stringify - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>.
|
||||
* unified - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>.
|
||||
* unist-util-visit - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>.
|
||||
* vanilla-colorful - Copyright (c) 2020 Serhii Kulykov <iamkulykov@gmail.com>.
|
||||
|
||||
Trademarks
|
||||
----------
|
||||
|
||||
**CKEditor** is a trademark of [CKSource Holding sp. z o.o.](https://cksource.com) All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-188
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-184
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
|
||||
*/
|
||||
|
||||
import type { Translations } from '@ckeditor/ckeditor5-utils';
|
||||
declare const translations: Translations;
|
||||
export default translations;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
|
||||
*/
|
||||
|
||||
import type { Translations } from '@ckeditor/ckeditor5-utils';
|
||||
declare const translations: Translations;
|
||||
export default translations;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
|
||||
*/
|
||||
|
||||
import type { Translations } from '@ckeditor/ckeditor5-utils';
|
||||
declare const translations: Translations;
|
||||
export default translations;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
|
||||
*/
|
||||
|
||||
import type { Translations } from '@ckeditor/ckeditor5-utils';
|
||||
declare const translations: Translations;
|
||||
export default translations;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
|
||||
*/
|
||||
|
||||
import type { Translations } from '@ckeditor/ckeditor5-utils';
|
||||
declare const translations: Translations;
|
||||
export default translations;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
|
||||
*/
|
||||
|
||||
import type { Translations } from '@ckeditor/ckeditor5-utils';
|
||||
declare const translations: Translations;
|
||||
export default translations;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
||||
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
|
||||
*/
|
||||
|
||||
import type { Translations } from '@ckeditor/ckeditor5-utils';
|
||||
declare const translations: Translations;
|
||||
export default translations;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user