diff --git a/apps/cli/lib/dependency-management/paths.ts b/apps/cli/lib/dependency-management/paths.ts index f128e7363b..edfc96b015 100644 --- a/apps/cli/lib/dependency-management/paths.ts +++ b/apps/cli/lib/dependency-management/paths.ts @@ -84,3 +84,23 @@ export function getPhpMyAdminPath(): string { export function getBlueprintsPharPath(): string { return path.join( getWpFilesPath(), 'blueprints', 'blueprints.phar' ); } + +// Studio's own PHP helper scripts ship read-only with the CLI bundle under +// `dist/cli/php` (copied from `apps/cli/php` at build time by the `write-dist-extras` +// vite plugin). `import.meta.dirname` resolves to the bundle output dir. +function getBundledPhpPath(): string { + return path.join( import.meta.dirname, 'php' ); +} + +// PHP driver that imports a WordPress export (WXR) file via the wordpress-importer plugin. +export function getBundledWxrImportScriptPath(): string { + return path.join( getBundledPhpPath(), 'import-wxr.php' ); +} + +// The official wordpress-importer plugin, downloaded into `wp-files/` at install time via the +// `FILES_TO_DOWNLOAD` registry in `scripts/download-wp-server-files.ts` and shipped in the CLI +// bundle. Installed into the site's `wp-content/plugins` before running a WXR import so the +// import works offline. +export function getBundledWordPressImporterPath(): string { + return path.join( getWpFilesPath(), 'wordpress-importer' ); +} diff --git a/apps/cli/lib/import-export/import/handlers/backup-handler-factory.ts b/apps/cli/lib/import-export/import/handlers/backup-handler-factory.ts index de570b76a0..7308ae675c 100644 --- a/apps/cli/lib/import-export/import/handlers/backup-handler-factory.ts +++ b/apps/cli/lib/import-export/import/handlers/backup-handler-factory.ts @@ -2,6 +2,7 @@ import { BackupArchiveInfo } from '../types'; import { BackupHandlerSql } from './backup-handler-sql'; import { BackupHandlerTarGz } from './backup-handler-tar-gz'; import { BackupHandlerWpress } from './backup-handler-wpress'; +import { BackupHandlerXml } from './backup-handler-xml'; import { BackupHandlerZip } from './backup-handler-zip'; import type { ImportExportEventEmitter } from '../../events'; @@ -47,6 +48,9 @@ export class BackupHandlerFactory { ]; private static sqlExtensions = [ '.sql' ]; + private static xmlTypes = [ 'application/xml', 'text/xml' ]; + private static xmlExtensions = [ '.xml' ]; + static create( file: BackupArchiveInfo ): BackupHandler | undefined { if ( this.isZip( file ) ) { return new BackupHandlerZip(); @@ -54,6 +58,8 @@ export class BackupHandlerFactory { return new BackupHandlerTarGz(); } else if ( this.isSql( file ) ) { return new BackupHandlerSql(); + } else if ( this.isXml( file ) ) { + return new BackupHandlerXml(); } else if ( this.isWpress( file ) ) { return new BackupHandlerWpress(); } @@ -80,6 +86,13 @@ export class BackupHandlerFactory { ); } + private static isXml( file: BackupArchiveInfo ): boolean { + return ( + ( this.xmlTypes.includes( file.type ) || ! file.type ) && + this.xmlExtensions.some( ( ext ) => file.path.endsWith( ext ) ) + ); + } + private static isWpress( file: BackupArchiveInfo ): boolean { return file.path.endsWith( '.wpress' ); } diff --git a/apps/cli/lib/import-export/import/handlers/backup-handler-xml.ts b/apps/cli/lib/import-export/import/handlers/backup-handler-xml.ts new file mode 100644 index 0000000000..9e1cad0285 --- /dev/null +++ b/apps/cli/lib/import-export/import/handlers/backup-handler-xml.ts @@ -0,0 +1,41 @@ +import fs from 'fs'; +import path from 'path'; +import { ImportEvents } from '@studio/common/lib/import-export-events'; +import { ImportExportEventEmitter } from '../../events'; +import { BackupHandler } from '../handlers/backup-handler-factory'; +import { BackupArchiveInfo } from '../types'; + +export class BackupHandlerXml extends ImportExportEventEmitter implements BackupHandler { + async listFiles( backup: BackupArchiveInfo ): Promise< string[] > { + return [ path.basename( backup.path ) ]; + } + + async extractFiles( file: BackupArchiveInfo, extractionDirectory: string ): Promise< void > { + const fileName = path.basename( file.path ); + const destPath = path.join( extractionDirectory, fileName ); + + this.emit( ImportEvents.BACKUP_EXTRACT_START ); + + this.emit( ImportEvents.BACKUP_EXTRACT_FILE_START, { + progress: 0, + processedFiles: 0, + totalFiles: 1, + currentFile: fileName, + } ); + + await fs.promises.copyFile( file.path, destPath ); + + this.emit( ImportEvents.BACKUP_EXTRACT_PROGRESS, { + progress: 100, + processedFiles: 1, + totalFiles: 1, + currentFile: fileName, + } ); + + this.emit( ImportEvents.BACKUP_EXTRACT_COMPLETE, { + progress: 100, + processedFiles: 1, + totalFiles: 1, + } ); + } +} diff --git a/apps/cli/lib/import-export/import/handlers/tests/backup-handler-factory.test.ts b/apps/cli/lib/import-export/import/handlers/tests/backup-handler-factory.test.ts new file mode 100644 index 0000000000..e78e2dd1ec --- /dev/null +++ b/apps/cli/lib/import-export/import/handlers/tests/backup-handler-factory.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { BackupHandlerFactory } from '../backup-handler-factory'; +import { BackupHandlerSql } from '../backup-handler-sql'; +import { BackupHandlerXml } from '../backup-handler-xml'; + +describe( 'BackupHandlerFactory', () => { + it( 'creates a BackupHandlerXml for an .xml file with an xml mime type', () => { + const handler = BackupHandlerFactory.create( { + path: '/tmp/export.xml', + type: 'application/xml', + } ); + expect( handler ).toBeInstanceOf( BackupHandlerXml ); + } ); + + it( 'creates a BackupHandlerXml for an .xml file with an empty mime type', () => { + const handler = BackupHandlerFactory.create( { path: '/tmp/export.xml', type: '' } ); + expect( handler ).toBeInstanceOf( BackupHandlerXml ); + } ); + + it( 'does not create a BackupHandlerXml for a .sql file', () => { + const handler = BackupHandlerFactory.create( { path: '/tmp/backup.sql', type: '' } ); + expect( handler ).toBeInstanceOf( BackupHandlerSql ); + } ); + + it( 'returns undefined for an unsupported file', () => { + expect( + BackupHandlerFactory.create( { path: '/tmp/notes.txt', type: 'text/plain' } ) + ).toBeUndefined(); + } ); +} ); diff --git a/apps/cli/lib/import-export/import/import-manager.ts b/apps/cli/lib/import-export/import/import-manager.ts index 351b92110f..958fa78165 100644 --- a/apps/cli/lib/import-export/import/import-manager.ts +++ b/apps/cli/lib/import-export/import/import-manager.ts @@ -19,6 +19,7 @@ import { SQLImporter, WpressImporter, } from './importers/importer'; +import { WxrImporter } from './importers/wxr-importer'; import { BackupArchiveInfo, NewImporter } from './types'; import { JetpackValidator } from './validators/jetpack-validator'; import { LocalValidator } from './validators/local-validator'; @@ -26,6 +27,7 @@ import { PlaygroundValidator } from './validators/playground-validator'; import { SqlValidator } from './validators/sql-validator'; import { Validator } from './validators/validator'; import { WpressValidator } from './validators/wpress-validator'; +import { XmlValidator } from './validators/xml-validator'; interface ImporterOption { validator: Validator; @@ -100,5 +102,6 @@ export const DEFAULT_IMPORTER_OPTIONS: ImporterOption[] = [ { validator: new JetpackValidator(), importer: JetpackImporter }, { validator: new LocalValidator(), importer: LocalImporter }, { validator: new SqlValidator(), importer: SQLImporter }, + { validator: new XmlValidator(), importer: WxrImporter }, { validator: new WpressValidator(), importer: WpressImporter }, ]; diff --git a/apps/cli/lib/import-export/import/importers/wxr-importer.ts b/apps/cli/lib/import-export/import/importers/wxr-importer.ts new file mode 100644 index 0000000000..dbda6952d5 --- /dev/null +++ b/apps/cli/lib/import-export/import/importers/wxr-importer.ts @@ -0,0 +1,124 @@ +import fs from 'fs'; +import path from 'path'; +import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; +import { ImportEvents } from '@studio/common/lib/import-export-events'; +import { __, sprintf } from '@wordpress/i18n'; +import { SiteData } from 'cli/lib/cli-config/core'; +import { + getBundledWordPressImporterPath, + getBundledWxrImportScriptPath, +} from 'cli/lib/dependency-management/paths'; +import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; +import { ImportExportEventEmitter } from '../../events'; +import { BackupContents } from '../types'; +import { ensureDir, Importer, ImporterResult } from './importer'; + +// Files staged into the site for the import are placed under this directory (at the +// site root) so they don't collide with real site content and are easy to clean up. +const IMPORT_STAGING_SUBDIR = '.studio-wxr-import'; + +// The wordpress-importer plugin ships in the CLI bundle (downloaded at build time, see +// `getBundledWordPressImporterPath`) and is installed here so the WXR import works offline +// (no wordpress.org fetch). It is loaded directly by the PHP driver, so no `wp plugin +// activate` step is required. +const WORDPRESS_IMPORTER_PLUGIN_SLUG = 'wordpress-importer'; + +// WordPress export (WXR) importer. Unlike the full-site backup importers it does not +// replace wp-content or the database — it merges the exported content (posts, pages, +// terms, authors, media) into the existing install via the wordpress-importer plugin, +// mirroring the WordPress dashboard's Tools → Import → WordPress flow. +export class WxrImporter extends ImportExportEventEmitter implements Importer { + constructor( protected backup: BackupContents ) { + super(); + } + + async import( site: SiteData ): Promise< ImporterResult > { + this.emit( ImportEvents.IMPORT_START, 'xml' ); + + const wxrFile = this.backup.wxrFiles?.[ 0 ]; + if ( ! wxrFile ) { + const error = new Error( __( 'No WordPress export (.xml) file found to import.' ) ); + this.emit( ImportEvents.IMPORT_ERROR, error.message ); + throw error; + } + + const stagingDir = path.join( site.path, IMPORT_STAGING_SUBDIR ); + const stagedWxrName = 'import.xml'; + const stagedScriptName = 'import-wxr.php'; + + try { + await this.ensureWordPressImporterPlugin( site ); + + // Studio's wp-cli can't `eval-file` arbitrary host paths — the file must live + // inside the site VFS (mounted at /wordpress). Stage the WXR and the driver + // script under the site root and reference them with paths relative to it. + await ensureDir( stagingDir ); + await fs.promises.copyFile( wxrFile, path.join( stagingDir, stagedWxrName ) ); + await fs.promises.copyFile( + getBundledWxrImportScriptPath(), + path.join( stagingDir, stagedScriptName ) + ); + + const scriptRelPath = `${ IMPORT_STAGING_SUBDIR }/${ stagedScriptName }`; + const wxrRelPath = `${ IMPORT_STAGING_SUBDIR }/${ stagedWxrName }`; + + this.emit( ImportEvents.IMPORT_DATABASE_START ); + + await using command = await runWpCliCommand( + site, + [ + `--skip-plugins=${ WORDPRESS_IMPORTER_PLUGIN_SLUG }`, + 'eval-file', + scriptRelPath, + wxrRelPath, + ], + { phpVersion: DEFAULT_PHP_VERSION } + ); + + const exitCode = await command.response.exitCode; + const stderr = await command.response.stderrText; + + if ( stderr ) { + console.error( __( 'Error during WordPress export import:' ), stderr ); + } + if ( exitCode !== 0 ) { + throw new Error( sprintf( __( 'WordPress export import failed: %s' ), stderr ) ); + } + + this.emit( ImportEvents.IMPORT_DATABASE_COMPLETE ); + + // Note: no site-URL rewrite here. Unlike the SQL/backup importers (which + // replace the whole database with the source site's URL), a WXR merge leaves + // the target site's URL unchanged, and the importer has no knowledge of the + // source site's URL. Internal links inside imported posts therefore keep + // pointing at the source — matching the WordPress dashboard importer. + + this.emit( ImportEvents.IMPORT_COMPLETE, 'xml' ); + return { + extractionDirectory: this.backup.extractionDirectory, + sqlFiles: this.backup.sqlFiles, + wpConfig: this.backup.wpConfig, + wpContentFiles: this.backup.wpContentFiles, + wpContentDirectory: this.backup.wpContentDirectory, + }; + } catch ( error ) { + this.emit( + ImportEvents.IMPORT_ERROR, + error instanceof Error ? error.message : String( error ) + ); + throw error; + } finally { + await fs.promises.rm( stagingDir, { recursive: true, force: true } ).catch( () => undefined ); + } + } + + // Install the vendored wordpress-importer plugin into the site's plugins directory + // so it's available offline. Overwrites any existing copy to keep it up to date. + protected async ensureWordPressImporterPlugin( site: SiteData ): Promise< void > { + const pluginsDir = path.join( site.path, 'wp-content', 'plugins' ); + const destDir = path.join( pluginsDir, WORDPRESS_IMPORTER_PLUGIN_SLUG ); + await ensureDir( pluginsDir ); + await fs.promises.rm( destDir, { recursive: true, force: true } ); + await fs.promises.cp( getBundledWordPressImporterPath(), destDir, { recursive: true } ); + } +} diff --git a/apps/cli/lib/import-export/import/tests/import-manager.test.ts b/apps/cli/lib/import-export/import/tests/import-manager.test.ts new file mode 100644 index 0000000000..00c10db0e1 --- /dev/null +++ b/apps/cli/lib/import-export/import/tests/import-manager.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_IMPORTER_OPTIONS } from '../import-manager'; +import { WxrImporter } from '../importers/wxr-importer'; + +// Mirrors the private `selectImporter` logic: the first validator whose +// `canHandle` returns true wins. +function selectImporterClass( fileList: string[] ) { + for ( const { validator, importer } of DEFAULT_IMPORTER_OPTIONS ) { + if ( validator.canHandle( fileList ) ) { + return importer; + } + } + return null; +} + +describe( 'DEFAULT_IMPORTER_OPTIONS', () => { + it( 'selects the WxrImporter for a lone .xml file', () => { + expect( selectImporterClass( [ 'export.xml' ] ) ).toBe( WxrImporter ); + } ); + + it( 'does not select the WxrImporter for a .sql file', () => { + expect( selectImporterClass( [ 'backup.sql' ] ) ).not.toBe( WxrImporter ); + } ); +} ); diff --git a/apps/cli/lib/import-export/import/types.ts b/apps/cli/lib/import-export/import/types.ts index 30e460df2b..ea75219219 100644 --- a/apps/cli/lib/import-export/import/types.ts +++ b/apps/cli/lib/import-export/import/types.ts @@ -14,6 +14,9 @@ export interface BackupContents { wpContentFiles: string[]; wpContentDirectory: string; metaFile?: string; + // WordPress export (WXR) files, i.e. the `.xml` produced by Tools → Export. + // Imported via the wordpress-importer plugin rather than a database import. + wxrFiles?: string[]; } export interface BackupArchiveInfo { diff --git a/apps/cli/lib/import-export/import/validators/tests/xml-validator.test.ts b/apps/cli/lib/import-export/import/validators/tests/xml-validator.test.ts new file mode 100644 index 0000000000..cd285bdb25 --- /dev/null +++ b/apps/cli/lib/import-export/import/validators/tests/xml-validator.test.ts @@ -0,0 +1,41 @@ +import path from 'path'; +import { describe, it, expect } from 'vitest'; +import { XmlValidator } from '../xml-validator'; + +describe( 'XmlValidator', () => { + const validator = new XmlValidator(); + + describe( 'canHandle', () => { + it( 'returns true for a single .xml file', () => { + expect( validator.canHandle( [ 'export.xml' ] ) ).toBe( true ); + } ); + + it( 'is case-insensitive on the extension', () => { + expect( validator.canHandle( [ 'Export.XML' ] ) ).toBe( true ); + } ); + + it( 'returns false for a single .sql file', () => { + expect( validator.canHandle( [ 'backup.sql' ] ) ).toBe( false ); + } ); + + it( 'returns false for multiple files even if one is .xml', () => { + expect( validator.canHandle( [ 'export.xml', 'other.xml' ] ) ).toBe( false ); + expect( validator.canHandle( [ 'export.xml', 'readme.txt' ] ) ).toBe( false ); + } ); + + it( 'returns false for an empty file list', () => { + expect( validator.canHandle( [] ) ).toBe( false ); + } ); + } ); + + describe( 'parseBackupContents', () => { + it( 'records the .xml file under wxrFiles with an absolute path', () => { + const extractionDirectory = path.join( 'tmp', 'extract' ); + const result = validator.parseBackupContents( [ 'export.xml' ], extractionDirectory ); + expect( result.wxrFiles ).toEqual( [ path.join( extractionDirectory, 'export.xml' ) ] ); + expect( result.sqlFiles ).toEqual( [] ); + expect( result.wpContentFiles ).toEqual( [] ); + expect( result.extractionDirectory ).toBe( extractionDirectory ); + } ); + } ); +} ); diff --git a/apps/cli/lib/import-export/import/validators/xml-validator.ts b/apps/cli/lib/import-export/import/validators/xml-validator.ts new file mode 100644 index 0000000000..19218c43a3 --- /dev/null +++ b/apps/cli/lib/import-export/import/validators/xml-validator.ts @@ -0,0 +1,28 @@ +import path from 'path'; +import { ImportExportEventEmitter } from '../../events'; +import { BackupContents } from '../types'; +import { Validator } from './validator'; + +export class XmlValidator extends ImportExportEventEmitter implements Validator { + canHandle( fileList: string[] ): boolean { + return fileList.length === 1 && fileList[ 0 ].toLowerCase().endsWith( '.xml' ); + } + + parseBackupContents( fileList: string[], extractionDirectory: string ): BackupContents { + const extractedBackup: BackupContents = { + extractionDirectory, + sqlFiles: [], + wpConfig: '', + wpContentFiles: [], + wpContentDirectory: '', + wxrFiles: [], + }; + + for ( const file of fileList ) { + if ( file.toLowerCase().endsWith( '.xml' ) ) { + extractedBackup.wxrFiles?.push( path.join( extractionDirectory, file ) ); + } + } + return extractedBackup; + } +} diff --git a/apps/cli/lib/import-export/utils.ts b/apps/cli/lib/import-export/utils.ts index d255430d80..4dd367ce4d 100644 --- a/apps/cli/lib/import-export/utils.ts +++ b/apps/cli/lib/import-export/utils.ts @@ -13,5 +13,9 @@ export function getBackupFileType( importFile: string ): string { return 'application/sql'; } + if ( normalizedPath.endsWith( '.xml' ) ) { + return 'application/xml'; + } + return ''; } diff --git a/apps/cli/php/import-wxr.php b/apps/cli/php/import-wxr.php new file mode 100644 index 0000000000..49b05550b7 --- /dev/null +++ b/apps/cli/php/import-wxr.php @@ -0,0 +1,119 @@ + + * + * Must be run via WP-CLI. Will not execute in a web context. + * + * @package Studio + */ + +if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { + return; +} + +$wxr_path = isset( $args[0] ) ? $args[0] : ''; + +if ( empty( $wxr_path ) || ! file_exists( $wxr_path ) ) { + WP_CLI::error( "WXR not found: $wxr_path" ); +} + +define( 'WP_LOAD_IMPORTERS', true ); +require_once ABSPATH . 'wp-admin/includes/admin.php'; + +// This script is expected to be invoked with `--skip-plugins=wordpress-importer` +// (see wxr-importer.ts). That prevents WP-CLI's bootstrap from loading the plugin +// before we've defined WP_LOAD_IMPORTERS — which would leave the class undefined +// AND cache the file in require_once. With the plugin skipped, this require_once is +// the first load and everything wires up. +if ( ! class_exists( 'WP_Import' ) ) { + $candidates = array( + WP_PLUGIN_DIR . '/wordpress-importer/src/wordpress-importer.php', + WP_PLUGIN_DIR . '/wordpress-importer/wordpress-importer.php', + ); + $loaded = false; + foreach ( $candidates as $candidate ) { + if ( file_exists( $candidate ) ) { + require_once $candidate; + $loaded = true; + break; + } + } + if ( ! $loaded ) { + WP_CLI::error( 'wordpress-importer plugin files not found under ' . WP_PLUGIN_DIR ); + } +} +if ( ! class_exists( 'WP_Import' ) ) { + WP_CLI::error( 'WP_Import class still not defined after loading the plugin. Did you remember to pass --skip-plugins=wordpress-importer on the wp-cli invocation?' ); +} + +// Minimum boilerplate to drive wordpress-importer headlessly — mirrors what the +// plugin's own CLI path does. +kses_remove_filters(); +$admins = get_users( array( 'role' => 'Administrator' ) ); +if ( ! empty( $admins ) ) { + wp_set_current_user( $admins[0]->ID ); +} + +$wp_import = new WP_Import(); +$wp_import->fetch_attachments = true; + +// Skip intermediate image-size (thumbnail) generation during import. WP_Import +// runs wp_generate_attachment_metadata per attachment, which regenerates every +// registered size — for media-heavy sites (100s of images) this blows Studio's +// 120s `start-server` IPC silence window before the import can finish. The +// full-size image is imported regardless; thumbnails can be regenerated later +// via `wp media regenerate` if needed. +add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' ); + +// Heartbeat. The import below is wrapped in ob_start(), so WP_Import's own +// per-item progress echo is captured into the buffer and never reaches the +// WP-CLI channel until import() returns. A media-heavy WXR (100s of attachments) +// then runs SILENTLY for well over Studio's 120s `start-server` IPC silence +// window, so the daemon kills the wp-cli call mid-import. WP_CLI::log writes to +// the STDOUT *handle*, which ob_start does NOT capture — so emitting one per N +// imported items keeps the channel active without polluting the captured output. +$dla_progress = 0; +$dla_heartbeat = static function () use ( &$dla_progress ) { + $dla_progress++; + if ( 0 === $dla_progress % 5 ) { + WP_CLI::log( sprintf( ' …imported %d items', $dla_progress ) ); + } +}; +add_action( 'add_attachment', $dla_heartbeat ); +add_action( 'wp_import_insert_post', $dla_heartbeat ); +add_action( 'wp_import_insert_term', $dla_heartbeat ); + +$_GET = array( + 'import' => 'wordpress', + 'step' => 2, +); +$_POST = array( + 'imported_authors' => array(), + 'user_map' => array(), + 'fetch_attachments' => true, +); + +WP_CLI::log( "Importing $wxr_path" ); + +ob_start(); +$wp_import->import( $wxr_path ); +$import_output = ob_get_clean(); +WP_CLI::log( $import_output ); + +WP_CLI::success( 'WXR import complete.' ); diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index 8ed14fec6c..139d47fa42 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -199,7 +199,7 @@ const ImportSite = ( { if ( ! isValidImportFile( file ) ) { setFileError( __( - 'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, .wpress, or .sql file.' + 'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, .wpress, .sql, or .xml file.' ) ); return; @@ -257,7 +257,7 @@ const ImportSite = ( {
{ createInterpolateElement( __( - 'Import a Jetpack backup, a full-site backup in another format, or a .sql database file. ' + 'Import a Jetpack backup, a full-site backup in another format, a .sql database file, or a WordPress export (.xml) file. ' ), { learn_more_link: , diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 2439eb6d1b..91b66c974c 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -240,8 +240,13 @@ export function useAddSite() { if ( formValues.useCustomDomain && ! formValues.customDomain ) { usedCustomDomain = generateCustomDomainFromSiteName( formValues.siteName ); } - // For import/sync workflows, the respective handlers will start the server - const shouldSkipStart = !! fileForImport || !! selectedRemoteSite; + // For import/sync workflows, the respective handlers will start the server. + // Exception: a WordPress export (.xml / WXR) is merged into an existing + // install via the wordpress-importer plugin, so WordPress must already be + // installed and configured before the import runs. Start the server during + // creation in that case so `wp-config.php` and the database exist first. + const isWxrImport = !! fileForImport && fileForImport.name.toLowerCase().endsWith( '.xml' ); + const shouldSkipStart = ( !! fileForImport && ! isWxrImport ) || !! selectedRemoteSite; const enableHttps = formValues.useCustomDomain ? formValues.enableHttps : false; let updatedBlueprint: Blueprint | undefined; diff --git a/apps/studio/src/modules/add-site/components/import-backup.tsx b/apps/studio/src/modules/add-site/components/import-backup.tsx index 1675dc6ed1..f1862a09c5 100644 --- a/apps/studio/src/modules/add-site/components/import-backup.tsx +++ b/apps/studio/src/modules/add-site/components/import-backup.tsx @@ -97,7 +97,7 @@ export default function ImportBackup( { } else { setFileError( __( - 'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, or .wpress file.' + 'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, .wpress, or .xml file.' ) ); } @@ -193,7 +193,7 @@ export default function ImportBackup( { <> { createInterpolateElement( __( - 'Import a Jetpack backup or a full-site backup in another format. ' + 'Import a Jetpack backup, a full-site backup in another format, or a WordPress export (.xml) file. ' ), { learn_more_link: , diff --git a/packages/common/constants.ts b/packages/common/constants.ts index 7b7aa7eb19..df269e184d 100644 --- a/packages/common/constants.ts +++ b/packages/common/constants.ts @@ -43,7 +43,15 @@ export const PRESSABLE_PHP_VERSION: typeof PressablePHPVersion = PressablePHPVer export const SQLITE_FILENAME = 'sqlite-database-integration' as const; // Import file constants -export const ACCEPTED_IMPORT_FILE_TYPES = [ '.zip', '.gz', '.gzip', '.tar', '.tar.gz', '.wpress' ]; +export const ACCEPTED_IMPORT_FILE_TYPES = [ + '.zip', + '.gz', + '.gzip', + '.tar', + '.tar.gz', + '.wpress', + '.xml', +]; // Archiver options export const ARCHIVER_OPTIONS = { diff --git a/packages/common/lib/import-export-events.ts b/packages/common/lib/import-export-events.ts index 04ab8107f9..23763c6df7 100644 --- a/packages/common/lib/import-export-events.ts +++ b/packages/common/lib/import-export-events.ts @@ -42,6 +42,7 @@ const importerTypeSchema = z.union( [ z.literal( 'playground' ), z.literal( 'sql' ), z.literal( 'wpress' ), + z.literal( 'xml' ), ] ); export const backupExtractProgressEventDataSchema = z.object( { diff --git a/scripts/download-wp-server-files.ts b/scripts/download-wp-server-files.ts index fa9a8ec7fb..dcf85f91dd 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -24,6 +24,9 @@ async function fetchWithRetry( name: string, url: string ): Promise< Buffer > { } const WP_SERVER_FILES_PATH = path.join( import.meta.dirname, '..', 'wp-files' ); + +// Pinned so builds are reproducible. Bump deliberately. +const WORDPRESS_IMPORTER_VERSION = '0.9.5'; const PHPMYADMIN_PATCH_FILES_PATH = path.join( import.meta.dirname, '..', 'apps', 'cli', 'php' ); const PHPMYADMIN_LOCAL_PATCH_FILES = new Map< string, string >( [ [ 'config.inc.php', path.join( PHPMYADMIN_PATCH_FILES_PATH, 'config.inc.php' ) ], @@ -116,6 +119,13 @@ const FILES_TO_DOWNLOAD: FileToDownload[] = [ getUrl: () => PHPMYADMIN_DOWNLOAD_URL, destinationPath: path.join( WP_SERVER_FILES_PATH, 'phpmyadmin' ), }, + { + name: 'wordpress-importer', + description: `wordpress-importer ${ WORDPRESS_IMPORTER_VERSION }`, + getUrl: () => + `https://downloads.wordpress.org/plugin/wordpress-importer.${ WORDPRESS_IMPORTER_VERSION }.zip`, + destinationPath: WP_SERVER_FILES_PATH, + }, { name: 'reprint', description: `reprint.phar`,