Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.33% covered (danger)
17.33%
65 / 375
10.64% covered (danger)
10.64%
5 / 47
CRAP
0.00% covered (danger)
0.00%
0 / 1
Posts
17.16% covered (danger)
17.16%
64 / 373
10.64% covered (danger)
10.64%
5 / 47
20281.92
0.00% covered (danger)
0.00%
0 / 1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 table_name
n/a
0 / 0
n/a
0 / 0
1
 table
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_object_by_id
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 init_listeners
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 daily_akismet_meta_cleanup_before
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 daily_akismet_meta_cleanup_after
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init_full_sync_listeners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_before_send
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 estimate_full_sync_actions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_where_sql
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_full_sync_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trim_post_meta
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 filter_updated_post_meta_before_send
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 filter_added_post_meta_before_send
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
110
 mark_post_is_being_deleted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_skip_deleted_post_meta
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 unmark_post_being_deleted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 on_before_enqueue_updated_attachment_metadata
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 expand_jetpack_sync_save_post
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 filter_jetpack_sync_before_enqueue_jetpack_sync_save_post
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
11.56
 filter_jetpack_sync_before_enqueue_jetpack_published_post
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
10.14
 filter_blacklisted_post_types_deleted
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 filter_meta
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 filter_updated_post_meta
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 filter_deleted_post_meta
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 has_valid_meta_args
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
20
 is_allowed_post_meta
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 is_whitelisted_post_meta
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 is_post_type_allowed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 remove_embed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 add_embed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 filter_post_content_and_add_links
29.31% covered (danger)
29.31%
17 / 58
0.00% covered (danger)
0.00%
0 / 1
94.48
 save_published
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 enter_rest_callback
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 remember_rest_post_before
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 leave_rest_callback
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
240
 stored_meta_matches_request
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
90
 is_gutenberg_meta_box_update
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 wp_insert_post
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 wp_after_insert_post
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 send_published
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
132
 build_full_sync_action_array
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 add_term_relationships
n/a
0 / 0
n/a
0 / 0
1
 expand_posts_with_metadata_and_terms
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 get_min_max_object_ids_for_batches
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_next_chunk
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 expand_posts
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Posts sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Modules;
9
10use Automattic\Jetpack\Constants as Jetpack_Constants;
11use Automattic\Jetpack\Roles;
12use Automattic\Jetpack\Sync\Activity_Log_Event;
13use Automattic\Jetpack\Sync\Modules;
14use Automattic\Jetpack\Sync\Settings;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * Class to handle sync for posts.
22 */
23class Posts extends Module {
24    /**
25     * The post IDs of posts that were just published but not synced yet.
26     *
27     * @access private
28     *
29     * @var array
30     */
31    private $just_published = array();
32
33    /**
34     * Nesting depth of REST callbacks currently running.
35     *
36     * @var int
37     */
38    private $rest_callback_depth = 0;
39
40    /**
41     * Depth of the REST callback each post was published in, until that callback completes.
42     *
43     * @var int[]
44     */
45    private $published_in_rest_callback = array();
46
47    /**
48     * Posts as they were before a running REST callback first updated them.
49     *
50     * @var \WP_Post[]
51     */
52    private $rest_posts_before = array();
53
54    /**
55     * The previous status of posts that we use for calculating post status transitions.
56     *
57     * @access private
58     *
59     * @var array
60     */
61    private $previous_status = array();
62
63    /**
64     * Action handler callable.
65     *
66     * @access private
67     *
68     * @var callable
69     */
70    private $action_handler;
71
72    /**
73     * Mark posts that are deleted in the current request.
74     *
75     * @access private
76     *
77     * @var array
78     */
79    private static $deleted_posts_in_request = array();
80
81    /**
82     * Import end.
83     *
84     * @access private
85     *
86     * @todo This appears to be unused - let's remove it.
87     *
88     * @var boolean
89     */
90    private $import_end = false;
91
92    /**
93     * Max bytes allowed for post_content => length.
94     * Current Setting : 5MB.
95     *
96     * @access public
97     *
98     * @var int
99     */
100    const MAX_POST_CONTENT_LENGTH = 5000000;
101
102    /**
103     * Default previous post state.
104     * Used for default previous post status.
105     *
106     * @access public
107     *
108     * @var string
109     */
110    const DEFAULT_PREVIOUS_STATE = 'new';
111
112    /**
113     * REST meta fields that decide whether a publish emails subscribers or shares to Social.
114     * A publish is never completed when one of them failed to save.
115     *
116     * @var string[]
117     */
118    const PUBLISH_CONTROL_META_FIELDS = array(
119        '_jetpack_dont_email_post_to_subs',
120        '_jetpack_newsletter_access',
121        '_jetpack_newsletter_tier_id',
122        'jetpack_publicize_feature_enabled',
123    );
124
125    /**
126     * Sync module name.
127     *
128     * @access public
129     *
130     * @return string
131     */
132    public function name() {
133        return 'posts';
134    }
135
136    /**
137     * The table name.
138     *
139     * @access public
140     *
141     * @return string
142     * @deprecated since 3.11.0 Use table() instead.
143     */
144    public function table_name() {
145        _deprecated_function( __METHOD__, '3.11.0', 'Automattic\\Jetpack\\Sync\\Posts->table' );
146        return 'posts';
147    }
148
149    /**
150     * The table in the database with the prefix.
151     *
152     * @access public
153     *
154     * @return string|bool
155     */
156    public function table() {
157        global $wpdb;
158        return $wpdb->posts;
159    }
160
161    /**
162     * Retrieve a post by its ID.
163     *
164     * @access public
165     *
166     * @param string $object_type Type of the sync object.
167     * @param int    $id          ID of the sync object.
168     * @return \WP_Post|bool Filtered \WP_Post object, or false if the object is not a post.
169     */
170    public function get_object_by_id( $object_type, $id ) {
171        if ( 'post' === $object_type ) {
172            $post = get_post( (int) $id );
173            if ( $post ) {
174                return $this->filter_post_content_and_add_links( $post );
175            }
176        }
177
178        return false;
179    }
180
181    /**
182     * Initialize posts action listeners.
183     *
184     * @access public
185     *
186     * @param callable $callable Action handler callable.
187     */
188    public function init_listeners( $callable ) {
189        $this->action_handler = $callable;
190
191        add_action( 'before_delete_post', array( $this, 'mark_post_is_being_deleted' ), 0, 1 );
192        add_action( 'wp_insert_post', array( $this, 'wp_insert_post' ), 11, 3 );
193        add_action( 'wp_after_insert_post', array( $this, 'wp_after_insert_post' ), 11, 2 );
194        add_action( 'jetpack_sync_save_post', $callable, 10, 4 );
195
196        add_action( 'deleted_post', $callable, 10 );
197        add_action( 'jetpack_published_post', $callable, 10, 3 );
198        add_filter( 'jetpack_sync_before_enqueue_deleted_post', array( $this, 'filter_blacklisted_post_types_deleted' ) );
199
200        add_action( 'transition_post_status', array( $this, 'save_published' ), 10, 3 );
201        add_filter( 'rest_request_before_callbacks', array( $this, 'enter_rest_callback' ) );
202        add_action( 'pre_post_update', array( $this, 'remember_rest_post_before' ) );
203        add_filter( 'rest_request_after_callbacks', array( $this, 'leave_rest_callback' ), 10, 3 );
204
205        // Listen for meta changes.
206        $this->init_listeners_for_meta_type( 'post', $callable );
207        add_filter( 'jetpack_sync_before_enqueue_added_post_meta', array( $this, 'filter_meta' ) );
208        add_filter( 'jetpack_sync_before_enqueue_updated_post_meta', array( $this, 'filter_updated_post_meta' ) );
209        add_filter( 'jetpack_sync_before_enqueue_deleted_post_meta', array( $this, 'filter_deleted_post_meta' ) );
210
211        add_filter( 'jetpack_sync_before_enqueue_jetpack_sync_save_post', array( $this, 'filter_jetpack_sync_before_enqueue_jetpack_sync_save_post' ) );
212        add_filter( 'jetpack_sync_before_enqueue_jetpack_published_post', array( $this, 'filter_jetpack_sync_before_enqueue_jetpack_published_post' ) );
213
214        add_action( 'jetpack_daily_akismet_meta_cleanup_before', array( $this, 'daily_akismet_meta_cleanup_before' ) );
215        add_action( 'jetpack_daily_akismet_meta_cleanup_after', array( $this, 'daily_akismet_meta_cleanup_after' ) );
216        add_action( 'jetpack_post_meta_batch_delete', $callable, 10, 2 );
217
218        add_action( 'deleted_post', array( $this, 'unmark_post_being_deleted' ), 11, 1 );
219    }
220
221    /**
222     * Before Akismet's daily cleanup of spam detection metadata.
223     *
224     * @access public
225     *
226     * @param array $feedback_ids IDs of feedback posts.
227     */
228    public function daily_akismet_meta_cleanup_before( $feedback_ids ) {
229        remove_action( 'deleted_post_meta', $this->action_handler );
230
231        if ( ! is_array( $feedback_ids ) || count( $feedback_ids ) < 1 ) {
232            return;
233        }
234
235        $ids_chunks = array_chunk( $feedback_ids, 100, false );
236        foreach ( $ids_chunks as $chunk ) {
237            /**
238             * Used for syncing deletion of batch post meta
239             *
240             * @since 1.6.3
241             * @since-jetpack 6.1.0
242             *
243             * @module sync
244             *
245             * @param array $feedback_ids feedback post IDs
246             * @param string $meta_key to be deleted
247             */
248            do_action( 'jetpack_post_meta_batch_delete', $chunk, '_feedback_akismet_values' );
249        }
250    }
251
252    /**
253     * After Akismet's daily cleanup of spam detection metadata.
254     *
255     * @access public
256     *
257     * @param array $feedback_ids IDs of feedback posts.
258     */
259    public function daily_akismet_meta_cleanup_after( $feedback_ids ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
260        add_action( 'deleted_post_meta', $this->action_handler );
261    }
262
263    /**
264     * Initialize posts action listeners for full sync.
265     *
266     * @access public
267     *
268     * @param callable $callable Action handler callable.
269     */
270    public function init_full_sync_listeners( $callable ) {
271        add_action( 'jetpack_full_sync_posts', $callable ); // Also sends post meta.
272    }
273
274    /**
275     * Initialize the module in the sender.
276     *
277     * @access public
278     */
279    public function init_before_send() {
280        // meta.
281        add_filter( 'jetpack_sync_before_send_added_post_meta', array( $this, 'filter_added_post_meta_before_send' ), 5 ); // Incase this filter is used elsewhere, we run early.
282        add_filter( 'jetpack_sync_before_send_updated_post_meta', array( $this, 'filter_updated_post_meta_before_send' ), 5 ); // Incase this filter is used elsewhere, we run early.
283        add_filter( 'jetpack_sync_before_send_deleted_post_meta', array( $this, 'trim_post_meta' ) );
284        // Full sync.
285        $sync_module = Modules::get_module( 'full-sync' );
286        if ( $sync_module instanceof Full_Sync_Immediately ) {
287            add_filter( 'jetpack_sync_before_send_jetpack_full_sync_posts', array( $this, 'build_full_sync_action_array' ) );
288        } else {
289            add_filter( 'jetpack_sync_before_send_jetpack_full_sync_posts', array( $this, 'expand_posts_with_metadata_and_terms' ) );
290        }
291    }
292
293    /**
294     * Enqueue the posts actions for full sync.
295     *
296     * @access public
297     *
298     * @param array   $config               Full sync configuration for this sync module.
299     * @param int     $max_items_to_enqueue Maximum number of items to enqueue.
300     * @param boolean $state                True if full sync has finished enqueueing this module, false otherwise.
301     * @return array Number of actions enqueued, and next module state.
302     */
303    public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) {
304        global $wpdb;
305
306        return $this->enqueue_all_ids_as_action( 'jetpack_full_sync_posts', $wpdb->posts, 'ID', $this->get_where_sql( $config ), $max_items_to_enqueue, $state );
307    }
308
309    /**
310     * Retrieve an estimated number of actions that will be enqueued.
311     *
312     * @access public
313     *
314     * @todo Use $wpdb->prepare for the SQL query.
315     *
316     * @param array $config Full sync configuration for this sync module.
317     * @return int Number of items yet to be enqueued.
318     */
319    public function estimate_full_sync_actions( $config ) {
320        global $wpdb;
321
322        $query = "SELECT count(*) FROM $wpdb->posts WHERE " . $this->get_where_sql( $config );
323        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
324        $count = (int) $wpdb->get_var( $query );
325
326        return (int) ceil( $count / self::ARRAY_CHUNK_SIZE );
327    }
328
329    /**
330     * Retrieve the WHERE SQL clause based on the module config.
331     *
332     * @access public
333     *
334     * @param array $config Full sync configuration for this sync module.
335     * @return string WHERE SQL clause, or `null` if no comments are specified in the module config.
336     */
337    public function get_where_sql( $config ) {
338        $where_sql = Settings::get_blacklisted_post_types_sql();
339
340        // Config is a list of post IDs to sync.
341        if ( is_array( $config ) && ! empty( $config ) ) {
342            $where_sql .= ' AND ID IN (' . implode( ',', array_map( 'intval', $config ) ) . ')';
343        }
344
345        return $where_sql;
346    }
347
348    /**
349     * Retrieve the actions that will be sent for this module during a full sync.
350     *
351     * @access public
352     *
353     * @return array Full sync actions of this module.
354     */
355    public function get_full_sync_actions() {
356        return array( 'jetpack_full_sync_posts' );
357    }
358
359    /**
360     * Filter meta arguments so that we don't sync meta_values over MAX_META_LENGTH.
361     *
362     * @param array $args action arguments.
363     *
364     * @return array filtered action arguments.
365     */
366    public function trim_post_meta( $args ) {
367        list( $meta_id, $object_id, $meta_key, $meta_value ) = $args;
368        // Explicitly truncate meta_value when it exceeds limit.
369        // Large content will cause OOM issues and break Sync.
370        $serialized_value = maybe_serialize( $meta_value );
371        if ( $serialized_value === null || strlen( $serialized_value ) >= self::MAX_META_LENGTH ) {
372            $meta_value = '';
373        }
374        return array( $meta_id, $object_id, $meta_key, $meta_value );
375    }
376
377    /**
378     * Updated post meta send-time filter: refreshes _wp_attachment_metadata to the latest DB value, then trims.
379     *
380     * @param array $args [ $meta_id, $object_id, $meta_key, $meta_value ].
381     * @return array Filtered args.
382     */
383    public function filter_updated_post_meta_before_send( $args ) {
384        if ( ! is_array( $args ) || count( $args ) < 4 ) {
385            return $args;
386        }
387        list( $meta_id, $object_id, $meta_key, $meta_value ) = $args;
388        if ( '_wp_attachment_metadata' !== $meta_key || 'attachment' !== get_post_type( (int) $object_id ) ) {
389            return $this->trim_post_meta( $args );
390        }
391        $current_value = wp_get_attachment_metadata( (int) $object_id );
392        if ( is_array( $current_value ) && ! empty( $current_value ) ) {
393            $meta_value = $current_value;
394        }
395        return $this->trim_post_meta( array( $meta_id, $object_id, $meta_key, $meta_value ) );
396    }
397
398    /**
399     * Added post meta send-time filter: refreshes _wp_attachment_metadata to the latest DB value, then trims.
400     *
401     * @param array $args [ $meta_id, $object_id, $meta_key, $meta_value ].
402     * @return array|false Filtered args, or false to skip sending when the snapshot is clearly incomplete.
403     */
404    public function filter_added_post_meta_before_send( $args ) {
405        if ( ! is_array( $args ) || count( $args ) < 4 ) {
406            return $args;
407        }
408        list( $meta_id, $object_id, $meta_key, $meta_value ) = $args;
409        if ( '_wp_attachment_metadata' !== $meta_key || 'attachment' !== get_post_type( (int) $object_id ) ) {
410            return $this->trim_post_meta( $args );
411        }
412        $current_value = wp_get_attachment_metadata( (int) $object_id );
413        // For added_post_meta, skip clearly incomplete snapshots (e.g., missing or empty sizes).
414        if ( ! is_array( $current_value ) || empty( $current_value ) ) {
415            return false;
416        }
417        if ( isset( $current_value['sizes'] ) && is_array( $current_value['sizes'] ) && count( $current_value['sizes'] ) === 0 ) {
418            return false;
419        }
420        $meta_value = $current_value;
421        return $this->trim_post_meta( array( $meta_id, $object_id, $meta_key, $meta_value ) );
422    }
423
424    /**
425     * Mark a post as being deleted in the current request.
426     *
427     * @param int $post_id ID of the post being deleted.
428     */
429    public function mark_post_is_being_deleted( $post_id ) {
430        self::$deleted_posts_in_request[ (int) $post_id ] = true;
431    }
432
433    /**
434     * Enqueue-time per-request dedupe for deleted post metadata, if the post itself is being deleted.
435     *
436     * @param array $args [ $meta_id, $post_id, $meta_key, $meta_value ].
437     * @return array|false
438     */
439    public function maybe_skip_deleted_post_meta( $args ) {
440        if ( is_array( $args ) && isset( $args[1] ) && is_numeric( $args[1] ) ) {
441            $post_id = (int) $args[1];
442            if ( isset( self::$deleted_posts_in_request[ $post_id ] ) ) {
443                return false;
444            }
445        }
446        return $args;
447    }
448
449    /**
450     * Unmark a post as being deleted in the current request, to clean up.
451     *
452     * @param int $post_id ID of the post.
453     */
454    public function unmark_post_being_deleted( $post_id ) {
455        unset( self::$deleted_posts_in_request[ (int) $post_id ] );
456    }
457
458    /**
459     * Enqueue-time per-request dedupe for updated attachment metadata.
460     *
461     * @param array $args [ $meta_id, $object_id, $meta_key, $meta_value ].
462     * @return array|false
463     */
464    public function on_before_enqueue_updated_attachment_metadata( $args ) {
465        if ( ! is_array( $args ) || count( $args ) < 3 ) {
466            return $args;
467        }
468        $post_id  = (int) $args[1];
469        $meta_key = $args[2];
470        if ( '_wp_attachment_metadata' !== $meta_key || 'attachment' !== get_post_type( $post_id ) ) {
471            return $args;
472        }
473        static $seen_updated_meta_for_post = array();
474        if ( isset( $seen_updated_meta_for_post[ $post_id ] ) ) {
475            return false;
476        }
477        $seen_updated_meta_for_post[ $post_id ] = true;
478        return $args;
479    }
480
481    /**
482     * Process content before send.
483     *
484     * @param array $args Arguments of the `wp_insert_post` hook.
485     *
486     * @return array
487     */
488    public function expand_jetpack_sync_save_post( $args ) {
489        list( $post_id, $post, $update, $previous_state ) = $args;
490        return array( $post_id, $this->filter_post_content_and_add_links( $post ), $update, $previous_state );
491    }
492
493    /**
494     * Filter all blacklisted post types and add filtered post content.
495     *
496     * @param array $args Hook arguments.
497     * @return array|false Hook arguments, or false if the post type is a blacklisted one.
498     */
499    public function filter_jetpack_sync_before_enqueue_jetpack_sync_save_post( $args ) {
500        if (
501            ! is_array( $args )
502            || ! array_key_exists( 0, $args ) || ! is_numeric( $args[0] )
503            || ! array_key_exists( 1, $args ) || ! ( $args[1] instanceof \WP_Post )
504        ) {
505            return false;
506        }
507
508        list( $post_id, $post, $update, $previous_state ) = array_pad( $args, 4, null );
509
510        if ( in_array( $post->post_type, Settings::get_setting( 'post_types_blacklist' ), true ) ) {
511            return false;
512        }
513
514        // During incremental sync, skip posts whose type is not registered (e.g. CPT unregistered before sync).
515        // Full sync may have already sent them; we simply don't enqueue incremental updates for them.
516        if ( ! get_post_type_object( $post->post_type ) ) {
517            return false;
518        }
519
520        if ( Activity_Log_Event::POST_TYPE === $post->post_type && ! Activity_Log_Event::is_valid_post( $post ) ) {
521            return false;
522        }
523
524        return array( (int) $post_id, $this->filter_post_content_and_add_links( $post ), $update, $previous_state );
525    }
526
527    /**
528     * Add filtered post content.
529     *
530     * @param array $args Hook arguments.
531     * @return array|false Hook arguments, or false if the arguments are invalid.
532     */
533    public function filter_jetpack_sync_before_enqueue_jetpack_published_post( $args ) {
534        if (
535            ! is_array( $args )
536            || ! array_key_exists( 0, $args ) || ! is_numeric( $args[0] )
537            || ! array_key_exists( 1, $args ) || ! is_array( $args[1] )
538            || ! array_key_exists( 2, $args ) || ! ( $args[2] instanceof \WP_Post )
539        ) {
540            return false;
541        }
542
543        list( $post_id, $flags, $post ) = $args;
544
545        if ( Activity_Log_Event::POST_TYPE === $post->post_type && ! Activity_Log_Event::is_valid_post( $post ) ) {
546            return false;
547        }
548
549        return array( (int) $post_id, $flags, $this->filter_post_content_and_add_links( $post ) );
550    }
551
552    /**
553     * Filter all blacklisted post types.
554     *
555     * @param array $args Hook arguments.
556     * @return array|false Hook arguments, or false if the post type is a blacklisted one.
557     */
558    public function filter_blacklisted_post_types_deleted( $args ) {
559        if ( ! is_array( $args ) || ! array_key_exists( 0, $args ) || ! is_numeric( $args[0] ) ) {
560            return false;
561        }
562        // deleted_post is called after the SQL delete but before cache cleanup.
563        // There is the potential we can't detect post_type at this point.
564        if ( ! $this->is_post_type_allowed( $args[0] ) ) {
565            return false;
566        }
567
568        return $args;
569    }
570
571    /**
572     * Filter all meta that is not blacklisted, or is stored for a disallowed post type.
573     *
574     * @param array|false $args Hook arguments.
575     * @return array|false Hook arguments, or false if meta was filtered.
576     */
577    public function filter_meta( $args ) {
578        if ( ! $this->has_valid_meta_args( $args ) || ! is_numeric( $args[1] ) ) {
579            return false;
580        }
581
582        return $this->is_allowed_post_meta( $args[1], $args[2] ) ? $args : false;
583    }
584
585    /**
586     * Filter updated post meta that is not whitelisted, is stored for a disallowed post type,
587     * or is duplicate attachment metadata.
588     *
589     * @param array|false $args Hook arguments.
590     * @return array|false Hook arguments, or false if meta was filtered.
591     */
592    public function filter_updated_post_meta( $args ) {
593        $args = $this->on_before_enqueue_updated_attachment_metadata( $args );
594        if ( false === $args ) {
595            return false;
596        }
597
598        return $this->filter_meta( $args );
599    }
600
601    /**
602     * Filter deleted post meta that is not whitelisted, or is stored for a disallowed post type.
603     *
604     * @param array|false $args Hook arguments.
605     * @return array|false Hook arguments, or false if meta was filtered.
606     */
607    public function filter_deleted_post_meta( $args ) {
608        if ( ! $this->has_valid_meta_args( $args ) ) {
609            return false;
610        }
611        // Core uses post ID 0 on this hook for delete-all metadata operations. Only mirror value-constrained deletes.
612        if ( 0 === $args[1] ) {
613            if ( ! array_key_exists( 3, $args ) || '' === $args[3] || null === $args[3] || false === $args[3] ) {
614                return false;
615            }
616
617            return $this->is_whitelisted_post_meta( $args[2] ) ? $args : false;
618        }
619
620        $args = $this->filter_meta( $args );
621        if ( false === $args ) {
622            return false;
623        }
624
625        return $this->maybe_skip_deleted_post_meta( $args );
626    }
627
628    /**
629     * Whether metadata hook arguments include a meta key.
630     *
631     * @param array|false $args Hook arguments.
632     * @return bool Whether metadata hook arguments include a meta key.
633     */
634    private function has_valid_meta_args( $args ) {
635        return is_array( $args ) && array_key_exists( 1, $args ) && array_key_exists( 2, $args ) && is_string( $args[2] );
636    }
637
638    /**
639     * Whether post metadata is allowed to sync.
640     *
641     * @param int|string $object_id Post ID.
642     * @param string     $meta_key  Meta key.
643     * @return bool Whether post metadata is allowed to sync.
644     */
645    private function is_allowed_post_meta( $object_id, $meta_key ) {
646        return $this->is_post_type_allowed( $object_id ) && $this->is_whitelisted_post_meta( $meta_key );
647    }
648
649    /**
650     * Whether a post meta key is whitelisted.
651     *
652     * @param string $meta_key Meta key.
653     * @return boolean Whether the post meta key is whitelisted.
654     */
655    public function is_whitelisted_post_meta( $meta_key ) {
656        if ( ! is_string( $meta_key ) ) {
657            return false;
658        }
659        // The '_wpas_skip_' meta key prefix is used by Publicize to mark posts that should be skipped.
660        return str_starts_with( $meta_key, '_wpas_skip_' ) || in_array( $meta_key, Settings::get_setting( 'post_meta_whitelist' ), true );
661    }
662
663    /**
664     * Whether a post type is allowed.
665     * A post type will be disallowed if it's present in the post type blacklist.
666     *
667     * @param int $post_id ID of the post.
668     * @return boolean Whether the post type is allowed.
669     */
670    public function is_post_type_allowed( $post_id ) {
671        $post = get_post( (int) $post_id );
672
673        if ( isset( $post->post_type ) ) {
674            return ! in_array( $post->post_type, Settings::get_setting( 'post_types_blacklist' ), true );
675        }
676        return false;
677    }
678
679    /**
680     * Remove the embed shortcode.
681     *
682     * @global $wp_embed
683     */
684    public function remove_embed() {
685        global $wp_embed;
686        remove_filter( 'the_content', array( $wp_embed, 'run_shortcode' ), 8 );
687        // remove the embed shortcode since we would do the part later.
688        remove_shortcode( 'embed' );
689        // Attempts to embed all URLs in a post.
690        remove_filter( 'the_content', array( $wp_embed, 'autoembed' ), 8 );
691    }
692
693    /**
694     * Add the embed shortcode.
695     *
696     * @global $wp_embed
697     */
698    public function add_embed() {
699        global $wp_embed;
700        add_filter( 'the_content', array( $wp_embed, 'run_shortcode' ), 8 );
701        // Shortcode placeholder for strip_shortcodes().
702        add_shortcode( 'embed', '__return_false' );
703        // Attempts to embed all URLs in a post.
704        add_filter( 'the_content', array( $wp_embed, 'autoembed' ), 8 );
705    }
706
707    /**
708     * Expands wp_insert_post to include filtered content
709     *
710     * @param \WP_Post $post_object Post object.
711     */
712    public function filter_post_content_and_add_links( $post_object ) {
713        global $post;
714
715        // Used to restore the post global.
716        $current_post = $post;
717
718        // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
719        $post = $post_object;
720
721        // Return non existant post.
722        $post_type = get_post_type_object( $post->post_type );
723        if ( empty( $post_type ) || ! is_object( $post_type ) ) {
724            $non_existant_post                    = new \stdClass();
725            $non_existant_post->ID                = $post->ID;
726            $non_existant_post->post_modified     = $post->post_modified;
727            $non_existant_post->post_modified_gmt = $post->post_modified_gmt;
728            $non_existant_post->post_status       = 'jetpack_sync_non_registered_post_type';
729            $non_existant_post->post_type         = $post->post_type;
730            // Restore global post.
731            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
732            $post = $current_post;
733
734            return $non_existant_post;
735        }
736        /**
737         * Filters whether to prevent sending post data to .com
738         *
739         * Passing true to the filter will prevent the post data from being sent
740         * to the WordPress.com.
741         * Instead we pass data that will still enable us to do a checksum against the
742         * Jetpacks data but will prevent us from displaying the data on in the API as well as
743         * other services.
744         *
745         * @since 1.6.3
746         * @since-jetpack 4.2.0
747         *
748         * @param boolean false prevent post data from being synced to WordPress.com
749         * @param mixed $post \WP_Post object
750         */
751        if ( apply_filters( 'jetpack_sync_prevent_sending_post_data', false, $post ) ) {
752            // We only send the bare necessary object to be able to create a checksum.
753            $blocked_post                    = new \stdClass();
754            $blocked_post->ID                = $post->ID;
755            $blocked_post->post_modified     = $post->post_modified;
756            $blocked_post->post_modified_gmt = $post->post_modified_gmt;
757            $blocked_post->post_status       = 'jetpack_sync_blocked';
758            $blocked_post->post_type         = $post->post_type;
759
760            // Restore global post.
761            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
762            $post = $current_post;
763
764            return $blocked_post;
765        }
766
767        // lets not do oembed just yet.
768        $this->remove_embed();
769
770        if ( 0 < strlen( $post->post_password ) ) {
771            $post->post_password = 'auto-' . wp_generate_password( 10, false );
772        }
773
774        // Explicitly omit post_content when it exceeds limit.
775        // Large content will cause OOM issues and break Sync.
776        if ( strlen( $post->post_content ) >= self::MAX_POST_CONTENT_LENGTH ) {
777            $post->post_content = '';
778        }
779
780        /** This filter is already documented in core. wp-includes/post-template.php */
781        if ( Settings::get_setting( 'render_filtered_content' ) && $post_type->public ) {
782            global $shortcode_tags;
783            /**
784             * Filter prevents some shortcodes from expanding.
785             *
786             * Since we can can expand some type of shortcode better on the .com side and make the
787             * expansion more relevant to contexts. For example [galleries] and subscription emails
788             *
789             * @since 1.6.3
790             * @since-jetpack 4.5.0
791             *
792             * @param array of shortcode tags to remove.
793             */
794            $shortcodes_to_remove        = apply_filters(
795                'jetpack_sync_do_not_expand_shortcodes',
796                array(
797                    'gallery',
798                    'slideshow',
799                )
800            );
801            $removed_shortcode_callbacks = array();
802            foreach ( $shortcodes_to_remove as $shortcode ) {
803                if ( isset( $shortcode_tags[ $shortcode ] ) ) {
804                    $removed_shortcode_callbacks[ $shortcode ] = $shortcode_tags[ $shortcode ];
805                }
806            }
807
808            array_map( 'remove_shortcode', array_keys( $removed_shortcode_callbacks ) );
809            /**
810             * Certain modules such as Likes, Related Posts and Sharedaddy are using `Settings::is_syncing`
811             * in order to NOT get rendered in filtered post content.
812             * Since the current method runs now before enqueueing instead of before sending,
813             * we are setting `is_syncing` flag to true in order to preserve the existing functionality.
814             */
815
816            $is_syncing_current = Settings::is_syncing();
817            Settings::set_is_syncing( true );
818            $post->post_content_filtered = apply_filters( 'the_content', $post->post_content );
819            $post->post_excerpt_filtered = apply_filters( 'the_excerpt', $post->post_excerpt );
820            Settings::set_is_syncing( $is_syncing_current );
821
822            foreach ( $removed_shortcode_callbacks as $shortcode => $callback ) {
823                add_shortcode( $shortcode, $callback );
824            }
825        }
826
827        $this->add_embed();
828
829        if ( has_post_thumbnail( $post->ID ) ) {
830            $image_attributes = wp_get_attachment_image_src( get_post_thumbnail_id( $post->ID ), 'full' );
831            if ( is_array( $image_attributes ) && isset( $image_attributes[0] ) ) {
832                $post->featured_image = $image_attributes[0];
833            }
834        }
835
836        $post->permalink = get_permalink( $post->ID );
837        $post->shortlink = wp_get_shortlink( $post->ID );
838
839        if ( function_exists( 'amp_get_permalink' ) ) {
840            $post->amp_permalink = amp_get_permalink( $post->ID );
841        }
842
843        $filtered_post = $post;
844
845        // Restore global post.
846        // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
847        $post = $current_post;
848
849        return $filtered_post;
850    }
851
852    /**
853     * Handle transition from another post status to a published one.
854     *
855     * @param string   $new_status New post status.
856     * @param string   $old_status Old post status.
857     * @param \WP_Post $post       Post object.
858     */
859    public function save_published( $new_status, $old_status, $post ) {
860        if ( ! $post instanceof \WP_Post ) {
861            return;
862        }
863        if ( 'publish' === $new_status && 'publish' !== $old_status ) {
864            $this->just_published[ $post->ID ] = true;
865            if ( $this->rest_callback_depth > 0 ) {
866                $this->published_in_rest_callback[ $post->ID ] = $this->rest_callback_depth;
867            }
868        }
869
870        $this->previous_status[ $post->ID ] = $old_status;
871    }
872
873    /**
874     * Track that a REST callback started.
875     *
876     * @param \WP_REST_Response|\WP_Error|mixed $response Result to send, if a filter already set one.
877     * @return \WP_REST_Response|\WP_Error|mixed Unchanged response.
878     */
879    public function enter_rest_callback( $response ) {
880        ++$this->rest_callback_depth;
881        return $response;
882    }
883
884    /**
885     * Remember a post before a REST callback updates it, for `wp_after_insert_post`.
886     *
887     * @param int $post_id Post ID.
888     */
889    public function remember_rest_post_before( $post_id ) {
890        if ( $this->rest_callback_depth > 0 && ! isset( $this->rest_posts_before[ $post_id ] ) ) {
891            $post = get_post( $post_id );
892            if ( $post instanceof \WP_Post ) {
893                $this->rest_posts_before[ $post_id ] = clone $post;
894            }
895        }
896    }
897
898    /**
899     * Complete `wp_after_insert_post` for posts a failed REST callback published.
900     *
901     * The posts controller skips that hook when saving meta fails. It is completed only when a
902     * single meta field failed and every other requested field is stored, so stale newsletter or
903     * sharing settings can't trigger emails or shares.
904     *
905     * @param \WP_REST_Response|\WP_Error|mixed $response Result of the REST callback.
906     * @param array                             $handler  Route handler.
907     * @param \WP_REST_Request                  $request  Request.
908     * @return \WP_REST_Response|\WP_Error|mixed Unchanged response.
909     */
910    public function leave_rest_callback( $response, $handler, $request ) {
911        $failed_field = null;
912        if ( is_wp_error( $response ) && array( 'rest_meta_database_error' ) === $response->get_error_codes() ) {
913            $failures     = $response->get_all_error_data( 'rest_meta_database_error' );
914            $failed_field = 1 === count( $failures ) && is_array( $failures[0] ) ? ( $failures[0]['key'] ?? null ) : null;
915            if ( in_array( $failed_field, self::PUBLISH_CONTROL_META_FIELDS, true ) ) {
916                $failed_field = null;
917            }
918        }
919
920        foreach ( $this->published_in_rest_callback as $post_id => $depth ) {
921            // Posts from an outer callback are settled when that one completes.
922            if ( $depth < $this->rest_callback_depth ) {
923                continue;
924            }
925            unset( $this->published_in_rest_callback[ $post_id ] );
926            $post_before = $this->rest_posts_before[ $post_id ] ?? null;
927            unset( $this->rest_posts_before[ $post_id ] );
928
929            if ( ! is_wp_error( $response ) || ! isset( $this->just_published[ $post_id ] ) ) {
930                continue;
931            }
932
933            $post = get_post( $post_id );
934            if ( null !== $failed_field && $post instanceof \WP_Post && 'publish' === $post->post_status && $this->stored_meta_matches_request( $post_id, $request, $failed_field ) ) {
935                wp_after_insert_post( $post, null !== $post_before, $post_before );
936            }
937            unset( $this->just_published[ $post_id ] );
938        }
939
940        if ( 1 === $this->rest_callback_depth ) {
941            $this->rest_posts_before = array();
942        }
943        $this->rest_callback_depth = max( 0, $this->rest_callback_depth - 1 );
944        return $response;
945    }
946
947    /**
948     * Whether every requested REST meta field, except the one that failed, is stored as requested.
949     *
950     * @param int              $post_id      Post ID.
951     * @param \WP_REST_Request $request      Request.
952     * @param string           $failed_field REST name of the meta field that failed to save.
953     * @return bool
954     */
955    private function stored_meta_matches_request( $post_id, $request, $failed_field ) {
956        $requested = $request['meta'];
957        if ( ! is_array( $requested ) ) {
958            return true;
959        }
960
961        $post = get_post( $post_id );
962        if ( ! $post instanceof \WP_Post || ! class_exists( 'WP_REST_Post_Meta_Fields' ) ) {
963            return false;
964        }
965
966        $stored = ( new \WP_REST_Post_Meta_Fields( $post->post_type ) )->get_value( $post_id, $request );
967        foreach ( $requested as $name => $value ) {
968            // A reset (`null`) that failed would have been a second meta error.
969            if ( $name === $failed_field || null === $value ) {
970                continue;
971            }
972            if ( ! array_key_exists( $name, $stored ) || $stored[ $name ] !== $value ) {
973                return false;
974            }
975        }
976
977        return true;
978    }
979
980    /**
981     * When publishing or updating a post, the Gutenberg editor sends two requests:
982     * 1. sent to WP REST API endpoint `wp-json/wp/v2/posts/$id`
983     * 2. sent to wp-admin/post.php `?post=$id&action=edit&classic-editor=1&meta_box=1`
984     *
985     * The 2nd request is to update post meta, which is not supported on WP REST API.
986     * When syncing post data, we will include if this was a meta box update.
987     *
988     * @return boolean Whether this is a Gutenberg meta box update.
989     */
990    private function is_gutenberg_meta_box_update() {
991        // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- We only check the request to determine if this is a Gutenberg meta box update, and we only use the result to set a boolean logged in the sync event. If anyone anywhere else gets the flag and does something CSRF-able with it, they should ensure that a nonce has been checked.
992        return (
993            isset( $_POST['action'], $_GET['classic-editor'], $_GET['meta_box'] ) &&
994            'editpost' === $_POST['action'] &&
995            '1' === $_GET['classic-editor'] &&
996            '1' === $_GET['meta_box']
997            // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
998        );
999    }
1000
1001    /**
1002     * Handler for the wp_insert_post hook.
1003     * Called upon creation of a new post.
1004     *
1005     * @param int      $post_ID Post ID.
1006     * @param \WP_Post $post    Post object.
1007     * @param boolean  $update  Whether this is an existing post being updated or not.
1008     */
1009    public function wp_insert_post( $post_ID, $post = null, $update = null ) {
1010        if ( ! is_numeric( $post_ID ) || ! $post instanceof \WP_Post ) {
1011            return;
1012        }
1013
1014        // Workaround for https://github.com/woocommerce/woocommerce/issues/18007.
1015        if ( 'shop_order' === $post->post_type ) {
1016            $post = get_post( $post_ID );
1017        }
1018
1019        $previous_status = $this->previous_status[ $post_ID ] ?? self::DEFAULT_PREVIOUS_STATE;
1020
1021        $just_published = $this->just_published[ $post_ID ] ?? false;
1022
1023        $state = array(
1024            'is_auto_save'                 => (bool) Jetpack_Constants::get_constant( 'DOING_AUTOSAVE' ),
1025            'previous_status'              => $previous_status,
1026            'just_published'               => $just_published,
1027            'is_gutenberg_meta_box_update' => $this->is_gutenberg_meta_box_update(),
1028        );
1029        /**
1030         * Filter that is used to add to the post flags ( meta data ) when a post gets published
1031         *
1032         * @since 1.6.3
1033         * @since-jetpack 5.8.0
1034         *
1035         * @param int $post_ID the post ID
1036         * @param mixed $post \WP_Post object
1037         * @param bool $update Whether this is an existing post being updated or not.
1038         * @param mixed $state state
1039         *
1040         * @module sync
1041         */
1042        do_action( 'jetpack_sync_save_post', $post_ID, $post, $update, $state );
1043        unset( $this->previous_status[ $post_ID ] );
1044    }
1045
1046    /**
1047     * Handler for the wp_after_insert_post hook.
1048     * Called after creation/update of a new post.
1049     *
1050     * @param int      $post_ID Post ID.
1051     * @param \WP_Post $post    Post object.
1052     **/
1053    public function wp_after_insert_post( $post_ID, $post ) {
1054        if ( ! is_numeric( $post_ID ) || ! $post instanceof \WP_Post ) {
1055            return;
1056        }
1057
1058        // Workaround for https://github.com/woocommerce/woocommerce/issues/18007.
1059        if ( 'shop_order' === $post->post_type ) {
1060            $post = get_post( $post_ID );
1061        }
1062
1063        $this->send_published( $post_ID, $post );
1064    }
1065
1066    /**
1067     * Send a published post for sync.
1068     *
1069     * @param int      $post_ID Post ID.
1070     * @param \WP_Post $post    Post object.
1071     */
1072    public function send_published( $post_ID, $post ) {
1073        if ( ! isset( $this->just_published[ $post_ID ] ) ) {
1074            return;
1075        }
1076
1077        // Post revisions cause race conditions where this send_published add the action before the actual post gets synced.
1078        if ( wp_is_post_autosave( $post ) || wp_is_post_revision( $post ) ) {
1079            return;
1080        }
1081
1082        $post_flags = array(
1083            'post_type' => $post->post_type,
1084        );
1085
1086        $author_user_object = get_user_by( 'id', $post->post_author );
1087        if ( $author_user_object ) {
1088            $roles = new Roles();
1089
1090            $post_flags['author'] = array(
1091                'id'              => $post->post_author,
1092                'wpcom_user_id'   => get_user_meta( $post->post_author, 'wpcom_user_id', true ),
1093                'display_name'    => $author_user_object->display_name,
1094                'email'           => $author_user_object->user_email,
1095                'translated_role' => $roles->translate_user_to_role( $author_user_object ),
1096            );
1097        }
1098
1099        /**
1100         * Filter that is used to add to the post flags ( meta data ) when a post gets published
1101         *
1102         * @since 1.6.3
1103         * @since-jetpack 4.4.0
1104         *
1105         * @param mixed array post flags that are added to the post
1106         * @param mixed $post \WP_Post object
1107         */
1108        $flags = apply_filters( 'jetpack_published_post_flags', $post_flags, $post );
1109
1110        // Only Send Pulished Post event if post_type is not blacklisted.
1111        if ( ! in_array( $post->post_type, Settings::get_setting( 'post_types_blacklist' ), true ) ) {
1112
1113            /**
1114             * Action that gets synced when a post type gets published.
1115             *
1116             * @since 1.6.3
1117             * @since-jetpack 4.4.0
1118             *
1119             * @param int $post_ID
1120             * @param mixed array $flags post flags that are added to the post
1121             * @param WP_Post $post The post object
1122             */
1123            do_action( 'jetpack_published_post', $post_ID, $flags, $post );
1124        }
1125        unset( $this->just_published[ $post_ID ] );
1126
1127        /**
1128         * Send additional sync action for Activity Log when post is a Customizer publish
1129         */
1130        if ( 'customize_changeset' === $post->post_type ) {
1131            $post_content = json_decode( $post->post_content, true );
1132            if ( ! is_iterable( $post_content ) ) {
1133                return;
1134            }
1135            foreach ( $post_content as $key => $value ) {
1136                // Skip if it isn't a widget.
1137                if ( 'widget_' !== substr( $key, 0, strlen( 'widget_' ) ) ) {
1138                    continue;
1139                }
1140                // Change key from "widget_archives[2]" to "archives-2".
1141                $key = str_replace( 'widget_', '', $key );
1142                $key = str_replace( '[', '-', $key );
1143                $key = str_replace( ']', '', $key );
1144
1145                global $wp_registered_widgets;
1146                if ( isset( $wp_registered_widgets[ $key ] ) ) {
1147                    $widget_data = array(
1148                        'name'  => $wp_registered_widgets[ $key ]['name'],
1149                        'id'    => $key,
1150                        'title' => $value['value']['title'] ?? '',
1151                    );
1152                    do_action( 'jetpack_widget_edited', $widget_data );
1153                }
1154            }
1155        }
1156    }
1157
1158    /**
1159     * Build the full sync action object for Posts.
1160     *
1161     * @access public
1162     *
1163     * @param array $args An array with the posts and the previous end.
1164     *
1165     * @return array An array with the posts, postmeta and the previous end.
1166     */
1167    public function build_full_sync_action_array( $args ) {
1168        list( $filtered_posts, $previous_end ) = $args;
1169        return array(
1170            $filtered_posts['objects'],
1171            $filtered_posts['meta'],
1172            array(), // WPCOM does not process term relationships in full sync posts actions for a while now, let's skip them.
1173            $previous_end,
1174        );
1175    }
1176
1177    /**
1178     * Add term relationships to post objects within a hook before they are serialized and sent to the server.
1179     * This is used in Full Sync Immediately
1180     *
1181     * @access public
1182     *
1183     * @param array $args The hook parameters.
1184     * @return array $args The expanded hook parameters.
1185     * @deprecated since 4.7.0
1186     */
1187    public function add_term_relationships( $args ) {
1188        _deprecated_function( __METHOD__, '4.7.0' );
1189        list( $filtered_posts, $previous_interval_end ) = $args;
1190
1191        return array(
1192            $filtered_posts['objects'],
1193            $filtered_posts['meta'],
1194            $this->get_term_relationships( $filtered_posts['object_ids'] ),
1195            $previous_interval_end,
1196        );
1197    }
1198
1199    /**
1200     * Expand post IDs to post objects within a hook before they are serialized and sent to the server.
1201     * This is used in Legacy Full Sync
1202     *
1203     * @access public
1204     *
1205     * @param array $args The hook parameters.
1206     * @return array $args The expanded hook parameters.
1207     */
1208    public function expand_posts_with_metadata_and_terms( $args ) {
1209        list( $post_ids, $previous_interval_end ) = $args;
1210
1211        $posts              = $this->expand_posts( $post_ids );
1212        $posts_metadata     = $this->get_metadata( $post_ids, 'post', Settings::get_setting( 'post_meta_whitelist' ) );
1213        $term_relationships = $this->get_term_relationships( $post_ids );
1214
1215        return array(
1216            $posts,
1217            $posts_metadata,
1218            $term_relationships,
1219            $previous_interval_end,
1220        );
1221    }
1222
1223    /**
1224     * Gets a list of minimum and maximum object ids for each batch based on the given batch size.
1225     *
1226     * @access public
1227     *
1228     * @param int         $batch_size The batch size for objects.
1229     * @param string|bool $where_sql  The sql where clause minus 'WHERE', or false if no where clause is needed.
1230     *
1231     * @return array|bool An array of min and max ids for each batch. FALSE if no table can be found.
1232     */
1233    public function get_min_max_object_ids_for_batches( $batch_size, $where_sql = false ) {
1234        return parent::get_min_max_object_ids_for_batches( $batch_size, $this->get_where_sql( $where_sql ) );
1235    }
1236
1237    /**
1238     * Given the Module Configuration and Status return the next chunk of items to send.
1239     * This function also expands the posts and metadata and filters them based on the maximum size constraints.
1240     *
1241     * @param array $config This module Full Sync configuration.
1242     * @param array $status This module Full Sync status.
1243     * @param int   $chunk_size Chunk size.
1244     *
1245     * @return array
1246     */
1247    public function get_next_chunk( $config, $status, $chunk_size ) {
1248
1249        $post_ids = parent::get_next_chunk( $config, $status, $chunk_size );
1250
1251        if ( empty( $post_ids ) ) {
1252            return array();
1253        }
1254
1255        $posts = $this->expand_posts( $post_ids );
1256
1257        // If no posts were fetched, make sure to return the expected structure so that status is updated correctly.
1258        if ( empty( $posts ) ) {
1259            return array(
1260                'object_ids' => $post_ids,
1261                'objects'    => array(),
1262                'meta'       => array(),
1263            );
1264        }
1265        // Get the post IDs from the posts that were fetched.
1266        $fetched_post_ids = wp_list_pluck( $posts, 'ID' );
1267        $metadata         = $this->get_metadata( $fetched_post_ids, 'post', Settings::get_setting( 'post_meta_whitelist' ) );
1268
1269        // Filter the posts and metadata based on the maximum size constraints.
1270        list( $filtered_post_ids, $filtered_posts, $filtered_posts_metadata ) = $this->filter_objects_and_metadata_by_size(
1271            'post',
1272            $posts,
1273            $metadata,
1274            self::MAX_META_LENGTH,
1275            self::MAX_SIZE_FULL_SYNC
1276        );
1277
1278        return array(
1279            'object_ids' => $filtered_post_ids,
1280            'objects'    => $filtered_posts,
1281            'meta'       => $filtered_posts_metadata,
1282        );
1283    }
1284
1285    /**
1286     * Expand posts.
1287     *
1288     * @param array $post_ids Post IDs.
1289     *
1290     * @return array Expanded posts.
1291     */
1292    private function expand_posts( $post_ids ) {
1293        $posts = array_filter( array_map( array( 'WP_Post', 'get_instance' ), $post_ids ) );
1294        $posts = array_map( array( $this, 'filter_post_content_and_add_links' ), $posts );
1295        $posts = array_values( $posts ); // Reindex in case posts were deleted.
1296        return $posts;
1297    }
1298}