self::string_arg() ); $config = array( 'config' => self::payload_arg() ); self::route( '/state', $read, 'state' ); self::route( '/job/(?P[a-zA-Z0-9\-]+)', $read, 'job', $job_id ); self::route( '/jobs/(?P[a-zA-Z0-9\-]+)', $read, 'job', $job_id ); self::route( '/jobs/(?P[a-zA-Z0-9\-]+)/cancel', $write, 'job_cancel', $job_id ); self::route( '/jobs/(?P[a-zA-Z0-9\-]+)/step', $write, 'job_step', $job_id ); self::route( '/jobs/(?P[a-zA-Z0-9\-]+)/pause', $write, 'job_pause', $job_id ); self::route( '/jobs/(?P[a-zA-Z0-9\-]+)/resume', $write, 'job_resume', $job_id ); self::route( '/jobs-active', $read, 'job_active' ); self::route( '/tables', $read, 'tables' ); self::route( '/post-types', $read, 'post_types' ); self::route( '/themes', $read, 'themes' ); self::route( '/plugins', $read, 'plugins' ); self::route( '/profiles/save', $write, 'profile_save', array( 'name' => self::string_arg(), 'type' => self::string_arg( 'sanitize_key' ), 'payload' => self::payload_arg(), 'from_recent' => self::string_arg(), ) ); self::route( '/profiles/rename', $write, 'profile_rename', array( 'id' => self::string_arg(), 'name' => self::string_arg(), ) ); self::route( '/profiles/update', $write, 'profile_update', array( 'id' => self::string_arg(), 'type' => self::string_arg( 'sanitize_key' ), 'payload' => self::payload_arg(), 'name' => self::string_arg(), ) ); self::route( '/profiles/delete', $write, 'profile_delete', array( 'id' => self::string_arg() ) ); self::route( '/recent/save', $write, 'recent_save', array( 'label' => self::string_arg(), 'type' => self::string_arg( 'sanitize_key' ), 'payload' => self::payload_arg(), ) ); self::route( '/recent/delete', $write, 'recent_delete', array( 'id' => self::string_arg() ) ); self::route( '/settings/save', $write, 'settings_save', array( 'settings' => self::payload_arg() ) ); self::route( '/export/start', $write, 'export_start', $config ); self::route( '/export/cleanup', $write, 'export_cleanup', array( 'job_id' => self::string_arg() ) ); self::route( '/backup/start', $write, 'backup_start', $config ); self::route( '/find-replace/preview', $write, 'find_replace_preview', $config ); self::route( '/find-replace/apply', $write, 'find_replace_apply', $config ); self::route( '/find-replace/start', $write, 'find_replace_apply', $config ); } /** * Register one spinxqm/v1 route behind the plugin permission check. * * @param string $path Route path (regex allowed). * @param string $methods HTTP method(s). * @param string $callback Method name on this class. * @param array $args Argument schema. */ private static function route( string $path, string $methods, string $callback, array $args = array() ): void { register_rest_route( 'spinxqm/v1', $path, array( array( 'methods' => $methods, 'callback' => array( __CLASS__, $callback ), 'permission_callback' => array( Security::class, 'rest_permission' ), 'args' => $args, ), ) ); } /** * Schema for an optional string argument. * * @param string $sanitize Name of the sanitize callback function. * @return array */ private static function string_arg( string $sanitize = 'sanitize_text_field' ): array { return array( 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => $sanitize, ); } /** * Schema for a structured payload argument (JSON object, or a JSON-encoded * string). Its contents are sanitized recursively by safe_payload(). * * @return array */ private static function payload_arg(): array { return array( 'validate_callback' => static function ( $value ): bool { return null === $value || is_array( $value ) || is_string( $value ); }, ); } /** * Return the bootstrap state for the admin app (prefix, site URL, settings, profiles). * * @return array */ public static function state(): array { global $wpdb; $settings = Helpers::get_settings(); return array( 'prefix' => $wpdb->prefix, 'site_url' => home_url(), // Shown in Settings; relative to the WordPress root, never the absolute server path. 'storage_dir' => Helpers::display_path( Helpers::storage_dir() ), 'settings' => $settings, 'saved_profiles' => Profile_Manager::get_saved(), 'recent_profiles' => Profile_Manager::get_recent(), ); } /** * Return the formatted state of a single job. * * @param WP_REST_Request $request Request carrying the job `id`. * @return array|WP_Error Job state, or a 404 error if the job does not exist. */ public static function job( WP_REST_Request $request ) { $id = sanitize_text_field( (string) $request['id'] ); $job = Helpers::get_job( $id ); if ( empty( $job ) ) { return new WP_Error( 'spinxqm_job_missing', __( 'Job not found.', 'spinx-quick-migrate' ), array( 'status' => 404 ) ); } return self::format_job_response( $job ); } /** * Advance a job by one chunk and return its state. The front-end calls this * back-to-back to drive export/backup/find-replace (no cron, no delay). * * @param WP_REST_Request $request Request carrying the job `id`. * @return array|WP_Error Job state, or a 404 error if the job does not exist. */ public static function job_step( WP_REST_Request $request ) { $id = sanitize_text_field( (string) $request['id'] ); $job = Helpers::get_job( $id ); if ( empty( $job ) ) { return new WP_Error( 'spinxqm_job_missing', __( 'Job not found.', 'spinx-quick-migrate' ), array( 'status' => 404 ) ); } // A step can run for seconds. If some other code opened a PHP session, its // lock would block the concurrent pause/cancel request until this step // returns (the "buttons hang" symptom). Release it up front so pause/cancel // land immediately; the running step then sees the flag (fresh reads) and // aborts on its next batch. We never open a session ourselves. if ( function_exists( 'session_status' ) && PHP_SESSION_ACTIVE === session_status() ) { session_write_close(); } $type = (string) ( $job['type'] ?? '' ); if ( 'export' === $type ) { return self::advance_export( $id ); } if ( 'backup' === $type ) { return self::advance_backup( $id ); } if ( 'find_replace' === $type ) { return self::advance_find_replace( $id ); } return self::format_job_response( $job ); } /** * Re-read a job from storage and format it for the API. * * @param string $job_id Job ID. * @return array */ private static function job_state( string $job_id ): array { return self::format_job_response( Helpers::get_job( $job_id ) ); } /** * Cancel a job and immediately clean up its partial output. * * @param WP_REST_Request $request Request carrying the job `id`. * @return array */ public static function job_cancel( WP_REST_Request $request ): array { $id = sanitize_text_field( (string) $request['id'] ); $ok = Helpers::cancel_job( $id ); if ( $ok ) { $job = Helpers::get_job( $id ); $type = sanitize_key( (string) ( $job['type'] ?? '' ) ); // All ops are browser-driven now — clean up partial output immediately // (no cron tick is coming to do it). if ( 'export' === $type ) { self::cleanup_export_temp( $id ); } elseif ( 'backup' === $type ) { self::cleanup_backup_temp( $id ); } elseif ( 'find_replace' === $type ) { self::cleanup_find_replace_temp( $id ); } self::finish_job_cancelled( $id ); } return array( 'success' => $ok, 'job_id' => $id, 'message' => $ok ? __( 'Cancellation requested.', 'spinx-quick-migrate' ) : __( 'Job cannot be cancelled.', 'spinx-quick-migrate' ), ); } /** * Request a pause of a running job. * * @param WP_REST_Request $request Request carrying the job `id`. * @return array */ public static function job_pause( WP_REST_Request $request ): array { $id = sanitize_text_field( (string) $request['id'] ); $ok = Helpers::pause_job( $id ); return array( 'success' => $ok, 'job_id' => $id, 'message' => $ok ? __( 'Pause requested.', 'spinx-quick-migrate' ) : __( 'Job cannot be paused.', 'spinx-quick-migrate' ), ); } /** * Resume a paused job by clearing its pause flag. * * @param WP_REST_Request $request Request carrying the job `id`. * @return array */ public static function job_resume( WP_REST_Request $request ): array { $id = sanitize_text_field( (string) $request['id'] ); // All ops are browser-driven: clearing the pause flag is enough — the // front-end step loop restarts and continues from the checkpoint. $ok = Helpers::resume_job( $id ); return array( 'success' => $ok, 'job_id' => $id, 'message' => $ok ? __( 'Resuming...', 'spinx-quick-migrate' ) : __( 'Job cannot be resumed.', 'spinx-quick-migrate' ), ); } /** * Return the currently active job, if any. * * @return array */ public static function job_active(): array { $job = Helpers::get_active_job(); if ( empty( $job ) ) { return array( 'active' => false ); } return array( 'active' => true, 'job' => self::format_job_response( $job ), ); } /** * Delete a finished export's downloadable ZIP from server storage. Called by * the front-end once the browser download has been triggered. * * @param WP_REST_Request $request Request carrying the `job_id`. * @return array */ public static function export_cleanup( WP_REST_Request $request ): array { $id = sanitize_text_field( (string) $request->get_param( 'job_id' ) ); $job = Helpers::get_job( $id ); // Both export ZIPs and backup archives are auto-downloaded then removed. $type = (string) ( $job['type'] ?? '' ); if ( empty( $job ) || ( 'export' !== $type && 'backup' !== $type ) ) { return array( 'success' => false, 'deleted' => false, ); } $file = is_array( $job['result'] ?? null ) ? (string) ( $job['result']['file'] ?? '' ) : ''; $deleted = false; if ( '' !== $file && file_exists( $file ) && self::within_storage( $file ) ) { wp_delete_file( $file ); $deleted = ! file_exists( $file ); } return array( 'success' => true, 'deleted' => $deleted, ); } /** * List the site's database tables. * * @return array */ public static function tables(): array { return array( 'tables' => Database_Scanner::get_tables() ); } /** * List the site's post types. * * @return array */ public static function post_types(): array { return array( 'post_types' => Database_Scanner::get_post_types() ); } /** * List the installed themes. * * @return array */ public static function themes(): array { return array( 'themes' => Database_Scanner::get_themes() ); } /** * List the installed plugins. * * @return array */ public static function plugins(): array { return array( 'plugins' => Database_Scanner::get_plugins() ); } /** * Save a new named profile, optionally promoting (and removing) a recent config. * * @param WP_REST_Request $request Request carrying `name`, `type`, `payload` and optional `from_recent`. * @return array|WP_Error Updated profile lists, or an error for a missing/duplicate name. */ public static function profile_save( WP_REST_Request $request ) { $name = sanitize_text_field( (string) $request->get_param( 'name' ) ); $type = sanitize_key( (string) $request->get_param( 'type' ) ); $payload = self::safe_payload( $request->get_param( 'payload' ) ); $from_recent = sanitize_text_field( (string) $request->get_param( 'from_recent' ) ); if ( '' === $name ) { return new WP_Error( 'spinxqm_name_required', __( 'Profile name is required.', 'spinx-quick-migrate' ), array( 'status' => 400 ) ); } if ( Profile_Manager::name_exists( $name ) ) { return new WP_Error( 'spinxqm_name_duplicate', __( 'A profile with this name already exists.', 'spinx-quick-migrate' ), array( 'status' => 409 ) ); } $item = Profile_Manager::save( $name, $type, $payload ); // Promoting a recent (unsaved) config to a saved profile removes it from the recent // list, so a configuration never lives in both places at once. if ( '' !== $from_recent ) { Profile_Manager::delete_recent( $from_recent ); } return array( 'success' => true, 'profile' => $item, 'saved_profiles' => Profile_Manager::get_saved(), 'recent_profiles' => Profile_Manager::get_recent(), ); } /** * Overwrite an existing saved profile's type + payload with a new configuration * (the "Save to existing profile" path). Name and created_at are preserved. * * @param WP_REST_Request $request Request carrying `id`, `type`, `payload` and optional `name`. * @return array|WP_Error Updated profile, or an error if missing or the name is taken. */ public static function profile_update( WP_REST_Request $request ) { $id = sanitize_text_field( (string) $request->get_param( 'id' ) ); $type = sanitize_key( (string) $request->get_param( 'type' ) ); $payload = self::safe_payload( $request->get_param( 'payload' ) ); // Optional rename applied in the same call ("Save to existing profile" name box). // Absent/empty leaves the existing name untouched. $name = null !== $request->get_param( 'name' ) ? sanitize_text_field( (string) $request->get_param( 'name' ) ) : ''; if ( '' === $id ) { return new WP_Error( 'spinxqm_profile_missing', __( 'Profile not found.', 'spinx-quick-migrate' ), array( 'status' => 404 ) ); } if ( '' !== $name && Profile_Manager::name_exists( $name, $id ) ) { return new WP_Error( 'spinxqm_name_duplicate', __( 'A profile with this name already exists.', 'spinx-quick-migrate' ), array( 'status' => 409 ) ); } $item = Profile_Manager::update( $id, $type, $payload, '' !== $name ? $name : null ); if ( null === $item ) { return new WP_Error( 'spinxqm_profile_missing', __( 'Profile not found.', 'spinx-quick-migrate' ), array( 'status' => 404 ) ); } return array( 'success' => true, 'profile' => $item, 'saved_profiles' => Profile_Manager::get_saved(), ); } /** * Rename a saved profile. * * @param WP_REST_Request $request Request carrying the profile `id` and new `name`. * @return array|WP_Error Updated profile list, or an error if missing or the name is taken. */ public static function profile_rename( WP_REST_Request $request ) { $id = sanitize_text_field( (string) $request->get_param( 'id' ) ); $name = sanitize_text_field( (string) $request->get_param( 'name' ) ); if ( '' === $name ) { return new WP_Error( 'spinxqm_name_required', __( 'Profile name is required.', 'spinx-quick-migrate' ), array( 'status' => 400 ) ); } if ( Profile_Manager::name_exists( $name, $id ) ) { return new WP_Error( 'spinxqm_name_duplicate', __( 'A profile with this name already exists.', 'spinx-quick-migrate' ), array( 'status' => 409 ) ); } $ok = Profile_Manager::rename( $id, $name ); if ( ! $ok ) { return new WP_Error( 'spinxqm_profile_missing', __( 'Profile not found.', 'spinx-quick-migrate' ), array( 'status' => 404 ) ); } return array( 'success' => true, 'saved_profiles' => Profile_Manager::get_saved(), ); } /** * Delete a saved profile. * * @param WP_REST_Request $request Request carrying the profile `id`. * @return array */ public static function profile_delete( WP_REST_Request $request ): array { $id = sanitize_text_field( (string) $request->get_param( 'id' ) ); return array( 'success' => Profile_Manager::delete( $id ), 'saved_profiles' => Profile_Manager::get_saved(), ); } /** * Store a configuration in the recent (unsaved) list. * * @param WP_REST_Request $request Request carrying `label`, `type` and `payload`. * @return array */ public static function recent_save( WP_REST_Request $request ): array { $label = sanitize_text_field( (string) $request->get_param( 'label' ) ); $type = sanitize_key( (string) $request->get_param( 'type' ) ); $payload = self::safe_payload( $request->get_param( 'payload' ) ); $item = Profile_Manager::save_recent( $label, $type, $payload ); return array( 'success' => true, 'recent' => $item, 'recent_profiles' => Profile_Manager::get_recent(), ); } /** * Remove an entry from the recent (unsaved) list. * * @param WP_REST_Request $request Request carrying the recent entry `id`. * @return array */ public static function recent_delete( WP_REST_Request $request ): array { $id = sanitize_text_field( (string) $request->get_param( 'id' ) ); return array( 'success' => Profile_Manager::delete_recent( $id ), 'recent_profiles' => Profile_Manager::get_recent(), ); } /** * Sanitize, clamp and persist the plugin settings. * * @param WP_REST_Request $request Request carrying the `settings` payload. * @return array */ public static function settings_save( WP_REST_Request $request ): array { $current = Helpers::get_settings(); $input = self::safe_payload( $request->get_param( 'settings' ) ); $preset = sanitize_key( (string) ( $input['batch_preset'] ?? $current['batch_preset'] ?? 'medium' ) ); if ( ! Batch_Settings::is_valid_preset( $preset ) ) { $preset = 'medium'; } $settings = array( 'max_recent_unsaved' => max( 1, absint( $input['max_recent_unsaved'] ?? $current['max_recent_unsaved'] ) ), 'debug_log_enabled' => Security::bool( $input['debug_log_enabled'] ?? $current['debug_log_enabled'] ) ? 1 : 0, 'sql_rows_per_batch' => max( 100, absint( $input['sql_rows_per_batch'] ?? $current['sql_rows_per_batch'] ) ), 'file_scan_batch_size' => max( 50, absint( $input['file_scan_batch_size'] ?? $current['file_scan_batch_size'] ) ), 'batch_preset' => $preset, 'batch_size_custom' => max( 1, absint( $input['batch_size_custom'] ?? $current['batch_size_custom'] ?? 50 ) ), 'max_chunk_bytes' => max( 256 * KB_IN_BYTES, min( 128 * MB_IN_BYTES, absint( $input['max_chunk_bytes'] ?? $current['max_chunk_bytes'] ?? ( 6 * MB_IN_BYTES ) ) ) ), 'gzip_level' => max( 1, min( 9, (int) ( $input['gzip_level'] ?? $current['gzip_level'] ?? 6 ) ) ), 'export_exclusions' => self::sanitize_patterns( $input['export_exclusions'] ?? $current['export_exclusions'] ?? Helpers::default_export_exclusions() ), ); update_option( 'spinxqm_settings', $settings, false ); return array( 'success' => true, 'settings' => $settings, ); } /** * Create and prepare a browser-driven export job. * * @param WP_REST_Request $request Request carrying the export `config`. * @return array */ public static function export_start( WP_REST_Request $request ): array { Helpers::ensure_storage_dir(); $payload = self::safe_payload( $request->get_param( 'config' ) ); $job = Helpers::create_job( 'export' ); $audit = new Audit_Logger(); $audit->log_operation_start( get_current_user_id(), 'export', $job['id'], array( 'tables' => Security::sanitize_tables( (array) ( $payload['tables'] ?? array() ) ), ) ); $args = self::normalize_export_args( $payload ); self::prepare_job( $job['id'], 'export', $args ); return array( 'success' => true, 'job_id' => $job['id'], ); } /** * Create and prepare a browser-driven backup job. * * @param WP_REST_Request $request Request carrying the backup `config`. * @return array */ public static function backup_start( WP_REST_Request $request ): array { Helpers::ensure_storage_dir(); $payload = self::safe_payload( $request->get_param( 'config' ) ); $job = Helpers::create_job( 'backup' ); $audit = new Audit_Logger(); $audit->log_operation_start( get_current_user_id(), 'backup', $job['id'], array( 'tables' => Security::sanitize_tables( (array) ( $payload['tables'] ?? array() ) ), ) ); $args = self::normalize_export_args( $payload ); self::prepare_job( $job['id'], 'backup', $args ); return array( 'success' => true, 'job_id' => $job['id'], ); } /** * Preview a find/replace run (matching rows and replacement counts) without writing. * * @param WP_REST_Request $request Request carrying the find/replace `config`. * @return array|WP_Error Preview summary, or a validation error. */ public static function find_replace_preview( WP_REST_Request $request ) { $payload = self::safe_payload( $request->get_param( 'config' ) ); $validation = self::validate_find_replace_payload( $payload ); if ( is_wp_error( $validation ) ) { return $validation; } // Both Site URL and Site Path (and any custom rule) empty → nothing to preview. if ( empty( self::effective_rules( $payload['rules'] ?? array() ) ) ) { return array( 'success' => true, 'skipped' => true, 'message' => __( 'No find values provided — nothing to replace.', 'spinx-quick-migrate' ), 'preview' => array( 'summary' => array(), 'totals' => array( 'rows' => 0, 'replacements' => 0, ), ), ); } $args = array( 'tables' => Security::sanitize_tables( (array) ( $payload['tables'] ?? array() ) ), 'rules' => (array) ( $payload['rules'] ?? array() ), ); return array( 'success' => true, 'preview' => Find_Replace::preview( $args ), ); } /** * Validate a find/replace config and create a browser-driven find/replace job. * * @param WP_REST_Request $request Request carrying the find/replace `config`. * @return array|WP_Error Job details, or a validation error. */ public static function find_replace_apply( WP_REST_Request $request ) { $payload = self::safe_payload( $request->get_param( 'config' ) ); $validation = self::validate_find_replace_payload( $payload ); if ( is_wp_error( $validation ) ) { return $validation; } // Requirement: if both Site URL and Site Path sources are empty (and no // custom rule has a source), skip the replace operation entirely. if ( empty( self::effective_rules( $payload['rules'] ?? array() ) ) ) { return array( 'success' => true, 'skipped' => true, 'message' => __( 'No find values provided — replace skipped.', 'spinx-quick-migrate' ), ); } // Log which replace mode is running for traceability. $preset = self::validate_standard_presets( $payload ); $preset = is_array( $preset ) ? $preset : array( 'url' => false, 'path' => false, ); $has_custom = ! empty( self::effective_rules( $payload['custom_rules'] ?? array() ) ); if ( $preset['url'] && $preset['path'] ) { $mode = 'combined'; } elseif ( $preset['url'] ) { $mode = 'url_only'; } elseif ( $preset['path'] ) { $mode = 'path_only'; } else { $mode = $has_custom ? 'custom_only' : 'skip'; } Logger::log( 'info', 'Find & replace started', array( 'mode' => $mode ) ); Helpers::ensure_storage_dir(); $job = Helpers::create_job( 'find_replace' ); $audit = new Audit_Logger(); $audit->log_operation_start( get_current_user_id(), 'find_replace', $job['id'], array( 'tables' => Security::sanitize_tables( (array) ( $payload['tables'] ?? array() ) ), 'backup_before' => ! empty( $payload['backup_before'] ), 'backup_mode' => sanitize_key( (string) ( $payload['backup_before_mode'] ?? '' ) ), 'mode' => $mode, ) ); $fr_tables = Security::sanitize_tables( (array) ( $payload['tables'] ?? array() ) ); $backup_args = self::normalize_export_args( $payload ); // The pre-flight backup chooses its own table set independently of what the // find & replace run touches (No backup / All prefixed / Tables being migrated / // Manually selected). // Override the export args' table list (and pin the mode to "selected" so the // dump never re-derives "all") so the backup captures exactly that set. $backup_args['tables'] = self::resolve_backup_tables( $payload, $fr_tables ); $backup_args['tables_mode'] = 'selected'; $args = array( 'tables' => $fr_tables, 'rules' => (array) ( $payload['rules'] ?? array() ), 'backup_before' => ! empty( $payload['backup_before'] ), 'backup_before_mode' => sanitize_key( (string) ( $payload['backup_before_mode'] ?? '' ) ), 'backup_args' => $backup_args, ); // Browser-driven (no cron) — the front-end advances it via /jobs/{id}/step. self::prepare_job( $job['id'], 'find_replace', $args ); return array( 'success' => true, 'job_id' => $job['id'], ); } /** * Advance an export by ONE byte-budgeted chunk and return the job state. The * browser drives the run by calling POST /jobs/{id}/step back-to-back (no cron, * no delay). Checkpoint phases: * db — dump the database to SQL, then create the combined ZIP. * files — add the selected files to the ZIP, a byte-budgeted slice per step. * finalize — write the manifest, drop the standalone SQL, publish result. * * @param string $job_id Export job ID. * @return array Formatted job state (see format_job_response()). */ public static function advance_export( string $job_id ): array { if ( Helpers::is_job_cancelled( $job_id ) ) { self::cleanup_export_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( Helpers::is_job_paused( $job_id ) ) { return self::job_state( $job_id ); } $payload = get_transient( 'spinxqm_job_payload_' . $job_id ); if ( ! is_array( $payload ) || 'export' !== ( $payload['op'] ?? '' ) ) { return self::job_state( $job_id ); } $args = is_array( $payload['args'] ?? null ) ? $payload['args'] : array(); $audit = new Audit_Logger(); Batch_Settings::maybe_raise_memory(); $cp = Helpers::get_checkpoint( $job_id ); if ( empty( $cp['phase'] ) ) { $cp = self::init_export_job( $job_id, $args ); } switch ( $cp['phase'] ) { case 'db': // Files-only export: no database dump. Create the archive empty (the ZIP // builder skips a missing .sql) and go straight to the files phase. if ( empty( $cp['include_database'] ) ) { $zip = Zip_Exporter::create_archive( '' ); if ( empty( $zip['success'] ) ) { $msg = (string) ( $zip['message'] ?? __( 'ZIP creation failed.', 'spinx-quick-migrate' ) ); self::cleanup_export_temp( $job_id ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } $cp['zip_file'] = (string) $zip['file']; $cp['zip_url'] = (string) $zip['url']; $cp['zip_name'] = (string) $zip['filename']; $cp['phase'] = ! empty( $cp['file_sections'] ) ? 'files' : 'finalize'; $first_section = ! empty( $cp['file_sections'] ) ? (string) $cp['file_sections'][0] : ''; if ( '' !== $first_section ) { self::set_section_status( $job_id, $first_section, 'running' ); } break; } $res = Database_Exporter::dump_batch( $args, $job_id, (string) $cp['sql_file'], is_array( $cp['dump'] ?? null ) ? $cp['dump'] : array() ); if ( ! empty( $res['cancelled'] ) ) { self::cleanup_export_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( empty( $res['ok'] ) ) { $msg = (string) ( $res['message'] ?? __( 'Export failed.', 'spinx-quick-migrate' ) ); self::cleanup_export_temp( $job_id ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } $cp['dump'] = $res['checkpoint']; $cp['done_size'] = (int) $res['done_size']; $total_size = (int) $cp['db_size'] + (int) $cp['files_size']; // The database is one equal component slice; advance it by its own bytes. $bp = self::band_plan( $cp, 3.0, 99.0 ); $db_sz = max( 1, (int) $cp['db_size'] ); Helpers::update_job( $job_id, array( 'processed_size' => (int) $cp['done_size'], 'total_size' => $total_size, 'progress' => (int) floor( $bp['db_start'] + ( $bp['db_end'] - $bp['db_start'] ) * min( 1.0, (int) $cp['done_size'] / $db_sz ) ), ) ); if ( empty( $res['done'] ) ) { break; // More database batches; next step continues. } self::set_section_status( $job_id, 'database', 'completed' ); $zip = Zip_Exporter::create_archive( (string) $cp['sql_file'] ); if ( empty( $zip['success'] ) ) { $msg = (string) ( $zip['message'] ?? __( 'ZIP creation failed.', 'spinx-quick-migrate' ) ); self::cleanup_export_temp( $job_id ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } $cp['zip_file'] = (string) $zip['file']; $cp['zip_url'] = (string) $zip['url']; $cp['zip_name'] = (string) $zip['filename']; $cp['phase'] = 'files'; // Light up only the FIRST file section; the rest flip to running // one-by-one as the combined file stream crosses each section // boundary (see advance_file_sections). $first_section = ! empty( $cp['file_sections'] ) ? (string) $cp['file_sections'][0] : ''; if ( '' !== $first_section ) { self::set_section_status( $job_id, $first_section, 'running' ); } break; case 'files': if ( Helpers::is_job_cancelled( $job_id ) ) { self::cleanup_export_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } $files = self::get_export_files( $job_id ); $start = (int) ( $cp['file_index'] ?? 0 ); $total = (int) ( $cp['file_total'] ?? count( $files ) ); // Build ONE slice up to a generous byte budget, then reopen the ZIP // exactly once for the whole slice — ZipArchive rewrites on close, so // reopening per-file would be O(n^2). Cap by count too, to bound a // single close() when files happen to be large. $max_bytes = Batch_Settings::chunk_bytes( 'export' ); $slice = array(); $slice_bytes = 0; $idx = $start; while ( $idx < $total ) { $item = $files[ $idx ]; $slice[] = $item; ++$idx; $src = isset( $item['source'] ) ? (string) $item['source'] : ''; if ( '' !== $src && file_exists( $src ) ) { $slice_bytes += (int) filesize( $src ); } if ( $slice_bytes >= $max_bytes || count( $slice ) >= 1500 ) { break; } } if ( ! empty( $slice ) ) { $res = Zip_Exporter::append_files( (string) $cp['zip_file'], $slice ); if ( empty( $res['success'] ) ) { $msg = (string) ( $res['message'] ?? __( 'ZIP creation failed.', 'spinx-quick-migrate' ) ); self::cleanup_export_temp( $job_id ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } $start += count( $slice ); $cp['done_size'] = (int) $cp['done_size'] + (int) $res['added_size']; $total_size = (int) $cp['db_size'] + (int) $cp['files_size']; // Files share the band AFTER the database's equal slice, byte-weighted (smooth). $bp = self::band_plan( $cp, 3.0, 99.0 ); $files_sz = max( 1, (int) $cp['files_size'] ); $files_done = max( 0, (int) $cp['done_size'] - (int) $cp['db_size'] ); $progress = (int) floor( $bp['files_start'] + ( $bp['files_end'] - $bp['files_start'] ) * min( 1.0, $files_done / $files_sz ) ); $current = (string) ( $slice[ count( $slice ) - 1 ]['target'] ?? '' ); Helpers::update_job( $job_id, array( 'status' => 'running', 'progress' => $progress, /* translators: 1: files added so far, 2: total files */ 'message' => sprintf( __( 'Adding files %1$d of %2$d', 'spinx-quick-migrate' ), $start, $total ), 'current_item' => $current, 'processed' => $start, 'total' => $total, 'processed_size' => (int) $cp['done_size'], 'total_size' => $total_size, ) ); // Reflect per-module progress: the section we've reached is running, // the ones before it are completed. self::advance_file_sections( $job_id, (array) ( $cp['file_sections'] ?? array() ), $current ); } $cp['file_index'] = $start; if ( $start < $total ) { break; // More files remain; next step continues. } foreach ( (array) ( $cp['file_sections'] ?? array() ) as $sec ) { self::set_section_status( $job_id, $sec, 'completed' ); } $cp['phase'] = 'finalize'; break; case 'finalize': Zip_Exporter::finalize_archive( (string) $cp['zip_file'], array( 'plugin_version' => SPINXQM_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'site_url' => home_url(), 'export_date' => gmdate( 'c' ), 'options' => $args, ) ); // The SQL dump now lives inside the ZIP — drop the standalone copy. if ( ! empty( $cp['sql_file'] ) && file_exists( (string) $cp['sql_file'] ) ) { wp_delete_file( (string) $cp['sql_file'] ); } // Report the REAL final ZIP size (what the user downloads), not the // db-estimate + uncompressed-file-bytes sum used to drive the progress bar. clearstatcache( true, (string) $cp['zip_file'] ); $total_size = file_exists( (string) $cp['zip_file'] ) ? (int) filesize( (string) $cp['zip_file'] ) : ( (int) $cp['db_size'] + (int) $cp['files_size'] ); $result = array( 'success' => true, 'file' => (string) $cp['zip_file'], 'url' => (string) $cp['zip_url'], 'filename' => (string) $cp['zip_name'], ); Helpers::update_job( $job_id, array( 'status' => 'success', 'progress' => 100, 'message' => __( 'Export complete.', 'spinx-quick-migrate' ), 'current_item' => '', 'processed_size' => $total_size, 'total_size' => $total_size, 'finished_at' => time(), 'result' => $result, ) ); update_option( 'spinxqm_last_operation', array( 'type' => 'export', 'timestamp' => time(), 'result' => $result, ), false ); $audit->log_operation_complete( $job_id, array( 'result' => $result ) ); self::clear_export_files( $job_id ); self::clear_post_filter( $job_id ); Helpers::set_checkpoint( $job_id, array( 'phase' => 'done' ) ); delete_transient( 'spinxqm_job_payload_' . $job_id ); return self::job_state( $job_id ); } Helpers::set_checkpoint( $job_id, $cp ); return self::job_state( $job_id ); } /** * First-tick setup for an export job: announce sections, scan the file map * (persisted to its own transient), and seed the checkpoint. * * @param string $job_id Export job ID. * @param array $args Normalized export args. * @return array */ private static function init_export_job( string $job_id, array $args ): array { $has_files = ! empty( $args['file_options']['media']['enabled'] ) || ! empty( $args['file_options']['themes']['enabled'] ) || ! empty( $args['file_options']['plugins']['enabled'] ) || ! empty( $args['file_options']['mu-plugins']['enabled'] ) || ! empty( $args['file_options']['core']['enabled'] ); $include_db = ! isset( $args['include_database'] ) || ! empty( $args['include_database'] ); Helpers::update_job( $job_id, array( 'status' => 'running', 'started_at' => time(), 'message' => __( 'Preparing export…', 'spinx-quick-migrate' ), 'sections' => self::export_sections( $args, $has_files, $include_db ), ) ); if ( $include_db ) { self::set_section_status( $job_id, 'database', 'running' ); } $files = array(); $files_size = 0; $sections = array(); if ( $has_files ) { $scan_options = is_array( $args['file_options'] ?? null ) ? $args['file_options'] : array(); $scan_options['export_exclusions'] = is_array( $args['export_exclusions'] ?? null ) ? $args['export_exclusions'] : array(); $files = File_Scanner::collect( $scan_options ); foreach ( $files as $file_item ) { if ( isset( $file_item['source'] ) && file_exists( $file_item['source'] ) ) { $files_size += (int) filesize( $file_item['source'] ); } } foreach ( array( 'media', 'themes', 'plugins', 'mu-plugins', 'core' ) as $sec ) { if ( ! empty( $args['file_options'][ $sec ]['enabled'] ) ) { $sections[] = $sec; } } } self::set_export_files( $job_id, $files ); self::build_post_filter( $job_id, $args ); $db_estimate = $include_db ? Database_Exporter::estimated_size( (array) ( $args['tables'] ?? array() ) ) : 0; $sql_file = $include_db ? ( trailingslashit( Helpers::storage_dir() ) . Helpers::build_filename( 'database', 'sql' ) ) : ''; Helpers::update_job( $job_id, array( 'total_size' => $db_estimate + $files_size, 'processed_size' => 0, ) ); return array( 'phase' => 'db', 'include_database' => $include_db, 'sql_file' => $sql_file, 'dump' => array(), 'file_index' => 0, 'file_total' => count( $files ), 'files_size' => $files_size, 'file_sections' => $sections, // Equal-component band plan treats the database as one slice and each file // section (media/themes/plugins/core) as one slice, so the database can't // swallow most of the bar. band_plan() reads root_total + include_database. 'root_total' => count( $sections ), 'db_size' => $db_estimate, 'done_size' => 0, ); } /** * Persist an export job's scanned file map to its own transient. * * @param string $job_id Export job ID. * @param array> $files File map entries to store. * @return void */ private static function set_export_files( string $job_id, array $files ): void { set_transient( 'spinxqm_job_files_' . $job_id, $files, Helpers::job_ttl() ); } /** * Load an export job's scanned file map (empty when missing or expired). * * @param string $job_id Export job ID. * @return array> */ private static function get_export_files( string $job_id ): array { $files = get_transient( 'spinxqm_job_files_' . $job_id ); return is_array( $files ) ? $files : array(); } /** * Delete an export job's file map transient. * * @param string $job_id Export job ID. * @return void */ private static function clear_export_files( string $job_id ): void { delete_transient( 'spinxqm_job_files_' . $job_id ); } /** * When the user limits the export to specific post types, build the set of * post IDs that belong to those types and stash it (flipped, for O(1) lookup) * in a per-job transient. The dump engine uses it to drop wp_postmeta / * wp_comments rows that belong to excluded posts (wp_posts is filtered by its * own post_type column and needs no set). Built with one clean `post_type = %s` * query per selected type — no dynamic IN / interpolated SQL. * * @param string $job_id Export job ID. * @param array $args Normalized export args (`post_types_mode`, `post_types`). * @return void */ private static function build_post_filter( string $job_id, array $args ): void { if ( 'selected' !== ( $args['post_types_mode'] ?? 'all' ) ) { return; } $types = array(); foreach ( (array) ( $args['post_types'] ?? array() ) as $name ) { $name = sanitize_key( (string) $name ); if ( '' !== $name ) { $types[] = $name; } } if ( empty( $types ) ) { return; // Empty selection → treat as "no filter" (never export zero posts by accident). } global $wpdb; $ids = array(); foreach ( array_unique( $types ) as $type ) { $col = $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE post_type = %s', $wpdb->posts, $type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Collect live post IDs for the export post-type filter; prepared (%i/%s), must reflect current data. foreach ( (array) $col as $id ) { $ids[ (int) $id ] = 1; } } set_transient( 'spinxqm_job_postids_' . $job_id, $ids, Helpers::job_ttl() ); } /** * Delete an export job's included-post-ID transient. * * @param string $job_id Export job ID. * @return void */ private static function clear_post_filter( string $job_id ): void { delete_transient( 'spinxqm_job_postids_' . $job_id ); } /** * Remove an export job's partial on-disk output (ZIP + standalone SQL) and * file-map transient after a cancel or failure. * * @param string $job_id Export job ID. * @return void */ private static function cleanup_export_temp( string $job_id ): void { $cp = Helpers::get_checkpoint( $job_id ); foreach ( array( 'zip_file', 'sql_file' ) as $key ) { $path = isset( $cp[ $key ] ) ? (string) $cp[ $key ] : ''; if ( '' !== $path && file_exists( $path ) && self::within_storage( $path ) ) { wp_delete_file( $path ); } } self::clear_export_files( $job_id ); self::clear_post_filter( $job_id ); } /** * Guard: a path must live inside the plugin's storage directory before we * ever delete it. * * @param string $path Absolute path to check. * @return bool */ private static function within_storage( string $path ): bool { $base = wp_normalize_path( trailingslashit( Helpers::storage_dir() ) ); $norm = wp_normalize_path( $path ); return '' !== $base && 0 === strpos( $norm, $base ); } /** * Advance a backup by ONE byte-budgeted chunk and return the job state * (browser-driven via /step, same model as advance_export). Phases: * `db` (batched plain dump), `compress` (single-pass gzip when requested), * `finalize` (publish result). * * @param string $job_id Backup job ID. * @return array Formatted job state. */ public static function advance_backup( string $job_id ): array { if ( Helpers::is_job_cancelled( $job_id ) ) { self::cleanup_backup_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( Helpers::is_job_paused( $job_id ) ) { return self::job_state( $job_id ); } $payload = get_transient( 'spinxqm_job_payload_' . $job_id ); if ( ! is_array( $payload ) || 'backup' !== ( $payload['op'] ?? '' ) ) { return self::job_state( $job_id ); } $args = is_array( $payload['args'] ?? null ) ? $payload['args'] : array(); $audit = new Audit_Logger(); Batch_Settings::maybe_raise_memory(); $cp = Helpers::get_checkpoint( $job_id ); if ( empty( $cp['phase'] ) ) { $cp = self::init_backup_job( $job_id, $args ); } switch ( $cp['phase'] ) { case 'db': $res = Database_Exporter::dump_batch( $args, $job_id, (string) $cp['sql_plain'], is_array( $cp['dump'] ?? null ) ? $cp['dump'] : array() ); if ( ! empty( $res['cancelled'] ) ) { self::cleanup_backup_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( empty( $res['ok'] ) ) { $msg = (string) ( $res['message'] ?? __( 'Backup failed.', 'spinx-quick-migrate' ) ); self::cleanup_backup_temp( $job_id ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } $cp['dump'] = $res['checkpoint']; $cp['done_size'] = (int) $res['done_size']; $total_size = (int) $cp['db_size']; Helpers::update_job( $job_id, array( 'processed_size' => (int) $cp['done_size'], 'total_size' => $total_size, 'progress' => $total_size > 0 ? min( 95, (int) floor( ( $cp['done_size'] / $total_size ) * 95 ) ) : 5, ) ); if ( empty( $res['done'] ) ) { break; // More database batches; next step continues. } self::set_section_status( $job_id, 'database', 'completed' ); $cp['phase'] = ! empty( $cp['gzip'] ) ? 'compress' : 'finalize'; break; case 'compress': Helpers::update_job( $job_id, array( 'progress' => 97, 'message' => __( 'Compressing backup…', 'spinx-quick-migrate' ), ) ); $gz = (string) $cp['sql_plain'] . '.gz'; if ( ! Database_Exporter::compress_file( (string) $cp['sql_plain'], $gz, Batch_Settings::gzip_level() ) ) { self::cleanup_backup_temp( $job_id ); self::finish_job_error( $job_id, __( 'Backup compression failed.', 'spinx-quick-migrate' ) ); $audit->log_operation_failure( $job_id, 'Backup compression failed' ); return self::job_state( $job_id ); } if ( file_exists( (string) $cp['sql_plain'] ) ) { wp_delete_file( (string) $cp['sql_plain'] ); } $cp['result_file'] = $gz; $cp['phase'] = 'finalize'; break; case 'finalize': $file = ! empty( $cp['gzip'] ) ? (string) ( $cp['result_file'] ?? '' ) : (string) $cp['sql_plain']; $name = basename( $file ); // Report the REAL delivered file size — the .gz when compressed (much smaller // than the uncompressed db-estimate the bar used), else the plain .sql. clearstatcache( true, $file ); $total = ( $file && file_exists( $file ) ) ? (int) filesize( $file ) : (int) $cp['db_size']; $result = array( 'success' => true, 'file' => $file, 'url' => Downloads::url( $name ), 'filename' => $name, ); Helpers::update_job( $job_id, array( 'status' => 'success', 'progress' => 100, 'message' => __( 'Backup complete.', 'spinx-quick-migrate' ), 'current_item' => '', 'processed_size' => $total, 'total_size' => $total, 'finished_at' => time(), 'result' => $result, ) ); update_option( 'spinxqm_last_operation', array( 'type' => 'backup', 'timestamp' => time(), 'result' => $result, ), false ); $audit->log_operation_complete( $job_id, array( 'result' => $result ) ); self::clear_post_filter( $job_id ); Helpers::set_checkpoint( $job_id, array( 'phase' => 'done' ) ); delete_transient( 'spinxqm_job_payload_' . $job_id ); return self::job_state( $job_id ); } Helpers::set_checkpoint( $job_id, $cp ); return self::job_state( $job_id ); } /** * First-tick setup for a backup job: announce the database section, seed the * size estimate, and build the checkpoint (plain SQL path + gzip flag). * * @param string $job_id Backup job ID. * @param array $args Normalized export args for the backup. * @return array */ private static function init_backup_job( string $job_id, array $args ): array { Helpers::update_job( $job_id, array( 'status' => 'running', 'started_at' => time(), 'message' => __( 'Preparing backup…', 'spinx-quick-migrate' ), 'sections' => self::export_sections( $args, false ), ) ); self::set_section_status( $job_id, 'database', 'running' ); self::build_post_filter( $job_id, $args ); $db_estimate = Database_Exporter::estimated_size( (array) ( $args['tables'] ?? array() ) ); $sql_plain = trailingslashit( Helpers::storage_dir() ) . Helpers::build_filename( 'database', 'sql' ); Helpers::update_job( $job_id, array( 'total_size' => $db_estimate, 'processed_size' => 0, ) ); return array( 'phase' => 'db', 'sql_plain' => $sql_plain, 'gzip' => ! empty( $args['gzip'] ), 'dump' => array(), 'db_size' => $db_estimate, 'done_size' => 0, ); } /** * Remove a backup job's partial on-disk output (plain SQL + gzip) after a * cancel or failure. * * @param string $job_id Backup job ID. * @return void */ private static function cleanup_backup_temp( string $job_id ): void { $cp = Helpers::get_checkpoint( $job_id ); foreach ( array( 'sql_plain', 'result_file' ) as $key ) { $path = isset( $cp[ $key ] ) ? (string) $cp[ $key ] : ''; if ( '' !== $path && file_exists( $path ) && self::within_storage( $path ) ) { wp_delete_file( $path ); } } self::clear_post_filter( $job_id ); } /** * Advance a find & replace by ONE chunk and return the job state (browser-driven * via /step, same model as advance_export/advance_backup). Phases: * backup — optional pre-flight DB backup (raw, no rules applied), chunked. * replace — apply rules in pk-ordered, row-budgeted chunks (serialized-safe). * finalize — publish totals (+ the pre-flight backup file if one was made). * * @param string $job_id Find & replace job ID. * @return array Formatted job state. */ public static function advance_find_replace( string $job_id ): array { if ( Helpers::is_job_cancelled( $job_id ) ) { self::cleanup_find_replace_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( Helpers::is_job_paused( $job_id ) ) { return self::job_state( $job_id ); } $payload = get_transient( 'spinxqm_job_payload_' . $job_id ); if ( ! is_array( $payload ) || 'find_replace' !== ( $payload['op'] ?? '' ) ) { return self::job_state( $job_id ); } $args = is_array( $payload['args'] ?? null ) ? $payload['args'] : array(); $audit = new Audit_Logger(); Batch_Settings::maybe_raise_memory(); $cp = Helpers::get_checkpoint( $job_id ); if ( empty( $cp['phase'] ) ) { $cp = self::init_find_replace_job( $job_id, $args ); } $staged = ! empty( $cp['staged'] ); switch ( $cp['phase'] ) { case 'backup': // Raw dump (rules stripped in init) so the backup is a true rollback point. $res = Database_Exporter::dump_batch( is_array( $cp['backup_args'] ?? null ) ? $cp['backup_args'] : array(), $job_id, (string) $cp['backup_plain'], is_array( $cp['backup_dump'] ?? null ) ? $cp['backup_dump'] : array() ); if ( ! empty( $res['cancelled'] ) ) { self::cleanup_find_replace_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( empty( $res['ok'] ) ) { $msg = (string) ( $res['message'] ?? __( 'Pre-flight backup failed.', 'spinx-quick-migrate' ) ); self::cleanup_find_replace_temp( $job_id ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } $cp['backup_dump'] = $res['checkpoint']; // Backup occupies the first 40% of the bar. $bsize = max( 1, (int) ( $cp['backup_size'] ?? 0 ) ); Helpers::update_job( $job_id, array( 'progress' => (int) min( 40, floor( ( (int) $res['done_size'] / $bsize ) * 40 ) ) ) ); if ( empty( $res['done'] ) ) { break; } if ( ! empty( $cp['backup_gzip'] ) ) { $gz = (string) $cp['backup_plain'] . '.gz'; if ( ! Database_Exporter::compress_file( (string) $cp['backup_plain'], $gz, Batch_Settings::gzip_level() ) ) { self::cleanup_find_replace_temp( $job_id ); self::finish_job_error( $job_id, __( 'Pre-flight backup compression failed.', 'spinx-quick-migrate' ) ); return self::job_state( $job_id ); } if ( file_exists( (string) $cp['backup_plain'] ) ) { wp_delete_file( (string) $cp['backup_plain'] ); } $cp['backup_file'] = $gz; } else { $cp['backup_file'] = (string) $cp['backup_plain']; } self::set_section_status( $job_id, 'backup', 'completed' ); if ( $staged ) { self::set_section_status( $job_id, 'stage', 'running' ); $cp['phase'] = 'stage'; } else { self::set_section_status( $job_id, 'replace', 'running' ); $cp['phase'] = 'replace'; } break; case 'stage': // Build a staging copy of every processable table, then run the replace on // the copies — the live tables are never touched until the atomic swap. $settings = Helpers::get_settings(); $row_budget = max( 100, absint( $settings['sql_rows_per_batch'] ) ); $deadline = time() + Batch_Settings::time_budget( 'find_replace' ); $plan = is_array( $cp['stage'] ?? null ) ? $cp['stage'] : array(); $total = count( $plan ); $sti = (int) ( $cp['stage_ti'] ?? 0 ); $copied_now = 0; while ( $sti < $total ) { if ( Helpers::is_job_cancelled( $job_id ) ) { $cp['stage'] = $plan; $cp['stage_ti'] = $sti; Helpers::set_checkpoint( $job_id, $cp ); self::cleanup_find_replace_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( Helpers::is_job_paused( $job_id ) ) { break; } $e = $plan[ $sti ]; if ( empty( $e['created'] ) ) { $count = Staged_Replace::create_copy( (string) $e['orig'], (string) $e['temp'] ); if ( is_wp_error( $count ) ) { $cp['stage'] = $plan; $cp['stage_ti'] = $sti; Helpers::set_checkpoint( $job_id, $cp ); self::cleanup_find_replace_temp( $job_id ); self::finish_job_error( $job_id, $count->get_error_message() ); $audit->log_operation_failure( $job_id, $count->get_error_message() ); return self::job_state( $job_id ); } $e['created'] = true; $e['count'] = (int) $count; $e['done'] = ( 0 === (int) $count ); } if ( empty( $e['done'] ) ) { $chunk = Staged_Replace::copy_chunk( (string) $e['orig'], (string) $e['temp'], (string) $e['pk'], $row_budget ); if ( is_wp_error( $chunk ) ) { $cp['stage'] = $plan; $cp['stage_ti'] = $sti; Helpers::set_checkpoint( $job_id, $cp ); self::cleanup_find_replace_temp( $job_id ); self::finish_job_error( $job_id, $chunk->get_error_message() ); $audit->log_operation_failure( $job_id, $chunk->get_error_message() ); return self::job_state( $job_id ); } $e['done'] = ! empty( $chunk['done'] ); $copied_now += (int) $chunk['copied']; } $plan[ $sti ] = $e; if ( ! empty( $e['done'] ) ) { ++$sti; } $lo = ! empty( $cp['has_backup'] ) ? 25 : 0; $hi = ! empty( $cp['has_backup'] ) ? 45 : 25; Helpers::update_job( $job_id, array( 'status' => 'running', /* translators: %s: database table name */ 'message' => sprintf( __( 'Staging %s…', 'spinx-quick-migrate' ), (string) $e['orig'] ), 'current_item' => (string) $e['orig'], 'processed' => $sti, 'total' => $total, 'progress' => $total > 0 ? min( $hi, $lo + (int) floor( ( $sti / $total ) * ( $hi - $lo ) ) ) : $hi, ) ); if ( $copied_now >= $row_budget || time() >= $deadline ) { break; } } $cp['stage'] = $plan; $cp['stage_ti'] = $sti; if ( $sti >= $total ) { // Point the replace engine at the COPIES. $temp_tables = array(); foreach ( $plan as $e ) { $temp_tables[] = (string) $e['temp']; } $cp['fr'] = array( 'tables' => $temp_tables ); self::set_section_status( $job_id, 'stage', 'completed' ); self::set_section_status( $job_id, 'replace', 'running' ); $cp['phase'] = 'replace'; } break; case 'replace': $res = Find_Replace::apply_batch( $args, $job_id, is_array( $cp['fr'] ?? null ) ? $cp['fr'] : array() ); if ( ! empty( $res['cancelled'] ) ) { self::cleanup_find_replace_temp( $job_id ); self::finish_job_cancelled( $job_id ); return self::job_state( $job_id ); } if ( empty( $res['ok'] ) ) { $msg = (string) ( $res['message'] ?? __( 'Find & replace failed.', 'spinx-quick-migrate' ) ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } $cp['fr'] = $res['checkpoint']; $cp['totals'] = is_array( $res['totals'] ?? null ) ? $res['totals'] : array( 'rows' => 0, 'replacements' => 0, ); // Map table progress into the phase band. $tt = is_array( $res['checkpoint']['tables'] ?? null ) ? count( $res['checkpoint']['tables'] ) : 0; $ti = (int) ( $res['checkpoint']['ti'] ?? 0 ); $lo = $staged ? ( ! empty( $cp['has_backup'] ) ? 45 : 25 ) : ( ! empty( $cp['has_backup'] ) ? 40 : 0 ); $hi = $staged ? 95 : 99; $prog = $tt > 0 ? $lo + (int) floor( ( $ti / $tt ) * ( $hi - $lo ) ) : $hi; Helpers::update_job( $job_id, array( 'progress' => min( $hi, $prog ) ) ); if ( empty( $res['done'] ) ) { break; } self::set_section_status( $job_id, 'replace', 'completed' ); if ( $staged ) { self::set_section_status( $job_id, 'swap', 'running' ); Helpers::update_job( $job_id, array( 'progress' => 96, 'message' => __( 'Applying changes…', 'spinx-quick-migrate' ), ) ); $cp['phase'] = 'swap'; } else { $cp['phase'] = 'finalize'; } break; case 'swap': // Verify every staged copy, then swap them all in atomically. Live tables // are untouched until this single all-or-nothing RENAME. $plan = is_array( $cp['stage'] ?? null ) ? $cp['stage'] : array(); foreach ( $plan as $e ) { if ( ! Staged_Replace::verify( (string) $e['temp'], (int) $e['count'] ) ) { self::cleanup_find_replace_temp( $job_id ); /* translators: %s: database table name */ $msg = sprintf( __( 'Verification failed for %s — changes rolled back, live data left untouched.', 'spinx-quick-migrate' ), (string) $e['orig'] ); self::finish_job_error( $job_id, $msg ); $audit->log_operation_failure( $job_id, $msg ); return self::job_state( $job_id ); } } $pairs = array(); foreach ( $plan as $e ) { $pairs[] = array( 'orig' => (string) $e['orig'], 'temp' => (string) $e['temp'], 'old' => (string) $e['old'], ); } $swap = Staged_Replace::swap( $pairs ); if ( is_wp_error( $swap ) ) { // RENAME is atomic, so the live tables are intact — just drop the copies. self::cleanup_find_replace_temp( $job_id ); self::finish_job_error( $job_id, $swap->get_error_message() ); $audit->log_operation_failure( $job_id, $swap->get_error_message() ); return self::job_state( $job_id ); } Logger::log( 'info', 'Find & replace staged swap complete', array( 'tables' => count( $pairs ) ) ); self::set_section_status( $job_id, 'swap', 'completed' ); Helpers::update_job( $job_id, array( 'progress' => 99 ) ); $cp['phase'] = 'finalize'; break; case 'finalize': $totals = is_array( $cp['totals'] ?? null ) ? $cp['totals'] : array( 'rows' => 0, 'replacements' => 0, ); $result = array( 'success' => true, 'totals' => $totals, ); if ( ! empty( $cp['backup_file'] ) ) { $name = basename( (string) $cp['backup_file'] ); $result['backup'] = array( 'file' => (string) $cp['backup_file'], 'url' => Downloads::url( $name ), 'filename' => $name, ); } /* translators: 1: replacements count, 2: rows changed */ $msg = sprintf( __( 'Find & replace complete — %1$d replacements across %2$d rows.', 'spinx-quick-migrate' ), (int) ( $totals['replacements'] ?? 0 ), (int) ( $totals['rows'] ?? 0 ) ); Helpers::update_job( $job_id, array( 'status' => 'success', 'progress' => 100, 'message' => $msg, 'current_item' => '', 'finished_at' => time(), // F&R moves no files; show the scanned-database size as the headline. 'processed_size' => (int) ( $cp['fr_db_size'] ?? 0 ), 'total_size' => (int) ( $cp['fr_db_size'] ?? 0 ), 'result' => $result, ) ); update_option( 'spinxqm_last_operation', array( 'type' => 'find_replace', 'timestamp' => time(), 'result' => $result, ), false ); $audit->log_operation_complete( $job_id, array( 'result' => $result ) ); Helpers::set_checkpoint( $job_id, array( 'phase' => 'done' ) ); delete_transient( 'spinxqm_job_payload_' . $job_id ); return self::job_state( $job_id ); } Helpers::set_checkpoint( $job_id, $cp ); return self::job_state( $job_id ); } /** * First-tick setup for a find & replace job: announce sections and seed the * checkpoint. When a pre-flight backup is requested, its args have the * find/replace rules stripped so the backup captures the ORIGINAL data. * * @param string $job_id Find & replace job ID. * @param array $args Find & replace job args (tables, rules, backup options). * @return array */ private static function init_find_replace_job( string $job_id, array $args ): array { $has_backup = ! empty( $args['backup_before'] ) && ! empty( $args['backup_args'] ) && is_array( $args['backup_args'] ); // Staging is the default safe workflow (replace on copies, then atomic swap); a // filter can force the legacy in-place path for edge cases. $staged = (bool) apply_filters( 'spinxqm_fr_use_staging', true, $args ); // Resolve which selected tables are actually processable (have a primary key AND // text/blob columns) and build the per-table staging plan. Non-processable tables // are never touched — no replacement applies to them. $token = Staged_Replace::token( $job_id ); $plan = array(); $idx = 0; foreach ( Security::sanitize_tables( $args['tables'] ?? array() ) as $orig ) { $m = Find_Replace::table_meta( $orig ); if ( ! empty( $m['skip'] ) ) { continue; } $plan[] = array( 'orig' => $orig, 'temp' => Staged_Replace::temp_name( $token, $idx ), 'old' => Staged_Replace::old_name( $token, $idx ), 'pk' => (string) $m['pk'], 'count' => (int) ( $m['count'] ?? 0 ), 'created' => false, 'done' => false, ); ++$idx; } // No processable tables → nothing to stage. $staged = $staged && ! empty( $plan ); $sections = array(); if ( $has_backup ) { $sections[] = array( 'key' => 'backup', 'label' => __( 'Pre-flight backup', 'spinx-quick-migrate' ), 'status' => 'pending', ); } if ( $staged ) { $sections[] = array( 'key' => 'stage', 'label' => __( 'Stage tables', 'spinx-quick-migrate' ), 'status' => 'pending', ); } $sections[] = array( 'key' => 'replace', 'label' => __( 'Find & Replace', 'spinx-quick-migrate' ), 'status' => 'pending', ); if ( $staged ) { $sections[] = array( 'key' => 'swap', 'label' => __( 'Apply changes', 'spinx-quick-migrate' ), 'status' => 'pending', ); } Helpers::update_job( $job_id, array( 'status' => 'running', 'started_at' => time(), 'message' => $has_backup ? __( 'Preparing pre-flight backup…', 'spinx-quick-migrate' ) : ( $staged ? __( 'Staging tables…', 'spinx-quick-migrate' ) : __( 'Running find & replace…', 'spinx-quick-migrate' ) ), 'sections' => $sections, ) ); $cp = array( 'phase' => $has_backup ? 'backup' : ( $staged ? 'stage' : 'replace' ), 'has_backup' => $has_backup, 'staged' => $staged, 'token' => $token, 'stage' => $plan, 'stage_ti' => 0, // In-place (non-staged) replace targets the live processable tables directly; // staged replace is re-seeded with the temp names once staging completes. 'fr' => $staged ? array() : array( 'tables' => wp_list_pluck( $plan, 'orig' ) ), 'totals' => array( 'rows' => 0, 'replacements' => 0, ), // "Database size processed" for the process-popup headline — the estimated size // of the tables this find & replace scans (F&R moves no files, so it has no // transfer size). Written into processed_size/total_size at finalize. 'fr_db_size' => (int) Database_Exporter::estimated_size( wp_list_pluck( $plan, 'orig' ) ), ); if ( $has_backup ) { self::set_section_status( $job_id, 'backup', 'running' ); $backup_args = $args['backup_args']; // A pre-flight backup must be a faithful rollback point — never apply // the find/replace rules to it. $backup_args['standard_rules'] = array(); $backup_args['custom_rules'] = array(); // A pre-flight backup is a full rollback point — never narrow it by post type. $backup_args['post_types_mode'] = 'all'; $backup_args['post_types'] = array(); $cp['backup_args'] = $backup_args; $cp['backup_gzip'] = ! empty( $backup_args['gzip'] ); $cp['backup_plain'] = trailingslashit( Helpers::storage_dir() ) . Helpers::build_filename( 'pre-replace-backup', 'sql' ); $cp['backup_dump'] = array(); $cp['backup_size'] = Database_Exporter::estimated_size( (array) ( $backup_args['tables'] ?? array() ) ); Helpers::ensure_storage_dir(); } elseif ( $staged ) { self::set_section_status( $job_id, 'stage', 'running' ); } else { self::set_section_status( $job_id, 'replace', 'running' ); } return $cp; } /** * Roll back a find & replace job's transient artifacts after a cancel or failure: * - Drop the staging table copies, but ONLY before the atomic swap (after the swap * those names are gone / are the live tables, so dropping is both unnecessary and * must never happen). This is the automatic rollback — live data is left untouched. * - Remove a PARTIAL pre-flight backup during the backup phase. Once the backup is * complete it is the user's post-success rollback artifact and is kept. * * @param string $job_id Find & replace job ID. * @return void */ private static function cleanup_find_replace_temp( string $job_id ): void { $cp = Helpers::get_checkpoint( $job_id ); $phase = (string) ( $cp['phase'] ?? '' ); if ( ! empty( $cp['staged'] ) && in_array( $phase, array( 'backup', 'stage', 'replace', 'swap' ), true ) ) { $temps = array(); foreach ( (array) ( $cp['stage'] ?? array() ) as $e ) { if ( ! empty( $e['temp'] ) ) { $temps[] = (string) $e['temp']; } } if ( ! empty( $temps ) ) { Staged_Replace::drop_tables( $temps ); } } if ( 'backup' === $phase ) { foreach ( array( 'backup_plain', 'backup_file' ) as $key ) { $path = isset( $cp[ $key ] ) ? (string) $cp[ $key ] : ''; if ( '' !== $path && file_exists( $path ) && self::within_storage( $path ) ) { wp_delete_file( $path ); } } } } /** * Resolve which tables a pre-flight (backup-before-find-&-replace) backup should * capture. * * Supported modes: * prefix → every table carrying this site's table prefix. * selected_for_migration → the tables chosen for this find & replace run. * selected → the tables explicitly ticked in the backup list, * validated against the live table list. * * "none" never reaches here (no backup is created). An empty resolved set always * falls back to the full prefixed-table set, so a requested backup is never * silently skipped or written empty. * * @param array $payload Raw request payload. * @param array $fr_tables Sanitized tables selected for the run. * @return array Sanitized table names to back up. */ private static function resolve_backup_tables( array $payload, array $fr_tables ): array { $mode = sanitize_key( (string) ( $payload['backup_before_mode'] ?? '' ) ); if ( '' === $mode ) { // Legacy clients sent only the boolean; treat as "tables being migrated". $mode = ! empty( $payload['backup_before'] ) ? 'selected_for_migration' : 'none'; } $prefixed = Security::sanitize_tables( Helpers::all_prefix_tables() ); switch ( $mode ) { case 'prefix': $tables = $prefixed; break; case 'selected': // Manual selection — sanitize_tables() already validates each name // against the live SHOW TABLES list, dropping anything stale. $tables = Security::sanitize_tables( (array) ( $payload['backup_tables'] ?? array() ) ); break; case 'selected_for_migration': default: $tables = $fr_tables; break; } // Never write an empty backup when one was requested (fall back to prefixed). if ( empty( $tables ) ) { $tables = $prefixed; } return Security::sanitize_tables( $tables ); } /** * Normalize an export/backup request payload into the args the job engine expects. * * @param array $payload Sanitized request payload. * @return array */ private static function normalize_export_args( array $payload ): array { $pt_mode = 'selected' === ( $payload['post_types_mode'] ?? 'all' ) ? 'selected' : 'all'; $pt = array(); foreach ( (array) ( $payload['post_types'] ?? array() ) as $name ) { $name = sanitize_key( (string) $name ); if ( '' !== $name ) { $pt[] = $name; } } return array( 'tables' => Security::sanitize_tables( (array) ( $payload['tables'] ?? array() ) ), 'include_database' => ! isset( $payload['include_database'] ) ? true : Security::bool( $payload['include_database'] ), 'gzip' => Security::bool( $payload['gzip'] ?? true ), 'post_types_mode' => $pt_mode, 'post_types' => array_values( array_unique( $pt ) ), 'standard_rules' => is_array( $payload['standard_rules'] ?? null ) ? $payload['standard_rules'] : array(), 'custom_rules' => is_array( $payload['custom_rules'] ?? null ) ? $payload['custom_rules'] : array(), 'file_options' => is_array( $payload['file_options'] ?? null ) ? $payload['file_options'] : array(), 'advanced' => is_array( $payload['advanced'] ?? null ) ? $payload['advanced'] : array(), 'export_exclusions' => self::sanitize_patterns( $payload['export_exclusions'] ?? array() ), ); } /** * Decode (if JSON) and recursively sanitize a request payload into an array. * * @param mixed $payload Raw payload: an array or a JSON-encoded string. * @return array Sanitized payload (empty when invalid). */ private static function safe_payload( $payload ): array { if ( is_string( $payload ) ) { $payload = json_decode( wp_unslash( $payload ), true ); } if ( ! is_array( $payload ) ) { return array(); } $clean = self::sanitize_payload_value( $payload ); return is_array( $clean ) ? $clean : array(); } /** * Recursively sanitize a request payload before it is used or stored (profiles, * recent configs, job state). Keys → sanitize_key(); booleans/numbers kept; * strings → sanitize_text_field(), EXCEPT find & replace rule values (`find` / * `replace`), which must stay byte-exact to match and write database content — * they are only ever used via $wpdb->prepare() and escaped on output. * * @param mixed $value Value to sanitize. * @param string $key Key the value is stored under (list items inherit it). * @param int $depth Recursion depth guard. * @return mixed */ private static function sanitize_payload_value( $value, string $key = '', int $depth = 0 ) { if ( is_array( $value ) ) { if ( $depth > 8 ) { return array(); } $out = array(); foreach ( $value as $k => $v ) { if ( is_int( $k ) ) { $out[ $k ] = self::sanitize_payload_value( $v, $key, $depth + 1 ); continue; } $clean_key = sanitize_key( (string) $k ); if ( '' !== $clean_key ) { $out[ $clean_key ] = self::sanitize_payload_value( $v, $clean_key, $depth + 1 ); } } return $out; } if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { return $value; } if ( is_string( $value ) ) { return in_array( $key, array( 'find', 'replace' ), true ) ? $value : sanitize_text_field( $value ); } return null; } /** * Sanitize export exclusion patterns into a unique list of non-empty strings. * * @param mixed $patterns Newline-separated string or array of patterns. * @return array */ private static function sanitize_patterns( $patterns ): array { $list = array(); if ( is_string( $patterns ) ) { $list = preg_split( '/\r\n|\r|\n/', $patterns ); } elseif ( is_array( $patterns ) ) { $list = $patterns; } $out = array(); foreach ( $list as $pattern ) { $pattern = trim( sanitize_text_field( (string) $pattern ) ); if ( '' === $pattern ) { continue; } $out[] = $pattern; } return array_values( array_unique( $out ) ); } /** * Equal-component band plan for an export. The work range [$start, $end] is * divided into ONE equal slice per selected component — the database counts as a * single slice, and each file section (media/themes/plugins/core) as a single * slice — so no single phase dominates the bar (a large database no longer * swallows most of the progress). The database slice is [db_start, db_end]; the * file sections share [files_start, files_end], which is exactly root_total slices * wide. Reads `root_total` + `include_database` from the checkpoint. * * @param array $cp Export checkpoint. * @param float $start Start of the work range (percent). * @param float $end End of the work range (percent). * @return array{units:int,unit:float,has_db:bool,db_start:float,db_end:float,files_start:float,files_end:float} */ private static function band_plan( array $cp, float $start, float $end ): array { $root_total = max( 0, (int) ( $cp['root_total'] ?? 0 ) ); $has_db = ! empty( $cp['include_database'] ); $units = max( 1, ( $has_db ? 1 : 0 ) + $root_total ); $unit = ( $end - $start ) / $units; $files_start = $start + ( $has_db ? $unit : 0.0 ); return array( 'units' => $units, 'unit' => $unit, 'has_db' => $has_db, 'db_start' => $start, 'db_end' => $has_db ? $start + $unit : $start, 'files_start' => $files_start, 'files_end' => $end, ); } /** * Build the initial per-section progress list for an operation. * * @param array $args Normalized export args (`file_options`). * @param bool $with_files Whether to include the enabled file sections. * @param bool $with_db Whether to include the database section. * @return array */ private static function export_sections( array $args, bool $with_files, bool $with_db = true ): array { $sections = array(); if ( $with_db ) { $sections[] = array( 'key' => 'database', 'label' => __( 'Database', 'spinx-quick-migrate' ), 'status' => 'pending', ); } if ( $with_files ) { $fo = is_array( $args['file_options'] ?? null ) ? $args['file_options'] : array(); $map = array( 'media' => __( 'Media Uploads', 'spinx-quick-migrate' ), 'themes' => __( 'Themes', 'spinx-quick-migrate' ), 'plugins' => __( 'Plugins', 'spinx-quick-migrate' ), 'mu-plugins' => __( 'Must-Use Plugins', 'spinx-quick-migrate' ), 'core' => __( 'Core', 'spinx-quick-migrate' ), ); foreach ( $map as $key => $label ) { if ( ! empty( $fo[ $key ]['enabled'] ) ) { $sections[] = array( 'key' => $key, 'label' => $label, 'status' => 'pending', ); } } } return $sections; } /** * Flip one section's status (pending|running|completed|error) in the job state. * * @param string $job_id Job ID. * @param string $key Section key (e.g. `database`, `media`). * @param string $status New status. * @return void */ private static function set_section_status( string $job_id, string $key, string $status ): void { $job = Helpers::get_job( $job_id ); $sections = is_array( $job['sections'] ?? null ) ? $job['sections'] : array(); foreach ( $sections as $i => $section ) { if ( ( $section['key'] ?? '' ) === $key ) { $sections[ $i ]['status'] = $status; } } Helpers::update_job( $job_id, array( 'sections' => $sections ) ); } /** * Drive per-module progress during the export "files" phase. The combined file * stream is ordered media → themes → plugins → core, so the section of the * most-recently-added file marks the boundary: everything before it is marked * completed and that section is marked running. (Sections after it stay pending.) * * @param string $job_id Export job ID. * @param array $sections Enabled file-section keys, in canonical order. * @param string $target Zip target path of the last added file. * @return void */ private static function advance_file_sections( string $job_id, array $sections, string $target ): void { if ( empty( $sections ) || '' === $target ) { return; } $map = array( 'uploads/' => 'media', 'themes/' => 'themes', 'mu-plugins/' => 'mu-plugins', 'plugins/' => 'plugins', 'wordpress-core/' => 'core', ); $current = ''; foreach ( $map as $prefix => $key ) { if ( 0 === strpos( $target, $prefix ) ) { $current = $key; break; } } if ( '' === $current ) { return; } $reached = false; foreach ( $sections as $sec ) { if ( $sec === $current ) { self::set_section_status( $job_id, $sec, 'running' ); $reached = true; } elseif ( ! $reached ) { self::set_section_status( $job_id, $sec, 'completed' ); } } } /** * Prepare a job to be driven by the browser step loop: store the payload and * mark it queued, but do NOT schedule cron. The front-end advances it via * POST /jobs/{id}/step (no inter-request delay) — the key speed win over the * old cron-tick model. * * @param string $job_id Job ID. * @param string $operation Operation type (`export`, `backup` or `find_replace`). * @param array $args Operation args stored in the job payload. * @return void */ private static function prepare_job( string $job_id, string $operation, array $args ): void { set_transient( 'spinxqm_job_payload_' . $job_id, array( 'op' => $operation, 'args' => $args, 'created_at' => time(), ), Helpers::job_ttl() ); Helpers::update_job( $job_id, array( 'status' => 'queued', 'progress' => 0, 'message' => __( 'Queued...', 'spinx-quick-migrate' ), ) ); } /** * Mark a job as failed with an error message and drop its stored payload. * * @param string $job_id Job ID. * @param string $message Error message shown to the user. * @return void */ private static function finish_job_error( string $job_id, string $message ): void { Helpers::update_job( $job_id, array( 'status' => 'error', 'progress' => 100, 'message' => $message, 'errors' => array( $message ), 'finished_at' => time(), ) ); delete_transient( 'spinxqm_job_payload_' . $job_id ); } /** * Mark a job as cancelled and drop its stored payload. * * @param string $job_id Job ID. * @return void */ private static function finish_job_cancelled( string $job_id ): void { Helpers::update_job( $job_id, array( 'status' => 'cancelled', 'progress' => 100, 'message' => __( 'Operation cancelled.', 'spinx-quick-migrate' ), 'finished_at' => time(), ) ); delete_transient( 'spinxqm_job_payload_' . $job_id ); } /** * Rules that actually run: a rule is only effective when BOTH its source * (find) and destination (replace) are non-empty. A blank either side * (e.g. a half-filled Site URL/Site Path preset) is silently skipped, never * treated as an error, and never overwrites data with a blank value. * * @param mixed $rules Raw rules array. * @return array> */ private static function effective_rules( $rules ): array { if ( ! is_array( $rules ) ) { return array(); } $out = array(); foreach ( $rules as $rule ) { if ( is_array( $rule ) && '' !== trim( (string) ( $rule['find'] ?? '' ) ) && '' !== trim( (string) ( $rule['replace'] ?? '' ) ) ) { $out[] = $rule; } } return $out; } /** * Validate a find/replace payload's standard presets. * * @param array $payload Sanitized find/replace payload. * @return true|WP_Error True when valid, otherwise the validation error. */ private static function validate_find_replace_payload( array $payload ) { // Site URL is mandatory (a complete, valid source/destination pair); Site Path is // optional. Effective rules already require both source and destination, so a blank // replacement can never reach the database — no confirmation needed. $preset = self::validate_standard_presets( $payload ); return is_wp_error( $preset ) ? $preset : true; } /** * Validate the two standard presets. Site URL (index 0) is MANDATORY: it must be a * complete, well-formed source/destination pair. Site Path (index 1) is OPTIONAL: a * blank or partially-filled Site Path is skipped (never replaced, never an error); it * only validates when both of its fields are filled. Returns which presets are active, * or WP_Error. * * @param array $payload Sanitized find/replace payload (`standard_rules`). * @return array{url:bool,path:bool}|WP_Error */ private static function validate_standard_presets( array $payload ) { $standard = is_array( $payload['standard_rules'] ?? null ) ? array_values( $payload['standard_rules'] ) : array(); $active = array( 'url' => false, 'path' => false, ); // Site URL — required. Both fields must be present and valid. $url_rule = isset( $standard[0] ) && is_array( $standard[0] ) ? $standard[0] : array(); $url_src = trim( (string) ( $url_rule['find'] ?? '' ) ); $url_dst = trim( (string) ( $url_rule['replace'] ?? '' ) ); if ( '' === $url_src || '' === $url_dst ) { return new WP_Error( 'spinxqm_fr_url_required', __( 'Site URL is required: enter both the current and new URL.', 'spinx-quick-migrate' ), array( 'status' => 400 ) ); } if ( ! self::is_valid_url_value( $url_src ) || ! self::is_valid_url_value( $url_dst ) ) { return new WP_Error( 'spinxqm_fr_url', __( 'Enter a valid Site URL (http:// or https://).', 'spinx-quick-migrate' ), array( 'status' => 400 ) ); } $active['url'] = true; // Site Path — optional. Applied only when BOTH fields are filled; otherwise skipped. $path_rule = isset( $standard[1] ) && is_array( $standard[1] ) ? $standard[1] : array(); $path_src = trim( (string) ( $path_rule['find'] ?? '' ) ); $path_dst = trim( (string) ( $path_rule['replace'] ?? '' ) ); if ( '' !== $path_src && '' !== $path_dst ) { if ( ! self::is_valid_path_value( $path_src ) || ! self::is_valid_path_value( $path_dst ) ) { return new WP_Error( 'spinxqm_fr_path', __( 'Enter a valid Site Path.', 'spinx-quick-migrate' ), array( 'status' => 400 ) ); } $active['path'] = true; } return $active; } /** * Lenient URL check for find/replace values: rejects whitespace and non-http(s) * protocols, accepts protocol-relative (`//host`) and bare hosts/paths. * * @param string $value URL value to check. * @return bool */ private static function is_valid_url_value( string $value ): bool { if ( '' === $value || preg_match( '/\s/', $value ) ) { return false; } $candidate = ( 0 === strpos( $value, '//' ) ) ? 'http:' . $value : $value; if ( preg_match( '#^([a-z][a-z0-9+.\-]*):#i', $candidate, $m ) ) { if ( ! in_array( strtolower( $m[1] ), array( 'http', 'https' ), true ) ) { return false; } $parts = wp_parse_url( $candidate ); return is_array( $parts ) && ! empty( $parts['host'] ); } return true; // No scheme: a bare host/path, already space-free. } /** * Path check: rejects null bytes, control chars, and characters illegal in paths. * Windows drive letters and back/forward slashes are allowed. * * @param string $value Path value to check. * @return bool */ private static function is_valid_path_value( string $value ): bool { if ( '' === $value ) { return false; } return ! preg_match( '/[\x00-\x1F<>"|?*]/', $value ); } /** * Shape a stored job record into the API response (status, progress, sizes, timing, result). * * @param array $job Stored job record. * @return array */ private static function format_job_response( array $job ): array { $status = strtolower( (string) ( $job['status'] ?? 'queued' ) ); if ( 'completed' === $status ) { $status = 'success'; } elseif ( 'failed' === $status ) { $status = 'error'; } $processed_size = (int) ( $job['processed_size'] ?? 0 ); $total_size = (int) ( $job['total_size'] ?? 0 ); $started_at = (int) ( $job['started_at'] ?? 0 ); $finished_at = (int) ( $job['finished_at'] ?? 0 ); $paused_at = (int) ( $job['paused_at'] ?? 0 ); $paused_total = (int) ( $job['paused_total'] ?? 0 ); $processed = (int) ( $job['processed'] ?? 0 ); $total = (int) ( $job['total'] ?? 0 ); // Elapsed = wall time minus all paused spans. Freezes at completion // (finished_at) and holds steady while currently paused (paused_at). $end_ts = $finished_at > 0 ? $finished_at : time(); $paused = $paused_total + ( $paused_at > 0 ? max( 0, $end_ts - $paused_at ) : 0 ); $elapsed = $started_at > 0 ? max( 0, $end_ts - $started_at - $paused ) : 0; $speed = $elapsed > 0 ? round( $processed / $elapsed, 2 ) : 0; $eta = ( $speed > 0 && $total > $processed ) ? (int) ceil( ( $total - $processed ) / $speed ) : 0; return array( 'id' => (string) ( $job['id'] ?? '' ), 'type' => (string) ( $job['type'] ?? '' ), 'status' => $status, 'paused' => ! empty( $job['paused'] ), 'percent' => (int) ( $job['progress'] ?? 0 ), 'progress' => (int) ( $job['progress'] ?? 0 ), 'message' => (string) ( $job['message'] ?? '' ), 'current_item' => (string) ( $job['current_item'] ?? '' ), 'processed' => $processed, 'total' => $total, 'processed_size' => $processed_size, 'total_size' => $total_size, 'sections' => is_array( $job['sections'] ?? null ) ? array_values( $job['sections'] ) : array(), 'elapsed' => $elapsed, 'speed' => $speed, 'eta' => $eta, 'errors' => is_array( $job['errors'] ?? null ) ? $job['errors'] : array(), 'result' => self::sign_result_urls( is_array( $job['result'] ?? null ) ? $job['result'] : array() ), 'created_at' => (int) ( $job['created_at'] ?? 0 ), ); } /** * Re-issue download URLs (protected endpoint + fresh nonce for the current * user) for a job result's file and pre-flight backup, so a URL stored with * the job never goes stale and storage is never linked to directly. * * @param array $result Job result. * @return array */ private static function sign_result_urls( array $result ): array { if ( ! empty( $result['file'] ) ) { $result['url'] = Downloads::url( (string) $result['file'] ); } if ( is_array( $result['backup'] ?? null ) && ! empty( $result['backup']['file'] ) ) { $result['backup']['url'] = Downloads::url( (string) $result['backup']['file'] ); } // Absolute server paths stay server-side (cleanup reads them from job state). unset( $result['file'] ); if ( is_array( $result['backup'] ?? null ) ) { unset( $result['backup']['file'] ); } return $result; } }