Save worktree changes
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ async function fetchAdminData(pool) {
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||
}
|
||||
|
||||
async function fetchApiSourcesData(pool) {
|
||||
const [apiSources] = await pool.query(
|
||||
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC'
|
||||
);
|
||||
|
||||
return { apiSources: apiSources };
|
||||
}
|
||||
|
||||
async function fetchApiSourceById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function loadUrlText(urlValue) {
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(urlValue, {
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
contentType: response.headers.get('content-type') || '',
|
||||
bodyText: await response.text(),
|
||||
ok: response.ok
|
||||
};
|
||||
}
|
||||
|
||||
return await new Promise(function (resolve, reject) {
|
||||
const url = new URL(urlValue);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const request = transport.get(url, {
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}
|
||||
}, function (response) {
|
||||
response.setEncoding('utf8');
|
||||
let body = '';
|
||||
response.on('data', function (chunk) {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('end', function () {
|
||||
resolve({
|
||||
statusCode: response.statusCode || 0,
|
||||
contentType: String(response.headers['content-type'] || ''),
|
||||
bodyText: body,
|
||||
ok: !response.statusCode || response.statusCode < 400
|
||||
});
|
||||
});
|
||||
response.on('error', reject);
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchApiSourceResponse(apiUrl) {
|
||||
const response = await loadUrlText(apiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load API response (${response.statusCode}).`);
|
||||
}
|
||||
|
||||
const text = String(response.bodyText || '').trim();
|
||||
if (!text) {
|
||||
throw new Error('API response did not return JSON.');
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
throw new Error('API response was not valid JSON.');
|
||||
}
|
||||
|
||||
return {
|
||||
responseJson: JSON.stringify(parsed, null, 2),
|
||||
responseStatus: response.statusCode,
|
||||
responseContentType: response.contentType
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiSourcePayload(req, existingApiSource) {
|
||||
const fallback = existingApiSource || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('API source name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!apiUrl) {
|
||||
const error = new Error('API source URL is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(apiUrl);
|
||||
} catch (_error) {
|
||||
const error = new Error('Enter a valid API URL.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
const error = new Error('API URL must start with http or https.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(updateIntervalValue)) {
|
||||
const error = new Error('Update interval must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
apiUrl: parsedUrl.toString(),
|
||||
updateIntervalValue: Math.floor(updateIntervalValue),
|
||||
updateIntervalUnit: updateIntervalUnit
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchApiSourcesData: fetchApiSourcesData,
|
||||
fetchApiSourceById: fetchApiSourceById,
|
||||
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||
buildApiSourcePayload: buildApiSourcePayload
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeDeviceId(value) {
|
||||
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||
}
|
||||
|
||||
function collectLiveConnections(liveConnections) {
|
||||
return Array.isArray(liveConnections) ? liveConnections : [];
|
||||
}
|
||||
|
||||
async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConnections) {
|
||||
const normalizedName = normalizeClientName(clientName);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
||||
const live = collectLiveConnections(liveConnections);
|
||||
const lowerName = normalizedName.toLowerCase();
|
||||
|
||||
try {
|
||||
if (pool) {
|
||||
const [deviceRows] = await pool.query(
|
||||
`SELECT device_id
|
||||
FROM player_onboarding_devices
|
||||
WHERE client_name IS NOT NULL
|
||||
AND TRIM(client_name) <> ''
|
||||
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
|
||||
AND device_id <> ?
|
||||
LIMIT 1`,
|
||||
[normalizedName, normalizedDeviceId]
|
||||
);
|
||||
|
||||
if (deviceRows.length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const connection of live) {
|
||||
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||
continue;
|
||||
}
|
||||
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (_error) {
|
||||
for (const connection of live) {
|
||||
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||
continue;
|
||||
}
|
||||
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientNameLockName(clientName) {
|
||||
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
|
||||
}
|
||||
|
||||
async function withClientNameReservation(pool, clientName, handler) {
|
||||
if (!pool || typeof pool.getConnection !== 'function') {
|
||||
return handler();
|
||||
}
|
||||
|
||||
const normalizedName = normalizeClientName(clientName);
|
||||
if (!normalizedName) {
|
||||
return handler();
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
const lockName = buildClientNameLockName(normalizedName);
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
|
||||
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
|
||||
if (lockResult !== 1) {
|
||||
const error = new Error('Client name is busy. Please try again.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
lockAcquired = true;
|
||||
return await handler();
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
|
||||
} catch (_error) {}
|
||||
}
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeClientName: normalizeClientName,
|
||||
normalizeDeviceId: normalizeDeviceId,
|
||||
collectLiveConnections: collectLiveConnections,
|
||||
isClientNameAvailable: isClientNameAvailable,
|
||||
withClientNameReservation: withClientNameReservation
|
||||
};
|
||||
+16
-2
@@ -1,10 +1,12 @@
|
||||
const { fetchAdminData } = require('./admin');
|
||||
const { fetchPlaylistById } = require('./playlists');
|
||||
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
@@ -12,6 +14,17 @@ module.exports = {
|
||||
parseJsonSafe,
|
||||
fetchAdminData,
|
||||
fetchPlaylistById,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourceById,
|
||||
fetchApiSourceResponse,
|
||||
buildApiSourcePayload,
|
||||
fetchRssFeedsData,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
buildRssFeedPayload,
|
||||
fetchRssFeedItems,
|
||||
replaceRssFeedItems,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchTemplateById,
|
||||
@@ -22,5 +35,6 @@ module.exports = {
|
||||
buildCanvasSizePayload,
|
||||
buildSlidePayload,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload
|
||||
buildTemplatePayload,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||
}
|
||||
|
||||
async function fetchRssFeedsData(pool) {
|
||||
const [rssFeeds] = await pool.query(
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC'
|
||||
);
|
||||
|
||||
return { rssFeeds: rssFeeds };
|
||||
}
|
||||
|
||||
async function fetchRssFeedById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at
|
||||
FROM rss_feed_items
|
||||
WHERE rss_feed_id = ?
|
||||
ORDER BY position ASC, id ASC`,
|
||||
[rssFeedId]
|
||||
);
|
||||
|
||||
return rows.map(function (row) {
|
||||
return normalizeRssFeedItem(row);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRssFeedItem(row) {
|
||||
let itemJson = null;
|
||||
if (row && row.item_json) {
|
||||
try {
|
||||
itemJson = JSON.parse(row.item_json);
|
||||
} catch (_error) {
|
||||
itemJson = null;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.assign({}, row || {}, itemJson || {}, {
|
||||
itemJson: itemJson
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUrlText(urlValue) {
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(urlValue, {
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage RSS Reader'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load RSS feed (${response.status}).`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
return await new Promise(function (resolve, reject) {
|
||||
const url = new URL(urlValue);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const request = transport.get(url, {
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'Pulse Signage RSS Reader'
|
||||
}
|
||||
}, function (response) {
|
||||
if (response.statusCode && response.statusCode >= 400) {
|
||||
reject(new Error(`Unable to load RSS feed (${response.statusCode}).`));
|
||||
response.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
response.setEncoding('utf8');
|
||||
let body = '';
|
||||
response.on('data', function (chunk) {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('end', function () {
|
||||
resolve(body);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function stripCdata(value) {
|
||||
return String(value || '').replace(/^<!\[CDATA\[|\]\]>$/g, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function extractXmlTag(content, tagName) {
|
||||
const safeTagName = escapeRegExp(tagName);
|
||||
const pattern = new RegExp(`<${safeTagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\/${safeTagName}>`, 'i');
|
||||
const match = pattern.exec(content || '');
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
return decodeXmlEntities(stripCdata(match[1]).trim());
|
||||
}
|
||||
|
||||
function extractAtomLink(content) {
|
||||
const linkMatch = /<link\b[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/>/i.exec(content || '')
|
||||
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/>/i.exec(content || '')
|
||||
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*>/i.exec(content || '');
|
||||
if (!linkMatch) {
|
||||
return '';
|
||||
}
|
||||
return decodeXmlEntities(String(linkMatch[1] || '').trim());
|
||||
}
|
||||
|
||||
function parseRssItems(xmlText, itemLimit) {
|
||||
const normalizedXml = String(xmlText || '');
|
||||
const itemMatches = Array.from(normalizedXml.matchAll(/<item\b[\s\S]*?<\/item>|<entry\b[\s\S]*?<\/entry>/gi)).map(function (match) {
|
||||
return String(match[0] || '');
|
||||
});
|
||||
|
||||
if (!itemMatches.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return itemMatches.slice(0, Math.max(1, Number(itemLimit) || 1)).map(function (itemXml, index) {
|
||||
const title = extractXmlTag(itemXml, 'title') || `Item ${index + 1}`;
|
||||
const link = extractXmlTag(itemXml, 'link') || extractAtomLink(itemXml);
|
||||
const pubDate = extractXmlTag(itemXml, 'pubDate') || extractXmlTag(itemXml, 'updated') || extractXmlTag(itemXml, 'dc:date');
|
||||
const description = extractXmlTag(itemXml, 'description') || extractXmlTag(itemXml, 'summary') || extractXmlTag(itemXml, 'content:encoded');
|
||||
const author = extractXmlTag(itemXml, 'author') || extractXmlTag(itemXml, 'dc:creator');
|
||||
const comments = extractXmlTag(itemXml, 'comments');
|
||||
const guidMatch = /<guid\b([^>]*)>([\s\S]*?)<\/guid>/i.exec(itemXml || '');
|
||||
const guid = guidMatch ? decodeXmlEntities(stripCdata(String(guidMatch[2] || '').trim())) : '';
|
||||
const guidIsPermaLinkMatch = guidMatch && /\bisPermaLink\s*=\s*(["']?)(true|false)\1/i.exec(String(guidMatch[1] || ''));
|
||||
const categories = Array.from(String(itemXml || '').matchAll(/<category\b([^>]*)>([\s\S]*?)<\/category>/gi)).map(function (match) {
|
||||
const categoryAttributes = String(match[1] || '');
|
||||
const categoryDomainMatch = /\bdomain\s*=\s*(["'])([^"']+)\1/i.exec(categoryAttributes);
|
||||
return {
|
||||
value: decodeXmlEntities(stripCdata(String(match[2] || '').trim())),
|
||||
domain: categoryDomainMatch ? decodeXmlEntities(String(categoryDomainMatch[2] || '').trim()) : ''
|
||||
};
|
||||
}).filter(function (category) {
|
||||
return Boolean(category.value);
|
||||
});
|
||||
const enclosureMatch = /<enclosure\b([^>]*)\/?>(?:\s*)/i.exec(itemXml || '');
|
||||
const enclosureUrlMatch = enclosureMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||
const enclosureLengthMatch = enclosureMatch ? /\blength\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||
const enclosureTypeMatch = enclosureMatch ? /\btype\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||
const sourceMatch = /<source\b([^>]*)>([\s\S]*?)<\/source>/i.exec(itemXml || '');
|
||||
const sourceUrlMatch = sourceMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(sourceMatch[1] || '')) : null;
|
||||
|
||||
const parsedItem = {
|
||||
title: title,
|
||||
link: link,
|
||||
description: description,
|
||||
author: author,
|
||||
comments: comments,
|
||||
guid: guid,
|
||||
guidIsPermaLink: guidIsPermaLinkMatch ? String(guidIsPermaLinkMatch[2] || '').toLowerCase() === 'true' : null,
|
||||
pubDate: pubDate,
|
||||
categories: categories,
|
||||
enclosure: enclosureMatch ? {
|
||||
url: enclosureUrlMatch ? decodeXmlEntities(String(enclosureUrlMatch[2] || '').trim()) : '',
|
||||
length: enclosureLengthMatch ? Number(enclosureLengthMatch[2]) || null : null,
|
||||
type: enclosureTypeMatch ? decodeXmlEntities(String(enclosureTypeMatch[2] || '').trim()) : ''
|
||||
} : null,
|
||||
source: sourceMatch ? {
|
||||
title: decodeXmlEntities(stripCdata(String(sourceMatch[2] || '').trim())),
|
||||
url: sourceUrlMatch ? decodeXmlEntities(String(sourceUrlMatch[2] || '').trim()) : ''
|
||||
} : null,
|
||||
rawXml: itemXml
|
||||
};
|
||||
|
||||
return parsedItem;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRssFeedItems(feedUrl, itemLimit) {
|
||||
const xmlText = await loadUrlText(feedUrl);
|
||||
return parseRssItems(xmlText, itemLimit);
|
||||
}
|
||||
|
||||
async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : [];
|
||||
await connection.query('DELETE FROM rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
|
||||
|
||||
if (!normalizedItems.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const insertValues = normalizedItems.map(function (item, index) {
|
||||
return [
|
||||
rssFeedId,
|
||||
index + 1,
|
||||
JSON.stringify(item || {})
|
||||
];
|
||||
});
|
||||
|
||||
await connection.query(
|
||||
'INSERT INTO rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
|
||||
[insertValues]
|
||||
);
|
||||
|
||||
return insertValues.length;
|
||||
}
|
||||
|
||||
function buildRssFeedPayload(req, existingRssFeed) {
|
||||
const fallback = existingRssFeed || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim();
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('RSS feed name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!feedUrl) {
|
||||
const error = new Error('RSS feed URL is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(feedUrl);
|
||||
} catch (_error) {
|
||||
const error = new Error('Enter a valid RSS feed URL.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
const error = new Error('RSS feed URL must start with http or https.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(updateIntervalValue)) {
|
||||
const error = new Error('Update interval must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(itemLimit)) {
|
||||
const error = new Error('Item count must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
feedUrl: parsedUrl.toString(),
|
||||
updateIntervalValue: Math.floor(updateIntervalValue),
|
||||
updateIntervalUnit: updateIntervalUnit,
|
||||
itemLimit: Math.floor(itemLimit)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchRssFeedsData,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
buildRssFeedPayload,
|
||||
fetchRssFeedItems,
|
||||
replaceRssFeedItems
|
||||
};
|
||||
+54
-2
@@ -2,6 +2,7 @@ const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
const DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
@@ -60,10 +61,19 @@ function sanitizeTextColor(value, fallback) {
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
const parsed = Math.round(Number(raw.replace(/[^0-9.]/g, '')));
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
return Math.max(8, Number(fallback || DEFAULT_FONT_SIZE));
|
||||
}
|
||||
|
||||
function getTextRegionStyle(body, region, existingContent) {
|
||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
|
||||
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
|
||||
const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE);
|
||||
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
|
||||
return {
|
||||
font_family: fontFamily,
|
||||
@@ -80,7 +90,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
const existing = body[`existing_region_image_${region.id}`];
|
||||
content[region.region_key] = {
|
||||
type: 'image',
|
||||
value: uploaded ? `/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
value: uploaded ? `/media/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
};
|
||||
} else if (region.region_type === 'webpage') {
|
||||
const submitted = body[`region_webpage_${region.id}`];
|
||||
@@ -89,6 +99,14 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'webpage',
|
||||
value: submitted === undefined ? current : String(submitted || '').trim()
|
||||
};
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
const submitted = body[`region_rtmp_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
content[region.region_key] = {
|
||||
type: 'rtmp',
|
||||
value: submitted === undefined ? String(current.value || '').trim() : String(submitted || '').trim(),
|
||||
disable_audio: Boolean(body[`region_disable_audio_${region.id}`])
|
||||
};
|
||||
} else if (region.region_type === 'html') {
|
||||
const submitted = body[`region_html_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
@@ -96,6 +114,40 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'html',
|
||||
value: submitted === undefined ? current : String(submitted || '')
|
||||
};
|
||||
} else if (region.region_type === 'rss') {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const style = getTextRegionStyle(body, region, existingContent);
|
||||
const feedId = body[`region_rss_feed_id_${region.id}`];
|
||||
const itemNumber = body[`region_rss_item_number_${region.id}`];
|
||||
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||
content[region.region_key] = {
|
||||
type: 'rss',
|
||||
value: submitted === undefined ? String(current.value || '') : String(submitted || ''),
|
||||
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
} else if (region.region_type === 'api') {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const style = getTextRegionStyle(body, region, existingContent);
|
||||
const sourceId = body[`region_api_source_id_${region.id}`];
|
||||
const itemNumber = body[`region_api_item_number_${region.id}`];
|
||||
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||
content[region.region_key] = {
|
||||
type: 'api',
|
||||
value: submitted === undefined ? String(current.value || '') : String(submitted || ''),
|
||||
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
} else {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
|
||||
+31
-27
@@ -1,6 +1,7 @@
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -15,6 +16,14 @@ function normalizeTemplateRegionType(value) {
|
||||
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionLockRatio(value) {
|
||||
const rawRatio = String(value || '').trim();
|
||||
if (!/^\d+\s*:\s*\d+$/.test(rawRatio)) {
|
||||
return null;
|
||||
}
|
||||
return rawRatio.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
@@ -49,7 +58,7 @@ async function fetchTemplateById(pool, id) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
template.regions = regions;
|
||||
return template;
|
||||
}
|
||||
@@ -62,7 +71,7 @@ async function fetchTemplatesData(pool) {
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
return { templates, templateRegions };
|
||||
}
|
||||
|
||||
@@ -71,17 +80,21 @@ function extractTemplateRegions(body) {
|
||||
if (regionsJson) {
|
||||
const parsed = parseJsonSafe(regionsJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((region) => ({
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_type: normalizeTemplateRegionType(region.region_type),
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
height: Number(region.height || 100),
|
||||
z_index: Number(region.z_index || 0)
|
||||
})).filter((region) => region.region_key && region.label);
|
||||
return parsed.map((region) => {
|
||||
const regionType = normalizeTemplateRegionType(region.region_type);
|
||||
return {
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_type: regionType,
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(region.font_family || '').trim() || null : null,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
height: Number(region.height || 100),
|
||||
z_index: Number(region.z_index || 0)
|
||||
};
|
||||
}).filter((region) => region.region_key && region.label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +102,7 @@ function extractTemplateRegions(body) {
|
||||
const names = readFormArray(body, 'region_name[]');
|
||||
const labels = readFormArray(body, 'region_label[]');
|
||||
const types = readFormArray(body, 'region_type[]');
|
||||
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
||||
const xs = readFormArray(body, 'region_x[]');
|
||||
const ys = readFormArray(body, 'region_y[]');
|
||||
const widths = readFormArray(body, 'region_width[]');
|
||||
@@ -108,7 +122,8 @@ function extractTemplateRegions(body) {
|
||||
region_key: name,
|
||||
region_type: regionType,
|
||||
label: name,
|
||||
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||
x: Number(xs[i] || 0),
|
||||
y: Number(ys[i] || 0),
|
||||
width: Number(widths[i] || 100),
|
||||
@@ -139,7 +154,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const removeBackgroundImage = Boolean(req.body.remove_background_image);
|
||||
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
|
||||
const backgroundImagePath = backgroundImage
|
||||
? `/uploads/${backgroundImage.filename}`
|
||||
? `/media/${backgroundImage.filename}`
|
||||
: removeBackgroundImage
|
||||
? null
|
||||
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
||||
@@ -171,17 +186,6 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
regions = [{
|
||||
region_key: 'region_1',
|
||||
region_type: 'text',
|
||||
label: 'Region 1',
|
||||
font_family: 'Arial',
|
||||
x: 120,
|
||||
y: 120,
|
||||
width: Math.max(200, Math.round(canvasWidth * 0.22)),
|
||||
height: Math.max(120, Math.round(canvasHeight * 0.15)),
|
||||
z_index: 1
|
||||
}];
|
||||
}
|
||||
|
||||
ensureUniqueTemplateRegionNames(regions);
|
||||
|
||||
+21
-1
@@ -22,7 +22,27 @@ function readFormArray(body, key) {
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
|
||||
const normalizedName = String(name || '').trim();
|
||||
if (!normalizedName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedColumnName = String(columnName || 'name').trim() || 'name';
|
||||
const params = [normalizedName];
|
||||
let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`;
|
||||
if (excludeId !== undefined && excludeId !== null) {
|
||||
sql += ' AND id <> ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
sql += ' LIMIT 1';
|
||||
|
||||
const [rows] = await pool.query(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray
|
||||
readFormArray,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user