'wizard', 'shiftora_page_shiftora-connections' => 'connection', 'shiftora_page_shiftora-profiles' => 'profiles', 'shiftora_page_shiftora-backups' => 'backups', ]; /** Style handles in dependency order. Components enqueue in this order. */ private const CSS_ORDER = [ 'tokens', 'base', 'header', 'layout', 'components', 'card', 'button', 'form', 'badge', 'alert', 'table', 'progress', 'toast', 'stepper', 'log-viewer', 'connection-card', ]; /** Style handle to CSS file path, relative to SHIFTORA_DIR. */ private const CSS_FILES = [ 'tokens' => 'resources/css/tokens.css', 'base' => 'resources/css/base.css', 'header' => 'resources/css/header.css', 'layout' => 'resources/css/layout.css', 'components' => 'resources/css/components.css', 'card' => 'resources/css/card.css', 'button' => 'resources/css/button.css', 'form' => 'resources/css/form.css', 'badge' => 'resources/css/badge.css', 'alert' => 'resources/css/alert.css', 'table' => 'resources/css/table.css', 'progress' => 'resources/css/progress.css', 'toast' => 'resources/css/toast.css', 'stepper' => 'resources/css/stepper.css', 'log-viewer' => 'resources/css/log-viewer.css', 'connection-card' => 'resources/css/connection-card.css', ]; /** Enqueues page-specific scripts and styles. */ public function enqueue( string $hook ): void { $components = $this->style_components_for( $hook ); if ( [] === $components ) { return; } $this->enqueue_styles( $components ); $this->enqueue_scripts( $hook ); } /** * Keeps WordPress's "Session expired" dialog off Shiftora screens. * * A pull replaces this site's accounts as its last step, and the heartbeat would * cover the progress dialog with a login prompt at that moment. The Migration page * handles that moment itself: it signs the operator back in, or says what to do. * * @param bool $show Whether core would load the dialog. * @param \WP_Screen|null $screen The current screen. */ public function auth_check_load( bool $show, $screen ): bool { if ( is_object( $screen ) && str_contains( (string) ( $screen->id ?? '' ), 'shiftora' ) ) { return false; } return $show; } /** * Tags the body on every Shiftora admin page. * * WordPress pins #wpfooter absolutely and reserves room with bottom padding on * #wpbody-content. Shiftora zeroes that padding, so the inline rule keyed on * this class puts the footer back in flow on these pages only. */ public function body_class( string $classes ): string { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! in_array( $screen->id, self::PAGE_HOOKS, true ) ) { return $classes; } return ltrim( $classes . ' shiftora-admin' ); } /** Ordered stylesheet components for an admin hook; an unknown hook gives an empty list. */ public function style_components_for( string $hook ): array { if ( ! in_array( $hook, self::PAGE_HOOKS, true ) ) { return []; } return self::PAGE_FOUNDATIONS; } /** * Enqueues the page's CSS components in cascade order. Each sheet depends on * the previous one, so the order matches CSS_ORDER even when a page skips components. * * @param string[] $components Component slugs, ordered. */ private function enqueue_styles( array $components ): void { $previous = false; foreach ( self::CSS_ORDER as $component ) { if ( ! in_array( $component, $components, true ) ) { continue; } $handle = "shiftora-{$component}"; wp_enqueue_style( $handle, SHIFTORA_URL . self::CSS_FILES[ $component ], $previous ? [ "shiftora-{$previous}" ] : [], $this->asset_version( self::CSS_FILES[ $component ] ) ); $previous = $component; } // Remove the default WordPress left padding on Shiftora pages. if ( false !== $previous ) { wp_add_inline_style( "shiftora-{$previous}", '#wpcontent { padding-left: 0; } #wpbody-content { padding-bottom: 0; } body.shiftora-admin #wpfooter { position: static; }' ); } } /** * The REST base as a root-relative path rather than an absolute URL. * * rest_url() uses the configured home URL, which can differ from the origin the * admin is served from (another port, a rewriting proxy); an absolute URL then * makes every request cross-origin and drops the nonce. A path resolves against * the page's own origin, and still comes from rest_url() so subdirectory * installs, a renamed prefix and plain permalinks work. */ private static function rest_base_path( string $path ): string { $url = rest_url( $path ); $base_path = (string) ( wp_parse_url( $url, PHP_URL_PATH ) ?: '' ); if ( '' === $base_path ) { $base_path = '/' . ltrim( $path, '/' ); } // Plain permalinks can leave a route query on the URL; it belongs to the path or the request hits the wrong endpoint. $query = (string) ( wp_parse_url( $url, PHP_URL_QUERY ) ?: '' ); return '' !== $query ? $base_path . '?' . $query : $base_path; } /** * The payload localized as ShiftoraData. Includes the host upload limits * because the import screen chooses between one request and many before sending. * * @return array */ public function script_data(): array { return [ 'restUrl' => esc_url_raw( self::rest_base_path( 'shiftora/v1' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'adminUrl' => esc_url_raw( admin_url( 'admin.php' ) ), 'siteUrl' => esc_url_raw( get_site_url() ), 'helpPageUrl' => esc_url_raw( admin_url( 'admin.php?page=shiftora-help' ) ), // Read from the service that sends the report so the two cannot differ. 'supportEmail' => ( new DiagnosticsService() )->report_recipient(), 'version' => SHIFTORA_VERSION, 'host_upload_limits' => HostUploadLimits::from_php()->report(), 'debug' => ShiftoraDebug::enabled(), 'i18n' => $this->get_i18n_strings(), ]; } /** * Enqueues the JS for a hook. app.js publishes ShiftoraData and the shared * helpers; Alpine hydrates only when a feature module provides the x-data root. */ private function enqueue_scripts( string $hook ): void { wp_enqueue_script( 'shiftora-app', SHIFTORA_URL . 'resources/js/app.js', [], $this->asset_version( 'resources/js/app.js' ), true ); wp_localize_script( 'shiftora-app', 'ShiftoraData', $this->script_data() ); // a11y holds dialog, focus and keyboard behavior every screen needs, so it loads first, unconditionally. wp_enqueue_script( 'shiftora-a11y', SHIFTORA_URL . 'resources/js/modules/a11y.js', [ 'shiftora-app' ], $this->asset_version( 'resources/js/modules/a11y.js' ), true ); // confirm is needed by every module that guards a destructive action. wp_enqueue_script( 'shiftora-confirm', SHIFTORA_URL . 'resources/js/modules/confirm.js', [ 'shiftora-a11y' ], $this->asset_version( 'resources/js/modules/confirm.js' ), true ); $modules = $this->script_modules_for( $hook ); // maintenance only has controls on Settings. if ( in_array( $hook, self::MAINTENANCE_HOOKS, true ) ) { wp_enqueue_script( 'shiftora-maintenance', SHIFTORA_URL . 'resources/js/modules/maintenance.js', [ 'shiftora-confirm' ], $this->asset_version( 'resources/js/modules/maintenance.js' ), true ); } if ( ! $modules ) { return; } // Each module waits on the previous one so a page module can call a support module as it loads. $previous = 'shiftora-confirm'; foreach ( $modules as $module ) { $handle = "shiftora-{$module}"; wp_enqueue_script( $handle, SHIFTORA_URL . "resources/js/modules/{$module}.js", [ $previous ], $this->asset_version( "resources/js/modules/{$module}.js" ), true ); $previous = $handle; } /** * Filters the scripts Alpine waits for, so an add-on can extend a page's * component before Alpine starts it. * * @param string[] $deps Script handles, the page's last module by default. * @param string[] $modules The page's modules, e.g. [ 'wizard' ]. */ $alpine_deps = (array) apply_filters( 'shiftora_alpine_dependencies', [ $previous ], $modules ); wp_enqueue_script( 'shiftora-alpine', SHIFTORA_URL . 'resources/js/alpine.min.js', $alpine_deps, '3.14.9', true ); } /** The version to enqueue an asset with: the file's mtime to bust browser cache, falling back to the plugin version. */ public function asset_version( string $relative_path ): string { $file = @filemtime( dirname( __DIR__, 2 ) . '/' . ltrim( $relative_path, '/' ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes the file's mtime for cache busting; a missing file returns false and the plugin version is used instead. return is_int( $file ) ? (string) $file : SHIFTORA_VERSION; } /** * JS modules a page loads, in dependency order. * * @return string[] Module names without extension; empty for a page with no JS of its own. */ public function script_modules_for( string $hook ): array { $module = self::PAGE_MODULE[ $hook ] ?? null; if ( null === $module ) { return []; } return [ $module ]; } /** * The translatable strings handed to the browser. * * @return array */ public function get_i18n_strings(): array { return [ // Modules read these as `window.ShiftoraData?.i18n?. || `, so the JS fallback must match the text here. 'aFileTransferFailedCheckTheRemoteSite' => __( 'A file transfer failed. Check the remote site has enough disk space and file permissions, then retry.', 'shiftora' ), 'aNewKeyIsGeneratedAndTheOldOneStops' => __( 'A new key is generated and the old one stops working. Any site you have already given this Connection Info to will need the new one before it can connect again.', 'shiftora' ), 'aReportIsEmailedFromThisSiteToShiftora' => __(' A report is emailed from this site to Shiftora support, or saved locally when mail is unavailable. Credentials are redacted, but the report includes this site\'s hostname and its active theme and plugin list.', 'shiftora' ), 'anUnexpectedErrorOccurredCheckThe' => __( 'An unexpected error occurred. Check the migration logs for details.', 'shiftora' ), 'areYouSure' => __( 'Are you sure?', 'shiftora' ), 'backupCleanupCompleted' => __( 'Backup cleanup completed.', 'shiftora' ), 'backupCreatedSuccessfully' => __( 'Backup created successfully.', 'shiftora' ), 'backupCreationFailed' => __( 'Backup creation failed.', 'shiftora' ), 'backupDeleted' => __( 'Backup deleted.', 'shiftora' ), 'backupRestoredSuccessfully' => __( 'Backup restored successfully.', 'shiftora' ), 'cancel' => __( 'Cancel', 'shiftora' ), 'checkDiagnostics' => __( 'View diagnostics', 'shiftora' ), 'chooseADumpToUpload' => __( 'Choose a dump to upload.', 'shiftora' ), 'cleanupFailed' => __( 'Cleanup failed.', 'shiftora' ), 'clearStuckTransfers' => __( 'Clear stuck transfers?', 'shiftora' ), 'clearStuckTransfers2' => __( 'Clear stuck transfers', 'shiftora' ), 'confirmFlush' => __( 'This stops running migrations and clears stuck transfers, temporary migration tables and cached values. A migration interrupted while finalizing will restart instead of resuming. Continue?', 'shiftora' ), 'confirmReset' => __( 'This will permanently delete ALL connections, migrations, backups, and logs. This cannot be undone. Continue?', 'shiftora' ), 'connectionAdded' => __( 'Connection added.', 'shiftora' ), 'connectionUpdated' => __( 'Connection updated.', 'shiftora' ), 'couldNotAddConnection' => __( 'Could not add the connection.', 'shiftora' ), 'couldNotConnectToTheRemoteSiteCheckThe' => __( 'Could not connect to the remote site. Check the Connection Info and try again.', 'shiftora' ), 'couldNotCopyToClipboard' => __( 'Could not copy to clipboard.', 'shiftora' ), 'couldNotFetchSiteInformation' => __( 'Could not fetch site information.', 'shiftora' ), 'couldNotFetchTables' => __( 'Could not fetch tables.', 'shiftora' ), 'couldNotLoadTheSavedProfile' => __( 'Could not load the saved profile.', 'shiftora' ), 'couldNotSaveTheProfile' => __( 'Could not save the profile.', 'shiftora' ), 'databasePullCompletedRefreshingThis' => __( 'Database pull completed. Refreshing this page so WordPress can continue with the imported login session.', 'shiftora' ), 'deleteConnection' => __( 'Delete connection', 'shiftora' ), 'deleteEverything' => __( 'Delete everything', 'shiftora' ), 'deleteEverythingShiftoraHasRecorded' => __( 'Delete everything Shiftora has recorded?', 'shiftora' ), 'deleteFailed' => __( 'Delete failed.', 'shiftora' ), 'deleteFailed2' => __( 'Delete failed', 'shiftora' ), 'deleteProfile' => __( 'Delete profile', 'shiftora' ), 'deleteThisBackup' => __( 'Delete this backup?', 'shiftora' ), 'downloadStarted' => __( 'Download started!', 'shiftora' ), 'entireDatabase' => __( 'Entire database', 'shiftora' ), 'exportFailed' => __( 'Export failed.', 'shiftora' ), 'exportStartedPleaseFlushYourCacheAfter' => __( 'Export started. Please flush your cache after any restore or migration action.', 'shiftora' ), 'failedToRenameTheProfile' => __( 'Failed to rename the profile.', 'shiftora' ), 'failedToResetKey' => __( 'Failed to reset key.', 'shiftora' ), 'failedToStartMigration' => __( 'Failed to start migration.', 'shiftora' ), 'failedToUpdateSettings' => __( 'Failed to update settings.', 'shiftora' ), 'flushDone' => __( 'Cache flushed:', 'shiftora' ), 'flushFailed' => __( 'Flush failed. Please try again.', 'shiftora' ), 'flushing' => __( 'Flushing...', 'shiftora' ), 'forceRetry' => __( 'Force retry', 'shiftora' ), 'giveTheProfileAName' => __( 'Give the profile a name.', 'shiftora' ), 'importDidNotCompleteCleanly' => __( 'Import did not complete cleanly.', 'shiftora' ), 'importFailed' => __( 'Import failed.', 'shiftora' ), 'importSuccessful' => __( 'Import successful.', 'shiftora' ), 'invalidFormatCopyTheFullConnectionInfo' => __( 'Invalid format. Copy the full Connection Info from the remote site\'s Connections page: it should look like "https://example.com SECRET_KEY" with a space between the URL and the key.', 'shiftora' ), 'keepItRunning' => __( 'Keep it running', 'shiftora' ), 'localSupportFallback' => __( 'Local support fallback', 'shiftora' ), 'mediaFiles' => __( 'Media files', 'shiftora' ), 'migrationAppearsStalledNoHeartbeat' => __(' Migration appears stalled (no heartbeat or progress).', 'shiftora' ), 'migrationCompletedSuccessfullyYour' => __( 'Migration completed successfully. Your local database was updated, so WordPress may ask you to sign in again.', 'shiftora' ), 'migrationFailedButShiftoraRestoredThe' => __( 'Migration failed, but Shiftora restored the previous state from a recovery checkpoint.', 'shiftora' ), 'migrationIssue' => __( 'Migration issue', 'shiftora' ), 'migrationPaused' => __( 'Migration paused', 'shiftora' ), 'migrationPausedYouCanSafelyResumeIt' => __( 'Migration paused. You can safely resume it later.', 'shiftora' ), 'migrationPreflightCheckFailed' => __( 'Migration preflight check failed', 'shiftora' ), 'migrationResumed' => __( 'Migration resumed.', 'shiftora' ), 'migrationStopped' => __( 'Migration stopped', 'shiftora' ), 'isNotAnAdministratorThereSignIn' => __( 'is not an administrator there, so this page cannot follow the run. It finishes on its own. Sign in with an administrator account from the source site to see its result.', 'shiftora' ), 'lostContact' => __( 'Lost contact', 'shiftora' ), 'signingYouBackIn' => __( 'Signing you back in', 'shiftora' ), 'theAccountsAreNowTheSourceSites' => __( 'The accounts are now the source site’s. Signing you back in as', 'shiftora' ), 'theAccountsAreNowTheSourceSitesAnd' => __( 'The accounts are now the source site’s, and', 'shiftora' ), 'lostContactDuringPull' => __( 'This page lost contact with the site, usually because the pull just replaced the database and the site now runs the source site’s settings. Reload the page. If asked to sign in, use the source site’s admin login.', 'shiftora' ), 'migrationStoppedBeforeCompletion' => __( 'Migration stopped before completion.', 'shiftora' ), 'migrationWasStopped' => __( 'Migration was stopped.', 'shiftora' ), 'noActiveMigrationSelectedToForceStop' => __( 'No active migration selected to force stop.', 'shiftora' ), 'noAdditionalDetails' => __( 'No additional details.', 'shiftora' ), 'notIncluded' => __( 'Not included', 'shiftora' ), 'offNothingIsEmailedUnlessYouPress' => __( 'Off: nothing is emailed unless you press Report Issue above.', 'shiftora' ), 'partUploadFailed' => __( 'Part upload failed.', 'shiftora' ), 'pauseFailed' => __( 'Pause failed.', 'shiftora' ), 'pingFailed' => __( 'Ping failed', 'shiftora' ), 'pleaseFlushYourCacheAfterTheRestore' => __( 'Please flush your cache after the restore completes.', 'shiftora' ), 'profileRenamed' => __( 'Profile renamed.', 'shiftora' ), 'profileSaved' => __( 'Profile saved.', 'shiftora' ), 'profileWasForAPush' => __( 'This profile was saved for a push, which is no longer supported. To send this site somewhere, log into the destination and pull.', 'shiftora' ), 'reconnectSites' => __( 'Reconnect sites', 'shiftora' ), 'refreshNow' => __( 'Refresh Now', 'shiftora' ), 'replaceAccounts' => __( 'Replace accounts', 'shiftora' ), 'reportFailed' => __( 'Shiftora could not send the report right now. Please try again shortly.', 'shiftora' ), 'reportIssue' => __( 'Report Issue', 'shiftora' ), 'reportSavedLocal' => __( 'Shiftora saved a local copy of the report because mail is unavailable in this environment.', 'shiftora' ), 'reportSent' => __( 'Your report was sent to Shiftora support.', 'shiftora' ), 'resetDone' => __( 'Everything Shiftora stored has been deleted.', 'shiftora' ), 'resetFailed' => __( 'Reset failed. Please try again.', 'shiftora' ), 'resetKey' => __( 'Reset key', 'shiftora' ), 'resetTheSecretKey' => __( 'Reset the secret key?', 'shiftora' ), 'resetTypeWord' => __( 'Type RESET to confirm.', 'shiftora' ), 'resetting' => __( 'Resetting...', 'shiftora' ), 'restoreFailed' => __( 'Restore failed.', 'shiftora' ), 'restoreThisBackup' => __( 'Restore this backup?', 'shiftora' ), 'resumeFailed' => __( 'Resume failed.', 'shiftora' ), 'resumeMigration' => __( 'Resume migration', 'shiftora' ), 'resumeMigration2' => __( 'Resume migration', 'shiftora' ), 'retryFromStart' => __( 'Retry from start', 'shiftora' ), 'retryMigration' => __( 'Retry migration', 'shiftora' ), 'reviewConnection' => __( 'Review Connection', 'shiftora' ), 'reviewConnection2' => __( 'Review connection', 'shiftora' ), 'reviewYourSelectionBeforeStartingThe' => __( 'Review your selection before starting the migration.', 'shiftora' ), 'secretKeyRegeneratedSuccessfully' => __( 'Secret key regenerated successfully.', 'shiftora' ), 'selectAtLeastOneTableOrFileStageTo' => __( 'Select at least one table or file stage to migrate.', 'shiftora' ), 'selectAtLeastOneTableToExport' => __( 'Select at least one table to export.', 'shiftora' ), 'settingsUpdated' => __( 'Settings updated.', 'shiftora' ), 'shiftoraAdminDataIsStillLoadingPlease' => __( 'Shiftora admin data is still loading. Please refresh the page and try again.', 'shiftora' ), 'shiftoraCouldNotCompleteTheLocalSafety' => __( 'Shiftora could not complete the local safety backup for this migration. On a fresh or empty site, retrying is usually safe after reviewing diagnostics.', 'shiftora' ), 'shiftoraCouldNotSendTheReportRightNow' => __( 'Shiftora could not send the report right now.', 'shiftora' ), 'shiftoraIssueReport' => __( 'Shiftora issue report', 'shiftora' ), 'shiftoraMigrationIssue' => __( 'Shiftora migration issue', 'shiftora' ), 'shiftoraRecoveryGuidance' => __( 'Shiftora recovery guidance', 'shiftora' ), 'shiftoraWillEmailAReportWhenAMigration' => __( 'Shiftora will email a report when a migration fails critically.', 'shiftora' ), 'shiftoraWillNotEmailReports' => __( 'Shiftora will not email reports automatically.', 'shiftora' ), 'signInAgainToContinue' => __( 'Sign in again to continue', 'shiftora' ), 'statusPollingFailed' => __( 'Status polling failed.', 'shiftora' ), 'completely' => __( 'Completely', 'shiftora' ), 'flushYourCache' => __( 'Flush your cache.', 'shiftora' ), 'stopFailed' => __( 'Stop failed.', 'shiftora' ), 'stopTheMigration' => __( 'Stop the migration', 'shiftora' ), 'stopThisMigration' => __( 'Stop this migration?', 'shiftora' ), 'theBackupIsRemovedFromThisSiteAndIts' => __( 'The backup is removed from this site and its file is deleted. This cannot be undone.', 'shiftora' ), 'theConnectionDroppedPartwayThroughAn' => __( 'The connection dropped partway through an upload.', 'shiftora' ), 'theConnectionIsRemovedFromThisSite' => __( 'The connection is removed from this site, along with its migrations and the logs they produced. Migrations that already finished are not affected on the other site.', 'shiftora' ), 'theConnectionToThisSiteDroppedPartway' => __( 'The connection to this site dropped partway through.', 'shiftora' ), 'theDestinationSiteDeniedThisAction' => __( 'The destination site denied this action. Please review the connection permissions.', 'shiftora' ), 'theExportUrlWasNotReturnedByTheServer' => __( 'The export URL was not returned by the server.', 'shiftora' ), 'theMigrationEndsWhereItIsAnythingItHas' => __( 'The migration ends where it is. Anything it has already written to the destination stays there, and the tables this run was replacing are not restored, a restore point exists only on a pull, and only for the database.', 'shiftora' ), 'theMigrationFinishedAndYourLocal' => __( 'The migration finished and your local WordPress session was refreshed after the database update. Please sign in again if prompted.', 'shiftora' ), 'theMigrationWasInterruptedByABackground' => __( 'The migration was interrupted by a background task while files were still transferring. This is usually temporary, retrying the migration should resolve it.', 'shiftora' ), 'theRemoteSiteDoesNotAppearToHave' => __( 'The remote site does not appear to have Shiftora installed. Install and activate Shiftora on the remote site, then copy its Connection Info.', 'shiftora' ), 'theSavedProfileIsRemoved' => __( 'The saved profile is removed. This does not affect any migration that already ran from it.', 'shiftora' ), 'theSiteCouldNotAcceptTheUpload' => __( 'The site could not accept the upload.', 'shiftora' ), 'theUploadWasStopped' => __( 'The upload was stopped.', 'shiftora' ), 'theUrlMustStartWithHttpOrHttpsCheck' => __( 'The URL must start with http:// or https://. Check that you copied the full Connection Info string.', 'shiftora' ), 'theUsersTableNowBelongsToTheSourceSite' => __( 'The accounts on this site are now the source site\'s. Use those credentials from now on.', 'shiftora' ), 'thisPermanentlyDeletesEveryConnection' => __( 'This permanently deletes every connection, migration, backup and log on this site. It cannot be undone.', 'shiftora' ), 'thisReplacesTheAccountsOnThisSite' => __( 'This replaces the accounts on this site', 'shiftora' ), 'thisReplacesTheTablesTheBackupContains' => __( 'This replaces the tables the backup contains with the ones from when it was taken. This cannot be undone.', 'shiftora' ), 'thisStopsRunningMigrationsAndClears' => __( 'This stops running migrations and clears stuck transfers, staging tables and cached values. A migration interrupted while finalizing will restart instead of resuming.', 'shiftora' ), 'timeoutRecoverable' => __( 'The remote site took too long to respond. You can safely resume or retry.', 'shiftora' ), 'unhandledPromiseRejection' => __( 'Unhandled promise rejection', 'shiftora' ), 'unknownError' => __( 'Unknown error', 'shiftora' ), 'usingTheSavedConnectionForThis' => __( 'Using the saved connection for this migration flow.', 'shiftora' ), 'viewDiagnostics' => __( 'View Diagnostics', 'shiftora' ), 'yourActiveMigrationWasRestoredAfter' => __( 'Your active migration was restored after refresh.', 'shiftora' ), 'yourPausedMigrationIsReadyToResume' => __( 'Your paused migration is ready to resume.', 'shiftora' ), // phpcs:disable Squiz.PHP.EmbeddedPhp -- This block only holds translatable strings and executes nothing. ]; } }