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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 24 additions & 106 deletions src/lib/import-export/export/exporters/default-exporter.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love it!
CleanShot 2025-06-03 at 16 19 56@2x
😄

Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export class DefaultExporter extends EventEmitter implements Exporter {
private archive!: archiver.Archiver;
private backup: BackupContents;
private readonly options: ExportOptions;
private siteFiles: string[];
private readonly pathsToExclude = [
'wp-content/mu-plugins/sqlite-database-integration',
'wp-content/mu-plugins/0-allowed-redirect-hosts.php',
Expand All @@ -43,21 +42,12 @@ export class DefaultExporter extends EventEmitter implements Exporter {
constructor( options: ExportOptions ) {
super();
this.options = options;
this.siteFiles = [];
this.backup = {
backupFile: options.backupFile,
sqlFiles: [],
wpContent: {
uploads: [],
plugins: [],
themes: [],
muPlugins: [],
fonts: [],
},
};
}
async canHandle(): Promise< boolean > {
// Check for supported extension
const supportedExtension = [ 'tar.gz', 'tzg', 'zip' ].find( ( ext ) =>
this.options.backupFile.endsWith( ext )
);
Expand All @@ -67,30 +57,32 @@ export class DefaultExporter extends EventEmitter implements Exporter {
}

const requiredPaths = [
{ path: [ 'wp-content' ], isDir: true },
{ path: [ 'wp-includes' ], isDir: true },
{ path: 'wp-content', isDir: true },
{ path: 'wp-includes', isDir: true },
{ path: 'wp-load.php', isDir: false },
{ path: 'wp-config.php', isDir: false },
];

this.siteFiles = await this.getSiteFiles();

return requiredPaths.every( ( requiredPath ) =>
this.siteFiles.some( ( file ) => {
const relativePath = path.relative( this.options.site.path, file );
const relativePathItems = relativePath.split( path.sep );
return requiredPath.isDir
? ( requiredPath.path as string[] ).every(
( path, index ) => path === relativePathItems[ index ]
)
: relativePath === requiredPath.path;
} )
);
try {
for ( const requiredPath of requiredPaths ) {
const stats = await fsPromises.stat(
path.join( this.options.site.path, requiredPath.path )
);
if ( requiredPath.isDir && ! stats.isDirectory() ) {
return false;
}
if ( ! requiredPath.isDir && ! stats.isFile() ) {
return false;
}
}
return true;
} catch ( error ) {
return false;
}
}

async export(): Promise< void > {
this.emit( ExportEvents.EXPORT_START );
this.backup = await this.getBackupContents();
const output = fs.createWriteStream( this.options.backupFile );
this.archive = this.createArchive();

Expand Down Expand Up @@ -151,8 +143,11 @@ export class DefaultExporter extends EventEmitter implements Exporter {
}

private addWpConfig(): void {
if ( this.backup.wpConfigFile ) {
this.archive.file( this.backup.wpConfigFile, { name: 'wp-config.php' } );
const wpConfigPath = path.join( this.options.site.path, 'wp-config.php' );
if ( fs.existsSync( wpConfigPath ) ) {
this.archive.file( wpConfigPath, {
name: 'wp-config.php',
} );
}
}

Expand Down Expand Up @@ -181,13 +176,7 @@ export class DefaultExporter extends EventEmitter implements Exporter {
} );
this.emit( ExportEvents.WP_CONTENT_EXPORT_PROGRESS, { directory: absolutePath } );
}
this.emit( ExportEvents.WP_CONTENT_EXPORT_COMPLETE, {
uploads: this.backup.wpContent.uploads.length,
plugins: this.backup.wpContent.plugins.length,
themes: this.backup.wpContent.themes.length,
muPlugins: this.backup.wpContent.muPlugins.length,
fonts: this.backup.wpContent.fonts.length,
} );
this.emit( ExportEvents.WP_CONTENT_EXPORT_COMPLETE );
}

private async addDatabase(): Promise< void > {
Expand Down Expand Up @@ -223,77 +212,6 @@ export class DefaultExporter extends EventEmitter implements Exporter {
}
}

private async getSiteFiles(): Promise< string[] > {
if ( this.siteFiles.length ) {
return this.siteFiles;
}

const directoryContents = await fsPromises.readdir( this.options.site.path, {
recursive: true,
withFileTypes: true,
} );

return directoryContents.reduce< string[] >( ( files: string[], directoryContent ) => {
const filePath = path.join( directoryContent.path, directoryContent.name );
const relativePath = path.relative( this.options.site.path, filePath );

// Check for exact path exclusions
const isExcluded = this.pathsToExclude.some( ( pathToExclude ) =>
relativePath.startsWith( path.normalize( pathToExclude ) )
);

// Check for node_modules and .git directories anywhere
const isNodeModulesDirectory = relativePath.includes( 'node_modules' );
const isGitDirectory = relativePath.includes( '.git' );

if ( isExcluded || isNodeModulesDirectory || isGitDirectory ) {
return files;
}
if ( directoryContent.isFile() ) {
files.push( filePath );
}
return files;
}, [] );
}

private async getBackupContents(): Promise< BackupContents > {
const options = this.options;
const backupContents: BackupContents = {
backupFile: options.backupFile,
sqlFiles: [],
wpContent: {
uploads: [],
plugins: [],
themes: [],
muPlugins: [],
fonts: [],
},
};

const siteFiles = await this.getSiteFiles();
siteFiles.forEach( ( file ) => {
const relativePath = path.relative( options.site.path, file );
const relativePathItems = relativePath.split( path.sep );
const [ wpContent, wpContentDirectory ] = relativePathItems;
if ( path.basename( file ) === 'wp-config.php' ) {
backupContents.wpConfigFile = file;
} else if ( wpContent === 'wp-content' ) {
if (
wpContentDirectory === 'uploads' ||
wpContentDirectory === 'plugins' ||
wpContentDirectory === 'themes' ||
wpContentDirectory === 'fonts'
) {
backupContents.wpContent[ wpContentDirectory as BackupContentsCategory ].push( file );
} else if ( wpContentDirectory === 'mu-plugins' ) {
backupContents.wpContent.muPlugins.push( file );
}
}
} );

return backupContents;
}

private async createStudioJsonFile(): Promise< string > {
const wpVersion = await getWordPressVersionFromInstallation( this.options.site.path );
const studioJson: StudioJson = {
Expand Down
2 changes: 0 additions & 2 deletions src/lib/import-export/export/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export type ExportOptionsIncludes = BackupContentsCategory | 'database';
export interface BackupContents {
backupFile: string;
sqlFiles: string[];
wpContent: { [ index in BackupContentsCategory ]: string[] };
wpConfigFile?: string;
}

export type BackupContentsCategory = 'uploads' | 'plugins' | 'themes' | 'muPlugins' | 'fonts';
Expand Down
119 changes: 107 additions & 12 deletions src/lib/import-export/tests/export/exporters/default-exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,30 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {

( fsPromises.readdir as jest.Mock ).mockResolvedValue( mockFiles );

// Mock fsPromises.stat for canHandle method
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about cleaning up these comments as they seem self-explanatory?

( fsPromises.stat as jest.Mock ).mockImplementation( async ( filePath: string ) => {
const normalizedPath = normalize( filePath );
if ( normalizedPath.endsWith( 'wp-content' ) || normalizedPath.endsWith( 'wp-includes' ) ) {
return { isDirectory: () => true, isFile: () => false };
}
if (
normalizedPath.endsWith( 'wp-load.php' ) ||
normalizedPath.endsWith( 'wp-config.php' )
) {
return { isDirectory: () => false, isFile: () => true };
}
throw new Error( 'File not found' );
} );

// Mock fs.existsSync for addWpConfig method
( fs.existsSync as jest.Mock ).mockImplementation( ( filePath: string ) => {
const normalizedPath = normalize( filePath );
return normalizedPath.endsWith( 'wp-config.php' );
} );

mockBackup = {
backupFile: normalize( '/path/to/backup.tar.gz' ),
wpConfigFile: normalize( '/path/to/wp-config.php' ),
sqlFiles: [ normalize( '/tmp/studio_export_123/file.sql' ) ],
wpContent: {
uploads: [ normalize( '/path/to/wp-content/uploads/file1.jpg' ) ],
plugins: [ normalize( '/path/to/wp-content/plugins/plugin1' ) ],
themes: [ normalize( '/path/to/wp-content/themes/theme1' ) ],
muPlugins: [ normalize( '/path/to/wp-content/mu-plugins/custom-mu-plugin.php' ) ],
fonts: [ normalize( '/path/to/wp-content/fonts/custom-font.woff2' ) ],
},
};

mockOptions = {
Expand Down Expand Up @@ -247,9 +260,17 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {
const exporter = new DefaultExporter( options );
await exporter.export();

expect( mockArchiver.file ).toHaveBeenCalledWith( normalize( '/path/to/site/wp-config.php' ), {
name: 'wp-config.php',
} );
// wp-config.php should be called first, then meta.json
expect( mockArchiver.file ).toHaveBeenNthCalledWith(
1,
normalize( '/path/to/site/wp-config.php' ),
{ name: 'wp-config.php' }
);
expect( mockArchiver.file ).toHaveBeenNthCalledWith(
2,
normalize( '/tmp/studio_export_123/meta.json' ),
{ name: 'meta.json' }
);
} );

it( 'should add meta.json to the archive', async () => {
Expand Down Expand Up @@ -281,12 +302,19 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {

const exporter = new DefaultExporter( options );
await exporter.export();
// Check each expected call individually

// Check that wp-config.php and meta.json are both added
expect( mockArchiver.file ).toHaveBeenNthCalledWith(
1,
normalize( '/path/to/site/wp-config.php' ),
{ name: 'wp-config.php' }
);
expect( mockArchiver.file ).toHaveBeenNthCalledWith(
2,
normalize( '/tmp/studio_export_123/meta.json' ),
{ name: 'meta.json' }
);

expect( mockArchiver.directory ).toHaveBeenNthCalledWith(
1,
normalize( '/path/to/site/wp-content/uploads' ),
Expand Down Expand Up @@ -334,6 +362,11 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {
normalize( '/path/to/site/wp-config.php' ),
{ name: 'wp-config.php' }
);
expect( mockArchiver.file ).toHaveBeenNthCalledWith(
2,
normalize( '/tmp/studio_export_123/meta.json' ),
{ name: 'meta.json' }
);
expect( mockArchiver.directory ).toHaveBeenNthCalledWith(
1,
normalize( '/path/to/site/wp-content/mu-plugins' ),
Expand Down Expand Up @@ -369,6 +402,11 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {
normalize( '/tmp/studio_export_123/studio-backup-db-export-2023-07-31-12-00-00.sql' ),
{ name: 'sql/studio-backup-db-export-2023-07-31-12-00-00.sql' }
);
expect( mockArchiver.file ).toHaveBeenNthCalledWith(
3,
normalize( '/tmp/studio_export_123/meta.json' ),
{ name: 'meta.json' }
);
} );

it( 'should add a multiple SQL dumps to the archive when `splitDatabaseDumpByTable` is true', async () => {
Expand Down Expand Up @@ -401,6 +439,12 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {
{ name: `sql/${ tableName }.sql` }
);
}

expect( mockArchiver.file ).toHaveBeenNthCalledWith(
defaultTableNames.length + 2,
normalize( '/tmp/studio_export_123/meta.json' ),
{ name: 'meta.json' }
);
} );

it( 'should finalize the archive', async () => {
Expand Down Expand Up @@ -486,11 +530,62 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => {
normalize( '/path/to/site/wp-config.php' ),
{ name: 'wp-config.php' }
);
expect( mockArchiver.file ).toHaveBeenNthCalledWith(
2,
normalize( '/tmp/studio_export_123/meta.json' ),
{ name: 'meta.json' }
);
expect( mockArchiver.directory ).toHaveBeenNthCalledWith(
1,
normalize( '/path/to/site/wp-content/fonts' ),
normalize( 'wp-content/fonts' ),
expect.any( Function )
);
} );

it( "should not add wp-config if it doesn't exists", async () => {
( fs.existsSync as jest.Mock ).mockImplementation( ( filePath: string ) => {
const normalizedPath = normalize( filePath );
return ! normalizedPath.endsWith( 'wp-config.php' );
} );

await exporter.export();

expect( mockArchiver.file ).not.toHaveBeenCalledWith(
normalize( '/path/to/site/wp-config.php' ),
{ name: 'wp-config.php' }
);
} );

it( 'should initialize backup object with correct structure when creating a new exporter', () => {
const testOptions: ExportOptions = {
site: {
running: false,
id: 'test-site',
name: 'Test Site',
path: normalize( '/path/to/test/site' ),
port: 8080,
phpVersion: '8.3',
},
backupFile: normalize( '/path/to/test-backup.tar.gz' ),
includes: {
uploads: true,
plugins: true,
themes: true,
database: true,
muPlugins: true,
fonts: true,
},
phpVersion: '8.4',
};

const testExporter = new DefaultExporter( testOptions );

const { backup } = testExporter as unknown as { backup: BackupContents };

expect( backup ).toEqual( {
backupFile: normalize( '/path/to/test-backup.tar.gz' ),
sqlFiles: [],
} );
} );
} );
Loading