id, -strlen( $suffix ) ) === $suffix; } /** in_admin_header, priority 1000: only our notices on our page; every other screen is untouched. */ public static function clean_notices(): void { if ( ! self::is_our_screen() ) { return; } foreach ( self::NOTICE_HOOKS as $hook ) { remove_all_actions( $hook ); } } /** * Our own notices (and our messages about plugins we depend on), from Mench Migrate and its add-ons. Rendered once, * inside our layout by render_page(); not re-added to admin_notices, so they never show twice. */ public static function our_notices(): void { do_action( 'mench_migrate_admin_notices' ); } public static function assets( $hook_suffix ): void { $ours = '' !== self::$hook ? self::$hook : 'toplevel_page_' . self::PAGE; if ( $ours !== $hook_suffix ) { return; } $ver = static fn( string $file ): string => MENCH_MIGRATE_VERSION . '.' . (int) @filemtime( MENCH_MIGRATE_DIR . $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors wp_enqueue_style( 'mench-migrate-admin', MENCH_MIGRATE_URL . 'assets/admin.css', [], $ver( 'assets/admin.css' ) ); wp_enqueue_script( 'mench-migrate-admin', MENCH_MIGRATE_URL . 'assets/admin.js', [], $ver( 'assets/admin.js' ), true ); // JSON keeps chunkSize a number (wp_localize_script would turn top-level scalars into strings). wp_add_inline_script( 'mench-migrate-admin', 'var MenchMigrate = ' . wp_json_encode( self::script_config() ) . ';', 'before' ); } /** window.MenchMigrate: REST root, nonce, chunk size, this site's host and every string the script shows. */ public static function script_config(): array { return [ 'root' => esc_url_raw( rest_url( Mench_Migrate_Rest::NS ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'chunkSize' => Mench_Migrate_Rest_Uploads::CHUNK, 'host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'adminUrl' => esc_url_raw( self::url() ), 'loginUrl' => esc_url_raw( wp_login_url() ), 'permalinksUrl' => esc_url_raw( admin_url( 'options-permalink.php' ) ), 'resumeJob' => self::resume_job(), 'storageLabels' => self::storage_labels(), // The export checklist's "Uploading to …" row. 'phases' => self::phases(), 'i18n' => [ 'exportTitle' => __( 'Exporting this site', 'mench-migrate' ), 'importTitle' => __( 'Importing a site', 'mench-migrate' ), 'restoreTitle' => __( 'Restoring a backup', 'mench-migrate' ), 'starting' => __( 'Starting…', 'mench-migrate' ), /* translators: %s: a duration such as "2 min". */ 'elapsed' => __( 'Elapsed %s', 'mench-migrate' ), /* translators: %s: a percentage number. */ 'percent' => __( '%s%% complete', 'mench-migrate' ), 'cancel' => __( 'Cancel', 'mench-migrate' ), 'close' => __( 'Close', 'mench-migrate' ), 'resume' => __( 'Resume', 'mench-migrate' ), /* translators: %s: seconds until the next try. */ 'retrying' => __( 'Connection lost. Retrying in %s s…', 'mench-migrate' ), 'networkFailed' => __( 'The server could not be reached. Check your connection, then choose Resume.', 'mench-migrate' ), /* translators: %s: backup name. */ 'exportDone' => __( 'Export complete: %s', 'mench-migrate' ), /* translators: %s: number of files. */ 'exportSkipped' => __( '%s files could not be read and were left out.', 'mench-migrate' ), 'download' => __( 'Download', 'mench-migrate' ), 'viewBackups' => __( 'View backups', 'mench-migrate' ), /* translators: %s: date and time the rollback copy is deleted. */ 'importDone' => __( 'Import complete. Rollback is available until %s.', 'mench-migrate' ), 'importDoneNoRb' => __( 'Import complete.', 'mench-migrate' ), 'keep' => __( 'Keep this site', 'mench-migrate' ), 'rollback' => __( 'Roll back', 'mench-migrate' ), 'kept' => __( 'The imported site is kept and the rollback copy was removed.', 'mench-migrate' ), 'rollbackSignIn' => __( 'Sign in with an administrator of the imported site to roll back.', 'mench-migrate' ), 'rolledBack' => __( 'The site was rolled back to how it was before the import.', 'mench-migrate' ), 'rollingBack' => __( 'Rolling back…', 'mench-migrate' ), 'reload' => __( 'Reload the page', 'mench-migrate' ), 'relogin' => __( 'You are still signed in, now with your account from the imported site.', 'mench-migrate' ), 'loginNotice' => __( 'Your account is not in the imported site. Sign in again with an administrator account from the imported site.', 'mench-migrate' ), 'replaced' => __( "This site has been replaced by the imported one. Sign in with the imported site's administrator account to finish.", 'mench-migrate' ), 'signIn' => __( 'Sign in', 'mench-migrate' ), 'cancelled' => __( 'The job was cancelled. Nothing on this site was changed.', 'mench-migrate' ), 'cancelledAfterSwap' => __( 'the import was stopped after it had started switching the site', 'mench-migrate' ), 'rollbackNeeded' => __( 'The import had already started switching this site, so it cannot be resumed. Roll back to put the site back as it was before the import.', 'mench-migrate' ), 'startAgain' => __( 'Start the import again and enter the archive’s password.', 'mench-migrate' ), /* translators: %s: the error message. */ 'failed' => __( 'The job stopped: %s', 'mench-migrate' ), 'reportTitle' => __( 'Compatibility check', 'mench-migrate' ), 'blocking' => __( 'Must be fixed before importing', 'mench-migrate' ), 'warnings' => __( 'Warnings', 'mench-migrate' ), 'noIssues' => __( 'No compatibility problems were found.', 'mench-migrate' ), 'understand' => __( 'I understand this will replace this site', 'mench-migrate' ), /* translators: %s: this site's host name. */ 'typeHost' => __( 'Type %s to confirm', 'mench-migrate' ), 'confirm' => __( 'Confirm and import', 'mench-migrate' ), /* translators: 1: size uploaded, 2: size in all. */ 'uploading' => __( 'Uploading %1$s of %2$s', 'mench-migrate' ), /* translators: %s: a size such as "4 MB" (per second). */ 'speed' => __( '%s/s', 'mench-migrate' ), /* translators: %s: size uploaded so far. */ 'uploadPaused' => __( 'Upload paused at %s. Choose Resume to continue.', 'mench-migrate' ), 'diskFull' => __( 'The server ran out of disk space. Free some space, then choose Resume.', 'mench-migrate' ), 'badFile' => __( 'Choose a .mmig or .wpress archive.', 'mench-migrate' ), 'uploaded' => __( 'Upload complete. Starting the import…', 'mench-migrate' ), 'loading' => __( 'Loading…', 'mench-migrate' ), 'noBackups' => __( 'No backups yet. Export this site to create the first one.', 'mench-migrate' ), 'loadFailed' => __( 'The list could not be loaded.', 'mench-migrate' ), 'encrypted' => __( 'Encrypted', 'mench-migrate' ), 'restore' => __( 'Restore', 'mench-migrate' ), 'delete' => __( 'Delete', 'mench-migrate' ), /* translators: %s: backup name. */ 'deleteConfirm' => __( 'Delete %s permanently? This cannot be undone.', 'mench-migrate' ), 'deleteYes' => __( 'Delete permanently', 'mench-migrate' ), /* translators: %s: backup name. */ 'deleted' => __( '%s was deleted.', 'mench-migrate' ), 'restoreNeedsPart' => __( 'Choose at least one part to restore.', 'mench-migrate' ), 'testing' => __( 'Testing…', 'mench-migrate' ), 'pwMismatch' => __( 'The passwords do not match.', 'mench-migrate' ), 'needInclude' => __( 'Choose at least one part of the site to export.', 'mench-migrate' ), 'phaseUpload' => __( 'Uploading', 'mench-migrate' ), 'phaseDownload' => __( 'Downloading', 'mench-migrate' ), /* translators: %s: storage name, e.g. Cloud storage. */ 'phaseUploadTo' => __( 'Uploading to %s', 'mench-migrate' ), 'stepDone' => __( 'done', 'mench-migrate' ), 'stepCurrent' => __( 'in progress', 'mench-migrate' ), 'stepPending' => __( 'not started', 'mench-migrate' ), 'stepFailed' => __( 'stopped', 'mench-migrate' ), /* translators: 1: files done, 2: files in all. */ 'countFiles' => __( 'Files %1$s of %2$s', 'mench-migrate' ), /* translators: %s: number of files found so far. */ 'countFound' => __( 'Found %s files', 'mench-migrate' ), /* translators: 1: current table, 2: tables in all. */ 'countTable' => __( 'table %1$s of %2$s', 'mench-migrate' ), /* translators: %s: number of rows. */ 'countRows' => __( '%s rows', 'mench-migrate' ), /* translators: 1: links checked, 2: links in all. */ 'countLinks' => __( 'Links %1$s of %2$s', 'mench-migrate' ), /* translators: 1: size done, 2: size in all, e.g. "312 MB of 1.2 GB". */ 'countBytes' => __( '%1$s of %2$s', 'mench-migrate' ), 'database' => __( 'Database', 'mench-migrate' ), /* translators: %s: a duration such as "2 min". */ 'eta' => __( 'about %s left', 'mench-migrate' ), /* translators: %s: seconds. */ 'etaSeconds' => __( '%s s', 'mench-migrate' ), /* translators: %s: minutes. */ 'etaMinutes' => __( '%s min', 'mench-migrate' ), /* translators: 1: hours, 2: minutes. */ 'etaHours' => __( '%1$s h %2$s min', 'mench-migrate' ), 'stepsLabel' => __( 'Steps', 'mench-migrate' ), 'downloadHint' => __( 'Your browser shows the download’s progress.', 'mench-migrate' ), 'linksTitle' => __( 'Links checked', 'mench-migrate' ), 'linksAllOk' => __( 'All links work — no need to re-save permalinks.', 'mench-migrate' ), 'linksFailed' => __( 'Some links did not respond as expected', 'mench-migrate' ), 'linksFix' => __( 'Saving the permalinks once usually fixes it: open the Permalinks settings and choose Save Changes, then check again.', 'mench-migrate' ), 'linksUnchecked' => __( 'Some links could not be checked', 'mench-migrate' ), 'linkNotChecked' => __( 'Not checked (timed out)', 'mench-migrate' ), 'linksNone' => __( 'The links could not be checked.', 'mench-migrate' ), 'linkOk' => __( 'Works', 'mench-migrate' ), 'linkBad' => __( 'Did not respond as expected', 'mench-migrate' ), 'linkUnreachable' => __( 'no answer', 'mench-migrate' ), 'openPermalinks' => __( 'Open Permalinks settings', 'mench-migrate' ), 'checkAgain' => __( 'Check again', 'mench-migrate' ), 'checking' => __( 'Checking…', 'mench-migrate' ), 'protOk' => __( 'Backups protected', 'mench-migrate' ), 'protWarn' => __( 'Check backup protection', 'mench-migrate' ), 'protUnknown' => __( 'Backup protection could not be checked', 'mench-migrate' ), 'sizeUnits' => [ __( 'B', 'mench-migrate' ), __( 'KB', 'mench-migrate' ), __( 'MB', 'mench-migrate' ), __( 'GB', 'mench-migrate' ), __( 'TB', 'mench-migrate' ) ], 'stages' => [ 'queued' => __( 'Waiting to start', 'mench-migrate' ), 'awaiting_confirm' => __( 'Waiting for your confirmation', 'mench-migrate' ), ], ], ]; } /** * Rows of the dialog's step checklist per job type, each with the stage keys (Mench_Migrate_Progress::KEYS) it * covers. The import's first row is labelled Uploading or Downloading by the script, and only shown when used. */ /** @return array Each registered storage's short label by id. */ public static function storage_labels(): array { $out = []; foreach ( Mench_Migrate_Storages::all() as $storage ) { $out[ $storage->id() ] = self::short_label( $storage->label() ); } return $out; } /** A storage label without its bracketed detail: "Cloud storage (S3-compatible)" → "Cloud storage". */ public static function short_label( string $label ): string { $short = trim( (string) preg_replace( '/\s*\([^)]*\)\s*$/', '', $label ) ); return '' !== $short ? $short : $label; } public static function phases(): array { return [ 'export' => [ [ 'id' => 'scan', 'label' => __( 'Scanning files', 'mench-migrate' ), 'keys' => [ 'init', 'scan' ] ], [ 'id' => 'db', 'label' => __( 'Database', 'mench-migrate' ), 'keys' => [ 'db' ] ], [ 'id' => 'pack', 'label' => __( 'Packing files', 'mench-migrate' ), 'keys' => [ 'write-files' ] ], [ 'id' => 'finish', 'label' => __( 'Finishing', 'mench-migrate' ), 'keys' => [ 'finalize', 'storage' ] ], ], 'import' => [ [ 'id' => 'transfer', 'label' => '', 'keys' => [ 'download' ] ], [ 'id' => 'check', 'label' => __( 'Checking', 'mench-migrate' ), 'keys' => [ 'open', 'preflight' ] ], [ 'id' => 'db', 'label' => __( 'Database', 'mench-migrate' ), 'keys' => [ 'convert', 'db-create', 'db-rows' ] ], [ 'id' => 'files', 'label' => __( 'Files', 'mench-migrate' ), 'keys' => [ 'files' ] ], [ 'id' => 'swap', 'label' => __( 'Switching', 'mench-migrate' ), 'keys' => [ 'swap' ] ], [ 'id' => 'finish', 'label' => __( 'Finishing', 'mench-migrate' ), 'keys' => [ 'finalize', 'verify-links' ] ], ], ]; } /** The ?job= of the Import tab after signing in again: a well-formed job id, or ''. admin.js resumes it while it runs. */ public static function resume_job(): string { $id = isset( $_GET['job'] ) && is_string( $_GET['job'] ) ? wp_unslash( $_GET['job'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput -- validated by the regex. return 'import' === self::current_tab() && preg_match( '/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/', $id ) ? $id : ''; } /** "Manage licence" target: the add-on's own -license page when it registered one, otherwise Plugins. */ public static function licence_url( string $addon_id ): string { global $menu, $submenu; $page = sanitize_key( $addon_id ) . '-license'; $seen = []; foreach ( (array) $menu as $item ) { $seen[] = $item[2] ?? ''; } foreach ( (array) $submenu as $items ) { foreach ( (array) $items as $item ) { $seen[] = $item[2] ?? ''; } } return in_array( $page, $seen, true ) ? add_query_arg( 'page', $page, admin_url( 'admin.php' ) ) : admin_url( 'plugins.php' ); } public static function url( string $tab = '' ): string { return add_query_arg( array_filter( [ 'page' => self::PAGE, 'tab' => $tab ] ), admin_url( 'admin.php' ) ); } /** The current tab: '' (dashboard) or a registered tab slug. */ public static function current_tab(): string { $tab = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification return array_key_exists( $tab, Mench_Migrate_Plugin::admin_tabs() ) ? $tab : ''; } /** Settings with defaults: keep_rollback_hours (int ≥ 1, default 24), delete_backups_on_uninstall (bool). */ public static function settings(): array { return self::sanitize_settings( (array) get_option( self::SETTINGS, [] ) ); } public static function sanitize_settings( $input ): array { $input = is_array( $input ) ? $input : []; $raw = $input['keep_rollback_hours'] ?? null; $hours = is_scalar( $raw ) && is_numeric( trim( (string) $raw ) ) ? (int) $raw : 24; // Anything non-numeric keeps the default. return [ 'keep_rollback_hours' => max( 1, min( 720, $hours ) ), 'delete_backups_on_uninstall' => ! empty( $input['delete_backups_on_uninstall'] ), ]; } /** admin-post.php?action=mench_migrate_save_settings. */ public static function save_settings(): void { if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You are not allowed to change these settings.', 'mench-migrate' ), 403 ); } check_admin_referer( 'mench_migrate_save_settings' ); $raw = isset( $_POST[ self::SETTINGS ] ) ? wp_unslash( $_POST[ self::SETTINGS ] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitize_settings() below. update_option( self::SETTINGS, self::sanitize_settings( $raw ), false ); wp_safe_redirect( add_query_arg( 'updated', '1', self::url( 'settings' ) ) ); exit; } /** The page: workspace header, tabs, then the current tab's view. */ public static function render_page(): void { if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You are not allowed to use Mench Migrate.', 'mench-migrate' ), 403 ); } $tab = self::current_tab(); $tabs = Mench_Migrate_Plugin::admin_tabs(); $protected = Mench_Migrate_Paths::web_protected(); ?>


$protected ] ); } elseif ( in_array( $tab, $core, true ) ) { self::view( $tab ); } else { do_action( 'mench_migrate_admin_tab_' . $tab ); // An add-on's own tab. } self::dialog(); ?>
.php with $args extracted as $view. */ private static function view( string $name, array $view = [] ): void { include MENCH_MIGRATE_DIR . 'includes/admin/views/' . $name . '.php'; } /** The progress dialog every flow shares; admin.js fills it. */ private static function dialog(): void { ?> = 1000 && $u < count( $units ) - 1 ) { $n /= 1000; ++$u; } return number_format_i18n( $u ? $n : round( $n ), $u && $n < 10 ? 1 : 0 ) . ' ' . $units[ $u ]; } /** Local backups (cheap: one folder listing). */ public static function local_backups(): array { try { return ( new Mench_Migrate_Storage_Local() )->list(); } catch ( Throwable $e ) { return []; } } }