register_hooks();
}
/**
* Register the WordPress hooks
*/
private function register_hooks() {
add_action( 'wp_ajax_friends_run_migration', array( $this, 'ajax_run_migration' ) );
add_action( 'wp_ajax_friends_undo_migration', array( $this, 'ajax_undo_migration' ) );
add_action( 'wp_ajax_friends_get_migration_status', array( $this, 'ajax_get_migration_status' ) );
add_action( 'wp_ajax_friends_process_migration_batch', array( $this, 'ajax_process_migration_batch' ) );
add_action( 'wp_ajax_friends_get_migration_debug', array( $this, 'ajax_get_migration_debug' ) );
add_action( 'wp_ajax_friends_clear_failed_url', array( $this, 'ajax_clear_failed_url' ) );
add_action( 'wp_ajax_friends_deactivate_feed_by_url', array( $this, 'ajax_deactivate_feed_by_url' ) );
add_action( 'friends_convert_friend_users_batch', array( __CLASS__, 'convert_friend_users_batch' ) );
add_action( 'friends_link_feeds_as_term_children_batch', array( __CLASS__, 'link_feeds_as_term_children_batch' ) );
// Register debug output hooks for migrations.
add_action( 'friends_migration_debug_link_activitypub_feeds_to_actors', array( $this, 'debug_link_activitypub_feeds_to_actors' ) );
}
/**
* Register all migrations.
*/
public static function register_migrations() {
if ( ! empty( self::$registry ) ) {
return;
}
self::register(
array(
'id' => 'gravatar_to_user_icon',
'version' => '0.20.1',
'title' => 'Migrate Gravatar to User Icon',
'description' => 'Migrates user gravatar option to the new user_icon_url format.',
'method' => 'migrate_gravatar_to_user_icon_url',
'status_option' => null, // No status tracking for simple migrations.
)
);
self::register(
array(
'id' => 'feed_options_to_user',
'version' => '2.6.0',
'title' => 'Migrate Feed Options',
'description' => 'Moves feed options from global options to user-specific options.',
'method' => 'migrate_feed_options_to_user_options',
'status_option' => null,
)
);
self::register(
array(
'id' => 'wp_friendships',
'version' => '2.8.7',
'title' => 'Enable WP Friendships Flag',
'description' => 'Enables the WordPress friendships flag if friendship functionality was used.',
'method' => 'enable_wp_friendships_if_used',
'status_option' => null,
)
);
self::register(
array(
'id' => 'external_user_and_cron',
'version' => '2.9.4',
'title' => 'Migrate External User and Cron',
'description' => 'Renames external-mentions user to external and upgrades cron schedule.',
'method' => 'migrate_external_user_and_cron',
'status_option' => null,
)
);
self::register(
array(
'id' => 'frontend_default_view',
'version' => '3.1.8',
'title' => 'Migrate Frontend Default View',
'description' => 'Moves frontend default view option to user-specific setting.',
'method' => 'migrate_frontend_default_view_to_user_option',
'status_option' => null,
)
);
self::register(
array(
'id' => 'post_tags_to_friend_tags',
'version' => '4.0.0',
'title' => 'Migrate Post Tags to Friend Tags',
'description' => 'Converts post_tag taxonomy to friend_tag for Friends posts. Runs in batches.',
'method' => 'migrate_post_tags_to_friend_tags',
'status_option' => 'friends_tag_migration_completed',
'batched' => true,
'batch_method' => 'migrate_post_tags_batch',
'cron_hook' => 'friends_migrate_post_tags_batch',
'undo_method' => 'undo_post_tags_migration',
'progress' => array(
'in_progress' => 'friends_tag_migration_in_progress',
'total' => 'friends_tag_migration_total',
'processed' => 'friends_tag_migration_processed',
),
)
);
self::register(
array(
'id' => 'backfill_mention_tags',
'version' => '4.0.0',
'title' => 'Backfill Mention Tags',
'description' => 'Extracts mention tags from Mastodon HTML content in existing posts.',
'method' => 'backfill_mention_tags_from_mastodon_html',
'status_option' => 'friends_backfill_mention_tags_completed',
)
);
self::register(
array(
'id' => 'activitypub_attributed_to',
'version' => '4.0.0',
'title' => 'Migrate ActivityPub AttributedTo',
'description' => 'Converts attributedTo URLs to ap_actor post IDs for better reliability. Runs in batches.',
'method' => 'migrate_activitypub_attributed_to',
'status_option' => 'friends_ap_attributed_to_migration_completed',
'batched' => true,
'batch_method' => 'migrate_activitypub_attributed_to_batch',
'cron_hook' => 'friends_migrate_ap_attributed_to_batch',
'progress' => array(
'in_progress' => 'friends_ap_attributed_to_migration_in_progress',
'total' => 'friends_ap_attributed_to_migration_total',
'processed' => 'friends_ap_attributed_to_migration_processed',
),
)
);
self::register(
array(
'id' => 'activitypub_attributed_to_acct',
'version' => '4.2.0',
'title' => 'Backfill ActivityPub Actor Handles',
'description' => 'Adds cached ActivityPub actor handles to the latest 100 cached posts that already reference ap_actor posts.',
'method' => 'backfill_activitypub_attributed_to_acct',
'status_option' => 'friends_ap_attributed_to_acct_backfilled',
'depends_on' => 'activitypub_attributed_to',
)
);
self::register(
array(
'id' => 'import_activitypub_followings',
'version' => '4.0.0',
'title' => 'Import ActivityPub Followings',
'description' => 'Imports existing ActivityPub plugin followings as Friends subscriptions.',
'method' => 'import_activitypub_followings',
'status_option' => 'friends_activitypub_followings_imported',
)
);
self::register(
array(
'id' => 'link_activitypub_feeds_to_actors',
'version' => '4.0.0',
'title' => 'Link ActivityPub Feeds to Actors',
'description' => 'Links existing ActivityPub feeds to their ap_actor posts for URL synchronization. Runs in batches.',
'method' => 'link_activitypub_feeds_to_actors',
'status_option' => 'friends_ap_feeds_linked_to_actors',
'batched' => true,
'batch_method' => 'link_activitypub_feeds_to_actors_batch',
'cron_hook' => 'friends_link_ap_feeds_batch',
'progress' => array(
'in_progress' => 'friends_ap_feeds_link_migration_in_progress',
'total' => 'friends_ap_feeds_link_migration_total',
'processed' => 'friends_ap_feeds_link_migration_processed',
),
)
);
self::register(
array(
'id' => 'backfill_external_attributed_to',
'version' => '4.0.0',
'title' => 'Backfill External Post Authors',
'description' => 'Fetches actor information for External user posts from their original permalinks. Runs in batches.',
'method' => 'backfill_external_attributed_to',
'status_option' => 'friends_external_attributed_to_backfill_completed',
'batched' => true,
'batch_method' => 'backfill_external_attributed_to_batch',
'cron_hook' => 'friends_backfill_external_attributed_to_batch',
'progress' => array(
'in_progress' => 'friends_external_attributed_to_backfill_in_progress',
'total' => 'friends_external_attributed_to_backfill_total',
'processed' => 'friends_external_attributed_to_backfill_processed',
),
)
);
self::register(
array(
'id' => 'convert_replies_to_comments',
'version' => '4.0.0',
'title' => 'Convert Reply Posts to Comments',
'description' => 'Converts ActivityPub reply posts to comments on conversation root posts. Runs in batches.',
'method' => 'convert_replies_to_comments',
'status_option' => 'friends_replies_to_comments_completed',
'batched' => true,
'batch_method' => 'convert_replies_to_comments_batch',
'cron_hook' => 'friends_convert_replies_batch',
'undo_method' => 'undo_replies_to_comments',
'progress' => array(
'in_progress' => 'friends_replies_to_comments_in_progress',
'total' => 'friends_replies_to_comments_total',
'processed' => 'friends_replies_to_comments_processed',
),
)
);
self::register(
array(
'id' => 'sanitize_usernames',
'version' => '4.0.0',
'title' => 'Sanitize Friend Usernames',
'description' => 'Removes special characters like apostrophes from friend usernames to prevent URL issues.',
'method' => 'sanitize_friend_usernames',
'status_option' => 'friends_usernames_sanitized',
)
);
self::register(
array(
'id' => 'convert_friend_users_to_subscriptions',
'version' => '4.0.0',
'title' => 'Convert Friend Users to Subscriptions',
'description' => 'Converts WordPress users with friend/acquaintance/subscription roles to virtual subscriptions. Runs in batches.',
'method' => 'convert_friend_users_to_subscriptions',
'status_option' => 'friends_friend_users_converted',
'batched' => true,
'batch_method' => 'convert_friend_users_batch',
'cron_hook' => 'friends_convert_friend_users_batch',
'progress' => array(
'in_progress' => 'friends_friend_users_conversion_in_progress',
'total' => 'friends_friend_users_conversion_total',
'processed' => 'friends_friend_users_conversion_processed',
),
)
);
self::register(
array(
'id' => 'link_feeds_as_term_children',
'version' => '4.0.0',
'title' => 'Link Feeds as Term Children',
'description' => 'Migrates feed-to-subscription links from wp_term_relationships to the parent field in wp_term_taxonomy.',
'method' => 'link_feeds_as_term_children',
'depends_on' => 'convert_friend_users_to_subscriptions',
'status_option' => 'friends_feeds_linked_as_term_children',
'batched' => true,
'batch_method' => 'link_feeds_as_term_children_batch',
'cron_hook' => 'friends_link_feeds_as_term_children_batch',
'progress' => array(
'in_progress' => 'friends_feeds_term_children_in_progress',
'total' => 'friends_feeds_term_children_total',
'processed' => 'friends_feeds_term_children_processed',
),
)
);
}
/**
* Register a single migration.
*
* @param array $migration The migration data.
*/
private static function register( $migration ) {
$defaults = array(
'batched' => false,
'batch_method' => null,
'undo_method' => null,
'depends_on' => null,
'progress' => array(),
);
self::$registry[ $migration['id'] ] = array_merge( $defaults, $migration );
}
/**
* Get all registered migrations.
*
* @return array The migration registry.
*/
public static function get_registry() {
self::register_migrations();
return self::$registry;
}
/**
* Get a single migration by ID.
*
* @param string $id The migration ID.
* @return array|null The migration data or null.
*/
public static function get_migration( $id ) {
self::register_migrations();
return isset( self::$registry[ $id ] ) ? self::$registry[ $id ] : null;
}
/**
* Get the status of a migration.
*
* @param string $id The migration ID.
* @return array Status information.
*/
public static function get_migration_status_by_id( $id ) {
$migration = self::get_migration( $id );
if ( ! $migration ) {
return array( 'status' => 'unknown' );
}
$status = array(
'id' => $id,
'title' => $migration['title'],
'description' => $migration['description'],
'version' => $migration['version'],
'batched' => $migration['batched'],
);
// Check if dependency is met.
if ( ! empty( $migration['depends_on'] ) ) {
$dep_status = self::get_migration_status_by_id( $migration['depends_on'] );
if ( empty( $dep_status['completed'] ) ) {
$dep_migration = self::get_migration( $migration['depends_on'] );
$status['waiting_for'] = $migration['depends_on'];
$status['waiting_for_title'] = $dep_migration ? $dep_migration['title'] : $migration['depends_on'];
}
}
// Check completion status.
if ( $migration['status_option'] ) {
$status['completed'] = (bool) get_option( $migration['status_option'] );
} else {
// No status tracking - assume completed if we're past the version.
$current_version = get_option( 'friends_plugin_version', '0' );
$status['completed'] = version_compare( $current_version, $migration['version'], '>=' );
$status['no_tracking'] = true;
}
// Check progress for batched migrations.
if ( $migration['batched'] && ! empty( $migration['progress'] ) ) {
$status['in_progress'] = (bool) get_option( $migration['progress']['in_progress'] );
$status['total'] = (int) get_option( $migration['progress']['total'], 0 );
$status['processed'] = (int) get_option( $migration['progress']['processed'], 0 );
if ( $status['total'] > 0 ) {
$status['percent'] = min( 100, round( ( $status['processed'] / $status['total'] ) * 100, 1 ) );
} else {
$status['percent'] = 0;
}
// Check for next scheduled batch.
if ( $status['in_progress'] && ! empty( $migration['cron_hook'] ) ) {
$next_scheduled = wp_next_scheduled( $migration['cron_hook'] );
if ( $next_scheduled ) {
$status['next_scheduled'] = $next_scheduled;
}
$status['cron_hook'] = $migration['cron_hook'];
}
}
return $status;
}
/**
* Check whether any tracked migrations are still pending.
*
* Uses the friends_migration_status option as a fast gate: if it is already
* 'completed' this returns false immediately without touching any other options.
* When every tracked migration has finished it writes 'completed' to that option
* so subsequent calls are a single cheap get_option().
*
* @return bool True if at least one tracked migration has not yet completed.
*/
public static function has_pending_tracked_migrations() {
if ( 'completed' === get_option( 'friends_migration_status' ) ) {
return false;
}
self::register_migrations();
foreach ( self::$registry as $migration ) {
if ( $migration['status_option'] && ! get_option( $migration['status_option'] ) ) {
return true;
}
}
update_option( 'friends_migration_status', 'completed', false );
return false;
}
/**
* Get status of all migrations.
*
* @return array Status information for all migrations.
*/
public static function get_all_statuses() {
self::register_migrations();
$statuses = array();
foreach ( array_keys( self::$registry ) as $id ) {
$statuses[ $id ] = self::get_migration_status_by_id( $id );
}
return $statuses;
}
/**
* Run a specific migration manually.
*
* @param string $id The migration ID.
* @return array Result of the migration.
*/
public static function run_migration( $id ) {
$migration = self::get_migration( $id );
if ( ! $migration ) {
return array(
'success' => false,
'message' => 'Migration not found: ' . $id,
);
}
// Check dependencies.
if ( ! empty( $migration['depends_on'] ) ) {
$dep_status = self::get_migration_status_by_id( $migration['depends_on'] );
if ( empty( $dep_status['completed'] ) ) {
$dep_migration = self::get_migration( $migration['depends_on'] );
return array(
'success' => false,
'message' => sprintf( 'Requires "%s" to complete first.', $dep_migration ? $dep_migration['title'] : $migration['depends_on'] ),
);
}
}
// Reset status for re-running.
if ( $migration['status_option'] ) {
delete_option( $migration['status_option'] );
}
if ( $migration['batched'] && ! empty( $migration['progress'] ) ) {
foreach ( $migration['progress'] as $option ) {
delete_option( $option );
}
}
// Call the migration method.
$method = $migration['method'];
if ( method_exists( __CLASS__, $method ) ) {
self::$method();
// If the method returned without setting the status option
// (e.g. nothing to migrate, or required plugin not active),
// mark the migration as completed.
if ( $migration['status_option'] && ! get_option( $migration['status_option'] ) ) {
update_option( $migration['status_option'], true, false );
}
return array(
'success' => true,
'message' => 'Migration started: ' . $migration['title'],
'batched' => $migration['batched'],
);
}
return array(
'success' => false,
'message' => 'Migration method not found: ' . $method,
);
}
/**
* Process a single batch for a migration.
*
* @param string $id The migration ID.
* @return array Result of the batch processing.
*/
public static function process_batch( $id ) {
$migration = self::get_migration( $id );
if ( ! $migration ) {
return array(
'success' => false,
'message' => 'Migration not found: ' . $id,
);
}
if ( ! $migration['batched'] || empty( $migration['batch_method'] ) ) {
return array(
'success' => false,
'message' => 'Migration is not batched: ' . $id,
);
}
$batch_method = $migration['batch_method'];
if ( ! method_exists( __CLASS__, $batch_method ) ) {
return array(
'success' => false,
'message' => 'Batch method not found: ' . $batch_method,
);
}
// Clear any scheduled cron for this batch to avoid double-processing.
if ( ! empty( $migration['cron_hook'] ) ) {
wp_clear_scheduled_hook( $migration['cron_hook'] );
}
// Process the batch.
self::$batch_method();
return array(
'success' => true,
'message' => 'Batch processed',
);
}
/**
* AJAX handler for clearing failed URLs to allow retry.
*/
public function ajax_clear_failed_url() {
check_ajax_referer( 'friends_clear_failed_url' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized' );
}
$url = isset( $_POST['url'] ) ? sanitize_text_field( wp_unslash( $_POST['url'] ) ) : '';
if ( '__all__' === $url ) {
// Clear all failed URLs.
delete_option( 'friends_ap_feeds_failed_urls' );
delete_option( 'friends_ap_feeds_failed_count' );
delete_option( 'friends_ap_feeds_link_migration_failed_urls' );
delete_option( 'friends_ap_feeds_link_migration_failed' );
delete_option( 'friends_ap_feeds_link_migration_current_url' );
delete_option( 'friends_ap_feeds_linked_to_actors' );
wp_send_json_success( 'All failed URLs cleared' );
}
if ( empty( $url ) ) {
wp_send_json_error( 'No URL provided' );
}
// Remove the specific URL from both failed URL lists.
$failed_urls = get_option( 'friends_ap_feeds_failed_urls', array() );
$failed_urls = array_filter(
$failed_urls,
function ( $item ) use ( $url ) {
return $item['url'] !== $url;
}
);
update_option( 'friends_ap_feeds_failed_urls', array_values( $failed_urls ), false );
update_option( 'friends_ap_feeds_failed_count', count( $failed_urls ), false );
$in_progress_failed = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
$in_progress_failed = array_filter(
$in_progress_failed,
function ( $item ) use ( $url ) {
return $item['url'] !== $url;
}
);
update_option( 'friends_ap_feeds_link_migration_failed_urls', array_values( $in_progress_failed ), false );
update_option( 'friends_ap_feeds_link_migration_failed', count( $in_progress_failed ), false );
// Clear the current URL marker if it matches.
if ( get_option( 'friends_ap_feeds_link_migration_current_url' ) === $url ) {
delete_option( 'friends_ap_feeds_link_migration_current_url' );
}
// Reset migration completed flag so it can run again.
delete_option( 'friends_ap_feeds_linked_to_actors' );
wp_send_json_success( 'URL cleared: ' . $url );
}
/**
* AJAX handler for deactivating a feed by URL.
*/
public function ajax_deactivate_feed_by_url() {
check_ajax_referer( 'friends_deactivate_feed' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized' );
}
$url = isset( $_POST['url'] ) ? sanitize_text_field( wp_unslash( $_POST['url'] ) ) : '';
if ( empty( $url ) ) {
wp_send_json_error( 'No URL provided' );
}
// Find the feed by URL.
$feed = User_Feed::get_by_url( $url );
if ( ! $feed ) {
wp_send_json_error( 'Feed not found: ' . $url );
}
// Deactivate the feed.
$feed->update_metadata( 'active', false );
// Also clear from failed URLs list.
$failed_urls = get_option( 'friends_ap_feeds_failed_urls', array() );
$failed_urls = array_filter(
$failed_urls,
function ( $item ) use ( $url ) {
return $item['url'] !== $url;
}
);
update_option( 'friends_ap_feeds_failed_urls', array_values( $failed_urls ), false );
update_option( 'friends_ap_feeds_failed_count', count( $failed_urls ), false );
$in_progress_failed = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
$in_progress_failed = array_filter(
$in_progress_failed,
function ( $item ) use ( $url ) {
return $item['url'] !== $url;
}
);
update_option( 'friends_ap_feeds_link_migration_failed_urls', array_values( $in_progress_failed ), false );
wp_send_json_success( 'Feed deactivated: ' . $url );
}
/**
* AJAX handler for running a migration.
*/
public function ajax_run_migration() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
}
if ( ! check_ajax_referer( 'friends-run-migration', '_wpnonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
}
$migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
if ( empty( $migration_id ) ) {
wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
}
$result = self::run_migration( $migration_id );
if ( $result['success'] ) {
wp_send_json_success( $result );
} else {
wp_send_json_error( $result );
}
}
/**
* AJAX handler for undoing a migration.
*/
public function ajax_undo_migration() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
}
if ( ! check_ajax_referer( 'friends-undo-migration', '_wpnonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
}
$migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
if ( empty( $migration_id ) ) {
wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
}
$result = self::undo_migration( $migration_id );
if ( $result['success'] ) {
$status = self::get_migration_status_by_id( $migration_id );
ob_start();
self::render_status_badge( $status );
$html = ob_get_clean();
$result['html'] = $html;
wp_send_json_success( $result );
} else {
wp_send_json_error( $result );
}
}
/**
* Undo a specific migration.
*
* @param string $id The migration ID.
* @return array Result of the undo.
*/
public static function undo_migration( $id ) {
$migration = self::get_migration( $id );
if ( ! $migration ) {
return array(
'success' => false,
'message' => 'Migration not found: ' . $id,
);
}
if ( empty( $migration['undo_method'] ) ) {
return array(
'success' => false,
'message' => 'Migration does not support undo: ' . $id,
);
}
$undo_method = $migration['undo_method'];
if ( ! method_exists( __CLASS__, $undo_method ) ) {
return array(
'success' => false,
'message' => 'Undo method not found: ' . $undo_method,
);
}
$result = self::$undo_method();
if ( is_wp_error( $result ) ) {
return array(
'success' => false,
'message' => $result->get_error_message(),
);
}
// Reset completion status.
if ( $migration['status_option'] ) {
delete_option( $migration['status_option'] );
}
return array(
'success' => true,
'message' => 'Migration undone: ' . $migration['title'],
'details' => $result,
);
}
/**
* Check if a migration can be undone.
*
* @param string $id The migration ID.
* @return bool Whether the migration can be undone.
*/
public static function can_undo_migration( $id ) {
$migration = self::get_migration( $id );
if ( ! $migration || empty( $migration['undo_method'] ) ) {
return false;
}
if ( 'post_tags_to_friend_tags' === $id ) {
$recovery = get_option( 'friends_tag_migration_recovery', array() );
return ! empty( $recovery );
}
if ( 'convert_replies_to_comments' === $id ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_redirects_to_comment'
)
);
return $count > 0;
}
return false;
}
/**
* AJAX handler for getting migration status.
*/
public function ajax_get_migration_status() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
}
if ( ! check_ajax_referer( 'friends-migration-status', '_wpnonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
}
$migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
if ( empty( $migration_id ) ) {
wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
}
$status = self::get_migration_status_by_id( $migration_id );
ob_start();
self::render_status_badge( $status );
$html = ob_get_clean();
wp_send_json_success(
array(
'status' => $status,
'html' => $html,
'in_progress' => ! empty( $status['in_progress'] ),
)
);
}
/**
* AJAX handler for processing a migration batch.
*/
public function ajax_process_migration_batch() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
}
if ( ! check_ajax_referer( 'friends-process-batch', '_wpnonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
}
$migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
if ( empty( $migration_id ) ) {
wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
}
$result = self::process_batch( $migration_id );
if ( ! $result['success'] ) {
wp_send_json_error( $result );
}
// Get updated status.
$status = self::get_migration_status_by_id( $migration_id );
ob_start();
self::render_status_badge( $status );
$html = ob_get_clean();
wp_send_json_success(
array(
'status' => $status,
'html' => $html,
'in_progress' => ! empty( $status['in_progress'] ),
)
);
}
/**
* AJAX handler for getting migration debug output.
*/
public function ajax_get_migration_debug() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'friends' ) ) );
}
if ( ! check_ajax_referer( 'friends-migration-debug', '_wpnonce', false ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'friends' ) ) );
}
$migration_id = isset( $_POST['migration_id'] ) ? sanitize_key( $_POST['migration_id'] ) : '';
if ( empty( $migration_id ) ) {
wp_send_json_error( array( 'message' => __( 'No migration ID provided.', 'friends' ) ) );
}
$status = self::get_migration_status_by_id( $migration_id );
ob_start();
/**
* Hook to output debug content for a specific migration via AJAX.
*
* @param array $status The migration status.
*/
do_action( 'friends_migration_debug_' . $migration_id, $status );
$html = ob_get_clean();
if ( empty( $html ) ) {
wp_send_json_error( array( 'message' => __( 'No debug handler registered for this migration.', 'friends' ) ) );
}
wp_send_json_success( array( 'html' => $html ) );
}
/**
* Render a migration status badge.
*
* @param array $status The migration status.
*/
public static function render_status_badge( $status ) {
if ( ! empty( $status['in_progress'] ) ) {
echo '' . esc_html__( 'In Progress', 'friends' ) . '';
if ( isset( $status['percent'] ) ) {
echo '
';
echo '' . esc_html( $status['processed'] ) . ' / ' . esc_html( $status['total'] ) . '';
}
// Show next scheduled time if available.
if ( ! empty( $status['next_scheduled'] ) ) {
echo '
' . esc_html(
sprintf(
/* translators: %s is a relative time like "in 5 seconds" */
__( 'Next batch: %s', 'friends' ),
human_time_diff( time(), $status['next_scheduled'] )
)
) . '';
} else {
echo '
' . esc_html__( 'No batch scheduled!', 'friends' ) . '';
}
} elseif ( $status['completed'] ) {
if ( ! empty( $status['no_tracking'] ) ) {
echo '' . esc_html__( 'Completed', 'friends' ) . '';
echo '
' . esc_html__( '(no tracking)', 'friends' ) . '';
} else {
echo '' . esc_html__( 'Completed', 'friends' ) . '';
}
} else {
echo '' . esc_html__( 'Pending', 'friends' ) . '';
}
}
/**
* Migrate user gravatar option to user_icon_url (version 0.20.1)
*/
public static function migrate_gravatar_to_user_icon_url() {
$users = User_Query::all_associated_users();
foreach ( $users->get_results() as $user ) {
$gravatar = get_user_option( 'friends_gravatar', $user->ID );
$user_icon_url = get_user_option( 'friends_user_icon_url', $user->ID );
if ( $gravatar ) {
if ( ! $user_icon_url ) {
update_user_option( $user->ID, 'friends_user_icon_url', $gravatar );
}
delete_user_option( $user->ID, 'friends_gravatar' );
}
}
}
/**
* Migrate feed options from global to user options (version 2.6.0)
*/
public static function migrate_feed_options_to_user_options() {
$users = User_Query::all_associated_users();
foreach ( $users->get_results() as $user ) {
if ( get_option( 'friends_feed_rules_' . $user->ID ) ) {
$user->update_user_option( 'friends_feed_rules', get_option( 'friends_feed_rules_' . $user->ID ) );
}
if ( get_option( 'friends_feed_catch_all_' . $user->ID ) ) {
$user->update_user_option( 'friends_feed_catch_all', get_option( 'friends_feed_catch_all_' . $user->ID ) );
}
}
}
/**
* Enable WordPress friendships flag if used (version 2.8.7)
*/
public static function enable_wp_friendships_if_used() {
$users = User_Query::all_associated_users();
foreach ( $users->get_results() as $user ) {
if ( ! ( $user instanceof Subscription ) ) {
// We have a user that is not a virtual user, so the friendship functionality had been used.
update_option( 'friends_enable_wp_friendships', 1 );
break;
}
}
}
/**
* Migrate external mentions user and upgrade cron schedule (version 2.9.4)
*/
public static function migrate_external_user_and_cron() {
// Migrate to the new External user.
$user = User::get_by_username( 'external-mentions' );
if ( $user && $user instanceof Subscription ) {
wp_update_term(
$user->get_term_id(),
Subscription::TAXONOMY,
array(
'slug' => 'external',
'name' => _x( 'External', 'user name', 'friends' ),
)
);
$user->update_user_option( 'display_name', _x( 'External', 'user name', 'friends' ) );
}
// Upgrade cron schedule.
$next_scheduled = wp_next_scheduled( 'cron_friends_refresh_feeds' );
if ( $next_scheduled ) {
$event = wp_get_scheduled_event( 'cron_friends_refresh_feeds' );
if ( $event && 'fifteen-minutes' !== $event->schedule ) {
wp_unschedule_event( $next_scheduled, 'cron_friends_refresh_feeds' );
$next_scheduled = false;
}
}
if ( ! $next_scheduled ) {
wp_schedule_event( time(), 'fifteen-minutes', 'cron_friends_refresh_feeds' );
}
}
/**
* Migrate frontend default view option to user option (version 3.1.8)
*/
public static function migrate_frontend_default_view_to_user_option() {
$users = User_Query::all_admin_users();
foreach ( $users->get_results() as $user ) {
$default_view = get_option( 'friends_frontend_default_view' );
if ( $default_view ) {
$user->update_user_option( 'friends_frontend_default_view', $default_view );
}
}
}
/**
* Migrate post_tag taxonomy to friend_tag taxonomy for Friends CPT posts (version 4.0.0)
* Initiates batched migration for large datasets.
*/
public static function migrate_post_tags_to_friend_tags() {
// Check if migration is already in progress.
if ( get_option( 'friends_tag_migration_in_progress' ) ) {
return;
}
// Check if migration has already been completed.
if ( get_option( 'friends_tag_migration_completed' ) ) {
return;
}
// Count total posts to migrate.
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
$total_posts = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE p.post_type = %s AND tt.taxonomy = 'post_tag'",
Friends::CPT
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
if ( ! $total_posts ) {
return;
}
// Set migration progress tracking (not autoloaded).
update_option( 'friends_tag_migration_in_progress', true, false );
update_option( 'friends_tag_migration_total', $total_posts, false );
update_option( 'friends_tag_migration_processed', 0, false );
update_option( 'friends_tag_migration_recovery', array(), false );
// Schedule the first batch.
wp_schedule_single_event( time(), 'friends_migrate_post_tags_batch' );
}
/**
* Process a single batch of post tag migration.
*/
public static function migrate_post_tags_batch() {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
$batch_size = apply_filters( 'friends_tag_migration_batch_size', 100 );
// Always query from offset 0: processed relationships are deleted each batch,
// so the next unprocessed batch is always at the start of the result set.
$friends_posts_with_tags = $wpdb->get_results(
$wpdb->prepare(
"SELECT p.ID, tr.term_taxonomy_id, t.term_id, t.name, t.slug
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
WHERE p.post_type = %s AND tt.taxonomy = 'post_tag'
ORDER BY p.ID, t.term_id
LIMIT %d OFFSET 0",
Friends::CPT,
$batch_size
)
);
if ( empty( $friends_posts_with_tags ) ) {
// Migration complete - run cleanup.
self::finalize_post_tag_migration();
return;
}
// Group posts by ID to handle multiple tags per post.
$posts_by_id = array();
foreach ( $friends_posts_with_tags as $tagged_post ) {
if ( ! isset( $posts_by_id[ $tagged_post->ID ] ) ) {
$posts_by_id[ $tagged_post->ID ] = array();
}
$posts_by_id[ $tagged_post->ID ][] = $tagged_post;
}
// Process each post with all its tags.
$term_taxonomy_ids = array();
$term_ids_to_check = array();
foreach ( $posts_by_id as $post_id => $post_tags ) {
// Collect all tag names for this post.
$tag_names = array();
foreach ( $post_tags as $tagged_post ) {
$tag_names[] = $tagged_post->name;
$term_taxonomy_ids[] = $tagged_post->term_taxonomy_id;
$term_ids_to_check[] = $tagged_post->term_id;
}
// Add all friend_tags to this post at once.
wp_set_post_terms( $post_id, $tag_names, Friends::TAG_TAXONOMY, true );
}
// Store recovery data before deleting relationships.
$recovery = get_option( 'friends_tag_migration_recovery', array() );
foreach ( $posts_by_id as $post_id => $post_tags ) {
$recovery[ $post_id ] = wp_list_pluck( $post_tags, 'name' );
}
update_option( 'friends_tag_migration_recovery', $recovery, false );
// Bulk delete post_tag relationships for this batch of Friends posts.
if ( ! empty( $term_taxonomy_ids ) ) {
$processed_post_ids = array_keys( $posts_by_id );
$post_placeholders = implode( ',', array_fill( 0, count( $processed_post_ids ), '%d' ) );
$term_placeholders = implode( ',', array_fill( 0, count( $term_taxonomy_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->term_relationships}
WHERE object_id IN ($post_placeholders)
AND term_taxonomy_id IN ($term_placeholders)",
array_merge( $processed_post_ids, $term_taxonomy_ids )
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
}
// Update progress.
$processed = (int) get_option( 'friends_tag_migration_processed', 0 );
$processed += count( $posts_by_id );
update_option( 'friends_tag_migration_processed', $processed, false );
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
// Schedule next batch.
wp_schedule_single_event( time() + 1, 'friends_migrate_post_tags_batch' );
}
/**
* Finalize post tag migration by cleaning up orphaned terms.
*/
public static function finalize_post_tag_migration() {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// Get all post_tag terms that were potentially used by Friends posts.
$all_post_tag_terms = $wpdb->get_results(
"SELECT DISTINCT t.term_id
FROM {$wpdb->terms} t
INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'post_tag'"
);
// Clean up orphaned post_tag terms.
foreach ( $all_post_tag_terms as $term_data ) {
// Calculate count excluding revisions, ap_actor, post_collection, and Friends CPT.
$real_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.term_id = %d
AND tt.taxonomy = 'post_tag'
AND p.post_status IN ('publish', 'private', 'draft', 'pending', 'future')
AND p.post_type NOT IN ('revision', 'ap_actor', 'post_collection', %s)",
$term_data->term_id,
Friends::CPT
)
);
if ( 0 === (int) $real_count ) {
wp_delete_term( $term_data->term_id, 'post_tag' );
}
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
// Clear migration progress flags.
delete_option( 'friends_tag_migration_in_progress' );
delete_option( 'friends_tag_migration_total' );
delete_option( 'friends_tag_migration_processed' );
// Mark migration as completed (not autoloaded).
update_option( 'friends_tag_migration_completed', time(), false );
// Log completion.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'Friends: Post tag migration completed successfully.' );
}
/**
* Get migration status information.
*
* @return array Array with migration status details.
*/
public static function get_migration_status() {
$status = array(
'completed' => (bool) get_option( 'friends_tag_migration_completed' ),
'in_progress' => (bool) get_option( 'friends_tag_migration_in_progress' ),
'total' => (int) get_option( 'friends_tag_migration_total', 0 ),
'processed' => (int) get_option( 'friends_tag_migration_processed', 0 ),
'completed_time' => get_option( 'friends_tag_migration_completed' ),
);
if ( $status['total'] > 0 && $status['processed'] > 0 ) {
$status['progress_percent'] = min( 100, round( ( $status['processed'] / $status['total'] ) * 100, 1 ) );
} else {
$status['progress_percent'] = 0;
}
return $status;
}
/**
* Reset migration status to allow re-running migration.
* Useful for manual triggers or development.
*/
public static function reset_migration_status() {
delete_option( 'friends_tag_migration_completed' );
delete_option( 'friends_tag_migration_in_progress' );
delete_option( 'friends_tag_migration_total' );
delete_option( 'friends_tag_migration_processed' );
delete_option( 'friends_tag_migration_offset' );
// Clear any scheduled migration events.
wp_clear_scheduled_hook( 'friends_migrate_post_tags_batch' );
}
/**
* Manually trigger post tag migration.
* Resets status first to allow re-running.
*/
public static function trigger_migration_manually() {
self::reset_migration_status();
self::migrate_post_tags_to_friend_tags();
}
/**
* Clean up orphaned post_tag terms that exist in friend_tag taxonomy.
* These are post_tag terms that were originally created by Friends posts
* but are now orphaned after migration to friend_tag taxonomy.
*
* @return array Cleanup results with counts.
*/
public static function cleanup_orphaned_post_tags() {
// Use the comprehensive cleanup approach instead of Friends-focused.
return self::recalculate_all_post_tag_counts();
}
/**
* Recalculate post_tag counts and cleanup orphaned tags.
* This recalculates counts for ALL post_tag terms excluding Friends CPT posts,
* and removes any with zero count after recalculation.
*
* @return array Cleanup results with counts.
*/
public static function recalculate_all_post_tag_counts() {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// Get ALL post_tag terms (not just those with friend_tag equivalents)
$all_post_tags = $wpdb->get_results(
"SELECT t.term_id, t.name, t.slug, tt.count
FROM {$wpdb->terms} t
INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'post_tag'"
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
$cleanup_results = array(
'checked' => 0,
'recalculated' => 0,
'deleted' => 0,
'tags_processed' => array(),
);
foreach ( $all_post_tags as $tag_data ) {
++$cleanup_results['checked'];
$old_count = $tag_data->count;
// Calculate the REAL count excluding revisions, ap_actor, post_collection, and Friends CPT.
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
$real_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.term_id = %d
AND tt.taxonomy = 'post_tag'
AND p.post_status IN ('publish', 'private', 'draft', 'pending', 'future')
AND p.post_type NOT IN ('revision', 'ap_actor', 'post_collection', %s)",
$tag_data->term_id,
Friends::CPT
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// Update the term count in the database to reflect reality (excluding Friends posts and other irrelevant types)
$update_result = $wpdb->update(
$wpdb->term_taxonomy,
array( 'count' => $real_count ),
array(
'term_id' => $tag_data->term_id,
'taxonomy' => 'post_tag',
)
);
// Clear term cache so get_term() returns the updated count.
clean_term_cache( $tag_data->term_id, 'post_tag' );
++$cleanup_results['recalculated'];
$new_count = (int) $real_count;
// Log if count changed or if we're about to delete.
if ( $old_count !== $new_count || 0 === $new_count ) {
$cleanup_results['tags_processed'][] = array(
'name' => $tag_data->name,
'slug' => $tag_data->slug,
'old_count' => $old_count,
'new_count' => $new_count,
'action' => 0 === $new_count ? 'deleted' : 'count_updated',
'posts_using' => $new_count,
);
}
// If count is 0 after recalculation, delete the orphaned post_tag.
if ( 0 === $new_count ) {
$deleted = wp_delete_term( $tag_data->term_id, 'post_tag' );
if ( ! is_wp_error( $deleted ) && $deleted ) {
++$cleanup_results['deleted'];
}
}
}
return $cleanup_results;
}
/**
* Backfill mention tags from Mastodon HTML content (version 4.1.0)
*/
public static function backfill_mention_tags_from_mastodon_html() {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// Build lookup table of local user ActivityPub URLs before processing posts.
$local_user_urls = self::build_local_user_activitypub_lookup();
if ( empty( $local_user_urls ) ) {
update_option( 'friends_backfill_mention_tags_completed', true, false );
return;
}
// Get all Friends posts that might contain Mastodon mentions in HTML.
$posts_with_html = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_content
FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status IN ('publish', 'private')
AND post_content LIKE %s",
Friends::CPT,
'%u-url mention%'
)
);
if ( empty( $posts_with_html ) ) {
update_option( 'friends_backfill_mention_tags_completed', true, false );
return;
}
$processed_count = 0;
$mention_tags_added = 0;
foreach ( $posts_with_html as $post ) {
$mention_tags = self::extract_mention_tags_from_html( $post->post_content, $local_user_urls );
if ( ! empty( $mention_tags ) ) {
wp_set_post_terms( $post->ID, $mention_tags, Friends::TAG_TAXONOMY, true );
$mention_tags_added += count( $mention_tags );
}
++$processed_count;
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
if ( $processed_count > 0 ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Processed %d posts, added %d mention tags', $processed_count, $mention_tags_added ) );
}
update_option( 'friends_backfill_mention_tags_completed', true, false );
}
/**
* Build a lookup table of local user ActivityPub URLs to usernames
*
* @return array Array mapping ActivityPub URLs to usernames
*/
private static function build_local_user_activitypub_lookup() {
$lookup = array();
// Check if ActivityPub plugin is available.
if ( ! class_exists( '\Activitypub\Collection\Actors' ) ) {
return $lookup;
}
$users = get_users();
foreach ( $users as $user ) {
try {
// Use ActivityPub plugin to get the proper actor URL.
$actor = \Activitypub\Collection\Actors::get_by_id( $user->ID );
if ( ! is_wp_error( $actor ) && $actor ) {
$activitypub_url = $actor->get_id();
if ( $activitypub_url ) {
$lookup[ $activitypub_url ] = $user->user_login;
}
}
} catch ( Exception $e ) {
// Skip this user if there's an error getting their ActivityPub ID.
continue;
}
}
return $lookup;
}
/**
* Extract mention tags from Mastodon HTML content
*
* @param string $html_content The HTML content to parse.
* @param array $local_user_urls Lookup table of local user URLs to usernames.
* @return array Array of mention tag names
*/
private static function extract_mention_tags_from_html( $html_content, $local_user_urls ) {
$mention_tags = array();
// Parse HTML to find mention links:
// Pattern: @USERNAME.
$pattern = '/]*href=["\'](https?:\/\/[^"\']+)["\'][^>]*class=["\'][^"\']*u-url mention[^"\']*["\'][^>]*>@([^<]+)<\/span><\/a>/i';
if ( preg_match_all( $pattern, $html_content, $matches, PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$activitypub_url = $match[1];
// Look up the URL in our pre-built lookup table.
if ( isset( $local_user_urls[ $activitypub_url ] ) ) {
$username = $local_user_urls[ $activitypub_url ];
$mention_tags[] = Friend_Tag::mention_tag( $username );
}
}
}
return array_unique( $mention_tags );
}
/**
* Import existing followings from the ActivityPub plugin (version 4.1.0)
*
* This method runs on upgrade and imports any existing follows from
* the ActivityPub plugin that don't already exist in Friends.
*/
public static function import_activitypub_followings() {
// Check if the Following class is available (ActivityPub plugin 7.x+).
if ( ! class_exists( '\Activitypub\Collection\Following' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
return;
}
// Check if already imported.
if ( get_option( 'friends_activitypub_followings_imported' ) ) {
return;
}
// Get the ActivityPub user ID.
$user_id = Feed_Parser_ActivityPub::get_activitypub_actor_id( Friends::get_main_friend_user_id() );
// Get all followings from ActivityPub.
$actors = \Activitypub\Collection\Following::get_all( $user_id );
if ( is_wp_error( $actors ) || ! is_array( $actors ) ) {
update_option( 'friends_activitypub_followings_imported', true, false );
return;
}
$imported = 0;
$feed_parser = new Feed_Parser_ActivityPub( Friends::get_instance()->feed );
foreach ( $actors as $actor ) {
if ( ! $actor instanceof \WP_Post ) {
continue;
}
// Get URL from the post's guid field (canonical actor URL).
$actor_url = $actor->guid;
if ( empty( $actor_url ) || ! is_string( $actor_url ) ) {
continue;
}
// Check if a Friends subscription already exists for this actor.
$existing_feed = User_Feed::get_by_url( $actor_url );
if ( $existing_feed instanceof User_Feed ) {
continue;
}
// Create a new Friends subscription for this actor.
$result = $feed_parser->create_friend_subscription_from_actor( $actor_url );
if ( ! is_wp_error( $result ) ) {
++$imported;
}
}
update_option( 'friends_activitypub_followings_imported', true, false );
update_option( 'friends_activitypub_followings_imported_count', $imported, false );
}
/**
* Migrate ActivityPub attributedTo from URL format to ap_actor post ID format (version 4.1.0)
* Initiates batched migration for large datasets.
*/
public static function migrate_activitypub_attributed_to() {
// Check if the Remote_Actors class is available (ActivityPub plugin 7.x+).
if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
return;
}
// Check if migration is already in progress.
if ( get_option( 'friends_ap_attributed_to_migration_in_progress' ) ) {
return;
}
// Check if migration has already been completed.
if ( get_option( 'friends_ap_attributed_to_migration_completed' ) ) {
return;
}
// Count total posts to migrate (posts with attributedTo.id but not ap_actor_id).
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
$total_posts = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->postmeta}
WHERE meta_key = %s
AND meta_value LIKE %s
AND meta_value NOT LIKE %s",
Feed_Parser_ActivityPub::SLUG,
'%"attributedTo"%"id"%',
'%"ap_actor_id"%'
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
if ( ! $total_posts ) {
// No posts to migrate.
update_option( 'friends_ap_attributed_to_migration_completed', true, false );
return;
}
// Set migration progress tracking (not autoloaded).
update_option( 'friends_ap_attributed_to_migration_in_progress', true, false );
update_option( 'friends_ap_attributed_to_migration_total', $total_posts, false );
update_option( 'friends_ap_attributed_to_migration_processed', 0, false );
// Schedule the first batch.
wp_schedule_single_event( time(), 'friends_migrate_ap_attributed_to_batch' );
}
/**
* Process a single batch of attributedTo migration.
*/
public static function migrate_activitypub_attributed_to_batch() {
// Check if the Remote_Actors class is available.
if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
self::finalize_ap_attributed_to_migration();
return;
}
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
$batch_size = apply_filters( 'friends_ap_attributed_to_migration_batch_size', 50 );
// Always query from offset 0: successfully migrated posts gain ap_actor_id and
// drop out of the NOT LIKE filter. Failed posts get ap_actor_id=0 as sentinel.
$posts_to_migrate = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value
FROM {$wpdb->postmeta}
WHERE meta_key = %s
AND meta_value LIKE %s
AND meta_value NOT LIKE %s
ORDER BY post_id
LIMIT %d OFFSET 0",
Feed_Parser_ActivityPub::SLUG,
'%"attributedTo"%"id"%',
'%"ap_actor_id"%',
$batch_size
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
if ( empty( $posts_to_migrate ) ) {
// Migration complete.
self::finalize_ap_attributed_to_migration();
return;
}
$processed = (int) get_option( 'friends_ap_attributed_to_migration_processed', 0 );
foreach ( $posts_to_migrate as $row ) {
$meta = maybe_unserialize( $row->meta_value );
if ( ! is_array( $meta ) || ! isset( $meta['attributedTo']['id'] ) ) {
++$processed;
continue;
}
$actor_url = $meta['attributedTo']['id'];
// Try to get or create the ap_actor post ID.
$ap_actor_id = null;
if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
$actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
$ap_actor_id = $actor_post->ID;
}
}
if ( $ap_actor_id ) {
// Update to new format.
$meta['attributedTo']['ap_actor_id'] = $ap_actor_id;
unset( $meta['attributedTo']['id'] );
} else {
// Store sentinel so this post is excluded from future queries.
$meta['attributedTo']['ap_actor_id'] = 0;
}
update_post_meta( $row->post_id, Feed_Parser_ActivityPub::SLUG, $meta );
++$processed;
}
// Update progress.
update_option( 'friends_ap_attributed_to_migration_processed', $processed, false );
// Schedule next batch.
wp_schedule_single_event( time() + 1, 'friends_migrate_ap_attributed_to_batch' );
}
/**
* Finalize the attributedTo migration.
*/
private static function finalize_ap_attributed_to_migration() {
delete_option( 'friends_ap_attributed_to_migration_in_progress' );
update_option( 'friends_ap_attributed_to_migration_completed', true, false );
}
/**
* Backfill cached ActivityPub actor handles for the latest 100 cached posts.
*/
public static function backfill_activitypub_attributed_to_acct() {
if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
update_option( 'friends_ap_attributed_to_acct_backfilled', true, false );
return;
}
if ( get_option( 'friends_ap_attributed_to_acct_backfilled' ) ) {
return;
}
global $wpdb;
$limit = 100;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
$posts_to_migrate = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.post_id, pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status IN ( 'publish', 'private' )
AND pm.meta_key = %s
AND pm.meta_value LIKE %s
AND pm.meta_value LIKE %s
AND pm.meta_value NOT LIKE %s
ORDER BY p.post_date_gmt DESC, p.ID DESC
LIMIT %d",
Friends::CPT,
Feed_Parser_ActivityPub::SLUG,
'%"attributedTo"%',
'%"ap_actor_id"%',
'%"acct"%',
$limit
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = 0;
foreach ( $posts_to_migrate as $row ) {
$meta = maybe_unserialize( $row->meta_value );
if ( ! is_array( $meta ) || empty( $meta['attributedTo']['ap_actor_id'] ) || ! empty( $meta['attributedTo']['acct'] ) ) {
continue;
}
$acct = \Activitypub\Collection\Remote_Actors::get_acct( $meta['attributedTo']['ap_actor_id'] );
if ( ! $acct ) {
continue;
}
$meta['attributedTo']['acct'] = ltrim( $acct, '@' );
update_post_meta( $row->post_id, Feed_Parser_ActivityPub::SLUG, $meta );
++$updated;
}
update_option( 'friends_ap_attributed_to_acct_backfilled', true, false );
update_option( 'friends_ap_attributed_to_acct_backfilled_count', $updated, false );
}
/**
* Link existing ActivityPub feeds to their ap_actor posts (version 4.1.0)
*
* This migration finds all User_Feed entries with parser='activitypub'
* and links them to their corresponding ap_actor posts by assigning
* the User_Feed taxonomy term to the ap_actor post.
*/
public static function link_activitypub_feeds_to_actors() {
// Check if the Remote_Actors class is available (ActivityPub plugin 7.x+).
if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
return;
}
// Check if migration is already in progress.
if ( get_option( 'friends_ap_feeds_link_migration_in_progress' ) ) {
return;
}
// Check if migration has already been completed.
if ( get_option( 'friends_ap_feeds_linked_to_actors' ) ) {
return;
}
// Get all ActivityPub feeds to count them.
$activitypub_feeds = User_Feed::get_by_parser( Feed_Parser_ActivityPub::SLUG );
if ( empty( $activitypub_feeds ) ) {
update_option( 'friends_ap_feeds_linked_to_actors', true, false );
return;
}
$total = count( $activitypub_feeds );
// Set migration progress tracking (not autoloaded).
update_option( 'friends_ap_feeds_link_migration_in_progress', true, false );
update_option( 'friends_ap_feeds_link_migration_total', $total, false );
update_option( 'friends_ap_feeds_link_migration_processed', 0, false );
update_option( 'friends_ap_feeds_link_migration_linked', 0, false );
update_option( 'friends_ap_feeds_link_migration_skipped', 0, false );
update_option( 'friends_ap_feeds_link_migration_failed', 0, false );
update_option( 'friends_ap_feeds_link_migration_failed_urls', array(), false );
// Clear any previous run's stats.
delete_option( 'friends_ap_feeds_linked_count' );
delete_option( 'friends_ap_feeds_skipped_count' );
delete_option( 'friends_ap_feeds_failed_count' );
delete_option( 'friends_ap_feeds_failed_urls' );
// Schedule the first batch.
wp_schedule_single_event( time(), 'friends_link_ap_feeds_batch' );
}
/**
* Reduce ActivityPub HTTP timeout during migration.
*
* @return int Timeout in seconds.
*/
public static function reduce_activitypub_timeout() {
return 15; // 15 seconds instead of default 100.
}
/**
* Process a single batch of linking ActivityPub feeds to actors.
*/
public static function link_activitypub_feeds_to_actors_batch() {
// Check if the Remote_Actors class is available.
if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
self::finalize_ap_feeds_link_migration();
return;
}
// Reduce HTTP timeout to avoid PHP max_execution_time issues.
add_filter( 'activitypub_remote_get_timeout', array( __CLASS__, 'reduce_activitypub_timeout' ) );
// Process one at a time since network requests can timeout.
$batch_size = apply_filters( 'friends_ap_feeds_link_batch_size', 1 );
// Get all ActivityPub feeds.
$activitypub_feeds = User_Feed::get_by_parser( Feed_Parser_ActivityPub::SLUG );
if ( empty( $activitypub_feeds ) ) {
self::finalize_ap_feeds_link_migration();
return;
}
$processed = (int) get_option( 'friends_ap_feeds_link_migration_processed', 0 );
$linked = (int) get_option( 'friends_ap_feeds_link_migration_linked', 0 );
$skipped = (int) get_option( 'friends_ap_feeds_link_migration_skipped', 0 );
$failed = (int) get_option( 'friends_ap_feeds_link_migration_failed', 0 );
$failed_urls = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
// Check for crash recovery - if we were processing a URL and crashed, skip it.
$current_url = get_option( 'friends_ap_feeds_link_migration_current_url', '' );
if ( $current_url ) {
// We crashed while processing this URL - mark it as failed and move on.
++$failed;
$failed_urls[] = array(
'url' => $current_url,
'time' => time(),
'feed' => 'Unknown (crashed)',
'reason' => 'timeout_or_crash',
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Recovered from crash on %s - skipping', $current_url ) );
delete_option( 'friends_ap_feeds_link_migration_current_url' );
// Update failed counts.
update_option( 'friends_ap_feeds_link_migration_failed', $failed, false );
update_option( 'friends_ap_feeds_link_migration_failed_urls', $failed_urls, false );
}
// Get the batch to process.
$batch = array_slice( $activitypub_feeds, $processed, $batch_size );
if ( empty( $batch ) ) {
self::finalize_ap_feeds_link_migration();
return;
}
foreach ( $batch as $feed ) {
++$processed;
// Skip if already linked.
if ( $feed->get_ap_actor_id() ) {
++$skipped;
continue;
}
// Get the feed URL (stored as term name).
$actor_url = $feed->get_url();
if ( empty( $actor_url ) ) {
continue;
}
// Check if this URL was already marked as failed (from a previous crash).
$already_failed = false;
foreach ( $failed_urls as $failed_item ) {
if ( $failed_item['url'] === $actor_url ) {
$already_failed = true;
break;
}
}
if ( $already_failed ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Skipping previously failed URL: %s', $actor_url ) );
continue;
}
// Log which URL we're about to process.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Processing feed %d/%d: %s', $processed, count( $activitypub_feeds ), $actor_url ) );
// Store current URL for crash recovery before network request.
update_option( 'friends_ap_feeds_link_migration_current_url', $actor_url, false );
// Fetch or create the ap_actor using ActivityPub plugin API.
$ap_actor_id = null;
$actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
$ap_actor_id = $actor_post->ID;
}
// Clear crash recovery marker.
delete_option( 'friends_ap_feeds_link_migration_current_url' );
if ( $ap_actor_id ) {
$result = $feed->set_ap_actor_id( $ap_actor_id );
if ( is_wp_error( $result ) || false === $result ) {
++$failed;
$failed_urls[] = array(
'url' => $actor_url,
'time' => time(),
'feed' => $feed->get_friend_user()->display_name ?? 'Unknown',
'reason' => is_wp_error( $result ) ? $result->get_error_message() : 'set_ap_actor_id_failed',
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Failed to link %s - set_ap_actor_id failed', $actor_url ) );
} else {
if ( $feed->get_active() ) {
self::ensure_activitypub_following( $actor_url );
}
++$linked;
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Linked %s to ap_actor %d', $actor_url, $ap_actor_id ) );
}
} else {
++$failed;
$failed_urls[] = array(
'url' => $actor_url,
'time' => time(),
'feed' => $feed->get_friend_user()->display_name ?? 'Unknown',
'reason' => 'could_not_resolve',
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Failed to link %s - could not resolve actor', $actor_url ) );
}
}
// Update progress.
update_option( 'friends_ap_feeds_link_migration_processed', $processed, false );
update_option( 'friends_ap_feeds_link_migration_linked', $linked, false );
update_option( 'friends_ap_feeds_link_migration_skipped', $skipped, false );
update_option( 'friends_ap_feeds_link_migration_failed', $failed, false );
update_option( 'friends_ap_feeds_link_migration_failed_urls', $failed_urls, false );
// Check if we're done.
$total = (int) get_option( 'friends_ap_feeds_link_migration_total', 0 );
if ( $processed >= $total ) {
self::finalize_ap_feeds_link_migration();
return;
}
// Schedule next batch.
wp_schedule_single_event( time() + 1, 'friends_link_ap_feeds_batch' );
}
/**
* Ensure the main Friends user is following the given actor in the ActivityPub plugin.
*
* @param string $actor_url The actor URL.
*/
private static function ensure_activitypub_following( $actor_url ) {
if ( ! function_exists( '\Activitypub\follow' ) ) {
return;
}
$user_id = Feed_Parser_ActivityPub::get_activitypub_actor_id( null );
$result = \Activitypub\follow( $actor_url, $user_id );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Failed to follow %s: %s', $actor_url, $result->get_error_message() ) );
} else {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Sent follow request for %s', $actor_url ) );
}
}
/**
* Finalize the AP feeds link migration.
*/
private static function finalize_ap_feeds_link_migration() {
$linked = (int) get_option( 'friends_ap_feeds_link_migration_linked', 0 );
$skipped = (int) get_option( 'friends_ap_feeds_link_migration_skipped', 0 );
$failed = (int) get_option( 'friends_ap_feeds_link_migration_failed', 0 );
$failed_urls = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
// Clean up progress options.
delete_option( 'friends_ap_feeds_link_migration_in_progress' );
delete_option( 'friends_ap_feeds_link_migration_total' );
delete_option( 'friends_ap_feeds_link_migration_processed' );
// Keep the counts and failed URLs for reference.
update_option( 'friends_ap_feeds_linked_to_actors', true, false );
update_option( 'friends_ap_feeds_linked_count', $linked, false );
update_option( 'friends_ap_feeds_skipped_count', $skipped, false );
update_option( 'friends_ap_feeds_failed_count', $failed, false );
update_option( 'friends_ap_feeds_failed_urls', $failed_urls, false );
// Clean up batch counts and crash recovery.
delete_option( 'friends_ap_feeds_link_migration_linked' );
delete_option( 'friends_ap_feeds_link_migration_skipped' );
delete_option( 'friends_ap_feeds_link_migration_failed' );
delete_option( 'friends_ap_feeds_link_migration_failed_urls' );
delete_option( 'friends_ap_feeds_link_migration_current_url' );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Completed - Linked %d, Already linked %d, Failed %d', $linked, $skipped, $failed ) );
if ( $failed > 0 && ! empty( $failed_urls ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'Friends Migration: Failed URLs: ' . wp_json_encode( array_column( $failed_urls, 'url' ) ) );
}
}
/**
* Render the admin migrations page.
*/
public static function render_admin_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'friends' ) );
}
add_filter(
'friends_admin_tabs',
function ( $tabs ) {
$tabs[ __( 'Migrations', 'friends' ) ] = 'friends-migrations';
return $tabs;
}
);
Friends::template_loader()->get_template_part(
'admin/settings-header',
null,
array(
'active' => 'friends-migrations',
'title' => __( 'Friends', 'friends' ),
)
);
// Handle version update form submission.
if ( isset( $_POST['update_version'] ) && check_admin_referer( 'friends-update-version' ) ) {
$new_version = isset( $_POST['stored_version'] ) ? sanitize_text_field( wp_unslash( $_POST['stored_version'] ) ) : '';
if ( empty( $new_version ) ) {
delete_option( 'friends_plugin_version' );
$message = __( 'Stored version cleared. Migrations will run on next page load.', 'friends' );
} else {
update_option( 'friends_plugin_version', $new_version );
$message = sprintf(
/* translators: %s is a version number */
__( 'Stored version updated to %s.', 'friends' ),
$new_version
);
}
echo '' . esc_html( $message ) . '
';
}
$statuses = self::get_all_statuses();
Friends::template_loader()->get_template_part(
'admin/migrations',
null,
array(
'statuses' => $statuses,
)
);
}
/**
* Debug output hook for the link_activitypub_feeds_to_actors migration.
*
* @param array $status The migration status (unused but required by hook signature).
*/
public function debug_link_activitypub_feeds_to_actors( $status ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! class_exists( '\Feed_Parser_ActivityPub' ) ) {
echo '' . esc_html__( 'ActivityPub plugin not detected. Migration cannot proceed.', 'friends' ) . '
';
return;
}
$failed_urls = get_option( 'friends_ap_feeds_failed_urls', array() );
$failed_count = (int) get_option( 'friends_ap_feeds_failed_count', 0 );
$linked_count = (int) get_option( 'friends_ap_feeds_linked_count', 0 );
$skipped_count = (int) get_option( 'friends_ap_feeds_skipped_count', 0 );
// Also check in-progress stats.
$in_progress_linked = (int) get_option( 'friends_ap_feeds_link_migration_linked', 0 );
$in_progress_skipped = (int) get_option( 'friends_ap_feeds_link_migration_skipped', 0 );
$in_progress_failed = get_option( 'friends_ap_feeds_link_migration_failed_urls', array() );
$linked_count += $in_progress_linked;
$skipped_count += $in_progress_skipped;
$failed_count += count( $in_progress_failed );
if ( ! empty( $in_progress_failed ) ) {
$failed_urls = array_merge( $failed_urls, $in_progress_failed );
}
echo '';
// Calculate live counts.
$activitypub_feeds = User_Feed::get_by_parser( Feed_Parser_ActivityPub::SLUG );
$actually_linked = 0;
$total_feeds = count( $activitypub_feeds );
foreach ( $activitypub_feeds as $feed ) {
if ( $feed->get_ap_actor_id() ) {
++$actually_linked;
}
}
echo '
' . esc_html__( 'Migration Statistics', 'friends' ) . '
';
echo '
';
echo '- ' . esc_html__( 'Linked (stored):', 'friends' ) . ' ' . esc_html( $linked_count ) . '
';
echo '- ' . esc_html__( 'Actually linked (live):', 'friends' ) . ' ' . esc_html( $actually_linked ) . ' / ' . esc_html( $total_feeds ) . '
';
echo '- ' . esc_html__( 'Already linked (skipped):', 'friends' ) . ' ' . esc_html( $skipped_count ) . '
';
echo '- ' . esc_html__( 'Failed:', 'friends' ) . ' ' . esc_html( $failed_count ) . '
';
echo '
';
// Show current URL being processed (if migration is in progress).
$current_url = get_option( 'friends_ap_feeds_link_migration_current_url', '' );
if ( $current_url ) {
echo '
';
echo '' . esc_html__( 'Currently processing:', 'friends' ) . ' ';
echo '' . esc_html( $current_url ) . '';
echo '
' . esc_html__( 'If this URL appears stuck, it may be causing a timeout.', 'friends' ) . '';
echo '
';
}
if ( ! empty( $failed_urls ) ) {
echo '
' . esc_html__( 'Failed URLs', 'friends' ) . '
';
$retry_nonce = wp_create_nonce( 'friends_clear_failed_url' );
$deactivate_nonce = wp_create_nonce( 'friends_deactivate_feed' );
echo '';
echo '
';
echo '
';
echo '| ' . esc_html__( 'Feed/User', 'friends' ) . ' | ' . esc_html__( 'URL', 'friends' ) . ' | ' . esc_html__( 'Reason', 'friends' ) . ' | ' . esc_html__( 'Time', 'friends' ) . ' | ' . esc_html__( 'Actions', 'friends' ) . ' |
';
echo '';
foreach ( $failed_urls as $item ) {
$reason = $item['reason'] ?? 'unknown';
$reason_label = 'timeout_or_crash' === $reason ? __( 'Timeout/Crash', 'friends' ) : __( 'Could not resolve', 'friends' );
echo '';
echo '| ' . esc_html( $item['feed'] ?? 'Unknown' ) . ' | ';
echo '' . esc_html( $item['url'] ) . ' | ';
echo '' . esc_html( $reason_label ) . ' | ';
echo '' . esc_html( isset( $item['time'] ) ? human_time_diff( $item['time'] ) . ' ago' : 'N/A' ) . ' | ';
echo '';
echo ' ';
echo '';
echo ' | ';
echo '
';
}
echo '
';
} else {
echo '
' . esc_html__( 'No failed URLs recorded.', 'friends' ) . '
';
}
// Show feeds that still need linking (excluding those already shown in failed URLs).
$failed_url_list = array_column( $failed_urls, 'url' );
$unlinked = array();
foreach ( $activitypub_feeds as $feed ) {
if ( ! $feed->get_ap_actor_id() && ! in_array( $feed->get_url(), $failed_url_list, true ) ) {
$unlinked[] = array(
'url' => $feed->get_url(),
'user' => $feed->get_friend_user()->display_name ?? 'Unknown',
);
}
}
if ( ! empty( $unlinked ) ) {
// translators: %d is the number of unlinked feeds.
echo '
' . sprintf( esc_html__( 'Other Unlinked Feeds (%d)', 'friends' ), count( $unlinked ) ) . '
';
echo '
' . esc_html__( 'These feeds are not linked and not in the failed list. They may need manual investigation.', 'friends' ) . '
';
echo '
';
echo '| ' . esc_html__( 'User', 'friends' ) . ' | ' . esc_html__( 'Feed URL', 'friends' ) . ' | ' . esc_html__( 'Actions', 'friends' ) . ' |
';
echo '';
foreach ( array_slice( $unlinked, 0, 20 ) as $item ) {
echo '';
echo '| ' . esc_html( $item['user'] ) . ' | ';
echo '' . esc_html( $item['url'] ) . ' | ';
echo ' | ';
echo '
';
}
if ( count( $unlinked ) > 20 ) {
// translators: %d is the number of additional items not shown.
echo '| ' . sprintf( esc_html__( '... and %d more', 'friends' ), count( $unlinked ) - 20 ) . ' |
';
}
echo '
';
}
echo '
';
}
/**
* Backfill attributedTo for External user posts (version 4.0.0)
*
* This migration fetches actor information from the original permalink
* for posts belonging to the External user that don't have attributedTo set.
*/
public static function backfill_external_attributed_to() {
// Check if the Remote_Actors class is available (ActivityPub plugin 7.x+).
if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) || ! class_exists( __NAMESPACE__ . '\Feed_Parser_ActivityPub' ) ) {
return;
}
// Check if migration is already in progress.
if ( get_option( 'friends_external_attributed_to_backfill_in_progress' ) ) {
return;
}
// Check if migration has already been completed.
if ( get_option( 'friends_external_attributed_to_backfill_completed' ) ) {
return;
}
// Get the External user.
$external_user = User::get_by_username( Feed_Parser_ActivityPub::EXTERNAL_USERNAME );
if ( ! $external_user || is_wp_error( $external_user ) ) {
update_option( 'friends_external_attributed_to_backfill_completed', true, false );
return;
}
// Count posts using WP_Query with the same logic as the External page.
$query = new \WP_Query();
$query->set( 'post_type', Friends::CPT );
$query->set( 'post_status', array( 'publish', 'private' ) );
$query->set( 'posts_per_page', -1 );
$query->set( 'fields', 'ids' );
$query->set(
'meta_query',
array(
'relation' => 'OR',
array(
'key' => Feed_Parser_ActivityPub::SLUG,
'compare' => 'NOT EXISTS',
),
array(
'key' => Feed_Parser_ActivityPub::SLUG,
'value' => 'attributedTo',
'compare' => 'NOT LIKE',
),
)
);
$external_user->modify_query_by_author( $query );
$post_ids = $query->get_posts();
$total_posts = count( $post_ids );
if ( ! $total_posts ) {
update_option( 'friends_external_attributed_to_backfill_completed', true, false );
return;
}
// Set migration progress tracking (not autoloaded).
update_option( 'friends_external_attributed_to_backfill_in_progress', true, false );
update_option( 'friends_external_attributed_to_backfill_total', $total_posts, false );
update_option( 'friends_external_attributed_to_backfill_processed', 0, false );
// Schedule the first batch.
wp_schedule_single_event( time(), 'friends_backfill_external_attributed_to_batch' );
}
/**
* Process a single batch of External user attributedTo backfill.
*/
public static function backfill_external_attributed_to_batch() {
// Check if the Remote_Actors class is available.
if ( ! class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
self::finalize_external_attributed_to_backfill();
return;
}
// Get the External user.
$external_user = User::get_by_username( Feed_Parser_ActivityPub::EXTERNAL_USERNAME );
if ( ! $external_user || is_wp_error( $external_user ) ) {
self::finalize_external_attributed_to_backfill();
return;
}
// Reduce HTTP timeout to avoid PHP max_execution_time issues.
add_filter( 'activitypub_remote_get_timeout', array( __CLASS__, 'reduce_activitypub_timeout' ) );
$batch_size = apply_filters( 'friends_external_attributed_to_batch_size', 5 );
// Use WP_Query with the same logic as the External page.
$query = new \WP_Query();
$query->set( 'post_type', Friends::CPT );
$query->set( 'post_status', array( 'publish', 'private' ) );
$query->set( 'posts_per_page', $batch_size );
$query->set( 'orderby', 'ID' );
$query->set( 'order', 'ASC' );
$query->set(
'meta_query',
array(
'relation' => 'OR',
array(
'key' => Feed_Parser_ActivityPub::SLUG,
'compare' => 'NOT EXISTS',
),
array(
'key' => Feed_Parser_ActivityPub::SLUG,
'value' => 'attributedTo',
'compare' => 'NOT LIKE',
),
)
);
$external_user->modify_query_by_author( $query );
$posts_to_process = $query->get_posts();
if ( empty( $posts_to_process ) ) {
self::finalize_external_attributed_to_backfill();
return;
}
$processed = (int) get_option( 'friends_external_attributed_to_backfill_processed', 0 );
foreach ( $posts_to_process as $post ) {
++$processed;
// The guid contains the ActivityPub object URL.
$object_url = $post->guid;
if ( empty( $object_url ) || ! filter_var( $object_url, FILTER_VALIDATE_URL ) ) {
continue;
}
// Fetch the ActivityPub object to get the attributedTo.
$response = \Activitypub\safe_remote_get( $object_url, 0 );
if ( is_wp_error( $response ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Failed to fetch object %s for post %d: %s', $object_url, $post->ID, $response->get_error_message() ) );
continue;
}
$body = wp_remote_retrieve_body( $response );
$object = json_decode( $body, true );
if ( ! is_array( $object ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Invalid response for object %s for post %d', $object_url, $post->ID ) );
continue;
}
$actor_url = null;
if ( isset( $object['attributedTo'] ) ) {
if ( is_string( $object['attributedTo'] ) ) {
$actor_url = $object['attributedTo'];
} elseif ( is_array( $object['attributedTo'] ) && isset( $object['attributedTo']['id'] ) ) {
$actor_url = $object['attributedTo']['id'];
}
}
if ( empty( $actor_url ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: No attributedTo found in object %s for post %d', $object_url, $post->ID ) );
continue;
}
// Fetch/create the ap_actor for this actor URL.
$actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
if ( is_wp_error( $actor_post ) || ! $actor_post instanceof \WP_Post ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Failed to fetch actor %s for post %d', $actor_url, $post->ID ) );
continue;
}
// Update the post meta with attributedTo.
$meta = get_post_meta( $post->ID, Feed_Parser_ActivityPub::SLUG, true );
if ( ! is_array( $meta ) ) {
$meta = array();
}
$meta['attributedTo'] = array( 'ap_actor_id' => $actor_post->ID );
update_post_meta( $post->ID, Feed_Parser_ActivityPub::SLUG, $meta );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Updated post %d with attributedTo %s (ap_actor %d)', $post->ID, $actor_url, $actor_post->ID ) );
}
// Update progress.
update_option( 'friends_external_attributed_to_backfill_processed', $processed, false );
// Schedule next batch.
wp_schedule_single_event( time() + 1, 'friends_backfill_external_attributed_to_batch' );
}
/**
* Finalize the External user attributedTo backfill migration.
*/
private static function finalize_external_attributed_to_backfill() {
$processed = (int) get_option( 'friends_external_attributed_to_backfill_processed', 0 );
delete_option( 'friends_external_attributed_to_backfill_in_progress' );
update_option( 'friends_external_attributed_to_backfill_completed', true, false );
update_option( 'friends_external_attributed_to_backfill_count', $processed, false );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: External attributedTo backfill completed - processed %d posts', $processed ) );
}
/**
* Convert ActivityPub reply posts to comments on conversation root posts.
*
* This migration finds posts that are replies (have inReplyTo) and converts
* them to WordPress comments on the conversation root post.
*/
public static function convert_replies_to_comments() {
if ( get_option( 'friends_replies_to_comments_in_progress' ) ) {
return;
}
if ( get_option( 'friends_replies_to_comments_completed' ) ) {
return;
}
// Check for ActivityPub plugin.
if ( ! class_exists( '\Activitypub\Http' ) ) {
return;
}
global $wpdb;
// Count posts linked to ActivityPub feeds OR with mention-* tags (External mentions).
$mention_like = 'mention-%';
$total_posts = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
LEFT JOIN {$wpdb->termmeta} tm ON tt.term_id = tm.term_id AND tm.meta_key = 'parser'
LEFT JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND (
(tt.taxonomy = %s AND tm.meta_value = 'activitypub')
OR (tt.taxonomy = 'friend_tag' AND t.slug LIKE %s)
)",
Friends::CPT,
User_Feed::TAXONOMY,
$mention_like
)
);
if ( ! $total_posts ) {
update_option( 'friends_replies_to_comments_completed', true, false );
return;
}
// Initialize progress tracking.
update_option( 'friends_replies_to_comments_in_progress', true, false );
update_option( 'friends_replies_to_comments_total', $total_posts, false );
update_option( 'friends_replies_to_comments_processed', 0, false );
update_option( 'friends_replies_to_comments_converted', 0, false );
update_option( 'friends_replies_to_comments_skipped', 0, false );
update_option( 'friends_replies_to_comments_failed', 0, false );
update_option( 'friends_replies_to_comments_failed_posts', array(), false );
// Schedule the first batch.
wp_schedule_single_event( time(), 'friends_convert_replies_batch' );
}
/**
* Process a batch of reply posts to convert to comments.
*/
public static function convert_replies_to_comments_batch() {
if ( ! class_exists( '\Activitypub\Http' ) ) {
self::finalize_replies_to_comments_migration();
return;
}
// Reduce HTTP timeout.
add_filter( 'activitypub_remote_get_timeout', array( __CLASS__, 'reduce_activitypub_timeout' ) );
// Process one at a time due to network operations.
$batch_size = apply_filters( 'friends_replies_batch_size', 1 );
global $wpdb;
$processed = (int) get_option( 'friends_replies_to_comments_processed', 0 );
// Check for crash recovery.
$current_post = get_option( 'friends_replies_current_post' );
if ( $current_post ) {
// Previous batch crashed, skip this post.
$failed_posts = get_option( 'friends_replies_to_comments_failed_posts', array() );
$failed_posts[] = array(
'post_id' => $current_post,
'reason' => 'timeout_or_crash',
'time' => time(),
);
update_option( 'friends_replies_to_comments_failed_posts', $failed_posts, false );
$failed = (int) get_option( 'friends_replies_to_comments_failed', 0 );
update_option( 'friends_replies_to_comments_failed', $failed + 1, false );
delete_option( 'friends_replies_current_post' );
}
// Always query from offset 0: converted posts are trashed (removed from results)
// and all processed posts are marked with _friends_reply_checked meta.
$mention_like = 'mention-%';
$posts = $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT p.ID, p.guid, p.post_author, p.post_content, p.post_date_gmt
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
LEFT JOIN {$wpdb->termmeta} tm ON tt.term_id = tm.term_id AND tm.meta_key = 'parser'
LEFT JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND (
(tt.taxonomy = %s AND tm.meta_value = 'activitypub')
OR (tt.taxonomy = 'friend_tag' AND t.slug LIKE %s)
)
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->postmeta} pm2
WHERE pm2.post_id = p.ID AND pm2.meta_key = '_friends_reply_checked'
)
ORDER BY p.ID ASC
LIMIT %d OFFSET 0",
Friends::CPT,
User_Feed::TAXONOMY,
$mention_like,
$batch_size
)
);
if ( empty( $posts ) ) {
self::finalize_replies_to_comments_migration();
return;
}
// Track stats.
$converted = (int) get_option( 'friends_replies_to_comments_converted', 0 );
$skipped = (int) get_option( 'friends_replies_to_comments_skipped', 0 );
$failed = (int) get_option( 'friends_replies_to_comments_failed', 0 );
$failed_posts = get_option( 'friends_replies_to_comments_failed_posts', array() );
foreach ( $posts as $post ) {
++$processed;
// Crash recovery: store current post ID.
update_option( 'friends_replies_current_post', $post->ID, false );
$result = self::process_potential_reply_post( $post );
// Clear crash recovery marker.
delete_option( 'friends_replies_current_post' );
if ( 'converted' === $result ) {
++$converted;
} elseif ( 'skipped' === $result ) {
++$skipped;
} else {
++$failed;
$failed_posts[] = array(
'post_id' => $post->ID,
'guid' => $post->guid,
'reason' => $result,
'time' => time(),
);
}
// Mark as checked so it's excluded from future queries.
update_post_meta( $post->ID, '_friends_reply_checked', 1 );
}
// Update progress.
update_option( 'friends_replies_to_comments_processed', $processed, false );
update_option( 'friends_replies_to_comments_converted', $converted, false );
update_option( 'friends_replies_to_comments_skipped', $skipped, false );
update_option( 'friends_replies_to_comments_failed', $failed, false );
update_option( 'friends_replies_to_comments_failed_posts', $failed_posts, false );
// Schedule next batch.
wp_schedule_single_event( time() + 1, 'friends_convert_replies_batch' );
}
/**
* Process a potential reply post and convert it to a comment if needed.
*
* @param object $post The post object.
* @return string Result: 'converted', 'skipped', or error reason.
*/
public static function process_potential_reply_post( $post ) {
// Get the ActivityPub object URL from the post's GUID.
$object_url = $post->guid;
// Fetch the ActivityPub object.
$object = \Activitypub\Http::get_remote_object( $object_url, true );
if ( is_wp_error( $object ) ) {
return 'fetch_failed: ' . $object->get_error_message();
}
if ( ! is_array( $object ) ) {
return 'invalid_object';
}
// Check if this is a reply.
if ( empty( $object['inReplyTo'] ) ) {
// Not a reply, skip.
return 'skipped';
}
// Only convert to comment if the user was mentioned in this post.
if ( ! Friend_Tag::has_mention( $post->ID ) ) {
return 'skipped';
}
$in_reply_to = $object['inReplyTo'];
if ( is_array( $in_reply_to ) ) {
$in_reply_to = reset( $in_reply_to );
}
// Find the conversation root by walking up the chain.
$root_result = self::find_conversation_root( $in_reply_to, 10 );
if ( is_wp_error( $root_result ) ) {
return 'root_failed: ' . $root_result->get_error_message();
}
$root_post_id = $root_result['post_id'];
// Convert the reply post to a comment.
$comment_result = self::convert_post_to_comment( $post, $root_post_id, $object );
if ( is_wp_error( $comment_result ) ) {
return 'comment_failed: ' . $comment_result->get_error_message();
}
$comment_id = $comment_result;
// Store redirect meta on the post before trashing.
update_post_meta( $post->ID, '_redirects_to_comment', $comment_id );
update_post_meta( $post->ID, '_redirect_post_id', $root_post_id );
// Check if the original post was a mention (has mention-* tag).
if ( Friend_Tag::has_mention( $post->ID ) ) {
// Mark the root post as having a mention in comments.
update_post_meta( $root_post_id, '_has_mention_in_comments', true );
}
// Trash the original reply post (keep for URL redirects).
wp_trash_post( $post->ID );
return 'converted';
}
/**
* Walk up the inReplyTo chain to find the conversation root.
*
* @param string $url The URL to start from.
* @param int $max_depth Maximum depth to walk.
* @return array|\WP_Error Result with post_id and root_url, or WP_Error.
*/
private static function find_conversation_root( $url, $max_depth ) {
$current_url = $url;
$chain = array( $url );
$earliest_available = null;
for ( $depth = 0; $depth < $max_depth; ++$depth ) {
// Check if we already have this post locally.
$existing_post_id = Feed::url_to_postid( $current_url );
if ( $existing_post_id ) {
// We have this post, use it as the root.
return array(
'post_id' => $existing_post_id,
'root_url' => $current_url,
'created' => false,
);
}
// Fetch the remote object.
$object = \Activitypub\Http::get_remote_object( $current_url, true );
if ( is_wp_error( $object ) || ! is_array( $object ) ) {
// Cannot fetch, use earliest available in chain as fallback.
break;
}
// Remember this as a valid point in the chain.
$earliest_available = array(
'url' => $current_url,
'object' => $object,
);
// Check if this has inReplyTo.
if ( empty( $object['inReplyTo'] ) ) {
// This is the root!
return self::ensure_root_post_exists( $current_url, $object );
}
// Move up the chain.
$parent_url = $object['inReplyTo'];
if ( is_array( $parent_url ) ) {
$parent_url = reset( $parent_url );
}
// Prevent infinite loops.
if ( in_array( $parent_url, $chain, true ) ) {
break;
}
$chain[] = $parent_url;
$current_url = $parent_url;
}
// Use earliest available as fallback root.
if ( $earliest_available ) {
return self::ensure_root_post_exists(
$earliest_available['url'],
$earliest_available['object']
);
}
return new \WP_Error( 'no_root_found', 'Could not find conversation root' );
}
/**
* Ensure a root post exists, creating it if necessary.
*
* @param string $url The URL of the root post.
* @param array $ap_object The ActivityPub object.
* @return array|\WP_Error Result with post_id and created flag, or WP_Error.
*/
private static function ensure_root_post_exists( $url, $ap_object ) {
// Check if post already exists.
$existing_post_id = Feed::url_to_postid( $url );
if ( $existing_post_id ) {
return array(
'post_id' => $existing_post_id,
'root_url' => $url,
'created' => false,
);
}
// Need to create the root post.
// Find the appropriate user feed for the author.
$actor_url = $ap_object['attributedTo'] ?? $ap_object['actor'] ?? null;
if ( ! $actor_url ) {
return new \WP_Error( 'no_actor', 'Cannot determine actor for root post' );
}
if ( is_array( $actor_url ) ) {
$actor_url = $actor_url['id'] ?? reset( $actor_url );
}
// Use the External mentions feed.
$feed_parser = new Feed_Parser_ActivityPub( Friends::get_instance()->feed );
$user_feed = $feed_parser->get_external_mentions_feed();
if ( ! $user_feed ) {
return new \WP_Error( 'no_feed', 'Cannot find appropriate feed for root post' );
}
// Create feed item from the object.
$permalink = $ap_object['url'] ?? $url;
if ( is_array( $permalink ) ) {
foreach ( $permalink as $p ) {
if ( is_string( $p ) ) {
$permalink = $p;
break;
}
if ( is_array( $p ) && isset( $p['href'] ) ) {
$permalink = $p['href'];
break;
}
}
}
$item_data = array(
'permalink' => $permalink,
'content' => $ap_object['content'] ?? '',
'title' => $ap_object['name'] ?? '',
'date' => $ap_object['published'] ?? gmdate( 'Y-m-d H:i:s' ),
'post_format' => 'status',
'_external_id' => $ap_object['id'] ?? $url,
Feed_Parser_ActivityPub::SLUG => array(),
);
// Set author if available.
if ( class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
$actor_post = \Activitypub\Collection\Remote_Actors::fetch_by_uri( $actor_url );
if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
$item_data[ Feed_Parser_ActivityPub::SLUG ]['attributedTo'] = array( 'ap_actor_id' => $actor_post->ID );
$actor = \Activitypub\Collection\Remote_Actors::get_actor( $actor_post->ID );
if ( $actor ) {
$item_data['author'] = $actor->get_name() ? $actor->get_name() : $actor->get_preferred_username();
}
}
}
$item = new Feed_Item( $item_data );
// Process through the feed system.
$new_posts = Friends::get_instance()->feed->process_incoming_feed_items(
array( $item ),
$user_feed
);
// Try to find the post we just created.
$post_id = Feed::url_to_postid( $permalink );
if ( ! $post_id ) {
$post_id = Feed::url_to_postid( $url );
}
if ( ! $post_id && isset( $ap_object['id'] ) ) {
$post_id = Feed::url_to_postid( $ap_object['id'] );
}
if ( $post_id ) {
return array(
'post_id' => $post_id,
'root_url' => $url,
'created' => true,
);
}
return new \WP_Error( 'create_failed', 'Failed to create root post' );
}
/**
* Convert a post to a comment on the root post.
*
* @param object $post The post object to convert.
* @param int $root_post_id The root post ID.
* @param array $ap_object The ActivityPub object.
* @return int|\WP_Error The comment ID or WP_Error.
*/
private static function convert_post_to_comment( $post, $root_post_id, $ap_object ) {
// Get actor information.
$actor_url = $ap_object['attributedTo'] ?? $ap_object['actor'] ?? null;
if ( is_array( $actor_url ) ) {
$actor_url = $actor_url['id'] ?? reset( $actor_url );
}
// Build comment author info.
$comment_author = '';
$comment_author_url = '';
$comment_author_email = '';
$remote_actor_id = null;
if ( $actor_url && class_exists( '\Activitypub\Collection\Remote_Actors' ) ) {
$actor_post = \Activitypub\Collection\Remote_Actors::get_by_uri( $actor_url );
if ( ! is_wp_error( $actor_post ) && $actor_post instanceof \WP_Post ) {
$remote_actor_id = $actor_post->ID;
$actor = \Activitypub\Collection\Remote_Actors::get_actor( $actor_post->ID );
if ( $actor ) {
$comment_author = $actor->get_name() ? $actor->get_name() : $actor->get_preferred_username();
$comment_author_url = $actor->get_url() ? $actor->get_url() : $actor_url;
}
}
// Try to get webfinger for email field.
if ( class_exists( '\Activitypub\Webfinger' ) ) {
$webfinger = \Activitypub\Webfinger::uri_to_acct( $actor_url );
if ( ! is_wp_error( $webfinger ) ) {
$comment_author_email = str_replace( 'acct:', '', $webfinger );
}
}
}
// Fallback to post meta.
if ( empty( $comment_author ) ) {
$author_meta = get_post_meta( $post->ID, 'author', true );
$comment_author = $author_meta ? $author_meta : __( 'Unknown', 'friends' );
}
// Prepare comment data.
$comment_data = array(
'comment_post_ID' => $root_post_id,
'comment_author' => $comment_author,
'comment_author_url' => $comment_author_url,
'comment_author_email' => $comment_author_email,
'comment_content' => $post->post_content,
'comment_date_gmt' => $post->post_date_gmt,
'comment_date' => get_date_from_gmt( $post->post_date_gmt ),
'comment_type' => 'comment',
'comment_approved' => 1,
'comment_parent' => 0, // Flat comments, no threading.
'comment_meta' => array(
'source_id' => $post->guid,
'source_url' => $post->guid,
'protocol' => 'activitypub',
'_original_post_guid' => $post->guid,
),
);
// Store reference to remote actor if available.
if ( $remote_actor_id ) {
$comment_data['comment_meta']['_activitypub_remote_actor_id'] = $remote_actor_id;
}
return self::persist_comment( $comment_data );
}
/**
* Persist a comment with ActivityPub-compatible settings.
*
* @param array $comment_data The comment data.
* @return int|\WP_Error The comment ID or WP_Error.
*/
private static function persist_comment( $comment_data ) {
// Disable flood control.
remove_action( 'check_comment_flood', 'check_comment_flood_db' );
// Do not require email.
add_filter( 'pre_option_require_name_email', '__return_false' );
// Disable Akismet nonce check.
add_filter(
'akismet_comment_nonce',
function () {
return 'inactive';
}
);
// Pre-approve comment (bypass Akismet and other moderation).
add_filter( 'pre_comment_approved', '__return_true', 100 );
// Allow p and br tags.
add_filter( 'wp_kses_allowed_html', array( __CLASS__, 'allowed_comment_html' ), 10, 2 );
$comment_id = wp_new_comment( $comment_data, true );
// Restore filters.
remove_filter( 'wp_kses_allowed_html', array( __CLASS__, 'allowed_comment_html' ) );
remove_filter( 'pre_comment_approved', '__return_true', 100 );
remove_filter( 'pre_option_require_name_email', '__return_false' );
add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
return $comment_id;
}
/**
* Allow additional HTML tags in comments.
*
* @param array $allowed_tags The allowed tags.
* @param string $context The context.
* @return array The modified allowed tags.
*/
public static function allowed_comment_html( $allowed_tags, $context = '' ) {
if ( 'pre_comment_content' !== $context ) {
return $allowed_tags;
}
if ( ! array_key_exists( 'br', $allowed_tags ) ) {
$allowed_tags['br'] = array();
}
if ( ! array_key_exists( 'p', $allowed_tags ) ) {
$allowed_tags['p'] = array();
}
return $allowed_tags;
}
/**
* Undo the post tags migration by restoring post_tag terms from recovery data.
*
* @return array|WP_Error Result details or error.
*/
public static function undo_post_tags_migration() {
$recovery = get_option( 'friends_tag_migration_recovery', array() );
if ( empty( $recovery ) ) {
return new \WP_Error( 'no_recovery_data', 'No recovery data found for post tag migration.' );
}
$restored = 0;
foreach ( $recovery as $post_id => $tag_names ) {
if ( ! get_post( $post_id ) ) {
continue;
}
wp_set_post_terms( (int) $post_id, $tag_names, 'post_tag', true );
++$restored;
}
delete_option( 'friends_tag_migration_recovery' );
delete_option( 'friends_tag_migration_completed' );
return array( 'restored' => $restored );
}
/**
* Undo the replies-to-comments migration by untrashing posts and deleting created comments.
*
* @return array|WP_Error Result details or error.
*/
public static function undo_replies_to_comments() {
global $wpdb;
// Find all trashed posts with _redirects_to_comment meta.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT p.ID AS post_id, pm.meta_value AS comment_id
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND pm.meta_key = %s",
'_redirects_to_comment'
)
);
if ( empty( $rows ) ) {
return new \WP_Error( 'no_recovery_data', 'No trashed reply posts found to restore.' );
}
$restored = 0;
$comments_deleted = 0;
foreach ( $rows as $row ) {
// Delete the created comment.
if ( $row->comment_id && get_comment( $row->comment_id ) ) {
wp_delete_comment( $row->comment_id, true );
++$comments_deleted;
}
// Untrash the post.
wp_untrash_post( $row->post_id );
// Clean up migration meta.
delete_post_meta( $row->post_id, '_redirects_to_comment' );
delete_post_meta( $row->post_id, '_redirect_post_id' );
++$restored;
}
// Clean up _has_mention_in_comments meta.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_has_mention_in_comments' ) );
delete_option( 'friends_replies_to_comments_completed' );
delete_option( 'friends_replies_converted_count' );
delete_option( 'friends_replies_skipped_count' );
delete_option( 'friends_replies_failed_count' );
delete_option( 'friends_replies_failed_posts_final' );
return array(
'restored' => $restored,
'comments_deleted' => $comments_deleted,
);
}
/**
* Finalize the reply to comments migration.
*/
private static function finalize_replies_to_comments_migration() {
global $wpdb;
$converted = (int) get_option( 'friends_replies_to_comments_converted', 0 );
$skipped = (int) get_option( 'friends_replies_to_comments_skipped', 0 );
$failed = (int) get_option( 'friends_replies_to_comments_failed', 0 );
$failed_posts = get_option( 'friends_replies_to_comments_failed_posts', array() );
// Clean up _friends_reply_checked meta markers.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_friends_reply_checked' ) );
// Clean up progress options.
delete_option( 'friends_replies_to_comments_in_progress' );
delete_option( 'friends_replies_to_comments_total' );
delete_option( 'friends_replies_to_comments_processed' );
delete_option( 'friends_replies_current_post' );
// Keep final stats.
update_option( 'friends_replies_to_comments_completed', true, false );
update_option( 'friends_replies_converted_count', $converted, false );
update_option( 'friends_replies_skipped_count', $skipped, false );
update_option( 'friends_replies_failed_count', $failed, false );
if ( ! empty( $failed_posts ) ) {
update_option( 'friends_replies_failed_posts_final', $failed_posts, false );
}
// Clean up batch stats.
delete_option( 'friends_replies_to_comments_converted' );
delete_option( 'friends_replies_to_comments_skipped' );
delete_option( 'friends_replies_to_comments_failed' );
delete_option( 'friends_replies_to_comments_failed_posts' );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Reply to comments completed - Converted %d, Skipped %d, Failed %d', $converted, $skipped, $failed ) );
}
/**
* Sanitize friend usernames (version 4.0.0)
* Removes special characters like apostrophes from friend usernames to prevent URL issues.
*/
public static function sanitize_friend_usernames() {
global $wpdb;
// Get all WordPress users that are Friends plugin users.
$users = User_Query::all_associated_users();
foreach ( $users->get_results() as $user ) {
if ( $user instanceof Subscription ) {
// Skip subscriptions - they use taxonomy terms and will be handled separately.
continue;
}
$old_username = $user->user_login;
$new_username = User::sanitize_username( $old_username );
// Only update if the username needs sanitization.
if ( $old_username !== $new_username && ! username_exists( $new_username ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$wpdb->users,
array( 'user_login' => $new_username ),
array( 'ID' => $user->ID ),
array( '%s' ),
array( '%d' )
);
clean_user_cache( $user->ID );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Sanitized username from "%s" to "%s"', $old_username, $new_username ) );
}
}
// Get all Subscription users (virtual users stored as taxonomy terms).
$term_query = new \WP_Term_Query(
array(
'taxonomy' => Subscription::TAXONOMY,
'hide_empty' => false,
)
);
foreach ( $term_query->get_terms() as $term ) {
$old_slug = $term->slug;
$old_name = $term->name;
$new_slug = User::sanitize_username( $old_slug );
$new_name = User::sanitize_username( $old_name );
// Only update if the term needs sanitization.
if ( ( $old_slug !== $new_slug || $old_name !== $new_name ) && ! term_exists( $new_slug, Subscription::TAXONOMY ) ) {
wp_update_term(
$term->term_id,
Subscription::TAXONOMY,
array(
'slug' => $new_slug,
'name' => $new_name,
)
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Sanitized subscription username from "%s" to "%s"', $old_slug, $new_slug ) );
}
}
update_option( 'friends_usernames_sanitized', true, false );
}
/**
* Convert WordPress users with friend/acquaintance/subscription roles to virtual subscriptions.
*/
public static function convert_friend_users_to_subscriptions() {
if ( get_option( 'friends_friend_users_conversion_in_progress' ) ) {
return;
}
if ( get_option( 'friends_friend_users_converted' ) ) {
return;
}
$users = new \WP_User_Query(
array(
'role__in' => array( 'friend', 'acquaintance', 'friend_request', 'pending_friend_request', 'subscription' ),
'number' => -1,
'fields' => 'ID',
)
);
$total = $users->get_total();
if ( ! $total ) {
update_option( 'friends_friend_users_converted', true, false );
return;
}
update_option( 'friends_friend_users_conversion_in_progress', true, false );
update_option( 'friends_friend_users_conversion_total', $total, false );
update_option( 'friends_friend_users_conversion_processed', 0, false );
wp_schedule_single_event( time(), 'friends_convert_friend_users_batch' );
}
/**
* Process a batch of friend users to convert to virtual subscriptions.
*/
public static function convert_friend_users_batch() {
$batch_size = apply_filters( 'friends_friend_user_conversion_batch_size', 20 );
$users = new \WP_User_Query(
array(
'role__in' => array( 'friend', 'acquaintance', 'friend_request', 'pending_friend_request', 'subscription' ),
'number' => $batch_size,
'offset' => 0,
)
);
$results = $users->get_results();
if ( empty( $results ) ) {
update_option( 'friends_friend_users_converted', true, false );
delete_option( 'friends_friend_users_conversion_in_progress' );
delete_option( 'friends_friend_users_conversion_total' );
delete_option( 'friends_friend_users_conversion_processed' );
return;
}
$processed = (int) get_option( 'friends_friend_users_conversion_processed', 0 );
foreach ( $results as $wp_user ) {
$result = Subscription::convert_from_user( new User( $wp_user ) );
if ( is_wp_error( $result ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'Friends Migration: Failed to convert user %d (%s): %s', $wp_user->ID, $wp_user->user_login, $result->get_error_message() ) );
continue;
}
++$processed;
}
update_option( 'friends_friend_users_conversion_processed', $processed, false );
wp_schedule_single_event( time() + 1, 'friends_convert_friend_users_batch' );
}
/**
* Initialize the migration to link feed terms as children of subscription terms.
*/
public static function link_feeds_as_term_children() {
if ( get_option( 'friends_feeds_term_children_in_progress' ) ) {
return;
}
if ( get_option( 'friends_feeds_linked_as_term_children' ) ) {
return;
}
global $wpdb;
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->term_relationships} tr
JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = %s
AND EXISTS (
SELECT 1 FROM {$wpdb->term_taxonomy} sub
WHERE sub.term_id = tr.object_id
AND sub.taxonomy = %s
)",
User_Feed::TAXONOMY,
Subscription::TAXONOMY
)
);
if ( ! $total ) {
update_option( 'friends_feeds_linked_as_term_children', true, false );
return;
}
update_option( 'friends_feeds_term_children_in_progress', true, false );
update_option( 'friends_feeds_term_children_total', $total, false );
update_option( 'friends_feeds_term_children_processed', 0, false );
wp_schedule_single_event( time(), 'friends_link_feeds_as_term_children_batch' );
}
/**
* Process a batch of feed-to-subscription relationships, converting them to parent links.
*/
public static function link_feeds_as_term_children_batch() {
$batch_size = apply_filters( 'friends_feeds_term_children_batch_size', 50 );
global $wpdb;
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
"SELECT tr.object_id AS subscription_term_id, tt.term_taxonomy_id AS feed_tt_id, tt.term_id AS feed_term_id
FROM {$wpdb->term_relationships} tr
JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = %s
AND EXISTS (
SELECT 1 FROM {$wpdb->term_taxonomy} sub
WHERE sub.term_id = tr.object_id
AND sub.taxonomy = %s
)
LIMIT %d OFFSET 0",
User_Feed::TAXONOMY,
Subscription::TAXONOMY,
$batch_size
)
);
if ( empty( $rows ) ) {
update_option( 'friends_feeds_linked_as_term_children', true, false );
delete_option( 'friends_feeds_term_children_in_progress' );
delete_option( 'friends_feeds_term_children_total' );
delete_option( 'friends_feeds_term_children_processed' );
return;
}
$processed = (int) get_option( 'friends_feeds_term_children_processed', 0 );
foreach ( $rows as $row ) {
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->term_taxonomy,
array( 'parent' => (int) $row->subscription_term_id ),
array( 'term_taxonomy_id' => (int) $row->feed_tt_id ),
array( '%d' ),
array( '%d' )
);
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->term_relationships,
array(
'object_id' => (int) $row->subscription_term_id,
'term_taxonomy_id' => (int) $row->feed_tt_id,
),
array( '%d', '%d' )
);
++$processed;
}
clean_term_cache( wp_list_pluck( $rows, 'feed_term_id' ), User_Feed::TAXONOMY );
update_option( 'friends_feeds_term_children_processed', $processed, false );
wp_schedule_single_event( time() + 1, 'friends_link_feeds_as_term_children_batch' );
}
}