// Slide data access helpers, including rich-text normalization and payload building. const { fetchTemplateById } = require('#src/data/templates'); const { parseJsonSafe, validateMaxLength } = require('#src/data/utils'); const { normalizeLayout } = require('#src/web/public/js/shared/region-layout'); const TITLE_MAX_LENGTH = 255; const { buildQrCodeContent } = require('#src/data/qr-code'); const DEFAULT_FONT_SIZE = 32; const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; function sanitizeRichTextAttributes(tagName, attrText) { const 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'], img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], 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'] }; const allowed = allowedAttributes[tagName] || []; if (!allowed.length) { return ''; } const attrs = []; String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { const lowerKey = String(key || '').toLowerCase(); if (!allowed.includes(lowerKey)) { return ''; } const 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') { const targetValue = String(value || '').trim(); if (targetValue === '_blank') { attrs.push(' target="_blank"'); if (!attrs.includes(' rel="noreferrer noopener"')) { attrs.push(' rel="noreferrer noopener"'); } return ''; } } attrs.push(' ' + lowerKey + '="' + String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + '"'); return ''; }); return attrs.join(''); } function sanitizeRichText(html) { let output = String(html || ''); output = output.replace(//gi, ''); output = output.replace(//gi, ''); return output.replace(/<[^>]+>/g, (tag) => { const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); if (!match) { return ''; } const closing = Boolean(match[1]); const name = String(match[2] || '').toLowerCase(); const attrText = String(match[3] || ''); if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { return ''; } if (closing) { return ``; } return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`; }); } function stripEditorOnlyMarkup(value) { return String(value || '') .replace(/]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '') .replace(/]*class="[^"]*api-region-sample-accordion[^"]*"[^>]*>[\s\S]*?<\/details>/gi, '') .trim(); } function normalizeEditorMarkup(value) { return String(value === undefined || value === null ? '' : value) .replace(/https?:\/\/[^"'\s<>]+(\/media\/[^"'\s<>)]*)/gi, '$1') .trim(); } function normalizeEditorMediaReferences(value) { if (Array.isArray(value)) { return value.map(normalizeEditorMediaReferences); } if (value && typeof value === 'object') { Object.keys(value).forEach((key) => { value[key] = normalizeEditorMediaReferences(value[key]); }); return value; } return typeof value === 'string' ? normalizeEditorMarkup(value) : value; } 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 === 'timetable') { const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; const suffix = '_' + region.id; const generic = {}; const submittedText = body[`region_text_${region.id}`]; const normalizedText = submittedText === undefined ? String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : '') : String(submittedText || ''); 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]; }); if (Object.prototype.hasOwnProperty.call(generic, 'timetable_display_mode')) { generic.display_mode = generic.timetable_display_mode; delete generic.timetable_display_mode; } if (Object.prototype.hasOwnProperty.call(generic, 'timetable_max_items')) { generic.max_items = generic.timetable_max_items; delete generic.timetable_max_items; } Object.keys(current).forEach((key) => { if (generic[key] === undefined) { generic[key] = current[key]; } }); delete generic.timetable_display_mode; delete generic.timetable_max_items; generic.date_filter = ['between', 'before', 'after'].includes(generic.date_filter) ? generic.date_filter : 'all'; for (const field of ['date_from', 'date_to']) { const value = String(generic[field] || '').trim(); const date = value ? new Date(value) : null; const active = generic.date_filter === 'between' || generic.date_filter === (field === 'date_from' ? 'after' : 'before'); if (active && date && Number.isNaN(date.getTime())) { const error = new Error('Timetable date range must contain valid date-times.'); error.statusCode = 400; throw error; } generic[field] = date && !Number.isNaN(date.getTime()) ? date.toISOString() : ''; } if (generic.date_filter === 'between' && generic.date_from && generic.date_to && generic.date_from > generic.date_to) { const error = new Error('Timetable date range must end on or after its start.'); error.statusCode = 400; throw error; } generic.text = normalizedText; generic.value = normalizedText; generic.type = region.region_type; content[region.region_key] = generic; } 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 ? (current.feed_id || null) : (feedId === '' ? 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 sortMode = body[`region_api_sort_mode_${region.id}`]; const sortBy = body[`region_api_sort_by_${region.id}`]; const sortDirection = body[`region_api_sort_direction_${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 ? (current.source_id || null) : (sourceId === '' ? 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(), sort_by: sortMode === undefined ? String(current.sort_by || '') : (sortMode === 'field' ? String(sortBy || '').trim() : ''), sort_direction: sortDirection === undefined ? (String(current.sort_direction || '').toLowerCase() === 'desc' ? 'desc' : 'asc') : (sortDirection === 'desc' ? 'desc' : 'asc'), variable_name: 'item', font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } else if (region.region_type === 'weather') { const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const locationId = body[`region_weather_location_id_${region.id}`]; const submittedForecastMode = body[`region_weather_forecast_mode_${region.id}`]; const forecastMode = ['current', 'hourly'].includes(submittedForecastMode) ? submittedForecastMode : 'daily'; const style = getTextRegionStyle(body, region, existingContent); content[region.region_key] = { type: 'weather', value: body[`region_text_${region.id}`] !== undefined ? String(body[`region_text_${region.id}`] || '') : String(current.value || ''), weather_location_id: locationId === undefined || locationId === null ? (current.weather_location_id || null) : (locationId === '' ? null : Number(locationId)), weather_icon_set_id: body[`region_weather_icon_set_id_${region.id}`] === undefined ? String(current.weather_icon_set_id || '') : String(body[`region_weather_icon_set_id_${region.id}`] || '').trim(), forecast_mode: body[`region_weather_forecast_mode_${region.id}`] === undefined ? (['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily') : forecastMode, font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } else if (region.region_type === 'chart') { const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; let manualRows = []; let datasets = []; try { const parsedRows = JSON.parse(String(body[`region_chart_manual_rows_${region.id}`] || '[]')); manualRows = Array.isArray(parsedRows) ? parsedRows.slice(0, 500) : []; } catch (_error) { manualRows = Array.isArray(current.manual_rows) ? current.manual_rows : []; } try { const parsedDatasets = JSON.parse(String(body[`region_chart_datasets_${region.id}`] || '[]')); datasets = Array.isArray(parsedDatasets) ? parsedDatasets.slice(0, 12) : []; } catch (_error) { datasets = Array.isArray(current.datasets) ? current.datasets : []; } const colors = String(body[`region_chart_colors_${region.id}`] === undefined ? (Array.isArray(current.colors) ? current.colors.join(',') : '') : body[`region_chart_colors_${region.id}`] || '') .split(',') .map((color) => String(color || '').trim()) .filter((color) => /^#[0-9a-fA-F]{3,8}$/.test(color)) .slice(0, 24); const chartSourceKind = String(body[`region_chart_source_kind_${region.id}`] || current.source_kind || 'manual') === 'api' ? 'api' : 'manual'; const chartOptions = { legend: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic')) ? String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic') : 'automatic', begin_at_zero: body[`region_chart_begin_at_zero_control_${region.id}`] === undefined ? current.chart_options?.begin_at_zero !== false : body[`region_chart_begin_at_zero_${region.id}`] === 'on', y_axis_min: String(body[`region_chart_y_axis_min_${region.id}`] === undefined ? current.chart_options?.y_axis_min || '' : body[`region_chart_y_axis_min_${region.id}`] || '').trim(), y_axis_max: String(body[`region_chart_y_axis_max_${region.id}`] === undefined ? current.chart_options?.y_axis_max || '' : body[`region_chart_y_axis_max_${region.id}`] || '').trim(), line_points: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic')) ? String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic') : 'automatic', line_fill: body[`region_chart_line_fill_${region.id}`] === 'on', bar_orientation: String(body[`region_chart_bar_orientation_${region.id}`] || current.chart_options?.bar_orientation || 'vertical') === 'horizontal' ? 'horizontal' : 'vertical', value_format: ['number', 'percent', 'currency'].includes(String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number')) ? String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number') : 'number', decimal_places: Math.max(0, Math.min(4, Number(body[`region_chart_decimal_places_${region.id}`] === undefined ? current.chart_options?.decimal_places || 0 : body[`region_chart_decimal_places_${region.id}`] || 0) || 0)), text_size: Math.max(8, Math.min(48, Number(body[`region_chart_text_size_${region.id}`] === undefined ? current.chart_options?.text_size || 12 : body[`region_chart_text_size_${region.id}`] || 12) || 12)), grid_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color || '')) ? String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color) : '#000000', text_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color || '')) ? String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color) : '#000000' }; const sourceOptions = { sort: ['none', 'reverse-original', 'value-ascending', 'value-descending'].includes(String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none')) ? String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none') : 'none', limit: Math.max(0, Math.min(500, Number(body[`region_chart_source_limit_${region.id}`] === undefined ? current.source_options?.limit || 0 : body[`region_chart_source_limit_${region.id}`] || 0) || 0)), missing: ['skip', 'zero', 'gap'].includes(String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap')) ? String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap') : 'gap' }; content[region.region_key] = { type: 'chart', chart_type: ['line', 'bar', 'doughnut', 'pie'].includes(String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar')) ? String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar') : 'bar', source_kind: chartSourceKind, source_id: chartSourceKind === 'api' ? (body[`region_chart_source_id_${region.id}`] === undefined ? (current.source_id || null) : (body[`region_chart_source_id_${region.id}`] === '' ? null : Number(body[`region_chart_source_id_${region.id}`]))) : null, items_path: String(body[`region_chart_items_path_${region.id}`] === undefined ? current.items_path || '' : body[`region_chart_items_path_${region.id}`] || '').trim(), label_path: String(body[`region_chart_label_path_${region.id}`] === undefined ? current.label_path || '' : body[`region_chart_label_path_${region.id}`] || '').trim(), value_path: String(body[`region_chart_value_path_${region.id}`] === undefined ? current.value_path || '' : body[`region_chart_value_path_${region.id}`] || '').trim(), dataset_label: String(body[`region_chart_dataset_label_${region.id}`] === undefined ? current.dataset_label || 'Value' : body[`region_chart_dataset_label_${region.id}`] || 'Value').trim() || 'Value', color: /^#[0-9a-fA-F]{3,8}$/.test(String(body[`region_chart_color_${region.id}`] || current.color || '')) ? String(body[`region_chart_color_${region.id}`]) : (current.color || '#36a269'), datasets, colors, manual_rows: manualRows, chart_options: chartOptions, source_options: sourceOptions }; } else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather', 'chart'].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: sanitizeRichText(stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || '')))), font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } } return normalizeEditorMediaReferences(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); const submittedLayouts = req.body.region_layouts_json; let layouts = null; if (submittedLayouts !== undefined) { layouts = parseJsonSafe(submittedLayouts); if (!layouts || typeof layouts !== 'object' || Array.isArray(layouts)) { const error = new Error('Invalid slide layout.'); error.statusCode = 400; throw error; } } const canvas = { width: template.canvas_size_width, height: template.canvas_size_height }; for (const region of template.regions) { const key = region.region_key; const value = layouts ? (Object.hasOwn(layouts, key) ? layouts[key] : null) : Number(existingSlide && existingSlide.template_id) === template.id && existingContent[key] && existingContent[key].layout; if (!value) continue; const layout = normalizeLayout(value, canvas); if (!layout) { const error = new Error('Invalid position or size for region ' + key + '.'); error.statusCode = 400; throw error; } if (content[key]) content[key].layout = layout; } 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; } const layout = content[regionKey].layout; content[regionKey] = await buildQrCodeContent(content[regionKey]); if (layout) content[regionKey].layout = layout; })); return { title, templateId: template.id, contentJson: JSON.stringify(content) }; } return { title, templateId: null, contentJson: existingSlide ? existingSlide.content_json : null }; } module.exports = { fetchSlideById, buildSlidePayload };