450 lines
18 KiB
JavaScript
450 lines
18 KiB
JavaScript
// Slide data access helpers, including rich-text normalization and payload building.
|
|
|
|
const { fetchTemplateById } = require('./templates');
|
|
const { parseJsonSafe, validateMaxLength } = require('./utils');
|
|
|
|
const TITLE_MAX_LENGTH = 255;
|
|
const { buildQrCodeContent } = require('./qr-code');
|
|
|
|
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 || '');
|
|
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
return output.replace(/<[^>]+>/g, (tag) => {
|
|
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
|
if (!match) {
|
|
return '';
|
|
}
|
|
const closing = Boolean(match[1]);
|
|
const name = String(match[2] || '').toLowerCase();
|
|
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
|
return '';
|
|
}
|
|
if (name === 'br') {
|
|
return '<br>';
|
|
}
|
|
return closing ? `</${name}>` : `<${name}>`;
|
|
});
|
|
}
|
|
|
|
function normalizePlainText(value) {
|
|
return String(value === undefined || value === null ? '' : value)
|
|
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
|
.replace(/<[^>]*>/g, '')
|
|
.replace(/ /gi, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function stripEditorOnlyMarkup(value) {
|
|
return String(value || '')
|
|
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
|
|
.replace(/<details[^>]*class="[^"]*api-region-sample-accordion[^"]*"[^>]*>[\s\S]*?<\/details>/gi, '')
|
|
.trim();
|
|
}
|
|
|
|
function normalizeEditorMarkup(value) {
|
|
return String(value === undefined || value === null ? '' : value).trim();
|
|
}
|
|
|
|
async function fetchSlideById(pool, id) {
|
|
const [slides] = await pool.query(`
|
|
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
|
|
st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
|
FROM c_slides s
|
|
LEFT JOIN c_templates st ON st.id = s.template_id
|
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
|
WHERE s.id = ?
|
|
`, [id]);
|
|
if (!slides.length) {
|
|
return null;
|
|
}
|
|
const slide = slides[0];
|
|
slide.content = parseJsonSafe(slide.content_json) || {};
|
|
if (slide.template_id) {
|
|
slide.template = await fetchTemplateById(pool, slide.template_id);
|
|
}
|
|
return slide;
|
|
}
|
|
|
|
function getFilesByField(files) {
|
|
const map = {};
|
|
(files || []).forEach((file) => {
|
|
map[file.fieldname] = file;
|
|
});
|
|
return map;
|
|
}
|
|
|
|
function getSubmittedValue(body, key, fallback) {
|
|
if (body && Object.prototype.hasOwnProperty.call(body, key)) {
|
|
return String(body[key] || '').trim();
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function sanitizeTextColor(value, fallback) {
|
|
const raw = String(value || '').trim();
|
|
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
|
return raw;
|
|
}
|
|
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 = 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,
|
|
font_size: fontSize,
|
|
font_color: fontColor
|
|
};
|
|
}
|
|
|
|
function getQrRegionStyle(body, region, existingContent) {
|
|
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
|
const styleKeys = [
|
|
'qr_margin',
|
|
'qr_background_color',
|
|
'qr_background_transparent',
|
|
'qr_background_color_mode',
|
|
'qr_background_gradient_type',
|
|
'qr_background_gradient_rotation',
|
|
'qr_background_gradient_color_1',
|
|
'qr_background_gradient_color_2',
|
|
'qr_dots_color',
|
|
'qr_dots_type',
|
|
'qr_dots_color_mode',
|
|
'qr_dots_gradient_type',
|
|
'qr_dots_gradient_rotation',
|
|
'qr_dots_gradient_color_1',
|
|
'qr_dots_gradient_color_2',
|
|
'qr_corners_square_color',
|
|
'qr_corners_square_type',
|
|
'qr_corners_square_color_mode',
|
|
'qr_corners_square_gradient_type',
|
|
'qr_corners_square_gradient_rotation',
|
|
'qr_corners_square_gradient_color_1',
|
|
'qr_corners_square_gradient_color_2',
|
|
'qr_corners_dot_color',
|
|
'qr_corners_dot_type',
|
|
'qr_corners_dot_color_mode',
|
|
'qr_corners_dot_gradient_type',
|
|
'qr_corners_dot_gradient_rotation',
|
|
'qr_corners_dot_gradient_color_1',
|
|
'qr_corners_dot_gradient_color_2',
|
|
'qr_image',
|
|
'qr_image_size',
|
|
'qr_image_margin',
|
|
'qr_image_hide_background_dots',
|
|
'qr_border_radius'
|
|
];
|
|
const style = {};
|
|
|
|
styleKeys.forEach((key) => {
|
|
const fieldNames = [`region_${key}_${region.id}`, `${key}_${region.id}`];
|
|
let fieldName = fieldNames[0];
|
|
let hasSubmittedValue = false;
|
|
let submitted;
|
|
|
|
for (let index = 0; index < fieldNames.length; index += 1) {
|
|
const candidate = fieldNames[index];
|
|
if (Object.prototype.hasOwnProperty.call(body || {}, candidate)) {
|
|
fieldName = candidate;
|
|
hasSubmittedValue = true;
|
|
submitted = body[candidate];
|
|
break;
|
|
}
|
|
}
|
|
|
|
const value = String(submitted || '').trim();
|
|
|
|
if (key === 'qr_background_transparent' || key === 'qr_image_hide_background_dots') {
|
|
style[key] = hasSubmittedValue;
|
|
return;
|
|
}
|
|
|
|
if (key === 'qr_background_color_mode' || key === 'qr_dots_color_mode' || key === 'qr_corners_square_color_mode' || key === 'qr_corners_dot_color_mode' || key === 'qr_background_gradient_type' || key === 'qr_dots_gradient_type' || key === 'qr_corners_square_gradient_type' || key === 'qr_corners_dot_gradient_type') {
|
|
style[key] = value === 'none' ? 'none' : value;
|
|
return;
|
|
}
|
|
|
|
if (key === 'qr_image') {
|
|
style[key] = value;
|
|
return;
|
|
}
|
|
|
|
if (submitted === undefined) {
|
|
if (existing[key] !== undefined && existing[key] !== null && String(existing[key]).trim() !== '') {
|
|
style[key] = existing[key];
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (value !== '') {
|
|
if (key === 'qr_margin') {
|
|
const parsedMargin = Number(value);
|
|
if (Number.isFinite(parsedMargin)) {
|
|
style[key] = Math.max(0, Math.round(parsedMargin));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key === 'qr_image_size') {
|
|
const parsedSize = Number(value);
|
|
if (Number.isFinite(parsedSize)) {
|
|
style[key] = Math.max(0, Math.min(1, parsedSize));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key === 'qr_image_margin' || key === 'qr_border_radius') {
|
|
const parsedImageMargin = Number(value);
|
|
if (Number.isFinite(parsedImageMargin)) {
|
|
style[key] = Math.max(0, Math.round(parsedImageMargin));
|
|
}
|
|
return;
|
|
}
|
|
|
|
style[key] = value;
|
|
}
|
|
});
|
|
|
|
return style;
|
|
}
|
|
|
|
async function getRssFeedItemCount(pool, feedId) {
|
|
const [rows] = await pool.query(
|
|
'SELECT COUNT(*) AS count FROM i_rss_feed_items WHERE rss_feed_id = ?',
|
|
[feedId]
|
|
);
|
|
|
|
return Number(rows && rows[0] && rows[0].count) || 0;
|
|
}
|
|
|
|
async function buildTemplateContent(pool, template, body, filesByField, existingContent) {
|
|
const content = {};
|
|
for (const region of template.regions) {
|
|
if (region.region_type === 'image') {
|
|
const uploaded = filesByField[`region_image_${region.id}`];
|
|
const existing = body[`existing_region_image_${region.id}`];
|
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
|
content[region.region_key] = {
|
|
type: 'image',
|
|
value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current)
|
|
};
|
|
} else if (region.region_type === 'video') {
|
|
const uploaded = filesByField[`region_video_${region.id}`];
|
|
const existing = body[`existing_region_video_${region.id}`];
|
|
const durationValue = body[`existing_region_video_duration_${region.id}`];
|
|
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
|
|
const parsedDuration = Number(durationValue || existingDuration || 0);
|
|
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
|
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
|
content[region.region_key] = {
|
|
type: 'video',
|
|
value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_video_${region.id}`, current),
|
|
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
|
|
};
|
|
} else if (region.region_type === 'webpage') {
|
|
const submitted = body[`region_webpage_${region.id}`];
|
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
|
content[region.region_key] = {
|
|
type: 'webpage',
|
|
value: submitted === undefined ? current : String(submitted || '').trim()
|
|
};
|
|
} else if (region.region_type === 'qr-code') {
|
|
const uploadedImage = filesByField[`region_qr_image_${region.id}`];
|
|
const submitted = body[`region_qr_code_${region.id}`];
|
|
const submittedSvg = body[`region_qr_svg_${region.id}`];
|
|
const submittedPreview = body[`region_qr_preview_${region.id}`];
|
|
const existingImage = body[`existing_region_qr_image_${region.id}`];
|
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
|
const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim();
|
|
const qrStyle = getQrRegionStyle(body, region, existingContent);
|
|
delete qrStyle.qr_image;
|
|
content[region.region_key] = {
|
|
type: 'qr-code',
|
|
value: nextValue,
|
|
qr_svg: submittedSvg === undefined ? String(current.qr_svg || '').trim() : String(submittedSvg || '').trim(),
|
|
qr_preview: submittedPreview === undefined ? String(current.qr_preview || '').trim() : String(submittedPreview || '').trim(),
|
|
qr_image: uploadedImage ? `/media/uploads/${uploadedImage.filename}` : String(existingImage === undefined ? String(current.qr_image || '').trim() : String(existingImage || '').trim()),
|
|
...qrStyle
|
|
};
|
|
} 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 : '';
|
|
content[region.region_key] = {
|
|
type: 'html',
|
|
value: submitted === undefined ? current : String(submitted || '')
|
|
};
|
|
} else if (region.region_type === 'time-date') {
|
|
const submitted = body[`region_text_${region.id}`];
|
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
|
const timezoneValue = body[`region_timezone_${region.id}`];
|
|
content[region.region_key] = {
|
|
type: 'time-date',
|
|
value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''),
|
|
timezone: timezoneValue === undefined || timezoneValue === null ? String(current.timezone || current.time_zone || '') : String(timezoneValue || '').trim()
|
|
};
|
|
} 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));
|
|
const normalizedFeedId = feedId === undefined || feedId === null || feedId === '' ? Number(current.feed_id || 0) : Number(feedId);
|
|
const itemCount = normalizedFeedId > 0 ? Math.max(1, await getRssFeedItemCount(pool, normalizedFeedId) || 1) : 1;
|
|
content[region.region_key] = {
|
|
type: 'rss',
|
|
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
|
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
|
item_number: Math.min(itemCount, 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 itemsPath = body[`region_api_items_path_${region.id}`];
|
|
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
|
content[region.region_key] = {
|
|
type: 'api',
|
|
value: stripEditorOnlyMarkup(normalizeEditorMarkup(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,
|
|
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
|
|
variable_name: 'item',
|
|
font_family: style.font_family,
|
|
font_size: style.font_size,
|
|
font_color: style.font_color
|
|
};
|
|
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) {
|
|
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
|
const suffix = '_' + region.id;
|
|
const generic = {};
|
|
|
|
Object.keys(body || {}).forEach((key) => {
|
|
if (!key.startsWith('region_') || !key.endsWith(suffix)) {
|
|
return;
|
|
}
|
|
const field = key.slice('region_'.length, -suffix.length);
|
|
if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') {
|
|
return;
|
|
}
|
|
generic[field] = body[key];
|
|
});
|
|
|
|
Object.keys(filesByField || {}).forEach((fieldName) => {
|
|
if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) {
|
|
return;
|
|
}
|
|
const field = fieldName.slice('region_'.length, -suffix.length);
|
|
if (!field) {
|
|
return;
|
|
}
|
|
generic[field] = `/media/uploads/${filesByField[fieldName].filename}`;
|
|
});
|
|
|
|
Object.keys(current).forEach((key) => {
|
|
if (generic[key] === undefined) {
|
|
generic[key] = current[key];
|
|
}
|
|
});
|
|
|
|
generic.type = region.region_type;
|
|
content[region.region_key] = generic;
|
|
} else {
|
|
const submitted = body[`region_text_${region.id}`];
|
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
|
const style = getTextRegionStyle(body, region, existingContent);
|
|
content[region.region_key] = {
|
|
type: 'text',
|
|
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))),
|
|
font_family: style.font_family,
|
|
font_size: style.font_size,
|
|
font_color: style.font_color
|
|
};
|
|
}
|
|
}
|
|
return content;
|
|
}
|
|
|
|
async function buildSlidePayload(pool, req, existingSlide) {
|
|
const title = validateMaxLength(req.body.title || '', TITLE_MAX_LENGTH, 'Slide title');
|
|
const templateId = req.body.template_id ? Number(req.body.template_id) : null;
|
|
const filesByField = getFilesByField(req.files || []);
|
|
const template = templateId ? await fetchTemplateById(pool, templateId) : null;
|
|
const existingContent = existingSlide ? parseJsonSafe(existingSlide.content_json) || {} : {};
|
|
|
|
if (!title) {
|
|
const error = new Error('Slide title is required.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (templateId && !template) {
|
|
const error = new Error('Template not found.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (template) {
|
|
const content = await buildTemplateContent(pool, template, req.body, filesByField, existingContent);
|
|
await Promise.all(Object.keys(content).map(async function (regionKey) {
|
|
const region = template.regions.find(function (item) {
|
|
return String(item.region_key || '').trim() === regionKey;
|
|
});
|
|
if (!region || region.region_type !== 'qr-code') {
|
|
return;
|
|
}
|
|
|
|
content[regionKey] = await buildQrCodeContent(content[regionKey]);
|
|
}));
|
|
|
|
return {
|
|
title,
|
|
templateId: template.id,
|
|
contentJson: JSON.stringify(content)
|
|
};
|
|
}
|
|
|
|
return {
|
|
title,
|
|
templateId: null,
|
|
contentJson: existingSlide ? existingSlide.content_json : null
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
fetchSlideById,
|
|
buildSlidePayload
|
|
};
|