From 9ca372429b7d123dfca6ecde7ed998346302574c Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 24 Oct 2026 20:26:00 +0100 Subject: [PATCH] Release v2.19.1: repair managed font references --- CHANGELOG.md | 6 +++ build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/data/content-references.js | 15 ++++++-- src/data/font-families.js | 20 ++++++++++ src/db/migrations.js | 52 +++++++++++++++++++++++++ src/web/lib/media/font-library.js | 15 +------- test/content-references.test.js | 54 ++++++++++++++++++++++++++ test/timetable-migrations.test.js | 63 +++++++++++++++++++++++++++++++ 11 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 src/data/font-families.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d60f96d..fc90577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.19.1 - 2026-10-24 + +### Fixed + +- Font reference indexing handles HTML-encoded quotes and excludes built-in fonts and generic fallbacks. A one-time migration rebuilds font references for all slides and template defaults without changing content or other reference types. + ## 2.19.0 - 2026-10-24 ### Added diff --git a/build/package.player.json b/build/package.player.json index 73645c9..c5b870e 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.19.0", + "version": "2.19.1", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index f7a352c..2cab916 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.19.0", + "version": "2.19.1", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 43ff81e..7709cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.19.0", + "version": "2.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.19.0", + "version": "2.19.1", "dependencies": { "animate.css": "^4.1.1", "bootstrap-icons": "1.13.1", diff --git a/package.json b/package.json index 4b4aa06..f1c6982 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.19.0", + "version": "2.19.1", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/content-references.js b/src/data/content-references.js index e3aadaa..096f734 100644 --- a/src/data/content-references.js +++ b/src/data/content-references.js @@ -1,4 +1,5 @@ const crypto = require('crypto'); +const { BASE_FONT_FAMILIES } = require('./font-families'); const REFERENCE_TYPES_BY_FIELD = { source_id: 'api-request', @@ -34,7 +35,10 @@ function addReference(references, seen, ownerKey, referenceType, value) { if (!Number.isFinite(numericValue)) return; referenceValue = String(numericValue); } - if (referenceType === 'font-family') referenceValue = normalizeFontFamily(referenceValue); + 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; @@ -75,10 +79,13 @@ function collectValueReferences(value, ownerKey, references, seen) { if (normalized) addReference(references, seen, ownerKey, 'media', normalized); }); - const fontFamilies = /font-family\s*:\s*(?:"([^"]+)"|'([^']+)'|([^;}"']+))/gi; + 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(value))) { - getFontFamilyCandidates(match[1] || match[2] || match[3]).forEach(function (family) { + while ((match = fontFamilies.exec(fontValue))) { + getFontFamilyCandidates(match[1]).forEach(function (family) { addReference(references, seen, ownerKey, 'font-family', family); }); } diff --git a/src/data/font-families.js b/src/data/font-families.js new file mode 100644 index 0000000..756e856 --- /dev/null +++ b/src/data/font-families.js @@ -0,0 +1,20 @@ +const DEFAULT_FONT_FAMILY_FORMATS = [ + 'Arial=Arial,Helvetica,sans-serif', + 'Comic Sans MS=Comic Sans MS,cursive,sans-serif', + 'Courier New=Courier New,Courier,monospace', + 'Georgia=Georgia,serif', + 'Helvetica=Helvetica,Arial,sans-serif', + 'Impact=Impact,Charcoal,sans-serif', + 'Lucida Sans Unicode=Lucida Sans Unicode,Lucida Grande,sans-serif', + 'Palatino Linotype=Palatino Linotype,Book Antiqua,Palatino,serif', + 'Tahoma=Tahoma,Geneva,sans-serif', + 'Times New Roman=Times New Roman,Times,serif', + 'Trebuchet MS=Trebuchet MS,Helvetica,sans-serif', + 'Verdana=Verdana,Geneva,sans-serif' +]; + +const BASE_FONT_FAMILIES = new Set(DEFAULT_FONT_FAMILY_FORMATS.flatMap(function (entry) { + return entry.split('=')[1].split(',').map(function (family) { return family.toLowerCase(); }); +}).concat(['system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'fantasy', 'math', 'fangsong', 'emoji', 'inherit', 'initial', 'unset', 'revert', 'revert-layer'])); + +module.exports = { DEFAULT_FONT_FAMILY_FORMATS, BASE_FONT_FAMILIES }; \ No newline at end of file diff --git a/src/db/migrations.js b/src/db/migrations.js index 7989114..b14fd5a 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -825,6 +825,58 @@ const VERSIONED_MIGRATIONS = [ lastRegionId = Number(regions[regions.length - 1].id); } } + }, + { + version: '2.19.1', + label: 'v2.19.1 rebuild managed font references', + run: async function (pool) { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM c_content_references WHERE reference_type = ?', ['font-family']); + + let lastSlideId = 0; + while (true) { + const [slides] = await connection.query( + 'SELECT id, content_json FROM c_slides WHERE id > ? AND content_json IS NOT NULL ORDER BY id ASC LIMIT 500', + [lastSlideId] + ); + if (!slides.length) break; + const references = []; + slides.forEach(function (slide) { + references.push.apply(references, collectContentReferences('slide', slide.id, parseJsonSafe(slide.content_json) || {}) + .filter(function (reference) { return reference.reference_type === 'font-family'; })); + }); + await insertContentReferences(connection, references); + lastSlideId = Number(slides[slides.length - 1].id); + } + + let lastRegionId = 0; + while (true) { + const [regions] = await connection.query( + 'SELECT id, template_id, region_key, default_content_json FROM c_template_regions WHERE id > ? AND default_content_json IS NOT NULL ORDER BY id ASC LIMIT 500', + [lastRegionId] + ); + if (!regions.length) break; + const references = []; + regions.forEach(function (region) { + const regionContent = {}; + regionContent[region.region_key] = parseJsonSafe(region.default_content_json) || {}; + references.push.apply(references, collectContentReferences('template', region.template_id, regionContent) + .filter(function (reference) { return reference.reference_type === 'font-family'; })); + }); + await insertContentReferences(connection, references); + lastRegionId = Number(regions[regions.length - 1].id); + } + + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } } ]; diff --git a/src/web/lib/media/font-library.js b/src/web/lib/media/font-library.js index d3b82b3..4339b3a 100644 --- a/src/web/lib/media/font-library.js +++ b/src/web/lib/media/font-library.js @@ -2,24 +2,11 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { migrateLegacyAssets } = require('#src/web/lib/media/assets-storage'); +const { DEFAULT_FONT_FAMILY_FORMATS } = require('#src/data/font-families'); const FONT_LIBRARY_DIR_NAME = 'assets/fonts'; const FONT_MANIFEST_FILE = 'fonts.json'; const FONT_STYLESHEET_FILE = 'fonts.css'; -const DEFAULT_FONT_FAMILY_FORMATS = [ - 'Arial=Arial,Helvetica,sans-serif', - 'Comic Sans MS=Comic Sans MS,cursive,sans-serif', - 'Courier New=Courier New,Courier,monospace', - 'Georgia=Georgia,serif', - 'Helvetica=Helvetica,Arial,sans-serif', - 'Impact=Impact,Charcoal,sans-serif', - 'Lucida Sans Unicode=Lucida Sans Unicode,Lucida Grande,sans-serif', - 'Palatino Linotype=Palatino Linotype,Book Antiqua,Palatino,serif', - 'Tahoma=Tahoma,Geneva,sans-serif', - 'Times New Roman=Times New Roman,Times,serif', - 'Trebuchet MS=Trebuchet MS,Helvetica,sans-serif', - 'Verdana=Verdana,Geneva,sans-serif' -]; const FONT_FILE_EXTENSIONS = new Map([ ['.woff2', 'woff2'], ['.woff', 'woff'], diff --git a/test/content-references.test.js b/test/content-references.test.js index 8508814..6da12a9 100644 --- a/test/content-references.test.js +++ b/test/content-references.test.js @@ -38,6 +38,20 @@ test('content reference extraction deduplicates repeated references within a reg ]); }); +test('content reference extraction decodes HTML-encoded font names before parsing CSS', () => { + const references = collectContentReferences('slide', 12, { + numeric: { value: 'Ubuntu', font_family: 'Arial' }, + named: { value: 'Ubuntu' }, + hex: { value: 'Ubuntu', font_family: 'Ubuntu' } + }); + + assert.deepEqual(references.map(reference => [reference.owner_key, reference.reference_value]), [ + ['numeric', 'ubuntu'], + ['named', 'ubuntu sans'], + ['hex', 'ubuntu'] + ]); +}); + test('content reference replacement preserves unchanged rows and only deletes or inserts the delta', async () => { const calls = []; const connection = { @@ -66,4 +80,44 @@ test('content reference replacement preserves unchanged rows and only deletes or assert.equal(insertion[1][0].length, 2); assert.deepEqual(insertion[1][0].map(row => [row[3], row[4]]), [['api-request', '5'], ['rss-feed', '8']]); assert.ok(!calls.some(([sql]) => sql === 'DELETE FROM c_content_references WHERE owner_type = ? AND owner_id = ?')); +}); + +test('content reference replacement repairs encoded font references and removes the base font', async () => { + const calls = []; + const connection = { + async query(sql, params) { + calls.push([sql, params]); + if (sql.startsWith('SELECT id, owner_key')) { + return [[ + { id: 101, owner_key: 'second_place', reference_type: 'font-family', reference_value: ''' }, + { id: 102, owner_key: 'second_place', reference_type: 'font-family', reference_value: 'arial' } + ]]; + } + return [{}]; + } + }; + + await replaceContentReferences(connection, 'slide', 22, { + second_place: { value: 'Second', font_family: 'Arial' } + }); + + const deletion = calls.find(([sql]) => sql.startsWith('DELETE FROM c_content_references')); + assert.deepEqual(deletion[1], ['slide', 22, [101, 102]]); + const insertion = calls.find(([sql]) => sql.startsWith('INSERT IGNORE INTO c_content_references')); + assert.deepEqual(insertion[1][0].map(row => [row[2], row[3], row[4]]), [ + ['second_place', 'font-family', 'ubuntu'] + ]); +}); + +test('content references omit built-in font stacks but retain managed font families', () => { + const references = collectContentReferences('slide', 117, { + base: { font_family: 'Arial, Helvetica, sans-serif' }, + quoted: { fontFamily: "'TREBUCHET MS', Geneva, sans-serif" }, + generic: { value: '

Base

' }, + managed: { font_family: 'Arial', value: 'Second' } + }); + + assert.deepEqual(references.map(reference => [reference.owner_key, reference.reference_value]), [ + ['managed', 'ubuntu regular'] + ]); }); \ No newline at end of file diff --git a/test/timetable-migrations.test.js b/test/timetable-migrations.test.js index f2cae1a..ecfc613 100644 --- a/test/timetable-migrations.test.js +++ b/test/timetable-migrations.test.js @@ -9,6 +9,11 @@ function createPool(responses) { const queries = []; return { queries, + async getConnection() { return this; }, + async beginTransaction() { queries.push(['START TRANSACTION']); }, + async commit() { queries.push(['COMMIT']); }, + async rollback() { queries.push(['ROLLBACK']); }, + release() { queries.push(['RELEASE CONNECTION']); }, async query(sql, params) { queries.push([sql, params]); const text = String(sql); @@ -200,4 +205,62 @@ test('2.17.1 migration removes obsolete dashboard.allow grants and permission', assert.ok(pool.queries.some(function ([sql, params]) { return String(sql).includes('DELETE FROM a_permissions') && params[0] === 'dashboard.allow'; })); +}); + +test('2.19.1 migration rebuilds only managed font references for all content in a transaction', async () => { + const pool = createPool([ + { match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] }, + { match: sql => sql.includes('information_schema.TABLES'), result: [[{ table_count: 0 }]] }, + { + match: (sql, params) => sql.startsWith('SELECT id, content_json') && params[0] === 0, + result: [[{ id: 117, content_json: JSON.stringify({ second_place: { + font_family: 'Arial', value: 'Second', + source_id: 4, image: '/media/uploads/keep.png' + } }) }]] + }, + { + match: (sql, params) => sql.startsWith('SELECT id, content_json') && params[0] === 117, + result: [[{ id: 118, content_json: { hero: { font_family: 'Acme Display' } } }]] + }, + { + match: (sql, params) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 0, + result: [[{ id: 900, template_id: 42, region_key: 'hero', default_content_json: { font_family: 'Acme Display', feed_id: 8 } }]] + } + ]); + + const pending = await getPendingMigrations(pool, { currentVersion: '2.19.0' }); + assert.deepEqual(pending.map(migration => migration.version), ['2.19.1']); + await pending[0].run(pool); + + const deletions = pool.queries.filter(([sql]) => sql.startsWith('DELETE')); + assert.deepEqual(deletions, [['DELETE FROM c_content_references WHERE reference_type = ?', ['font-family']]]); + const inserted = pool.queries.filter(([sql]) => sql.startsWith('INSERT IGNORE INTO c_content_references')) + .flatMap(([, params]) => params[0]); + assert.deepEqual(inserted.map(row => row.slice(0, 5)), [ + ['slide', 117, 'second_place', 'font-family', 'ubuntu regular'], + ['slide', 118, 'hero', 'font-family', 'acme display'], + ['template', 42, 'hero', 'font-family', 'acme display'] + ]); + assert.ok(pool.queries.some(([sql, params]) => sql.startsWith('SELECT id, content_json') && params[0] === 118)); + assert.ok(pool.queries.some(([sql, params]) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 900)); + assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql))); + assert.deepEqual(pool.queries.slice(-2), [['COMMIT'], ['RELEASE CONNECTION']]); + assert.deepEqual(await getPendingMigrations(pool, { currentVersion: '2.19.1' }), []); +}); + +test('2.19.1 font repair rolls back and releases its connection on failure', async () => { + const pool = createPool([ + { match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] }, + { match: sql => sql.includes('information_schema.TABLES'), result: [[{ table_count: 0 }]] } + ]); + const [migration] = await getPendingMigrations(pool, { currentVersion: '2.19.0' }); + const query = pool.query.bind(pool); + pool.query = async function (sql, params) { + if (sql.startsWith('SELECT id, content_json')) throw new Error('read failed'); + return query(sql, params); + }; + + await assert.rejects(migration.run(pool), /read failed/); + assert.deepEqual(pool.queries.slice(-2), [['ROLLBACK'], ['RELEASE CONNECTION']]); + assert.ok(!pool.queries.some(([sql]) => sql === 'COMMIT')); }); \ No newline at end of file