Release v2.19.1: repair managed font references
This commit is contained in:
1 parent
90253e98a4
commit
9ca372429b
11 files changed
+212
-23
No files matched your search
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.19.0 - 2026-10-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.19.0",
|
"version": "2.19.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.19.0",
|
"version": "2.19.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.19.0",
|
"version": "2.19.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.19.0",
|
"version": "2.19.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.13.1",
|
"bootstrap-icons": "1.13.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.19.0",
|
"version": "2.19.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const { BASE_FONT_FAMILIES } = require('./font-families');
|
||||||
|
|
||||||
const REFERENCE_TYPES_BY_FIELD = {
|
const REFERENCE_TYPES_BY_FIELD = {
|
||||||
source_id: 'api-request',
|
source_id: 'api-request',
|
||||||
@@ -34,7 +35,10 @@ function addReference(references, seen, ownerKey, referenceType, value) {
|
|||||||
if (!Number.isFinite(numericValue)) return;
|
if (!Number.isFinite(numericValue)) return;
|
||||||
referenceValue = String(numericValue);
|
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;
|
if (!referenceValue) return;
|
||||||
const key = String(ownerKey || '') + '\u0000' + referenceType + '\u0000' + referenceValue;
|
const key = String(ownerKey || '') + '\u0000' + referenceType + '\u0000' + referenceValue;
|
||||||
if (seen.has(key)) return;
|
if (seen.has(key)) return;
|
||||||
@@ -75,10 +79,13 @@ function collectValueReferences(value, ownerKey, references, seen) {
|
|||||||
if (normalized) addReference(references, seen, ownerKey, 'media', normalized);
|
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;
|
let match;
|
||||||
while ((match = fontFamilies.exec(value))) {
|
while ((match = fontFamilies.exec(fontValue))) {
|
||||||
getFontFamilyCandidates(match[1] || match[2] || match[3]).forEach(function (family) {
|
getFontFamilyCandidates(match[1]).forEach(function (family) {
|
||||||
addReference(references, seen, ownerKey, 'font-family', family);
|
addReference(references, seen, ownerKey, 'font-family', family);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -825,6 +825,58 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
lastRegionId = Number(regions[regions.length - 1].id);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,11 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { migrateLegacyAssets } = require('#src/web/lib/media/assets-storage');
|
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_LIBRARY_DIR_NAME = 'assets/fonts';
|
||||||
const FONT_MANIFEST_FILE = 'fonts.json';
|
const FONT_MANIFEST_FILE = 'fonts.json';
|
||||||
const FONT_STYLESHEET_FILE = 'fonts.css';
|
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([
|
const FONT_FILE_EXTENSIONS = new Map([
|
||||||
['.woff2', 'woff2'],
|
['.woff2', 'woff2'],
|
||||||
['.woff', 'woff'],
|
['.woff', 'woff'],
|
||||||
|
|||||||
@@ -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: '<span style="font-family: 'Ubuntu';">Ubuntu</span>', font_family: 'Arial' },
|
||||||
|
named: { value: '<span style="font-family: "Ubuntu Sans", sans-serif;">Ubuntu</span>' },
|
||||||
|
hex: { value: '<span style="font-family: 'Ubuntu';">Ubuntu</span>', 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 () => {
|
test('content reference replacement preserves unchanged rows and only deletes or inserts the delta', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const connection = {
|
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.equal(insertion[1][0].length, 2);
|
||||||
assert.deepEqual(insertion[1][0].map(row => [row[3], row[4]]), [['api-request', '5'], ['rss-feed', '8']]);
|
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 = ?'));
|
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: '<span style="font-family: 'Ubuntu';">Second</span>', 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: '<p style="font-family: system-ui, serif, monospace, cursive, fantasy;">Base</p>' },
|
||||||
|
managed: { font_family: 'Arial', value: '<span style="font-family: 'Ubuntu Regular', Helvetica, sans-serif;">Second</span>' }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(references.map(reference => [reference.owner_key, reference.reference_value]), [
|
||||||
|
['managed', 'ubuntu regular']
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,11 @@ function createPool(responses) {
|
|||||||
const queries = [];
|
const queries = [];
|
||||||
return {
|
return {
|
||||||
queries,
|
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) {
|
async query(sql, params) {
|
||||||
queries.push([sql, params]);
|
queries.push([sql, params]);
|
||||||
const text = String(sql);
|
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]) {
|
assert.ok(pool.queries.some(function ([sql, params]) {
|
||||||
return String(sql).includes('DELETE FROM a_permissions') && params[0] === 'dashboard.allow';
|
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: '<span style="font-family: 'Ubuntu Regular', Helvetica, sans-serif;">Second</span>',
|
||||||
|
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'));
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user