PluginProbe
ActivityPub / 7.8.2
ActivityPub v7.8.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/collection/class-posts.php +464 -106 8.2.07.8.2 View file →
@@ -6,185 +6,543 @@
6 6 */
7 7
8 8 namespace Activitypub\Collection;
9 9
10 -use Activitypub\Blocks;
11 -use Activitypub\Hashtag;
12 -use Activitypub\Link;
10 +use Activitypub\Attachments;
11 +use Activitypub\Sanitize;
13 12
14 -use function Activitypub\get_content_visibility;
13 +use function Activitypub\generate_post_summary;
14 +use function Activitypub\object_to_uri;
15 15
16 16 /**
17 17 * Posts collection.
18 18 *
19 - * Provides CRUD methods for local WordPress posts created
20 - * via ActivityPub Client-to-Server (C2S) outbox.
21 - *
22 - * @see Remote_Posts for federated posts received via Server-to-Server (S2S).
19 + * Provides methods to retrieve, create, update, and manage ActivityPub posts (articles, notes, media, etc.).
23 20 */
24 21 class Posts {
25 22 /**
26 - * Create a WordPress post from an ActivityPub activity.
23 + * The post type for the posts.
27 24 *
28 - * @since 8.1.0
25 + * @var string
26 + */
27 + const POST_TYPE = 'ap_post';
28 +
29 + /**
30 + * Add an object to the collection.
29 31 *
30 - * @param array $activity The activity data.
31 - * @param int $user_id The local user ID.
32 - * @param string|null $visibility Content visibility.
32 + * @param array $activity The activity object data.
33 + * @param int|int[] $recipients The id(s) of the local blog-user(s).
33 34 *
34 - * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure.
35 + * @return \WP_Post|\WP_Error The object post or WP_Error on failure.
35 36 */
36 - public static function create( $activity, $user_id, $visibility = null ) {
37 - // Verify the user has permission to create posts.
38 - if ( $user_id > 0 && ! \user_can( $user_id, 'publish_posts' ) ) {
39 - return new \WP_Error(
40 - 'activitypub_forbidden',
41 - \__( 'You do not have permission to create posts.', 'activitypub' ),
42 - array( 'status' => 403 )
43 - );
37 + public static function add( $activity, $recipients ) {
38 + $recipients = (array) $recipients;
39 + $activity_object = $activity['object'];
40 +
41 + $existing = self::get_by_guid( $activity_object['id'] );
42 + // If post exists, call update instead.
43 + if ( ! \is_wp_error( $existing ) ) {
44 + return self::update( $activity, $recipients );
44 45 }
45 46
46 - $object = $activity['object'] ?? array();
47 + // Post doesn't exist, create new post.
48 + $actor = Remote_Actors::fetch_by_uri( object_to_uri( $activity_object['attributedTo'] ) );
47 49
48 - $object_type = $object['type'] ?? '';
49 - $content = \wp_kses_post( $object['content'] ?? '' );
50 - $name = \sanitize_text_field( $object['name'] ?? '' );
51 - $summary = \wp_kses_post( $object['summary'] ?? '' );
50 + if ( \is_wp_error( $actor ) ) {
51 + return $actor;
52 + }
52 53
53 - // Process content: autop, autolink, hashtags, and convert to blocks.
54 - $content = self::prepare_content( $content );
54 + $post_array = self::activity_to_post( $activity_object );
55 + $post_id = \wp_insert_post( $post_array, true );
55 56
56 - // Use name as title for Articles, or generate from content for Notes.
57 - $title = $name;
58 - if ( empty( $title ) && ! empty( $content ) ) {
59 - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
57 + if ( \is_wp_error( $post_id ) ) {
58 + return $post_id;
60 59 }
61 60
62 - // Determine visibility if not provided.
63 - if ( null === $visibility ) {
64 - $visibility = get_content_visibility( $activity );
61 + \add_post_meta( $post_id, '_activitypub_remote_actor_id', $actor->ID );
62 +
63 + // Add recipients as separate meta entries after post is created.
64 + foreach ( $recipients as $user_id ) {
65 + self::add_recipient( $post_id, $user_id );
65 66 }
66 67
67 - $post_data = array(
68 - 'post_author' => $user_id > 0 ? $user_id : 0,
69 - 'post_title' => $title,
70 - 'post_content' => $content,
71 - 'post_excerpt' => $summary,
72 - 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish',
73 - 'post_type' => 'post',
74 - 'meta_input' => array(
75 - 'activitypub_content_visibility' => $visibility,
76 - ),
68 + self::add_taxonomies( $post_id, $activity_object );
69 + self::maybe_import_attachments( $activity_object, $post_id );
70 +
71 + return \get_post( $post_id );
72 + }
73 +
74 + /**
75 + * Get an object from the collection.
76 + *
77 + * @param int $id The object ID.
78 + *
79 + * @return \WP_Post|null The post object or null on failure.
80 + */
81 + public static function get( $id ) {
82 + return \get_post( $id );
83 + }
84 +
85 + /**
86 + * Get an object by its GUID.
87 + *
88 + * @param string $guid The object GUID.
89 + *
90 + * @return \WP_Post|\WP_Error The object post or WP_Error on failure.
91 + */
92 + public static function get_by_guid( $guid ) {
93 + global $wpdb;
94 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
95 + $post_id = $wpdb->get_var(
96 + $wpdb->prepare(
97 + "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
98 + \esc_url( $guid ),
99 + self::POST_TYPE
100 + )
77 101 );
78 102
79 - $post_id = \wp_insert_post( $post_data, true );
103 + if ( ! $post_id ) {
104 + return new \WP_Error(
105 + 'activitypub_post_not_found',
106 + \__( 'Post not found', 'activitypub' ),
107 + array( 'status' => 404 )
108 + );
109 + }
80 110
111 + return \get_post( $post_id );
112 + }
113 +
114 + /**
115 + * Update an object in the collection.
116 + *
117 + * @param array $activity The activity object data.
118 + * @param int|int[] $recipients The id(s) of the local blog-user(s).
119 + *
120 + * @return \WP_Post|\WP_Error The updated object post or WP_Error on failure.
121 + */
122 + public static function update( $activity, $recipients ) {
123 + $recipients = (array) $recipients;
124 +
125 + $post = self::get_by_guid( $activity['object']['id'] );
126 + if ( \is_wp_error( $post ) ) {
127 + return $post;
128 + }
129 +
130 + $post_array = self::activity_to_post( $activity['object'] );
131 + $post_array['ID'] = $post->ID;
132 + $post_id = \wp_update_post( $post_array, true );
133 +
81 134 if ( \is_wp_error( $post_id ) ) {
82 135 return $post_id;
83 136 }
84 137
85 - // Set post format to 'status' for Notes so the transformer maps it back correctly.
86 - if ( 'Note' === $object_type ) {
87 - \set_post_format( $post_id, 'status' );
138 + // Add new recipients using add_recipient (handles deduplication).
139 + foreach ( $recipients as $user_id ) {
140 + self::add_recipient( $post_id, $user_id );
88 141 }
89 142
143 + self::add_taxonomies( $post_id, $activity['object'] );
144 +
145 + // Always delete existing attachments on update in case filter value changed.
146 + Attachments::delete_ap_posts_directory( $post_id );
147 + self::maybe_import_attachments( $activity['object'], $post_id );
148 +
90 149 return \get_post( $post_id );
91 150 }
92 151
93 152 /**
94 - * Update a WordPress post from an ActivityPub activity.
153 + * Delete an object from the collection.
95 154 *
96 - * @since 8.1.0
155 + * @param int $id The object ID.
97 156 *
98 - * @param \WP_Post $post The post to update.
99 - * @param array $activity The activity data.
100 - * @param string|null $visibility Content visibility.
157 + * @return \WP_Post|false|null Post data on success, false or null on failure.
158 + */
159 + public static function delete( $id ) {
160 + return \wp_delete_post( $id, true );
161 + }
162 +
163 + /**
164 + * Delete an object from the collection by its GUID.
101 165 *
102 - * @return \WP_Post|\WP_Error The updated post on success, WP_Error on failure.
166 + * @param string $guid The object GUID.
167 + *
168 + * @return \WP_Post|\WP_Error|false|null Post data on success, false or null on failure, or WP_Error if no post to delete.
103 169 */
104 - public static function update( $post, $activity, $visibility = null ) {
105 - $object = $activity['object'] ?? array();
170 + public static function delete_by_guid( $guid ) {
171 + $post = self::get_by_guid( $guid );
172 + if ( \is_wp_error( $post ) ) {
173 + return $post;
174 + }
106 175
107 - $content = \wp_kses_post( $object['content'] ?? '' );
108 - $name = \sanitize_text_field( $object['name'] ?? '' );
109 - $summary = \wp_kses_post( $object['summary'] ?? '' );
176 + return self::delete( $post->ID );
177 + }
110 178
111 - // Process content: autop, autolink, hashtags, and convert to blocks.
112 - $content = self::prepare_content( $content );
179 + /**
180 + * Extract hashtag names from ActivityPub tag array.
181 + *
182 + * @param array $tags Array of ActivityPub tags.
183 + *
184 + * @return array Array of normalized hashtag names (without # prefix, trimmed, sanitized).
185 + */
186 + public static function extract_hashtags( $tags ) {
187 + $hashtags = array();
113 188
114 - // Use name as title for Articles, or generate from content for Notes.
115 - $title = $name;
116 - if ( empty( $title ) && ! empty( $content ) ) {
117 - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
189 + if ( empty( $tags ) || ! \is_array( $tags ) ) {
190 + return $hashtags;
118 191 }
119 192
120 - // Determine visibility if not provided.
121 - if ( null === $visibility ) {
122 - $visibility = get_content_visibility( $activity );
193 + foreach ( $tags as $tag ) {
194 + if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) {
195 + // Strip # prefix, trim whitespace, and sanitize.
196 + $normalized = \trim( \ltrim( $tag['name'], '#' ) );
197 + $normalized = \wp_strip_all_tags( $normalized );
198 +
199 + if ( ! empty( $normalized ) ) {
200 + $hashtags[] = $normalized;
201 + }
202 + }
123 203 }
124 204
125 - $post_data = array(
126 - 'ID' => $post->ID,
127 - 'post_title' => $title,
128 - 'post_content' => $content,
129 - 'post_excerpt' => $summary,
130 - 'meta_input' => array(
131 - 'activitypub_content_visibility' => $visibility,
132 - ),
205 + return $hashtags;
206 + }
207 +
208 + /**
209 + * Remove hashtags from content.
210 + *
211 + * Removes hashtags that appear at the end of the content.
212 + * Handles both plain text and HTML content, including hashtags within anchor tags.
213 + *
214 + * @param string $content The content to process.
215 + * @param array $tags Array of tag objects from activity (with 'type' and 'name' keys).
216 + *
217 + * @return string The content with trailing hashtags removed.
218 + */
219 + public static function remove_hashtags( $content, $tags ) {
220 + if ( empty( $content ) || empty( $tags ) || ! \is_array( $tags ) ) {
221 + return $content;
222 + }
223 +
224 + // Extract and normalize hashtags from tag objects.
225 + $normalized_tags = self::extract_hashtags( $tags );
226 +
227 + if ( empty( $normalized_tags ) ) {
228 + return $content;
229 + }
230 +
231 + // Build pattern to match trailing hashtags (at end of content or before closing tags).
232 + $tag_patterns = array();
233 + foreach ( $normalized_tags as $tag ) {
234 + $escaped_tag = \preg_quote( $tag, '/' );
235 + $tag_patterns[] = '(?:<a[^>]*>\s*)?#' . $escaped_tag . '(?=\s|<|$)(?:\s*<\/a>)?';
236 + }
237 +
238 + /*
239 + * Pattern explanation:
240 + * Match one or more hashtags (plain or in anchor tags) at the end of content.
241 + * The pattern matches trailing hashtags before closing HTML tags or at end of string.
242 + */
243 + $pattern = '/(?:\s+(?:' . \implode( '|', $tag_patterns ) . '))+(?=\s*(?:<\/[^>]+>)*\s*$)/i';
244 + $content = \preg_replace( $pattern, '', $content );
245 +
246 + // Clean up any extra whitespace at end of paragraphs.
247 + $content = \preg_replace( '/<p>\s*<\/p>/', '', $content );
248 + $content = \preg_replace( '/\s+<\/p>/', '</p>', $content );
249 + $content = \preg_replace( '/\s+<\/strong>/', '</strong>', $content );
250 +
251 + return \trim( $content );
252 + }
253 +
254 + /**
255 + * Convert an activity to a post array.
256 + *
257 + * @param array $activity The activity array.
258 + *
259 + * @return array|\WP_Error The post array or WP_Error on failure.
260 + */
261 + private static function activity_to_post( $activity ) {
262 + if ( ! \is_array( $activity ) ) {
263 + return new \WP_Error( 'invalid_activity', \__( 'Invalid activity format', 'activitypub' ) );
264 + }
265 +
266 + $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $activity['published'] ?? 'now' ) );
267 +
268 + // Sanitize content and remove hashtags.
269 + $content = isset( $activity['content'] ) ? Sanitize::content( $activity['content'] ) : '';
270 + $content = self::remove_hashtags( $content, $activity['tag'] ?? array() );
271 +
272 + return array(
273 + 'post_title' => isset( $activity['name'] ) ? \wp_strip_all_tags( $activity['name'] ) : '',
274 + 'post_content' => $content,
275 + 'post_excerpt' => isset( $activity['summary'] ) ? \wp_strip_all_tags( $activity['summary'] ) : generate_post_summary( $activity['content'] ?? '' ),
276 + 'post_status' => 'publish',
277 + 'post_type' => self::POST_TYPE,
278 + 'post_date_gmt' => $gm_date,
279 + 'post_date' => \get_date_from_gmt( $gm_date ),
280 + 'guid' => isset( $activity['id'] ) ? \esc_url_raw( $activity['id'] ) : '',
133 281 );
282 + }
134 283
135 - $post_id = \wp_update_post( $post_data, true );
284 + /**
285 + * Add taxonomies to the object post.
286 + *
287 + * @param int $post_id The post ID.
288 + * @param array $activity_object The activity object data.
289 + */
290 + private static function add_taxonomies( $post_id, $activity_object ) {
291 + // Save Object Type as Taxonomy item.
292 + \wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' );
136 293
137 - if ( \is_wp_error( $post_id ) ) {
138 - return $post_id;
294 + // Save the Hashtags as Taxonomy items.
295 + $tags = self::extract_hashtags( $activity_object['tag'] ?? array() );
296 +
297 + \wp_set_post_terms( $post_id, $tags, 'ap_tag' );
298 + }
299 +
300 + /**
301 + * Maybe import attachments for an activity object.
302 + *
303 + * Checks if attachments should be stored locally via filter and imports them if enabled.
304 + *
305 + * @param array $activity_object The activity object data.
306 + * @param int $post_id The post ID.
307 + */
308 + private static function maybe_import_attachments( $activity_object, $post_id ) {
309 + // Process attachments if present.
310 + if ( empty( $activity_object['attachment'] ) ) {
311 + return;
139 312 }
140 313
141 - return \get_post( $post_id );
314 + /**
315 + * Filters whether to store attachments locally for incoming ActivityPub posts.
316 + *
317 + * Allows plugins or users to disable local storage of attachments from
318 + * incoming ActivityPub posts. When disabled, attachments won't be downloaded
319 + * and stored locally, which can be useful for users with limited webspace.
320 + *
321 + * @param bool $store_locally Whether to store attachments locally. Default true.
322 + * @param array $activity_object The ActivityPub activity object.
323 + * @param int $post_id The post ID.
324 + */
325 + $store_locally = \apply_filters( 'activitypub_store_attachments_locally', true, $activity_object, $post_id );
326 +
327 + if ( $store_locally ) {
328 + Attachments::import_post_files( $activity_object['attachment'], $post_id );
329 + }
142 330 }
143 331
144 332 /**
145 - * Delete (trash) a WordPress post.
333 + * Get posts by remote actor.
146 334 *
147 - * @since 8.1.0
335 + * @param string $actor The remote actor URI.
148 336 *
337 + * @return array Array of WP_Post objects.
338 + */
339 + public static function get_by_remote_actor( $actor ) {
340 + $remote_actor = Remote_Actors::fetch_by_uri( $actor );
341 +
342 + if ( \is_wp_error( $remote_actor ) ) {
343 + return array();
344 + }
345 +
346 + return self::get_by_remote_actor_id( $remote_actor->ID );
347 + }
348 +
349 + /**
350 + * Get posts by remote actor ID.
351 + *
352 + * @param int $actor_id The remote actor post ID.
353 + *
354 + * @return array Array of WP_Post objects.
355 + */
356 + public static function get_by_remote_actor_id( $actor_id ) {
357 + $query = new \WP_Query(
358 + array(
359 + 'post_type' => self::POST_TYPE,
360 + 'posts_per_page' => -1,
361 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
362 + 'meta_key' => '_activitypub_remote_actor_id',
363 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
364 + 'meta_value' => $actor_id,
365 + )
366 + );
367 +
368 + return $query->posts;
369 + }
370 +
371 + /**
372 + * Get all recipients for a post.
373 + *
149 374 * @param int $post_id The post ID.
150 375 *
151 - * @return \WP_Post|false|null Post data on success, false or null on failure.
376 + * @return int[] Array of user IDs who are recipients.
152 377 */
153 - public static function delete( $post_id ) {
154 - return \wp_trash_post( $post_id );
378 + public static function get_recipients( $post_id ) {
379 + // Get all meta values with key '_activitypub_user_id' (single => false).
380 + $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
381 + $recipients = \array_map( 'intval', $recipients );
382 +
383 + return $recipients;
155 384 }
156 385
157 386 /**
158 - * Prepare content for storage as a WordPress post.
387 + * Check if a user is a recipient of a post.
159 388 *
160 - * Applies wpautop (for plain text), autolinks bare URLs,
161 - * converts hashtags to links, and wraps in block markup.
389 + * @param int $post_id The post ID.
390 + * @param int $user_id The user ID to check.
162 391 *
163 - * @since 8.1.0
392 + * @return bool True if user is a recipient, false otherwise.
393 + */
394 + public static function has_recipient( $post_id, $user_id ) {
395 + $recipients = self::get_recipients( $post_id );
396 +
397 + return \in_array( (int) $user_id, $recipients, true );
398 + }
399 +
400 + /**
401 + * Add a recipient to an existing post.
164 402 *
165 - * @param string $content The HTML or plain-text content.
403 + * @param int $post_id The post ID.
404 + * @param int $user_id The user ID to add.
166 405 *
167 - * @return string The processed content with block markup.
406 + * @return bool True on success, false on failure.
168 407 */
169 - public static function prepare_content( $content ) {
170 - if ( empty( $content ) ) {
171 - return '';
408 + public static function add_recipient( $post_id, $user_id ) {
409 + $user_id = (int) $user_id;
410 + // Allow 0 for blog user, but reject negative values.
411 + if ( $user_id < 0 ) {
412 + return false;
172 413 }
173 414
174 - // Wrap plain text in paragraphs if it has no block-level HTML.
175 - if ( ! \preg_match( '/<(p|h[1-6]|ul|ol|blockquote|figure|hr|img|div|pre|table)\b/i', $content ) ) {
176 - $content = \wpautop( $content );
415 + // Check if already a recipient.
416 + if ( self::has_recipient( $post_id, $user_id ) ) {
417 + return true;
177 418 }
178 419
179 - // Convert bare URLs to links.
180 - $content = Link::the_content( $content );
420 + // Add new recipient as separate meta entry.
421 + return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
422 + }
181 423
182 - // Convert #hashtags to links.
183 - $content = Hashtag::the_content( $content );
424 + /**
425 + * Add multiple recipients to an existing post.
426 + *
427 + * @param int $post_id The post ID.
428 + * @param int[] $user_ids The user ID or array of user IDs to add.
429 + */
430 + public static function add_recipients( $post_id, $user_ids ) {
431 + foreach ( $user_ids as $user_id ) {
432 + self::add_recipient( $post_id, $user_id );
433 + }
434 + }
184 435
185 - // Convert HTML to block markup.
186 - $content = Blocks::convert_from_html( $content );
436 + /**
437 + * Remove a recipient from a post.
438 + *
439 + * @param int $post_id The post ID.
440 + * @param int $user_id The user ID to remove.
441 + *
442 + * @return bool True on success, false on failure.
443 + */
444 + public static function remove_recipient( $post_id, $user_id ) {
445 + $user_id = (int) $user_id;
187 446
188 - return $content;
447 + // Allow 0 for blog user, but reject negative values.
448 + if ( $user_id < 0 ) {
449 + return false;
450 + }
451 +
452 + // Delete the specific meta entry with this value.
453 + return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
454 + }
455 +
456 + /**
457 + * Delete all posts.
458 + *
459 + * Used during plugin uninstall to clean up all remote posts.
460 + *
461 + * @return int The number of posts deleted.
462 + */
463 + public static function delete_all() {
464 + $post_ids = \get_posts(
465 + array(
466 + 'post_type' => self::POST_TYPE,
467 + 'post_status' => array( 'any', 'trash', 'auto-draft' ),
468 + 'fields' => 'ids',
469 + 'numberposts' => -1,
470 + )
471 + );
472 +
473 + foreach ( $post_ids as $post_id ) {
474 + \wp_delete_post( $post_id, true );
475 + }
476 +
477 + return count( $post_ids );
478 + }
479 +
480 + /**
481 + * Purge old remote posts.
482 + *
483 + * Deletes remote posts older than the specified number of days,
484 + * but preserves posts that have comments from local users
485 + * as these indicate meaningful local interactions.
486 + *
487 + * @param int $days Number of days to keep items. Items older than this will be deleted.
488 + *
489 + * @return int The number of items deleted.
490 + */
491 + public static function purge( $days ) {
492 + $total_posts = (int) \wp_count_posts( self::POST_TYPE )->publish;
493 + if ( $total_posts <= 200 ) {
494 + return 0;
495 + }
496 +
497 + $post_ids = \get_posts(
498 + array(
499 + 'post_type' => self::POST_TYPE,
500 + 'post_status' => 'any',
501 + 'fields' => 'ids',
502 + 'numberposts' => -1,
503 + 'date_query' => array(
504 + array(
505 + 'before' => \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) ),
506 + ),
507 + ),
508 + )
509 + );
510 +
511 + global $wpdb;
512 +
513 + $deleted = 0;
514 + foreach ( $post_ids as $post_id ) {
515 + /**
516 + * Filter whether to preserve a specific ap_post from being purged.
517 + *
518 + * @param bool $preserve Whether to preserve this post. Default false.
519 + * @param int $post_id The ap_post ID being considered for deletion.
520 + *
521 + * @return bool Whether to preserve this post from deletion.
522 + */
523 + if ( \apply_filters( 'activitypub_preserve_ap_post', false, $post_id ) ) {
524 + continue;
525 + }
526 +
527 + /*
528 + * Preserve posts with comments from local users.
529 + * Local user comments have a user_id > 0, while Fediverse comments have user_id = 0.
530 + */
531 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery
532 + $has_local_comments = (bool) $wpdb->get_var(
533 + $wpdb->prepare(
534 + "SELECT 1 FROM $wpdb->comments WHERE comment_post_ID = %d AND user_id > 0 LIMIT 1",
535 + $post_id
536 + )
537 + );
538 + if ( $has_local_comments ) {
539 + continue;
540 + }
541 +
542 + \wp_delete_post( $post_id, true );
543 + ++$deleted;
544 + }
545 +
546 + return $deleted;
189 547 }
190 548 }