PluginProbe
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel / 1.8.0
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel v1.8.0
1.8.0 1.7.1 1.7.0 1.6.0 1.5.2 trunk 0.1 0.2.0 0.2.1 0.3 0.3.1 0.3.2 0.3.3 0.3.4 0.4 0.4.1 0.4.2 0.4.3 1.0 1.1 1.2 1.3.1 1.4.0 1.4.1 1.5.0 All 27 releases
flywp / includes / StaticSite / Engine / Incremental / ChangeLog.php

ChangeLog.php in FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel 1.8.0, at includes/StaticSite/Engine/Incremental/ChangeLog.php

661 lines 20.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FlyWP\StaticSite\Engine\Incremental;
4
5 use FlyWP\StaticSite\Engine\Discovery\WordPressGateway;
6 use FlyWP\StaticSite\Engine\Discovery\WpGateway;
7
8 /**
9 * Records content changes for incremental builds.
10 *
11 * Row kinds:
12 *
13 * - url: rebuild the URL. object_type/object_id tell the builder which lists to rebuild too.
14 * - delete: the URL is gone.
15 * - list: rebuild all pages of a list (object_type term, user, blog, archive or date). Written before a hard delete.
16 * - global: the change can touch every page, so the next build is full.
17 *
18 * Rules:
19 *
20 * - Links are recorded at change time, because an old permalink is not known after the change.
21 * - A link change of a parent post or term changes the child links too, so it is global.
22 * - During an import or an install, the request records one global row only.
23 * - Multisite is out of scope. Rows go to the table of the current site prefix.
24 *
25 * @since 1.8.0
26 */
27 class ChangeLog implements ChangeStore {
28
29 /**
30 * Table name without prefix.
31 */
32 const TABLE = WpChangeStore::TABLE;
33
34 /**
35 * Option that turns on the hooks. The app sets it to "1".
36 */
37 const ENABLED_OPTION = 'flywp_static_enabled';
38
39 /**
40 * A deleted term with more objects than this is a global change.
41 */
42 const MAX_TERM_OBJECTS = 200;
43
44 /**
45 * Actions that change the whole site.
46 */
47 const GLOBAL_HOOKS = [
48 'switch_theme',
49 'customize_save_after',
50 'wp_update_nav_menu',
51 'wp_delete_nav_menu',
52 'activated_plugin',
53 'deactivated_plugin',
54 'upgrader_process_complete',
55 ];
56
57 /**
58 * Options that change the whole site.
59 */
60 const GLOBAL_OPTIONS = [
61 'permalink_structure',
62 'category_base',
63 'tag_base',
64 'blogname',
65 'blogdescription',
66 'home',
67 'siteurl',
68 'show_on_front',
69 'page_on_front',
70 'page_for_posts',
71 'posts_per_page',
72 'sidebars_widgets',
73 'site_icon',
74 'date_format',
75 'time_format',
76 'timezone_string',
77 'gmt_offset',
78 'WPLANG',
79 ];
80
81 /**
82 * Option name prefixes that change the whole site (widgets, theme mods).
83 */
84 const GLOBAL_OPTION_PREFIXES = [ 'widget_', 'theme_mods_' ];
85
86 /**
87 * Block theme and reusable content post types. When published, they can show on every page.
88 */
89 const GLOBAL_POST_TYPES = [ 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_block' ];
90
91 /**
92 * Rows recorded in this request, to write each row one time only.
93 *
94 * @var array<string, bool>
95 */
96 private $recorded = [];
97
98 /**
99 * Term links before an edit, by term ID.
100 *
101 * @var array<int, string|null>
102 */
103 private $term_links = [];
104
105 /**
106 * @var WordPressGateway
107 */
108 private $wp;
109
110 /**
111 * @var ChangeStore
112 */
113 private $store;
114
115 /**
116 * @param WordPressGateway|null $wp Gateway. Null for WordPress.
117 * @param ChangeStore|null $store Rows. Null for the database table.
118 */
119 public function __construct( WordPressGateway $wp = null, ChangeStore $store = null ) {
120 $this->wp = $wp ? $wp : new WpGateway();
121 $this->store = $store ? $store : new WpChangeStore();
122 }
123
124 /**
125 * Register the hooks. Does nothing when the option flywp_static_enabled is not "1".
126 *
127 * @return void
128 */
129 public static function register_hooks() {
130 if ( '1' !== (string) get_option( self::ENABLED_OPTION ) ) {
131 return;
132 }
133
134 $log = new self();
135
136 add_action( 'save_post', [ $log, 'on_save_post' ], 10, 2 );
137 add_action( 'post_updated', [ $log, 'on_post_updated' ], 10, 3 );
138 add_action( 'before_delete_post', [ $log, 'on_before_delete_post' ] );
139 add_action( 'set_object_terms', [ $log, 'on_set_object_terms' ], 10, 6 );
140 add_action( 'edit_terms', [ $log, 'on_edit_terms' ], 10, 2 );
141 add_action( 'edited_term', [ $log, 'on_edited_term' ], 10, 3 );
142 add_action( 'pre_delete_term', [ $log, 'on_pre_delete_term' ], 10, 2 );
143 add_action( 'delete_term', [ $log, 'on_delete_term' ], 10, 5 );
144 add_action( 'transition_comment_status', [ $log, 'on_transition_comment_status' ], 10, 3 );
145 add_action( 'comment_post', [ $log, 'on_comment_post' ], 10, 2 );
146 add_action( 'edit_comment', [ $log, 'on_edit_comment' ] );
147 add_action( 'attachment_updated', [ $log, 'on_attachment_updated' ] );
148 add_filter( 'wp_update_attachment_metadata', [ $log, 'on_attachment_metadata' ], 10, 2 );
149 add_action( 'delete_attachment', [ $log, 'on_delete_attachment' ] );
150 add_action( 'updated_option', [ $log, 'on_option' ] );
151 add_action( 'added_option', [ $log, 'on_option' ] );
152 add_action( 'deleted_option', [ $log, 'on_option' ] );
153
154 foreach ( self::GLOBAL_HOOKS as $hook ) {
155 add_action(
156 $hook,
157 function () use ( $log, $hook ) {
158 $log->record( 'global', null, null, null, $hook );
159 }
160 );
161 }
162 }
163
164 /**
165 * Create or update the table.
166 *
167 * @return void
168 */
169 public static function install() {
170 WpChangeStore::install();
171 }
172
173 /**
174 * @return string Table name with prefix.
175 */
176 public static function table() {
177 return WpChangeStore::table();
178 }
179
180 /**
181 * {@inheritdoc}
182 */
183 public function insert( $kind, $object_type, $object_id, $url, $reason ) {
184 return $this->store->insert( $kind, $object_type, $object_id, $url, $reason );
185 }
186
187 /**
188 * {@inheritdoc}
189 */
190 public function rows( $after_id, $up_to_id, $limit ) {
191 return $this->store->rows( $after_id, $up_to_id, $limit );
192 }
193
194 /**
195 * {@inheritdoc}
196 */
197 public function has_global( $after_id, $up_to_id ) {
198 return $this->store->has_global( $after_id, $up_to_id );
199 }
200
201 /**
202 * {@inheritdoc}
203 */
204 public function max_id() {
205 return $this->store->max_id();
206 }
207
208 /**
209 * {@inheritdoc}
210 */
211 public function clear_up_to( $change_id ) {
212 return $this->store->clear_up_to( $change_id );
213 }
214
215 /**
216 * Write one row. A url, delete or list row without a URL is not written.
217 *
218 * @param string $kind url, delete, list or global.
219 * @param string|null $object_type Object type.
220 * @param int|null $object_id Object ID.
221 * @param string|null $url Absolute URL.
222 * @param string $reason Short reason.
223 *
224 * @return bool False when the row is not written.
225 */
226 public function record( $kind, $object_type, $object_id, $url, $reason ) {
227 if ( 'global' !== $kind && $this->wp->is_bulk_request() ) {
228 return $this->record( 'global', null, null, null, 'bulk_request' );
229 }
230
231 if ( 'global' !== $kind && ( ! is_string( $url ) || '' === $url ) ) {
232 return false;
233 }
234
235 $key = 'global' === $kind ? 'global' : implode( '|', [ $kind, $object_type, $object_id, $url, $reason ] );
236
237 if ( isset( $this->recorded[ $key ] ) ) {
238 return true;
239 }
240
241 $object_id = null === $object_id ? null : (int) $object_id;
242 $url = 'global' === $kind ? null : $url;
243
244 if ( ! $this->store->insert( $kind, $object_type, $object_id, $url, $reason ) ) {
245 return false;
246 }
247
248 $this->recorded[ $key ] = true;
249
250 return true;
251 }
252
253 /**
254 * A post is saved. This also runs when a post is published the first time.
255 *
256 * @param int $post_id Post ID.
257 * @param object $post Post.
258 *
259 * @return void
260 */
261 public function on_save_post( $post_id, $post ) {
262 if ( ! is_object( $post ) || 'revision' === $post->post_type ) {
263 return;
264 }
265
266 if ( in_array( $post->post_type, self::GLOBAL_POST_TYPES, true ) ) {
267 if ( 'publish' === $post->post_status ) {
268 $this->record( 'global', null, null, null, 'post_type:' . $post->post_type );
269 }
270
271 return;
272 }
273
274 $this->record_post( $post_id, 'post_saved' );
275 }
276
277 /**
278 * A post is updated. Record the old permalink when the post is unpublished, trashed or its URL changes.
279 *
280 * @param int $post_id Post ID.
281 * @param object $after Post after the update.
282 * @param object $before Post before the update.
283 *
284 * @return void
285 */
286 public function on_post_updated( $post_id, $after, $before ) {
287 if ( ! is_object( $after ) || ! is_object( $before ) || 'publish' !== $before->post_status ) {
288 return;
289 }
290
291 if ( in_array( $before->post_type, self::GLOBAL_POST_TYPES, true ) ) {
292 if ( 'publish' !== $after->post_status ) {
293 $this->record( 'global', null, null, null, 'post_type:' . $before->post_type );
294 }
295
296 return;
297 }
298
299 if ( ! $this->is_public_type( $before->post_type ) ) {
300 return;
301 }
302
303 $old_link = $this->wp->object_permalink( $before );
304 $new_link = 'publish' === $after->post_status ? $this->wp->object_permalink( $after ) : null;
305
306 if ( null !== $old_link && $old_link !== $new_link ) {
307 if ( $this->wp->post_has_children( $post_id, $before->post_type ) ) {
308 $this->record( 'global', null, null, null, 'parent_link_changed' );
309
310 return;
311 }
312
313 $this->record( 'delete', 'post', $post_id, $old_link, null === $new_link ? 'post_unpublished' : 'permalink_changed' );
314 }
315
316 if ( (int) $before->post_author !== (int) $after->post_author ) {
317 $this->record( 'list', 'user', (int) $before->post_author, $this->wp->author_link( (int) $before->post_author ), 'author_changed' );
318 }
319 }
320
321 /**
322 * A post is about to be deleted. Record its permalink and its lists while they are known.
323 *
324 * @param int $post_id Post ID.
325 *
326 * @return void
327 */
328 public function on_before_delete_post( $post_id ) {
329 $post = $this->wp->post( $post_id );
330
331 if ( null === $post ) {
332 return;
333 }
334
335 if ( in_array( $post['post_type'], self::GLOBAL_POST_TYPES, true ) ) {
336 if ( 'publish' === $post['status'] ) {
337 $this->record( 'global', null, null, null, 'post_type:' . $post['post_type'] );
338 }
339
340 return;
341 }
342
343 if ( ! $this->is_public_type( $post['post_type'] ) ) {
344 return;
345 }
346
347 if ( $this->wp->post_has_children( $post['id'], $post['post_type'] ) ) {
348 $this->record( 'global', null, null, null, 'parent_deleted' );
349
350 return;
351 }
352
353 if ( 'publish' === $post['status'] ) {
354 $this->record( 'delete', 'post', $post['id'], $this->wp->permalink( $post['id'] ), 'post_deleted' );
355 }
356
357 foreach ( PostLists::for_post( $this->wp, $post ) as $list ) {
358 $this->record( 'list', $list['type'], $list['id'], $list['url'], 'post_deleted' );
359 }
360 }
361
362 /**
363 * Terms of an object are set. Record the lists of terms that the post left.
364 *
365 * @param int $object_id Object ID.
366 * @param array $terms Terms.
367 * @param int[] $tt_ids New term taxonomy IDs.
368 * @param string $taxonomy Taxonomy.
369 * @param bool $append Append mode.
370 * @param int[] $old_tt_ids Old term taxonomy IDs.
371 *
372 * @return void
373 */
374 public function on_set_object_terms( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) {
375 if ( ! in_array( $taxonomy, $this->wp->public_taxonomies(), true ) ) {
376 return;
377 }
378
379 $post = $this->wp->post( $object_id );
380
381 if ( null === $post || 'publish' !== $post['status'] || ! $this->wp->object_in_taxonomy( $post['post_type'], $taxonomy ) ) {
382 return;
383 }
384
385 foreach ( array_diff( array_map( 'intval', (array) $old_tt_ids ), array_map( 'intval', (array) $tt_ids ) ) as $tt_id ) {
386 $term = $this->wp->term_by_tt_id( $tt_id, $taxonomy );
387
388 if ( null !== $term ) {
389 $this->record( 'list', 'term', $term['id'], $this->wp->term_link( $term['id'], $taxonomy ), 'term_removed' );
390 }
391 }
392 }
393
394 /**
395 * A term is about to be updated. Keep its link to find a slug or parent change.
396 *
397 * @param int $term_id Term ID.
398 * @param string $taxonomy Taxonomy.
399 *
400 * @return void
401 */
402 public function on_edit_terms( $term_id, $taxonomy ) {
403 if ( in_array( $taxonomy, $this->wp->public_taxonomies(), true ) ) {
404 $this->term_links[ (int) $term_id ] = $this->wp->term_link( $term_id, $taxonomy );
405 }
406 }
407
408 /**
409 * A term is updated.
410 *
411 * @param int $term_id Term ID.
412 * @param int $tt_id Term taxonomy ID.
413 * @param string $taxonomy Taxonomy.
414 *
415 * @return void
416 */
417 public function on_edited_term( $term_id, $tt_id, $taxonomy ) {
418 if ( ! in_array( $taxonomy, $this->wp->public_taxonomies(), true ) ) {
419 return;
420 }
421
422 $link = $this->wp->term_link( $term_id, $taxonomy );
423 $old_link = $this->term_links[ (int) $term_id ] ?? null;
424
425 if ( null !== $old_link && $old_link !== $link ) {
426 if ( $this->wp->term_has_children( $term_id, $taxonomy ) ) {
427 $this->record( 'global', null, null, null, 'parent_term_link_changed' );
428
429 return;
430 }
431
432 $this->record( 'delete', 'term', $term_id, $old_link, 'term_link_changed' );
433 }
434
435 $this->record( 'url', 'term', $term_id, $link, 'term_edited' );
436 }
437
438 /**
439 * A term is about to be deleted.
440 *
441 * @param int $term_id Term ID.
442 * @param string $taxonomy Taxonomy.
443 *
444 * @return void
445 */
446 public function on_pre_delete_term( $term_id, $taxonomy ) {
447 if ( ! in_array( $taxonomy, $this->wp->public_taxonomies(), true ) ) {
448 return;
449 }
450
451 if ( $this->wp->term_has_children( $term_id, $taxonomy ) ) {
452 $this->record( 'global', null, null, null, 'parent_term_deleted' );
453
454 return;
455 }
456
457 $this->record( 'delete', 'term', $term_id, $this->wp->term_link( $term_id, $taxonomy ), 'term_deleted' );
458 }
459
460 /**
461 * A term is deleted. The posts of the term show other terms now.
462 *
463 * @param int $term_id Term ID.
464 * @param int $tt_id Term taxonomy ID.
465 * @param string $taxonomy Taxonomy.
466 * @param mixed $deleted_term Deleted term.
467 * @param int[] $object_ids Object IDs of the term.
468 *
469 * @return void
470 */
471 public function on_delete_term( $term_id, $tt_id, $taxonomy, $deleted_term, $object_ids ) {
472 if ( ! in_array( $taxonomy, $this->wp->public_taxonomies(), true ) ) {
473 return;
474 }
475
476 $object_ids = (array) $object_ids;
477
478 if ( count( $object_ids ) > self::MAX_TERM_OBJECTS ) {
479 $this->record( 'global', null, null, null, 'large_term_deleted' );
480
481 return;
482 }
483
484 foreach ( $object_ids as $object_id ) {
485 $this->record_post( $object_id, 'term_deleted' );
486 }
487 }
488
489 /**
490 * A comment status changes. Only approved comments show on the page.
491 *
492 * @param string $new_status New status.
493 * @param string $old_status Old status.
494 * @param object $comment Comment.
495 *
496 * @return void
497 */
498 public function on_transition_comment_status( $new_status, $old_status, $comment ) {
499 if ( is_object( $comment ) && in_array( 'approved', [ $new_status, $old_status ], true ) ) {
500 $this->record_post( (int) $comment->comment_post_ID, 'comment' );
501 }
502 }
503
504 /**
505 * A new comment is added.
506 *
507 * @param int $comment_id Comment ID.
508 * @param int|string $approved 1, 0, "spam" or "trash".
509 *
510 * @return void
511 */
512 public function on_comment_post( $comment_id, $approved ) {
513 if ( 1 === (int) $approved ) {
514 $this->on_edit_comment( $comment_id );
515 }
516 }
517
518 /**
519 * A comment is edited.
520 *
521 * @param int $comment_id Comment ID.
522 *
523 * @return void
524 */
525 public function on_edit_comment( $comment_id ) {
526 $comment = $this->wp->comment( $comment_id );
527
528 if ( null !== $comment && '1' === $comment['approved'] ) {
529 $this->record_post( $comment['post_id'], 'comment' );
530 }
531 }
532
533 /**
534 * An attachment is updated.
535 *
536 * @param int $attachment_id Attachment ID.
537 *
538 * @return void
539 */
540 public function on_attachment_updated( $attachment_id ) {
541 $this->record_attachment( 'url', $attachment_id, $this->wp->attachment_metadata( $attachment_id ), 'attachment_updated' );
542 }
543
544 /**
545 * The attachment metadata is saved, for example after the file is replaced. This is a filter.
546 *
547 * @param mixed $metadata New metadata.
548 * @param int $attachment_id Attachment ID.
549 *
550 * @return mixed The metadata, unchanged.
551 */
552 public function on_attachment_metadata( $metadata, $attachment_id ) {
553 if ( is_array( $metadata ) ) {
554 $this->record_attachment( 'url', $attachment_id, $metadata, 'attachment_file' );
555 }
556
557 return $metadata;
558 }
559
560 /**
561 * An attachment is about to be deleted.
562 *
563 * @param int $attachment_id Attachment ID.
564 *
565 * @return void
566 */
567 public function on_delete_attachment( $attachment_id ) {
568 $this->record_attachment( 'delete', $attachment_id, $this->wp->attachment_metadata( $attachment_id ), 'attachment_deleted' );
569 }
570
571 /**
572 * An option is added, updated or deleted.
573 *
574 * @param string $option Option name.
575 *
576 * @return void
577 */
578 public function on_option( $option ) {
579 $global = in_array( $option, self::GLOBAL_OPTIONS, true );
580
581 foreach ( self::GLOBAL_OPTION_PREFIXES as $prefix ) {
582 $global = $global || 0 === strpos( $option, $prefix );
583 }
584
585 if ( $global ) {
586 $this->record( 'global', null, null, null, 'option:' . $option );
587 }
588 }
589
590 /**
591 * URLs of an attachment file: the original file, each image size and the original image before scaling.
592 *
593 * @param string|null $file_url URL of the attached file.
594 * @param array $metadata Attachment metadata.
595 *
596 * @return string[]
597 */
598 public static function attachment_urls( $file_url, array $metadata ) {
599 if ( ! $file_url ) {
600 return [];
601 }
602
603 $base = substr( $file_url, 0, strrpos( $file_url, '/' ) + 1 );
604 $files = array_column( (array) ( $metadata['sizes'] ?? [] ), 'file' );
605 $urls = [ $file_url ];
606
607 if ( ! empty( $metadata['original_image'] ) ) {
608 $files[] = $metadata['original_image'];
609 }
610
611 foreach ( $files as $file ) {
612 if ( is_string( $file ) && '' !== $file ) {
613 $urls[] = $base . $file;
614 }
615 }
616
617 return array_values( array_unique( $urls ) );
618 }
619
620 /**
621 * @param string $kind url or delete.
622 * @param int $attachment_id Attachment ID.
623 * @param array $metadata Attachment metadata.
624 * @param string $reason Reason.
625 *
626 * @return void
627 */
628 private function record_attachment( $kind, $attachment_id, array $metadata, $reason ) {
629 foreach ( self::attachment_urls( $this->wp->attachment_file_url( $attachment_id ), $metadata ) as $url ) {
630 $this->record( $kind, null, null, $url, $reason );
631 }
632 }
633
634 /**
635 * Record the permalink of a published post of a public type.
636 *
637 * @param int $post_id Post ID.
638 * @param string $reason Reason.
639 *
640 * @return void
641 */
642 private function record_post( $post_id, $reason ) {
643 $post = $this->wp->post( $post_id );
644
645 if ( null === $post || 'publish' !== $post['status'] || ! $this->is_public_type( $post['post_type'] ) ) {
646 return;
647 }
648
649 $this->record( 'url', 'post', $post['id'], $this->wp->permalink( $post['id'] ), $reason );
650 }
651
652 /**
653 * @param string $post_type Post type.
654 *
655 * @return bool
656 */
657 private function is_public_type( $post_type ) {
658 return in_array( $post_type, $this->wp->public_post_types(), true );
659 }
660 }
661