Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The Studio CLI allows you to import and export local backups.

When exporting, choose either a full-site backup as a `.zip` or `.tar.gz` file, or a database-only backup as a `.sql` file.

For imports, backup files from your WordPress.com site or from Jetpack’s Activity Log page are supported. So are `.wpress` files and `.zip` files from WordPress Playground or Local. For more details, see the [documentation](https://developer.wordpress.com/docs/developer-tools/studio/import-export/).
For imports, backup files from your WordPress.com site or from Jetpack’s Activity Log page are supported. So are `.wpress` files and `.zip` files from WordPress Playground or Local, and WordPress export (WXR) `.xml` files produced by **Tools → Export**. For more details, see the [documentation](https://developer.wordpress.com/docs/developer-tools/studio/import-export/).

```bash
studio export --path ~/Studio/my-site
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ For long CSS or page-content files (>~200 lines), load the \`block-content\` ski
- site_connected_remote_sites: List the durable WordPress.com remote sites (production/staging) already attached to a local site for syncing. These are distinct from temporary preview sites (preview_list). Call this before site_push to decide how to ask the user which remote site to target.
- site_push: Push a local site to a WordPress.com site. Requires authentication (studio auth login). Specify the remote site URL or ID and sync options (all, sqls, uploads, plugins, themes, contents).
- site_pull: Pull a WordPress.com site to a local site. Requires authentication. Specify the remote site URL or ID and sync options.
- site_import: Import a backup file (.zip, .tar.gz, .sql, .wpress) into a local site.
- site_import: Import a backup file (.zip, .tar.gz, .sql, .wpress, .xml WordPress export) into a local site.
- site_export: Export a local site to a backup file. Supports full-site (.zip, .tar.gz) or database-only (.sql) exports.
${ studioPresentToolBullet }${ automaticArtifactSection }

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/ai/tools/import-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { captureCommandOutput, resolveSite, textResult } from './utils';

export const importSiteTool = defineTool(
'site_import',
'Imports a backup file into a local WordPress site. Supports .zip, .tar.gz, .sql, and .wpress formats. ' +
'Imports a backup file into a local WordPress site. Supports .zip, .tar.gz, .sql, .wpress, and WordPress export (WXR) .xml formats. ' +
'The site server will be stopped during import and restarted afterward if it was running.',
{
nameOrPath: Type.String( { description: 'The local site name or file system path' } ),
Expand Down
20 changes: 20 additions & 0 deletions apps/cli/lib/dependency-management/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -47,13 +48,18 @@ 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();
} else if ( this.isTarGz( file ) ) {
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();
}
Expand All @@ -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' );
}
Expand Down
41 changes: 41 additions & 0 deletions apps/cli/lib/import-export/import/handlers/backup-handler-xml.ts
Original file line number Diff line number Diff line change
@@ -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,
} );
}
}
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
3 changes: 3 additions & 0 deletions apps/cli/lib/import-export/import/import-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ 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';
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;
Expand Down Expand Up @@ -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 },
];
124 changes: 124 additions & 0 deletions apps/cli/lib/import-export/import/importers/wxr-importer.ts
Original file line number Diff line number Diff line change
@@ -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 } );
}
}
24 changes: 24 additions & 0 deletions apps/cli/lib/import-export/import/tests/import-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );
3 changes: 3 additions & 0 deletions apps/cli/lib/import-export/import/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );
} );
Loading
Loading