diff --git a/admin/admin-menu-and-tabs.php b/admin/admin-menu-and-tabs.php index da01c05..7554b40 100644 --- a/admin/admin-menu-and-tabs.php +++ b/admin/admin-menu-and-tabs.php @@ -161,7 +161,8 @@ public static function get_settings(): array { 'jwt_token_set_at' => 0, ], 'file' => [ - 'compression' => 'zip', + 'compression' => 'zip', + 'job_max_age_days' => defined( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) ? (int) constant( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) : 7, ], ]; diff --git a/admin/class-dt-migration-import-ajax.php b/admin/class-dt-migration-import-ajax.php index 928bb97..4e910ba 100644 --- a/admin/class-dt-migration-import-ajax.php +++ b/admin/class-dt-migration-import-ajax.php @@ -16,6 +16,10 @@ class Disciple_Tools_Migration_Import_Ajax { public function __construct() { add_action( 'wp_ajax_dt_migration_import_batch', [ $this, 'handle_import_batch' ] ); add_action( 'wp_ajax_dt_migration_preflight', [ $this, 'handle_preflight' ] ); + add_action( 'wp_ajax_dt_migration_file_job_delete', [ $this, 'handle_file_job_delete' ] ); + add_action( 'wp_ajax_dt_migration_file_job_complete', [ $this, 'handle_file_job_complete' ] ); + add_action( 'wp_ajax_dt_migration_file_job_failed', [ $this, 'handle_file_job_failed' ] ); + add_action( 'wp_ajax_dt_migration_file_job_cancelled', [ $this, 'handle_file_job_cancelled' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); } @@ -37,7 +41,7 @@ public function enqueue_scripts( string $hook ) : void { 'dt-migration-import', $plugin_url . 'admin/js/import.js', [ 'jquery' ], - '0.3.6', + '0.4.0', true ); $record_order = class_exists( 'Disciple_Tools_Migration_Import_Engine' ) @@ -47,9 +51,10 @@ public function enqueue_scripts( string $hook ) : void { 'dt-migration-import', 'dtMigrationImport', [ - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'dt_migration_import' ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'dt_migration_import' ), 'recordImportOrder' => array_values( $record_order ), + 'fileJobId' => '', 'strings' => [ 'continue' => __( 'Continue', 'disciple-tools-migration' ), 'confirm' => __( 'Confirm', 'disciple-tools-migration' ), @@ -66,6 +71,9 @@ public function enqueue_scripts( string $hook ) : void { 'preflightRunning' => __( 'Running preflight…', 'disciple-tools-migration' ), 'preflightFailed' => __( 'Preflight request failed.', 'disciple-tools-migration' ), 'runPreflight' => __( 'Run preflight', 'disciple-tools-migration' ), + 'deleteFileJobConfirm' => __( 'Delete this file migration job and its stored data?', 'disciple-tools-migration' ), + 'deleteFileJobFailed' => __( 'Could not delete the job.', 'disciple-tools-migration' ), + 'preflightFileJobMissing' => __( 'No file migration job is active. Use Upload & Preview or Retry from the job list first.', 'disciple-tools-migration' ), ], ] ); @@ -110,6 +118,11 @@ private function get_modal_css() : string { .dt-migration-error-details { margin-top: 16px; padding: 12px; background: #fcf0f1; border: 1px solid #d63638; border-radius: 4px; } .dt-migration-error-details strong { display: block; margin-bottom: 8px; color: #b32d2e; } .dt-migration-error-scroll { max-height: 200px; overflow-y: auto; padding: 8px; background: #fff; border: 1px solid #c3c4c7; border-radius: 4px; white-space: pre-wrap; font-size: 12px; line-height: 1.4; } + .dt-migration-past-jobs { margin-top: 8px; } + .dt-migration-past-jobs .dt-migration-job-pill { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; } + .dt-migration-past-jobs .dt-migration-job-pill--success { background: #00a32a; color: #fff; } + .dt-migration-past-jobs .dt-migration-job-pill--failed { background: #d63638; color: #fff; } + .dt-migration-past-jobs .dt-migration-job-pill--neutral { background: #dcdcde; color: #1d2327; } '; } @@ -263,6 +276,7 @@ public function handle_import_batch() : void { $code = wp_remote_retrieve_response_code( $records_res ); $rbody = json_decode( (string) wp_remote_retrieve_body( $records_res ), true ); $recs = $rbody['records'] ?? []; + $pum = $rbody['post_user_meta'] ?? []; $total = (int) ( $rbody['total'] ?? 0 ); $has_more = ! empty( $rbody['has_more'] ); @@ -275,6 +289,21 @@ public function handle_import_batch() : void { ] ); } + // Apply per-user private meta returned alongside this batch. + $batch_post_ids = []; + foreach ( $recs as $rec ) { + if ( isset( $rec['ID'] ) ) { + $batch_post_ids[] = (int) $rec['ID']; + } + } + $pum_result = Disciple_Tools_Migration_Import_Engine::import_post_user_meta_for_posts( + is_array( $pum ) ? $pum : [], + $batch_post_ids + ); + if ( ! empty( $pum_result['errors'] ) ) { + $batch_result['errors'] = array_merge( $batch_result['errors'] ?? [], $pum_result['errors'] ); + } + wp_send_json_success( [ 'done' => ! $has_more, 'phase' => 'records', @@ -292,7 +321,61 @@ public function handle_import_batch() : void { } /** - * Handles import batch requests for file mode (payload in transient). + * Resolves the file-mode job payload for the current user, or returns an error structure. + * Callers must verify the AJAX nonce (handle_import_batch, handle_preflight) before this runs. + * + * @param int $user_id Current user. + * @return array{ payload: array, job_id: string }|array{ error: string } + */ + private function resolve_file_job_payload( int $user_id ) { + $posted = filter_input( INPUT_POST, 'file_job_id' ); + $raw = ( is_string( $posted ) && $posted !== '' ) ? sanitize_text_field( wp_unslash( $posted ) ) : ''; + $job_id = Disciple_Tools_Migration_File_Job_Store::sanitize_job_id( $raw ); + if ( $job_id === '' ) { + return [ + 'error' => __( 'No file migration job was specified. Use Upload & Preview or Retry from the job list.', 'disciple-tools-migration' ), + ]; + } + $payload = Disciple_Tools_Migration_File_Job_Store::get_payload( $user_id, $job_id ); + if ( $payload === null || ! Disciple_Tools_Migration_File_Job_Store::is_valid_migration_payload( $payload ) ) { + return [ + 'error' => __( 'That migration file is not available. Upload the JSON again or use Retry on a job that still has a stored file.', 'disciple-tools-migration' ), + ]; + } + return [ 'payload' => $payload, 'job_id' => $job_id ]; + } + + /** + * Marks a job as running on the first import step when appropriate. + * + * @param int $user_id + * @param string $job_id + * @param string $step + * @param int $offset + * @param bool $init_records + * @return void + */ + private function maybe_mark_file_job_running( int $user_id, string $job_id, string $step, int $offset, bool $init_records ) : void { + $meta = Disciple_Tools_Migration_File_Job_Store::get_job_meta( $user_id, $job_id ); + if ( $meta === null ) { + return; + } + $st = (string) ( $meta['status'] ?? '' ); + if ( $st === Disciple_Tools_Migration_File_Job_Store::STATUS_RUNNING ) { + return; + } + if ( ! in_array( $st, [ Disciple_Tools_Migration_File_Job_Store::STATUS_READY, Disciple_Tools_Migration_File_Job_Store::STATUS_FAILED, Disciple_Tools_Migration_File_Job_Store::STATUS_CANCELLED ], true ) ) { + return; + } + $is_first = ( $step === 'settings' ) || ( $step === 'records' && $offset === 0 && $init_records ); + if ( ! $is_first ) { + return; + } + Disciple_Tools_Migration_File_Job_Store::set_status( $user_id, $job_id, Disciple_Tools_Migration_File_Job_Store::STATUS_RUNNING ); + } + + /** + * Handles import batch requests for file mode (payload stored in options per job). * * @param string $step 'settings' or 'records' * @param array $settings Migration settings. @@ -301,15 +384,25 @@ private function handle_file_mode_batch( string $step, array $settings ) : void // Nonce verified in handle_import_batch() via check_ajax_referer( 'dt_migration_import', 'nonce' ). // phpcs:disable WordPress.Security.NonceVerification.Missing - $transient_key = 'dt_migration_file_payload_' . get_current_user_id(); - $payload = get_transient( $transient_key ); + $user_id = get_current_user_id(); + $resolved = $this->resolve_file_job_payload( $user_id ); + if ( isset( $resolved['error'] ) ) { + wp_send_json_error( [ 'message' => $resolved['error'] ] ); + } + $payload = $resolved['payload']; + $job_id = $resolved['job_id']; + + $post_type = isset( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : ''; + $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0; + $init_q = ! empty( $_POST['init_records_import'] ); + $this->maybe_mark_file_job_running( $user_id, $job_id, $step, 'records' === $step ? $offset : 0, 'records' === $step && $init_q ); $export_block = ( is_array( $payload ) && isset( $payload['export'] ) && is_array( $payload['export'] ) ) ? $payload['export'] : []; $has_dt = ! empty( $export_block['dt_settings'] ); $has_users = array_key_exists( 'system_users', $export_block ) && is_array( $export_block['system_users'] ); if ( ! is_array( $payload ) || ( ! $has_dt && ! $has_users ) ) { wp_send_json_error( [ - 'message' => __( 'No migration file loaded or payload expired. Please upload the file again.', 'disciple-tools-migration' ), + 'message' => __( 'That migration file is not available. Upload the JSON again or use Retry on a job that still has a stored file.', 'disciple-tools-migration' ), ] ); } @@ -338,10 +431,7 @@ private function handle_file_mode_batch( string $step, array $settings ) : void } if ( $step === 'records' ) { - $post_type = isset( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : ''; - $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0; - $limit = 50; - $init_q = ! empty( $_POST['init_records_import'] ); + $limit = 50; if ( empty( $post_type ) ) { wp_send_json_error( [ 'message' => __( 'Post type required.', 'disciple-tools-migration' ) ] ); @@ -380,6 +470,22 @@ private function handle_file_mode_batch( string $step, array $settings ) : void ] ); } + // Apply per-user private meta (dt_post_user_meta) for the post IDs in this slice. + $slice_post_ids = []; + foreach ( $slice as $rec ) { + if ( isset( $rec['ID'] ) ) { + $slice_post_ids[] = (int) $rec['ID']; + } + } + $pum_rows = $payload['post_user_meta'][ $post_type ] ?? []; + $pum_result = Disciple_Tools_Migration_Import_Engine::import_post_user_meta_for_posts( + is_array( $pum_rows ) ? $pum_rows : [], + $slice_post_ids + ); + if ( ! empty( $pum_result['errors'] ) ) { + $batch_result['errors'] = array_merge( $batch_result['errors'] ?? [], $pum_result['errors'] ); + } + wp_send_json_success( [ 'done' => ! $has_more, 'phase' => 'records', @@ -451,14 +557,18 @@ public function handle_preflight() : void { * @return array|array{ error: string } */ private function preflight_file_payload( array $settings_map, array $records_selected_in ) : array { - $transient_key = 'dt_migration_file_payload_' . get_current_user_id(); - $payload = get_transient( $transient_key ); + $user_id = get_current_user_id(); + $resolved = $this->resolve_file_job_payload( $user_id ); + if ( isset( $resolved['error'] ) ) { + return [ 'error' => $resolved['error'] ]; + } + $payload = $resolved['payload']; $export_block = ( is_array( $payload ) && isset( $payload['export'] ) && is_array( $payload['export'] ) ) ? $payload['export'] : []; $has_dt = ! empty( $export_block['dt_settings'] ); $has_users = array_key_exists( 'system_users', $export_block ) && is_array( $export_block['system_users'] ); if ( ! is_array( $payload ) || ( ! $has_dt && ! $has_users ) ) { - return [ 'error' => __( 'No migration file loaded or payload expired. Please upload the file again.', 'disciple-tools-migration' ) ]; + return [ 'error' => __( 'That migration file is not available. Upload the JSON again or use Retry on a job that still has a stored file.', 'disciple-tools-migration' ) ]; } $records_all = isset( $payload['records'] ) && is_array( $payload['records'] ) ? $payload['records'] : []; @@ -593,4 +703,96 @@ private function preflight_api_payload( array $settings, array $settings_map, ar 'info' => $info_out, ]; } + + /** + * AJAX: remove a file migration job and its stored payload. + * + * @return void + */ + public function handle_file_job_delete() : void { + check_ajax_referer( 'dt_migration_import', 'nonce' ); + + if ( ! current_user_can( 'manage_dt' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'disciple-tools-migration' ) ] ); + } + + $posted = filter_input( INPUT_POST, 'job_id' ); + $raw = ( is_string( $posted ) && $posted !== '' ) ? sanitize_text_field( wp_unslash( $posted ) ) : ''; + $job_id = Disciple_Tools_Migration_File_Job_Store::sanitize_job_id( $raw ); + if ( $job_id === '' ) { + wp_send_json_error( [ 'message' => __( 'Invalid job.', 'disciple-tools-migration' ) ] ); + } + + Disciple_Tools_Migration_File_Job_Store::delete_job( get_current_user_id(), $job_id ); + wp_send_json_success( [ 'deleted' => true ] ); + } + + /** + * AJAX: mark a file job successful and clear stored JSON. + * + * @return void + */ + public function handle_file_job_complete() : void { + check_ajax_referer( 'dt_migration_import', 'nonce' ); + + if ( ! current_user_can( 'manage_dt' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'disciple-tools-migration' ) ] ); + } + + $posted = filter_input( INPUT_POST, 'file_job_id' ); + $raw = ( is_string( $posted ) && $posted !== '' ) ? sanitize_text_field( wp_unslash( $posted ) ) : ''; + $job_id = Disciple_Tools_Migration_File_Job_Store::sanitize_job_id( $raw ); + if ( $job_id === '' ) { + wp_send_json_error( [ 'message' => __( 'Invalid job.', 'disciple-tools-migration' ) ] ); + } + + Disciple_Tools_Migration_File_Job_Store::mark_success_and_clear_payload( get_current_user_id(), $job_id ); + wp_send_json_success( [ 'ok' => true ] ); + } + + /** + * AJAX: mark a file job as failed (payload kept for retry). + * + * @return void + */ + public function handle_file_job_failed() : void { + check_ajax_referer( 'dt_migration_import', 'nonce' ); + + if ( ! current_user_can( 'manage_dt' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'disciple-tools-migration' ) ] ); + } + + $posted = filter_input( INPUT_POST, 'file_job_id' ); + $raw = ( is_string( $posted ) && $posted !== '' ) ? sanitize_text_field( wp_unslash( $posted ) ) : ''; + $job_id = Disciple_Tools_Migration_File_Job_Store::sanitize_job_id( $raw ); + if ( $job_id === '' ) { + wp_send_json_error( [ 'message' => __( 'Invalid job.', 'disciple-tools-migration' ) ] ); + } + + Disciple_Tools_Migration_File_Job_Store::set_status( get_current_user_id(), $job_id, Disciple_Tools_Migration_File_Job_Store::STATUS_FAILED ); + wp_send_json_success( [ 'ok' => true ] ); + } + + /** + * AJAX: mark a file job as user-cancelled. + * + * @return void + */ + public function handle_file_job_cancelled() : void { + check_ajax_referer( 'dt_migration_import', 'nonce' ); + + if ( ! current_user_can( 'manage_dt' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'disciple-tools-migration' ) ] ); + } + + $posted = filter_input( INPUT_POST, 'file_job_id' ); + $raw = ( is_string( $posted ) && $posted !== '' ) ? sanitize_text_field( wp_unslash( $posted ) ) : ''; + $job_id = Disciple_Tools_Migration_File_Job_Store::sanitize_job_id( $raw ); + if ( $job_id === '' ) { + wp_send_json_error( [ 'message' => __( 'Invalid job.', 'disciple-tools-migration' ) ] ); + } + + Disciple_Tools_Migration_File_Job_Store::set_status( get_current_user_id(), $job_id, Disciple_Tools_Migration_File_Job_Store::STATUS_CANCELLED ); + wp_send_json_success( [ 'ok' => true ] ); + } } diff --git a/admin/class-dt-migration-tab-import.php b/admin/class-dt-migration-tab-import.php index 3e23f47..edd6505 100644 --- a/admin/class-dt-migration-tab-import.php +++ b/admin/class-dt-migration-tab-import.php @@ -56,12 +56,22 @@ class Disciple_Tools_Migration_Tab_Import { */ private $import_preview_channel = null; + /** + * Active file-mode job id (JSON stored in options) for the current preview, if any. + * + * @var string + */ + private $active_file_job_id = ''; + public function content() { $settings = Disciple_Tools_Migration_Menu::get_settings(); // Process any submitted connection test form before rendering. $this->process_form_fields( $settings ); $settings = Disciple_Tools_Migration_Menu::get_settings(); + if ( ! empty( $settings['enabled'] ) ) { + $this->try_load_file_job_from_query( $settings ); + } ?>
@@ -368,11 +378,12 @@ class="dt-migration-record-checkbox"

-
+

+ render_past_file_jobs_table( $settings ); ?> connection_error ) ) : ?>

connection_error ); ?>

@@ -489,7 +500,7 @@ class="dt-migration-record-checkbox"

- settings_preview ) ) : ?> + settings_preview ) && ( $this->import_preview_channel === 'api' || $this->import_preview_channel === 'file' ) ) : ?> render_import_modal_and_progress(); ?> @@ -919,9 +930,58 @@ private function process_file_upload( array $settings ) : void { return; } - $transient_key = 'dt_migration_file_payload_' . get_current_user_id(); - set_transient( $transient_key, $payload, 15 * MINUTE_IN_SECONDS ); + if ( ! class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) { + $this->connection_error = esc_html__( 'Migration file jobs are not available. Please reload the page.', 'disciple-tools-migration' ); + return; + } + + $raw_name = isset( $_FILES['dt_migration_import_file']['name'] ) ? (string) wp_unslash( $_FILES['dt_migration_import_file']['name'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Client-provided upload name; sanitized with sanitize_file_name() on next line. Nonce verified above. + $filename = ( $raw_name !== '' ) ? sanitize_file_name( $raw_name ) : 'export.json'; + $job_id = Disciple_Tools_Migration_File_Job_Store::create_job( get_current_user_id(), $payload, $filename ); + if ( $job_id === '' ) { + $this->connection_error = esc_html__( 'Could not store the migration file for import. The server may be out of space or the export may be too large for your database settings.', 'disciple-tools-migration' ); + return; + } + $this->active_file_job_id = $job_id; + $this->build_file_preview_state_from_payload( $payload ); + if ( class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) { + Disciple_Tools_Migration_File_Job_Store::prune_expired_jobs(); + } + } + + /** + * Fills preview state from a GET ?file_job= request (retry a past job). + * + * @param array $settings + * @return void + */ + private function try_load_file_job_from_query( array $settings ) : void { + if ( ! class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) || $this->settings_preview !== null ) { + return; + } + if ( ! isset( $_GET['file_job'] ) ) { + return; + } + $raw = sanitize_text_field( wp_unslash( $_GET['file_job'] ) ); + $id = Disciple_Tools_Migration_File_Job_Store::sanitize_job_id( $raw ); + if ( $id === '' ) { + return; + } + $user_id = get_current_user_id(); + $payload = Disciple_Tools_Migration_File_Job_Store::get_payload( $user_id, $id ); + if ( $payload === null || ! Disciple_Tools_Migration_File_Job_Store::is_valid_migration_payload( $payload ) ) { + $this->connection_error = esc_html__( 'That migration file job was not found, completed without a retriable copy, or you do not have access. Upload the JSON again.', 'disciple-tools-migration' ); + return; + } + $this->active_file_job_id = $id; + $this->build_file_preview_state_from_payload( $payload ); + } + /** + * @param array $payload Full export payload. + * @return void + */ + private function build_file_preview_state_from_payload( array $payload ) : void { $dt_settings = $payload['export']['dt_settings'] ?? []; $post_types = $dt_settings['dt_post_types_settings']['values'] ?? []; $tiles_all = $dt_settings['dt_tiles_settings']['values'] ?? []; @@ -947,4 +1007,111 @@ private function process_file_upload( array $settings ) : void { $this->import_preview_channel = 'file'; } + + /** + * Renders a table of past file import jobs for the current user. + * + * @param array $settings + * @return void + */ + private function render_past_file_jobs_table( array $settings ) : void { + if ( ! class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) || empty( $settings['enabled'] ) ) { + return; + } + $user_id = get_current_user_id(); + $rows = Disciple_Tools_Migration_File_Job_Store::list_jobs_for_user( $user_id ); + $page_token = Disciple_Tools_Migration_Menu::instance()->token; + $url_base = add_query_arg( + [ + 'page' => $page_token, + 'tab' => 'import', + ], + admin_url( 'admin.php' ) + ); + ?> +

+

+ +

+ + + + + + + + + + + + + + + + + 0 ? date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $ts ) : '—'; + $size_str = $bytes > 0 + /* translators: %s: file size e.g. 2 MB */ + ? size_format( $bytes, 1 ) + : '—'; + $has_payload = $job_id !== '' && Disciple_Tools_Migration_File_Job_Store::job_has_stored_payload( $job_id ); + $can_retry = $has_payload && in_array( $status, [ Disciple_Tools_Migration_File_Job_Store::STATUS_READY, Disciple_Tools_Migration_File_Job_Store::STATUS_FAILED, Disciple_Tools_Migration_File_Job_Store::STATUS_CANCELLED, Disciple_Tools_Migration_File_Job_Store::STATUS_RUNNING ], true ); + $retry_url = add_query_arg( 'file_job', $job_id, $url_base ); + $pill_class = 'dt-migration-job-pill dt-migration-job-pill--neutral'; + if ( $status === Disciple_Tools_Migration_File_Job_Store::STATUS_SUCCESS ) { + $pill_class = 'dt-migration-job-pill dt-migration-job-pill--success'; + } elseif ( in_array( $status, [ Disciple_Tools_Migration_File_Job_Store::STATUS_FAILED, Disciple_Tools_Migration_File_Job_Store::STATUS_CANCELLED ], true ) ) { + $pill_class = 'dt-migration-job-pill dt-migration-job-pill--failed'; + } + $status_label = $status; + switch ( $status ) { + case Disciple_Tools_Migration_File_Job_Store::STATUS_SUCCESS: + $status_label = __( 'Success', 'disciple-tools-migration' ); + break; + case Disciple_Tools_Migration_File_Job_Store::STATUS_FAILED: + $status_label = __( 'Failed', 'disciple-tools-migration' ); + break; + case Disciple_Tools_Migration_File_Job_Store::STATUS_CANCELLED: + $status_label = __( 'Cancelled', 'disciple-tools-migration' ); + break; + case Disciple_Tools_Migration_File_Job_Store::STATUS_RUNNING: + $status_label = __( 'In progress', 'disciple-tools-migration' ); + break; + case Disciple_Tools_Migration_File_Job_Store::STATUS_READY: + default: + $status_label = __( 'Ready', 'disciple-tools-migration' ); + break; + } + ?> + + + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + +

+ +

+ @@ -187,7 +214,25 @@ public function process_form_fields(): void { $settings['allowed_items']['records'][ $post_type ] = ! empty( $incoming_records[ $post_type ] ); } + $days = isset( $post_vars['dt_migration_file_job_max_age_days'] ) ? (int) $post_vars['dt_migration_file_job_max_age_days'] : 7; + if ( $days < 1 ) { + $days = 1; + } + if ( $days > 365 ) { + $days = 365; + } + if ( ! isset( $settings['file'] ) || ! is_array( $settings['file'] ) ) { + $settings['file'] = [ + 'compression' => 'zip', + ]; + } + $settings['file']['job_max_age_days'] = $days; + Disciple_Tools_Migration_Menu::update_settings( $settings ); + + if ( class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) { + Disciple_Tools_Migration_File_Job_Store::prune_expired_jobs(); + } } /** @@ -224,4 +269,4 @@ public function right_column( array $settings ) { = phases.length ) { + notifyFileJobCompleteIfNeeded(); setImportSpinner( false ); setProgress( 100 ); $currentPhase.text( @@ -425,6 +486,7 @@ markStepActive( currentPhaseIndex ); runPhase( phase ).then( function( result ) { if ( result && result.cancelled ) { + notifyFileJobCancelledIfNeeded(); setImportSpinner( false ); $currentPhase.text( 'Import cancelled.' ); $cancelImport.hide(); @@ -435,6 +497,7 @@ currentPhaseIndex++; startNextPhase(); } ).catch( function( err ) { + notifyFileJobFailedIfNeeded(); setImportSpinner( false ); $currentPhase.text( 'Import failed.' ); showError( err ); @@ -480,6 +543,36 @@ $pfProceed = $( '.dt-migration-preflight-proceed' ); $pfClose = $( '.dt-migration-preflight-close' ); + // Recent-jobs Delete works on any page with the jobs table, even when no + // upload preview is visible. Register before the modal guard below. + $( document ).on( 'click', '.dt-migration-file-job-delete', function( e ) { + e.preventDefault(); + const $btn = $( this ); + const id = $btn.data( 'job-id' ); + if ( ! id || ! window.confirm( t( 'deleteFileJobConfirm', 'Delete this file migration job and its stored data?' ) ) ) { + return; + } + if ( typeof dtMigrationImport === 'undefined' ) { + return; + } + $btn.prop( 'disabled', true ); + $.post( dtMigrationImport.ajaxUrl, { + action: 'dt_migration_file_job_delete', + nonce: dtMigrationImport.nonce, + job_id: id + } ).done( function( r ) { + if ( r.success ) { + window.location.reload(); + return; + } + window.alert( ( r.data && r.data.message ) ? r.data.message : t( 'deleteFileJobFailed', 'Could not delete the job.' ) ); + $btn.prop( 'disabled', false ); + } ).fail( function() { + window.alert( t( 'deleteFileJobFailed', 'Could not delete the job.' ) ); + $btn.prop( 'disabled', false ); + } ); + } ); + if ( ! $modal.length || ! $( '.dt-migration-start-import' ).length ) { return; } @@ -543,6 +636,7 @@ const checked = $( this ).prop( 'checked' ); $section.find( '.dt-migration-record-checkbox' ).prop( 'checked', checked ); } ); + } $( document ).ready( init ); diff --git a/disciple-tools-migration.php b/disciple-tools-migration.php index 3ff3bfa..a4955e1 100755 --- a/disciple-tools-migration.php +++ b/disciple-tools-migration.php @@ -21,6 +21,14 @@ exit; // Exit if accessed directly } +/** + * Default max age (days) for stored file migration jobs when option is unset. + * Overridden by Settings > File import jobs. + */ +if ( ! defined( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) ) { + define( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS', 7 ); +} + /** * Gets the instance of the Disciple_Tools_Migration_Plugin class. * @@ -105,6 +113,7 @@ private function __construct() { require_once 'includes/class-dt-migration-import-engine.php'; require_once 'includes/class-dt-migration-preflight.php'; } + require_once 'includes/class-dt-migration-file-job-store.php'; if ( is_admin() ) { require_once 'includes/class-dt-migration-export-file.php'; @@ -117,6 +126,9 @@ private function __construct() { new Disciple_Tools_Migration_Import_Ajax(); } + add_action( 'init', [ $this, 'maybe_schedule_file_job_prune' ] ); + add_action( 'dt_migration_prune_file_jobs', [ $this, 'run_file_job_prune' ] ); + $this->i18n(); if ( is_admin() ) { @@ -151,6 +163,32 @@ public static function activation() { */ public static function deactivation() { delete_option( 'dismissed-disciple-tools-migration' ); + $ts = wp_next_scheduled( 'dt_migration_prune_file_jobs' ); + if ( $ts ) { + wp_unschedule_event( $ts, 'dt_migration_prune_file_jobs' ); + } + } + + /** + * Schedules daily prune of expired file migration jobs. + * + * @return void + */ + public function maybe_schedule_file_job_prune() { + if ( ! wp_next_scheduled( 'dt_migration_prune_file_jobs' ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'dt_migration_prune_file_jobs' ); + } + } + + /** + * Cron: removes file job records older than configured retention. + * + * @return void + */ + public function run_file_job_prune() { + if ( class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) { + Disciple_Tools_Migration_File_Job_Store::prune_expired_jobs(); + } } /** diff --git a/documentation/README.md b/documentation/README.md index 214f472..dc8fa77 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -28,7 +28,7 @@ This folder contains user and reference documentation for moving Disciple.Tools 1. Install and activate the plugin on **source** and **destination** Disciple.Tools sites (see [Overview](user-guide/overview.md)). 2. On the **source** site, open **Extensions (D.T)** → **Migration** → **Settings**: enable migration and select what to export (including record types). 3. On the **source** site, open **Export** and download the JSON package. -4. Move the file to a secure location and upload it on the **destination** site under **Import**. +4. Move the file to a secure location and upload it on the **destination** site under **Import** (**Upload & Preview**). The destination creates a **file migration job** (stored in the database) so long runs can complete; you can track status in **Recent file migration jobs** and adjust retention under **Settings** → **File import jobs** (see [Settings and scope](user-guide/settings-and-scope.md)). 5. Optionally run **preflight**, review warnings, then start the import. ### B. API-based (direct site-to-site) @@ -41,4 +41,11 @@ Both channels respect the same scope rules and the same import semantics on the ## Screenshots -Illustrations live next to the guides that reference them, under each section’s `imgs/` directory. Filenames are placeholders until real screenshots are added. +Illustrations live next to the guides that reference them, under each section’s `imgs/` directory. + +| File (under `user-guide/imgs/`) | Used in | +|--------------------------------|---------| +| `fig-10-settings-file-jobs.png` | [Settings and scope](user-guide/settings-and-scope.md) — **File import jobs** retention | +| `fig-11-import-recent-file-jobs.png` | [Migration via file](user-guide/migration-via-file.md) — **Recent file migration jobs** table | + +Add or replace these PNGs locally if the links in the Markdown are missing or broken in your preview. diff --git a/documentation/reference/data-and-security.md b/documentation/reference/data-and-security.md index ad9c7f5..704d051 100644 --- a/documentation/reference/data-and-security.md +++ b/documentation/reference/data-and-security.md @@ -25,6 +25,10 @@ API imports use a **bearer token** stored on the destination after a successful JSON export files contain **PII and ministry data**. Encrypt at rest and limit distribution to trusted operators. +### File import jobs on the destination + +When you **Upload & Preview** on the destination, the plugin stores a copy of the decoded export in the **WordPress database** (per user, as a **job** with metadata) so chunked preflight and import can run without a short time limit. After a **successful** import, the large payload is typically **removed** while a small **success** record may remain in the job list until automatic or manual cleanup. Operators with access to the Migration screens can **delete** jobs from the list; retention in days is configurable under **Settings** → **File import jobs** (see [Settings and scope](../user-guide/settings-and-scope.md)). Treat database backups as including any not-yet-pruned job payloads. + ## Capabilities Migration admin UI and REST permission checks rely on **`manage_dt`**. Some user operations may require **`promote_users`** or other WordPress caps when creating or elevating accounts. diff --git a/documentation/user-guide/imgs/fig-10-settings-file-jobs.png b/documentation/user-guide/imgs/fig-10-settings-file-jobs.png new file mode 100644 index 0000000..d76eed6 Binary files /dev/null and b/documentation/user-guide/imgs/fig-10-settings-file-jobs.png differ diff --git a/documentation/user-guide/imgs/fig-11-import-recent-file-jobs.png b/documentation/user-guide/imgs/fig-11-import-recent-file-jobs.png new file mode 100644 index 0000000..892de44 Binary files /dev/null and b/documentation/user-guide/imgs/fig-11-import-recent-file-jobs.png differ diff --git a/documentation/user-guide/migration-via-file.md b/documentation/user-guide/migration-via-file.md index d638990..5a59384 100644 --- a/documentation/user-guide/migration-via-file.md +++ b/documentation/user-guide/migration-via-file.md @@ -27,14 +27,34 @@ Use your organization’s secure channel (encrypted storage, controlled access). ## On the destination site 1. Go to **Extensions (D.T)** → **Migration** → **Import**. -2. In the **file** section, upload the JSON export. -3. Use **preflight** if you want warnings about collisions or field mismatches (see [Preflight and warnings](preflight-and-warnings.md)). +2. In the **Upload & preview (JSON file)** section, choose the JSON export and click **Upload & Preview**. The site stores the file as a **file migration job** in the database (not a short browser session) so you can run **preflight** and **import** over a long time without losing the payload mid-run. +3. Use **preflight** if you want warnings about collisions or field mismatches (see [Preflight and warnings](preflight-and-warnings.md)). Preflight applies to the **active** job (the one you just uploaded or opened via **Retry** — see below). 4. Choose what to apply (settings categories and record types) consistent with the export, then start the import. The UI runs imports in **stages** (settings, then records in **dependency-aware order** — for example types such as people groups and groups before contacts and trainings, then other enabled types). - + ![Import tab: JSON file upload and actions](imgs/fig-06-import-file.png) +### Recent file migration jobs + +Under **Upload & preview (JSON file)**, the **Recent file migration jobs** table lists past uploads for your user account. + +| Column | Meaning | +|--------|---------| +| **Date** | When the job was created (upload or equivalent). | +| **File** | Original filename from the upload. | +| **Status** | Shown as a pill: **Success** (green) when a run finished; **Failed** or **Cancelled** (red) when a run errored or you stopped it; **Ready** or **In progress** (neutral) when not finished or not started. | +| **Size** | Approximate stored size while the JSON is kept; may show a dash after **Success** because the large payload is cleared to save space. | +| **Actions** | **Retry** loads that job’s data back into the preview (if the file is still stored). **Delete** removes the job and any stored data for it. | + +Completed jobs with **Success** often **cannot** be retried from this list, because the import clears the stored JSON on success. Upload the file again to run another import. Failed or cancelled jobs **can** be retried while the payload still exists. + +Automatic removal of old jobs uses the **day limit** on **Settings** → **File import jobs** (see [Settings and scope](settings-and-scope.md)). + + + +![Import tab: recent file migration jobs (table, status, actions)](imgs/fig-11-import-recent-file-jobs.png) + ## After import Verify records, users, and configuration in Disciple.Tools. If something failed mid-run, check the messages in the import UI and use [Troubleshooting](troubleshooting.md). diff --git a/documentation/user-guide/overview.md b/documentation/user-guide/overview.md index a9d5bfb..713facb 100644 --- a/documentation/user-guide/overview.md +++ b/documentation/user-guide/overview.md @@ -20,7 +20,7 @@ Imports on the destination are designed to **replace** existing data for the sel ## Where to find Migration in WordPress -Open the WordPress admin, then go to **Extensions (D.T)** → **Migration**. The screen has three tabs: **Settings**, **Export**, and **Import**. +Open the WordPress admin, then go to **Extensions (D.T)** → **Migration**. The screen has three tabs: **Settings**, **Export**, and **Import**. On **Settings**, you can set how long **file import jobs** stay in the database after a JSON upload; on **Import**, the **Recent file migration jobs** table shows status and optional **Retry** / **Delete** actions (see [Migration via file](migration-via-file.md)). diff --git a/documentation/user-guide/preflight-and-warnings.md b/documentation/user-guide/preflight-and-warnings.md index c485adb..5650bbe 100644 --- a/documentation/user-guide/preflight-and-warnings.md +++ b/documentation/user-guide/preflight-and-warnings.md @@ -12,6 +12,8 @@ Use preflight when: You can still start an import without preflight; the UI offers both flows. +For **file** imports, preflight uses the same **file migration job** as the import (the payload from **Upload & Preview** or **Retry** on [Migration via file](migration-via-file.md)). If the UI says no job is active, upload again or use **Retry** on a job that still has stored data. + ![Preflight warnings and informational lines](imgs/fig-08-preflight.png) diff --git a/documentation/user-guide/settings-and-scope.md b/documentation/user-guide/settings-and-scope.md index 39b59f4..a56b467 100644 --- a/documentation/user-guide/settings-and-scope.md +++ b/documentation/user-guide/settings-and-scope.md @@ -36,6 +36,18 @@ For each selected type, importing on a destination **removes existing records of Save changes with **Save Settings**. +## File import jobs (retention) + +On the same **Settings** tab, the **File import jobs** section controls how long **completed file migration jobs** remain in the database before the site removes them automatically. Each time you use **Upload & Preview** on the **Import** tab, the destination stores the JSON as a **job** (see [Migration via file](migration-via-file.md)) so long imports are not cut off by a short session window. + +- **Remove file migration jobs after (days):** enter a value between **1** and **365** (default is **7**). Jobs older than this are pruned on a schedule and when you save these settings. Lower the number if you need to free space sooner; raise it if you want the **Recent file migration jobs** list on the Import tab to show history for longer. + +Saving **Settings** also runs a one-time cleanup pass against jobs past this age. + + + +![Settings: file import job retention (days)](imgs/fig-10-settings-file-jobs.png) + ## API connection storage (destination) The plugin stores the **source site base URL** and a **JWT** obtained after a successful connection test (see [Migration via API](migration-via-api.md)). **Username and password** used to fetch the token are **not** stored. JWT and URL are kept in the migration settings option for subsequent API import batches. @@ -47,4 +59,5 @@ The plugin stores the **source site base URL** and a **JWT** obtained after a su ## See also - [Export tab](migration-via-file.md) — how the download reflects these choices +- [Migration via file](migration-via-file.md) — **Recent file migration jobs**, **Retry**, and how retention relates to the day limit above - [REST API capabilities](../reference/rest-api.md) — how the source reports `allowed_items` diff --git a/documentation/user-guide/troubleshooting.md b/documentation/user-guide/troubleshooting.md index 36ce40d..3df9c0e 100644 --- a/documentation/user-guide/troubleshooting.md +++ b/documentation/user-guide/troubleshooting.md @@ -53,7 +53,27 @@ Re-run **Test Connection** after fixing Server A. Clear old tokens by saving a f **Symptom:** Download or API batch stalls. -**Fix:** Increase PHP / web server timeouts where appropriate; for API imports, batches use pagination — retry from the UI. For very large file uploads, check `upload_max_filesize` / `post_max_size`. +**Fix:** Increase PHP / web server timeouts where appropriate; for API imports, batches use pagination — retry from the UI. For very large file uploads, check `upload_max_filesize` / `post_max_size` and the database’s ability to store large options (or raise `max_allowed_packet` on MySQL if the host requires it for multi‑MB JSON). + +## File import: no job or job not found + +**Symptom:** Message that the **migration file job** is missing, not found, or no longer has a retriable copy; preflight or import cannot start. + +**Common causes:** + +- The JSON was **never uploaded** in this session, or the page was opened without using **Upload & Preview** or **Retry** for a job that still has data. +- The job **completed successfully** and the site **cleared the stored file** to save space (see [Migration via file](migration-via-file.md) — re-upload the export). +- The job was **deleted** from **Recent file migration jobs** or **aged out** by the **day limit** on **Settings** → **File import jobs**. + +**Fix:** Upload the export again, or use **Retry** on a job that still lists retriable data. Adjust the retention day limit if you need jobs to last longer in the list. + +## Import progress appears stuck (file import) + +**Symptom:** Progress bar or counts stop updating during a file-based import. + +**Fix:** Check browser **Network** for failed `admin-ajax.php` requests and read the JSON **response** for the error text. The UI surfaces the same message in the import panel. A **failed** or **cancelled** run updates the **Recent file migration jobs** status so you can **Retry** when the file is still stored. If the import **succeeded** but the list still looks odd, refresh the page — **Success** clears the stored JSON; **size** may show as a dash. + +For **nonce** or permission errors, reload the admin page and start again from **Upload & Preview** or **Retry** (not only a hard refresh of a stale tab). ## Theme version notice @@ -61,11 +81,11 @@ Re-run **Test Connection** after fixing Server A. Clear old tokens by saving a f **Fix:** Upgrade the **Disciple.Tools theme** to the version required by the plugin (see main plugin file / readme). -## Import progress appears stuck +## Import progress appears stuck (general) **Symptom:** Progress bar or counts stop updating. -**Fix:** Check browser console and server error logs; session or nonce expiry can interrupt long jobs — refresh and consult whether partial import requires cleanup (records may be half imported depending on stage). +**Fix:** For **API** imports, JWT expiry or network issues can interrupt long jobs — re-run **Test Connection** and try again. For **file** imports, see [Import progress appears stuck (file import)](#import-progress-appears-stuck-file-import) above. In all cases, check the error text in the import UI, browser **Network** responses, and server error logs; partial imports may leave records half-imported depending on the stage, and you may need cleanup or a targeted re-run. diff --git a/includes/class-dt-migration-export-file.php b/includes/class-dt-migration-export-file.php index 3fc630e..585fc70 100644 --- a/includes/class-dt-migration-export-file.php +++ b/includes/class-dt-migration-export-file.php @@ -37,7 +37,8 @@ public static function build_export( array $record_options = [] ) : array { $export_block['system_users'] = Disciple_Tools_Migration_System_Users::build_export_payload(); } - $records = []; + $records = []; + $post_user_meta = []; $allowed_records = $allowed['records'] ?? []; if ( ! empty( $allowed_records ) && is_array( $allowed_records ) ) { foreach ( $allowed_records as $post_type => $enabled ) { @@ -49,7 +50,9 @@ public static function build_export( array $record_options = [] ) : array { $min_id = isset( $opts['min_id'] ) ? absint( $opts['min_id'] ) : 0; $max_id = isset( $opts['max_id'] ) ? absint( $opts['max_id'] ) : 0; - $records[ $post_type ] = self::fetch_records( $post_type, $limit, $min_id, $max_id ); + $ids = self::get_record_ids( $post_type, $limit, $min_id, $max_id ); + $records[ $post_type ] = self::fetch_records_for_ids( $post_type, $ids ); + $post_user_meta[ $post_type ] = self::fetch_post_user_meta( $ids ); } } @@ -62,8 +65,9 @@ public static function build_export( array $record_options = [] ) : array { 'mode' => 'file', 'allowed_items' => $allowed, ], - 'export' => $export_block, - 'records' => $records, + 'export' => $export_block, + 'records' => $records, + 'post_user_meta' => $post_user_meta, ]; } @@ -79,19 +83,92 @@ public static function build_export( array $record_options = [] ) : array { */ public static function fetch_records( string $post_type, int $limit = 0, int $min_id = 0, int $max_id = 0 ) : array { $ids = self::get_record_ids( $post_type, $limit, $min_id, $max_id ); - $records = []; - foreach ( $ids as $post_id ) { - $post = DT_Posts::get_post( $post_type, $post_id, true, false ); - if ( ! is_wp_error( $post ) && is_array( $post ) ) { - if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) { - $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post ); + return self::fetch_records_for_ids( $post_type, $ids ); + } + + /** + * Fetches records for a fixed list of post IDs. + * + * Switches the current user to 0 around each DT_Posts::get_post() call so the + * dt_post_user_meta query inside the theme returns no rows. This keeps + * exporter-specific private meta (tasks, private favorites/inputs/etc.) out of + * the per-record payload — full per-user private state is exported separately + * via {@see self::fetch_post_user_meta()}. + * + * @param string $post_type + * @param int[] $ids + * @return array + */ + private static function fetch_records_for_ids( string $post_type, array $ids ) : array { + $records = []; + $original_user_id = get_current_user_id(); + wp_set_current_user( 0 ); + try { + foreach ( $ids as $post_id ) { + $post = DT_Posts::get_post( $post_type, (int) $post_id, false, false ); + if ( ! is_wp_error( $post ) && is_array( $post ) ) { + if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) { + $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post ); + } + $records[] = $post; } - $records[] = $post; } + } finally { + wp_set_current_user( $original_user_id ); } return $records; } + /** + * Dumps wp_dt_post_user_meta rows for the given post IDs. + * + * Carries every per-user private value (tasks, private favorites, private inputs, + * private link/multi_select/key_select/etc.) regardless of who created it. The + * source-side primary key id is stripped — re-imports MUST NOT re-use it. + * + * @param int[] $post_ids + * @return array + */ + public static function fetch_post_user_meta( array $post_ids ) : array { + if ( empty( $post_ids ) ) { + return []; + } + global $wpdb; + $post_ids = array_values( array_map( 'intval', $post_ids ) ); + $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, user_id, meta_key, meta_value, date, category + FROM {$wpdb->dt_post_user_meta} + WHERE post_id IN ( $placeholders ) + ORDER BY post_id ASC, user_id ASC, id ASC", + $post_ids + ), + ARRAY_A + ); + // phpcs:enable + + if ( ! is_array( $rows ) ) { + return []; + } + + return array_map( + static function ( array $row ) : array { + return [ + 'post_id' => (int) $row['post_id'], + 'user_id' => (int) $row['user_id'], + 'meta_key' => (string) $row['meta_key'], + 'meta_value' => $row['meta_value'], + 'date' => $row['date'], + 'category' => $row['category'], + ]; + }, + $rows + ); + } + /** * Gets post IDs for a post type with optional limit and ID range. * Always ORDER BY ID ASC. diff --git a/includes/class-dt-migration-file-job-store.php b/includes/class-dt-migration-file-job-store.php new file mode 100644 index 0000000..53f30fe --- /dev/null +++ b/includes/class-dt-migration-file-job-store.php @@ -0,0 +1,357 @@ + File import jobs). + */ + public const MAX_AGE_DAYS_DEFAULT = 7; + + /** + * Sanitize a job id (UUID). + * + * @param string $job_id + * @return string + */ + public static function sanitize_job_id( string $job_id ) : string { + $job_id = strtolower( trim( $job_id ) ); + if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $job_id ) ) { + return ''; + } + return $job_id; + } + + /** + * @return array> + */ + private static function get_registry() : array { + $raw = get_option( self::REGISTRY_OPTION, [] ); + return is_array( $raw ) ? $raw : []; + } + + /** + * @param array> $registry + * @return void + */ + private static function save_registry( array $registry ) : void { + if ( $registry === [] ) { + delete_option( self::REGISTRY_OPTION ); + return; + } + if ( get_option( self::REGISTRY_OPTION, null ) === false ) { + add_option( self::REGISTRY_OPTION, $registry, '', 'no' ); + } else { + update_option( self::REGISTRY_OPTION, $registry, false ); + } + } + + /** + * @param string $job_id + * @return string + */ + public static function payload_option_name( string $job_id ) : string { + return self::PAYLOAD_KEY_PREFIX . $job_id; + } + + /** + * @param int $user_id + * @param array $payload Full migration JSON (decoded). + * @param string $label Original filename or short label. + * @return string Job id (UUID) or empty string on failure. + */ + public static function create_job( int $user_id, array $payload, string $label ) : string { + $job_id = self::sanitize_job_id( wp_generate_uuid4() ); + if ( $job_id === '' ) { + return ''; + } + + $key = self::payload_option_name( $job_id ); + $ok = add_option( $key, $payload, '', 'no' ); + if ( ! $ok ) { + delete_option( $key ); + if ( ! add_option( $key, $payload, '', 'no' ) ) { + return ''; + } + } + + $json = wp_json_encode( $payload ); + $size = is_string( $json ) ? strlen( $json ) : 0; + + $registry = self::get_registry(); + $now = time(); + $registry[ $job_id ] = [ + 'user_id' => $user_id, + 'stored_at' => $now, + 'updated_at' => $now, + 'status' => self::STATUS_READY, + 'label' => sanitize_file_name( $label ), + 'byte_size' => $size, + ]; + self::save_registry( $registry ); + + return $job_id; + } + + /** + * @param int $user_id + * @param string $job_id + * @return array|null + */ + public static function get_job_meta( int $user_id, string $job_id ) : ?array { + $job_id = self::sanitize_job_id( $job_id ); + if ( $job_id === '' ) { + return null; + } + $registry = self::get_registry(); + $row = $registry[ $job_id ] ?? null; + if ( ! is_array( $row ) || (int) ( $row['user_id'] ?? 0 ) !== $user_id ) { + return null; + } + return $row; + } + + /** + * @param string $job_id Job UUID. + * @return bool + */ + public static function job_has_stored_payload( string $job_id ) : bool { + $job_id = self::sanitize_job_id( $job_id ); + if ( $job_id === '' ) { + return false; + } + $name = self::payload_option_name( $job_id ); + $val = get_option( $name, null ); + return is_array( $val ) && ! empty( $val ); + } + + /** + * @param int $user_id + * @param string $job_id + * @return array|null Full payload or null. + */ + public static function get_payload( int $user_id, string $job_id ) : ?array { + $job_id = self::sanitize_job_id( $job_id ); + if ( $job_id === '' ) { + return null; + } + if ( self::get_job_meta( $user_id, $job_id ) === null ) { + return null; + } + $name = self::payload_option_name( $job_id ); + $val = get_option( $name, null ); + if ( ! is_array( $val ) ) { + return null; + } + return $val; + } + + /** + * @param int $user_id + * @param string $job_id + * @return bool + */ + public static function job_exists_for_user( int $user_id, string $job_id ) : bool { + return self::get_job_meta( $user_id, $job_id ) !== null; + } + + /** + * @param int $user_id User ID. + * @param string $job_id Job UUID. + * @param string $status One of the STATUS_* constants. + * @return void + */ + public static function set_status( int $user_id, string $job_id, string $status ) : void { + $job_id = self::sanitize_job_id( $job_id ); + if ( $job_id === '' ) { + return; + } + $allowed = [ + self::STATUS_READY, + self::STATUS_RUNNING, + self::STATUS_SUCCESS, + self::STATUS_FAILED, + self::STATUS_CANCELLED, + ]; + if ( ! in_array( $status, $allowed, true ) ) { + return; + } + $registry = self::get_registry(); + if ( ! isset( $registry[ $job_id ] ) || (int) ( $registry[ $job_id ]['user_id'] ?? 0 ) !== $user_id ) { + return; + } + $registry[ $job_id ]['status'] = $status; + $registry[ $job_id ]['updated_at'] = time(); + self::save_registry( $registry ); + } + + /** + * Marks success and drops the stored payload to save space; keeps registry row. + * + * @param int $user_id + * @param string $job_id + * @return void + */ + public static function mark_success_and_clear_payload( int $user_id, string $job_id ) : void { + $job_id = self::sanitize_job_id( $job_id ); + if ( $job_id === '' ) { + return; + } + $registry = self::get_registry(); + if ( ! isset( $registry[ $job_id ] ) || (int) ( $registry[ $job_id ]['user_id'] ?? 0 ) !== $user_id ) { + return; + } + delete_option( self::payload_option_name( $job_id ) ); + $registry[ $job_id ]['status'] = self::STATUS_SUCCESS; + $registry[ $job_id ]['byte_size'] = 0; + $registry[ $job_id ]['updated_at'] = time(); + self::save_registry( $registry ); + } + + /** + * @param int $user_id + * @param string $job_id + * @return void + */ + public static function delete_job( int $user_id, string $job_id ) : void { + $job_id = self::sanitize_job_id( $job_id ); + if ( $job_id === '' ) { + return; + } + $registry = self::get_registry(); + if ( ! isset( $registry[ $job_id ] ) || (int) ( $registry[ $job_id ]['user_id'] ?? 0 ) !== $user_id ) { + return; + } + unset( $registry[ $job_id ] ); + self::save_registry( $registry ); + delete_option( self::payload_option_name( $job_id ) ); + } + + /** + * @param int $user_id + * @return array> List of rows with job_id key added. + */ + public static function list_jobs_for_user( int $user_id ) : array { + $registry = self::get_registry(); + $out = []; + foreach ( $registry as $job_id => $row ) { + if ( ! is_string( $job_id ) || ! is_array( $row ) ) { + continue; + } + if ( (int) ( $row['user_id'] ?? 0 ) !== $user_id ) { + continue; + } + $row['job_id'] = $job_id; + $out[] = $row; + } + usort( + $out, + static function ( $a, $b ) { + return ( (int) ( $b['stored_at'] ?? 0 ) ) <=> ( (int) ( $a['stored_at'] ?? 0 ) ); + } + ); + return $out; + } + + /** + * Max retention days from plugin settings, clamped 1..365. + * + * @return int + */ + public static function get_max_age_days() : int { + $raw = 0; + if ( class_exists( 'Disciple_Tools_Migration_Menu', false ) ) { + $settings = Disciple_Tools_Migration_Menu::get_settings(); + $file = isset( $settings['file'] ) && is_array( $settings['file'] ) ? $settings['file'] : []; + $raw = (int) ( $file['job_max_age_days'] ?? 0 ); + } else { + $opt = get_option( 'dt_migration_settings', [] ); + if ( is_array( $opt ) && isset( $opt['file'] ) && is_array( $opt['file'] ) ) { + $raw = (int) ( $opt['file']['job_max_age_days'] ?? 0 ); + } + } + if ( $raw < 1 ) { + $raw = (int) ( defined( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) ? constant( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) : self::MAX_AGE_DAYS_DEFAULT ); + } + if ( $raw < 1 ) { + $raw = self::MAX_AGE_DAYS_DEFAULT; + } + $raw = (int) apply_filters( 'dt_migration_file_job_max_age_days', $raw ); + if ( $raw < 1 ) { + $raw = 1; + } + if ( $raw > 365 ) { + $raw = 365; + } + return $raw; + } + + /** + * Removes registry rows and payload options past retention. + * + * @return int Number of jobs removed. + */ + public static function prune_expired_jobs() : int { + $max_days = self::get_max_age_days(); + $cut = time() - ( $max_days * DAY_IN_SECONDS ); + $registry = self::get_registry(); + $to_remove = []; + foreach ( $registry as $job_id => $row ) { + if ( ! is_string( $job_id ) || ! is_array( $row ) ) { + continue; + } + $t = (int) ( $row['stored_at'] ?? 0 ); + if ( $t >= $cut ) { + continue; + } + $to_remove[] = [ + 'job_id' => $job_id, + 'user_id' => (int) ( $row['user_id'] ?? 0 ), + ]; + } + $removed = 0; + foreach ( $to_remove as $item ) { + self::delete_job( $item['user_id'], $item['job_id'] ); + $removed++; + } + return $removed; + } + + /** + * Validates export block shape (same as upload handler). + * + * @param array $payload + * @return bool + */ + public static function is_valid_migration_payload( array $payload ) : bool { + $export_block = $payload['export'] ?? []; + $has_dt_settings = ! empty( $export_block['dt_settings'] ); + $has_users_block = array_key_exists( 'system_users', $export_block ) && is_array( $export_block['system_users'] ); + return is_array( $export_block ) && ( $has_dt_settings || $has_users_block ); + } +} diff --git a/includes/class-dt-migration-import-engine.php b/includes/class-dt-migration-import-engine.php index c64e1e8..3a2d6ef 100644 --- a/includes/class-dt-migration-import-engine.php +++ b/includes/class-dt-migration-import-engine.php @@ -821,6 +821,83 @@ public static function apply_deferred_group_connections() : array { * * @return int Number deleted, or -1 on error. */ + /** + * Imports per-user private meta (dt_post_user_meta) for a slice of post IDs. + * + * Replace semantics: deletes all existing dt_post_user_meta rows for the in-scope + * post IDs, then inserts the rows from the export whose post_id is in scope. + * The source-side user_id is remapped via {@see Disciple_Tools_Migration_System_Users::remap_user_id()}. + * + * Rows whose remapped user does not exist on the target are skipped with a logged warning. + * Rows whose post_id is not in scope are silently ignored (they belong to a different batch). + * + * @param array $rows + * @param int[] $post_ids_in_scope Post IDs covered by the current batch (already inserted). + * @return array{ inserted:int, errors: string[] } + */ + public static function import_post_user_meta_for_posts( array $rows, array $post_ids_in_scope ) : array { + $result = [ 'inserted' => 0, 'errors' => [] ]; + + $post_ids = array_values( array_unique( array_filter( array_map( 'intval', $post_ids_in_scope ) ) ) ); + if ( empty( $post_ids ) ) { + return $result; + } + + global $wpdb; + $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->dt_post_user_meta} WHERE post_id IN ( $placeholders )", + $post_ids + ) + ); + // phpcs:enable + + if ( empty( $rows ) ) { + return $result; + } + + $scope = array_flip( $post_ids ); + foreach ( $rows as $row ) { + $pid = isset( $row['post_id'] ) ? (int) $row['post_id'] : 0; + if ( $pid <= 0 || ! isset( $scope[ $pid ] ) ) { + continue; + } + $source_uid = isset( $row['user_id'] ) ? (int) $row['user_id'] : 0; + if ( $source_uid <= 0 ) { + continue; + } + $target_uid = Disciple_Tools_Migration_System_Users::remap_user_id( $source_uid ); + if ( $target_uid <= 0 || ! get_user_by( 'id', $target_uid ) ) { + $result['errors'][] = sprintf( + /* translators: 1: post ID, 2: source user ID */ + __( 'Skipped post_user_meta row (post #%1$d): no target user for source user %2$d.', 'disciple-tools-migration' ), + $pid, + $source_uid + ); + continue; + } + + $ok = $wpdb->insert( + $wpdb->dt_post_user_meta, + [ + 'user_id' => $target_uid, + 'post_id' => $pid, + 'meta_key' => (string) ( $row['meta_key'] ?? '' ), + 'meta_value' => $row['meta_value'] ?? '', + 'date' => $row['date'] ?? null, + 'category' => $row['category'] ?? null, + ] + ); + if ( $ok ) { + ++$result['inserted']; + } + } + + return $result; + } + public static function delete_posts_by_type( string $post_type ) : int { $query = new WP_Query( [ @@ -1264,6 +1341,40 @@ private static function normalize_multi_select_for_import( $value ) : array { return [ 'values' => $values, 'force_values' => true ]; } + /** + * Normalizes a link field from export format to DT_Posts update format. + * + * Export returns links as [{ type, value, meta_id }, ...]. + * DT_Posts::update_post expects { values: [ { type, value }, ... ], force_values?: true }. + * + * meta_id is stripped: it refers to the source-site postmeta row, and passing it would + * send the entry through the "update existing" branch and update the wrong row (or none). + * + * @param mixed $value Link data from export. + * @return array{ values: array, force_values: bool } + */ + private static function normalize_link_for_import( $value ) : array { + if ( is_array( $value ) && isset( $value['values'] ) ) { + return [ + 'values' => array_values( (array) $value['values'] ), + 'force_values' => ! empty( $value['force_values'] ), + ]; + } + $values = []; + if ( is_array( $value ) ) { + foreach ( $value as $item ) { + if ( ! is_array( $item ) || ! isset( $item['type'] ) || ! array_key_exists( 'value', $item ) ) { + continue; + } + $values[] = [ + 'type' => (string) $item['type'], + 'value' => $item['value'], + ]; + } + } + return [ 'values' => $values, 'force_values' => true ]; + } + /** * Extracts the value from a multi-select, location, or tags item. * @@ -1340,6 +1451,8 @@ private static function prepare_record_fields_for_import( array $record, string $connection_types = []; $multi_select_types = []; $user_select_types = []; + $link_types = []; + $private_field_types = []; if ( class_exists( 'DT_Posts' ) ) { $post_settings = DT_Posts::get_post_settings( $post_type, false ); $connection_types = (array) ( $post_settings['connection_types'] ?? [] ); @@ -1349,8 +1462,15 @@ private static function prepare_record_fields_for_import( array $record, string if ( ! isset( $fs['type'] ) ) { continue; } + // Private field values flow through the post_user_meta block, never through records. + if ( $fs['type'] === 'task' || ! empty( $fs['private'] ) ) { + $private_field_types[] = $fk; + continue; + } if ( $fs['type'] === 'user_select' ) { $user_select_types[] = $fk; + } elseif ( $fs['type'] === 'link' ) { + $link_types[] = $fk; } elseif ( in_array( $fs['type'], $values_based_types, true ) ) { $multi_select_types[] = $fk; } @@ -1361,12 +1481,17 @@ private static function prepare_record_fields_for_import( array $record, string if ( in_array( $key, $exclude, true ) ) { continue; } + if ( in_array( $key, $private_field_types, true ) ) { + continue; + } if ( in_array( $key, $connection_types, true ) ) { $fields[ $key ] = self::normalize_connection_for_import( $value ); } elseif ( in_array( $key, $multi_select_types, true ) ) { $fields[ $key ] = self::normalize_multi_select_for_import( $value ); } elseif ( in_array( $key, $user_select_types, true ) ) { $fields[ $key ] = self::normalize_and_remap_user_select_for_import( $value ); + } elseif ( in_array( $key, $link_types, true ) ) { + $fields[ $key ] = self::normalize_link_for_import( $value ); } else { $fields[ $key ] = self::normalize_field_value_for_import( $value ); } diff --git a/includes/class-dt-migration-system-users.php b/includes/class-dt-migration-system-users.php index 12d5ae9..c8fe49f 100644 --- a/includes/class-dt-migration-system-users.php +++ b/includes/class-dt-migration-system-users.php @@ -160,8 +160,17 @@ static function ( $a, $b ) { $out['map'][ (string) $old_id ] = (int) $existing->ID; ++$out['mapped_existing']; + $norm_roles = self::normalized_roles_from_row( $row ); + + if ( is_multisite() && ! is_user_member_of_blog( $existing->ID, get_current_blog_id() ) ) { + $add_err = self::add_existing_user_to_current_blog( $existing->ID, $norm_roles ); + if ( $add_err !== null ) { + return array_merge( $out, [ 'error' => $add_err ] ); + } + } + if ( $is_src_admin ) { - $roles_err = self::assign_roles_to_wp_user( $existing->ID, self::normalized_roles_from_row( $row ) ); + $roles_err = self::assign_roles_to_wp_user( $existing->ID, $norm_roles ); if ( $roles_err !== null ) { return array_merge( $out, [ 'error' => $roles_err ] ); } @@ -349,6 +358,63 @@ private static function assign_roles_to_wp_user( int $user_id, array $roles ) : return null; } + /** + * Adds an existing network user to the current subsite using their source roles. + * + * Multisite-only. Skips when user is already a member of the current blog. + * Uses add_user_to_blog() for the primary role so the standard hooks fire, + * then adds any extra roles via WP_User::add_role(). Falls back to the site's + * default role when the export carries no usable role. + * + * @param int $user_id Target site user ID. + * @param string[] $roles Sanitized role slugs from the export row. + * @return string|null Error message, or null on success / no-op. + */ + private static function add_existing_user_to_current_blog( int $user_id, array $roles ) : ?string { + $roles = array_values( array_filter( array_map( 'sanitize_key', $roles ) ) ); + + if ( in_array( 'administrator', $roles, true ) && ! current_user_can( 'promote_users' ) ) { + return __( 'You do not have permission to assign the administrator role.', 'disciple-tools-migration' ); + } + + $wp_roles = wp_roles(); + foreach ( $roles as $slug ) { + if ( ! $wp_roles->is_role( $slug ) ) { + return sprintf( + /* translators: %s: role slug from export */ + __( 'Unknown or invalid role in export: %s', 'disciple-tools-migration' ), + $slug + ); + } + } + + if ( empty( $roles ) ) { + $default_role = sanitize_key( (string) get_option( 'default_role', 'subscriber' ) ); + if ( ! $wp_roles->is_role( $default_role ) ) { + $default_role = 'subscriber'; + } + $primary = $default_role; + $extras = []; + } else { + $primary = array_shift( $roles ); + $extras = $roles; + } + + $result = add_user_to_blog( get_current_blog_id(), $user_id, $primary ); + if ( is_wp_error( $result ) ) { + return $result->get_error_message(); + } + + if ( ! empty( $extras ) ) { + $user = new WP_User( $user_id ); + foreach ( $extras as $extra_role ) { + $user->add_role( $extra_role ); + } + } + + return null; + } + /** * @param array $row * @return int|WP_Error diff --git a/rest-api/rest-api.php b/rest-api/rest-api.php index 563062d..238500b 100644 --- a/rest-api/rest-api.php +++ b/rest-api/rest-api.php @@ -366,24 +366,35 @@ public function records_batch( WP_REST_Request $request ) : WP_REST_Response { $ids = $wp_query->posts ?? []; $total = (int) ( $wp_query->found_posts ?? 0 ); - $records = []; - foreach ( $ids as $post_id ) { - $post = DT_Posts::get_post( $post_type, (int) $post_id, true, false ); - if ( ! is_wp_error( $post ) && is_array( $post ) ) { - if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) { - $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post ); + $records = []; + $original_user_id = get_current_user_id(); + wp_set_current_user( 0 ); + try { + foreach ( $ids as $post_id ) { + $post = DT_Posts::get_post( $post_type, (int) $post_id, false, false ); + if ( ! is_wp_error( $post ) && is_array( $post ) ) { + if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) { + $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post ); + } + $records[] = $post; } - $records[] = $post; } + } finally { + wp_set_current_user( $original_user_id ); } + $post_user_meta = class_exists( 'Disciple_Tools_Migration_Export_File' ) + ? Disciple_Tools_Migration_Export_File::fetch_post_user_meta( array_map( 'intval', $ids ) ) + : []; + return new WP_REST_Response( [ - 'records' => $records, - 'total' => $total, - 'offset' => $offset, - 'limit' => $limit, - 'has_more' => ( $offset + count( $records ) ) < $total, + 'records' => $records, + 'post_user_meta' => $post_user_meta, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'has_more' => ( $offset + count( $records ) ) < $total, ], 200 );