const crypto = require('crypto'); const { BASE_FONT_FAMILIES } = require('./font-families'); const REFERENCE_TYPES_BY_FIELD = { source_id: 'api-request', feed_id: 'rss-feed', timetable_group_id: 'timetable-group', weather_location_id: 'weather-location', weather_icon_set_id: 'weather-icon-set' }; function normalizeMediaReference(value) { const raw = String(value || '').trim(); if (!raw) return ''; let pathname = raw; if (/^https?:\/\//i.test(raw)) { try { pathname = new URL(raw).pathname; } catch (_error) { return ''; } } pathname = pathname.split(/[?#]/, 1)[0]; return pathname.startsWith('/media/') ? pathname : ''; } function normalizeFontFamily(value) { return String(value || '').trim().replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').toLowerCase(); } function getFontFamilyCandidates(value) { return String(value || '').split(',').map(normalizeFontFamily).filter(Boolean); } function addReference(references, seen, ownerKey, referenceType, value) { let referenceValue = String(value === undefined || value === null ? '' : value).trim(); if (['api-request', 'rss-feed', 'timetable-group', 'weather-location'].includes(referenceType)) { const numericValue = Number(referenceValue); if (!Number.isFinite(numericValue)) return; referenceValue = String(numericValue); } if (referenceType === 'font-family') { referenceValue = normalizeFontFamily(referenceValue); if (BASE_FONT_FAMILIES.has(referenceValue)) return; } if (!referenceValue) return; const key = String(ownerKey || '') + '\u0000' + referenceType + '\u0000' + referenceValue; if (seen.has(key)) return; seen.add(key); references.push({ owner_key: String(ownerKey || ''), reference_type: referenceType, reference_value: referenceValue, reference_hash: crypto.createHash('sha256').update(referenceType + '\u0000' + referenceValue).digest() }); } function collectValueReferences(value, ownerKey, references, seen) { if (Array.isArray(value)) { value.forEach(function (item) { collectValueReferences(item, ownerKey, references, seen); }); return; } if (value && typeof value === 'object') { Object.keys(value).forEach(function (field) { const fieldValue = value[field]; const normalizedField = String(field || '').toLowerCase(); if (normalizedField === 'font_family' || normalizedField === 'fontfamily') { getFontFamilyCandidates(fieldValue).forEach(function (family) { addReference(references, seen, ownerKey, 'font-family', family); }); } const referenceType = REFERENCE_TYPES_BY_FIELD[normalizedField]; if (referenceType) addReference(references, seen, ownerKey, referenceType, fieldValue); collectValueReferences(fieldValue, ownerKey, references, seen); }); return; } if (typeof value !== 'string') return; const embeddedMedia = value.match(/(?:https?:\/\/[^\s"'<>]+)?\/media\/[^\s"'<>)]*/gi) || []; embeddedMedia.forEach(function (mediaValue) { const normalized = normalizeMediaReference(mediaValue); if (normalized) addReference(references, seen, ownerKey, 'media', normalized); }); const fontValue = value.replace(/&(?:quot|apos|#0*34|#0*39|#x0*22|#x0*27);/gi, function (entity) { return /^&(?:quot|#0*34|#x0*22);$/i.test(entity) ? '"' : "'"; }); const fontFamilies = /font-family\s*:\s*((?:"[^"]*"|'[^']*'|[^;}"'])+)/gi; let match; while ((match = fontFamilies.exec(fontValue))) { getFontFamilyCandidates(match[1]).forEach(function (family) { addReference(references, seen, ownerKey, 'font-family', family); }); } } function collectContentReferences(ownerType, ownerId, content) { const references = []; const entries = content && typeof content === 'object' && !Array.isArray(content) ? Object.entries(content) : []; entries.forEach(function (entry) { const ownerKey = String(entry[0] || '').slice(0, 100); const seen = new Set(); collectValueReferences(entry[1], ownerKey, references, seen); }); return references.map(function (reference) { return Object.assign({ owner_type: ownerType, owner_id: Number(ownerId) }, reference); }); } async function insertContentReferences(pool, references) { const rows = Array.isArray(references) ? references : []; for (let offset = 0; offset < rows.length; offset += 500) { const batch = rows.slice(offset, offset + 500).map(function (reference) { return [reference.owner_type, reference.owner_id, reference.owner_key, reference.reference_type, reference.reference_value, reference.reference_hash]; }); if (!batch.length) continue; await pool.query( 'INSERT IGNORE INTO c_content_references (owner_type, owner_id, owner_key, reference_type, reference_value, reference_hash) VALUES ?', [batch] ); } } async function replaceContentReferences(connection, ownerType, ownerId, content) { const references = collectContentReferences(ownerType, ownerId, content); const [existing] = await connection.query( 'SELECT id, owner_key, reference_type, reference_value FROM c_content_references WHERE owner_type = ? AND owner_id = ?', [ownerType, ownerId] ); const existingByKey = new Map((existing || []).map(function (reference) { return [JSON.stringify([String(reference.owner_key || ''), String(reference.reference_type || ''), String(reference.reference_value || '')]), reference]; })); const nextKeys = new Set(references.map(function (reference) { return JSON.stringify([reference.owner_key, reference.reference_type, reference.reference_value]); })); const staleIds = Array.from(existingByKey.entries()) .filter(function (entry) { return !nextKeys.has(entry[0]); }) .map(function (entry) { return entry[1].id; }); const additions = references.filter(function (reference) { return !existingByKey.has(JSON.stringify([reference.owner_key, reference.reference_type, reference.reference_value])); }); if (staleIds.length) { await connection.query( 'DELETE FROM c_content_references WHERE owner_type = ? AND owner_id = ? AND id IN (?)', [ownerType, ownerId, staleIds] ); } await insertContentReferences(connection, additions); } async function getContentReferenceValues(pool, referenceType) { const [rows] = await pool.query( 'SELECT DISTINCT reference_value FROM c_content_references WHERE reference_type = ?', [referenceType] ); return (rows || []).map(function (row) { return String(row.reference_value || ''); }).filter(Boolean); } async function getContentReferenceOwnerCounts(pool, referenceType) { const [rows] = await pool.query( `SELECT reference_value, COUNT(DISTINCT owner_type, owner_id) AS owner_count FROM c_content_references WHERE reference_type = ? GROUP BY reference_value`, [referenceType] ); return new Map((rows || []).map(function (row) { return [String(row.reference_value || ''), Number(row.owner_count) || 0]; })); } async function getContentReferenceOwners(pool, referenceType) { const [rows] = await pool.query( 'SELECT DISTINCT owner_type, owner_id FROM c_content_references WHERE reference_type = ?', [referenceType] ); return (rows || []).map(function (row) { return { ownerType: String(row.owner_type || ''), ownerId: Number(row.owner_id) }; }).filter(function (owner) { return owner.ownerType && Number.isFinite(owner.ownerId); }); } async function countContentReferenceOwners(pool, referenceType, referenceValue) { const [rows] = await pool.query( `SELECT COUNT(DISTINCT owner_type, owner_id) AS owner_count FROM c_content_references WHERE reference_type = ? AND reference_value = ?`, [referenceType, String(referenceValue || '')] ); return Number(rows && rows[0] && rows[0].owner_count) || 0; } async function hasContentReference(pool, referenceType, referenceValue) { return (await countContentReferenceOwners(pool, referenceType, referenceValue)) > 0; } module.exports = { collectContentReferences, insertContentReferences, replaceContentReferences, getContentReferenceValues, getContentReferenceOwnerCounts, getContentReferenceOwners, countContentReferenceOwners, hasContentReference };