'GET', 'permission_callback' => static function () { return current_user_can( 'manage_options' ) && current_user_can( 'activate_plugins' ); }, 'callback' => static function () { global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i ORDER BY id DESC LIMIT 20', Store::table( 'imports' ) ) ); return array_map( static function ( $id ) { return self::result( self::job( (int) $id ) ); }, $ids ); }, ) ); foreach ( array( 'preview', 'start', 'step', 'cutover', 'rollback', 'status', 'discard' ) as $action ) { $args = array(); if ( 'start' === $action ) { $args['hash'] = array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$', 'required' => true, ); } if ( ! in_array( $action, array( 'preview', 'start' ), true ) ) { $args['job'] = array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ); } if ( 'cutover' === $action ) { $args['scope'] = array( 'type' => 'string', 'enum' => array( 'backend', 'block', 'archive' ), 'required' => true, ); } if ( 'rollback' === $action ) { $args['confirm_changes'] = array( 'type' => 'boolean', 'default' => false, ); } register_rest_route( 'threepo/v1', '/import/' . $action, array( 'methods' => 'POST', 'permission_callback' => static function () { return current_user_can( 'manage_options' ) && current_user_can( 'activate_plugins' ) ? true : Store::error( 'forbidden', __( 'Administrator access is required.', '3task-post-order' ), rest_authorization_required_code() ); }, 'callback' => array( self::class, 'dispatch' ), 'args' => $args, ) ); } } /** * @param array $c Validated values. * @return list> */ public static function rows( array $c, int $offset, int $limit = 500, int $missing_in = 0 ): array { global $wpdb; $s = Order::source( $c ); $rank = 'post' === $c['kind'] ? 'o.menu_order' : 'o.term_order'; // Preparation reads only items not yet copied, so a shifted source cannot repeat an item. $missing = $missing_in ? $wpdb->prepare( ' AND NOT EXISTS (SELECT 1 FROM %i e WHERE e.context_id=%d AND e.object_id=', Store::table( 'entries' ), $missing_in ) . $s['id'] . ')' : ''; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- All values are prepared; source() supplies only fixed identifiers and prepared predicates. return $wpdb->get_results( "SELECT {$s['id']} id,$rank source_rank,{$s['title']} title FROM {$s['from']} WHERE {$s['where']}$missing ORDER BY $rank ASC,{$s['id']} ASC" . $wpdb->prepare( ' LIMIT %d OFFSET %d', $limit, $offset ), ARRAY_A ); } /** * @return array|\WP_Error */ public static function preview() { global $wpdb; if ( defined( 'ICL_SITEPRESS_VERSION' ) || function_exists( 'pll_languages_list' ) ) { return Store::error( 'language', __( 'Multilingual imports are not supported in this version.', '3task-post-order' ) ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; if ( is_plugin_active_for_network( self::LEGACY ) ) { return Store::error( 'network', __( 'Deactivate network-wide legacy ordering before importing individual sites.', '3task-post-order' ) ); } $options = get_option( 'hicpo_options', array() ); $plan = array(); $warnings = array(); foreach ( array( 'objects' => 'post', 'tags' => 'term', ) as $key => $kind ) { foreach ( (array) ( $options[ $key ] ?? array() ) as $type ) { $object = 'post' === $kind ? get_post_type_object( $type ) : get_taxonomy( $type ); if ( ! $object || ! $object->show_ui ) { // translators: %s: unavailable content type slug. $warnings[] = sprintf( __( 'Unavailable content type: %s', '3task-post-order' ), sanitize_key( $type ) ); continue; } if ( 'term' === $kind && ! $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $wpdb->terms, 'term_order' ) ) ) { return Store::error( 'source', __( 'The legacy term_order column is missing. No source data was changed.', '3task-post-order' ) ); } $parents = array( 0 ); if ( $object->hierarchical ) { $parents = 'post' === $kind ? $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_parent FROM %i WHERE post_type=%s AND post_status='publish' ORDER BY post_parent", $wpdb->posts, $type ) ) : $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT parent FROM %i WHERE taxonomy=%s ORDER BY parent', $wpdb->term_taxonomy, $type ) ); } foreach ( $parents as $parent ) { $c = array( 'kind' => $kind, 'object_type' => $type, 'taxonomy' => '', 'term_id' => 0, 'parent_id' => (int) $parent, 'label' => $object->label . ( $parent ? ' #' . $parent : '' ), ); $s = Order::source( $c ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- All values are prepared; source() supplies only fixed identifiers and prepared predicates. $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$s['from']} WHERE {$s['where']}" ); if ( ! $count ) { continue; } $c['count'] = $count; $c['sample'] = array_map( static function ( $row ) { return array( 'id' => (int) $row['id'], 'title' => wp_strip_all_tags( $row['title'] ), 'rank' => (int) $row['source_rank'], ); }, self::rows( $c, 0, 8 ) ); $plan[] = $c; } } } if ( count( $plan ) > 500 ) { return Store::error( 'capacity', __( 'This import has more than 500 parent groups. Import smaller groups first.', '3task-post-order' ) ); } $hash = self::hash( $plan ); return array( 'hash' => $hash, 'plan' => $plan, 'warnings' => $warnings, 'active' => is_plugin_active( self::LEGACY ), 'note' => __( 'Published items only. Equal legacy ranks use ascending IDs. Compare the preview before switching. Network site ordering is not imported.', '3task-post-order' ), ); } /** * @param list> $plan Validated values. */ public static function hash( array $plan ): string { $hash = hash_init( 'sha256' ); hash_update( $hash, wp_json_encode( get_option( 'hicpo_options', array() ) ) ); foreach ( $plan as $c ) { hash_update( $hash, wp_json_encode( array( $c['kind'], $c['object_type'], $c['parent_id'] ) ) ); $offset = 0; do { $rows = self::rows( $c, $offset, 1000 ); foreach ( $rows as $row ) { hash_update( $hash, $row['id'] . ':' . $row['source_rank'] . ';' ); } $offset += count( $rows ); $more = count( $rows ) === 1000; } while ( $more ); } return hash_final( $hash ); } /** * @return array|null */ public static function job( int $id, bool $locked = false ): ?array { global $wpdb; $sql = $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', Store::table( 'imports' ), $id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is prepared immediately above; only the fixed FOR UPDATE suffix is appended. $row = $wpdb->get_row( $sql . ( $locked ? ' FOR UPDATE' : '' ), ARRAY_A ); if ( ! $row ) { return null; } $row['id'] = (int) $row['id']; $row['plan'] = json_decode( $row['plan'], true ); return $row; } /** * @param array $job Validated values. * @return array */ public static function result( array $job ): array { return array( 'id' => $job['id'], 'status' => $job['status'], 'contexts' => array_map( 'intval', array_column( $job['plan'], 'context_id' ) ), 'completed_groups' => (int) $job['cursor_index'], 'total_groups' => count( $job['plan'] ), 'current_items' => (int) $job['cursor_offset'], ); } /** * @return array|\WP_Error */ public static function dispatch( WP_REST_Request $r ) { $action = strtolower( basename( $r->get_route() ) ); $allowed = array( 'preview' => array(), 'start' => array( 'hash' ), 'step' => array( 'job' ), 'cutover' => array( 'job', 'scope' ), 'rollback' => array( 'job', 'confirm_changes' ), 'status' => array( 'job' ), 'discard' => array( 'job' ), ); $body = Api::body( $r, $allowed[ $action ] ); if ( is_wp_error( $body ) ) { return $body; } global $wpdb; $lock = substr( Store::table( 'import_operations' ), 0, 64 ); if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) ) { return Store::error( 'busy', __( 'Another import operation is running. Try again shortly.', '3task-post-order' ), 409 ); } try { if ( 'preview' === $action ) { return self::preview(); } if ( 'start' === $action ) { return self::start( $r['hash'] ); } $job = self::job( (int) $r['job'] ); if ( ! $job ) { return Store::error( 'import', __( 'This import does not exist.', '3task-post-order' ), 404 ); } if ( 'step' === $action ) { return self::step( $job['id'] ); } if ( 'cutover' === $action ) { return self::cutover( $job, $r['scope'] ); } if ( 'discard' === $action ) { return self::discard( $job ); } if ( 'rollback' === $action ) { return self::rollback( $job, (bool) $r['confirm_changes'] ); } return self::result( $job ); } catch ( RuntimeException $e ) { return Store::error( 'import', __( 'The import did not finish. Your legacy ranks are unchanged. Reload its status before continuing.', '3task-post-order' ), 503 ); } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** * @return array|\WP_Error */ public static function start( string $hash ) { global $wpdb; $preview = self::preview(); if ( is_wp_error( $preview ) ) { return $preview; } if ( ! hash_equals( $preview['hash'], $hash ) || ! $preview['plan'] ) { return Store::error( 'source', __( 'The source changed or contains no published items. Preview it again.', '3task-post-order' ), 409 ); } foreach ( $preview['plan'] as $planned ) { $key = implode( ':', array( $planned['kind'], $planned['object_type'], '', 0, $planned['parent_id'] ) ); $existing = $wpdb->get_var( $wpdb->prepare( 'SELECT label FROM %i WHERE context_key=%s', Store::table( 'contexts' ), $key ) ); if ( null !== $existing ) { /* translators: %s: Name of the existing order. */ return Store::error( 'existing', sprintf( __( 'The order "%s" already covers this content. Delete that order first, or keep it and skip the import.', '3task-post-order' ), $existing ), 409 ); } } Order::begin(); try { // A single durable advisory lock serializes import creation on this site's connection. $lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', substr( Store::table( 'import_start' ), 0, 64 ) ) ); if ( 1 !== (int) $lock ) { throw new RuntimeException( 'busy' ); } Order::check( $wpdb->insert( Store::table( 'imports' ), array( 'status' => 'preparing', 'source_hash' => $hash, 'plan' => '[]', 'was_active' => $preview['active'] ? 1 : 0, 'created' => current_time( 'mysql', true ), ) ) ); $id = (int) $wpdb->insert_id; $plan = $preview['plan']; foreach ( $plan as &$c ) { $key = implode( ':', array( $c['kind'], $c['object_type'], '', 0, $c['parent_id'] ) ); if ( $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE context_key=%s', Store::table( 'contexts' ), $key ) ) ) { throw new RuntimeException( 'existing' ); } $context = Store::create( $c ); if ( is_wp_error( $context ) ) { throw new RuntimeException( 'context' ); } $c['context_id'] = $context['id']; Order::check( $wpdb->update( Store::table( 'contexts' ), array( 'scope' => 'importing', 'import_id' => $id, ), array( 'id' => $context['id'] ) ) ); } unset( $c ); Order::check( $wpdb->update( Store::table( 'imports' ), array( 'plan' => wp_json_encode( $plan ) ), array( 'id' => $id ) ) ); Order::check( $wpdb->query( 'COMMIT' ) ); } catch ( RuntimeException $e ) { $wpdb->query( 'ROLLBACK' ); throw $e; } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', substr( Store::table( 'import_start' ), 0, 64 ) ) ); } return self::result( self::job( $id ) ); } /** * @return array */ public static function step( int $id ) { global $wpdb; Order::begin(); try { $job = self::job( $id, true ); if ( 'preparing' !== $job['status'] ) { Order::check( $wpdb->query( 'COMMIT' ) ); return self::result( $job ); } $index = (int) $job['cursor_index']; $offset = (int) $job['cursor_offset']; $c = $job['plan'][ $index ]; $rows = self::rows( $c, 0, 500, (int) $c['context_id'] ); foreach ( $rows as $row ) { if ( ! Order::object_allowed( $c, (int) $row['id'] ) ) { throw new RuntimeException( 'object' ); } Order::check( $wpdb->insert( Store::table( 'entries' ), array( 'context_id' => $c['context_id'], 'object_id' => (int) $row['id'], 'rank_value' => ( ++$offset ) * 1024, ) ) ); } if ( count( $rows ) < 500 ) { ++$index; $offset = 0; } $status = $index >= count( $job['plan'] ) ? 'ready' : 'preparing'; Order::check( $wpdb->update( Store::table( 'imports' ), array( 'cursor_index' => $index, 'cursor_offset' => $offset, 'status' => $status, ), array( 'id' => $id ) ) ); Order::check( $wpdb->query( 'COMMIT' ) ); } catch ( RuntimeException $e ) { $wpdb->query( 'ROLLBACK' ); throw $e; } return self::result( self::job( $id ) ); } /** * @return list */ public static function conflicts(): array { require_once ABSPATH . 'wp-admin/includes/plugin.php'; $found = array(); foreach ( array( 'simple-custom-post-order/simple-custom-post-order.php', 'post-types-order/post-types-order.php', 'taxonomy-terms-order/taxonomy-terms-order.php', 'metronet-reorder-posts/metronet-reorder-posts.php', 'simple-page-ordering/simple-page-ordering.php' ) as $plugin ) { if ( is_plugin_active( $plugin ) ) { $found[] = $plugin; } } return $found; } /** * @param array $job Validated values. * @return array|\WP_Error */ public static function cutover( array $job, string $scope ) { global $wpdb; require_once ABSPATH . 'wp-admin/includes/plugin.php'; if ( 'active' === $job['status'] ) { return self::result( $job ); } if ( ! in_array( $job['status'], array( 'ready', 'switching' ), true ) ) { return Store::error( 'state', __( 'Finish preparing this import first.', '3task-post-order' ), 409 ); } // Preparation may have happened before the current user's permissions changed. foreach ( $job['plan'] as $planned ) { $context = Store::get( (int) $planned['context_id'] ); if ( ! $context || ! Store::allowed( $context ) ) { return Store::error( 'forbidden', __( 'This ordering context is unavailable.', '3task-post-order' ), 403 ); } try { Order::all_allowed( $context ); } catch ( RuntimeException $error ) { return Store::error( 'object', __( 'An item is unavailable or outside this context.', '3task-post-order' ), 403 ); } } if ( self::conflicts() ) { return Store::error( 'conflict', __( 'Deactivate other ordering plugins before switching.', '3task-post-order' ), 409 ); } if ( 'ready' === $job['status'] ) { $job['was_active'] = is_plugin_active( self::LEGACY ) ? 1 : 0; Order::check( $wpdb->update( Store::table( 'imports' ), array( 'was_active' => $job['was_active'] ), array( 'id' => $job['id'] ) ) ); } Order::check( $wpdb->update( Store::table( 'imports' ), array( 'status' => 'switching' ), array( 'id' => $job['id'], 'status' => $job['status'], ) ) ); deactivate_plugins( self::LEGACY, false, false ); $fresh = self::preview(); if ( is_wp_error( $fresh ) || ! hash_equals( $job['source_hash'], $fresh['hash'] ) ) { if ( $job['was_active'] ) { $reactivated = activate_plugin( self::LEGACY ); if ( is_wp_error( $reactivated ) ) { return Store::error( 'legacy', __( 'Reactivate the legacy plugin manually, then retry the rollback.', '3task-post-order' ), 503 ); } } $wpdb->update( Store::table( 'imports' ), array( 'status' => 'ready' ), array( 'id' => $job['id'] ) ); return Store::error( 'source', __( 'The legacy source changed. Roll back this preparation and create a new import.', '3task-post-order' ), 409 ); } Order::begin(); try { $latest = self::job( $job['id'], true ); if ( 'switching' !== $latest['status'] ) { throw new RuntimeException( 'state' ); } foreach ( $job['plan'] as $c ) { Order::check( $wpdb->update( Store::table( 'contexts' ), array( 'scope' => $scope ), array( 'id' => $c['context_id'], 'import_id' => $job['id'], ) ) ); } Order::check( $wpdb->update( Store::table( 'imports' ), array( 'status' => 'active' ), array( 'id' => $job['id'] ) ) ); Order::check( $wpdb->query( 'COMMIT' ) ); } catch ( RuntimeException $e ) { $wpdb->query( 'ROLLBACK' ); throw $e; } foreach ( $job['plan'] as $c ) { do_action( 'threepo_order_changed', $c['context_id'], 0, 0 ); } return self::result( self::job( $job['id'] ) ); } /** * @param array $job Validated values. * @return array|\WP_Error */ public static function rollback( array $job, bool $confirmed ) { global $wpdb; require_once ABSPATH . 'wp-admin/includes/plugin.php'; if ( in_array( $job['status'], array( 'rolled_back', 'discarded' ), true ) ) { return self::result( $job ); } Order::begin(); try { $latest = self::job( $job['id'], true ); foreach ( $latest['plan'] as $c ) { $context = $wpdb->get_row( $wpdb->prepare( 'SELECT revision FROM %i WHERE id=%d FOR UPDATE', Store::table( 'contexts' ), $c['context_id'] ), ARRAY_A ); if ( $context && (int) $context['revision'] > 0 && ! $confirmed ) { $wpdb->query( 'ROLLBACK' ); return Store::error( 'changed', __( 'These orders have changed since import. Confirm that the legacy order should be restored. Your new order will remain stored.', '3task-post-order' ), 409 ); } } Order::check( $wpdb->update( Store::table( 'contexts' ), array( 'scope' => 'backend' ), array( 'import_id' => $job['id'] ) ) ); Order::check( $wpdb->update( Store::table( 'imports' ), array( 'status' => 'rollback_pending' ), array( 'id' => $job['id'] ) ) ); Order::check( $wpdb->query( 'COMMIT' ) ); } catch ( RuntimeException $e ) { $wpdb->query( 'ROLLBACK' ); throw $e; } if ( $job['was_active'] ) { $activated = activate_plugin( self::LEGACY ); if ( is_wp_error( $activated ) ) { return Store::error( 'legacy', __( 'The imported order is disabled. Reactivate the legacy plugin manually, then retry the rollback.', '3task-post-order' ), 503 ); } } Order::check( $wpdb->update( Store::table( 'imports' ), array( 'status' => 'rolled_back' ), array( 'id' => $job['id'] ) ) ); return self::result( self::job( $job['id'] ) ); } /** * Discard only unused, inactive staging data belonging to this import. * @param array $job Validated import record. * @return array|\WP_Error */ public static function discard( array $job ) { global $wpdb; if ( 'discarded' === $job['status'] ) { return self::result( $job ); } Order::begin(); try { $job = self::job( $job['id'], true ); if ( ! in_array( $job['status'], array( 'preparing', 'ready', 'rolled_back' ), true ) ) { $wpdb->query( 'ROLLBACK' ); return Store::error( 'state', __( 'Return to the legacy order before discarding this import.', '3task-post-order' ), 409 ); } foreach ( $job['plan'] as $c ) { $revision = $wpdb->get_var( $wpdb->prepare( 'SELECT revision FROM %i WHERE id=%d FOR UPDATE', Store::table( 'contexts' ), $c['context_id'] ) ); if ( null !== $revision && (int) $revision > 0 ) { $wpdb->query( 'ROLLBACK' ); return Store::error( 'changed', __( 'This imported order has newer changes and will be kept.', '3task-post-order' ), 409 ); } } foreach ( $job['plan'] as $c ) { foreach ( array( 'entries', 'history', 'snapshots' ) as $table ) { Order::check( $wpdb->delete( Store::table( $table ), array( 'context_id' => $c['context_id'] ) ) ); } Order::check( $wpdb->delete( Store::table( 'contexts' ), array( 'id' => $c['context_id'], 'import_id' => $job['id'], ) ) ); } Order::check( $wpdb->update( Store::table( 'imports' ), array( 'status' => 'discarded' ), array( 'id' => $job['id'] ) ) ); Order::check( $wpdb->query( 'COMMIT' ) ); } catch ( RuntimeException $e ) { $wpdb->query( 'ROLLBACK' ); throw $e; } return self::result( self::job( $job['id'] ) ); } }