PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.0
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.0
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Fonts / Registry.php

Registry.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.0, at src/Fonts/Registry.php

553 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Registry of hosted font variants.
4 *
5 * @package EasyFonts
6 */
7
8 namespace EasyFonts\Fonts;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * CRUD over the easyfonts_fonts table.
14 */
15 class Registry {
16
17 /**
18 * Table name.
19 *
20 * @return string
21 */
22 private function table(): string {
23 global $wpdb;
24
25 return $wpdb->prefix . 'easyfonts_fonts';
26 }
27
28 /**
29 * Stable identity for a variant.
30 *
31 * @param string $family Family.
32 * @param string $weight Weight.
33 * @param string $style Style.
34 * @param string $subset Subset.
35 * @return string
36 */
37 public static function variant_key( string $family, string $weight, string $style, string $subset ): string {
38 return strtolower( str_replace( ' ', '-', $family ) ) . ':' . $weight . ':' . $style . ':' . $subset;
39 }
40
41 /**
42 * Last DB error from a write, if any (for diagnostics).
43 *
44 * @var string
45 */
46 private static string $last_error = '';
47
48 /**
49 * Most recent write error.
50 *
51 * @return string
52 */
53 public static function last_error(): string {
54 return self::$last_error;
55 }
56
57 /**
58 * Insert or update a variant by its variant_key.
59 *
60 * Uses SELECT + insert/update with explicit format arrays (rather than a
61 * hand-built INSERT … ON DUPLICATE KEY UPDATE) so NULL metrics and the
62 * MySQL-8.0.20-deprecated VALUES() function can't break the write.
63 *
64 * @param array<string,mixed> $data Variant data.
65 * @return void
66 */
67 public function upsert( array $data ): void {
68 global $wpdb;
69
70 $family = sanitize_text_field( $data['family'] ?? '' );
71
72 if ( '' === $family ) {
73 return;
74 }
75
76 $weight = sanitize_text_field( $data['weight'] ?? '400' );
77 $style = sanitize_text_field( $data['style'] ?? 'normal' );
78 $subset = sanitize_text_field( $data['subset'] ?? 'latin' );
79 $key = self::variant_key( $family, $weight, $style, $subset );
80
81 $row = array(
82 'family' => $family,
83 'weight' => $weight,
84 'style' => $style,
85 'subset' => $subset,
86 'variant_key' => $key,
87 'is_variable' => ! empty( $data['is_variable'] ) ? 1 : 0,
88 'provider' => sanitize_text_field( $data['provider'] ?? 'google' ),
89 'css_file' => sanitize_text_field( $data['css_file'] ?? '' ),
90 'font_file' => sanitize_text_field( $data['font_file'] ?? '' ),
91 'file_size' => absint( $data['file_size'] ?? 0 ),
92 'source_url' => esc_url_raw( $data['source_url'] ?? '' ),
93 );
94
95 $formats = array( '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%s' );
96
97 // metrics is nullable: only include it when we actually have an array,
98 // so a missing value leaves the existing column untouched.
99 if ( ! empty( $data['metrics'] ) && is_array( $data['metrics'] ) ) {
100 $row['metrics'] = wp_json_encode( $data['metrics'] );
101 $formats[] = '%s';
102 }
103
104 $table = $this->table();
105
106 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
107 $existing = $wpdb->get_var(
108 $wpdb->prepare( "SELECT id FROM {$table} WHERE variant_key = %s", $key )
109 );
110
111 if ( $existing ) {
112 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
113 $wpdb->update( $table, $row, array( 'id' => (int) $existing ), $formats, array( '%d' ) );
114 } else {
115 $row['created_at'] = current_time( 'mysql' );
116 $formats[] = '%s';
117 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
118 $wpdb->insert( $table, $row, $formats );
119 }
120
121 if ( ! empty( $wpdb->last_error ) ) {
122 self::$last_error = $wpdb->last_error;
123
124 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
125 error_log( 'EasyFonts: font registration failed — ' . $wpdb->last_error ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
126 }
127 }
128 }
129
130 /**
131 * Variant keys that the user has disabled (is_enabled = 0). The consolidator
132 * consults this to skip those variants when building the stylesheet.
133 *
134 * @return array<string,bool> Map of variant_key => true.
135 */
136 public function disabled_keys(): array {
137 global $wpdb;
138
139 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
140 $keys = $wpdb->get_col( "SELECT variant_key FROM {$this->table()} WHERE is_enabled = 0" );
141
142 return $keys ? array_fill_keys( $keys, true ) : array();
143 }
144
145 /**
146 * Distinct family/weight/style targets the user marked for preloading
147 * (and which are still enabled), each resolved to a font file. Deduped:
148 * one entry per family+weight+style, preferring the latin subset file.
149 *
150 * @return array<int,array{family:string,weight:string,style:string,file:string}>
151 */
152 public function preload_targets(): array {
153 global $wpdb;
154
155 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
156 $rows = $wpdb->get_results(
157 "SELECT family, weight, style, font_file, subset
158 FROM {$this->table()}
159 WHERE is_preloaded = 1 AND is_enabled = 1 AND font_file <> ''
160 ORDER BY ( subset = 'latin' ) DESC",
161 ARRAY_A
162 );
163
164 if ( ! $rows ) {
165 return array();
166 }
167
168 $seen = array();
169 $out = array();
170
171 foreach ( $rows as $r ) {
172 $dedupe = strtolower( $r['family'] ) . '|' . $r['weight'] . '|' . $r['style'];
173
174 if ( isset( $seen[ $dedupe ] ) ) {
175 continue; // First (latin-preferred) wins.
176 }
177
178 $seen[ $dedupe ] = true;
179 $out[] = array(
180 'family' => $r['family'],
181 'weight' => $r['weight'],
182 'style' => $r['style'],
183 'file' => $r['font_file'],
184 );
185 }
186
187 return $out;
188 }
189
190 /**
191 * Set a boolean flag (is_enabled | is_preloaded) on ALL variants at once.
192 *
193 * @param string $field 'is_enabled' or 'is_preloaded'.
194 * @param bool $value New value.
195 * @return void
196 */
197 /**
198 * Auto-enable preload for a variant, but only if the user hasn't set their
199 * own preload choice for it (preload_user_set = 0). Does NOT mark user_set,
200 * so a later user toggle still wins. Returns true if a row was actually
201 * flipped on (so the caller can count it against the per-page cap).
202 *
203 * @param string $family Family.
204 * @param string $weight Weight.
205 * @param string $style Style.
206 * @return bool
207 */
208 public function auto_preload( string $family, string $weight, string $style ): bool {
209 global $wpdb;
210 $table = $this->table();
211
212 // 1. Exact match (family + weight + style), only if not user-set.
213 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
214 $updated = (int) $wpdb->query(
215 $wpdb->prepare(
216 "UPDATE {$table} SET is_preloaded = 1
217 WHERE family = %s AND weight = %s AND style = %s
218 AND preload_user_set = 0 AND is_preloaded = 0 AND is_enabled = 1",
219 $family,
220 $weight,
221 $style
222 )
223 );
224
225 if ( $updated > 0 ) {
226 return true;
227 }
228
229 // 2. The browser's computed weight/style often differs from the hosted
230 // file (e.g. a heading renders at 700 but only 400 is hosted, or a
231 // variable font resolves oddly). So if no exact row matched, preload
232 // the family's single best enabled, not-yet-preloaded, not-user-set
233 // variant — preferring the requested style, then a 400/normal-ish
234 // weight. This ensures an above-the-fold family still gets preloaded.
235 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
236 $id = $wpdb->get_var(
237 $wpdb->prepare(
238 "SELECT id FROM {$table}
239 WHERE family = %s
240 AND preload_user_set = 0 AND is_preloaded = 0 AND is_enabled = 1
241 ORDER BY ( style = %s ) DESC,
242 ABS( CAST( NULLIF(weight,'') AS UNSIGNED ) - 400 ) ASC,
243 id ASC
244 LIMIT 1",
245 $family,
246 $style
247 )
248 );
249
250 if ( ! $id ) {
251 return false;
252 }
253
254 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
255 $wpdb->query(
256 $wpdb->prepare( "UPDATE {$table} SET is_preloaded = 1 WHERE id = %d", (int) $id )
257 );
258
259 return true;
260 }
261
262 public function set_flag_all( string $field, bool $value ): void {
263 global $wpdb;
264
265 if ( ! in_array( $field, array( 'is_enabled', 'is_preloaded' ), true ) ) {
266 return;
267 }
268
269 // Bulk toggles are user actions, so also stamp the matching user_set
270 // column to protect the choice from later auto-detection.
271 $col = 'is_enabled' === $field ? 'load_user_set' : 'preload_user_set';
272
273 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
274 $wpdb->query( "UPDATE {$this->table()} SET `{$field}` = " . ( $value ? 1 : 0 ) . ", `{$col}` = 1" );
275 }
276
277 /**
278 * Set a boolean flag (is_enabled | is_preloaded) on variants.
279 *
280 * @param string $field 'is_enabled' or 'is_preloaded'.
281 * @param bool $value New value.
282 * @param string $family Family to target.
283 * @param string $weight Weight (empty = whole family).
284 * @param string $style Style (empty = whole family).
285 * @return void
286 */
287 public function set_flag( string $field, bool $value, string $family, string $weight = '', string $style = '', bool $mark_user = true ): void {
288 global $wpdb;
289
290 if ( ! in_array( $field, array( 'is_enabled', 'is_preloaded' ), true ) ) {
291 return;
292 }
293
294 $where = array( 'family' => $family );
295 $where_formats = array( '%s' );
296
297 if ( '' !== $weight ) {
298 $where['weight'] = $weight;
299 $where_formats[] = '%s';
300 }
301
302 if ( '' !== $style ) {
303 $where['style'] = $style;
304 $where_formats[] = '%s';
305 }
306
307 $data = array( $field => $value ? 1 : 0 );
308 $formats = array( '%d' );
309
310 // Record that a human set this, so the auto-detect logic never overrides
311 // the choice later (load → load_user_set, preload → preload_user_set).
312 if ( $mark_user ) {
313 $col = 'is_enabled' === $field ? 'load_user_set' : 'preload_user_set';
314 $data[ $col ] = 1;
315 $formats[] = '%d';
316 }
317
318 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
319 $wpdb->update(
320 $this->table(),
321 $data,
322 $where,
323 $formats,
324 $where_formats
325 );
326 }
327
328 /**
329 * Grouped families with usage classification + per-(weight,style) flags, for
330 * the admin Fonts screen. Subsets are merged into each weight/style row.
331 *
332 * @param array<string,bool> $rendered Set of "family|weight|style" (lowercased) seen rendering.
333 * @param array<string,bool> $rendered_family Set of lowercased families seen rendering.
334 * @return array<int,array<string,mixed>>
335 */
336 public function grouped_with_usage( array $rendered, array $rendered_family ): array {
337 global $wpdb;
338
339 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
340 $rows = $wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY family ASC, weight ASC", ARRAY_A );
341
342 if ( ! $rows ) {
343 return array();
344 }
345
346 $families = array();
347
348 foreach ( $rows as $row ) {
349 $family = $row['family'];
350 $lc = strtolower( $family );
351
352 if ( ! isset( $families[ $family ] ) ) {
353 $families[ $family ] = array(
354 'family' => $family,
355 'is_variable' => (int) $row['is_variable'],
356 'has_metrics' => ! empty( $row['metrics'] ),
357 'used' => isset( $rendered_family[ $lc ] ),
358 'total_size' => 0,
359 'enabled' => false,
360 '_variants' => array(),
361 );
362 }
363
364 $ws = $row['weight'] . '|' . $row['style'];
365 $key = $lc . '|' . $row['weight'] . '|' . $row['style'];
366
367 if ( ! isset( $families[ $family ]['_variants'][ $ws ] ) ) {
368 $families[ $family ]['_variants'][ $ws ] = array(
369 'weight' => $row['weight'],
370 'style' => $row['style'],
371 'subsets' => array(),
372 'is_enabled' => true,
373 'is_preloaded' => false,
374 'used' => isset( $rendered[ $key ] ),
375 'size' => 0,
376 );
377 }
378
379 $v = &$families[ $family ]['_variants'][ $ws ];
380 $v['subsets'][] = $row['subset'];
381 $v['size'] += (int) $row['file_size'];
382 $v['is_enabled'] = $v['is_enabled'] && ( 1 === (int) $row['is_enabled'] );
383 $v['is_preloaded'] = $v['is_preloaded'] || ( 1 === (int) $row['is_preloaded'] );
384 unset( $v );
385
386 $families[ $family ]['total_size'] += (int) $row['file_size'];
387
388 if ( 1 === (int) $row['is_enabled'] ) {
389 $families[ $family ]['enabled'] = true;
390 }
391 }
392
393 // Flatten variant maps to lists.
394 $out = array();
395
396 foreach ( $families as $fam ) {
397 $fam['variants'] = array_values( $fam['_variants'] );
398 unset( $fam['_variants'] );
399 $out[] = $fam;
400 }
401
402 return $out;
403 }
404
405 /**
406 * All variants grouped by family.
407 *
408 * @return array<int,array<string,mixed>>
409 */
410 public function grouped(): array {
411 global $wpdb;
412
413 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
414 $rows = $wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY family ASC, weight ASC", ARRAY_A );
415
416 if ( ! $rows ) {
417 return array();
418 }
419
420 $out = array();
421
422 foreach ( $rows as $row ) {
423 $family = $row['family'];
424
425 if ( ! isset( $out[ $family ] ) ) {
426 $out[ $family ] = array(
427 'family' => $family,
428 'is_variable' => (int) $row['is_variable'],
429 'has_metrics' => ! empty( $row['metrics'] ),
430 'total_size' => 0,
431 'variants' => array(),
432 );
433 }
434
435 $out[ $family ]['variants'][] = array(
436 'id' => (int) $row['id'],
437 'weight' => $row['weight'],
438 'style' => $row['style'],
439 'subset' => $row['subset'],
440 'size' => (int) $row['file_size'],
441 );
442
443 $out[ $family ]['total_size'] += (int) $row['file_size'];
444 }
445
446 return array_values( $out );
447 }
448
449 /**
450 * Store metrics for every variant of a family (used by the backfill).
451 *
452 * @param string $family Family.
453 * @param array<string,float|int> $metrics Metrics.
454 * @return void
455 */
456 public function set_family_metrics( string $family, array $metrics ): void {
457 global $wpdb;
458
459 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
460 $wpdb->update(
461 $this->table(),
462 array( 'metrics' => wp_json_encode( $metrics ) ),
463 array( 'family' => $family ),
464 array( '%s' ),
465 array( '%s' )
466 );
467 }
468
469 /**
470 * Stored metrics for a family (first variant that has them).
471 *
472 * @param string $family Family.
473 * @return array<string,float|int>|null
474 */
475 public function metrics_for( string $family ): ?array {
476 global $wpdb;
477
478 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
479 $json = $wpdb->get_var(
480 $wpdb->prepare(
481 "SELECT metrics FROM {$this->table()} WHERE family = %s AND metrics IS NOT NULL LIMIT 1",
482 $family
483 )
484 );
485
486 if ( ! $json ) {
487 return null;
488 }
489
490 $decoded = json_decode( $json, true );
491
492 return is_array( $decoded ) ? $decoded : null;
493 }
494
495 /**
496 * Resolve the local font file for a family/weight/style (first subset match).
497 *
498 * @param string $family Family.
499 * @param string $weight Weight.
500 * @param string $style Style.
501 * @return string|null Cache-relative font filename.
502 */
503 public function resolve_file( string $family, string $weight, string $style ): ?string {
504 global $wpdb;
505
506 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
507 $file = $wpdb->get_var(
508 $wpdb->prepare(
509 "SELECT font_file FROM {$this->table()}
510 WHERE family = %s AND weight = %s AND style = %s AND font_file <> ''
511 ORDER BY ( subset = 'latin' ) DESC
512 LIMIT 1",
513 $family,
514 $weight,
515 $style
516 )
517 );
518
519 return $file ? (string) $file : null;
520 }
521
522 /**
523 * Stats helper.
524 *
525 * @return array{families:int,variants:int,bytes:int}
526 */
527 public function stats(): array {
528 global $wpdb;
529
530 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
531 $variants = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table()}" );
532 $families = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT family) FROM {$this->table()}" );
533 $bytes = (int) $wpdb->get_var( "SELECT COALESCE(SUM(file_size),0) FROM {$this->table()}" );
534 // phpcs:enable
535
536 return array(
537 'families' => $families,
538 'variants' => $variants,
539 'bytes' => $bytes,
540 );
541 }
542
543 /**
544 * Wipe the table (used on purge).
545 */
546 public function truncate(): void {
547 global $wpdb;
548
549 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
550 $wpdb->query( "TRUNCATE TABLE {$this->table()}" );
551 }
552 }
553