diff --git a/admin/admin-menu-and-tabs.php b/admin/admin-menu-and-tabs.php index a242216..d355a1d 100644 --- a/admin/admin-menu-and-tabs.php +++ b/admin/admin-menu-and-tabs.php @@ -95,6 +95,12 @@ public static function get_settings(): array { 'file' => [ 'compression' => 'zip', ], + 'file_export_memory' => [ + 'budget_ratio' => null, + 'bytes_per_record' => null, + 'bytes_per_user' => null, + 'settings_overhead_bytes' => null, + ], ]; $current = get_option( 'dt_migration_settings', [] ); @@ -102,7 +108,18 @@ public static function get_settings(): array { $current = []; } - return wp_parse_args( $current, $defaults ); + $merged = wp_parse_args( $current, $defaults ); + + // Shallow wp_parse_args replaces the whole file_export_memory key; merge inner keys so stale + // partial saves (e.g. only bytes_per_record) still fill missing keys from defaults. + if ( isset( $merged['file_export_memory'] ) && is_array( $merged['file_export_memory'] ) ) { + $merged['file_export_memory'] = wp_parse_args( + $merged['file_export_memory'], + $defaults['file_export_memory'] + ); + } + + return $merged; } /** diff --git a/admin/class-dt-migration-export-download.php b/admin/class-dt-migration-export-download.php index d2ad388..3970984 100644 --- a/admin/class-dt-migration-export-download.php +++ b/admin/class-dt-migration-export-download.php @@ -38,38 +38,26 @@ public function handle_download() : void { wp_die( esc_html__( 'Migration is not enabled.', 'disciple-tools-migration' ) ); } - $record_options = []; $export_by = $this->sanitize_post_type_assoc_array( 'dt_migration_export_by', 'sanitize_key' ); $limits = $this->sanitize_post_type_assoc_array( 'dt_migration_export_limit', 'absint' ); $min_ids = $this->sanitize_post_type_assoc_array( 'dt_migration_export_min_id', 'absint' ); $max_ids = $this->sanitize_post_type_assoc_array( 'dt_migration_export_max_id', 'absint' ); $allowed_records = $settings['allowed_items']['records'] ?? []; - foreach ( $allowed_records as $post_type => $enabled ) { - if ( ! $enabled ) { - continue; - } - $raw_mode = isset( $export_by[ $post_type ] ) ? sanitize_key( (string) $export_by[ $post_type ] ) : 'all'; - if ( $raw_mode === 'limit' ) { - $mode = 'limit'; - } elseif ( $raw_mode === 'range' ) { - $mode = 'range'; - } else { - $mode = 'all'; - } - if ( $mode === 'all' ) { - continue; - } - $limit = $mode === 'limit' ? absint( $limits[ $post_type ] ?? 0 ) : 0; - $min_id = $mode === 'range' ? absint( $min_ids[ $post_type ] ?? 0 ) : 0; - $max_id = $mode === 'range' ? absint( $max_ids[ $post_type ] ?? 0 ) : 0; - if ( $limit > 0 || $min_id > 0 || $max_id > 0 ) { - $record_options[ $post_type ] = [ - 'limit' => $limit, - 'min_id' => $min_id, - 'max_id' => $max_id, - ]; - } + + $record_options = Disciple_Tools_Migration_Export_File::parse_download_record_options( + is_array( $allowed_records ) ? $allowed_records : [], + $export_by, + $limits, + $min_ids, + $max_ids + ); + + $memory_check = Disciple_Tools_Migration_Export_File::evaluate_file_export_memory( $record_options ); + if ( empty( $memory_check['allowed'] ) ) { + set_transient( 'dt_migration_export_flash_notice_' . get_current_user_id(), 'file_export_memory', MINUTE_IN_SECONDS ); + wp_safe_redirect( admin_url( 'admin.php?page=disciple_tools_migration&tab=export' ) ); + exit; } $payload = Disciple_Tools_Migration_Export_File::build_export( $record_options ); diff --git a/admin/class-dt-migration-import-ajax.php b/admin/class-dt-migration-import-ajax.php index 302d3f4..1daf37d 100644 --- a/admin/class-dt-migration-import-ajax.php +++ b/admin/class-dt-migration-import-ajax.php @@ -33,6 +33,30 @@ public function enqueue_scripts( string $hook ) : void { } $plugin_url = plugin_dir_url( dirname( __FILE__ ) ); + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings'; + + if ( $tab === 'export' ) { + wp_enqueue_script( + 'dt-migration-export', + $plugin_url . 'admin/js/export.js', + [ 'jquery' ], + '1.0.0', + true + ); + wp_localize_script( + 'dt-migration-export', + 'dtMigrationExport', + [ + 'preflightUrl' => esc_url_raw( rest_url( 'dt-migration/v1/export-file-preflight' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'strings' => [ + 'memoryBlocked' => __( 'This downloadable export is estimated to exceed the server memory limit. Use the Import tab to connect to the target site and migrate over the API instead, or reduce what is enabled for export on the Settings tab.', 'disciple-tools-migration' ), + 'preflightFailed' => __( 'Could not verify export safety. Try again or check your connection.', 'disciple-tools-migration' ), + ], + ] + ); + } + wp_enqueue_script( 'dt-migration-import', $plugin_url . 'admin/js/import.js', diff --git a/admin/class-dt-migration-tab-export.php b/admin/class-dt-migration-tab-export.php index 94a2f98..32b915e 100644 --- a/admin/class-dt-migration-tab-export.php +++ b/admin/class-dt-migration-tab-export.php @@ -7,10 +7,39 @@ * Placeholder for the Migration Export tab. Will be wired to settings in later phases. */ class Disciple_Tools_Migration_Tab_Export { + + /** + * Shows a short-lived admin notice after redirect (e.g. file export blocked by memory heuristic). + * + * @return void + */ + private function maybe_render_file_export_flash_notice() : void { + $key = 'dt_migration_export_flash_notice_' . get_current_user_id(); + $flag = get_transient( $key ); + if ( 'file_export_memory' !== $flag ) { + return; + } + + delete_transient( $key ); + ?> +
+

+ +

+
+
+ maybe_render_file_export_flash_notice(); ?>
@@ -151,7 +180,7 @@ public function main_column( array $settings ) { $show_export_record_filters = apply_filters( 'dt_migration_show_export_record_filters', false ); if ( ! empty( $record_stats ) ) : ?> -
+ diff --git a/admin/class-dt-migration-tab-settings.php b/admin/class-dt-migration-tab-settings.php index 0e40fde..8461565 100644 --- a/admin/class-dt-migration-tab-settings.php +++ b/admin/class-dt-migration-tab-settings.php @@ -126,6 +126,55 @@ public function main_column( array $settings ) {

+ + + + + + + +

+ +

+

+
+ + +

+

+
+ +

+

+
+ +

+

+
+ +

+ @@ -180,6 +229,50 @@ public function process_form_fields(): void { $settings['allowed_items']['records']['contacts'] = ! empty( $allowed['records']['contacts'] ); $settings['allowed_items']['records']['groups'] = ! empty( $allowed['records']['groups'] ); + if ( ! isset( $settings['file_export_memory'] ) || ! is_array( $settings['file_export_memory'] ) ) { + $settings['file_export_memory'] = []; + } + + $ratio_raw = isset( $post_vars['dt_migration_export_memory_budget_ratio'] ) ? trim( (string) $post_vars['dt_migration_export_memory_budget_ratio'] ) : ''; + if ( '' === $ratio_raw ) { + $settings['file_export_memory']['budget_ratio'] = null; + } else { + $r = (float) str_replace( ',', '.', $ratio_raw ); + $settings['file_export_memory']['budget_ratio'] = max( 0.05, min( 0.95, $r ) ); + } + + $bpr_raw = isset( $post_vars['dt_migration_export_memory_bytes_per_record'] ) ? trim( (string) $post_vars['dt_migration_export_memory_bytes_per_record'] ) : ''; + if ( '' === $bpr_raw ) { + $settings['file_export_memory']['bytes_per_record'] = null; + } else { + $settings['file_export_memory']['bytes_per_record'] = max( 1024, absint( $bpr_raw ) ); + } + + $bpu_raw = isset( $post_vars['dt_migration_export_memory_bytes_per_user'] ) ? trim( (string) $post_vars['dt_migration_export_memory_bytes_per_user'] ) : ''; + if ( '' === $bpu_raw ) { + $settings['file_export_memory']['bytes_per_user'] = null; + } else { + $settings['file_export_memory']['bytes_per_user'] = max( 128, absint( $bpu_raw ) ); + } + + $ov_raw = isset( $post_vars['dt_migration_export_memory_settings_overhead_bytes'] ) ? trim( (string) $post_vars['dt_migration_export_memory_settings_overhead_bytes'] ) : ''; + if ( '' === $ov_raw ) { + $settings['file_export_memory']['settings_overhead_bytes'] = null; + } else { + $settings['file_export_memory']['settings_overhead_bytes'] = absint( $ov_raw ); + } + + // Do not persist file_export_memory when every field means "use defaults". update_option() can + // drop null leaf values, which used to leave stale keys (e.g. a large bytes_per_record alone). + if ( + null === $settings['file_export_memory']['budget_ratio'] + && null === $settings['file_export_memory']['bytes_per_record'] + && null === $settings['file_export_memory']['bytes_per_user'] + && null === $settings['file_export_memory']['settings_overhead_bytes'] + ) { + unset( $settings['file_export_memory'] ); + } + Disciple_Tools_Migration_Menu::update_settings( $settings ); } diff --git a/admin/js/export.js b/admin/js/export.js new file mode 100644 index 0000000..c41ce01 --- /dev/null +++ b/admin/js/export.js @@ -0,0 +1,58 @@ +/** + * Disciple.Tools Migration - Downloadable JSON export preflight (memory heuristic). + */ +( function( $ ) { + 'use strict'; + + const strings = ( typeof dtMigrationExport !== 'undefined' && dtMigrationExport.strings ) ? dtMigrationExport.strings : {}; + + function t( key, fallback ) { + return strings[ key ] || fallback; + } + + $( function() { + const $form = $( '#dt-migration-download-export-form' ); + if ( ! $form.length || typeof dtMigrationExport === 'undefined' ) { + return; + } + + const endpoint = dtMigrationExport.preflightUrl || ''; + const nonce = dtMigrationExport.nonce || ''; + + $form.on( 'submit', function( e ) { + e.preventDefault(); + + const bodyStr = $form.serialize(); + + fetch( endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-WP-Nonce': nonce + }, + credentials: 'same-origin', + body: bodyStr + } ) + .then( function( resp ) { + return resp.json().then( function( data ) { + return { ok_http: resp.ok, status: resp.status, payload: data }; + } ); + } ) + .then( function( wrapped ) { + const data = wrapped.payload; + if ( ! wrapped.ok_http ) { + throw new Error( 'http' ); + } + if ( data && data.ok ) { + $form.off( 'submit' ); + $form.get( 0 ).submit(); + return; + } + window.alert( data && data.message ? data.message : t( 'memoryBlocked', t( 'preflightFailed', 'Request failed.' ) ) ); + } ) + .catch( function() { + window.alert( t( 'preflightFailed', 'Could not verify export safety. Try again or check your connection.' ) ); + } ); + } ); + } ); +}( jQuery ) ); diff --git a/disciple-tools-migration.php b/disciple-tools-migration.php index 3ff3bfa..1d121c6 100755 --- a/disciple-tools-migration.php +++ b/disciple-tools-migration.php @@ -106,8 +106,11 @@ private function __construct() { require_once 'includes/class-dt-migration-preflight.php'; } - if ( is_admin() ) { + if ( is_admin() || $is_rest ) { require_once 'includes/class-dt-migration-export-file.php'; + } + + if ( is_admin() ) { require_once 'admin/class-dt-migration-export-download.php'; new Disciple_Tools_Migration_Export_Download(); } diff --git a/includes/class-dt-migration-export-file.php b/includes/class-dt-migration-export-file.php index 3fc630e..3eefec0 100644 --- a/includes/class-dt-migration-export-file.php +++ b/includes/class-dt-migration-export-file.php @@ -13,6 +13,18 @@ class Disciple_Tools_Migration_Export_File { const EXPORT_VERSION = '1.0'; + /** @var float Ratio of PHP memory_limit used as the safe budget for estimating file export size. */ + const DEFAULT_FILE_EXPORT_MEMORY_BUDGET_RATIO = 0.2; + + /** @var int Heuristic bytes per exported record (large JSON per post + connections + comments). */ + const DEFAULT_FILE_EXPORT_BYTES_PER_RECORD = 153600; + + /** @var int Heuristic bytes per WordPress user in system_users export. */ + const DEFAULT_FILE_EXPORT_BYTES_PER_USER = 4096; + + /** @var int Base overhead for settings/tiles/fields JSON blocks in the payload. */ + const DEFAULT_FILE_EXPORT_SETTINGS_OVERHEAD_BYTES = 10485760; + /** * Builds a full export payload (settings + records) for file download. * @@ -219,6 +231,333 @@ public static function get_record_stats() : array { return $stats; } + /** + * Parses per–post-type export options from the downloadable export form (matches handle_download logic). + * + * @param array $allowed_records Post type => enabled from settings. + * @param array $export_by Raw mode per post type: all|limit|range. + * @param array $limits Limit per post type when mode is limit. + * @param array $min_ids Min ID when mode is range. + * @param array $max_ids Max ID when mode is range. + * @return array + */ + public static function parse_download_record_options( array $allowed_records, array $export_by, array $limits, array $min_ids, array $max_ids ) : array { + $record_options = []; + + foreach ( $allowed_records as $post_type => $enabled ) { + if ( ! $enabled ) { + continue; + } + + $raw_mode = isset( $export_by[ $post_type ] ) ? sanitize_key( (string) $export_by[ $post_type ] ) : 'all'; + if ( 'limit' === $raw_mode ) { + $mode = 'limit'; + } elseif ( 'range' === $raw_mode ) { + $mode = 'range'; + } else { + $mode = 'all'; + } + + if ( 'all' === $mode ) { + continue; + } + + $limit = 'limit' === $mode ? absint( $limits[ $post_type ] ?? 0 ) : 0; + $min_id = 'range' === $mode ? absint( $min_ids[ $post_type ] ?? 0 ) : 0; + $max_id = 'range' === $mode ? absint( $max_ids[ $post_type ] ?? 0 ) : 0; + + if ( $limit > 0 || $min_id > 0 || $max_id > 0 ) { + $record_options[ $post_type ] = [ + 'limit' => $limit, + 'min_id' => $min_id, + 'max_id' => $max_id, + ]; + } + } + + return $record_options; + } + + /** + * Counts records that would be included for export (same rules as get_record_ids, without loading IDs into memory). + * + * @param string $post_type Post type. + * @param int $limit 0 = no limit. + * @param int $min_id 0 = no minimum ID. + * @param int $max_id 0 = no maximum ID. + * @return int + */ + public static function count_records_for_export( string $post_type, int $limit, int $min_id, int $max_id ) : int { + global $wpdb; + + if ( $limit > 0 ) { + if ( $min_id > 0 && $max_id > 0 ) { + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash' AND ID >= %d AND ID <= %d", + $post_type, + $min_id, + $max_id + ) + ); + } elseif ( $min_id > 0 ) { + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash' AND ID >= %d", + $post_type, + $min_id + ) + ); + } elseif ( $max_id > 0 ) { + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash' AND ID <= %d", + $post_type, + $max_id + ) + ); + } else { + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash'", + $post_type + ) + ); + } + + return min( $limit, max( 0, $count ) ); + } + + if ( $min_id > 0 && $max_id > 0 ) { + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash' AND ID >= %d AND ID <= %d", + $post_type, + $min_id, + $max_id + ) + ); + } + if ( $min_id > 0 ) { + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash' AND ID >= %d", + $post_type, + $min_id + ) + ); + } + if ( $max_id > 0 ) { + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash' AND ID <= %d", + $post_type, + $max_id + ) + ); + } + + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status != 'trash'", + $post_type + ) + ); + } + + /** + * Returns effective memory heuristic settings (stored values merged with constants and filters). + * + * @return array{budget_ratio: float, bytes_per_record: int, bytes_per_user: int, settings_overhead_bytes: int} + */ + public static function get_effective_file_export_memory_profile() : array { + $settings = []; + if ( class_exists( 'Disciple_Tools_Migration_Menu' ) ) { + $stored = Disciple_Tools_Migration_Menu::get_settings()['file_export_memory'] ?? []; + $settings = is_array( $stored ) ? $stored : []; + } + + $defaults = [ + 'budget_ratio' => null, + 'bytes_per_record' => null, + 'bytes_per_user' => null, + 'settings_overhead_bytes' => null, + ]; + $settings = wp_parse_args( $settings, $defaults ); + + $ratio_raw = $settings['budget_ratio']; + if ( null === $ratio_raw || '' === $ratio_raw ) { + $ratio = self::DEFAULT_FILE_EXPORT_MEMORY_BUDGET_RATIO; + } else { + $ratio = (float) $ratio_raw; + } + /** @var float $ratio */ + $ratio = (float) apply_filters( 'dt_migration_export_memory_budget_ratio', $ratio ); + $ratio = max( 0.05, min( 0.95, $ratio ) ); + + $bpr_raw = $settings['bytes_per_record']; + if ( null === $bpr_raw || '' === $bpr_raw ) { + $bytes_per_record = self::DEFAULT_FILE_EXPORT_BYTES_PER_RECORD; + } else { + $bytes_per_record = absint( $bpr_raw ); + } + $bytes_per_record = (int) apply_filters( 'dt_migration_export_bytes_per_record', max( 1024, absint( $bytes_per_record ) ) ); + + $bpu_raw = $settings['bytes_per_user']; + if ( null === $bpu_raw || '' === $bpu_raw ) { + $bytes_per_user = self::DEFAULT_FILE_EXPORT_BYTES_PER_USER; + } else { + $bytes_per_user = absint( $bpu_raw ); + } + $bytes_per_user = (int) apply_filters( 'dt_migration_export_bytes_per_user', max( 128, absint( $bytes_per_user ) ) ); + + $ov_raw = $settings['settings_overhead_bytes']; + if ( null === $ov_raw || '' === $ov_raw ) { + $settings_overhead = self::DEFAULT_FILE_EXPORT_SETTINGS_OVERHEAD_BYTES; + } else { + $settings_overhead = absint( $ov_raw ); + } + $settings_overhead = (int) apply_filters( 'dt_migration_export_settings_overhead_bytes', max( 0, absint( $settings_overhead ) ) ); + + return [ + 'budget_ratio' => $ratio, + 'bytes_per_record' => $bytes_per_record, + 'bytes_per_user' => $bytes_per_user, + 'settings_overhead_bytes' => $settings_overhead, + ]; + } + + /** + * Parses PHP ini memory_limit into bytes; handles unlimited (-1) with a capped fallback used only for estimating. + * + * @return int + */ + public static function get_effective_ini_memory_cap_bytes() : int { + $mem_str = (string) ini_get( 'memory_limit' ); + if ( function_exists( 'wp_convert_hr_to_bytes' ) ) { + $raw = (int) wp_convert_hr_to_bytes( $mem_str ); + } else { + $raw = absint( $mem_str ); + } + + // Unlimited memory -- use a fallback cap for heuristic comparison only. + if ( $raw <= 0 || '-1' === $mem_str ) { + return (int) apply_filters( 'dt_migration_export_fallback_memory_cap_bytes', 536870912 ); + } + + return max( (int) $raw, 1 ); + } + + /** + * Tests whether building the JSON file export likely fits within the configured memory budget heuristic. + * + * @param array $record_options Same shape as {@see build_export()}. + * @return array{ allowed: bool, estimated_bytes: int, budget_bytes: int, memory_limit_bytes: int, breakdown: array } + */ + public static function evaluate_file_export_memory( array $record_options = [] ) : array { + if ( ! class_exists( 'Disciple_Tools_Migration_Menu' ) ) { + return [ + 'allowed' => false, + 'estimated_bytes' => 0, + 'budget_bytes' => 0, + 'memory_limit_bytes' => 0, + 'breakdown' => [ 'reason' => 'menu_unavailable' ], + ]; + } + + $memory_limit_bytes = self::get_effective_ini_memory_cap_bytes(); + $profile = self::get_effective_file_export_memory_profile(); + $budget_bytes = (int) floor( $memory_limit_bytes * $profile['budget_ratio'] ); + $budget_bytes = max( $budget_bytes, 1 ); + + $migration_settings = Disciple_Tools_Migration_Menu::get_settings(); + $allowed = $migration_settings['allowed_items'] ?? []; + + $estimated = (int) $profile['settings_overhead_bytes']; + $breakdown = [ + 'settings_overhead_bytes' => (int) $profile['settings_overhead_bytes'], + 'records_bytes_total' => 0, + 'users_bytes' => 0, + 'record_counts' => [], + 'total_user_count' => 0, + ]; + + if ( ! empty( $allowed['system_users'] ) && function_exists( 'count_users' ) ) { + $totals = count_users(); + $total_users = isset( $totals['total_users'] ) ? (int) $totals['total_users'] : 0; + $breakdown['total_user_count'] = $total_users; + $users_segment = $total_users * $profile['bytes_per_user']; + $estimated += $users_segment; + $breakdown['users_bytes'] = $users_segment; + } + + $allowed_records = $allowed['records'] ?? []; + if ( ! empty( $allowed_records ) && is_array( $allowed_records ) ) { + foreach ( $allowed_records as $post_type => $enabled ) { + if ( ! $enabled ) { + continue; + } + + $opts = $record_options[ $post_type ] ?? []; + $limit = isset( $opts['limit'] ) ? absint( $opts['limit'] ) : 0; + $min_id = isset( $opts['min_id'] ) ? absint( $opts['min_id'] ) : 0; + $max_id = isset( $opts['max_id'] ) ? absint( $opts['max_id'] ) : 0; + + $n = self::count_records_for_export( $post_type, $limit, $min_id, $max_id ); + + $per_pt = max( $profile['bytes_per_record'], (int) apply_filters( + 'dt_migration_export_estimated_bytes_for_post_type_single_record', + $profile['bytes_per_record'], + $post_type, + [ 'limit' => $limit, 'min_id' => $min_id, 'max_id' => $max_id ] + ) ); + + $segment = $n * $per_pt; + $estimated += $segment; + $breakdown['records_bytes_total'] += $segment; + $breakdown['record_counts'][ $post_type ] = [ + 'count' => $n, + 'bytes_estimate' => $segment, + ]; + } + } + + $estimated = (int) apply_filters( + 'dt_migration_export_estimated_payload_bytes_heuristic', + $estimated, + $record_options, + $migration_settings + ); + + $allowed_budget = $estimated <= $budget_bytes; + + $allowed_budget = apply_filters( + 'dt_migration_export_allow_file_download_by_estimate', + $allowed_budget, + [ + 'estimated_bytes' => $estimated, + 'budget_bytes' => $budget_bytes, + 'memory_limit_bytes' => $memory_limit_bytes, + 'profile' => $profile, + 'record_options' => $record_options, + ] + ); + + return [ + 'allowed' => (bool) $allowed_budget, + 'estimated_bytes' => $estimated, + 'budget_bytes' => $budget_bytes, + 'memory_limit_bytes' => $memory_limit_bytes, + 'breakdown' => array_merge( + $breakdown, + [ + 'estimated_total' => $estimated, + 'budget_ratio_effective' => $profile['budget_ratio'], + ] + ), + ]; + } + /** * Builds settings export (mirrors REST API export logic). * diff --git a/rest-api/rest-api.php b/rest-api/rest-api.php index 00e6d2f..f1509a2 100644 --- a/rest-api/rest-api.php +++ b/rest-api/rest-api.php @@ -61,6 +61,18 @@ public function add_api_routes() { ] ); + register_rest_route( + $namespace, + '/export-file-preflight', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'export_file_preflight' ], + 'permission_callback' => function ( WP_REST_Request $request ) { + return $this->has_permission( $request ); + }, + ] + ); + register_rest_route( $namespace, '/records/(?P[a-zA-Z0-9_-]+)', @@ -303,6 +315,123 @@ public function records_preview( WP_REST_Request $request ) : WP_REST_Response { return new WP_REST_Response( $response, 200 ); } + /** + * Checks whether the downloadable JSON file export is estimated to fit in memory (matches admin download form). + * + * @param WP_REST_Request $request Body: same fields as POST to admin-post download (JSON object or application/x-www-form-urlencoded). + * + * @return WP_REST_Response + */ + public function export_file_preflight( WP_REST_Request $request ) : WP_REST_Response { + if ( ! class_exists( 'Disciple_Tools_Migration_Menu' ) || ! class_exists( 'Disciple_Tools_Migration_Export_File' ) ) { + return new WP_REST_Response( + [ + 'ok' => false, + 'message' => __( 'Migration helpers are not available.', 'disciple-tools-migration' ), + 'details' => null, + ], + 200 + ); + } + + $settings = Disciple_Tools_Migration_Menu::get_settings(); + if ( empty( $settings['enabled'] ) ) { + return new WP_REST_Response( + [ + 'ok' => false, + 'message' => __( 'Migration is not enabled.', 'disciple-tools-migration' ), + 'details' => null, + ], + 200 + ); + } + + $data = $this->parse_export_preflight_request_body( $request ); + $data = dt_recursive_sanitize_array( $data ); + + $export_by = isset( $data['dt_migration_export_by'] ) && is_array( $data['dt_migration_export_by'] ) ? $this->sanitize_post_type_assoc( $data['dt_migration_export_by'], 'sanitize_key' ) : []; + + $limits = isset( $data['dt_migration_export_limit'] ) && is_array( $data['dt_migration_export_limit'] ) ? $this->sanitize_post_type_assoc( $data['dt_migration_export_limit'], 'absint' ) : []; + $min_ids = isset( $data['dt_migration_export_min_id'] ) && is_array( $data['dt_migration_export_min_id'] ) ? $this->sanitize_post_type_assoc( $data['dt_migration_export_min_id'], 'absint' ) : []; + $max_ids = isset( $data['dt_migration_export_max_id'] ) && is_array( $data['dt_migration_export_max_id'] ) ? $this->sanitize_post_type_assoc( $data['dt_migration_export_max_id'], 'absint' ) : []; + + $allowed_records = $settings['allowed_items']['records'] ?? []; + $allowed_records = is_array( $allowed_records ) ? $allowed_records : []; + + $record_options = Disciple_Tools_Migration_Export_File::parse_download_record_options( + $allowed_records, + $export_by, + $limits, + $min_ids, + $max_ids + ); + + $evaluation = Disciple_Tools_Migration_Export_File::evaluate_file_export_memory( $record_options ); + + return new WP_REST_Response( + [ + 'ok' => ! empty( $evaluation['allowed'] ), + 'message' => empty( $evaluation['allowed'] ) + ? __( 'This downloadable export is estimated to exceed the server memory limit. Use the Import tab to connect to the target site and migrate over the API instead, or reduce what is enabled for export on the Settings tab.', 'disciple-tools-migration' ) + : '', + 'details' => $evaluation, + ], + 200 + ); + } + + /** + * Parses JSON or urlencoded body into an array of request parameters. + * + * @param WP_REST_Request $request Request. + * @return array + */ + private function parse_export_preflight_request_body( WP_REST_Request $request ) : array { + $raw = (string) $request->get_body(); + if ( $raw === '' ) { + return []; + } + + $trimmed = ltrim( $raw ); + if ( $trimmed !== '' && ( '{' === $trimmed[0] || '[' === $trimmed[0] ) ) { + $decoded = json_decode( $raw, true ); + if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) { + return $decoded; + } + } + + $parsed = []; + parse_str( $raw, $parsed ); + + return is_array( $parsed ) ? wp_unslash( $parsed ) : []; + } + + /** + * Sanitizes a request array keyed by post type (same semantics as downloadable export sanitize). + * + * @param array $assoc Raw keys keyed by post type. + * @param string $mode 'sanitize_key' or 'absint'. + * @return array + */ + private function sanitize_post_type_assoc( array $assoc, string $mode ) : array { + $out = []; + + foreach ( $assoc as $raw_key => $raw_val ) { + $key = sanitize_key( (string) $raw_key ); + if ( $key === '' ) { + continue; + } + + if ( 'absint' === $mode ) { + $out[ $key ] = absint( $raw_val ); + } else { + $out[ $key ] = sanitize_key( (string) $raw_val ); + } + } + + return $out; + } + /** * Returns post types allowed for record export based on migration settings. *