├── .gitignore ├── .travis.yml ├── blog-extractor.php ├── composer.json ├── features ├── extract.feature └── load-wp-cli.feature ├── inc └── class-blog-extract.php └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | composer.lock 3 | wp-cli.local.yml 4 | node_modules/ 5 | vendor/ 6 | composer.phar 7 | installer -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: trusty 3 | 4 | language: php 5 | php: 6 | - 7.1 7 | - 7.2 8 | - 7.3 9 | 10 | notifications: 11 | email: 12 | on_success: never 13 | on_failure: change 14 | 15 | branches: 16 | only: 17 | - master 18 | 19 | cache: 20 | directories: 21 | - $HOME/.composer/cache 22 | 23 | 24 | env: 25 | global: 26 | - PATH="$TRAVIS_BUILD_DIR/vendor/bin:$PATH" 27 | - WP_CLI_BIN_DIR="$TRAVIS_BUILD_DIR/vendor/bin" 28 | 29 | before_install: 30 | - | 31 | # Remove Xdebug for a huge performance increase: 32 | if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then 33 | phpenv config-rm xdebug.ini 34 | else 35 | echo "xdebug.ini does not exist" 36 | fi 37 | - | 38 | # Raise PHP memory limit to 2048MB 39 | echo 'memory_limit = 2048M' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 40 | - composer validate 41 | 42 | install: 43 | - composer install 44 | - composer prepare-tests 45 | 46 | script: 47 | - composer phpunit 48 | - composer behat || composer behat-rerun 49 | 50 | jobs: 51 | include: 52 | - stage: sniff 53 | script: 54 | - composer lint 55 | - composer phpcs 56 | env: BUILD=sniff 57 | - stage: test 58 | php: 7.3 59 | env: WP_VERSION=latest 60 | - stage: test 61 | php: 7.2 62 | env: WP_VERSION=latest 63 | - stage: test 64 | php: 5.6 65 | env: WP_VERSION=latest 66 | - stage: test 67 | php: 5.6 68 | env: WP_VERSION=trunk 69 | - stage: test 70 | php: 5.4 71 | env: WP_VERSION=5.1.1 -------------------------------------------------------------------------------- /blog-extractor.php: -------------------------------------------------------------------------------- 1 | 14 | * : ID of blog to extract 15 | * 16 | * [--v] 17 | * : Verbose 18 | * 19 | * ## EXAMPLES 20 | * 21 | * wp extract 3 22 | */ 23 | function __invoke( $args, $assoc_args ) { 24 | 25 | // verify multisite 26 | if ( ! is_multisite() ) { 27 | WP_CLI::error( "This is a multisite command only." ); 28 | } 29 | 30 | // get args 31 | list( $blogid ) = $args; 32 | $v = \WP_CLI\Utils\get_flag_value( $assoc_args, 'v', false ); 33 | 34 | // verify valid blog id 35 | if ( ! ( $details = get_blog_details( $blogid ) ) ) { 36 | WP_CLI::error( "Given blog id is invalid." ); 37 | return; 38 | } 39 | 40 | switch_to_blog( $blogid ); 41 | 42 | global $wpdb; 43 | 44 | $blog_tables = $wpdb->tables('blog'); 45 | 46 | // here we look for additional tables that may have been added by plugins 47 | $extra_tables = $wpdb->get_col( "SHOW TABLES LIKE '{$wpdb->prefix}%'" ); 48 | $extra_tables = array_diff( $extra_tables, $blog_tables ); 49 | foreach ( $extra_tables as $et ) { 50 | $blog_tables[ substr( $et, strlen( $wpdb->prefix ) ) ] = $et; 51 | } 52 | unset( $extra_tables ); 53 | 54 | /************************************\ 55 | DATABASE 56 | \************************************/ 57 | /* 58 | * We use the $rename_tables array to store any tables that 59 | * will need to be renamed upon import to the new database. 60 | */ 61 | $rename_tables = array(); 62 | 63 | /* 64 | * For blog ID 1, we have to use a different temp user table name, 65 | * since $wpdb->prefix doesn't have a number appended 66 | * and we don't want to affect the global user tables. 67 | */ 68 | if ( 1 == $blogid ) { 69 | $tmp_users = "{$wpdb->prefix}temp_users"; 70 | $tmp_usermeta = "{$wpdb->prefix}temp_usermeta"; 71 | 72 | // Add these to the rename_tables array, so we can 73 | // rename them when importing to the new database 74 | $rename_tables[ $tmp_users ] = "{$wpdb->prefix}users"; 75 | $rename_tables[ $tmp_usermeta ] = "{$wpdb->prefix}usermeta"; 76 | } else { 77 | $tmp_users = "{$wpdb->prefix}users"; 78 | $tmp_usermeta = "{$wpdb->prefix}usermeta"; 79 | } 80 | 81 | $blog_1_case = ! empty( $rename_tables ); // just a nice flag to use later 82 | 83 | $blog_tables['users'] = $tmp_users; 84 | $blog_tables['usermeta'] = $tmp_usermeta; 85 | 86 | // @debug let's take a look at what tables we're archiving 87 | WP_CLI::debug( print_r( $blog_tables, true ) ); 88 | 89 | $users = wp_list_pluck( get_users(), 'ID' ); 90 | $super_admin_ids = array(); 91 | foreach ( get_super_admins() as $username ) { 92 | $super_admin_ids[] = get_user_by( 'login', $username )->ID; 93 | } 94 | 95 | $supes = array_diff( $super_admin_ids, $users ); 96 | $users = array_filter( array_unique( array_merge( $users, $super_admin_ids ) ) ); 97 | 98 | $userlist = implode( ',', $users ); 99 | 100 | if ( $v ) { 101 | WP_CLI::log( 'Begin copying user tables' ); 102 | } 103 | 104 | /* 105 | * Checks if we are attempting to create a temp table 106 | * with the same name as the main user table. If so, bail. 107 | * Currently happens if attempting to export blog ID 1, 108 | * since the DB prefix will not have a number appended. 109 | */ 110 | if ( $tmp_users == $wpdb->users ) { 111 | // OMG run away, FAST before we break something important 112 | WP_CLI::error( 'There was an error duplicating user tables' ); 113 | } 114 | 115 | // duplicate global user tables 116 | // delete unnecessary rows (probably not performant on large data) 117 | $wpdb->query( "create table if not exists {$tmp_users} like {$wpdb->users}" ); 118 | $check = $wpdb->get_col( "select * from {$tmp_users}" ); 119 | if ( empty( $check ) ) { 120 | if ( $v ) { 121 | WP_CLI::log( 'copying main users table' ); 122 | } 123 | if ( $userlist ) { 124 | $wpdb->query( "insert into {$tmp_users} select * from {$wpdb->users} where ID IN ({$userlist})" ); 125 | } else { 126 | WP_CLI::log( WP_CLI::colorize( "%YNo users were exported. You'll need to create users manually.%n" ) ); 127 | } 128 | } 129 | 130 | $wpdb->query( "create table if not exists {$tmp_usermeta} like {$wpdb->usermeta}" ); 131 | $check = $wpdb->get_col( "select * from {$tmp_usermeta}" ); 132 | if ( empty( $check ) ) { 133 | if ( $v ) { 134 | WP_CLI::log( 'copying main usermeta table' ); 135 | } 136 | if ( $userlist ) { 137 | $wpdb->query( "insert into {$tmp_usermeta} select * from {$wpdb->usermeta} where user_id IN ({$userlist})" ); 138 | } 139 | } 140 | 141 | // for the super admins that were not specifically added 142 | // to the blog on the network, give administrator role 143 | foreach ( $supes as $sid ) { 144 | $wpdb->insert( $tmp_usermeta, 145 | array( 146 | 'user_id' => $sid, 147 | 'meta_key' => $wpdb->prefix .'capabilities', 148 | 'meta_value' => serialize( array( 'administrator' => true ) ), 149 | ), 150 | array( 151 | '%d', 152 | '%s', 153 | '%s', 154 | ) 155 | ); 156 | } 157 | 158 | if ( $v ) { 159 | WP_CLI::log( 'Begin exporting tables' ); 160 | } 161 | $tablelist = implode( ' ', $blog_tables ); 162 | $sql_file = "database-{$blogid}.sql"; 163 | 164 | $cmd = \WP_CLI\Utils\esc_cmd( '/usr/bin/env mysqldump --no-defaults %s ' . $tablelist, 165 | DB_NAME 166 | ); 167 | 168 | $creds = array( 169 | 'host' => DB_HOST, 170 | 'user' => DB_USER, 171 | 'pass' => DB_PASSWORD, 172 | 'result-file' => $sql_file 173 | ); 174 | 175 | \WP_CLI\Utils\run_mysql_command( $cmd, $creds ); 176 | 177 | if ( file_exists( ABSPATH . $sql_file ) ) { 178 | if ( ( $filesize = filesize( ABSPATH . $sql_file ) ) > 0 ) { 179 | 180 | // Add statements to rename any tables we have in the $rename_tables array 181 | if ( $blog_1_case ) { 182 | $sql_fh = fopen( ABSPATH . $sql_file, 'a' ); 183 | fwrite( $sql_fh, "\n" ); 184 | foreach ( $rename_tables as $oldname => $newname ) { 185 | fwrite( $sql_fh, "RENAME TABLE `{$oldname}` TO `{$newname}`;\n" ); 186 | } 187 | fclose( $sql_fh ); 188 | } 189 | 190 | if ( $v ) { 191 | WP_CLI::log( 'Database tables exported' ); 192 | } 193 | 194 | $wpdb->query( "drop table if exists {$tmp_users}, {$tmp_usermeta}" ); 195 | 196 | } else { 197 | unlink( ABSPATH . $sql_file ); 198 | WP_CLI::error( 'There was an error exporting the archive.' ); 199 | } 200 | } else { 201 | WP_CLI::error( 'There was an error exporting the archive.' ); 202 | } 203 | 204 | /************************************\ 205 | FILES 206 | \************************************/ 207 | 208 | $export_dirs = array( ABSPATH . $sql_file ); 209 | 210 | // uploads 211 | $upload_dir = wp_upload_dir(); 212 | $export_dirs[] = $upload_dir['basedir']; 213 | 214 | // plugins 215 | $plugins = get_option( 'active_plugins' ); 216 | $plugins = array_map( function($i) { 217 | $parts = explode( '/', $i ); 218 | $root = array_shift( $parts ); 219 | return WP_CONTENT_DIR .'/plugins/'. $root; 220 | }, $plugins ); 221 | $export_dirs = array_merge( $export_dirs, $plugins ); 222 | 223 | // network plugins 224 | $networkplugins = wp_get_active_network_plugins(); 225 | $networkplugins = array_map( function($i) { 226 | $parts = explode( '/', str_replace( WP_CONTENT_DIR .'/plugins/', '', $i ) ); 227 | $root = array_shift( $parts ); 228 | return WP_CONTENT_DIR .'/plugins/'. $root; 229 | }, $networkplugins ); 230 | $export_dirs = array_merge( $export_dirs, $networkplugins ); 231 | 232 | // mu plugins 233 | $export_dirs[] = ABSPATH . MUPLUGINDIR; 234 | 235 | // mu plugins 236 | $dropins = array_keys( get_dropins() ); 237 | $dropins = array_map( function($i) { 238 | return WP_CONTENT_DIR .'/'. $i; 239 | }, $dropins ); 240 | $export_dirs = array_merge( $export_dirs, $dropins ); 241 | 242 | 243 | // theme(s) 244 | $themes = array_unique( array( get_stylesheet(), get_template() ) ); 245 | $themes = array_map( function($i) { return WP_CONTENT_DIR. get_raw_theme_root( get_stylesheet() ) .'/' . $i;}, $themes ); 246 | $export_dirs = array_merge( $export_dirs, $themes ); 247 | 248 | // remove ABSPATH. makes the export more friendly when extracted 249 | $exports = array_map( function($i) { 250 | return '"'. str_replace( ABSPATH, '', $i ) .'"'; 251 | }, $export_dirs ); 252 | 253 | // @debug let's take a look at what we're archiving 254 | WP_CLI::debug( print_r( $exports, true ) ); 255 | 256 | // work out any directories that should be excluded from the archive 257 | $exclude = ''; 258 | $exclude_exports = array(); 259 | 260 | if ( $blog_1_case ) { 261 | // if we renamed, we're on site ID 1, which also means uploads aren't in /sites/ 262 | $exclude_exports[] = str_replace( ABSPATH, '', $upload_dir['basedir'] ) . '/sites'; 263 | } 264 | 265 | if ( count( $exclude_exports ) > 0 ) { 266 | foreach ( $exclude_exports as $ee ) { 267 | $exclude .= " --exclude=$ee "; 268 | } 269 | } 270 | 271 | // @debug let's take a look 272 | WP_CLI::debug( print_r( $exclude_exports, true ) ); 273 | 274 | // @todo make this user-set 275 | $export_file = "archive-{$blogid}.tar.gz"; 276 | 277 | // GOOD! 278 | $abspath = ABSPATH; 279 | $exports = implode( ' ', $exports ); 280 | if ( $v ) { 281 | WP_CLI::log( 'Begin archiving files' ); 282 | } 283 | shell_exec( "cd {$abspath}; tar -czhvf {$export_file} {$exports} {$exclude}" ); 284 | 285 | if ( file_exists( ABSPATH . $export_file ) ) { 286 | if ( ( $filesize = filesize( ABSPATH . $export_file ) ) > 0 ) { 287 | // sql dump was archived, remove regular file 288 | unlink( ABSPATH . $sql_file ); 289 | 290 | $filesize = size_format( $filesize, 2 ); 291 | WP_CLI::success( "$export_file created! ($filesize)" ); 292 | 293 | $prefix = WP_CLI::colorize( "%P{$wpdb->prefix}%n" ); 294 | WP_CLI::log( 'In your new install in wp-config.php, set the $table_prefix to '. $prefix ); 295 | WP_CLI::log( 'You\'ll also need to do a search-replace for the url change' ); 296 | 297 | $old_url = untrailingslashit( $details->domain. $details->path ); 298 | WP_CLI::log( '=========================================' ); 299 | WP_CLI::log( 'Example commands to get you up and running again:' . WP_CLI::colorize( '%G' ) ); 300 | 301 | WP_CLI::log( "# update URLs" ); 302 | WP_CLI::log( "wp search-replace {$old_url} NEWURL" ); 303 | if ( ! $blog_1_case ) { 304 | // again, we're on ID 1, so uploads aren't in /sites/, so no need for these find-replace recommendations 305 | $rel_upl = trailingslashit( str_replace( ABSPATH, '', $upload_dir['basedir'] ) ); 306 | WP_CLI::log( "# move the uploads to the typical directory" ); 307 | WP_CLI::log( "mv {$rel_upl}* wp-content/uploads/" ); 308 | WP_CLI::log( "# remove the old directory" ); 309 | WP_CLI::log( "rm -rf wp-content/uploads/sites/" ); 310 | WP_CLI::log( "# update database" ); 311 | WP_CLI::log( "wp search-replace {$rel_upl} wp-content/uploads/" ); 312 | } 313 | 314 | WP_CLI::log( WP_CLI::colorize( '%n' ) . '===The End===============================' ); 315 | 316 | 317 | } else { 318 | unlink( ABSPATH . $export_file ); 319 | WP_CLI::error( 'There was an error creating the archive.' ); 320 | } 321 | } else { 322 | WP_CLI::error( 'Unable to create the archive.' ); 323 | } 324 | 325 | } 326 | 327 | } 328 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Blog Extractor [![travis-badge](https://travis-ci.org/trepmal/blog-extractor.svg?branch=master)](https://travis-ci.org/trepmal/blog-extractor) 2 | 3 | 4 | 5 | ## Warning! 6 | 7 | This has had limited testing so far and there may be bugs. Would love feedback from those willing to test this out. *There should be no risk to the multisite. In fact, no changes are made to the multisite. But, you know, gremlins.* 8 | 9 | ## Installation 10 | 11 | ### As Package 12 | 13 | This is the easiest. It will also make the command available anywhere `wp` is. 14 | 15 | `wp package install trepmal/blog-extractor` 16 | 17 | ### As Standard Plugin 18 | 19 | You can also install this command as a standard WordPress plugin, however that means the command will only be available for that WordPress installation where the plugin is active. 20 | 21 | `wp plugin install https://github.com/trepmal/blog-extractor/archive/master.zip --activate-network` 22 | 23 | ## Usage 24 | 25 | Extract a single blog from a multisite network. (Does not delete original site) 26 | 27 | ``` 28 | wp extract 29 | ``` 30 | 31 | Creates an tar file in the WordPress root directory. Tar file contains: 32 | 33 | * sql dump of site, including user tables 34 | * wp-content/ 35 | * uploads/sites/{id} 36 | * plugins/{active-plugins} 37 | * plugins/{network-activated plugins} (will need to be reactivated) 38 | * mu-plugins 39 | * themes/{active theme} (including parent if needed) 40 | * dropins (such as object-cache.php) 41 | 42 | In setting up the standalone site, a few things need to be done: 43 | 44 | * in `wp-config.php` change the $table_prefix to match the ID'd prefix from the multisite (this is given in the success message) 45 | * after the tables are imported 46 | * run the search-replace command to change the URLs 47 | * move the uploads from the /sites/{id}/ directory to the main /uploads/ folder 48 | * run search-replace again to change those affected URLs 49 | 50 | --- 51 | 52 | Example, if you run 53 | 54 | ``` 55 | $ wp extract 100 56 | ``` 57 | You'd get something like 58 | 59 | ``` 60 | > Success: archive-100.tar.gz created! (1.33 MB) 61 | > In your new install in wp-config.php, set the $table_prefix to wp_100_ 62 | > You'll also need to do a search-replace for the url change 63 | > ========================================= 64 | > # update URLs 65 | > wp search-replace ms.dev/montana NEWURL 66 | > # move the uploads to the typical directory 67 | > mv wp-content/uploads/sites/100/* wp-content/uploads/ 68 | > # remove the old directory 69 | > rm -rf wp-content/uploads/sites/ 70 | > # update database 71 | > wp search-replace wp-content/uploads/sites/100/ wp-content/uploads/ 72 | > ========================================= 73 | ``` --------------------------------------------------------------------------------