PluginProbe
Friends / trunk
Friends vtrunk
4.3.1 4.3.0 4.2.2 4.2.1 4.2.0 4.1.0 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.1 2.9.2 All 87 releases
friends / includes / class-migration.php

class-migration.php in Friends trunk, at includes/class-migration.php

3,364 lines 114.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Friends Migration
4 *
5 * This contains the migration functions for the Friends plugin.
6 * This class is only loaded when migrations are needed during plugin upgrades.
7 *
8 * @package Friends
9 */
10
11 namespace Friends;
12
13 /**
14 * This class handles plugin migrations during upgrades.
15 *
16 * @package Friends
17 * @author Alex Kirk
18 */
19 class Migration {
20
21 /**
22 * Registry of all migrations.
23 * Each migration has: id, version, title, description, method, status_option, batched, batch_method
24 *
25 * @var array
26 */
27 private static $registry = array();
28
29 /**
30 * Constructor
31 */
32 public function __construct() {
33 $this->register_hooks();
34 }
35
36 /**
37 * Register the WordPress hooks
38 */
39 private function register_hooks() {
40 add_action( 'wp_ajax_friends_run_migration', array( $this, 'ajax_run_migration' ) );
41 add_action( 'wp_ajax_friends_undo_migration', array( $this, 'ajax_undo_migration' ) );
42 add_action( 'wp_ajax_friends_get_migration_status', array( $this, 'ajax_get_migration_status' ) );
43 add_action( 'wp_ajax_friends_process_migration_batch', array( $this, 'ajax_process_migration_batch' ) );
44 add_action( 'wp_ajax_friends_get_migration_debug', array( $this, 'ajax_get_migration_debug' ) );
45 add_action( 'wp_ajax_friends_clear_failed_url', array( $this, 'ajax_clear_failed_url' ) );
46 add_action( 'wp_ajax_friends_deactivate_feed_by_url', array( $this, 'ajax_deactivate_feed_by_url' ) );
47 add_action( 'friends_convert_friend_users_batch', array( __CLASS__, 'convert_friend_users_batch' ) );
48 add_action( 'friends_link_feeds_as_term_children_batch', array( __CLASS__, 'link_feeds_as_term_children_batch' ) );
49
50 // Register debug output hooks for migrations.
51 add_action( 'friends_migration_debug_link_activitypub_feeds_to_actors', array( $this, 'debug_link_activitypub_feeds_to_actors' ) );
52 }
53
54 /**
55 * Register all migrations.
56 */
57 public static function register_migrations() {
58 if ( ! empty( self::$registry ) ) {
59 return;
60 }
61
62 self::register(
63 array(
64 'id' => 'gravatar_to_user_icon',
65 'version' => '0.20.1',
66 'title' => 'Migrate Gravatar to User Icon',
67 'description' => 'Migrates user gravatar option to the new user_icon_url format.',
68 'method' => 'migrate_gravatar_to_user_icon_url',
69 'status_option' => null, // No status tracking for simple migrations.
70 )
71 );
72
73 self::register(
74 array(
75 'id' => 'feed_options_to_user',
76 'version' => '2.6.0',
77 'title' => 'Migrate Feed Options',
78 'description' => 'Moves feed options from global options to user-specific options.',
79 'method' => 'migrate_feed_options_to_user_options',
80 'status_option' => null,
81 )
82 );
83
84 self::register(
85 array(
86 'id' => 'wp_friendships',
87 'version' => '2.8.7',
88 'title' => 'Enable WP Friendships Flag',
89 'description' => 'Enables the WordPress friendships flag if friendship functionality was used.',
90 'method' => 'enable_wp_friendships_if_used',
91 'status_option' => null,
92 )
93 );
94
95 self::register(
96 array(
97 'id' => 'external_user_and_cron',
98 'version' => '2.9.4',
99 'title' => 'Migrate External User and Cron',
100 'description' => 'Renames external-mentions user to external and upgrades cron schedule.',
101 'method' => 'migrate_external_user_and_cron',
102 'status_option' => null,
103 )
104 );
105
106 self::register(
107 array(
108 'id' => 'frontend_default_view',
109 'version' => '3.1.8',
110 'title' => 'Migrate Frontend Default View',
111 'description' => 'Moves frontend default view option to user-specific setting.',
112 'method' => 'migrate_frontend_default_view_to_user_option',
113 'status_option' => null,
114 )
115 );
116
117 self::register(
118 array(
119 'id' => 'post_tags_to_friend_tags',
120 'version' => '4.0.0',
121 'title' => 'Migrate Post Tags to Friend Tags',
122 'description' => 'Converts post_tag taxonomy to friend_tag for Friends posts. Runs in batches.',
123 'method' => 'migrate_post_tags_to_friend_tags',
124 'status_option' => 'friends_tag_migration_completed',
125 'batched' => true,
126 'batch_method' => 'migrate_post_tags_batch',
127 'cron_hook' => 'friends_migrate_post_tags_batch',
128 'undo_method' => 'undo_post_tags_migration',
129 'progress' => array(
130 'in_progress' => 'friends_tag_migration_in_progress',
131 'total' => 'friends_tag_migration_total',
132 'processed' => 'friends_tag_migration_processed',
133 ),
134 )
135 );
136
137 self::register(
138 array(
139 'id' => 'backfill_mention_tags',
140 'version' => '4.0.0',
141 'title' => 'Backfill Mention Tags',
142 'description' => 'Extracts mention tags from Mastodon HTML content in existing posts.',
143 'method' => 'backfill_mention_tags_from_mastodon_html',
144 'status_option' => 'friends_backfill_mention_tags_completed',
145 )
146 );
147
148 self::register(
149 array(
150 'id' => 'activitypub_attributed_to',
151 'version' => '4.0.0',
152 'title' => 'Migrate ActivityPub AttributedTo',
153 'description' => 'Converts attributedTo URLs to ap_actor post IDs for better reliability. Runs in batches.',
154 'method' => 'migrate_activitypub_attributed_to',
155 'status_option' => 'friends_ap_attributed_to_migration_completed',
156 'batched' => true,
157 'batch_method' => 'migrate_activitypub_attributed_to_batch',
158 'cron_hook' => 'friends_migrate_ap_attributed_to_batch',
159 'progress' => array(
160 'in_progress' => 'friends_ap_attributed_to_migration_in_progress',
161 'total' => 'friends_ap_attributed_to_migration_total',
162 'processed' => 'friends_ap_attributed_to_migration_processed',
163 ),
164 )
165 );
166
167 self::register(
168 array(
169 'id' => 'activitypub_attributed_to_acct',
170 'version' => '4.2.0',
171 'title' => 'Backfill ActivityPub Actor Handles',
172 'description' => 'Adds cached ActivityPub actor handles to the latest 100 cached posts that already reference ap_actor posts.',
173 'method' => 'backfill_activitypub_attributed_to_acct',
174 'status_option' => 'friends_ap_attributed_to_acct_backfilled',
175 'depends_on' => 'activitypub_attributed_to',
176 )
177 );
178
179 self::register(
180 array(
181 'id' => 'import_activitypub_followings',
182 'version' => '4.0.0',
183 'title' => 'Import ActivityPub Followings',
184 'description' => 'Imports existing ActivityPub plugin followings as Friends subscriptions.',
185 'method' => 'import_activitypub_followings',
186 'status_option' => 'friends_activitypub_followings_imported',
187 )
188 );
189
190 self::register(
191 array(
192 'id' => 'link_activitypub_feeds_to_actors',
193 'version' => '4.0.0',
194 'title' => 'Link ActivityPub Feeds to Actors',
195 'description' => 'Links existing ActivityPub feeds to their ap_actor posts for URL synchronization. Runs in batches.',
196 'method' => 'link_activitypub_feeds_to_actors',
197 'status_option' => 'friends_ap_feeds_linked_to_actors',
198 'batched' => true,
199 'batch_method' => 'link_activitypub_feeds_to_actors_batch',
200 'cron_hook' => 'friends_link_ap_feeds_batch',
201 'progress' => array(
202 'in_progress' => 'friends_ap_feeds_link_migration_in_progress',
203 'total' => 'friends_ap_feeds_link_migration_total',
204 'processed' => 'friends_ap_feeds_link_migration_processed',
205 ),
206 )
207 );
208
209 self::register(
210 array(
211 'id' => 'backfill_external_attributed_to',
212 'version' => '4.0.0',
213 'title' => 'Backfill External Post Authors',
214 'description' => 'Fetches actor information for External user posts from their original permalinks. Runs in batches.',
215 'method' => 'backfill_external_attributed_to',
216 'status_option' => 'friends_external_attributed_to_backfill_completed',
217 'batched' => true,
218 'batch_method' => 'backfill_external_attributed_to_batch',
219 'cron_hook' => 'friends_backfill_external_attributed_to_batch',
220 'progress' => array(
221 'in_progress' => 'friends_external_attributed_to_backfill_in_progress',
222 'total' => 'friends_external_attributed_to_backfill_total',
223 'processed' => 'friends_external_attributed_to_backfill_processed',
224 ),
225 )
226 );
227
228 self::register(
229 array(
230 'id' => 'convert_replies_to_comments',
231 'version' => '4.0.0',
232 'title' => 'Convert Reply Posts to Comments',
233 'description' => 'Converts ActivityPub reply posts to comments on conversation root posts. Runs in batches.',
234 'method' => 'convert_replies_to_comments',
235 'status_option' => 'friends_replies_to_comments_completed',
236 'batched' => true,
237 'batch_method' => 'convert_replies_to_comments_batch',
238 'cron_hook' => 'friends_convert_replies_batch',
239 'undo_method' => 'undo_replies_to_comments',
240 'progress' => array(
241 'in_progress' => 'friends_replies_to_comments_in_progress',
242 'total' => 'friends_replies_to_comments_total',
243 'processed' => 'friends_replies_to_comments_processed',
244 ),
245 )
246 );
247
248 self::register(
249 array(
250 'id' => 'sanitize_usernames',
251 'version' => '4.0.0',
252 'title' => 'Sanitize Friend Usernames',
253 'description' => 'Removes special characters like apostrophes from friend usernames to prevent URL issues.',
254 'method' => 'sanitize_friend_usernames',
255 'status_option' => 'friends_usernames_sanitized',
256 )
257 );
258
259 self::register(
260 array(
261 'id' => 'convert_friend_users_to_subscriptions',
262 'version' => '4.0.0',
263 'title' => 'Convert Friend Users to Subscriptions',
264 'description' => 'Converts WordPress users with friend/acquaintance/subscription roles to virtual subscriptions. Runs in batches.',
265 'method' => 'convert_friend_users_to_subscriptions',
266 'status_option' => 'friends_friend_users_converted',
267 'batched' => true,
268 'batch_method' => 'convert_friend_users_batch',
269 'cron_hook' => 'friends_convert_friend_users_batch',
270 'progress' => array(
271 'in_progress' => 'friends_friend_users_conversion_in_progress',
272 'total' => 'friends_friend_users_conversion_total',
273 'processed' => 'friends_friend_users_conversion_processed',
274 ),
275 )
276 );
277
278 self::register(
279 array(
280 'id' => 'link_feeds_as_term_children',
281 'version' => '4.0.0',
282 'title' => 'Link Feeds as Term Children',
283 'description' => 'Migrates feed-to-subscription links from wp_term_relationships to the parent field in wp_term_taxonomy.',
284 'method' => 'link_feeds_as_term_children',
285 'depends_on' => 'convert_friend_users_to_subscriptions',
286 'status_option' => 'friends_feeds_linked_as_term_children',
287 'batched' => true,
288 'batch_method' => 'link_feeds_as_term_children_batch',
289 'cron_hook' => 'friends_link_feeds_as_term_children_batch',
290 'progress' => array(
291 'in_progress' => 'friends_feeds_term_children_in_progress',
292 'total' => 'friends_feeds_term_children_total',
293 'processed' => 'friends_feeds_term_children_processed',
294 ),
295 )
296 );
297 }
298
299 /**
300 * Register a single migration.
301 *
302 * @param array $migration The migration data.
303 */
304 private static function register( $migration ) {
305 $defaults = array(
306 'batched' => false,
307 'batch_method' => null,
308 'undo_method' => null,
309 'depends_on' => null,
310 'progress' => array(),
311 );
312 self::$registry[ $migration['id'] ] = array_merge( $defaults, $migration );
313 }
314
315 /**
316 * Get all registered migrations.
317 *
318 * @return array The migration registry.
319 */
320 public static function get_registry() {
321 self::register_migrations();
322 return self::$registry;
323 }
324
325 /**
326 * Get a single migration by ID.
327 *
328 * @param string $id The migration ID.
329 * @return array|null The migration data or null.
330 */
331 public static function get_migration( $id ) {
332 self::register_migrations();
333 return isset( self::$registry[ $id ] ) ? self::$registry[ $id ] : null;
334 }
335
336 /**
337 * Get the status of a migration.
338 *
339 * @param string $id The migration ID.
340 * @return array Status information.
341 */
342 public static function get_migration_status_by_id( $id ) {
343 $migration = self::get_migration( $id );
344 if ( ! $migration ) {
345 return array( 'status' => 'unknown' );
346 }
347
348 $status = array(
349 'id' => $id,
350 'title' => $migration['title'],
351 'description' => $migration['description'],
352 'version' => $migration['version'],
353 'batched' => $migration['batched'],
354 );
355
356 // Check if dependency is met.
357 if ( ! empty( $migration['depends_on'] ) ) {
358 $dep_status = self::get_migration_status_by_id( $migration['depends_on'] );
359 if ( empty( $dep_status['completed'] ) ) {
360 $dep_migration = self::get_migration( $migration['depends_on'] );
361 $status['waiting_for'] = $migration['depends_on'];
362 $status['waiting_for_title'] = $dep_migration ? $dep_migration['title'] : $migration['depends_on'];
363 }
364 }
365
366 // Check completion status.
367 if ( $migration['status_option'] ) {
368 $status['completed'] = (bool) get_option( $migration['status_option'] );
369 } else {
370 // No status tracking - assume completed if we're past the version.
371 $current_version = get_option( 'friends_plugin_version', '0' );
372 $status['completed'] = version_compare( $current_version, $migration['version'], '>=' );
373 $status['no_tracking'] = true;
374 }
375
376 // Check progress for batched migrations.
377 if ( $migration['batched'] && ! empty( $migration['progress'] ) ) {
378 $status['in_progress'] = (bool) get_option( $migration['progress']['in_progress'] );
379 $status['total'] = (int) get_option( $migration['progress']['total'], 0 );
380 $status['processed'] = (int) get_option( $migration['progress']['processed'], 0 );
381 if ( $status['total'] > 0 ) {
382 $status['percent'] = min( 100, round( ( $status['processed'] / $status['total'] ) * 100, 1 ) );
383 } else {
384 $status['percent'] = 0;
385 }
386
387 // Check for next scheduled batch.
388 if ( $status['in_progress'] && ! empty( $migration['cron_hook'] ) ) {
389 $next_scheduled = wp_next_scheduled( $migration['cron_hook'] );
390 if ( $next_scheduled ) {
391 $status['next_scheduled'] = $next_scheduled;
392 }
393 $status['cron_hook'] = $migration['cron_hook'];
394 }
395 }
396
397 return $status;
398 }
399
400 /**
401 * Check whether any tracked migrations are still pending.
402 *
403 * Uses the friends_migration_status option as a fast gate: if it is already
404 * 'completed' this returns false immediately without touching any other options.
405 * When every tracked migration has finished it writes 'completed' to that option
406 * so subsequent calls are a single cheap get_option().
407 *
408 * @return bool True if at least one tracked migration has not yet completed.
409 */
410 public static function has_pending_tracked_migrations() {
411 if ( 'completed' === get_option( 'friends_migration_status' ) ) {
412 return false;
413 }
414 self::register_migrations();
415 foreach ( self::$registry as $migration ) {
416 if ( $migration['status_option'] && ! get_option( $migration['status_option'] ) ) {
417 return true;
418 }
419 }
420 update_option( 'friends_migration_status', 'completed', false );
421 return false;
422 }
423
424 /**
425 * Get status of all migrations.
426 *
427 * @return array Status information for all migrations.
428 */
429 public static function get_all_statuses() {
430 self::register_migrations();
431 $statuses = array();
432 foreach ( array_keys( self::$registry ) as $id ) {
433 $statuses[ $id ] = self::get_migration_status_by_id( $id );
434 }
435 return $statuses;
436 }
437
438 /**
439 * Run a specific migration manually.
440 *
441 * @param string $id The migration ID.
442 * @return array Result of the migration.
443 */
444 public static function run_migration( $id ) {
445 $migration = self::get_migration( $id );
446 if ( ! $migration ) {
447 return array(
448 'success' => false,
449 'message' => 'Migration not found: ' . $id,
450 );
451 }
452
453 // Check dependencies.
454 if ( ! empty( $migration['depends_on'] ) ) {
455 $dep_status = self::get_migration_status_by_id( $migration['depends_on'] );
456 if ( empty( $dep_status['completed'] ) ) {
457 $dep_migration = self::get_migration( $migration['depends_on'] );
458 return array(
459 'success' => false,
460 'message' => sprintf( 'Requires "%s" to complete first.', $dep_migration ? $dep_migration['title'] : $migration['depends_on'] ),
461 );
462 }
463 }
464
465 // Reset status for re-running.
466 if ( $migration['status_option'] ) {
467 delete_option( $migration['status_option'] );
468 }
469 if ( $migration['batched'] && ! empty( $migration['progress'] ) ) {
470 foreach ( $migration['progress'] as $option ) {
471 delete_option( $option );
472 }
473 }
474
475 // Call the migration method.
476 $method = $migration['method'];
477 if ( method_exists( __CLASS__, $method ) ) {
478 self::$method();
479
480 // If the method returned without setting the status option
481 // (e.g. nothing to migrate, or required plugin not active),
482 // mark the migration as completed.
483 if ( $migration['status_option'] && ! get_option( $migration['status_option'] ) ) {
484 update_option( $migration['status_option'], true, false );
485 }
486
487 return array(
488 'success' => true,
489 'message' => 'Migration started: ' . $migration['title'],
490 'batched' => $migration['batched'],
491 );
492 }
493
494 return array(
495 'success' => false,
496 'message' => 'Migration method not found: ' . $method,
497 );
498 }
499
500 /**
501 * Process a single batch for a migration.
502 *
503 * @param string $id The migration ID.
504 * @return array Result of the batch processing.
505 */
506 public static function process_batch( $id ) {
507 $migration = self::get_migration( $id );
508 if ( ! $migration ) {
509 return array(
510 'success' => false,
511 'message' => 'Migration not found: ' . $id,
512 );
513 }
514
515 if ( ! $migration['batched'] || empty( $migration['batch_method'] ) ) {
516 return array(
517 'success' => false,
518 'message' => 'Migration is not batched: ' . $id,
519 );
520 }
521
522 $batch_method = $migration['batch_method'];
523 if ( ! method_exists( __CLASS__, $batch_method ) ) {
524 return array(
525 'success' => false,
526 'message' => 'Batch method not found: ' . $batch_method,
527 );
528 }
529
530 // Clear any scheduled cron for this batch to avoid double-processing.
531 if ( ! empty( $migration['cron_hook'] ) ) {
532 wp_clear_scheduled_hook( $migration['cron_hook'] );
533 }
534
535 // Process the batch.
536 self::$batch_method();
537
538 return array(
539 'success' => true,
540 'message' => 'Batch processed',
541 );
542 }
543
544 /**
545 * AJAX handler for clearing failed URLs to allow retry.
546 */
547 public function ajax_clear_failed_url() {
548 check_ajax_referer( 'friends_clear_failed_url' );
549
550 if ( ! current_user_can( 'manage_options' ) ) {
551 wp_send_json_error( 'Unauthorized' );
552 }
553
554 $url = isset( $_POST['url'] ) ? sanitize_text_field( wp_unslash( $_POST['url'] ) ) : '';
555
556 if ( '__all__' === $url ) {
557 // Clear all failed URLs.
558 delete_option( 'friends_ap_feeds_failed_urls' );
559 delete_option( 'friends_ap_feeds_failed_count' );
560 delete_option( 'friends_ap_feeds_link_migration_failed_urls' );
561 delete_option( 'friends_ap_feeds_link_migration_failed' );
562 delete_option( 'friends_ap_feeds_link_migration_current_url' );
563 delete_option( 'friends_ap_feeds_linked_to_actors' );
564 wp_send_json_success( 'All failed URLs cleared' );
565 }
566
567 if ( empty( $url ) ) {
568 wp_send_json_error( 'No URL provided' );
569 }
570
571 // Remove the specific URL from both failed URL lists.
572 $failed_urls = get_option( 'friends_ap_feeds_failed_urls', array() );
573 $failed_urls = array_filter(
574 $failed_urls,
575 function ( $item ) use ( $url ) {
576 return $item['url'] !== $url;
577 }
578 );
579 update_option( 'friends_ap_feeds_failed_urls', array_values( $failed_urls ), false );
580 update_option( 'friends_ap_feeds_failed_count', count( $failed_urls ), false );
581
582 $in_progress_failed = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
583 $in_progress_failed = array_filter(
584 $in_progress_failed,
585 function ( $item ) use ( $url ) {
586 return $item['url'] !== $url;
587 }
588 );
589 update_option( 'friends_ap_feeds_link_migration_failed_urls', array_values( $in_progress_failed ), false );
590 update_option( 'friends_ap_feeds_link_migration_failed', count( $in_progress_failed ), false );
591
592 // Clear the current URL marker if it matches.
593 if ( get_option( 'friends_ap_feeds_link_migration_current_url' ) === $url ) {
594 delete_option( 'friends_ap_feeds_link_migration_current_url' );
595 }
596
597 // Reset migration completed flag so it can run again.
598 delete_option( 'friends_ap_feeds_linked_to_actors' );
599
600 wp_send_json_success( 'URL cleared: ' . $url );
601 }
602
603 /**
604 * AJAX handler for deactivating a feed by URL.
605 */
606 public function ajax_deactivate_feed_by_url() {
607 check_ajax_referer( 'friends_deactivate_feed' );
608
609 if ( ! current_user_can( 'manage_options' ) ) {
610 wp_send_json_error( 'Unauthorized' );
611 }
612
613 $url = isset( $_POST['url'] ) ? sanitize_text_field( wp_unslash( $_POST['url'] ) ) : '';
614 if ( empty( $url ) ) {
615 wp_send_json_error( 'No URL provided' );
616 }
617
618 // Find the feed by URL.
619 $feed = User_Feed::get_by_url( $url );
620 if ( ! $feed ) {
621 wp_send_json_error( 'Feed not found: ' . $url );
622 }
623
624 // Deactivate the feed.
625 $feed->update_metadata( 'active', false );
626
627 // Also clear from failed URLs list.
628 $failed_urls = get_option( 'friends_ap_feeds_failed_urls', array() );
629 $failed_urls = array_filter(
630 $failed_urls,
631 function ( $item ) use ( $url ) {
632 return $item['url'] !== $url;
633 }
634 );
635 update_option( 'friends_ap_feeds_failed_urls', array_values( $failed_urls ), false );
636 update_option( 'friends_ap_feeds_failed_count', count( $failed_urls ), false );
637
638 $in_progress_failed = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
639 $in_progress_failed = array_filter(
640 $in_progress_failed,
641 function ( $item ) use ( $url ) {
642 return $item['url'] !== $url;
643 }
644 );
645 update_option( 'friends_ap_feeds_link_migration_failed_urls', array_values( $in_progress_failed ), false );
646
647 wp_send_json_success( 'Feed deactivated: ' . $url );
648 }
649
650 /**
651 * AJAX handler for running a migration.
652 */
653 public function ajax_run_migration() {
654 if ( ! current_user_can( 'manage_options' ) ) {
655 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
656 }
657
658 if ( ! check_ajax_referer( 'friends-run-migration', '_wpnonce', false ) ) {
659 wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
660 }
661
662 $migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
663 if ( empty( $migration_id ) ) {
664 wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
665 }
666
667 $result = self::run_migration( $migration_id );
668
669 if ( $result['success'] ) {
670 wp_send_json_success( $result );
671 } else {
672 wp_send_json_error( $result );
673 }
674 }
675
676 /**
677 * AJAX handler for undoing a migration.
678 */
679 public function ajax_undo_migration() {
680 if ( ! current_user_can( 'manage_options' ) ) {
681 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
682 }
683
684 if ( ! check_ajax_referer( 'friends-undo-migration', '_wpnonce', false ) ) {
685 wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
686 }
687
688 $migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
689 if ( empty( $migration_id ) ) {
690 wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
691 }
692
693 $result = self::undo_migration( $migration_id );
694
695 if ( $result['success'] ) {
696 $status = self::get_migration_status_by_id( $migration_id );
697
698 ob_start();
699 self::render_status_badge( $status );
700 $html = ob_get_clean();
701
702 $result['html'] = $html;
703 wp_send_json_success( $result );
704 } else {
705 wp_send_json_error( $result );
706 }
707 }
708
709 /**
710 * Undo a specific migration.
711 *
712 * @param string $id The migration ID.
713 * @return array Result of the undo.
714 */
715 public static function undo_migration( $id ) {
716 $migration = self::get_migration( $id );
717 if ( ! $migration ) {
718 return array(
719 'success' => false,
720 'message' => 'Migration not found: ' . $id,
721 );
722 }
723
724 if ( empty( $migration['undo_method'] ) ) {
725 return array(
726 'success' => false,
727 'message' => 'Migration does not support undo: ' . $id,
728 );
729 }
730
731 $undo_method = $migration['undo_method'];
732 if ( ! method_exists( __CLASS__, $undo_method ) ) {
733 return array(
734 'success' => false,
735 'message' => 'Undo method not found: ' . $undo_method,
736 );
737 }
738
739 $result = self::$undo_method();
740
741 if ( is_wp_error( $result ) ) {
742 return array(
743 'success' => false,
744 'message' => $result->get_error_message(),
745 );
746 }
747
748 // Reset completion status.
749 if ( $migration['status_option'] ) {
750 delete_option( $migration['status_option'] );
751 }
752
753 return array(
754 'success' => true,
755 'message' => 'Migration undone: ' . $migration['title'],
756 'details' => $result,
757 );
758 }
759
760 /**
761 * Check if a migration can be undone.
762 *
763 * @param string $id The migration ID.
764 * @return bool Whether the migration can be undone.
765 */
766 public static function can_undo_migration( $id ) {
767 $migration = self::get_migration( $id );
768 if ( ! $migration || empty( $migration['undo_method'] ) ) {
769 return false;
770 }
771
772 if ( 'post_tags_to_friend_tags' === $id ) {
773 $recovery = get_option( 'friends_tag_migration_recovery', array() );
774 return ! empty( $recovery );
775 }
776
777 if ( 'convert_replies_to_comments' === $id ) {
778 global $wpdb;
779 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
780 $count = $wpdb->get_var(
781 $wpdb->prepare(
782 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
783 '_redirects_to_comment'
784 )
785 );
786 return $count > 0;
787 }
788
789 return false;
790 }
791
792 /**
793 * AJAX handler for getting migration status.
794 */
795 public function ajax_get_migration_status() {
796 if ( ! current_user_can( 'manage_options' ) ) {
797 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
798 }
799
800 if ( ! check_ajax_referer( 'friends-migration-status', '_wpnonce', false ) ) {
801 wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
802 }
803
804 $migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
805 if ( empty( $migration_id ) ) {
806 wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
807 }
808
809 $status = self::get_migration_status_by_id( $migration_id );
810
811 ob_start();
812 self::render_status_badge( $status );
813 $html = ob_get_clean();
814
815 wp_send_json_success(
816 array(
817 'status' => $status,
818 'html' => $html,
819 'in_progress' => ! empty( $status['in_progress'] ),
820 )
821 );
822 }
823
824 /**
825 * AJAX handler for processing a migration batch.
826 */
827 public function ajax_process_migration_batch() {
828 if ( ! current_user_can( 'manage_options' ) ) {
829 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
830 }
831
832 if ( ! check_ajax_referer( 'friends-process-batch', '_wpnonce', false ) ) {
833 wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
834 }
835
836 $migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
837 if ( empty( $migration_id ) ) {
838 wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
839 }
840
841 $result = self::process_batch( $migration_id );
842
843 if ( ! $result['success'] ) {
844 wp_send_json_error( $result );
845 }
846
847 // Get updated status.
848 $status = self::get_migration_status_by_id( $migration_id );
849
850 ob_start();
851 self::render_status_badge( $status );
852 $html = ob_get_clean();
853
854 wp_send_json_success(
855 array(
856 'status' => $status,
857 'html' => $html,
858 'in_progress' => ! empty( $status['in_progress'] ),
859 )
860 );
861 }
862
863 /**
864 * AJAX handler for getting migration debug output.
865 */
866 public function ajax_get_migration_debug() {
867 if ( ! current_user_can( 'manage_options' ) ) {
868 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
869 }
870
871 if ( ! check_ajax_referer( 'friends-migration-debug', '_wpnonce', false ) ) {
872 wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
873 }
874
875 $migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
876 if ( empty( $migration_id ) ) {
877 wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
878 }
879
880 $status = self::get_migration_status_by_id( $migration_id );
881
882 ob_start();
883 /**
884 * Hook to output debug content for a specific migration via AJAX.
885 *
886 * @param array $status The migration status.
887 */
888 do_action( 'friends_migration_debug_' . $migration_id, $status );
889 $html = ob_get_clean();
890
891 if ( empty( $html ) ) {
892 wp_send_json_error( array( 'message' => __( 'No debug handler registered for this migration.', 'friends' ) ) );
893 }
894
895 wp_send_json_success( array( 'html' => $html ) );
896 }
897
898 /**
899 * Render a migration status badge.
900 *
901 * @param array $status The migration status.
902 */
903 public static function render_status_badge( $status ) {
904 if ( ! empty( $status['in_progress'] ) ) {
905 echo '<span class="status-badge status-in-progress">' . esc_html__( 'In Progress', 'friends' ) . '</span>';
906 if ( isset( $status['percent'] ) ) {
907 echo '<div class="progress-bar"><div class="progress-bar-fill" style="width: ' . esc_attr( $status['percent'] ) . '%;"></div></div>';
908 echo '<small>' . esc_html( $status['processed'] ) . ' / ' . esc_html( $status['total'] ) . '</small>';
909 }
910 // Show next scheduled time if available.
911 if ( ! empty( $status['next_scheduled'] ) ) {
912 echo '<br><small>' . esc_html(
913 sprintf(
914 /* translators: %s is a relative time like "in 5 seconds" */
915 __( 'Next batch: %s', 'friends' ),
916 human_time_diff( time(), $status['next_scheduled'] )
917 )
918 ) . '</small>';
919 } else {
920 echo '<br><small class="warning">' . esc_html__( 'No batch scheduled!', 'friends' ) . '</small>';
921 }
922 } elseif ( $status['completed'] ) {
923 if ( ! empty( $status['no_tracking'] ) ) {
924 echo '<span class="status-badge status-no-tracking">' . esc_html__( 'Completed', 'friends' ) . '</span>';
925 echo '<br><small>' . esc_html__( '(no tracking)', 'friends' ) . '</small>';
926 } else {
927 echo '<span class="status-badge status-completed">' . esc_html__( 'Completed', 'friends' ) . '</span>';
928 }
929 } else {
930 echo '<span class="status-badge status-pending">' . esc_html__( 'Pending', 'friends' ) . '</span>';
931 }
932 }
933
934 /**
935 * Migrate user gravatar option to user_icon_url (version 0.20.1)
936 */
937 public static function migrate_gravatar_to_user_icon_url() {
938 $users = User_Query::all_associated_users();
939 foreach ( $users->get_results() as $user ) {
940 $gravatar = get_user_option( 'friends_gravatar', $user->ID );
941 $user_icon_url = get_user_option( 'friends_user_icon_url', $user->ID );
942 if ( $gravatar ) {
943 if ( ! $user_icon_url ) {
944 update_user_option( $user->ID, 'friends_user_icon_url', $gravatar );
945 }
946 delete_user_option( $user->ID, 'friends_gravatar' );
947 }
948 }
949 }
950
951 /**
952 * Migrate feed options from global to user options (version 2.6.0)
953 */
954 public static function migrate_feed_options_to_user_options() {
955 $users = User_Query::all_associated_users();
956 foreach ( $users->get_results() as $user ) {
957 if ( get_option( 'friends_feed_rules_' . $user->ID ) ) {
958 $user->update_user_option( 'friends_feed_rules', get_option( 'friends_feed_rules_' . $user->ID ) );
959 }
960 if ( get_option( 'friends_feed_catch_all_' . $user->ID ) ) {
961 $user->update_user_option( 'friends_feed_catch_all', get_option( 'friends_feed_catch_all_' . $user->ID ) );
962 }
963 }
964 }
965
966 /**
967 * Enable WordPress friendships flag if used (version 2.8.7)
968 */
969 public static function enable_wp_friendships_if_used() {
970 $users = User_Query::all_associated_users();
971 foreach ( $users->get_results() as $user ) {
972 if ( ! ( $user instanceof Subscription ) ) {
973 // We have a user that is not a virtual user, so the friendship functionality had been used.
974 update_option( 'friends_enable_wp_friendships', 1 );
975 break;
976 }
977 }
978 }
979
980 /**
981 * Migrate external mentions user and upgrade cron schedule (version 2.9.4)
982 */
983 public static function migrate_external_user_and_cron() {
984 // Migrate to the new External user.
985 $user = User::get_by_username( 'external-mentions' );
986 if ( $user && $user instanceof Subscription ) {
987 wp_update_term(
988 $user->get_term_id(),
989 Subscription::TAXONOMY,
990 array(
991 'slug' => 'external',
992 'name' => _x( 'External', 'user name', 'friends' ),
993 )
994 );
995 $user->update_user_option( 'display_name', _x( 'External', 'user name', 'friends' ) );
996 }
997
998 // Upgrade cron schedule.
999 $next_scheduled = wp_next_scheduled( 'cron_friends_refresh_feeds' );
1000 if ( $next_scheduled ) {
1001 $event = wp_get_scheduled_event( 'cron_friends_refresh_feeds' );
1002 if ( $event && 'fifteen-minutes' !== $event->schedule ) {
1003 wp_unschedule_event( $next_scheduled, 'cron_friends_refresh_feeds' );
1004 $next_scheduled = false;
1005 }
1006 }
1007 if ( ! $next_scheduled ) {
1008 wp_schedule_event( time(), 'fifteen-minutes', 'cron_friends_refresh_feeds' );
1009 }
1010 }
1011
1012 /**
1013 * Migrate frontend default view option to user option (version 3.1.8)
1014 */
1015 public static function migrate_frontend_default_view_to_user_option() {
1016 $users = User_Query::all_admin_users();
1017 foreach ( $users->get_results() as $user ) {
1018 $default_view = get_option( 'friends_frontend_default_view' );
1019 if ( $default_view ) {
1020 $user->update_user_option( 'friends_frontend_default_view', $default_view );
1021 }
1022 }
1023 }
1024
1025 /**
1026 * Migrate post_tag taxonomy to friend_tag taxonomy for Friends CPT posts (version 4.0.0)
1027 * Initiates batched migration for large datasets.
1028 */
1029 public static function migrate_post_tags_to_friend_tags() {
1030 // Check if migration is already in progress.
1031 if ( get_option( 'friends_tag_migration_in_progress' ) ) {
1032 return;
1033 }
1034
1035 // Check if migration has already been completed.
1036 if ( get_option( 'friends_tag_migration_completed' ) ) {
1037 return;
1038 }
1039
1040 // Count total posts to migrate.
1041 global $wpdb;
1042 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1043 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1044 $total_posts = $wpdb->get_var(
1045 $wpdb->prepare(
1046 "SELECT COUNT(DISTINCT p.ID)
1047 FROM {$wpdb->posts} p
1048 INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
1049 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1050 WHERE p.post_type = %s AND tt.taxonomy = 'post_tag'",
1051 Friends::CPT
1052 )
1053 );
1054 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1055 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1056
1057 if ( ! $total_posts ) {
1058 return;
1059 }
1060
1061 // Set migration progress tracking (not autoloaded).
1062 update_option( 'friends_tag_migration_in_progress', true, false );
1063 update_option( 'friends_tag_migration_total', $total_posts, false );
1064 update_option( 'friends_tag_migration_processed', 0, false );
1065 update_option( 'friends_tag_migration_recovery', array(), false );
1066
1067 // Schedule the first batch.
1068 wp_schedule_single_event( time(), 'friends_migrate_post_tags_batch' );
1069 }
1070
1071 /**
1072 * Process a single batch of post tag migration.
1073 */
1074 public static function migrate_post_tags_batch() {
1075 global $wpdb;
1076
1077 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1078 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1079
1080 $batch_size = apply_filters( 'friends_tag_migration_batch_size', 100 );
1081
1082 // Always query from offset 0: processed relationships are deleted each batch,
1083 // so the next unprocessed batch is always at the start of the result set.
1084 $friends_posts_with_tags = $wpdb->get_results(
1085 $wpdb->prepare(
1086 "SELECT p.ID, tr.term_taxonomy_id, t.term_id, t.name, t.slug
1087 FROM {$wpdb->posts} p
1088 INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
1089 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1090 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
1091 WHERE p.post_type = %s AND tt.taxonomy = 'post_tag'
1092 ORDER BY p.ID, t.term_id
1093 LIMIT %d OFFSET 0",
1094 Friends::CPT,
1095 $batch_size
1096 )
1097 );
1098
1099 if ( empty( $friends_posts_with_tags ) ) {
1100 // Migration complete - run cleanup.
1101 self::finalize_post_tag_migration();
1102 return;
1103 }
1104
1105 // Group posts by ID to handle multiple tags per post.
1106 $posts_by_id = array();
1107 foreach ( $friends_posts_with_tags as $tagged_post ) {
1108 if ( ! isset( $posts_by_id[ $tagged_post->ID ] ) ) {
1109 $posts_by_id[ $tagged_post->ID ] = array();
1110 }
1111 $posts_by_id[ $tagged_post->ID ][] = $tagged_post;
1112 }
1113
1114 // Process each post with all its tags.
1115 $term_taxonomy_ids = array();
1116 $term_ids_to_check = array();
1117
1118 foreach ( $posts_by_id as $post_id => $post_tags ) {
1119 // Collect all tag names for this post.
1120 $tag_names = array();
1121 foreach ( $post_tags as $tagged_post ) {
1122 $tag_names[] = $tagged_post->name;
1123 $term_taxonomy_ids[] = $tagged_post->term_taxonomy_id;
1124 $term_ids_to_check[] = $tagged_post->term_id;
1125 }
1126
1127 // Add all friend_tags to this post at once.
1128 wp_set_post_terms( $post_id, $tag_names, Friends::TAG_TAXONOMY, true );
1129 }
1130
1131 // Store recovery data before deleting relationships.
1132 $recovery = get_option( 'friends_tag_migration_recovery', array() );
1133 foreach ( $posts_by_id as $post_id => $post_tags ) {
1134 $recovery[ $post_id ] = wp_list_pluck( $post_tags, 'name' );
1135 }
1136 update_option( 'friends_tag_migration_recovery', $recovery, false );
1137
1138 // Bulk delete post_tag relationships for this batch of Friends posts.
1139 if ( ! empty( $term_taxonomy_ids ) ) {
1140 $processed_post_ids = array_keys( $posts_by_id );
1141 $post_placeholders = implode( ',', array_fill( 0, count( $processed_post_ids ), '%d' ) );
1142 $term_placeholders = implode( ',', array_fill( 0, count( $term_taxonomy_ids ), '%d' ) );
1143
1144 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1145 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
1146 $wpdb->query(
1147 $wpdb->prepare(
1148 "DELETE FROM {$wpdb->term_relationships}
1149 WHERE object_id IN ($post_placeholders)
1150 AND term_taxonomy_id IN ($term_placeholders)",
1151 array_merge( $processed_post_ids, $term_taxonomy_ids )
1152 )
1153 );
1154 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1155 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
1156 }
1157
1158 // Update progress.
1159 $processed = (int) get_option( 'friends_tag_migration_processed', 0 );
1160 $processed += count( $posts_by_id );
1161 update_option( 'friends_tag_migration_processed', $processed, false );
1162
1163 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1164 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1165
1166 // Schedule next batch.
1167 wp_schedule_single_event( time() + 1, 'friends_migrate_post_tags_batch' );
1168 }
1169
1170 /**
1171 * Finalize post tag migration by cleaning up orphaned terms.
1172 */
1173 public static function finalize_post_tag_migration() {
1174 global $wpdb;
1175
1176 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1177 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1178
1179 // Get all post_tag terms that were potentially used by Friends posts.
1180 $all_post_tag_terms = $wpdb->get_results(
1181 "SELECT DISTINCT t.term_id
1182 FROM {$wpdb->terms} t
1183 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1184 WHERE tt.taxonomy = 'post_tag'"
1185 );
1186
1187 // Clean up orphaned post_tag terms.
1188 foreach ( $all_post_tag_terms as $term_data ) {
1189 // Calculate count excluding revisions, ap_actor, post_collection, and Friends CPT.
1190 $real_count = $wpdb->get_var(
1191 $wpdb->prepare(
1192 "SELECT COUNT(DISTINCT p.ID)
1193 FROM {$wpdb->posts} p
1194 INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
1195 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1196 WHERE tt.term_id = %d
1197 AND tt.taxonomy = 'post_tag'
1198 AND p.post_status IN ('publish', 'private', 'draft', 'pending', 'future')
1199 AND p.post_type NOT IN ('revision', 'ap_actor', 'post_collection', %s)",
1200 $term_data->term_id,
1201 Friends::CPT
1202 )
1203 );
1204
1205 if ( 0 === (int) $real_count ) {
1206 wp_delete_term( $term_data->term_id, 'post_tag' );
1207 }
1208 }
1209
1210 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1211 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1212
1213 // Clear migration progress flags.
1214 delete_option( 'friends_tag_migration_in_progress' );
1215 delete_option( 'friends_tag_migration_total' );
1216 delete_option( 'friends_tag_migration_processed' );
1217
1218 // Mark migration as completed (not autoloaded).
1219 update_option( 'friends_tag_migration_completed', time(), false );
1220
1221 // Log completion.
1222 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1223 error_log( 'Friends: Post tag migration completed successfully.' );
1224 }
1225
1226 /**
1227 * Get migration status information.
1228 *
1229 * @return array Array with migration status details.
1230 */
1231 public static function get_migration_status() {
1232 $status = array(
1233 'completed' => (bool) get_option( 'friends_tag_migration_completed' ),
1234 'in_progress' => (bool) get_option( 'friends_tag_migration_in_progress' ),
1235 'total' => (int) get_option( 'friends_tag_migration_total', 0 ),
1236 'processed' => (int) get_option( 'friends_tag_migration_processed', 0 ),
1237 'completed_time' => get_option( 'friends_tag_migration_completed' ),
1238 );
1239
1240 if ( $status['total'] > 0 && $status['processed'] > 0 ) {
1241 $status['progress_percent'] = min( 100, round( ( $status['processed'] / $status['total'] ) * 100, 1 ) );
1242 } else {
1243 $status['progress_percent'] = 0;
1244 }
1245
1246 return $status;
1247 }
1248
1249 /**
1250 * Reset migration status to allow re-running migration.
1251 * Useful for manual triggers or development.
1252 */
1253 public static function reset_migration_status() {
1254 delete_option( 'friends_tag_migration_completed' );
1255 delete_option( 'friends_tag_migration_in_progress' );
1256 delete_option( 'friends_tag_migration_total' );
1257 delete_option( 'friends_tag_migration_processed' );
1258 delete_option( 'friends_tag_migration_offset' );
1259
1260 // Clear any scheduled migration events.
1261 wp_clear_scheduled_hook( 'friends_migrate_post_tags_batch' );
1262 }
1263
1264 /**
1265 * Manually trigger post tag migration.
1266 * Resets status first to allow re-running.
1267 */
1268 public static function trigger_migration_manually() {
1269 self::reset_migration_status();
1270 self::migrate_post_tags_to_friend_tags();
1271 }
1272
1273 /**
1274 * Clean up orphaned post_tag terms that exist in friend_tag taxonomy.
1275 * These are post_tag terms that were originally created by Friends posts
1276 * but are now orphaned after migration to friend_tag taxonomy.
1277 *
1278 * @return array Cleanup results with counts.
1279 */
1280 public static function cleanup_orphaned_post_tags() {
1281 // Use the comprehensive cleanup approach instead of Friends-focused.
1282 return self::recalculate_all_post_tag_counts();
1283 }
1284
1285 /**
1286 * Recalculate post_tag counts and cleanup orphaned tags.
1287 * This recalculates counts for ALL post_tag terms excluding Friends CPT posts,
1288 * and removes any with zero count after recalculation.
1289 *
1290 * @return array Cleanup results with counts.
1291 */
1292 public static function recalculate_all_post_tag_counts() {
1293 global $wpdb;
1294
1295 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1296 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1297
1298 // Get ALL post_tag terms (not just those with friend_tag equivalents)
1299 $all_post_tags = $wpdb->get_results(
1300 "SELECT t.term_id, t.name, t.slug, tt.count
1301 FROM {$wpdb->terms} t
1302 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1303 WHERE tt.taxonomy = 'post_tag'"
1304 );
1305
1306 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1307 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1308
1309 $cleanup_results = array(
1310 'checked' => 0,
1311 'recalculated' => 0,
1312 'deleted' => 0,
1313 'tags_processed' => array(),
1314 );
1315
1316 foreach ( $all_post_tags as $tag_data ) {
1317 ++$cleanup_results['checked'];
1318
1319 $old_count = $tag_data->count;
1320
1321 // Calculate the REAL count excluding revisions, ap_actor, post_collection, and Friends CPT.
1322 global $wpdb;
1323
1324 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1325 $real_count = $wpdb->get_var(
1326 $wpdb->prepare(
1327 "SELECT COUNT(DISTINCT p.ID)
1328 FROM {$wpdb->posts} p
1329 INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
1330 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
1331 WHERE tt.term_id = %d
1332 AND tt.taxonomy = 'post_tag'
1333 AND p.post_status IN ('publish', 'private', 'draft', 'pending', 'future')
1334 AND p.post_type NOT IN ('revision', 'ap_actor', 'post_collection', %s)",
1335 $tag_data->term_id,
1336 Friends::CPT
1337 )
1338 );
1339 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1340
1341 // Update the term count in the database to reflect reality (excluding Friends posts and other irrelevant types)
1342 $update_result = $wpdb->update(
1343 $wpdb->term_taxonomy,
1344 array( 'count' => $real_count ),
1345 array(
1346 'term_id' => $tag_data->term_id,
1347 'taxonomy' => 'post_tag',
1348 )
1349 );
1350 // Clear term cache so get_term() returns the updated count.
1351 clean_term_cache( $tag_data->term_id, 'post_tag' );
1352
1353 ++$cleanup_results['recalculated'];
1354 $new_count = (int) $real_count;
1355
1356 // Log if count changed or if we're about to delete.
1357 if ( $old_count !== $new_count || 0 === $new_count ) {
1358 $cleanup_results['tags_processed'][] = array(
1359 'name' => $tag_data->name,
1360 'slug' => $tag_data->slug,
1361 'old_count' => $old_count,
1362 'new_count' => $new_count,
1363 'action' => 0 === $new_count ? 'deleted' : 'count_updated',
1364 'posts_using' => $new_count,
1365 );
1366 }
1367
1368 // If count is 0 after recalculation, delete the orphaned post_tag.
1369 if ( 0 === $new_count ) {
1370 $deleted = wp_delete_term( $tag_data->term_id, 'post_tag' );
1371 if ( ! is_wp_error( $deleted ) && $deleted ) {
1372 ++$cleanup_results['deleted'];
1373 }
1374 }
1375 }
1376
1377 return $cleanup_results;
1378 }
1379
1380 /**
1381 * Backfill mention tags from Mastodon HTML content (version 4.1.0)
1382 */
1383 public static function backfill_mention_tags_from_mastodon_html() {
1384 global $wpdb;
1385
1386 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1387 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1388
1389 // Build lookup table of local user ActivityPub URLs before processing posts.
1390 $local_user_urls = self::build_local_user_activitypub_lookup();
1391
1392 if ( empty( $local_user_urls ) ) {
1393 update_option( 'friends_backfill_mention_tags_completed', true, false );
1394 return;
1395 }
1396
1397 // Get all Friends posts that might contain Mastodon mentions in HTML.
1398 $posts_with_html = $wpdb->get_results(
1399 $wpdb->prepare(
1400 "SELECT ID, post_content
1401 FROM {$wpdb->posts}
1402 WHERE post_type = %s
1403 AND post_status IN ('publish', 'private')
1404 AND post_content LIKE %s",
1405 Friends::CPT,
1406 '%u-url mention%'
1407 )
1408 );
1409
1410 if ( empty( $posts_with_html ) ) {
1411 update_option( 'friends_backfill_mention_tags_completed', true, false );
1412 return;
1413 }
1414
1415 $processed_count = 0;
1416 $mention_tags_added = 0;
1417
1418 foreach ( $posts_with_html as $post ) {
1419 $mention_tags = self::extract_mention_tags_from_html( $post->post_content, $local_user_urls );
1420
1421 if ( ! empty( $mention_tags ) ) {
1422 wp_set_post_terms( $post->ID, $mention_tags, Friends::TAG_TAXONOMY, true );
1423 $mention_tags_added += count( $mention_tags );
1424 }
1425
1426 ++$processed_count;
1427 }
1428
1429 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1430 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1431
1432 if ( $processed_count > 0 ) {
1433 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1434 error_log( sprintf( 'Friends Migration: Processed %d posts, added %d mention tags', $processed_count, $mention_tags_added ) );
1435 }
1436
1437 update_option( 'friends_backfill_mention_tags_completed', true, false );
1438 }
1439
1440 /**
1441 * Build a lookup table of local user ActivityPub URLs to usernames
1442 *
1443 * @return array Array mapping ActivityPub URLs to usernames
1444 */
1445 private static function build_local_user_activitypub_lookup() {
1446 $lookup = array();
1447
1448 // Check if ActivityPub plugin is available.
1449 if ( ! class_exists( '\Activitypub\Collection\Actors' ) ) {
1450 return $lookup;
1451 }
1452
1453 $users = get_users();
1454 foreach ( $users as $user ) {
1455 try {
1456 // Use ActivityPub plugin to get the proper actor URL.
1457 $actor = \Activitypub\Collection\Actors::get_by_id( $user->ID );
1458 if ( ! is_wp_error( $actor ) && $actor ) {
1459 $activitypub_url = $actor->get_id();
1460 if ( $activitypub_url ) {
1461 $lookup[ $activitypub_url ] = $user->user_login;
1462 }
1463 }
1464 } catch ( Exception $e ) {
1465 // Skip this user if there's an error getting their ActivityPub ID.
1466 continue;
1467 }
1468 }
1469
1470 return $lookup;
1471 }
1472
1473 /**
1474 * Extract mention tags from Mastodon HTML content
1475 *
1476 * @param string $html_content The HTML content to parse.
1477 * @param array $local_user_urls Lookup table of local user URLs to usernames.
1478 * @return array Array of mention tag names
1479 */
1480 private static function extract_mention_tags_from_html( $html_content, $local_user_urls ) {
1481 $mention_tags = array();
1482
1483 // Parse HTML to find mention links:
1484 // Pattern: <a href="ACTIVITYPUB_URL" class="u-url mention">@<span>USERNAME</span></a>.
1485 $pattern = '/<a\s+[^>]*href=["\'](https?:\/\/[^"\']+)["\'][^>]*class=["\'][^"\']*u-url mention[^"\']*["\'][^>]*>@<span>([^<]+)<\/span><\/a>/i';
1486
1487 if ( preg_match_all( $pattern, $html_content, $matches, PREG_SET_ORDER ) ) {
1488 foreach ( $matches as $match ) {
1489 $activitypub_url = $match[1];
1490
1491 // Look up the URL in our pre-built lookup table.
1492 if ( isset( $local_user_urls[ $activitypub_url ] ) ) {
1493 $username = $local_user_urls[ $activitypub_url ];
1494 $mention_tags[] = Friend_Tag::mention_tag( $username );
1495 }
1496 }
1497 }
1498
1499 return array_unique( $mention_tags );
1500 }
1501
1502 /**
1503 * Import existing followings from the ActivityPub plugin (version 4.1.0)
1504 *
1505 * This method runs on upgrade and imports any existing follows from
1506 * the ActivityPub plugin that don't already exist in Friends.
1507 */
1508 public static function import_activitypub_followings() {
1509 // Check if the Following class is available (ActivityPub plugin 7.x+).
1510 if ( ! class_exists( '\Activitypub\Collection\Following' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
1511 return;
1512 }
1513
1514 // Check if already imported.
1515 if ( get_option( 'friends_activitypub_followings_imported' ) ) {
1516 return;
1517 }
1518
1519 // Get the ActivityPub user ID.
1520 $user_id = Feed_Parser_ActivityPub::get_activitypub_actor_id( Friends::get_main_friend_user_id() );
1521
1522 // Get all followings from ActivityPub.
1523 $actors = \Activitypub\Collection\Following::get_all( $user_id );
1524 if ( is_wp_error( $actors ) || ! is_array( $actors ) ) {
1525 update_option( 'friends_activitypub_followings_imported', true, false );
1526 return;
1527 }
1528
1529 $imported = 0;
1530 $feed_parser = new Feed_Parser_ActivityPub( Friends::get_instance()->feed );
1531
1532 foreach ( $actors as $actor ) {
1533 if ( ! $actor instanceof \WP_Post ) {
1534 continue;
1535 }
1536
1537 // Get URL from the post's guid field (canonical actor URL).
1538 $actor_url = $actor->guid;
1539 if ( empty( $actor_url ) || ! is_string( $actor_url ) ) {
1540 continue;
1541 }
1542
1543 // Check if a Friends subscription already exists for this actor.
1544 $existing_feed = User_Feed::get_by_url( $actor_url );
1545 if ( $existing_feed instanceof User_Feed ) {
1546 continue;
1547 }
1548
1549 // Create a new Friends subscription for this actor.
1550 $result = $feed_parser->create_friend_subscription_from_actor( $actor_url );
1551 if ( ! is_wp_error( $result ) ) {
1552 ++$imported;
1553 }
1554 }
1555
1556 update_option( 'friends_activitypub_followings_imported', true, false );
1557 update_option( 'friends_activitypub_followings_imported_count', $imported, false );
1558 }
1559
1560 /**
1561 * Migrate ActivityPub attributedTo from URL format to ap_actor post ID format (version 4.1.0)
1562 * Initiates batched migration for large datasets.
1563 */
1564 public static function migrate_activitypub_attributed_to() {
1565 // Check if the Remote_Actors class is available (ActivityPub plugin 7.x+).
1566 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
1567 return;
1568 }
1569
1570 // Check if migration is already in progress.
1571 if ( get_option( 'friends_ap_attributed_to_migration_in_progress' ) ) {
1572 return;
1573 }
1574
1575 // Check if migration has already been completed.
1576 if ( get_option( 'friends_ap_attributed_to_migration_completed' ) ) {
1577 return;
1578 }
1579
1580 // Count total posts to migrate (posts with attributedTo.id but not ap_actor_id).
1581 global $wpdb;
1582 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1583 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1584 $total_posts = $wpdb->get_var(
1585 $wpdb->prepare(
1586 "SELECT COUNT(*)
1587 FROM {$wpdb->postmeta}
1588 WHERE meta_key = %s
1589 AND meta_value LIKE %s
1590 AND meta_value NOT LIKE %s",
1591 Feed_Parser_ActivityPub::SLUG,
1592 '%"attributedTo"%"id"%',
1593 '%"ap_actor_id"%'
1594 )
1595 );
1596 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1597 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1598
1599 if ( ! $total_posts ) {
1600 // No posts to migrate.
1601 update_option( 'friends_ap_attributed_to_migration_completed', true, false );
1602 return;
1603 }
1604
1605 // Set migration progress tracking (not autoloaded).
1606 update_option( 'friends_ap_attributed_to_migration_in_progress', true, false );
1607 update_option( 'friends_ap_attributed_to_migration_total', $total_posts, false );
1608 update_option( 'friends_ap_attributed_to_migration_processed', 0, false );
1609
1610 // Schedule the first batch.
1611 wp_schedule_single_event( time(), 'friends_migrate_ap_attributed_to_batch' );
1612 }
1613
1614 /**
1615 * Process a single batch of attributedTo migration.
1616 */
1617 public static function migrate_activitypub_attributed_to_batch() {
1618 // Check if the Remote_Actors class is available.
1619 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1620 self::finalize_ap_attributed_to_migration();
1621 return;
1622 }
1623
1624 global $wpdb;
1625
1626 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1627 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1628
1629 $batch_size = apply_filters( 'friends_ap_attributed_to_migration_batch_size', 50 );
1630
1631 // Always query from offset 0: successfully migrated posts gain ap_actor_id and
1632 // drop out of the NOT LIKE filter. Failed posts get ap_actor_id=0 as sentinel.
1633 $posts_to_migrate = $wpdb->get_results(
1634 $wpdb->prepare(
1635 "SELECT post_id, meta_value
1636 FROM {$wpdb->postmeta}
1637 WHERE meta_key = %s
1638 AND meta_value LIKE %s
1639 AND meta_value NOT LIKE %s
1640 ORDER BY post_id
1641 LIMIT %d OFFSET 0",
1642 Feed_Parser_ActivityPub::SLUG,
1643 '%"attributedTo"%"id"%',
1644 '%"ap_actor_id"%',
1645 $batch_size
1646 )
1647 );
1648 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1649 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1650
1651 if ( empty( $posts_to_migrate ) ) {
1652 // Migration complete.
1653 self::finalize_ap_attributed_to_migration();
1654 return;
1655 }
1656
1657 $processed = (int) get_option( 'friends_ap_attributed_to_migration_processed', 0 );
1658
1659 foreach ( $posts_to_migrate as $row ) {
1660 $meta = maybe_unserialize( $row->meta_value );
1661
1662 if ( ! is_array( $meta ) || ! isset( $meta['attributedTo']['id'] ) ) {
1663 ++$processed;
1664 continue;
1665 }
1666
1667 $actor_url = $meta['attributedTo']['id'];
1668
1669 // Try to get or create the ap_actor post ID.
1670 $ap_actor_id = null;
1671 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1672 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
1673 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
1674 $ap_actor_id = $actor_post->ID;
1675 }
1676 }
1677
1678 if ( $ap_actor_id ) {
1679 // Update to new format.
1680 $meta['attributedTo']['ap_actor_id'] = $ap_actor_id;
1681 unset( $meta['attributedTo']['id'] );
1682 } else {
1683 // Store sentinel so this post is excluded from future queries.
1684 $meta['attributedTo']['ap_actor_id'] = 0;
1685 }
1686 update_post_meta( $row->post_id, Feed_Parser_ActivityPub::SLUG, $meta );
1687
1688 ++$processed;
1689 }
1690
1691 // Update progress.
1692 update_option( 'friends_ap_attributed_to_migration_processed', $processed, false );
1693
1694 // Schedule next batch.
1695 wp_schedule_single_event( time() + 1, 'friends_migrate_ap_attributed_to_batch' );
1696 }
1697
1698 /**
1699 * Finalize the attributedTo migration.
1700 */
1701 private static function finalize_ap_attributed_to_migration() {
1702 delete_option( 'friends_ap_attributed_to_migration_in_progress' );
1703 update_option( 'friends_ap_attributed_to_migration_completed', true, false );
1704 }
1705
1706 /**
1707 * Backfill cached ActivityPub actor handles for the latest 100 cached posts.
1708 */
1709 public static function backfill_activitypub_attributed_to_acct() {
1710 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
1711 update_option( 'friends_ap_attributed_to_acct_backfilled', true, false );
1712 return;
1713 }
1714
1715 if ( get_option( 'friends_ap_attributed_to_acct_backfilled' ) ) {
1716 return;
1717 }
1718
1719 global $wpdb;
1720
1721 $limit = 100;
1722
1723 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
1724 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
1725 $posts_to_migrate = $wpdb->get_results(
1726 $wpdb->prepare(
1727 "SELECT pm.post_id, pm.meta_value
1728 FROM {$wpdb->postmeta} pm
1729 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1730 WHERE p.post_type = %s
1731 AND p.post_status IN ( 'publish', 'private' )
1732 AND pm.meta_key = %s
1733 AND pm.meta_value LIKE %s
1734 AND pm.meta_value LIKE %s
1735 AND pm.meta_value NOT LIKE %s
1736 ORDER BY p.post_date_gmt DESC, p.ID DESC
1737 LIMIT %d",
1738 Friends::CPT,
1739 Feed_Parser_ActivityPub::SLUG,
1740 '%"attributedTo"%',
1741 '%"ap_actor_id"%',
1742 '%"acct"%',
1743 $limit
1744 )
1745 );
1746 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
1747 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
1748
1749 $updated = 0;
1750
1751 foreach ( $posts_to_migrate as $row ) {
1752 $meta = maybe_unserialize( $row->meta_value );
1753 if ( ! is_array( $meta ) || empty( $meta['attributedTo']['ap_actor_id'] ) || ! empty( $meta['attributedTo']['acct'] ) ) {
1754 continue;
1755 }
1756
1757 $acct = \Activitypub\Collection\Remote_Actors::get_acct( $meta['attributedTo']['ap_actor_id'] );
1758 if ( ! $acct ) {
1759 continue;
1760 }
1761
1762 $meta['attributedTo']['acct'] = ltrim( $acct, '@' );
1763 update_post_meta( $row->post_id, Feed_Parser_ActivityPub::SLUG, $meta );
1764 ++$updated;
1765 }
1766
1767 update_option( 'friends_ap_attributed_to_acct_backfilled', true, false );
1768 update_option( 'friends_ap_attributed_to_acct_backfilled_count', $updated, false );
1769 }
1770
1771 /**
1772 * Link existing ActivityPub feeds to their ap_actor posts (version 4.1.0)
1773 *
1774 * This migration finds all User_Feed entries with parser='activitypub'
1775 * and links them to their corresponding ap_actor posts by assigning
1776 * the User_Feed taxonomy term to the ap_actor post.
1777 */
1778 public static function link_activitypub_feeds_to_actors() {
1779 // Check if the Remote_Actors class is available (ActivityPub plugin 7.x+).
1780 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
1781 return;
1782 }
1783
1784 // Check if migration is already in progress.
1785 if ( get_option( 'friends_ap_feeds_link_migration_in_progress' ) ) {
1786 return;
1787 }
1788
1789 // Check if migration has already been completed.
1790 if ( get_option( 'friends_ap_feeds_linked_to_actors' ) ) {
1791 return;
1792 }
1793
1794 // Get all ActivityPub feeds to count them.
1795 $activitypub_feeds = User_Feed::get_by_parser( Feed_Parser_ActivityPub::SLUG );
1796
1797 if ( empty( $activitypub_feeds ) ) {
1798 update_option( 'friends_ap_feeds_linked_to_actors', true, false );
1799 return;
1800 }
1801
1802 $total = count( $activitypub_feeds );
1803
1804 // Set migration progress tracking (not autoloaded).
1805 update_option( 'friends_ap_feeds_link_migration_in_progress', true, false );
1806 update_option( 'friends_ap_feeds_link_migration_total', $total, false );
1807 update_option( 'friends_ap_feeds_link_migration_processed', 0, false );
1808 update_option( 'friends_ap_feeds_link_migration_linked', 0, false );
1809 update_option( 'friends_ap_feeds_link_migration_skipped', 0, false );
1810 update_option( 'friends_ap_feeds_link_migration_failed', 0, false );
1811 update_option( 'friends_ap_feeds_link_migration_failed_urls', array(), false );
1812
1813 // Clear any previous run's stats.
1814 delete_option( 'friends_ap_feeds_linked_count' );
1815 delete_option( 'friends_ap_feeds_skipped_count' );
1816 delete_option( 'friends_ap_feeds_failed_count' );
1817 delete_option( 'friends_ap_feeds_failed_urls' );
1818
1819 // Schedule the first batch.
1820 wp_schedule_single_event( time(), 'friends_link_ap_feeds_batch' );
1821 }
1822
1823 /**
1824 * Reduce ActivityPub HTTP timeout during migration.
1825 *
1826 * @return int Timeout in seconds.
1827 */
1828 public static function reduce_activitypub_timeout() {
1829 return 15; // 15 seconds instead of default 100.
1830 }
1831
1832 /**
1833 * Process a single batch of linking ActivityPub feeds to actors.
1834 */
1835 public static function link_activitypub_feeds_to_actors_batch() {
1836 // Check if the Remote_Actors class is available.
1837 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
1838 self::finalize_ap_feeds_link_migration();
1839 return;
1840 }
1841
1842 // Reduce HTTP timeout to avoid PHP max_execution_time issues.
1843 add_filter( 'activitypub_remote_get_timeout', array( __CLASS__, 'reduce_activitypub_timeout' ) );
1844
1845 // Process one at a time since network requests can timeout.
1846 $batch_size = apply_filters( 'friends_ap_feeds_link_batch_size', 1 );
1847
1848 // Get all ActivityPub feeds.
1849 $activitypub_feeds = User_Feed::get_by_parser( Feed_Parser_ActivityPub::SLUG );
1850
1851 if ( empty( $activitypub_feeds ) ) {
1852 self::finalize_ap_feeds_link_migration();
1853 return;
1854 }
1855
1856 $processed = (int) get_option( 'friends_ap_feeds_link_migration_processed', 0 );
1857 $linked = (int) get_option( 'friends_ap_feeds_link_migration_linked', 0 );
1858 $skipped = (int) get_option( 'friends_ap_feeds_link_migration_skipped', 0 );
1859 $failed = (int) get_option( 'friends_ap_feeds_link_migration_failed', 0 );
1860 $failed_urls = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
1861
1862 // Check for crash recovery - if we were processing a URL and crashed, skip it.
1863 $current_url = get_option( 'friends_ap_feeds_link_migration_current_url', '' );
1864 if ( $current_url ) {
1865 // We crashed while processing this URL - mark it as failed and move on.
1866 ++$failed;
1867 $failed_urls[] = array(
1868 'url' => $current_url,
1869 'time' => time(),
1870 'feed' => 'Unknown (crashed)',
1871 'reason' => 'timeout_or_crash',
1872 );
1873 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1874 error_log( sprintf( 'Friends Migration: Recovered from crash on %s - skipping', $current_url ) );
1875 delete_option( 'friends_ap_feeds_link_migration_current_url' );
1876
1877 // Update failed counts.
1878 update_option( 'friends_ap_feeds_link_migration_failed', $failed, false );
1879 update_option( 'friends_ap_feeds_link_migration_failed_urls', $failed_urls, false );
1880 }
1881
1882 // Get the batch to process.
1883 $batch = array_slice( $activitypub_feeds, $processed, $batch_size );
1884
1885 if ( empty( $batch ) ) {
1886 self::finalize_ap_feeds_link_migration();
1887 return;
1888 }
1889
1890 foreach ( $batch as $feed ) {
1891 ++$processed;
1892
1893 // Skip if already linked.
1894 if ( $feed->get_ap_actor_id() ) {
1895 ++$skipped;
1896 continue;
1897 }
1898
1899 // Get the feed URL (stored as term name).
1900 $actor_url = $feed->get_url();
1901 if ( empty( $actor_url ) ) {
1902 continue;
1903 }
1904
1905 // Check if this URL was already marked as failed (from a previous crash).
1906 $already_failed = false;
1907 foreach ( $failed_urls as $failed_item ) {
1908 if ( $failed_item['url'] === $actor_url ) {
1909 $already_failed = true;
1910 break;
1911 }
1912 }
1913 if ( $already_failed ) {
1914 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1915 error_log( sprintf( 'Friends Migration: Skipping previously failed URL: %s', $actor_url ) );
1916 continue;
1917 }
1918
1919 // Log which URL we're about to process.
1920 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1921 error_log( sprintf( 'Friends Migration: Processing feed %d/%d: %s', $processed, count( $activitypub_feeds ), $actor_url ) );
1922
1923 // Store current URL for crash recovery before network request.
1924 update_option( 'friends_ap_feeds_link_migration_current_url', $actor_url, false );
1925
1926 // Fetch or create the ap_actor using ActivityPub plugin API.
1927 $ap_actor_id = null;
1928 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
1929 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
1930 $ap_actor_id = $actor_post->ID;
1931 }
1932
1933 // Clear crash recovery marker.
1934 delete_option( 'friends_ap_feeds_link_migration_current_url' );
1935
1936 if ( $ap_actor_id ) {
1937 $result = $feed->set_ap_actor_id( $ap_actor_id );
1938 if ( is_wp_error( $result ) || false === $result ) {
1939 ++$failed;
1940 $failed_urls[] = array(
1941 'url' => $actor_url,
1942 'time' => time(),
1943 'feed' => $feed->get_friend_user()->display_name ?? 'Unknown',
1944 'reason' => is_wp_error( $result ) ? $result->get_error_message() : 'set_ap_actor_id_failed',
1945 );
1946 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1947 error_log( sprintf( 'Friends Migration: Failed to link %s - set_ap_actor_id failed', $actor_url ) );
1948 } else {
1949 if ( $feed->get_active() ) {
1950 self::ensure_activitypub_following( $actor_url );
1951 }
1952 ++$linked;
1953 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1954 error_log( sprintf( 'Friends Migration: Linked %s to ap_actor %d', $actor_url, $ap_actor_id ) );
1955 }
1956 } else {
1957 ++$failed;
1958 $failed_urls[] = array(
1959 'url' => $actor_url,
1960 'time' => time(),
1961 'feed' => $feed->get_friend_user()->display_name ?? 'Unknown',
1962 'reason' => 'could_not_resolve',
1963 );
1964 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1965 error_log( sprintf( 'Friends Migration: Failed to link %s - could not resolve actor', $actor_url ) );
1966 }
1967 }
1968
1969 // Update progress.
1970 update_option( 'friends_ap_feeds_link_migration_processed', $processed, false );
1971 update_option( 'friends_ap_feeds_link_migration_linked', $linked, false );
1972 update_option( 'friends_ap_feeds_link_migration_skipped', $skipped, false );
1973 update_option( 'friends_ap_feeds_link_migration_failed', $failed, false );
1974 update_option( 'friends_ap_feeds_link_migration_failed_urls', $failed_urls, false );
1975
1976 // Check if we're done.
1977 $total = (int) get_option( 'friends_ap_feeds_link_migration_total', 0 );
1978 if ( $processed >= $total ) {
1979 self::finalize_ap_feeds_link_migration();
1980 return;
1981 }
1982
1983 // Schedule next batch.
1984 wp_schedule_single_event( time() + 1, 'friends_link_ap_feeds_batch' );
1985 }
1986
1987 /**
1988 * Ensure the main Friends user is following the given actor in the ActivityPub plugin.
1989 *
1990 * @param string $actor_url The actor URL.
1991 */
1992 private static function ensure_activitypub_following( $actor_url ) {
1993 if ( ! function_exists( '\Activitypub\follow' ) ) {
1994 return;
1995 }
1996
1997 $user_id = Feed_Parser_ActivityPub::get_activitypub_actor_id( null );
1998
1999 $result = \Activitypub\follow( $actor_url, $user_id );
2000 if ( is_wp_error( $result ) ) {
2001 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2002 error_log( sprintf( 'Friends Migration: Failed to follow %s: %s', $actor_url, $result->get_error_message() ) );
2003 } else {
2004 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2005 error_log( sprintf( 'Friends Migration: Sent follow request for %s', $actor_url ) );
2006 }
2007 }
2008
2009 /**
2010 * Finalize the AP feeds link migration.
2011 */
2012 private static function finalize_ap_feeds_link_migration() {
2013 $linked = (int) get_option( 'friends_ap_feeds_link_migration_linked', 0 );
2014 $skipped = (int) get_option( 'friends_ap_feeds_link_migration_skipped', 0 );
2015 $failed = (int) get_option( 'friends_ap_feeds_link_migration_failed', 0 );
2016 $failed_urls = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
2017
2018 // Clean up progress options.
2019 delete_option( 'friends_ap_feeds_link_migration_in_progress' );
2020 delete_option( 'friends_ap_feeds_link_migration_total' );
2021 delete_option( 'friends_ap_feeds_link_migration_processed' );
2022
2023 // Keep the counts and failed URLs for reference.
2024 update_option( 'friends_ap_feeds_linked_to_actors', true, false );
2025 update_option( 'friends_ap_feeds_linked_count', $linked, false );
2026 update_option( 'friends_ap_feeds_skipped_count', $skipped, false );
2027 update_option( 'friends_ap_feeds_failed_count', $failed, false );
2028 update_option( 'friends_ap_feeds_failed_urls', $failed_urls, false );
2029
2030 // Clean up batch counts and crash recovery.
2031 delete_option( 'friends_ap_feeds_link_migration_linked' );
2032 delete_option( 'friends_ap_feeds_link_migration_skipped' );
2033 delete_option( 'friends_ap_feeds_link_migration_failed' );
2034 delete_option( 'friends_ap_feeds_link_migration_failed_urls' );
2035 delete_option( 'friends_ap_feeds_link_migration_current_url' );
2036
2037 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2038 error_log( sprintf( 'Friends Migration: Completed - Linked %d, Already linked %d, Failed %d', $linked, $skipped, $failed ) );
2039
2040 if ( $failed > 0 && ! empty( $failed_urls ) ) {
2041 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2042 error_log( 'Friends Migration: Failed URLs: ' . wp_json_encode( array_column( $failed_urls, 'url' ) ) );
2043 }
2044 }
2045
2046 /**
2047 * Render the admin migrations page.
2048 */
2049 public static function render_admin_page() {
2050 if ( ! current_user_can( 'manage_options' ) ) {
2051 wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'friends' ) );
2052 }
2053
2054 add_filter(
2055 'friends_admin_tabs',
2056 function ( $tabs ) {
2057 $tabs[ __( 'Migrations', 'friends' ) ] = 'friends-migrations';
2058 return $tabs;
2059 }
2060 );
2061
2062 Friends::template_loader()->get_template_part(
2063 'admin/settings-header',
2064 null,
2065 array(
2066 'active' => 'friends-migrations',
2067 'title' => __( 'Friends', 'friends' ),
2068 )
2069 );
2070
2071 // Handle version update form submission.
2072 if ( isset( $_POST['update_version'] ) && check_admin_referer( 'friends-update-version' ) ) {
2073 $new_version = isset( $_POST['stored_version'] ) ? sanitize_text_field( wp_unslash( $_POST['stored_version'] ) ) : '';
2074 if ( empty( $new_version ) ) {
2075 delete_option( 'friends_plugin_version' );
2076 $message = __( 'Stored version cleared. Migrations will run on next page load.', 'friends' );
2077 } else {
2078 update_option( 'friends_plugin_version', $new_version );
2079 $message = sprintf(
2080 /* translators: %s is a version number */
2081 __( 'Stored version updated to %s.', 'friends' ),
2082 $new_version
2083 );
2084 }
2085 echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $message ) . '</p></div>';
2086 }
2087
2088 $statuses = self::get_all_statuses();
2089 Friends::template_loader()->get_template_part(
2090 'admin/migrations',
2091 null,
2092 array(
2093 'statuses' => $statuses,
2094 )
2095 );
2096 }
2097
2098 /**
2099 * Debug output hook for the link_activitypub_feeds_to_actors migration.
2100 *
2101 * @param array $status The migration status (unused but required by hook signature).
2102 */
2103 public function debug_link_activitypub_feeds_to_actors( $status ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
2104 if ( ! class_exists( '\Feed_Parser_ActivityPub' ) ) {
2105 echo '<p>' . esc_html__( 'ActivityPub plugin not detected. Migration cannot proceed.', 'friends' ) . '</p>';
2106 return;
2107 }
2108
2109 $failed_urls = get_option( 'friends_ap_feeds_failed_urls', array() );
2110 $failed_count = (int) get_option( 'friends_ap_feeds_failed_count', 0 );
2111 $linked_count = (int) get_option( 'friends_ap_feeds_linked_count', 0 );
2112 $skipped_count = (int) get_option( 'friends_ap_feeds_skipped_count', 0 );
2113
2114 // Also check in-progress stats.
2115 $in_progress_linked = (int) get_option( 'friends_ap_feeds_link_migration_linked', 0 );
2116 $in_progress_skipped = (int) get_option( 'friends_ap_feeds_link_migration_skipped', 0 );
2117 $in_progress_failed = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
2118
2119 $linked_count += $in_progress_linked;
2120 $skipped_count += $in_progress_skipped;
2121 $failed_count += count( $in_progress_failed );
2122
2123 if ( ! empty( $in_progress_failed ) ) {
2124 $failed_urls = array_merge( $failed_urls, $in_progress_failed );
2125 }
2126
2127 echo '<div class="migration-debug-content" style="padding: 15px; background: #f9f9f9;">';
2128
2129 // Calculate live counts.
2130 $activitypub_feeds = User_Feed::get_by_parser( Feed_Parser_ActivityPub::SLUG );
2131 $actually_linked = 0;
2132 $total_feeds = count( $activitypub_feeds );
2133 foreach ( $activitypub_feeds as $feed ) {
2134 if ( $feed->get_ap_actor_id() ) {
2135 ++$actually_linked;
2136 }
2137 }
2138
2139 echo '<h4>' . esc_html__( 'Migration Statistics', 'friends' ) . '</h4>';
2140 echo '<ul>';
2141 echo '<li><strong>' . esc_html__( 'Linked (stored):', 'friends' ) . '</strong> ' . esc_html( $linked_count ) . '</li>';
2142 echo '<li><strong>' . esc_html__( 'Actually linked (live):', 'friends' ) . '</strong> <span style="color: green;">' . esc_html( $actually_linked ) . '</span> / ' . esc_html( $total_feeds ) . '</li>';
2143 echo '<li><strong>' . esc_html__( 'Already linked (skipped):', 'friends' ) . '</strong> ' . esc_html( $skipped_count ) . '</li>';
2144 echo '<li><strong>' . esc_html__( 'Failed:', 'friends' ) . '</strong> ' . esc_html( $failed_count ) . '</li>';
2145 echo '</ul>';
2146
2147 // Show current URL being processed (if migration is in progress).
2148 $current_url = get_option( 'friends_ap_feeds_link_migration_current_url', '' );
2149 if ( $current_url ) {
2150 echo '<div class="notice notice-warning" style="margin: 10px 0; padding: 10px;">';
2151 echo '<strong>' . esc_html__( 'Currently processing:', 'friends' ) . '</strong> ';
2152 echo '<code>' . esc_html( $current_url ) . '</code>';
2153 echo '<br><small>' . esc_html__( 'If this URL appears stuck, it may be causing a timeout.', 'friends' ) . '</small>';
2154 echo '</div>';
2155 }
2156
2157 if ( ! empty( $failed_urls ) ) {
2158 echo '<h4>' . esc_html__( 'Failed URLs', 'friends' ) . '</h4>';
2159 $retry_nonce = wp_create_nonce( 'friends_clear_failed_url' );
2160 $deactivate_nonce = wp_create_nonce( 'friends_deactivate_feed' );
2161 echo '<script>
2162 function clearFailedUrl(url) {
2163 jQuery.post(ajaxurl, {action: "friends_clear_failed_url", url: url, _wpnonce: "' . esc_js( $retry_nonce ) . '"}, function(r) { location.reload(); });
2164 }
2165 function clearAllFailedUrls() {
2166 jQuery.post(ajaxurl, {action: "friends_clear_failed_url", url: "__all__", _wpnonce: "' . esc_js( $retry_nonce ) . '"}, function(r) { location.reload(); });
2167 }
2168 function deactivateFeed(url) {
2169 if (confirm("' . esc_js( __( 'Deactivate this feed?', 'friends' ) ) . '")) {
2170 jQuery.post(ajaxurl, {action: "friends_deactivate_feed_by_url", url: url, _wpnonce: "' . esc_js( $deactivate_nonce ) . '"}, function(r) { location.reload(); });
2171 }
2172 }
2173 </script>';
2174 echo '<p><button type="button" class="button" onclick="clearAllFailedUrls()">' . esc_html__( 'Clear All & Retry', 'friends' ) . '</button></p>';
2175 echo '<table class="widefat" style="margin-top: 10px;">';
2176 echo '<thead><tr><th>' . esc_html__( 'Feed/User', 'friends' ) . '</th><th>' . esc_html__( 'URL', 'friends' ) . '</th><th>' . esc_html__( 'Reason', 'friends' ) . '</th><th>' . esc_html__( 'Time', 'friends' ) . '</th><th>' . esc_html__( 'Actions', 'friends' ) . '</th></tr></thead>';
2177 echo '<tbody>';
2178 foreach ( $failed_urls as $item ) {
2179 $reason = $item['reason'] ?? 'unknown';
2180 $reason_label = 'timeout_or_crash' === $reason ? __( 'Timeout/Crash', 'friends' ) : __( 'Could not resolve', 'friends' );
2181 echo '<tr>';
2182 echo '<td>' . esc_html( $item['feed'] ?? 'Unknown' ) . '</td>';
2183 echo '<td><code style="word-break: break-all;">' . esc_html( $item['url'] ) . '</code></td>';
2184 echo '<td>' . esc_html( $reason_label ) . '</td>';
2185 echo '<td>' . esc_html( isset( $item['time'] ) ? human_time_diff( $item['time'] ) . ' ago' : 'N/A' ) . '</td>';
2186 echo '<td>';
2187 echo '<button type="button" class="button button-small" onclick="clearFailedUrl(\'' . esc_js( $item['url'] ) . '\')">' . esc_html__( 'Retry', 'friends' ) . '</button> ';
2188 echo '<button type="button" class="button button-small" style="color: #b32d2e; border-color: #b32d2e;" onclick="deactivateFeed(\'' . esc_js( $item['url'] ) . '\')">' . esc_html__( 'Deactivate', 'friends' ) . '</button>';
2189 echo '</td>';
2190 echo '</tr>';
2191 }
2192 echo '</tbody></table>';
2193 } else {
2194 echo '<p><em>' . esc_html__( 'No failed URLs recorded.', 'friends' ) . '</em></p>';
2195 }
2196
2197 // Show feeds that still need linking (excluding those already shown in failed URLs).
2198 $failed_url_list = array_column( $failed_urls, 'url' );
2199 $unlinked = array();
2200 foreach ( $activitypub_feeds as $feed ) {
2201 if ( ! $feed->get_ap_actor_id() && ! in_array( $feed->get_url(), $failed_url_list, true ) ) {
2202 $unlinked[] = array(
2203 'url' => $feed->get_url(),
2204 'user' => $feed->get_friend_user()->display_name ?? 'Unknown',
2205 );
2206 }
2207 }
2208
2209 if ( ! empty( $unlinked ) ) {
2210 // translators: %d is the number of unlinked feeds.
2211 echo '<h4>' . sprintf( esc_html__( 'Other Unlinked Feeds (%d)', 'friends' ), count( $unlinked ) ) . '</h4>';
2212 echo '<p><em>' . esc_html__( 'These feeds are not linked and not in the failed list. They may need manual investigation.', 'friends' ) . '</em></p>';
2213 echo '<table class="widefat" style="margin-top: 10px;">';
2214 echo '<thead><tr><th>' . esc_html__( 'User', 'friends' ) . '</th><th>' . esc_html__( 'Feed URL', 'friends' ) . '</th><th>' . esc_html__( 'Actions', 'friends' ) . '</th></tr></thead>';
2215 echo '<tbody>';
2216 foreach ( array_slice( $unlinked, 0, 20 ) as $item ) {
2217 echo '<tr>';
2218 echo '<td>' . esc_html( $item['user'] ) . '</td>';
2219 echo '<td><code style="word-break: break-all;">' . esc_html( $item['url'] ) . '</code></td>';
2220 echo '<td><button type="button" class="button button-small" style="color: #b32d2e; border-color: #b32d2e;" onclick="deactivateFeed(\'' . esc_js( $item['url'] ) . '\')">' . esc_html__( 'Deactivate', 'friends' ) . '</button></td>';
2221 echo '</tr>';
2222 }
2223 if ( count( $unlinked ) > 20 ) {
2224 // translators: %d is the number of additional items not shown.
2225 echo '<tr><td colspan="3"><em>' . sprintf( esc_html__( '... and %d more', 'friends' ), count( $unlinked ) - 20 ) . '</em></td></tr>';
2226 }
2227 echo '</tbody></table>';
2228 }
2229
2230 echo '</div>';
2231 }
2232
2233 /**
2234 * Backfill attributedTo for External user posts (version 4.0.0)
2235 *
2236 * This migration fetches actor information from the original permalink
2237 * for posts belonging to the External user that don't have attributedTo set.
2238 */
2239 public static function backfill_external_attributed_to() {
2240 // Check if the Remote_Actors class is available (ActivityPub plugin 7.x+).
2241 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
2242 return;
2243 }
2244
2245 // Check if migration is already in progress.
2246 if ( get_option( 'friends_external_attributed_to_backfill_in_progress' ) ) {
2247 return;
2248 }
2249
2250 // Check if migration has already been completed.
2251 if ( get_option( 'friends_external_attributed_to_backfill_completed' ) ) {
2252 return;
2253 }
2254
2255 // Get the External user.
2256 $external_user = User::get_by_username( Feed_Parser_ActivityPub::EXTERNAL_USERNAME );
2257 if ( ! $external_user || is_wp_error( $external_user ) ) {
2258 update_option( 'friends_external_attributed_to_backfill_completed', true, false );
2259 return;
2260 }
2261
2262 // Count posts using WP_Query with the same logic as the External page.
2263 $query = new \WP_Query();
2264 $query->set( 'post_type', Friends::CPT );
2265 $query->set( 'post_status', array( 'publish', 'private' ) );
2266 $query->set( 'posts_per_page', -1 );
2267 $query->set( 'fields', 'ids' );
2268 $query->set(
2269 'meta_query',
2270 array(
2271 'relation' => 'OR',
2272 array(
2273 'key' => Feed_Parser_ActivityPub::SLUG,
2274 'compare' => 'NOT EXISTS',
2275 ),
2276 array(
2277 'key' => Feed_Parser_ActivityPub::SLUG,
2278 'value' => 'attributedTo',
2279 'compare' => 'NOT LIKE',
2280 ),
2281 )
2282 );
2283 $external_user->modify_query_by_author( $query );
2284
2285 $post_ids = $query->get_posts();
2286 $total_posts = count( $post_ids );
2287
2288 if ( ! $total_posts ) {
2289 update_option( 'friends_external_attributed_to_backfill_completed', true, false );
2290 return;
2291 }
2292
2293 // Set migration progress tracking (not autoloaded).
2294 update_option( 'friends_external_attributed_to_backfill_in_progress', true, false );
2295 update_option( 'friends_external_attributed_to_backfill_total', $total_posts, false );
2296 update_option( 'friends_external_attributed_to_backfill_processed', 0, false );
2297
2298 // Schedule the first batch.
2299 wp_schedule_single_event( time(), 'friends_backfill_external_attributed_to_batch' );
2300 }
2301
2302 /**
2303 * Process a single batch of External user attributedTo backfill.
2304 */
2305 public static function backfill_external_attributed_to_batch() {
2306 // Check if the Remote_Actors class is available.
2307 if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
2308 self::finalize_external_attributed_to_backfill();
2309 return;
2310 }
2311
2312 // Get the External user.
2313 $external_user = User::get_by_username( Feed_Parser_ActivityPub::EXTERNAL_USERNAME );
2314 if ( ! $external_user || is_wp_error( $external_user ) ) {
2315 self::finalize_external_attributed_to_backfill();
2316 return;
2317 }
2318
2319 // Reduce HTTP timeout to avoid PHP max_execution_time issues.
2320 add_filter( 'activitypub_remote_get_timeout', array( __CLASS__, 'reduce_activitypub_timeout' ) );
2321
2322 $batch_size = apply_filters( 'friends_external_attributed_to_batch_size', 5 );
2323
2324 // Use WP_Query with the same logic as the External page.
2325 $query = new \WP_Query();
2326 $query->set( 'post_type', Friends::CPT );
2327 $query->set( 'post_status', array( 'publish', 'private' ) );
2328 $query->set( 'posts_per_page', $batch_size );
2329 $query->set( 'orderby', 'ID' );
2330 $query->set( 'order', 'ASC' );
2331 $query->set(
2332 'meta_query',
2333 array(
2334 'relation' => 'OR',
2335 array(
2336 'key' => Feed_Parser_ActivityPub::SLUG,
2337 'compare' => 'NOT EXISTS',
2338 ),
2339 array(
2340 'key' => Feed_Parser_ActivityPub::SLUG,
2341 'value' => 'attributedTo',
2342 'compare' => 'NOT LIKE',
2343 ),
2344 )
2345 );
2346 $external_user->modify_query_by_author( $query );
2347
2348 $posts_to_process = $query->get_posts();
2349
2350 if ( empty( $posts_to_process ) ) {
2351 self::finalize_external_attributed_to_backfill();
2352 return;
2353 }
2354
2355 $processed = (int) get_option( 'friends_external_attributed_to_backfill_processed', 0 );
2356
2357 foreach ( $posts_to_process as $post ) {
2358 ++$processed;
2359
2360 // The guid contains the ActivityPub object URL.
2361 $object_url = $post->guid;
2362 if ( empty( $object_url ) || ! filter_var( $object_url, FILTER_VALIDATE_URL ) ) {
2363 continue;
2364 }
2365
2366 // Fetch the ActivityPub object to get the attributedTo.
2367 $response = \Activitypub\safe_remote_get( $object_url, 0 );
2368 if ( is_wp_error( $response ) ) {
2369 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2370 error_log( sprintf( 'Friends Migration: Failed to fetch object %s for post %d: %s', $object_url, $post->ID, $response->get_error_message() ) );
2371 continue;
2372 }
2373 $body = wp_remote_retrieve_body( $response );
2374 $object = json_decode( $body, true );
2375 if ( ! is_array( $object ) ) {
2376 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2377 error_log( sprintf( 'Friends Migration: Invalid response for object %s for post %d', $object_url, $post->ID ) );
2378 continue;
2379 }
2380
2381 $actor_url = null;
2382 if ( isset( $object['attributedTo'] ) ) {
2383 if ( is_string( $object['attributedTo'] ) ) {
2384 $actor_url = $object['attributedTo'];
2385 } elseif ( is_array( $object['attributedTo'] ) && isset( $object['attributedTo']['id'] ) ) {
2386 $actor_url = $object['attributedTo']['id'];
2387 }
2388 }
2389
2390 if ( empty( $actor_url ) ) {
2391 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2392 error_log( sprintf( 'Friends Migration: No attributedTo found in object %s for post %d', $object_url, $post->ID ) );
2393 continue;
2394 }
2395
2396 // Fetch/create the ap_actor for this actor URL.
2397 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
2398 if ( is_wp_error( $actor_post ) || ! $actor_post instanceof \WP_Post ) {
2399 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2400 error_log( sprintf( 'Friends Migration: Failed to fetch actor %s for post %d', $actor_url, $post->ID ) );
2401 continue;
2402 }
2403
2404 // Update the post meta with attributedTo.
2405 $meta = get_post_meta( $post->ID, Feed_Parser_ActivityPub::SLUG, true );
2406 if ( ! is_array( $meta ) ) {
2407 $meta = array();
2408 }
2409
2410 $meta['attributedTo'] = array( 'ap_actor_id' => $actor_post->ID );
2411
2412 update_post_meta( $post->ID, Feed_Parser_ActivityPub::SLUG, $meta );
2413
2414 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2415 error_log( sprintf( 'Friends Migration: Updated post %d with attributedTo %s (ap_actor %d)', $post->ID, $actor_url, $actor_post->ID ) );
2416 }
2417
2418 // Update progress.
2419 update_option( 'friends_external_attributed_to_backfill_processed', $processed, false );
2420
2421 // Schedule next batch.
2422 wp_schedule_single_event( time() + 1, 'friends_backfill_external_attributed_to_batch' );
2423 }
2424
2425 /**
2426 * Finalize the External user attributedTo backfill migration.
2427 */
2428 private static function finalize_external_attributed_to_backfill() {
2429 $processed = (int) get_option( 'friends_external_attributed_to_backfill_processed', 0 );
2430
2431 delete_option( 'friends_external_attributed_to_backfill_in_progress' );
2432 update_option( 'friends_external_attributed_to_backfill_completed', true, false );
2433 update_option( 'friends_external_attributed_to_backfill_count', $processed, false );
2434
2435 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2436 error_log( sprintf( 'Friends Migration: External attributedTo backfill completed - processed %d posts', $processed ) );
2437 }
2438
2439 /**
2440 * Convert ActivityPub reply posts to comments on conversation root posts.
2441 *
2442 * This migration finds posts that are replies (have inReplyTo) and converts
2443 * them to WordPress comments on the conversation root post.
2444 */
2445 public static function convert_replies_to_comments() {
2446 if ( get_option( 'friends_replies_to_comments_in_progress' ) ) {
2447 return;
2448 }
2449 if ( get_option( 'friends_replies_to_comments_completed' ) ) {
2450 return;
2451 }
2452
2453 // Check for ActivityPub plugin.
2454 if ( ! class_exists( '\Activitypub\Http' ) ) {
2455 return;
2456 }
2457
2458 global $wpdb;
2459
2460 // Count posts linked to ActivityPub feeds OR with mention-* tags (External mentions).
2461 $mention_like = 'mention-%';
2462 $total_posts = $wpdb->get_var(
2463 $wpdb->prepare(
2464 "SELECT COUNT(DISTINCT p.ID)
2465 FROM {$wpdb->posts} p
2466 LEFT JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
2467 LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
2468 LEFT JOIN {$wpdb->termmeta} tm ON tt.term_id = tm.term_id AND tm.meta_key = 'parser'
2469 LEFT JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
2470 WHERE p.post_type = %s
2471 AND p.post_status = 'publish'
2472 AND (
2473 (tt.taxonomy = %s AND tm.meta_value = 'activitypub')
2474 OR (tt.taxonomy = 'friend_tag' AND t.slug LIKE %s)
2475 )",
2476 Friends::CPT,
2477 User_Feed::TAXONOMY,
2478 $mention_like
2479 )
2480 );
2481
2482 if ( ! $total_posts ) {
2483 update_option( 'friends_replies_to_comments_completed', true, false );
2484 return;
2485 }
2486
2487 // Initialize progress tracking.
2488 update_option( 'friends_replies_to_comments_in_progress', true, false );
2489 update_option( 'friends_replies_to_comments_total', $total_posts, false );
2490 update_option( 'friends_replies_to_comments_processed', 0, false );
2491 update_option( 'friends_replies_to_comments_converted', 0, false );
2492 update_option( 'friends_replies_to_comments_skipped', 0, false );
2493 update_option( 'friends_replies_to_comments_failed', 0, false );
2494 update_option( 'friends_replies_to_comments_failed_posts', array(), false );
2495
2496 // Schedule the first batch.
2497 wp_schedule_single_event( time(), 'friends_convert_replies_batch' );
2498 }
2499
2500 /**
2501 * Process a batch of reply posts to convert to comments.
2502 */
2503 public static function convert_replies_to_comments_batch() {
2504 if ( ! class_exists( '\Activitypub\Http' ) ) {
2505 self::finalize_replies_to_comments_migration();
2506 return;
2507 }
2508
2509 // Reduce HTTP timeout.
2510 add_filter( 'activitypub_remote_get_timeout', array( __CLASS__, 'reduce_activitypub_timeout' ) );
2511
2512 // Process one at a time due to network operations.
2513 $batch_size = apply_filters( 'friends_replies_batch_size', 1 );
2514
2515 global $wpdb;
2516 $processed = (int) get_option( 'friends_replies_to_comments_processed', 0 );
2517
2518 // Check for crash recovery.
2519 $current_post = get_option( 'friends_replies_current_post' );
2520 if ( $current_post ) {
2521 // Previous batch crashed, skip this post.
2522 $failed_posts = get_option( 'friends_replies_to_comments_failed_posts', array() );
2523 $failed_posts[] = array(
2524 'post_id' => $current_post,
2525 'reason' => 'timeout_or_crash',
2526 'time' => time(),
2527 );
2528 update_option( 'friends_replies_to_comments_failed_posts', $failed_posts, false );
2529 $failed = (int) get_option( 'friends_replies_to_comments_failed', 0 );
2530 update_option( 'friends_replies_to_comments_failed', $failed + 1, false );
2531 delete_option( 'friends_replies_current_post' );
2532 }
2533
2534 // Always query from offset 0: converted posts are trashed (removed from results)
2535 // and all processed posts are marked with _friends_reply_checked meta.
2536 $mention_like = 'mention-%';
2537 $posts = $wpdb->get_results(
2538 $wpdb->prepare(
2539 "SELECT DISTINCT p.ID, p.guid, p.post_author, p.post_content, p.post_date_gmt
2540 FROM {$wpdb->posts} p
2541 LEFT JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
2542 LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
2543 LEFT JOIN {$wpdb->termmeta} tm ON tt.term_id = tm.term_id AND tm.meta_key = 'parser'
2544 LEFT JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
2545 WHERE p.post_type = %s
2546 AND p.post_status = 'publish'
2547 AND (
2548 (tt.taxonomy = %s AND tm.meta_value = 'activitypub')
2549 OR (tt.taxonomy = 'friend_tag' AND t.slug LIKE %s)
2550 )
2551 AND NOT EXISTS (
2552 SELECT 1 FROM {$wpdb->postmeta} pm2
2553 WHERE pm2.post_id = p.ID AND pm2.meta_key = '_friends_reply_checked'
2554 )
2555 ORDER BY p.ID ASC
2556 LIMIT %d OFFSET 0",
2557 Friends::CPT,
2558 User_Feed::TAXONOMY,
2559 $mention_like,
2560 $batch_size
2561 )
2562 );
2563
2564 if ( empty( $posts ) ) {
2565 self::finalize_replies_to_comments_migration();
2566 return;
2567 }
2568
2569 // Track stats.
2570 $converted = (int) get_option( 'friends_replies_to_comments_converted', 0 );
2571 $skipped = (int) get_option( 'friends_replies_to_comments_skipped', 0 );
2572 $failed = (int) get_option( 'friends_replies_to_comments_failed', 0 );
2573 $failed_posts = get_option( 'friends_replies_to_comments_failed_posts', array() );
2574
2575 foreach ( $posts as $post ) {
2576 ++$processed;
2577
2578 // Crash recovery: store current post ID.
2579 update_option( 'friends_replies_current_post', $post->ID, false );
2580
2581 $result = self::process_potential_reply_post( $post );
2582
2583 // Clear crash recovery marker.
2584 delete_option( 'friends_replies_current_post' );
2585
2586 if ( 'converted' === $result ) {
2587 ++$converted;
2588 } elseif ( 'skipped' === $result ) {
2589 ++$skipped;
2590 } else {
2591 ++$failed;
2592 $failed_posts[] = array(
2593 'post_id' => $post->ID,
2594 'guid' => $post->guid,
2595 'reason' => $result,
2596 'time' => time(),
2597 );
2598 }
2599
2600 // Mark as checked so it's excluded from future queries.
2601 update_post_meta( $post->ID, '_friends_reply_checked', 1 );
2602 }
2603
2604 // Update progress.
2605 update_option( 'friends_replies_to_comments_processed', $processed, false );
2606 update_option( 'friends_replies_to_comments_converted', $converted, false );
2607 update_option( 'friends_replies_to_comments_skipped', $skipped, false );
2608 update_option( 'friends_replies_to_comments_failed', $failed, false );
2609 update_option( 'friends_replies_to_comments_failed_posts', $failed_posts, false );
2610
2611 // Schedule next batch.
2612 wp_schedule_single_event( time() + 1, 'friends_convert_replies_batch' );
2613 }
2614
2615 /**
2616 * Process a potential reply post and convert it to a comment if needed.
2617 *
2618 * @param object $post The post object.
2619 * @return string Result: 'converted', 'skipped', or error reason.
2620 */
2621 public static function process_potential_reply_post( $post ) {
2622 // Get the ActivityPub object URL from the post's GUID.
2623 $object_url = $post->guid;
2624
2625 // Fetch the ActivityPub object.
2626 $object = \Activitypub\Http::get_remote_object( $object_url, true );
2627
2628 if ( is_wp_error( $object ) ) {
2629 return 'fetch_failed: ' . $object->get_error_message();
2630 }
2631
2632 if ( ! is_array( $object ) ) {
2633 return 'invalid_object';
2634 }
2635
2636 // Check if this is a reply.
2637 if ( empty( $object['inReplyTo'] ) ) {
2638 // Not a reply, skip.
2639 return 'skipped';
2640 }
2641
2642 // Only convert to comment if the user was mentioned in this post.
2643 if ( ! Friend_Tag::has_mention( $post->ID ) ) {
2644 return 'skipped';
2645 }
2646
2647 $in_reply_to = $object['inReplyTo'];
2648 if ( is_array( $in_reply_to ) ) {
2649 $in_reply_to = reset( $in_reply_to );
2650 }
2651
2652 // Find the conversation root by walking up the chain.
2653 $root_result = self::find_conversation_root( $in_reply_to, 10 );
2654
2655 if ( is_wp_error( $root_result ) ) {
2656 return 'root_failed: ' . $root_result->get_error_message();
2657 }
2658
2659 $root_post_id = $root_result['post_id'];
2660
2661 // Convert the reply post to a comment.
2662 $comment_result = self::convert_post_to_comment( $post, $root_post_id, $object );
2663
2664 if ( is_wp_error( $comment_result ) ) {
2665 return 'comment_failed: ' . $comment_result->get_error_message();
2666 }
2667
2668 $comment_id = $comment_result;
2669
2670 // Store redirect meta on the post before trashing.
2671 update_post_meta( $post->ID, '_redirects_to_comment', $comment_id );
2672 update_post_meta( $post->ID, '_redirect_post_id', $root_post_id );
2673
2674 // Check if the original post was a mention (has mention-* tag).
2675 if ( Friend_Tag::has_mention( $post->ID ) ) {
2676 // Mark the root post as having a mention in comments.
2677 update_post_meta( $root_post_id, '_has_mention_in_comments', true );
2678 }
2679
2680 // Trash the original reply post (keep for URL redirects).
2681 wp_trash_post( $post->ID );
2682
2683 return 'converted';
2684 }
2685
2686 /**
2687 * Walk up the inReplyTo chain to find the conversation root.
2688 *
2689 * @param string $url The URL to start from.
2690 * @param int $max_depth Maximum depth to walk.
2691 * @return array|\WP_Error Result with post_id and root_url, or WP_Error.
2692 */
2693 private static function find_conversation_root( $url, $max_depth ) {
2694 $current_url = $url;
2695 $chain = array( $url );
2696 $earliest_available = null;
2697
2698 for ( $depth = 0; $depth < $max_depth; ++$depth ) {
2699 // Check if we already have this post locally.
2700 $existing_post_id = Feed::url_to_postid( $current_url );
2701 if ( $existing_post_id ) {
2702 // We have this post, use it as the root.
2703 return array(
2704 'post_id' => $existing_post_id,
2705 'root_url' => $current_url,
2706 'created' => false,
2707 );
2708 }
2709
2710 // Fetch the remote object.
2711 $object = \Activitypub\Http::get_remote_object( $current_url, true );
2712
2713 if ( is_wp_error( $object ) || ! is_array( $object ) ) {
2714 // Cannot fetch, use earliest available in chain as fallback.
2715 break;
2716 }
2717
2718 // Remember this as a valid point in the chain.
2719 $earliest_available = array(
2720 'url' => $current_url,
2721 'object' => $object,
2722 );
2723
2724 // Check if this has inReplyTo.
2725 if ( empty( $object['inReplyTo'] ) ) {
2726 // This is the root!
2727 return self::ensure_root_post_exists( $current_url, $object );
2728 }
2729
2730 // Move up the chain.
2731 $parent_url = $object['inReplyTo'];
2732 if ( is_array( $parent_url ) ) {
2733 $parent_url = reset( $parent_url );
2734 }
2735
2736 // Prevent infinite loops.
2737 if ( in_array( $parent_url, $chain, true ) ) {
2738 break;
2739 }
2740
2741 $chain[] = $parent_url;
2742 $current_url = $parent_url;
2743 }
2744
2745 // Use earliest available as fallback root.
2746 if ( $earliest_available ) {
2747 return self::ensure_root_post_exists(
2748 $earliest_available['url'],
2749 $earliest_available['object']
2750 );
2751 }
2752
2753 return new \WP_Error( 'no_root_found', 'Could not find conversation root' );
2754 }
2755
2756 /**
2757 * Ensure a root post exists, creating it if necessary.
2758 *
2759 * @param string $url The URL of the root post.
2760 * @param array $ap_object The ActivityPub object.
2761 * @return array|\WP_Error Result with post_id and created flag, or WP_Error.
2762 */
2763 private static function ensure_root_post_exists( $url, $ap_object ) {
2764 // Check if post already exists.
2765 $existing_post_id = Feed::url_to_postid( $url );
2766 if ( $existing_post_id ) {
2767 return array(
2768 'post_id' => $existing_post_id,
2769 'root_url' => $url,
2770 'created' => false,
2771 );
2772 }
2773
2774 // Need to create the root post.
2775 // Find the appropriate user feed for the author.
2776 $actor_url = $ap_object['attributedTo'] ?? $ap_object['actor'] ?? null;
2777 if ( ! $actor_url ) {
2778 return new \WP_Error( 'no_actor', 'Cannot determine actor for root post' );
2779 }
2780
2781 if ( is_array( $actor_url ) ) {
2782 $actor_url = $actor_url['id'] ?? reset( $actor_url );
2783 }
2784
2785 // Use the External mentions feed.
2786 $feed_parser = new Feed_Parser_ActivityPub( Friends::get_instance()->feed );
2787 $user_feed = $feed_parser->get_external_mentions_feed();
2788
2789 if ( ! $user_feed ) {
2790 return new \WP_Error( 'no_feed', 'Cannot find appropriate feed for root post' );
2791 }
2792
2793 // Create feed item from the object.
2794 $permalink = $ap_object['url'] ?? $url;
2795 if ( is_array( $permalink ) ) {
2796 foreach ( $permalink as $p ) {
2797 if ( is_string( $p ) ) {
2798 $permalink = $p;
2799 break;
2800 }
2801 if ( is_array( $p ) && isset( $p['href'] ) ) {
2802 $permalink = $p['href'];
2803 break;
2804 }
2805 }
2806 }
2807
2808 $item_data = array(
2809 'permalink' => $permalink,
2810 'content' => $ap_object['content'] ?? '',
2811 'title' => $ap_object['name'] ?? '',
2812 'date' => $ap_object['published'] ?? gmdate( 'Y-m-d H:i:s' ),
2813 'post_format' => 'status',
2814 '_external_id' => $ap_object['id'] ?? $url,
2815 Feed_Parser_ActivityPub::SLUG => array(),
2816 );
2817
2818 // Set author if available.
2819 if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
2820 $actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
2821 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
2822 $item_data[ Feed_Parser_ActivityPub::SLUG ]['attributedTo'] = array( 'ap_actor_id' => $actor_post->ID );
2823 $actor = \Activitypub\Collection\Remote_Actors::get_actor( $actor_post->ID );
2824 if ( $actor ) {
2825 $item_data['author'] = $actor->get_name() ? $actor->get_name() : $actor->get_preferred_username();
2826 }
2827 }
2828 }
2829
2830 $item = new Feed_Item( $item_data );
2831
2832 // Process through the feed system.
2833 $new_posts = Friends::get_instance()->feed->process_incoming_feed_items(
2834 array( $item ),
2835 $user_feed
2836 );
2837
2838 // Try to find the post we just created.
2839 $post_id = Feed::url_to_postid( $permalink );
2840 if ( ! $post_id ) {
2841 $post_id = Feed::url_to_postid( $url );
2842 }
2843 if ( ! $post_id && isset( $ap_object['id'] ) ) {
2844 $post_id = Feed::url_to_postid( $ap_object['id'] );
2845 }
2846
2847 if ( $post_id ) {
2848 return array(
2849 'post_id' => $post_id,
2850 'root_url' => $url,
2851 'created' => true,
2852 );
2853 }
2854
2855 return new \WP_Error( 'create_failed', 'Failed to create root post' );
2856 }
2857
2858 /**
2859 * Convert a post to a comment on the root post.
2860 *
2861 * @param object $post The post object to convert.
2862 * @param int $root_post_id The root post ID.
2863 * @param array $ap_object The ActivityPub object.
2864 * @return int|\WP_Error The comment ID or WP_Error.
2865 */
2866 private static function convert_post_to_comment( $post, $root_post_id, $ap_object ) {
2867 // Get actor information.
2868 $actor_url = $ap_object['attributedTo'] ?? $ap_object['actor'] ?? null;
2869 if ( is_array( $actor_url ) ) {
2870 $actor_url = $actor_url['id'] ?? reset( $actor_url );
2871 }
2872
2873 // Build comment author info.
2874 $comment_author = '';
2875 $comment_author_url = '';
2876 $comment_author_email = '';
2877 $remote_actor_id = null;
2878
2879 if ( $actor_url && class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
2880 $actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url );
2881 if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
2882 $remote_actor_id = $actor_post->ID;
2883 $actor = \Activitypub\Collection\Remote_Actors::get_actor( $actor_post->ID );
2884 if ( $actor ) {
2885 $comment_author = $actor->get_name() ? $actor->get_name() : $actor->get_preferred_username();
2886 $comment_author_url = $actor->get_url() ? $actor->get_url() : $actor_url;
2887 }
2888 }
2889
2890 // Try to get webfinger for email field.
2891 if ( class_exists( '\Activitypub\Webfinger' ) ) {
2892 $webfinger = \Activitypub\Webfinger::uri_to_acct( $actor_url );
2893 if ( ! is_wp_error( $webfinger ) ) {
2894 $comment_author_email = str_replace( 'acct:', '', $webfinger );
2895 }
2896 }
2897 }
2898
2899 // Fallback to post meta.
2900 if ( empty( $comment_author ) ) {
2901 $author_meta = get_post_meta( $post->ID, 'author', true );
2902 $comment_author = $author_meta ? $author_meta : __( 'Unknown', 'friends' );
2903 }
2904
2905 // Prepare comment data.
2906 $comment_data = array(
2907 'comment_post_ID' => $root_post_id,
2908 'comment_author' => $comment_author,
2909 'comment_author_url' => $comment_author_url,
2910 'comment_author_email' => $comment_author_email,
2911 'comment_content' => $post->post_content,
2912 'comment_date_gmt' => $post->post_date_gmt,
2913 'comment_date' => get_date_from_gmt( $post->post_date_gmt ),
2914 'comment_type' => 'comment',
2915 'comment_approved' => 1,
2916 'comment_parent' => 0, // Flat comments, no threading.
2917 'comment_meta' => array(
2918 'source_id' => $post->guid,
2919 'source_url' => $post->guid,
2920 'protocol' => 'activitypub',
2921 '_original_post_guid' => $post->guid,
2922 ),
2923 );
2924
2925 // Store reference to remote actor if available.
2926 if ( $remote_actor_id ) {
2927 $comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor_id;
2928 }
2929
2930 return self::persist_comment( $comment_data );
2931 }
2932
2933 /**
2934 * Persist a comment with ActivityPub-compatible settings.
2935 *
2936 * @param array $comment_data The comment data.
2937 * @return int|\WP_Error The comment ID or WP_Error.
2938 */
2939 private static function persist_comment( $comment_data ) {
2940 // Disable flood control.
2941 remove_action( 'check_comment_flood', 'check_comment_flood_db' );
2942
2943 // Do not require email.
2944 add_filter( 'pre_option_require_name_email', '__return_false' );
2945
2946 // Disable Akismet nonce check.
2947 add_filter(
2948 'akismet_comment_nonce',
2949 function () {
2950 return 'inactive';
2951 }
2952 );
2953
2954 // Pre-approve comment (bypass Akismet and other moderation).
2955 add_filter( 'pre_comment_approved', '__return_true', 100 );
2956
2957 // Allow p and br tags.
2958 add_filter( 'wp_kses_allowed_html', array( __CLASS__, 'allowed_comment_html' ), 10, 2 );
2959
2960 $comment_id = wp_new_comment( $comment_data, true );
2961
2962 // Restore filters.
2963 remove_filter( 'wp_kses_allowed_html', array( __CLASS__, 'allowed_comment_html' ) );
2964 remove_filter( 'pre_comment_approved', '__return_true', 100 );
2965 remove_filter( 'pre_option_require_name_email', '__return_false' );
2966 add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
2967
2968 return $comment_id;
2969 }
2970
2971 /**
2972 * Allow additional HTML tags in comments.
2973 *
2974 * @param array $allowed_tags The allowed tags.
2975 * @param string $context The context.
2976 * @return array The modified allowed tags.
2977 */
2978 public static function allowed_comment_html( $allowed_tags, $context = '' ) {
2979 if ( 'pre_comment_content' !== $context ) {
2980 return $allowed_tags;
2981 }
2982
2983 if ( ! array_key_exists( 'br', $allowed_tags ) ) {
2984 $allowed_tags['br'] = array();
2985 }
2986 if ( ! array_key_exists( 'p', $allowed_tags ) ) {
2987 $allowed_tags['p'] = array();
2988 }
2989
2990 return $allowed_tags;
2991 }
2992
2993 /**
2994 * Undo the post tags migration by restoring post_tag terms from recovery data.
2995 *
2996 * @return array|WP_Error Result details or error.
2997 */
2998 public static function undo_post_tags_migration() {
2999 $recovery = get_option( 'friends_tag_migration_recovery', array() );
3000 if ( empty( $recovery ) ) {
3001 return new \WP_Error( 'no_recovery_data', 'No recovery data found for post tag migration.' );
3002 }
3003
3004 $restored = 0;
3005 foreach ( $recovery as $post_id => $tag_names ) {
3006 if ( ! get_post( $post_id ) ) {
3007 continue;
3008 }
3009 wp_set_post_terms( (int) $post_id, $tag_names, 'post_tag', true );
3010 ++$restored;
3011 }
3012
3013 delete_option( 'friends_tag_migration_recovery' );
3014 delete_option( 'friends_tag_migration_completed' );
3015
3016 return array( 'restored' => $restored );
3017 }
3018
3019 /**
3020 * Undo the replies-to-comments migration by untrashing posts and deleting created comments.
3021 *
3022 * @return array|WP_Error Result details or error.
3023 */
3024 public static function undo_replies_to_comments() {
3025 global $wpdb;
3026
3027 // Find all trashed posts with _redirects_to_comment meta.
3028 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
3029 $rows = $wpdb->get_results(
3030 $wpdb->prepare(
3031 "SELECT p.ID AS post_id, pm.meta_value AS comment_id
3032 FROM {$wpdb->posts} p
3033 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
3034 WHERE p.post_status = 'trash'
3035 AND pm.meta_key = %s",
3036 '_redirects_to_comment'
3037 )
3038 );
3039
3040 if ( empty( $rows ) ) {
3041 return new \WP_Error( 'no_recovery_data', 'No trashed reply posts found to restore.' );
3042 }
3043
3044 $restored = 0;
3045 $comments_deleted = 0;
3046 foreach ( $rows as $row ) {
3047 // Delete the created comment.
3048 if ( $row->comment_id && get_comment( $row->comment_id ) ) {
3049 wp_delete_comment( $row->comment_id, true );
3050 ++$comments_deleted;
3051 }
3052
3053 // Untrash the post.
3054 wp_untrash_post( $row->post_id );
3055
3056 // Clean up migration meta.
3057 delete_post_meta( $row->post_id, '_redirects_to_comment' );
3058 delete_post_meta( $row->post_id, '_redirect_post_id' );
3059
3060 ++$restored;
3061 }
3062
3063 // Clean up _has_mention_in_comments meta.
3064 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
3065 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_has_mention_in_comments' ) );
3066
3067 delete_option( 'friends_replies_to_comments_completed' );
3068 delete_option( 'friends_replies_converted_count' );
3069 delete_option( 'friends_replies_skipped_count' );
3070 delete_option( 'friends_replies_failed_count' );
3071 delete_option( 'friends_replies_failed_posts_final' );
3072
3073 return array(
3074 'restored' => $restored,
3075 'comments_deleted' => $comments_deleted,
3076 );
3077 }
3078
3079 /**
3080 * Finalize the reply to comments migration.
3081 */
3082 private static function finalize_replies_to_comments_migration() {
3083 global $wpdb;
3084
3085 $converted = (int) get_option( 'friends_replies_to_comments_converted', 0 );
3086 $skipped = (int) get_option( 'friends_replies_to_comments_skipped', 0 );
3087 $failed = (int) get_option( 'friends_replies_to_comments_failed', 0 );
3088 $failed_posts = get_option( 'friends_replies_to_comments_failed_posts', array() );
3089
3090 // Clean up _friends_reply_checked meta markers.
3091 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
3092 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_friends_reply_checked' ) );
3093
3094 // Clean up progress options.
3095 delete_option( 'friends_replies_to_comments_in_progress' );
3096 delete_option( 'friends_replies_to_comments_total' );
3097 delete_option( 'friends_replies_to_comments_processed' );
3098 delete_option( 'friends_replies_current_post' );
3099
3100 // Keep final stats.
3101 update_option( 'friends_replies_to_comments_completed', true, false );
3102 update_option( 'friends_replies_converted_count', $converted, false );
3103 update_option( 'friends_replies_skipped_count', $skipped, false );
3104 update_option( 'friends_replies_failed_count', $failed, false );
3105 if ( ! empty( $failed_posts ) ) {
3106 update_option( 'friends_replies_failed_posts_final', $failed_posts, false );
3107 }
3108
3109 // Clean up batch stats.
3110 delete_option( 'friends_replies_to_comments_converted' );
3111 delete_option( 'friends_replies_to_comments_skipped' );
3112 delete_option( 'friends_replies_to_comments_failed' );
3113 delete_option( 'friends_replies_to_comments_failed_posts' );
3114
3115 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
3116 error_log( sprintf( 'Friends Migration: Reply to comments completed - Converted %d, Skipped %d, Failed %d', $converted, $skipped, $failed ) );
3117 }
3118
3119 /**
3120 * Sanitize friend usernames (version 4.0.0)
3121 * Removes special characters like apostrophes from friend usernames to prevent URL issues.
3122 */
3123 public static function sanitize_friend_usernames() {
3124 global $wpdb;
3125
3126 // Get all WordPress users that are Friends plugin users.
3127 $users = User_Query::all_associated_users();
3128 foreach ( $users->get_results() as $user ) {
3129 if ( $user instanceof Subscription ) {
3130 // Skip subscriptions - they use taxonomy terms and will be handled separately.
3131 continue;
3132 }
3133
3134 $old_username = $user->user_login;
3135 $new_username = User::sanitize_username( $old_username );
3136
3137 // Only update if the username needs sanitization.
3138 if ( $old_username !== $new_username && ! username_exists( $new_username ) ) {
3139 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
3140 $wpdb->update(
3141 $wpdb->users,
3142 array( 'user_login' => $new_username ),
3143 array( 'ID' => $user->ID ),
3144 array( '%s' ),
3145 array( '%d' )
3146 );
3147 clean_user_cache( $user->ID );
3148 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
3149 error_log( sprintf( 'Friends Migration: Sanitized username from "%s" to "%s"', $old_username, $new_username ) );
3150 }
3151 }
3152
3153 // Get all Subscription users (virtual users stored as taxonomy terms).
3154 $term_query = new \WP_Term_Query(
3155 array(
3156 'taxonomy' => Subscription::TAXONOMY,
3157 'hide_empty' => false,
3158 )
3159 );
3160
3161 foreach ( $term_query->get_terms() as $term ) {
3162 $old_slug = $term->slug;
3163 $old_name = $term->name;
3164 $new_slug = User::sanitize_username( $old_slug );
3165 $new_name = User::sanitize_username( $old_name );
3166
3167 // Only update if the term needs sanitization.
3168 if ( ( $old_slug !== $new_slug || $old_name !== $new_name ) && ! term_exists( $new_slug, Subscription::TAXONOMY ) ) {
3169 wp_update_term(
3170 $term->term_id,
3171 Subscription::TAXONOMY,
3172 array(
3173 'slug' => $new_slug,
3174 'name' => $new_name,
3175 )
3176 );
3177 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
3178 error_log( sprintf( 'Friends Migration: Sanitized subscription username from "%s" to "%s"', $old_slug, $new_slug ) );
3179 }
3180 }
3181
3182 update_option( 'friends_usernames_sanitized', true, false );
3183 }
3184
3185 /**
3186 * Convert WordPress users with friend/acquaintance/subscription roles to virtual subscriptions.
3187 */
3188 public static function convert_friend_users_to_subscriptions() {
3189 if ( get_option( 'friends_friend_users_conversion_in_progress' ) ) {
3190 return;
3191 }
3192 if ( get_option( 'friends_friend_users_converted' ) ) {
3193 return;
3194 }
3195
3196 $users = new \WP_User_Query(
3197 array(
3198 'role__in' => array( 'friend', 'acquaintance', 'friend_request', 'pending_friend_request', 'subscription' ),
3199 'number' => -1,
3200 'fields' => 'ID',
3201 )
3202 );
3203
3204 $total = $users->get_total();
3205
3206 if ( ! $total ) {
3207 update_option( 'friends_friend_users_converted', true, false );
3208 return;
3209 }
3210
3211 update_option( 'friends_friend_users_conversion_in_progress', true, false );
3212 update_option( 'friends_friend_users_conversion_total', $total, false );
3213 update_option( 'friends_friend_users_conversion_processed', 0, false );
3214
3215 wp_schedule_single_event( time(), 'friends_convert_friend_users_batch' );
3216 }
3217
3218 /**
3219 * Process a batch of friend users to convert to virtual subscriptions.
3220 */
3221 public static function convert_friend_users_batch() {
3222 $batch_size = apply_filters( 'friends_friend_user_conversion_batch_size', 20 );
3223
3224 $users = new \WP_User_Query(
3225 array(
3226 'role__in' => array( 'friend', 'acquaintance', 'friend_request', 'pending_friend_request', 'subscription' ),
3227 'number' => $batch_size,
3228 'offset' => 0,
3229 )
3230 );
3231
3232 $results = $users->get_results();
3233
3234 if ( empty( $results ) ) {
3235 update_option( 'friends_friend_users_converted', true, false );
3236 delete_option( 'friends_friend_users_conversion_in_progress' );
3237 delete_option( 'friends_friend_users_conversion_total' );
3238 delete_option( 'friends_friend_users_conversion_processed' );
3239 return;
3240 }
3241
3242 $processed = (int) get_option( 'friends_friend_users_conversion_processed', 0 );
3243
3244 foreach ( $results as $wp_user ) {
3245 $result = Subscription::convert_from_user( new User( $wp_user ) );
3246 if ( is_wp_error( $result ) ) {
3247 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
3248 error_log( sprintf( 'Friends Migration: Failed to convert user %d (%s): %s', $wp_user->ID, $wp_user->user_login, $result->get_error_message() ) );
3249 continue;
3250 }
3251 ++$processed;
3252 }
3253
3254 update_option( 'friends_friend_users_conversion_processed', $processed, false );
3255
3256 wp_schedule_single_event( time() + 1, 'friends_convert_friend_users_batch' );
3257 }
3258
3259 /**
3260 * Initialize the migration to link feed terms as children of subscription terms.
3261 */
3262 public static function link_feeds_as_term_children() {
3263 if ( get_option( 'friends_feeds_term_children_in_progress' ) ) {
3264 return;
3265 }
3266 if ( get_option( 'friends_feeds_linked_as_term_children' ) ) {
3267 return;
3268 }
3269
3270 global $wpdb;
3271
3272 $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
3273 $wpdb->prepare(
3274 "SELECT COUNT(*)
3275 FROM {$wpdb->term_relationships} tr
3276 JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
3277 WHERE tt.taxonomy = %s
3278 AND EXISTS (
3279 SELECT 1 FROM {$wpdb->term_taxonomy} sub
3280 WHERE sub.term_id = tr.object_id
3281 AND sub.taxonomy = %s
3282 )",
3283 User_Feed::TAXONOMY,
3284 Subscription::TAXONOMY
3285 )
3286 );
3287
3288 if ( ! $total ) {
3289 update_option( 'friends_feeds_linked_as_term_children', true, false );
3290 return;
3291 }
3292
3293 update_option( 'friends_feeds_term_children_in_progress', true, false );
3294 update_option( 'friends_feeds_term_children_total', $total, false );
3295 update_option( 'friends_feeds_term_children_processed', 0, false );
3296
3297 wp_schedule_single_event( time(), 'friends_link_feeds_as_term_children_batch' );
3298 }
3299
3300 /**
3301 * Process a batch of feed-to-subscription relationships, converting them to parent links.
3302 */
3303 public static function link_feeds_as_term_children_batch() {
3304 $batch_size = apply_filters( 'friends_feeds_term_children_batch_size', 50 );
3305
3306 global $wpdb;
3307
3308 $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
3309 $wpdb->prepare(
3310 "SELECT tr.object_id AS subscription_term_id, tt.term_taxonomy_id AS feed_tt_id, tt.term_id AS feed_term_id
3311 FROM {$wpdb->term_relationships} tr
3312 JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
3313 WHERE tt.taxonomy = %s
3314 AND EXISTS (
3315 SELECT 1 FROM {$wpdb->term_taxonomy} sub
3316 WHERE sub.term_id = tr.object_id
3317 AND sub.taxonomy = %s
3318 )
3319 LIMIT %d OFFSET 0",
3320 User_Feed::TAXONOMY,
3321 Subscription::TAXONOMY,
3322 $batch_size
3323 )
3324 );
3325
3326 if ( empty( $rows ) ) {
3327 update_option( 'friends_feeds_linked_as_term_children', true, false );
3328 delete_option( 'friends_feeds_term_children_in_progress' );
3329 delete_option( 'friends_feeds_term_children_total' );
3330 delete_option( 'friends_feeds_term_children_processed' );
3331 return;
3332 }
3333
3334 $processed = (int) get_option( 'friends_feeds_term_children_processed', 0 );
3335
3336 foreach ( $rows as $row ) {
3337 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
3338 $wpdb->term_taxonomy,
3339 array( 'parent' => (int) $row->subscription_term_id ),
3340 array( 'term_taxonomy_id' => (int) $row->feed_tt_id ),
3341 array( '%d' ),
3342 array( '%d' )
3343 );
3344
3345 $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
3346 $wpdb->term_relationships,
3347 array(
3348 'object_id' => (int) $row->subscription_term_id,
3349 'term_taxonomy_id' => (int) $row->feed_tt_id,
3350 ),
3351 array( '%d', '%d' )
3352 );
3353
3354 ++$processed;
3355 }
3356
3357 clean_term_cache( wp_list_pluck( $rows, 'feed_term_id' ), User_Feed::TAXONOMY );
3358
3359 update_option( 'friends_feeds_term_children_processed', $processed, false );
3360
3361 wp_schedule_single_event( time() + 1, 'friends_link_feeds_as_term_children_batch' );
3362 }
3363 }
3364