diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 22b0ace2..ba442ece 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -8,6 +8,7 @@ /node_modules/ /dependencies/ /../wordpress/ + /inc/site-exporter/mu-migration/ @@ -111,7 +112,7 @@ - + diff --git a/assets/js/template-library.js b/assets/js/template-library.js new file mode 100644 index 00000000..e256b7b3 --- /dev/null +++ b/assets/js/template-library.js @@ -0,0 +1,184 @@ +/* global Vue, wu_template_library, ajaxurl, _ */ +(function($) { + + const search_template = new Vue({ + el: '#search-templates', + data: { + search: wu_template_library.search, + }, + }); + + const wu_main_template_app = new Vue({ + el: '#wu-template-library', + data() { + + return { + loading: true, + category: wu_template_library.category, + templates: [], + }; + + }, + mounted() { + + this.fetch_templates_list(); + + }, + computed: { + search() { + + return search_template.search; + + }, + i18n() { + + return window.wu_template_library.i18n; + + }, + categories() { + + let categories = []; + + _.each(this.templates, function(template) { + + if (template.categories && Array.isArray(template.categories)) { + categories = categories.concat(template.categories); + } + + }); + + return _.unique(categories, function(cat) { + return cat.slug; + }); + + }, + templates_list() { + + const app = this; + + return _.filter(app.templates, function(template) { + + // Filter by category + if (app.category !== 'all') { + const hasCategory = template.categories && template.categories.some(cat => cat.slug === app.category); + if (!hasCategory) { + return false; + } + } + + // Filter by search + if (!app.search) { + + return true; + + } + + const search_fields = [ + template.slug || '', + template.name || '', + template.description || '', + template.short_description || '', + template.author || '', + template.industry_type || '', + ]; + + // Add category names to search + if (template.categories && Array.isArray(template.categories)) { + template.categories.forEach(function(cat) { + search_fields.push(cat.name || cat.slug || ''); + }); + } + + return search_fields.join(' ').toLowerCase().indexOf(app.search.toLowerCase()) > -1; + + }); + + }, + count() { + + return this.templates_list.length; + + }, + }, + methods: { + fetch_templates_list() { + + const app = this; + + $.ajax({ + method: 'GET', + url: ajaxurl, + data: { + action: 'serve_templates_list', + }, + success(data) { + + if (data.success && data.data) { + app.templates = data.data; + } else { + app.templates = []; + } + + app.loading = false; + + }, + error() { + + app.templates = []; + app.loading = false; + + }, + }); + + }, + }, + }); + + new Vue({ + el: '.wp-heading-inline', + data: {}, + computed: { + count() { + + return wu_main_template_app.count; + + }, + }, + }); + + new Vue({ + el: '#templates-menu', + data: {}, + methods: { + set_category(category) { + + this.main_app.category = category; + + const url = new URL(window.location.href); + + url.searchParams.set('tab', category); + + history.pushState({}, null, url); + + }, + }, + computed: { + main_app() { + + return wu_main_template_app; + + }, + category() { + + return wu_main_template_app.category; + + }, + available_categories() { + + return wu_main_template_app.categories; + + }, + }, + }); + +}(jQuery)); diff --git a/composer.json b/composer.json index 63e7cd7a..d06348cf 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "jasny/sso": "^0.4.2", "nyholm/psr7": "^1.8.0", "symfony/cache": "^5.4.29", + "symfony/finder": "^5.4", "scssphp/scssphp": "^1.11.1", "cweagans/composer-patches": "^1.7", "woocommerce/action-scheduler": "^3.9.1", diff --git a/composer.lock b/composer.lock index 3c13b255..711f5b4a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cb2d45765e1edc0ba8eb9510154108dd", + "content-hash": "31f00de0de1918010b3e5e53ded6879b", "packages": [ { "name": "amphp/amp", @@ -3979,6 +3979,69 @@ ], "time": "2024-09-25T14:11:13+00:00" }, + { + "name": "symfony/finder", + "version": "v5.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "63741784cd7b9967975eec610b256eed3ede022b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", + "reference": "63741784cd7b9967975eec610b256eed3ede022b", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v5.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-28T13:32:08+00:00" + }, { "name": "symfony/polyfill-php73", "version": "v1.33.0", @@ -9118,69 +9181,6 @@ ], "time": "2024-10-22T13:05:35+00:00" }, - { - "name": "symfony/finder", - "version": "v5.4.45", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "63741784cd7b9967975eec610b256eed3ede022b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", - "reference": "63741784cd7b9967975eec610b256eed3ede022b", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php80": "^1.16" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.45" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-28T13:32:08+00:00" - }, { "name": "symfony/polyfill-ctype", "version": "v1.33.0", diff --git a/inc/admin-pages/class-base-admin-page.php b/inc/admin-pages/class-base-admin-page.php index bdc068e6..d5f9aa80 100644 --- a/inc/admin-pages/class-base-admin-page.php +++ b/inc/admin-pages/class-base-admin-page.php @@ -176,7 +176,7 @@ public function __construct() { __FUNCTION__, sprintf( /* translators: 1: The current class. 2: 'init'. */ - esc_html__('Admin page %1$s loaded too early. Admin page should be loaded at the %2$s action or later.'), + esc_html__('Admin page %1$s loaded too early. Admin page should be loaded at the %2$s action or later.', 'ultimate-multisite'), '' . static::class . '', 'init' ), diff --git a/inc/admin-pages/class-site-edit-admin-page.php b/inc/admin-pages/class-site-edit-admin-page.php index fae2697c..0873038d 100644 --- a/inc/admin-pages/class-site-edit-admin-page.php +++ b/inc/admin-pages/class-site-edit-admin-page.php @@ -299,6 +299,15 @@ public function register_widgets(): void { parent::register_widgets(); + /** + * Allows other components to register widgets on the Site edit page. + * + * @since 2.5.0 + * + * @param Site_Edit_Admin_Page $page The current admin page instance. + */ + do_action('wu_edit_site_page_register_widgets', $this); + $label = $this->get_object()->get_type_label(); $class = $this->get_object()->get_type_class(); diff --git a/inc/admin-pages/class-site-list-admin-page.php b/inc/admin-pages/class-site-list-admin-page.php index 2eb7d60a..bfd72460 100644 --- a/inc/admin-pages/class-site-list-admin-page.php +++ b/inc/admin-pages/class-site-list-admin-page.php @@ -547,7 +547,7 @@ public function get_submenu_title() { */ public function action_links() { - return [ + $links = [ [ 'label' => __('Add Site', 'ultimate-multisite'), 'icon' => 'wu-circle-with-plus', @@ -555,6 +555,16 @@ public function action_links() { 'url' => wu_get_form_url('add_new_site'), ], ]; + + /** + * Filters the action links for the Sites list page. + * + * @since 2.5.0 + * + * @param array $links The action links. + * @return array + */ + return apply_filters('wu_site_list_page_action_links', $links); } /** diff --git a/inc/admin-pages/class-template-library-admin-page.php b/inc/admin-pages/class-template-library-admin-page.php new file mode 100644 index 00000000..5a1cd6df --- /dev/null +++ b/inc/admin-pages/class-template-library-admin-page.php @@ -0,0 +1,673 @@ + 'wu_read_settings', + ]; + + /** + * Should we hide admin notices on this page? + * + * @since 2.5.0 + * @var bool + */ + protected $hide_admin_notices = false; + + /** + * Should we force the admin menu into a folded state? + * + * @since 2.5.0 + * @var bool + */ + protected $fold_menu = false; + + /** + * Holds the section slug for the URLs. + * + * @since 2.5.0 + * @var string + */ + protected $section_slug = 'tab'; + + /** + * Defines if the step links on the side are clickable or not. + * + * @since 2.5.0 + * @var bool + */ + protected $clickable_navigation = true; + + /** + * Template Repository instance. + * + * @since 2.5.0 + * @var Template_Repository|null + */ + protected ?Template_Repository $repository = null; + + /** + * Allow child classes to add hooks to be run once the page is loaded. + * + * @since 2.5.0 + * @return void + */ + public function init(): void { + + parent::init(); + + add_action('wp_ajax_serve_templates_list', [$this, 'serve_templates_list']); + } + + /** + * Register forms. + * + * @since 2.5.0 + * @return void + */ + public function register_forms(): void { + + wu_register_form( + 'template_more_info', + [ + 'render' => [$this, 'display_more_info'], + 'handler' => [$this, 'install_template'], + ] + ); + + wu_register_form( + 'upload_template', + [ + 'render' => [$this, 'render_upload_template_modal'], + 'handler' => [$this, 'handle_upload_template_modal'], + 'capability' => 'manage_network', + ] + ); + } + + /** + * Renders the upload template modal. + * + * @since 2.5.0 + * @return void + */ + public function render_upload_template_modal(): void { + + // Reset upload limits for large ZIP files + \WP_Ultimo\Site_Exporter\Site_Exporter::get_instance()->reset_upload_limits(); + + $fields = [ + 'template_name' => [ + 'type' => 'text', + 'title' => __('Template Name', 'ultimate-multisite'), + 'placeholder' => __('My Awesome Template', 'ultimate-multisite'), + 'desc' => __('A descriptive name for this template.', 'ultimate-multisite'), + ], + 'zip_file' => [ + 'type' => 'text', + 'title' => __('ZIP File URL', 'ultimate-multisite'), + 'placeholder' => __('https://example.com/export.zip', 'ultimate-multisite'), + 'desc' => __('Upload or enter URL to a site export ZIP file.', 'ultimate-multisite'), + 'html_attr' => [ + 'id' => 'wu-template-zip-url', + ], + ], + 'upload_btn' => [ + 'type' => 'html', + 'content' => sprintf( + '', + __('Upload ZIP File', 'ultimate-multisite') + ), + 'wrapper_classes' => 'wu-mb-4', + ], + 'template_url' => [ + 'type' => 'text', + 'title' => __('Template Site URL', 'ultimate-multisite'), + 'placeholder' => is_subdomain_install() ? 'template-name.example.com' : 'example.com/template-name', + 'desc' => __('The URL for the new template site.', 'ultimate-multisite'), + ], + 'categories' => [ + 'type' => 'text', + 'title' => __('Categories', 'ultimate-multisite'), + 'placeholder' => __('business, portfolio', 'ultimate-multisite'), + 'desc' => __('Comma-separated list of categories.', 'ultimate-multisite'), + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Create Template', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end wu-text-right', + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'upload_template', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + ] + ); + + $form->render(); + + // Add media uploader script + ?> + + $template_url, + 'new_url' => $template_url, + 'delete_file' => false, + 'zip_url' => $zip_url, + ] + ); + + if (is_wp_error($result)) { + wp_send_json_error($result); + } + + // Parse categories + $category_list = array_map('trim', explode(',', $categories)); + $category_list = array_filter($category_list); + + // Note: The site will be created by the import process. + // We set a transient to update it to site_template type after import completes. + set_site_transient( + 'wu_pending_template_setup_' . md5($template_url), + [ + 'name' => $template_name, + 'url' => $template_url, + 'categories' => $category_list, + ], + HOUR_IN_SECONDS + ); + + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url( + 'wp-ultimo-sites', + [ + 'type' => 'site_template', + 'updated' => __('Template import started. The site will be available shortly.', 'ultimate-multisite'), + ] + ), + ] + ); + } + + /** + * Gets the Template Repository. + * + * @since 2.5.0 + * @return Template_Repository + */ + protected function get_repository(): Template_Repository { + + if (null === $this->repository) { + $this->repository = new Template_Repository(); + } + + return $this->repository; + } + + /** + * Get a template given a slug. + * + * @since 2.5.0 + * @param string $template_slug The template slug. + * @return array|null + */ + private function get_template(string $template_slug): ?array { + + $templates = $this->get_templates_list(); + + foreach ($templates as $template) { + if ($template['slug'] === $template_slug) { + return $template; + } + } + + return null; + } + + /** + * Displays the more info modal. + * + * @since 2.5.0 + * @return void + */ + public function display_more_info(): void { + + $template_slug = wu_request('template'); + + $template = $this->get_template($template_slug); + + wu_get_template( + 'template-library/details', + [ + 'template' => (object) $template, + 'template_slug' => $template_slug, + ] + ); + + do_action('wu_form_scripts', false); + } + + /** + * Installs a given template. + * + * @since 2.5.0 + * @return void + */ + public function install_template(): void { + + if (! current_user_can('manage_network_plugins')) { + $error = new \WP_Error('error', __('You do not have enough permissions to perform this task.', 'ultimate-multisite')); + + wp_send_json_error($error); + } + + $template_slug = wu_request('template'); + + $template = $this->get_template($template_slug); + + if (! $template) { + wp_send_json_error(new \WP_Error('not_found', __('Template not found.', 'ultimate-multisite'))); + } + + $download_url = $template['download_url'] ?? ''; + + if (! $download_url) { + wp_send_json_error( + new \WP_Error( + 'no_download', + sprintf( + /* translators: %s slug of the template. */ + __('Unable to download template. User does not have permission to install %s', 'ultimate-multisite'), + $template_slug + ) + ) + ); + } + + // Security check: Ensure URL is from our domain + $allowed = strncmp($download_url, MULTISITE_ULTIMATE_UPDATE_URL, strlen(MULTISITE_ULTIMATE_UPDATE_URL)) === 0; + + if (! $allowed) { + $error = new \WP_Error('insecure-url', __('You are trying to download a template from an insecure URL', 'ultimate-multisite')); + wp_send_json_error($error); + } + + // Install the template + $installer = $this->get_repository()->get_installer(); + + $result = $installer->install( + $download_url, + [ + 'slug' => $template['slug'], + 'name' => $template['name'], + 'version' => $template['template_version'], + ] + ); + + if (is_wp_error($result)) { + wp_send_json_error($result); + } + + // Clear template cache to reflect installation + $this->get_repository()->clear_cache(); + + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url( + 'wp-ultimo-sites', + [ + 'type' => 'site_template', + ] + ), + 'message' => __('Template installed successfully!', 'ultimate-multisite'), + ] + ); + } + + /** + * Enqueue the necessary scripts. + * + * @since 2.5.0 + * @return void + */ + public function register_scripts(): void { + + wp_enqueue_style('theme'); + + // Enqueue media uploader for template uploads + wp_enqueue_media(); + + wp_register_script('wu-template-library', wu_get_asset('template-library.js', 'js'), ['jquery', 'wu-vue', 'underscore'], wu_get_version(), true); + + wp_localize_script( + 'wu-template-library', + 'wu_template_library', + [ + 'search' => wu_request('s', ''), + 'category' => wu_request('tab', 'all'), + 'i18n' => [ + 'all' => __('All Templates', 'ultimate-multisite'), + 'loading' => __('Loading templates...', 'ultimate-multisite'), + 'no_result' => __('No templates found.', 'ultimate-multisite'), + ], + ] + ); + + wp_enqueue_script('wu-template-library'); + } + + /** + * Fetches the list of templates available. + * + * @since 2.5.0 + * @return array + */ + protected function get_templates_list(): array { + + $templates = $this->get_repository()->get_templates(); + + if (is_wp_error($templates)) { + wu_log_add( + 'api-calls', + sprintf( + /* translators: %s error message. */ + __('Failed to fetch templates from API: %s', 'ultimate-multisite'), + $templates->get_error_message() + ) + ); + return []; + } + + return $templates; + } + + /** + * Gets the list of templates from the remote server. + * + * @since 2.5.0 + * @return void + */ + public function serve_templates_list(): void { + + $templates_list = $this->get_templates_list(); + + wp_send_json_success($templates_list); + } + + /** + * Returns the title of the page. + * + * @since 2.5.0 + * @return string Title of the page. + */ + public function get_title(): string { + + return __('Template Library', 'ultimate-multisite'); + } + + /** + * Returns the title of menu for this page. + * + * @since 2.5.0 + * @return string Menu label of the page. + */ + public function get_menu_title(): string { + + return __('Template Library', 'ultimate-multisite'); + } + + /** + * Returns the title links for this page. + * + * @since 2.5.0 + * @return array + */ + public function get_title_links(): array { + + return [ + [ + 'label' => __('Upload Template', 'ultimate-multisite'), + 'icon' => 'upload', + 'classes' => 'wubox', + 'url' => wu_get_form_url('upload_template'), + ], + ]; + } + + /** + * Every child class should implement the output method to display the contents of the page. + * + * @since 2.5.0 + * @return void + */ + public function output(): void { + + $addon_repo = \WP_Ultimo::get_instance()->get_addon_repository(); + + $redirect_url = wu_network_admin_url('wp-ultimo-template-library'); + $code = wu_request('code'); + + if (wu_request('logout') && wp_verify_nonce(wu_request('_wpnonce'), 'logout')) { + $addon_repo->delete_tokens(); + } + + $more_info_url = wu_get_form_url( + 'template_more_info', + [ + 'width' => 768, + 'template' => 'TEMPLATE_SLUG', + ] + ); + + $user = $addon_repo->get_user_data(); + + if (! $user && $code) { + $addon_repo->save_access_token($code, $redirect_url); + $user = $addon_repo->get_user_data(); + } + + wu_get_template( + 'template-library/template-library', + [ + 'screen' => get_current_screen(), + 'page' => $this, + 'classes' => '', + 'sections' => $this->get_sections(), + 'current_section' => $this->get_current_section(), + 'clickable_navigation' => $this->clickable_navigation, + 'more_info_url' => $more_info_url, + 'oauth_url' => $addon_repo->get_oauth_url(), + 'logout_url' => wu_network_admin_url( + 'wp-ultimo-template-library', + [ + 'logout' => 'logout', + '_wpnonce' => wp_create_nonce('logout'), + ] + ), + 'user' => $user ?? false, + ] + ); + } + + /** + * Returns the list of settings sections. + * + * @since 2.5.0 + * @return array + */ + public function get_sections(): array { + + return [ + 'all' => [ + 'title' => __('All Templates', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-grid', + ], + 'business' => [ + 'title' => __('Business', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-briefcase', + ], + 'portfolio' => [ + 'title' => __('Portfolio', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-image', + ], + 'blog' => [ + 'title' => __('Blog', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-text-page', + ], + 'ecommerce' => [ + 'title' => __('E-commerce', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-shop', + ], + 'agency' => [ + 'title' => __('Agency', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-rocket', + ], + 'saas' => [ + 'title' => __('SaaS', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-cloud', + ], + 'community' => [ + 'title' => __('Community', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-users', + ], + ]; + } + + /** + * Default handler for step submission. Simply redirects to the next step. + * + * @since 2.5.0 + * @return void + */ + public function default_handler(): void { + // Not used for this page. + } +} diff --git a/inc/admin/class-network-usage-columns.php b/inc/admin/class-network-usage-columns.php index 840f8cc7..e71f5153 100644 --- a/inc/admin/class-network-usage-columns.php +++ b/inc/admin/class-network-usage-columns.php @@ -172,7 +172,7 @@ public function manage_themes_custom_column(string $column_name, string $theme_k if ( $theme_data->parent() ) { echo '
' . sprintf( // Translators: The placeholder will be replaced by the name of the parent theme. - esc_attr__('This is a child theme of %s.', 'multisite-enhancements'), + esc_attr__('This is a child theme of %s.', 'ultimate-multisite'), '' . esc_attr($theme_data->parent()->Name) . '' ); } @@ -182,7 +182,7 @@ public function manage_themes_custom_column(string $column_name, string $theme_k if ( count($used_as_parent) ) { echo '
' . esc_attr__( 'This is used as a parent theme by:', - 'multisite-enhancements' + 'ultimate-multisite' ) . ' '; echo esc_html(implode(', ', $used_as_parent)); } diff --git a/inc/checkout/composer.json b/inc/checkout/composer.json new file mode 100644 index 00000000..f2564799 --- /dev/null +++ b/inc/checkout/composer.json @@ -0,0 +1,14 @@ +{ + "name": "vendor_name/checkout", + "description": "description", + "minimum-stability": "stable", + "license": "proprietary", + "authors": [ + { + "name": "dave", + "email": "email@example.com" + } + ], + "require": { + } +} diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 0581b97b..731d6cac 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -179,6 +179,12 @@ public function init(): void { */ $this->notices = WP_Ultimo\Admin_Notices::get_instance(); + /* + * Show notice if Site Exporter addon was auto-deactivated. + */ + add_action('network_admin_notices', [$this, 'show_site_exporter_deactivation_notice']); + add_action('admin_notices', [$this, 'show_site_exporter_deactivation_notice']); + /* * Loads the Ultimate Multisite scripts handler */ @@ -381,6 +387,23 @@ public function load_public_apis(): void { require_once wu_path('inc/functions/invoice.php'); require_once wu_path('inc/functions/tax.php'); + /** + * Site Exporter and Importer APIs. + * + * Functions for exporting and importing sites. + * + * Since 2.5.0, Site Exporter is part of core. We need to + * deactivate the legacy addon if it's still active to + * prevent function redeclaration conflicts. + * + * @see wu_exporter_export() + * @see wu_exporter_import() + */ + $this->maybe_deactivate_site_exporter_addon(); + + require_once wu_path('inc/functions/exporter.php'); + require_once wu_path('inc/functions/importer.php'); + /** * Access Control. * @@ -517,6 +540,11 @@ protected function load_extra_components(): void { */ \WP_Ultimo\Tax\Tax::get_instance(); + /* + * Loads the Site Exporter + */ + \WP_Ultimo\Site_Exporter\Site_Exporter::get_instance(); + /* * Loads the template placeholders */ @@ -812,6 +840,8 @@ protected function load_admin_pages(): void { new WP_Ultimo\Admin_Pages\Addons_Admin_Page(); + new WP_Ultimo\Admin_Pages\Template_Library_Admin_Page(); + do_action('wp_ultimo_admin_pages'); } @@ -984,4 +1014,82 @@ public function grant_customer_capabilities($allcaps, $caps, $args, $user) { return $allcaps; } + + /** + * Deactivates the legacy Site Exporter addon if active. + * + * Since 2.5.0, Site Exporter functionality is part of core. + * We need to deactivate the addon automatically to prevent + * function redeclaration conflicts. + * + * @since 2.5.0 + * @return void + */ + private function maybe_deactivate_site_exporter_addon(): void { + + $addon_file = 'ultimate-multisite-site-exporter/ultimate-multisite-site-exporter.php'; + + // Check if the addon is network activated + if (is_multisite()) { + $network_plugins = get_site_option('active_sitewide_plugins', []); + + if (isset($network_plugins[ $addon_file ])) { + unset($network_plugins[ $addon_file ]); + update_site_option('active_sitewide_plugins', $network_plugins); + + // Set a transient to show a notice after redirect + set_site_transient('wu_site_exporter_addon_deactivated', true, 60); + } + } + + // Check if the addon is activated on the current site + $active_plugins = get_option('active_plugins', []); + $key = array_search($addon_file, $active_plugins, true); + + if (false !== $key) { + unset($active_plugins[ $key ]); + update_option('active_plugins', array_values($active_plugins)); + + // Set a transient to show a notice after redirect + set_transient('wu_site_exporter_addon_deactivated', true, 60); + } + } + + /** + * Shows a notice when the Site Exporter addon was auto-deactivated. + * + * @since 2.5.0 + * @return void + */ + public function show_site_exporter_deactivation_notice(): void { + + $show_notice = false; + + // Check network transient first + if (is_multisite() && get_site_transient('wu_site_exporter_addon_deactivated')) { + delete_site_transient('wu_site_exporter_addon_deactivated'); + $show_notice = true; + } + + // Check regular transient + if (get_transient('wu_site_exporter_addon_deactivated')) { + delete_transient('wu_site_exporter_addon_deactivated'); + $show_notice = true; + } + + if (! $show_notice) { + return; + } + + ?> +
+

+ +

+

+ +

+
+ prepare() method here because it will * escape the %s placeholder, which will break the query. diff --git a/inc/debug/class-debug.php b/inc/debug/class-debug.php index 5d0def49..2f34a8a1 100644 --- a/inc/debug/class-debug.php +++ b/inc/debug/class-debug.php @@ -385,7 +385,7 @@ public function handle_debug_generator_form(): void { wp_send_json_success( [ - 'redirect_url' => wu_network_admin_url('wp-ultimo'), + 'redirect_url' => wu_network_admin_url('ultimate-multisite'), ] ); } diff --git a/inc/development/class-toolkit.php b/inc/development/class-toolkit.php index 50892a8e..efc04dce 100644 --- a/inc/development/class-toolkit.php +++ b/inc/development/class-toolkit.php @@ -338,7 +338,7 @@ public function register_collector_overview(array $collectors, \QueryMonitor $qm */ public function add_overview_panel($output) { - $collector = \QM_Collectors::get('wp-ultimo'); + $collector = \QM_Collectors::get('ultimate-multisite'); $output['wp-ultimo'] = new Query_Monitor\Panel\Overview($collector); diff --git a/inc/functions/exporter.php b/inc/functions/exporter.php new file mode 100644 index 00000000..addd3888 --- /dev/null +++ b/inc/functions/exporter.php @@ -0,0 +1,447 @@ + $site_id, + 'options' => $options, + 'hash' => $hash, + ], + 'site-exporter' + ); + } else { + do_action_ref_array( + 'wu_export_site', + [ + 'site_id' => $site_id, + 'options' => $options, + ], + 'site-exporter' + ); + } + + return true; +} + +/** + * Gets a list of all the exports generated to date. + * + * @since 2.5.0 + * @return array + */ +function wu_exporter_get_all_exports(): array { + + $path = wu_maybe_create_folder('wu-site-exports'); + + $exports = Finder::create() + ->files() + ->name('*.zip') + ->sortByModifiedTime() + ->reverseSorting() + ->in($path); + + $results = []; + $base_url = wu_exporter_get_folder(); + + foreach ($exports as $file) { + $results[] = [ + 'file' => $file->getFilename(), + 'path' => $file->getPathname(), + 'date' => wp_date(get_option('date_format') . ' ' . get_option('time_format'), $file->getMTime()), + 'size' => size_format($file->getSize()), + 'url' => trailingslashit($base_url) . $file->getFilename(), + ]; + } + + return $results; +} + +/** + * Gets the exporter URL for the folder. + * + * @since 2.5.0 + * @return string + */ +function wu_exporter_get_folder(): string { + + return WP_Ultimo()->helper->get_folder_url('wu-site-exports'); +} + +/** + * Gets the site object based on the export name. + * + * @since 2.5.0 + * + * @param string $export_name The file name. + * @return \WP_Ultimo\Models\Site|false + */ +function wu_exporter_get_site_from_export_name(string $export_name) { + + $matches = []; + + preg_match('/wu-site-export-([0-9]+)/', $export_name, $matches); + + $site_id = absint($matches[1] ?? 0); + + return wu_get_site($site_id); +} + +/** + * Saves the time it took to generate the zip. + * + * @since 2.5.0 + * + * @param string $file The export filename. + * @param float $time The time it took. + * @return bool + */ +function wu_exporter_save_generation_time(string $file, float $time): bool { + + $times = wu_get_option('exporter_generation_times', []); + + $times[ $file ] = $time; + + return wu_save_option('exporter_generation_times', $times); +} + +/** + * Get the generated time for a given export. + * + * @since 2.5.0 + * + * @param string $file The file name. + * @return string + */ +function wu_exporter_get_generation_time(string $file): string { + + $times = wu_get_option('exporter_generation_times', []); + + $time = wu_get_isset($times, $file, false); + + if (false === $time) { + return __('Time to generate not saved', 'ultimate-multisite'); + } + + $now = time(); + + return human_time_diff($now, $now + $time); +} + +/** + * Adds a particular site as pending. + * + * @since 2.5.0 + * + * @param int $site_id The site ID. + * @param array $options The flags on what to export. + * @param bool $async If we should generate the export file asynchronously. + * @return string + */ +function wu_exporter_add_pending(int $site_id, array $options = [], bool $async = false): string { + + $base = [$site_id, $options, $async]; + + $hash = md5(serialize($base)); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + + wu_exporter_set_transient("wu_pending_site_export_{$hash}", $site_id, 2 * HOUR_IN_SECONDS); + + return $hash; +} + +/** + * Get pending exports. + * + * @since 2.5.0 + * @return array + */ +function wu_exporter_get_pending(): array { + + global $wpdb; + + $table = is_multisite() ? "{$wpdb->base_prefix}sitemeta" : "{$wpdb->base_prefix}options"; + + $like = is_multisite() ? '\\_site\\_transient\\_wu\\_pending\\_site\\_export\\_%' : '\\_transient\\_wu\\_pending\\_site\\_export\\_%'; + + $query = "SELECT meta_key, meta_value as site_id FROM {$table} WHERE meta_key LIKE '{$like}'"; + + return $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery +} + +/** + * Decide how to create transients. + * + * @since 2.5.0 + * + * @param string $transient The transient key. + * @param mixed $value The transient value. + * @param int $expiration The expiration period. + * @return bool + */ +function wu_exporter_set_transient(string $transient, $value, int $expiration = 0): bool { + + global $_wp_using_ext_object_cache; + + $default_wp_using_ext_object_cache = $_wp_using_ext_object_cache; + + $_wp_using_ext_object_cache = false; + + if (is_multisite()) { + $results = set_site_transient($transient, $value, $expiration); + } else { + $results = set_transient($transient, $value, $expiration); + } + + $_wp_using_ext_object_cache = $default_wp_using_ext_object_cache; + + return $results; +} + +/** + * Decides how to delete transients. + * + * @since 2.5.0 + * + * @param string $transient The transient key. + * @return bool + */ +function wu_exporter_delete_transient(string $transient): bool { + + global $_wp_using_ext_object_cache; + + $default_wp_using_ext_object_cache = $_wp_using_ext_object_cache; + + $_wp_using_ext_object_cache = false; + + if (is_multisite()) { + $results = delete_site_transient($transient); + } else { + $results = delete_transient($transient); + } + + $_wp_using_ext_object_cache = $default_wp_using_ext_object_cache; + + return $results; +} + +/** + * Add a plugin or pattern to the exclusion list on the export zips. + * + * @since 2.5.0 + * + * @param string $plugin_or_pattern The plugin name of pattern. E.g.: wp-ultimo or wp-ultimo-*. + * @return bool + */ +function wu_exporter_exclude_plugin_from_export(string $plugin_or_pattern): bool { + + add_filter( + 'wu_site_exporter_plugin_exclusion_list', + function ($plugins_or_patterns) use ($plugin_or_pattern) { + + $plugins_or_patterns[] = $plugin_or_pattern; + + return $plugins_or_patterns; + } + ); + + return true; +} + +// -------------------------------------------------------- +// Backwards compatibility aliases for deprecated functions +// -------------------------------------------------------- + +/** + * Deprecated: Use wu_exporter_export() instead. + * + * @deprecated 2.5.0 + * + * @param int $site_id The site ID. + * @param array $options The flags on what to export. + * @param bool $async If we should generate the export file asynchronously. + * @return \WP_Error|true + */ +function wu_site_exporter_export(int $site_id, array $options = [], bool $async = false) { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_export'); + + return wu_exporter_export($site_id, $options, $async); +} + +/** + * Deprecated: Use wu_exporter_get_all_exports() instead. + * + * @deprecated 2.5.0 + * @return array + */ +function wu_site_exporter_get_all_exports(): array { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_get_all_exports'); + + return wu_exporter_get_all_exports(); +} + +/** + * Deprecated: Use wu_exporter_get_folder() instead. + * + * @deprecated 2.5.0 + * @return string + */ +function wu_site_exporter_get_folder(): string { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_get_folder'); + + return wu_exporter_get_folder(); +} + +/** + * Deprecated: Use wu_exporter_get_site_from_export_name() instead. + * + * @deprecated 2.5.0 + * + * @param string $export_name The file name. + * @return \WP_Ultimo\Models\Site|false + */ +function wu_site_exporter_get_site_from_export_name(string $export_name) { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_get_site_from_export_name'); + + return wu_exporter_get_site_from_export_name($export_name); +} + +/** + * Deprecated: Use wu_exporter_save_generation_time() instead. + * + * @deprecated 2.5.0 + * + * @param string $file The export filename. + * @param float $time The time it took. + * @return bool + */ +function wu_site_exporter_save_generation_time(string $file, float $time): bool { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_save_generation_time'); + + return wu_exporter_save_generation_time($file, $time); +} + +/** + * Deprecated: Use wu_exporter_get_generation_time() instead. + * + * @deprecated 2.5.0 + * + * @param string $file The file name. + * @return string + */ +function wu_site_exporter_get_generation_time(string $file): string { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_get_generation_time'); + + return wu_exporter_get_generation_time($file); +} + +/** + * Deprecated: Use wu_exporter_add_pending() instead. + * + * @deprecated 2.5.0 + * + * @param int $site_id The site ID. + * @param array $options The flags on what to export. + * @param bool $async If we should generate the export file asynchronously. + * @return string + */ +function wu_site_exporter_add_pending(int $site_id, array $options = [], bool $async = false): string { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_add_pending'); + + return wu_exporter_add_pending($site_id, $options, $async); +} + +/** + * Deprecated: Use wu_exporter_get_pending() instead. + * + * @deprecated 2.5.0 + * @return array + */ +function wu_site_exporter_get_pending(): array { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_get_pending'); + + return wu_exporter_get_pending(); +} + +/** + * Deprecated transient function - Use wu_exporter_set_transient() instead. + * + * @deprecated 2.5.0 + * + * @param string $transient The transient key. + * @param mixed $value The transient value. + * @param int $expiration The expiration period. + * @return bool + */ +function wp_ultimo_site_exporter_set_transient(string $transient, $value, int $expiration = 0): bool { + + return wu_exporter_set_transient($transient, $value, $expiration); +} + +/** + * Deprecated transient function - Use wu_exporter_delete_transient() instead. + * + * @deprecated 2.5.0 + * + * @param string $transient The transient key. + * @return bool + */ +function wp_ultimo_site_exporter_delete_transient(string $transient): bool { + + return wu_exporter_delete_transient($transient); +} + +/** + * Deprecated: Use wu_exporter_exclude_plugin_from_export() instead. + * + * @deprecated 2.5.0 + * + * @param string $plugin_or_pattern The plugin name of pattern. + * @return bool + */ +function wu_site_exporter_exclude_plugin_from_export(string $plugin_or_pattern): bool { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_exclude_plugin_from_export'); + + return wu_exporter_exclude_plugin_from_export($plugin_or_pattern); +} diff --git a/inc/functions/importer.php b/inc/functions/importer.php new file mode 100644 index 00000000..119c250f --- /dev/null +++ b/inc/functions/importer.php @@ -0,0 +1,300 @@ + $file_name, + 'options' => $options, + ], + 'site-import' + ); + } + + return true; +} + +/** + * Adds a particular site import as pending. + * + * @since 2.5.0 + * + * @param string $file_name The zip file name. + * @param array $options The flags on what to import. + * @param bool $async Reserved for future use. + * @return string|\WP_Error + */ +function wu_exporter_add_pending_import(string $file_name, array $options = [], bool $async = false) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + + if (! file_exists($file_name) || ! in_array(mime_content_type($file_name), ['application/zip', 'application/x-gzip'], true)) { + return new \WP_Error('invalid-type', __('File does not exists or it has an invalid mime-type.', 'ultimate-multisite')); + } + + $base = [ + $file_name, + $options, + ]; + + $hash = md5(serialize($base)); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + + $base[] = $hash; + + wu_exporter_set_transient("wu_pending_site_import_{$hash}", $base, 2 * HOUR_IN_SECONDS); + + return $hash; +} + +/** + * Get pending imports. + * + * @since 2.5.0 + * @return array + */ +function wu_exporter_get_pending_imports(): array { + + global $wpdb; + + if (is_multisite()) { + $table = "{$wpdb->base_prefix}sitemeta"; + + $query = "SELECT meta_key, meta_value as options FROM {$table} WHERE meta_key LIKE '\\_site\\_transient\\_wu\\_pending\\_site\\_import\\_%'"; + } else { + $table = "{$wpdb->base_prefix}options"; + + $query = "SELECT option_name, option_value as options FROM {$table} WHERE option_name LIKE '\\_transient\\_wu\\_pending\\_site\\_import\\_%'"; + } + + $results = $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery + + $results = array_map( + function ($item) { + + $item->options = maybe_unserialize($item->options); + + return $item; + }, + $results + ); + + return $results; +} + +/** + * Saves the time it took to import the zip. + * + * @since 2.5.0 + * + * @param string $file The import filename. + * @param float $time The time it took. + * @return bool + */ +function wu_exporter_save_import_time(string $file, float $time): bool { + + $times = wu_get_option('exporter_import_times', []); + + $times[ $file ] = $time; + + return wu_save_option('exporter_import_times', $times); +} + +/** + * Converts a file URL to a file path + * + * @since 2.5.0 + * + * @param string $url The file URL. + * @return string + */ +function wu_exporter_url_to_path(string $url): string { + + $path = str_replace(set_url_scheme(site_url('/'), 'https'), ABSPATH, set_url_scheme($url, 'https')); + + if (file_exists($path)) { + return $path; + } + + return get_attached_file(attachment_url_to_postid($url)); +} + +/** + * Get the site from the new url. + * + * @since 2.5.0 + * + * @param string $url The file url. + * @return \WP_Site|object|false + */ +function wu_exporter_url_to_site(string $url) { + + $parsed = wp_parse_url($url); + + if (! isset($parsed['host'])) { + return false; + } + + $site = wu_exporter_maybe_get_site_by_path($parsed['host'], $parsed['path'] ?? ''); + + return $site; +} + +/** + * Gets a site by domain and path. + * + * @since 2.5.0 + * + * @param string $domain The site domain. + * @param string $path The site path. + * @return \WP_Site|object|false + */ +function wu_exporter_maybe_get_site_by_path(string $domain, string $path) { + + if (is_multisite()) { + return get_site_by_path($domain, $path); + } else { + return (object) [ + 'blog_id' => 1, + ]; + } +} + +// -------------------------------------------------------- +// Backwards compatibility aliases for deprecated functions +// -------------------------------------------------------- + +/** + * Deprecated: Use wu_exporter_import() instead. + * + * @deprecated 2.5.0 + * + * @param string $file_name The zip file name. + * @param array $options The flags on what to import. + * @param bool $async If we should generate the import file asynchronously. + * @return \WP_Error|true + */ +function wu_site_exporter_import(string $file_name, array $options = [], bool $async = true) { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_import'); + + return wu_exporter_import($file_name, $options, $async); +} + +/** + * Deprecated: Use wu_exporter_add_pending_import() instead. + * + * @deprecated 2.5.0 + * + * @param string $file_name The zip file name. + * @param array $options The flags on what to import. + * @param bool $async If we should generate the import file asynchronously. + * @return string|\WP_Error + */ +function wu_site_exporter_add_pending_import(string $file_name, array $options = [], bool $async = false) { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_add_pending_import'); + + return wu_exporter_add_pending_import($file_name, $options, $async); +} + +/** + * Deprecated: Use wu_exporter_get_pending_imports() instead. + * + * @deprecated 2.5.0 + * @return array + */ +function wu_site_exporter_get_pending_imports(): array { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_get_pending_imports'); + + return wu_exporter_get_pending_imports(); +} + +/** + * Deprecated: Use wu_exporter_save_import_time() instead. + * + * @deprecated 2.5.0 + * + * @param string $file The import filename. + * @param float $time The time it took. + * @return bool + */ +function wu_site_exporter_save_import_time(string $file, float $time): bool { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_save_import_time'); + + return wu_exporter_save_import_time($file, $time); +} + +/** + * Deprecated: Use wu_exporter_url_to_path() instead. + * + * @deprecated 2.5.0 + * + * @param string $url The file URL. + * @return string + */ +function wu_site_exporter_url_to_path(string $url): string { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_url_to_path'); + + return wu_exporter_url_to_path($url); +} + +/** + * Deprecated: Use wu_exporter_url_to_site() instead. + * + * @deprecated 2.5.0 + * + * @param string $url The file url. + * @return \WP_Site|object|false + */ +function wu_site_exporter_url_to_site(string $url) { + + _deprecated_function(__FUNCTION__, '2.5.0', 'wu_exporter_url_to_site'); + + return wu_exporter_url_to_site($url); +} + +/** + * Deprecated: Use wu_exporter_maybe_get_site_by_path() instead. + * + * @deprecated 2.5.0 + * + * @param string $domain The site domain. + * @param string $path The site path. + * @return \WP_Site|object|false + */ +function wp_ultimo_site_exporter_maybe_get_site_by_path(string $domain, string $path) { + + return wu_exporter_maybe_get_site_by_path($domain, $path); +} diff --git a/inc/helpers/class-hash.php b/inc/helpers/class-hash.php index 4582c565..f75ba98b 100644 --- a/inc/helpers/class-hash.php +++ b/inc/helpers/class-hash.php @@ -40,7 +40,7 @@ private function __construct() {} * @param string $group Hash group. Used to increase entropy. * @return string */ - public static function encode($number, $group = 'wp-ultimo') { + public static function encode($number, $group = 'ultimate-multisite') { $hasher = new Hashids($group, self::LENGTH, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'); @@ -56,7 +56,7 @@ public static function encode($number, $group = 'wp-ultimo') { * @param string $group Hash group. Used to increase entropy. * @return string|int */ - public static function decode($hash, $group = 'wp-ultimo') { + public static function decode($hash, $group = 'ultimate-multisite') { $hasher = new Hashids($group, 10, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'); diff --git a/inc/helpers/validation-rules/class-unique-customer-email.php b/inc/helpers/validation-rules/class-unique-customer-email.php new file mode 100644 index 00000000..6fb74f36 --- /dev/null +++ b/inc/helpers/validation-rules/class-unique-customer-email.php @@ -0,0 +1,86 @@ +message = __('A customer with the same email address already exists.', 'ultimate-multisite'); + + global $wpdb; + + // Query for users with this exact email address across the entire network + // We use a direct query to ensure exact email matching (not LIKE) + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->users} WHERE user_email = %s", + $value + ) + ); + + // No users with this email exist, so no customer can exist + if (empty($user_ids)) { + return true; + } + + // Check if any of these users are linked to an Ultimate Multisite customer + foreach ($user_ids as $user_id) { + $customer = wu_get_customer_by_user_id($user_id); + + if ($customer) { + // A customer with this email already exists + return false; + } + } + + // No customer found with this email + return true; + } +} diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index 4272774b..c07bebdf 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -1945,7 +1945,7 @@ public function publish_pending_site_async(): void { if (is_wp_error($result)) { // translators: %s full error message. - wu_log_add("membership-{$this->get_id()}", sprintf(__('Failed to trigger async site creation. The site will not be created until the next cron run which is much slower: %s'), $result->get_error_message())); + wu_log_add("membership-{$this->get_id()}", sprintf(__('Failed to trigger async site creation. The site will not be created until the next cron run which is much slower: %s', 'ultimate-multisite'), $result->get_error_message())); } wu_enqueue_async_action('wu_async_publish_pending_site', ['membership_id' => $this->get_id()], 'membership'); diff --git a/inc/site-exporter/class-site-exporter.php b/inc/site-exporter/class-site-exporter.php new file mode 100644 index 00000000..2900dc4f --- /dev/null +++ b/inc/site-exporter/class-site-exporter.php @@ -0,0 +1,1625 @@ +setup(); + + $this->loaded = true; + + /** + * Triggers when all the dependencies were loaded. + * + * Allows plugin developers to add new functionality. For example, support to new + * Hosting providers, etc. + * + * @since 2.5.0 + */ + do_action('wu_site_exporter_loaded'); + + // Backwards compatibility + do_action('wp_ultimo_site_exporter_load'); + } + + /** + * Adds the necessary hooks to deal with exports and imports. + * + * @since 2.5.0 + * @return void + */ + public function setup(): void { + + add_action('wu_export_site', [$this, 'handle_site_export'], 10, 3); + + add_action('wu_import_site', [$this, 'handle_site_import'], 10, 3); + + add_filter('wu_site_exporter_files_to_zip', [$this, 'maybe_exclude_wp_ultimo_plugins']); + + add_action('cron_schedules', [$this, 'maybe_add_schedule']); + + add_action('init', [$this, 'maybe_run_imports']); + + // Register admin forms and hooks + add_action('wu_register_forms', [$this, 'register_forms']); + + // Add export/import action links to Sites list page + add_filter('wu_site_list_page_action_links', [$this, 'add_site_list_action_links']); + + // Add export widget to Site edit page + add_action('wu_edit_site_page_register_widgets', [$this, 'register_site_edit_widgets']); + + // Handle import form submission + add_action('admin_init', [$this, 'maybe_handle_import']); + + // Add bulk export action + add_filter('wu_site_bulk_actions', [$this, 'add_bulk_export_action']); + add_action('wu_handle_bulk_action_form_site_export', [$this, 'handle_bulk_export'], 10, 3); + + // WordPress default Sites page integration (works without Ultimate Multisite setup) + $this->setup_wordpress_sites_integration(); + } + + /** + * Set up integration with the default WordPress Sites page. + * + * This allows exporting sites even before Ultimate Multisite is fully set up, + * making migration from other solutions easier. + * + * @since 2.5.0 + * @return void + */ + private function setup_wordpress_sites_integration(): void { + + // Add export action link to each site row + add_filter('manage_sites_action_links', [$this, 'add_wp_sites_row_actions'], 10, 2); + + // Add bulk export action + add_filter('bulk_actions-sites-network', [$this, 'add_wp_sites_bulk_actions']); + + // Handle bulk export action + add_filter('handle_network_bulk_actions-sites-network', [$this, 'handle_wp_sites_bulk_action'], 10, 3); + + // Add admin menu page for export/import + add_action('network_admin_menu', [$this, 'add_wp_export_menu_page']); + + // Handle direct export requests + add_action('admin_init', [$this, 'handle_direct_export_request']); + + // Display admin notices + add_action('network_admin_notices', [$this, 'display_export_notices']); + + // Enqueue scripts for WordPress sites page + add_action('admin_enqueue_scripts', [$this, 'enqueue_wp_sites_scripts']); + } + + /** + * Add export action link to WordPress Sites page rows. + * + * @since 2.5.0 + * + * @param array $actions Existing actions. + * @param int $blog_id The blog ID. + * @return array + */ + public function add_wp_sites_row_actions(array $actions, int $blog_id): array { + + // Don't add for main site + if (is_main_site($blog_id)) { + return $actions; + } + + $export_url = add_query_arg( + [ + 'page' => 'wu-site-export', + 'site_id' => $blog_id, + 'action' => 'export', + ], + network_admin_url('sites.php') + ); + + $actions['export'] = sprintf( + '%s', + esc_url($export_url), + __('Export', 'ultimate-multisite') + ); + + return $actions; + } + + /** + * Add bulk export action to WordPress Sites page. + * + * @since 2.5.0 + * + * @param array $actions Existing bulk actions. + * @return array + */ + public function add_wp_sites_bulk_actions(array $actions): array { + + $actions['export'] = __('Export', 'ultimate-multisite'); + + return $actions; + } + + /** + * Handle bulk export action from WordPress Sites page. + * + * @since 2.5.0 + * + * @param string $redirect_url The redirect URL. + * @param string $action The action being performed. + * @param array $blog_ids The selected blog IDs. + * @return string + */ + public function handle_wp_sites_bulk_action(string $redirect_url, string $action, array $blog_ids): string { + + if ('export' !== $action) { + return $redirect_url; + } + + $exported = 0; + + foreach ($blog_ids as $blog_id) { + // Skip main site + if (is_main_site($blog_id)) { + continue; + } + + wu_exporter_export($blog_id, ['uploads' => true], true); + ++$exported; + } + + return add_query_arg( + [ + 'page' => 'wu-site-export', + 'bulk_exported' => $exported, + ], + network_admin_url('sites.php') + ); + } + + /** + * Add export/import menu page under Sites. + * + * @since 2.5.0 + * @return void + */ + public function add_wp_export_menu_page(): void { + + add_submenu_page( + 'sites.php', + __('Export & Import Sites', 'ultimate-multisite'), + __('Export & Import', 'ultimate-multisite'), + 'manage_network', + 'wu-site-export', + [$this, 'render_wp_export_page'] + ); + } + + /** + * Render the export/import page. + * + * @since 2.5.0 + * @return void + */ + public function render_wp_export_page(): void { + + $site_id = absint(wu_request('site_id', 0)); + $action = wu_request('action', ''); + + // Get exports and pending items + $exports = wu_exporter_get_all_exports(); + $pending_exports = function_exists('wu_exporter_get_pending') ? wu_exporter_get_pending() : []; + $pending_imports = wu_exporter_get_pending_imports(); + + ?> +
+

+ + + render_export_form($site_id); ?> + + render_export_import_dashboard($exports, $pending_exports, $pending_imports); ?> + +
+ 'wu-site-export', + 'action' => 'do_export', + 'site_id' => $site_id, + ], + network_admin_url('sites.php') + ), + 'wu_export_site_' . $site_id + ); + + ?> +
+

+ +

+ ' . esc_html($blog_details->blogname) . ' (' . esc_html($blog_details->siteurl) . ')' + ); + ?> +

+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +

+ + +

+
+
+ +
+

+

+

+ + + +

+
+ + +
+

+

+ + + + + + + + + + + + + + + +
options[0] ?? __('Unknown', 'ultimate-multisite')); ?>
+
+ + + +
+

+

+ + + + + + + + + + $pending) : ?> + + + + + + + +
options[0] ?? '')); ?>options[1]['new_url'] ?? ''); ?> + + + +
+
+ + +
+

+ + +

+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+ +
+

+

+ +
+ + + + + + + + + + + + + +
+ + +

+
+ +

+
+ +
+ +

+ +

+
+
+ ! empty($_POST['include_themes']), + 'plugins' => ! empty($_POST['include_plugins']), + 'uploads' => ! empty($_POST['include_uploads']), + ]; + + $background = ! empty($_POST['background_run']); + + wu_exporter_export($site_id, $options, $background); + + $message = $background ? 'export_started' : 'export_complete'; + + wp_safe_redirect( + add_query_arg( + [ + 'page' => 'wu-site-export', + 'message' => $message, + ], + network_admin_url('sites.php') + ) + ); + exit; + } + + // Handle delete + if ('delete' === $action) { + $file = wu_request('file', ''); + + if (! wp_verify_nonce(wu_request('_wpnonce'), 'wu_delete_export')) { + wp_die(esc_html__('Security check failed.', 'ultimate-multisite')); + } + + if (! current_user_can('manage_network')) { + wp_die(esc_html__('You do not have permission to delete exports.', 'ultimate-multisite')); + } + + // Validate file name format for security + if (preg_match('/^wu-site-export-[0-9]+-/', $file)) { + $path = wu_maybe_create_folder('wu-site-exports') . $file; + + if (file_exists($path)) { + wp_delete_file($path); + } + } + + wp_safe_redirect( + add_query_arg( + [ + 'page' => 'wu-site-export', + 'message' => 'deleted', + ], + network_admin_url('sites.php') + ) + ); + exit; + } + + // Handle import + if ('import' === $action && isset($_POST['zip_url'])) { + if (! wp_verify_nonce(wu_request('_wpnonce'), 'wu_import_site')) { + wp_die(esc_html__('Security check failed.', 'ultimate-multisite')); + } + + if (! current_user_can('manage_network')) { + wp_die(esc_html__('You do not have permission to import sites.', 'ultimate-multisite')); + } + + $zip_url = sanitize_text_field(wp_unslash($_POST['zip_url'])); + $new_url = sanitize_text_field(wp_unslash($_POST['new_url'] ?? '')); + $delete_zip = ! empty($_POST['delete_zip']); + + if (empty($zip_url) || empty($new_url)) { + wp_safe_redirect( + add_query_arg( + [ + 'page' => 'wu-site-export', + 'message' => 'import_error', + 'error' => 'missing_fields', + ], + network_admin_url('sites.php') + ) + ); + exit; + } + + $file_path = $this->url_to_path($zip_url); + + if (! $file_path || ! file_exists($file_path)) { + wp_safe_redirect( + add_query_arg( + [ + 'page' => 'wu-site-export', + 'message' => 'import_error', + 'error' => 'file_not_found', + ], + network_admin_url('sites.php') + ) + ); + exit; + } + + wu_exporter_import( + $file_path, + [ + 'delete_file' => $delete_zip, + 'zip_url' => $zip_url, + 'url' => $new_url, + 'new_url' => $new_url, + ] + ); + + wp_safe_redirect( + add_query_arg( + [ + 'page' => 'wu-site-export', + 'message' => 'import_started', + ], + network_admin_url('sites.php') + ) + ); + exit; + } + } + + /** + * Display admin notices for export/import actions. + * + * @since 2.5.0 + * @return void + */ + public function display_export_notices(): void { + + $message = wu_request('message', ''); + $bulk_exported = absint(wu_request('bulk_exported', 0)); + + if ($bulk_exported > 0) { + printf( + '

%s

', + /* translators: %d number of sites */ + esc_html(sprintf(_n('%d site export started in background.', '%d site exports started in background.', $bulk_exported, 'ultimate-multisite'), $bulk_exported)) + ); + } + + switch ($message) { + case 'export_complete': + echo '

' . esc_html__('Site exported successfully!', 'ultimate-multisite') . '

'; + break; + case 'export_started': + echo '

' . esc_html__('Site export started in background. Check back shortly.', 'ultimate-multisite') . '

'; + break; + case 'deleted': + echo '

' . esc_html__('Export deleted successfully.', 'ultimate-multisite') . '

'; + break; + case 'import_started': + echo '

' . esc_html__('Site import started. The site will be available shortly.', 'ultimate-multisite') . '

'; + break; + case 'import_error': + $error = wu_request('error', ''); + $error_messages = [ + 'missing_fields' => __('Please provide both a ZIP file URL and target URL.', 'ultimate-multisite'), + 'file_not_found' => __('The ZIP file could not be found. Make sure it is uploaded to this site.', 'ultimate-multisite'), + ]; + $error_text = $error_messages[ $error ] ?? __('An error occurred during import.', 'ultimate-multisite'); + echo '

' . esc_html($error_text) . '

'; + break; + } + } + + /** + * Enqueue scripts for WordPress Sites page. + * + * @since 2.5.0 + * + * @param string $hook The current admin page hook. + * @return void + */ + public function enqueue_wp_sites_scripts(string $hook): void { + + if ('sites_page_wu-site-export' !== $hook) { + return; + } + + wp_enqueue_media(); + + wp_add_inline_script( + 'media-editor', + " + jQuery(document).ready(function($) { + $('#wu-wp-upload-zip').on('click', function(e) { + e.preventDefault(); + var frame = wp.media({ + title: '" . esc_js(__('Select or Upload ZIP File', 'ultimate-multisite')) . "', + button: { text: '" . esc_js(__('Use this file', 'ultimate-multisite')) . "' }, + multiple: false + }); + frame.on('select', function() { + var attachment = frame.state().get('selection').first().toJSON(); + $('#zip_url').val(attachment.url); + }); + frame.open(); + }); + }); + " + ); + + // Add spinning animation for pending exports + wp_add_inline_style( + 'common', + ' + .dashicons.spin { + animation: wu-spin 1s linear infinite; + } + @keyframes wu-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + ' + ); + } + + /** + * Register export/import forms. + * + * @since 2.5.0 + * @return void + */ + public function register_forms(): void { + + wu_register_form( + 'export_site', + [ + 'render' => [$this, 'render_export_site_modal'], + 'handler' => [$this, 'handle_export_site_modal'], + 'capability' => 'manage_network', + ] + ); + + wu_register_form( + 'import_site', + [ + 'render' => [$this, 'render_import_site_modal'], + 'handler' => [$this, 'handle_import_site_modal'], + 'capability' => 'manage_network', + ] + ); + + wu_register_form( + 'delete_export', + [ + 'render' => [$this, 'render_delete_export_modal'], + 'handler' => [$this, 'handle_delete_export_modal'], + 'capability' => 'manage_network', + ] + ); + } + + /** + * Renders the export site modal. + * + * @since 2.5.0 + * @return void + */ + public function render_export_site_modal(): void { + + $site_id = wu_request('id'); + $site = wu_get_site($site_id); + + $fields = [ + 'exporting_site' => [ + 'type' => 'model', + 'title' => __('Site to Export', 'ultimate-multisite'), + 'placeholder' => __('Search Sites...', 'ultimate-multisite'), + 'desc' => __('The site will be exported to a .zip file that can be imported into any Ultimate Multisite network.', 'ultimate-multisite'), + 'value' => '', + 'html_attr' => [ + 'data-model' => 'site', + 'data-selected' => $site ? wp_json_encode($site->to_search_results()) : '', + 'data-value-field' => 'blog_id', + 'data-label-field' => 'title', + 'data-search-field' => 'title', + 'data-max-items' => 1, + ], + ], + 'include_themes' => [ + 'type' => 'toggle', + 'title' => __('Include Themes', 'ultimate-multisite'), + 'desc' => __('Include the active theme and parent theme if applicable.', 'ultimate-multisite'), + 'value' => false, + ], + 'include_plugins' => [ + 'type' => 'toggle', + 'title' => __('Include Plugins', 'ultimate-multisite'), + 'desc' => __('Include active plugins in the export.', 'ultimate-multisite'), + 'value' => false, + ], + 'include_uploads' => [ + 'type' => 'toggle', + 'title' => __('Include Uploads', 'ultimate-multisite'), + 'desc' => __('Include media files from the uploads folder.', 'ultimate-multisite'), + 'value' => true, + ], + 'background_run' => [ + 'type' => 'toggle', + 'title' => __('Run in Background', 'ultimate-multisite'), + 'desc' => __('For large sites, run the export as a background process.', 'ultimate-multisite'), + 'value' => false, + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Export Site', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end wu-text-right', + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'export_site', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + ] + ); + + $form->render(); + } + + /** + * Handles the export site modal submission. + * + * @since 2.5.0 + * @return void + */ + public function handle_export_site_modal(): void { + + $site_id = wu_request('exporting_site', ''); + $site = wu_get_site($site_id); + + if (! $site) { + wp_send_json_error(new \WP_Error('invalid-site', __('Invalid site selected.', 'ultimate-multisite'))); + } + + wu_exporter_export( + $site_id, + [ + 'plugins' => wu_request('include_plugins'), + 'themes' => wu_request('include_themes'), + 'uploads' => wu_request('include_uploads'), + ], + wu_request('background_run') + ); + + $message = wu_request('background_run') + ? __('Export started in background...', 'ultimate-multisite') + : __('Export completed!', 'ultimate-multisite'); + + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url('wp-ultimo-sites', ['updated' => $message]), + ] + ); + } + + /** + * Renders the import site modal. + * + * @since 2.5.0 + * @return void + */ + public function render_import_site_modal(): void { + + $this->reset_upload_limits(); + + $fields = [ + 'zip_file' => [ + 'type' => 'text', + 'title' => __('ZIP File URL', 'ultimate-multisite'), + 'placeholder' => __('https://example.com/export.zip', 'ultimate-multisite'), + 'desc' => __('Enter the URL to the export ZIP file, or use the media uploader.', 'ultimate-multisite'), + 'html_attr' => [ + 'id' => 'wu-import-zip-url', + ], + ], + 'upload_btn' => [ + 'type' => 'html', + 'content' => sprintf( + '', + __('Upload ZIP File', 'ultimate-multisite') + ), + 'wrapper_classes' => 'wu-mb-4', + ], + 'new_url' => [ + 'type' => 'text', + 'title' => __('New Site URL', 'ultimate-multisite'), + 'placeholder' => is_subdomain_install() ? 'newsite.example.com' : 'example.com/newsite', + 'desc' => __('The URL for the new imported site.', 'ultimate-multisite'), + ], + 'remove_zip' => [ + 'type' => 'toggle', + 'title' => __('Delete ZIP After Import', 'ultimate-multisite'), + 'desc' => __('Remove the ZIP file after successful import.', 'ultimate-multisite'), + 'value' => true, + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Import Site', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end wu-text-right', + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'import_site', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + ] + ); + + $form->render(); + + // Add media uploader script + ?> + + url_to_path($zip_url); + + if (! $file_path || ! file_exists($file_path)) { + wp_send_json_error(new \WP_Error('file-not-found', __('ZIP file not found.', 'ultimate-multisite'))); + } + + $result = wu_exporter_import( + $file_path, + [ + 'delete_file' => wu_request('remove_zip'), + 'zip_url' => $zip_url, + 'url' => $new_url, + 'new_url' => $new_url, + ] + ); + + if (is_wp_error($result)) { + wp_send_json_error($result); + } + + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url('wp-ultimo-sites', ['updated' => __('Import process started.', 'ultimate-multisite')]), + ] + ); + } + + /** + * Renders the delete export modal. + * + * @since 2.5.0 + * @return void + */ + public function render_delete_export_modal(): void { + + $export_name = wu_request('file_name'); + + $fields = [ + 'confirm' => [ + 'type' => 'toggle', + 'title' => __('Confirm Deletion', 'ultimate-multisite'), + 'desc' => __('This action cannot be undone.', 'ultimate-multisite'), + 'value' => false, + 'html_attr' => [ + 'v-model' => 'confirm', + ], + ], + 'file_name' => [ + 'type' => 'hidden', + 'value' => $export_name, + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Delete Export', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end wu-text-right', + 'html_attr' => [ + 'v-bind:disabled' => '!confirm', + ], + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'delete_export', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + 'html_attr' => [ + 'data-wu-app' => 'delete_export', + 'data-state' => wu_convert_to_state(['confirm' => false]), + ], + ] + ); + + $form->render(); + } + + /** + * Handles the delete export modal submission. + * + * @since 2.5.0 + * @return void + */ + public function handle_delete_export_modal(): void { + + $export_name = wu_request('file_name'); + + if (empty($export_name)) { + wp_send_json_error(new \WP_Error('invalid-export', __('Invalid export file.', 'ultimate-multisite'))); + } + + // Validate file name format for security + if (! preg_match('/^wu-site-export-[0-9]+-/', $export_name)) { + wp_send_json_error(new \WP_Error('invalid-export', __('Invalid export file name.', 'ultimate-multisite'))); + } + + $path = wu_maybe_create_folder('wu-site-exports') . $export_name; + + if (! file_exists($path)) { + wp_send_json_error(new \WP_Error('not-found', __('Export file not found.', 'ultimate-multisite'))); + } + + $success = wp_delete_file($path); + + wp_send_json_success( + [ + 'redirect_url' => wu_network_admin_url('wp-ultimo-sites', ['deleted' => 1]), + ] + ); + } + + /** + * Add action links to Sites list page. + * + * @since 2.5.0 + * + * @param array $links Existing action links. + * @return array + */ + public function add_site_list_action_links(array $links): array { + + $links[] = [ + 'label' => __('Export Site', 'ultimate-multisite'), + 'icon' => 'wu-export', + 'classes' => 'wubox', + 'url' => wu_get_form_url('export_site'), + ]; + + $links[] = [ + 'label' => __('Import Site', 'ultimate-multisite'), + 'icon' => 'wu-import', + 'classes' => 'wubox', + 'url' => wu_get_form_url('import_site'), + ]; + + return $links; + } + + /** + * Register export widget on Site edit page. + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Admin_Pages\Site_Edit_Admin_Page $page The edit page instance. + * @return void + */ + public function register_site_edit_widgets($page): void { + + $site = $page->get_object(); + + if (! $site) { + return; + } + + $exports = wu_exporter_get_all_exports(); + $site_exports = array_filter( + $exports, + function ($export) use ($site) { + return strpos($export['file'], 'wu-site-export-' . $site->get_id() . '-') !== false; + } + ); + + $export_url = wu_get_form_url('export_site', ['id' => $site->get_id()]); + + $page->add_fields_widget( + 'site_export', + [ + 'title' => __('Site Export', 'ultimate-multisite'), + 'position' => 'side', + 'fields' => [ + 'export_button' => [ + 'type' => 'html', + 'wrapper_classes' => 'wu-bg-gray-100', + 'content' => sprintf( + '%s', + esc_url($export_url), + __('Export This Site', 'ultimate-multisite') + ), + ], + 'export_list' => [ + 'type' => 'html', + 'content' => $this->render_site_exports_list($site_exports, $site), + ], + ], + ] + ); + } + + /** + * Render the list of exports for a site. + * + * @since 2.5.0 + * + * @param array $exports The exports list. + * @param \WP_Ultimo\Models\Site $site The site object (reserved for future use). + * @return string + */ + private function render_site_exports_list(array $exports, $site): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + + if (empty($exports)) { + return sprintf( + '

%s

', + __('No exports available for this site.', 'ultimate-multisite') + ); + } + + $html = '
' . __('Previous Exports:', 'ultimate-multisite') . '
    '; + + foreach (array_slice($exports, 0, 5) as $export) { + $delete_url = wu_get_form_url('delete_export', ['file_name' => $export['file']]); + + $html .= sprintf( + '
  • + %s + %s +
  • ', + esc_url($export['url']), + esc_html($export['date']), + esc_url($delete_url), + __('Delete', 'ultimate-multisite') + ); + } + + $html .= '
'; + + return $html; + } + + /** + * Add bulk export action. + * + * @since 2.5.0 + * + * @param array $actions Existing bulk actions. + * @return array + */ + public function add_bulk_export_action(array $actions): array { + + $actions['export'] = __('Export Sites', 'ultimate-multisite'); + + return $actions; + } + + /** + * Handle bulk export action. + * + * @since 2.5.0 + * + * @param string $action The action name. + * @param string $model The model name. + * @param array $ids The selected IDs. + * @return void + */ + public function handle_bulk_export($action, $model, $ids): void { + + $item_ids = array_filter($ids); + + foreach ($item_ids as $item_id) { + wu_exporter_export($item_id, ['uploads' => true], true); + } + } + + /** + * Reset upload limits for importing. + * + * @since 2.5.0 + * @return void + */ + public function reset_upload_limits(): void { + + @ini_set('upload_max_size', '2048M'); // phpcs:ignore + @ini_set('post_max_size', '2064M'); // phpcs:ignore + @ini_set('max_execution_time', '0'); // phpcs:ignore + + if (is_main_site()) { + add_filter( + 'upload_mimes', + function ($mimes) { + $mimes['zip'] = 'application/zip'; + $mimes['gz'] = 'application/x-gzip'; + return $mimes; + }, + 999 + ); + + if (! defined('ALLOW_UNFILTERED_UPLOADS')) { + define('ALLOW_UNFILTERED_UPLOADS', true); + } + + add_filter('get_space_allowed', fn() => 999999); + } + } + + /** + * Handle import form submission (non-AJAX). + * + * @since 2.5.0 + * @return void + */ + public function maybe_handle_import(): void { + + if (! wu_request('wu-cancel-import')) { + return; + } + + check_admin_referer('wu-cancel-import'); + + $hash = wu_request('wu-cancel-import'); + + wu_exporter_delete_transient("wu_pending_site_import_{$hash}"); + + wp_safe_redirect( + add_query_arg( + 'error', + __('Import cancelled.', 'ultimate-multisite'), + remove_query_arg(['updated', 'wu-cancel-import', '_wpnonce']) + ) + ); + + exit; + } + + /** + * Convert URL to local file path. + * + * @since 2.5.0 + * + * @param string $url The URL to convert. + * @return string|false + */ + private function url_to_path(string $url) { + + $upload_dir = wp_upload_dir(); + $base_url = $upload_dir['baseurl']; + $base_dir = $upload_dir['basedir']; + + if (strpos($url, $base_url) === 0) { + return str_replace($base_url, $base_dir, $url); + } + + return false; + } + + /** + * Maybe exclude WP Ultimo and other plugins from the generated zip. + * + * @since 2.5.0 + * + * @param array $files_to_zip The files to be zipped. + * @return array + */ + public function maybe_exclude_wp_ultimo_plugins(array $files_to_zip): array { + + if (isset($files_to_zip['wp-content/plugins'])) { + $plugins_folder = $files_to_zip['wp-content/plugins']; + + /** + * Allows developers to manage a plugin list that maybe exclude from the generated zip. + * + * @since 2.5.0 + * + * @param array $plugin_list The plugins that will be excluded. + * @return array The plugin list. + */ + $not_name = apply_filters( + 'wu_site_exporter_plugin_exclusion_list', + [ + 'wp-ultimo*', + 'ultimate-multisite*', + ] + ); + + $plugins = Finder::create() + ->depth('== 0') + ->ignoreVCS(true) + ->notName($not_name) + ->in($plugins_folder); + + foreach ($plugins as $plugin) { + $files_to_zip[ 'wp-content/plugins/' . $plugin->getRelativePathname() ] = $plugin->getPathName(); + } + + unset($files_to_zip['wp-content/plugins']); + } + + return $files_to_zip; + } + + /** + * Maybe adds the the hook to the cron. + * + * @since 2.5.0 + * @return void + */ + public function maybe_run_imports(): void { + + if (! wp_next_scheduled('wu_import_site')) { + wp_schedule_event(time() + 10, 'wu_site_every_minute', 'wu_import_site'); + } + } + + /** + * Maybe adds a new schedule. + * + * @since 2.5.0 + * + * @param array $schedules The list of available schedules. + * @return array + */ + public function maybe_add_schedule(array $schedules): array { + + $pending_imports = wu_exporter_get_pending_imports(); + + if (empty($pending_imports)) { + return $schedules; + } + + $schedules['wu_site_every_minute'] = [ + 'interval' => 60, + 'display' => esc_html__('Every 60 Seconds', 'ultimate-multisite'), + ]; + + return $schedules; + } + + /** + * Handles a site export generation. + * + * @since 2.5.0 + * + * @param int $site_id The ID of the site being exported. + * @param array $options Export generation options. + * @param string $hash The hash generated. + * @return bool + */ + public function handle_site_export(int $site_id, array $options = [], string $hash = ''): bool { + + $this->load_dependencies(); + + $export_name = sprintf('wu-site-export-%s-%s-%s.zip', $site_id, gmdate('Y-m-d'), time()); + + $command = new \TenUp\MU_Migration\Commands\ExportCommand(); + + $base_path = wu_maybe_create_folder('wu-site-exports'); + + $args = [ + 'blog_id' => $site_id, + ]; + + if (wu_get_isset($options, 'plugins')) { + $args['plugins'] = 1; + } + + if (wu_get_isset($options, 'themes')) { + $args['themes'] = 1; + } + + if (wu_get_isset($options, 'uploads')) { + $args['uploads'] = 1; + } + + $start = microtime(true); + + $command->all([$base_path . $export_name], $args); + + $time = microtime(true) - $start; + + wu_exporter_save_generation_time($export_name, $time); + + wu_exporter_delete_transient("wu_pending_site_export_{$hash}"); + + return true; + } + + /** + * Handles the site import. + * + * @since 2.5.0 + * @return bool + */ + public function handle_site_import(): bool { + + $pending_imports = wu_exporter_get_pending_imports(); + + if (empty($pending_imports)) { + return false; + } + + $file_name = ''; + $options = []; + $hash = ''; + + foreach ($pending_imports as $pi) { + if (! isset($pi->options[1]['running'])) { + $file_name = $pi->options[0]; + $options = $pi->options[1]; + $hash = $pi->options[2]; + + break; + } + } + + if (empty($file_name)) { + return false; + } + + $options['running'] = false; + + $base = [ + $file_name, + $options, + $hash, + ]; + + wu_exporter_set_transient("wu_pending_site_import_{$hash}", $base, 2 * HOUR_IN_SECONDS); + + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once ABSPATH . 'wp-admin/includes/theme.php'; + + $this->load_dependencies(); + + $command = new \TenUp\MU_Migration\Commands\ImportCommand(); + + $defaults = [ + 'url' => '', + 'new_url' => '', + 'zip_url' => '', + 'delete_zip' => true, + 'mysql-single-transaction' => true, + ]; + + $args = wp_parse_args($options, $defaults); + + $start = microtime(true); + + $command->all([$file_name], $args); + + $time = microtime(true) - $start; + + wu_exporter_save_import_time($file_name, $time); + + wu_exporter_delete_transient("wu_pending_site_import_{$hash}"); + + $delete_file = isset($options['delete_file']); + + if ($delete_file) { + $attachment_id = attachment_url_to_postid($options['zip_url']); + + wp_delete_attachment($attachment_id, true); + } + + return true; + } + + /** + * Load the commands from mu-migration. + * + * @since 2.5.0 + * @return void + */ + public function load_dependencies(): void { + + $base_path = wu_path('inc/site-exporter/mu-migration'); + + if (file_exists($base_path . '/vendor/autoload.php')) { + require_once $base_path . '/vendor/autoload.php'; + require_once $base_path . '/includes/helpers.php'; + require_once $base_path . '/includes/commands/class-mu-migration.php'; + require_once $base_path . '/includes/commands/class-mu-migration-base.php'; + require_once $base_path . '/includes/commands/class-mu-migration-export.php'; + require_once $base_path . '/includes/commands/class-mu-migration-import.php'; + require_once $base_path . '/includes/commands/class-mu-migration-posts.php'; + require_once $base_path . '/includes/commands/class-mu-migration-users.php'; + } + } + + /** + * Returns true if all the requirements are met. + * + * @since 2.5.0 + * @return bool + */ + public function is_loaded(): bool { + + return $this->loaded; + } +} diff --git a/inc/site-exporter/database/class-import.php b/inc/site-exporter/database/class-import.php new file mode 100644 index 00000000..86b32f07 --- /dev/null +++ b/inc/site-exporter/database/class-import.php @@ -0,0 +1,235 @@ +filename = $filename; + $this->username = $username; + $this->password = $password; + $this->database = $database; + $this->host = $host; + $this->forceDropTables = $forceDropTables; + + // Connect to the database + $this->connect(); + + // If dropTables is true then delete the tables + if ($dropTables === true) { + $this->dropTables($site_id); + } + + // Open file and import the sql + $this->openfile(); + } + + /** + * Connect to the database + * + * @return void + */ + private function connect(): void { + + try { + $this->db = new PDO('mysql:host=' . $this->host . ';dbname=' . $this->database, $this->username, $this->password); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } catch (PDOException $e) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo 'Cannot connect: ' . $e->getMessage() . "\n"; + } + } + + /** + * Run queries + * + * @param string $query The query to perform. + * @return \PDOStatement|false + */ + private function query(string $query) { + + try { + return $this->db->query($query); + } catch (Error $e) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo 'Error with query: ' . $e->getMessage() . "\n"; + return false; + } + } + + /** + * Select all tables, loop through and delete/drop them. + * + * @param int|bool $site_id The site ID. + * @return void + */ + private function dropTables($site_id = false): void { + + global $wpdb; + + if (! $site_id) { + return; + } + + $table = is_multisite() ? $wpdb->base_prefix . $site_id : $wpdb->base_prefix; + + // Get list of tables + $tables = $this->query("SHOW TABLES LIKE'" . $table . "%'"); + if ($tables !== null && $tables !== false) { + // Loop through tables + $results = $tables->fetchAll(PDO::FETCH_COLUMN); + foreach ($results as $table) { + if (strpos($table, 'user') !== false) { + continue; + } + + if ($this->forceDropTables === true) { + // Delete table with foreign key checks disabled + $this->query('SET FOREIGN_KEY_CHECKS=0; DROP TABLE `' . $table . '`; SET FOREIGN_KEY_CHECKS=1;'); + } else { + // Delete table + $this->query('DROP TABLE `' . $table . '`'); + } + } + } + } + + /** + * Open $filename, loop through and import the commands + * + * @return void + */ + private function openfile(): void { + + try { + // If file cannot be found throw error + if (! file_exists($this->filename)) { + throw new Exception("Error: File not found.\n"); + } + + // Read in entire file + $fp = fopen($this->filename, 'r'); + + // Temporary variable, used to store current query + $templine = ''; + + // Loop through each line + while (($line = fgets($fp)) !== false) { + // Skip it if it's a comment + if (substr($line, 0, 2) === '--' || $line === '') { + continue; + } + + // Add this line to the current segment + $templine .= $line; + + // If it has a semicolon at the end, it's the end of the query + if (substr(trim($line), -1, 1) === ';') { + $this->query($templine); + // Reset temp variable to empty + $templine = ''; + } + } + + // Close the file + fclose($fp); + } catch (Exception $e) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo 'Error importing: ' . $e->getMessage() . "\n"; + } + } +} diff --git a/inc/site-exporter/database/class-manager.php b/inc/site-exporter/database/class-manager.php new file mode 100644 index 00000000..e023f7d0 --- /dev/null +++ b/inc/site-exporter/database/class-manager.php @@ -0,0 +1,200 @@ +wpdb = $wpdb; + } + + /** + * Returns an array of tables in the database. + * + * If multisite && mainsite: all tables of the site + * If multisite && subsite: all tables of current blog + * If single site: all tables of the site + * + * @param int $blog_id The blog ID. + * @return array + */ + public function get_tables(int $blog_id = 0): array { + + if (function_exists('is_multisite') && is_multisite()) { + if (is_main_site() && $blog_id === 0) { + $tables = $this->wpdb->get_col("SHOW TABLES LIKE'" . $this->wpdb->base_prefix . "%'"); + } else { + $tables = $this->wpdb->get_col( + "SHOW TABLES LIKE '" . $this->wpdb->base_prefix . absint($blog_id) . "\_%'" + ); + } + } else { + $tables = $this->wpdb->get_col("SHOW TABLES LIKE'" . $this->wpdb->base_prefix . "%'"); + } + + return $tables; + } + + /** + * Returns an array containing the size of each database table. + * + * @return array Table => Table Size in KB + */ + public function get_sizes(): array { + + $sizes = []; + $tables = $this->wpdb->get_results('SHOW TABLE STATUS', ARRAY_A); + + if (is_array($tables) && ! empty($tables)) { + foreach ($tables as $table) { + $size = round($table['Data_length'] / 1024, 2); + // Translators: %s is the value of the size in kByte. + $sizes[ $table['Name'] ] = sprintf(__('(%s KB)', 'ultimate-multisite'), $size); + } + } + + return $sizes; + } + + /** + * Returns the number of rows in a table. + * + * @param string $table The table name. + * @return int + */ + public function get_rows(string $table): int { + + $table = esc_sql($table); + + return (int) $this->wpdb->get_var("SELECT COUNT(*) FROM $table"); + } + + /** + * Gets the columns in a table. + * + * @param string $table The table to check. + * @return array 1st Element: Primary Key, 2nd Element All Columns + */ + public function get_columns(string $table): array { + + $primary_key = null; + $columns = []; + $fields = $this->wpdb->get_results('DESCRIBE ' . $table); + + if (is_array($fields)) { + foreach ($fields as $column) { + $columns[] = $column->Field; + if ('PRI' === $column->Key) { + $primary_key = $column->Field; + } + } + } + + return [$primary_key, $columns]; + } + + /** + * Get table content. + * + * @param string $table The Table Name. + * @param int $start The start row. + * @param int $end Number of Rows to be fetched. + * @return array|null + */ + public function get_table_content(string $table, int $start, int $end): ?array { + + $data = $this->wpdb->get_results("SELECT * FROM $table LIMIT $start, $end", ARRAY_A); + + return $data; + } + + /** + * Update table. + * + * @param string $table The table name. + * @param array $update_sql The update SQL parts. + * @param array $where_sql The where SQL parts. + * @return int|false + */ + public function update(string $table, array $update_sql, array $where_sql) { + + $sql = 'UPDATE ' . $table . ' SET ' . implode(', ', $update_sql) . + ' WHERE ' . implode(' AND ', array_filter($where_sql)); + + return $this->wpdb->query($sql); + } + + /** + * Get table structure. + * + * @param string $table The table name. + * @return array|object|null + */ + public function get_table_structure(string $table) { + + return $this->wpdb->get_results("DESCRIBE $table"); + } + + /** + * Returns a SQL CREATE TABLE Statement for the table provided in $table. + * + * @param string $table The Name of the table we want to create the statement for. + * @return array|object|null + */ + public function get_create_table_statement(string $table) { + + return $this->wpdb->get_results("SHOW CREATE TABLE $table", ARRAY_N); + } + + /** + * Flush table. + * + * @return void + */ + public function flush(): void { + + $this->wpdb->flush(); + } + + /** + * Get base prefix. + * + * @return string + */ + public function get_base_prefix(): string { + + return $this->wpdb->base_prefix; + } +} diff --git a/inc/site-exporter/database/class-max-execution-time.php b/inc/site-exporter/database/class-max-execution-time.php new file mode 100644 index 00000000..c8cebe44 --- /dev/null +++ b/inc/site-exporter/database/class-max-execution-time.php @@ -0,0 +1,62 @@ +store(); + } + + @set_time_limit($time); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } + + /** + * Restore timelimit. + * + * @return void + */ + public function restore(): void { + + $this->set($this->met); + } + + /** + * Fetch the max_execution_time from php.ini. + * + * @return void + */ + public function store(): void { + + $this->met = (int) ini_get('max_execution_time'); + } +} diff --git a/inc/site-exporter/database/class-replace.php b/inc/site-exporter/database/class-replace.php new file mode 100644 index 00000000..a02ffa19 --- /dev/null +++ b/inc/site-exporter/database/class-replace.php @@ -0,0 +1,431 @@ +dbm = $dbm; + $this->max_execution = $max_execution; + } + + /** + * The main loop for search and replace. + * + * This walks every table in the db that was selected and then + * walks every row and column replacing all occurrences of a string with another. + * We split large tables into blocks when dealing with them to save on memory consumption. + * + * @param string $search What we want to replace. + * @param string $replace What we want to replace it with. + * @param array $tables The name of the table we want to look at. + * @param string|null $csv CSV data. + * + * @return array|\WP_Error Collection of information gathered during the run. + * @throws \Throwable Whatever exception is thrown if WP_DEBUG is true. + */ + public function run_search_replace(string $search, string $replace, array $tables, ?string $csv = null) { + + if ($search === $replace && '' !== $search) { + return new \WP_Error('error', esc_html__('Search and replace pattern can\'t be the same!', 'ultimate-multisite')); + } + + $this->max_execution->set(); + + $report = [ + 'errors' => null, + 'changes' => [], + 'tables' => '0', + 'changes_count' => '0', + ]; + + foreach ((array) $tables as $table) { + // Count tables. + ++$report['tables']; + $table_report = $this->replace_values($search, $replace, $table, $csv); + // Log changes if any. + if (0 !== $table_report['change']) { + $report['changes'][ $table ] = $table_report; + $report['changes_count'] += $table_report['change']; + } + } + + $this->max_execution->restore(); + + return $report; + } + + /** + * Replace data values inside the table. + * + * @param string $search Search string. + * @param string $replace Replace string. + * @param string $table Table name. + * @param string|null $csv CSV data. + * + * @return array + * @throws \Throwable Whatever exception is thrown if WP_DEBUG is true. + */ + public function replace_values(string $search = '', string $replace = '', string $table = '', ?string $csv = null): array { + + $table_report = [ + 'table_name' => $table, + 'rows' => 0, + 'change' => 0, + 'changes' => [], + 'updates' => 0, + 'start' => microtime(), + 'end' => microtime(), + 'errors' => [], + ]; + + // Check we have a search string, bail if not. + if (empty($search) && empty($csv)) { + $table_report['errors'][] = 'Search string is empty'; + + return $table_report; + } + + // Grab table structure in order to determine which columns are used to store serialized values in it. + $table_structure = $this->dbm->get_table_structure($table); + + if (! $table_structure) { + return $table_report; + } + + $maybe_serialized = []; + foreach ($table_structure as $struct) { + // Longtext is used for meta_values as best practice in all of the automatic products. + if (0 === stripos($struct->Type, 'longtext')) { + $maybe_serialized[] = strtolower($struct->Field); + } + } + + // Split columns array in primary key string and columns array. + $columns = $this->dbm->get_columns($table); + + list($primary_key, $columns) = $columns; + + if (null === $primary_key) { + $table_report['errors'][] = "The table \"{$table}\" has no primary key. Changes will have to be made manually."; + + return $table_report; + } + + $table_report['start'] = microtime(); + + // Count the number of rows we have in the table if large we'll split into blocks + $row_count = $this->dbm->get_rows($table); + + $page_size = $this->page_size; + $pages = ceil($row_count / $page_size); + + // Prepare CSV data + if (null !== $csv) { + $csv_lines = explode("\n", $csv); + $csv_head = str_getcsv('search,replace'); + foreach ($csv_lines as $line) { + $this->csv_data[] = array_combine($csv_head, str_getcsv($line)); + } + } + + for ($page = 0; $page < $pages; $page++) { + $start = $page * $page_size; + + // Grab the content of the table + $data = $this->dbm->get_table_content($table, $start, $page_size); + + if (! $data) { + $table_report['errors'][] = 'no data in table ' . $table; + } + + foreach ($data as $row) { + ++$table_report['rows']; + + $update_sql = []; + $where_sql = []; + $update = true; + + foreach ($columns as $column) { + // Skip the GUID column per WordPress Codex. + if ($column === 'guid') { + continue; + } + + $data_to_fix = $row[ $column ]; + + if ($column === $primary_key) { + $where_sql[] = $column . ' = "' . $this->mysql_escape_mimic($data_to_fix) . '"'; + continue; + } + + // Run a search replace on the data that'll respect the serialisation. + if (is_serialized($data_to_fix, false) + && in_array(strtolower($column), $maybe_serialized, true) + ) { + // Run a search replace on the data that'll respect the serialisation. + $edited_data = $this->recursive_unserialize_replace($search, $replace, $data_to_fix); + } else { + $edited_data = str_replace($search, $replace, $data_to_fix); + } + + // Run a search replace by CSV parameters if CSV input present + if (null !== $csv) { + foreach ($this->csv_data as $entry) { + $edited_data = is_serialized($edited_data, false) ? + $this->recursive_unserialize_replace( + $entry['search'], + $entry['replace'], + $edited_data + ) : str_replace($entry['search'], $entry['replace'], $data_to_fix); + } + } + + // Something was changed. + if ($edited_data !== $data_to_fix) { + ++$table_report['change']; + + // log changes + $table_report['changes'][] = [ + 'row' => $table_report['rows'], + 'column' => $column, + 'from' => $data_to_fix, + 'to' => $edited_data, + ]; + + $update_sql[] = $column . ' = "' . $this->mysql_escape_mimic($edited_data) . '"'; + $update = true; + } + } + + // Determine what to do with updates. + if (true === $this->dry_run) { + // Don't do anything if a dry run. + continue; + } + + if ($update && ! empty($where_sql) && ! empty($update_sql)) { + // If there are changes to make, run the query. + $result = $this->dbm->update($table, $update_sql, $where_sql); + + if (! $result) { + $table_report['errors'][] = sprintf( + /* translators: $1 is the number of rows found in database */ + esc_html__('Error updating row: %d.', 'ultimate-multisite'), + $table_report['rows'] + ); + } else { + ++$table_report['updates']; + } + } + } + } + + $table_report['end'] = microtime(true); + + $this->dbm->flush(); + + return $table_report; + } + + /** + * Mimics the mysql_real_escape_string function. + * + * Adapted from a post by 'feedr' on php.net. + * + * @link http://php.net/manual/en/function.mysql-real-escape-string.php#101248 + * + * @param array|string $input The string to escape. + * @return array|string + */ + public function mysql_escape_mimic($input) { + + if (is_array($input)) { + return array_map([$this, 'mysql_escape_mimic'], $input); + } + + if (! empty($input) && is_string($input)) { + return str_replace( + ['\\', "\0", "\n", "\r", "'", '"', "\x1a"], + ['\\\\', '\\0', '\\n', '\\r', "\\'", '\\"', '\\Z'], + $input + ); + } + + return $input; + } + + /** + * Recursive unserialize replace + * + * Take a serialised array and unserialize it replacing elements as needed and + * unserializing any subordinate arrays and performing the replace on those too. + * + * @param string $from String we're looking to replace. + * @param string $to What we want it to be replaced with. + * @param array|string|object $data Used to pass any subordinate arrays back to in. + * @param bool $serialised Does the array passed via $data need serialising. + * + * @throws \Throwable Whatever exception is thrown if WP_DEBUG is true. + * @return mixed The original array with all elements replaced as needed. + */ + public function recursive_unserialize_replace(string $from = '', string $to = '', $data = '', bool $serialised = true) { + + // Some unserialized data cannot be re-serialised eg. SimpleXMLElements. + try { + $unserialized = is_serialized($data, false) ? maybe_unserialize($data) : false; + + if ($unserialized !== false && ! is_serialized_string($data)) { + $data = $this->recursive_unserialize_replace($from, $to, $unserialized, false); + } elseif (is_array($data)) { + $_tmp = []; + foreach ((array) $data as $key => $value) { + $_tmp[ $key ] = $this->recursive_unserialize_replace($from, $to, $value, false); + } + + $data = $_tmp; + + unset($_tmp); + } elseif (is_object($data)) { + $_tmp = $data; + $props = get_object_vars($data); + foreach ($props as $key => $value) { + $_tmp->$key = $this->recursive_unserialize_replace($from, $to, $value, false); + } + + $data = $_tmp; + + unset($_tmp); + } else { + // Don't process data that isn't a string. + if (! is_string($data)) { + return $data; + } + + $marker = false; + + if (is_serialized_string($data)) { + $data = maybe_unserialize($data); + $marker = true; + } + + $tmp_data = $data; + $data = str_replace($from, $to, $data); + + // Do not allow to return valid serialized data, + // If after replacement data is_serialized then add one | to the replacement. + if (is_serialized($data, false)) { + $data = str_replace($from, '|' . $to, $tmp_data); + } + + if ($marker) { + $data = maybe_serialize($data); + } + } + + if ($serialised) { + $data = serialize($data); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + } + } catch (Exception $throwable) { + if (defined('WP_DEBUG') && WP_DEBUG) { + throw $throwable; + } + + /** + * Error action for search and replace. + * + * @param \Throwable $throwable The exception. + */ + do_action('wu_site_exporter_replace_error', $throwable); + } + + return $data; + } + + /** + * Returns true, if dry run, false if not + * + * @return bool + */ + public function get_dry_run(): bool { + + return $this->dry_run; + } + + /** + * Sets the dry run option. + * + * @param bool $state TRUE for dry run, FALSE for writing changes to DB. + * @return bool + */ + public function set_dry_run(bool $state): bool { + + $this->dry_run = $state; + + return $state; + } +} diff --git a/inc/site-exporter/mu-migration/LICENSE.txt b/inc/site-exporter/mu-migration/LICENSE.txt new file mode 100644 index 00000000..917a49d6 --- /dev/null +++ b/inc/site-exporter/mu-migration/LICENSE.txt @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2016, Nícholas André, 10up Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/inc/site-exporter/mu-migration/README.md b/inc/site-exporter/mu-migration/README.md new file mode 100644 index 00000000..c7aef260 --- /dev/null +++ b/inc/site-exporter/mu-migration/README.md @@ -0,0 +1,125 @@ +# MU-Migration + +> This WP-CLI plugin makes the process of moving sites from single WordPress sites to a Multisite instance (or vice-versa) much easier. It exports everything into a zip package which can be used to automatically import it within the desired Multisite installation. + +[![Build Status](https://travis-ci.org/10up/MU-Migration.svg?branch=master)](https://travis-ci.org/10up/MU-Migration) [![Support Level](https://img.shields.io/badge/support-stable-blue.svg)](#support-level) [![MIT License](https://img.shields.io/github/license/10up/MU-Migration.svg)](https://github.com/10up/MU-Migration/blob/master/LICENSE.md) + +## Install + +### Requirements + +- PHP >= 7.1 +- WP-CLI >= 0.23 + +### Via WP-CLI Package Manager (requires wp-cli >= 0.23) + +Just run `wp package install 10up/mu-migration`. + +If you run into memory issues when installing the package, it's a known WP-CLI issue. [Check this out for a workaround](https://make.wordpress.org/cli/handbook/common-issues/#php-fatal-error-allowed-memory-size-of-999999-bytes-exhausted-tried-to-allocate-99-bytes). + +### Installing as a plugin + +Clone this repo onto `plugins/` folder, run `composer install` to fetch dependencies and activate the plugin. + +You need to install this on both the site you're moving and the target Multisite installation. + +## Why do I need this? + +Moving single WordPress sites to a Multisite environment (or the opposite) can be challenging, specially if you're moving more than one site to +Multisite. You'd need to replace tables prefix, update post_author and wc_customer_user (if WooCommerce is installed) with the new +users ID (Multisite has a shared users table, so if you're moving more than one site you can't guarantee that users will have the same IDs) and more. + +There are also a few housekeeping tasks that needs to be done to make sure that the new site will work smoothly and without losing any data. + +## How it works + +With a simple command you can export a whole site into a zip package. + +``` +$ wp mu-migration export all site.zip --plugins --themes --uploads +``` + +The above command will export users, tables, plugins folder, themes folder and the uploads folder to a unique zip file that you can +move to the Multisite server in order to be imported with the `import all` command. The optional flags `--plugins --themes --uploads`, +add the plugins folder, themes folder and uploads folder to the zip file respectively. + +You can also export subsites from another multisite instance, to do so pass the `--blog_id` parameter. E.g: + +``` +$ wp mu-migration export all subsite.zip --blog_id=2 +``` + +The following command can be used to import a site from a zip package. + +``` +$ wp mu-migration import all site.zip +``` + +If importing into Multisite, it will create a new site within your Multisite network based on the site you have just exported, if importing into a single install, it will override your single install with the exported subsite. + +The `import all` command will take care +of everything that needs to be done when moving a site to Multisite (replacing tables prefix, updating post_author IDs and etc). + +If you need to set up a new url for the site you're importing (if importing into staging or local environments for example), +you can pass it to the `import all` command. + +``` +$ wp mu-migration import all site.zip --new_url=multisite.dev/site +``` + +The import command also supports a `--mysql-single-transaction` parameter that will wrap the sql export into a single transaction to commit +all changes from the import at one time preventing the write from overwhelming the database server, especially in clustered mysql enviroments. + +You can also pass `--blog_id` to the `import all` command, in that case the import will override an existing subsite. + +``` +$ wp mu-migration import all site.zip --new_url=multisite.dev/site --blog_id=3 +``` + +In some edge cases it's possible that MU-Migration won't be able to recognize all custom tables while doing the export of a subsite in multisite +so if you need to move non-default tables, you can use `--tables` or `--non-default-tables` param. E.g + +``` +$ wp mu-migration export all subsite.zip --blog_id=1 --non-default-tables=wp_my_custom_table,wp_my_custom_table_2 +``` + +If you pass `--tables` it will export only the tables you have passed. So if you use it make sure to pass all tables that you want +to be exported, including the default tables. + +After the migration you can also manage users password (reset passwords and/or force users to reset their passwords). + +``` +$ wp mu-migration update_passwords [] [--blog_id=] [--reset] [--send_email] [--include=] [--exclude=] +``` + +E.g + +The following command will update all users passwords of the site with ID 3 to `new_weak_password`. + +``` +$ wp mu-migration update_passwords 'new_weak_password' --blog_id=3 +``` + +This next command will reset all users passwords to a random secure password and it will send a reset email to all users. + +``` +$ wp mu-migration update_passwords --reset --blog_id=3 --send_email +``` + +## Notes + +If your theme and plugins have been done in the WordPress way, you should not have major problem after the migration, keep in mind +that some themes may experience incompatibilities issue if doing things in the wrong way. (E.g hardcoded links like '/contact' etc) +Depending of the codebase of the site you're migrating you may need to push some fixes to your code. + +## Support Level + +**Stable:** 10up is not planning to develop any new features for this, but will still respond to bug reports and security concerns. We welcome PRs, but any that include new features should be small and easy to integrate and should not include breaking changes. We otherwise intend to keep this tested up to the most recent version of WordPress. + +## Credits + +Created by Nícholas André ([@nicholas_io](https://profiles.wordpress.org/nicholas_io)), at [10up.com](http://10up.com). + +## Like what you see? + + diff --git a/inc/site-exporter/mu-migration/bin/install-package-tests.sh b/inc/site-exporter/mu-migration/bin/install-package-tests.sh new file mode 100644 index 00000000..2ff49dd8 --- /dev/null +++ b/inc/site-exporter/mu-migration/bin/install-package-tests.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -ex + +install_db() { + mysql -e 'CREATE DATABASE IF NOT EXISTS wp_cli_test;' -uroot + mysql -e 'GRANT ALL PRIVILEGES ON wp_cli_test.* TO "wp_cli_test"@"localhost" IDENTIFIED BY "password1"' -uroot +} + +install_db diff --git a/inc/site-exporter/mu-migration/bin/test.sh b/inc/site-exporter/mu-migration/bin/test.sh new file mode 100644 index 00000000..65198feb --- /dev/null +++ b/inc/site-exporter/mu-migration/bin/test.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -ex + +# Run the unit tests, if they exist +if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ] +then + phpunit +fi + +# Run the functional tests +BEHAT_TAGS=$(php utils/behat-tags.php) + +vendor/bin/behat --format progress $BEHAT_TAGS --strict diff --git a/inc/site-exporter/mu-migration/composer.json b/inc/site-exporter/mu-migration/composer.json new file mode 100644 index 00000000..2233fc7d --- /dev/null +++ b/inc/site-exporter/mu-migration/composer.json @@ -0,0 +1,25 @@ +{ + "name": "10up/mu-migration", + "description": "A set of WP-CLI commands to support the migration of single WordPress instances to multisite", + "type": "wp-cli-package", + "homepage": "https://github.com/10up/MU-Migration", + "support": { + "issues": "https://github.com/10up/MU-Migration/issues" + }, + "require": { + "alchemy/zippy": "0.4.8" + }, + "license": "MIT", + "authors": [ + { + "name": "Nícholas André", + "email": "nicholas@iotecnologia.com.br" + } + ], + "autoload": { + "files": [ "mu-migration.php" ] + }, + "require-dev": { + "behat/behat": "~2.5" + } +} diff --git a/inc/site-exporter/mu-migration/composer.lock b/inc/site-exporter/mu-migration/composer.lock new file mode 100644 index 00000000..e73b243e --- /dev/null +++ b/inc/site-exporter/mu-migration/composer.lock @@ -0,0 +1,1150 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "755a0b7cfc174b3e983f2153afebb0be", + "packages": [ + { + "name": "alchemy/zippy", + "version": "0.4.8", + "source": { + "type": "git", + "url": "https://github.com/alchemy-fr/Zippy.git", + "reference": "2c231a0956daa0fa1e6057d411504ff98717392e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/alchemy-fr/Zippy/zipball/2c231a0956daa0fa1e6057d411504ff98717392e", + "reference": "2c231a0956daa0fa1e6057d411504ff98717392e", + "shasum": "" + }, + "require": { + "doctrine/collections": "~1.0", + "php": ">=5.5", + "symfony/filesystem": "^2.0.5|^3.0", + "symfony/polyfill-mbstring": "^1.3", + "symfony/process": "^2.1|^3.0" + }, + "require-dev": { + "ext-zip": "*", + "guzzle/guzzle": "~3.0", + "guzzlehttp/guzzle": "^6.0", + "phpunit/phpunit": "^4.0|^5.0", + "symfony/finder": "^2.0.5|^3.0" + }, + "suggest": { + "ext-zip": "To use the ZipExtensionAdapter", + "guzzle/guzzle": "To use the GuzzleTeleporter with Guzzle 3", + "guzzlehttp/guzzle": "To use the GuzzleTeleporter with Guzzle 6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Alchemy\\Zippy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alchemy", + "email": "dev.team@alchemy.fr", + "homepage": "http://www.alchemy.fr/" + } + ], + "description": "Zippy, the archive manager companion", + "keywords": [ + "bzip", + "compression", + "tar", + "zip" + ], + "support": { + "issues": "https://github.com/alchemy-fr/Zippy/issues", + "source": "https://github.com/alchemy-fr/Zippy/tree/master" + }, + "time": "2017-03-03T08:42:32+00:00" + }, + { + "name": "doctrine/collections", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "2b44dd4cbca8b5744327de78bafef5945c7e7b5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/2b44dd4cbca8b5744327de78bafef5945c7e7b5e", + "reference": "2b44dd4cbca8b5744327de78bafef5945c7e7b5e", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^0.5.3 || ^1", + "php": "^7.1.3 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0 || ^10.0", + "phpstan/phpstan": "^1.4.8", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.1.5", + "vimeo/psalm": "^4.22" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "lib/Doctrine/Common/Collections" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/1.8.0" + }, + "time": "2022-09-01T20:12:10+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "phpstan/phpstan": "1.4.10 || 1.10.15", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psalm/plugin-phpunit": "0.18.4", + "psr/log": "^1 || ^2 || ^3", + "vimeo/psalm": "4.30.0 || 5.12.0" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/v1.1.1" + }, + "time": "2023-06-03T09:27:29+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v3.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "b2da5009d9bacbd91d83486aa1f44c793a8c380d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b2da5009d9bacbd91d83486aa1f44c793a8c380d", + "reference": "b2da5009d9bacbd91d83486aa1f44c793a8c380d", + "shasum": "" + }, + "require": { + "php": ">=5.5.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Filesystem Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/3.0" + }, + "time": "2016-07-20T05:43:46+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/process", + "version": "v3.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b8648cf1d5af12a44a51d07ef9bf980921f15fca", + "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v3.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T10:57:07+00:00" + } + ], + "packages-dev": [ + { + "name": "behat/behat", + "version": "v2.5.5", + "source": { + "type": "git", + "url": "https://github.com/Behat/Behat.git", + "reference": "c1e48826b84669c97a1efa78459aedfdcdcf2120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Behat/zipball/c1e48826b84669c97a1efa78459aedfdcdcf2120", + "reference": "c1e48826b84669c97a1efa78459aedfdcdcf2120", + "shasum": "" + }, + "require": { + "behat/gherkin": "~2.3.0", + "php": ">=5.3.1", + "symfony/config": "~2.3", + "symfony/console": "~2.0", + "symfony/dependency-injection": "~2.0", + "symfony/event-dispatcher": "~2.0", + "symfony/finder": "~2.0", + "symfony/translation": "~2.3", + "symfony/yaml": "~2.0" + }, + "require-dev": { + "phpunit/phpunit": "~3.7.19" + }, + "suggest": { + "behat/mink-extension": "for integration with Mink testing framework", + "behat/symfony2-extension": "for integration with Symfony2 web framework", + "behat/yii-extension": "for integration with Yii web framework" + }, + "bin": [ + "bin/behat" + ], + "type": "library", + "autoload": { + "psr-0": { + "Behat\\Behat": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Scenario-oriented BDD framework for PHP 5.3", + "homepage": "http://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Symfony2" + ], + "support": { + "issues": "https://github.com/Behat/Behat/issues", + "source": "https://github.com/Behat/Behat/tree/v2.5.5" + }, + "time": "2015-06-01T09:37:55+00:00" + }, + { + "name": "behat/gherkin", + "version": "v2.3.5", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "2b33963da5525400573560c173ab5c9c057e1852" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/2b33963da5525400573560c173ab5c9c057e1852", + "reference": "2b33963da5525400573560c173ab5c9c057e1852", + "shasum": "" + }, + "require": { + "php": ">=5.3.1", + "symfony/finder": "~2.0" + }, + "require-dev": { + "symfony/config": "~2.0", + "symfony/translation": "~2.0", + "symfony/yaml": "~2.0" + }, + "suggest": { + "symfony/config": "If you want to use Config component to manage resources", + "symfony/translation": "If you want to use Symfony2 translations adapter", + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "2.2-dev" + } + }, + "autoload": { + "psr-0": { + "Behat\\Gherkin": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP 5.3", + "homepage": "http://behat.org/", + "keywords": [ + "BDD", + "Behat", + "DSL", + "Symfony2", + "parser" + ], + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/2.3" + }, + "time": "2013-10-15T11:22:17+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "symfony/config", + "version": "v2.8.52", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "7dd5f5040dc04c118d057fb5886563963eb70011" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/7dd5f5040dc04c118d057fb5886563963eb70011", + "reference": "7dd5f5040dc04c118d057fb5886563963eb70011", + "shasum": "" + }, + "require": { + "php": ">=5.3.9", + "symfony/filesystem": "~2.3|~3.0.0", + "symfony/polyfill-ctype": "~1.8" + }, + "require-dev": { + "symfony/yaml": "~2.7|~3.0.0" + }, + "suggest": { + "symfony/yaml": "To use the yaml reference dumper" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Config Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v2.8.50" + }, + "time": "2018-11-26T09:38:12+00:00" + }, + { + "name": "symfony/console", + "version": "v2.8.52", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "cbcf4b5e233af15cd2bbd50dee1ccc9b7927dc12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/cbcf4b5e233af15cd2bbd50dee1ccc9b7927dc12", + "reference": "cbcf4b5e233af15cd2bbd50dee1ccc9b7927dc12", + "shasum": "" + }, + "require": { + "php": ">=5.3.9", + "symfony/debug": "^2.7.2|~3.0.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/event-dispatcher": "~2.1|~3.0.0", + "symfony/process": "~2.1|~3.0.0" + }, + "suggest": { + "psr/log-implementation": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/process": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/console/tree/v2.8.52" + }, + "time": "2018-11-20T15:55:20+00:00" + }, + { + "name": "symfony/debug", + "version": "v3.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug.git", + "reference": "697c527acd9ea1b2d3efac34d9806bf255278b0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug/zipball/697c527acd9ea1b2d3efac34d9806bf255278b0a", + "reference": "697c527acd9ea1b2d3efac34d9806bf255278b0a", + "shasum": "" + }, + "require": { + "php": ">=5.5.9", + "psr/log": "~1.0" + }, + "conflict": { + "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + }, + "require-dev": { + "symfony/class-loader": "~2.8|~3.0", + "symfony/http-kernel": "~2.8|~3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Debug\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Debug Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/debug/tree/3.0" + }, + "abandoned": "symfony/error-handler", + "time": "2016-07-30T07:22:48+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v2.8.52", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "c306198fee8f872a8f5f031e6e4f6f83086992d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/c306198fee8f872a8f5f031e6e4f6f83086992d8", + "reference": "c306198fee8f872a8f5f031e6e4f6f83086992d8", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "conflict": { + "symfony/expression-language": "<2.6" + }, + "require-dev": { + "symfony/config": "~2.2|~3.0.0", + "symfony/expression-language": "~2.6|~3.0.0", + "symfony/yaml": "~2.3.42|~2.7.14|~2.8.7|~3.0.7" + }, + "suggest": { + "symfony/config": "", + "symfony/expression-language": "For using expressions in service container configuration", + "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them", + "symfony/yaml": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DependencyInjection Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/2.8" + }, + "time": "2019-04-16T11:33:46+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v2.8.52", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "a77e974a5fecb4398833b0709210e3d5e334ffb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a77e974a5fecb4398833b0709210e3d5e334ffb0", + "reference": "a77e974a5fecb4398833b0709210e3d5e334ffb0", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "^2.0.5|~3.0.0", + "symfony/dependency-injection": "~2.6|~3.0.0", + "symfony/expression-language": "~2.6|~3.0.0", + "symfony/stopwatch": "~2.3|~3.0.0" + }, + "suggest": { + "symfony/dependency-injection": "", + "symfony/http-kernel": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony EventDispatcher Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v2.8.50" + }, + "time": "2018-11-21T14:20:20+00:00" + }, + { + "name": "symfony/finder", + "version": "v2.8.52", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "1444eac52273e345d9b95129bf914639305a9ba4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/1444eac52273e345d9b95129bf914639305a9ba4", + "reference": "1444eac52273e345d9b95129bf914639305a9ba4", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Finder Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v2.8.50" + }, + "time": "2018-11-11T11:18:13+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "5bbc823adecdae860bb64756d639ecfec17b050a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a", + "reference": "5bbc823adecdae860bb64756d639ecfec17b050a", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/translation", + "version": "v2.8.52", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "fc58c2a19e56c29f5ba2736ec40d0119a0de2089" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/fc58c2a19e56c29f5ba2736ec40d0119a0de2089", + "reference": "fc58c2a19e56c29f5ba2736ec40d0119a0de2089", + "shasum": "" + }, + "require": { + "php": ">=5.3.9", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/config": "<2.7" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "~2.8", + "symfony/intl": "~2.7.25|^2.8.18|~3.2.5", + "symfony/yaml": "~2.2|~3.0.0" + }, + "suggest": { + "psr/log-implementation": "To use logging capability in translator", + "symfony/config": "", + "symfony/yaml": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Translation Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v2.8.50" + }, + "time": "2018-11-24T21:16:41+00:00" + }, + { + "name": "symfony/yaml", + "version": "v2.8.52", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "02c1859112aa779d9ab394ae4f3381911d84052b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/02c1859112aa779d9ab394ae4f3381911d84052b", + "reference": "02c1859112aa779d9ab394ae4f3381911d84052b", + "shasum": "" + }, + "require": { + "php": ">=5.3.9", + "symfony/polyfill-ctype": "~1.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Yaml Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v2.8.52" + }, + "time": "2018-11-11T11:18:13+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/inc/site-exporter/mu-migration/features/01-load-wp-cli.feature b/inc/site-exporter/mu-migration/features/01-load-wp-cli.feature new file mode 100644 index 00000000..035b8673 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/01-load-wp-cli.feature @@ -0,0 +1,10 @@ +Feature: Test that WP-CLI loads. + + Scenario: WP-CLI loads for your tests + Given a WP install + + When I run `wp eval 'echo "Hello world.";'` + Then STDOUT should contain: + """ + Hello world. + """ diff --git a/inc/site-exporter/mu-migration/features/02-info.feature b/inc/site-exporter/mu-migration/features/02-info.feature new file mode 100644 index 00000000..aadd1422 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/02-info.feature @@ -0,0 +1,12 @@ +Feature: Test an MU-Migration info command. + + Scenario: MU-Migration info works + Given a WP install + When I run `wp mu-migration info` + Then STDOUT should contain: + """ +MU-Migration version: %Yv{MU_MIGRATION_VERSION}%n + +Created by Nícholas André at 10up +Github: https://github.com/10up/MU-Migration + """ diff --git a/inc/site-exporter/mu-migration/features/03-export.feature b/inc/site-exporter/mu-migration/features/03-export.feature new file mode 100644 index 00000000..8be8d980 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/03-export.feature @@ -0,0 +1,341 @@ +Feature: Test MU-Migration export commands. + + Scenario: MU-Migration is able to export the users of a single site + Given a WP install + + When I run `wp user list --format=count` + And save STDOUT as {USERS_COUNT} + + When I run `wp mu-migration export users users.csv` + Then the users.csv file should exist + Then STDOUT should be: + """ + Success: {USERS_COUNT} users have been exported + """ + + When I run `cat users.csv` + Then STDOUT should be CSV containing: + | ID | user_login | user_email | role | + | 1 | admin | admin@example.com | administrator | + + When I run `wp eval-file {SRC_DIR}/features/tests/csv_matches_user.php users.csv` + Then STDOUT should be: + """ + Success + """ + Scenario: MU-Migration is able to export users of a subsite in multisite + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + + When I run `wp user list --format=count --url=example.com/site-2` + And save STDOUT as {USERS_COUNT} + + When I run `wp mu-migration export users users-subsite.csv --blog_id=2` + Then the users-subsite.csv file should exist + Then STDOUT should be: + """ + Success: {USERS_COUNT} users have been exported + """ + + When I run `cat users-subsite.csv` + Then STDOUT should be CSV containing: + | ID | user_login | user_email | role | + | 1 | admin | admin@example.com | administrator | + + When I run `wp eval-file {SRC_DIR}/features/tests/csv_matches_user.php users-subsite.csv --url=example.com/site-2` + Then STDOUT should be: + """ + Success + """ + + Scenario: MU-Migration is able to export tables of a single site + Given a WP install + + When I run `wp db prefix` + And save STDOUT as {DB_PREFIX} + + When I run `wp mu-migration export tables tables.sql` + Then the tables.sql file should exist + Then the tables.sql file should contain: + """ + CREATE TABLE `{DB_PREFIX}posts` |AND| + CREATE TABLE `{DB_PREFIX}postmeta` |AND| + CREATE TABLE `{DB_PREFIX}terms` |AND| + CREATE TABLE `{DB_PREFIX}termmeta` |AND| + CREATE TABLE `{DB_PREFIX}options` |AND| + CREATE TABLE `{DB_PREFIX}comments` |AND| + CREATE TABLE `{DB_PREFIX}commentmeta` |AND| + CREATE TABLE `{DB_PREFIX}term_taxonomy` |AND| + CREATE TABLE `{DB_PREFIX}term_relationships` + """ + Then the tables.sql file should not contain: + """ + CREATE TABLE `{DB_PREFIX}users` |AND| + CREATE TABLE `{DB_PREFIX}usermeta` + """ + Then STDOUT should be: + """ + Success: The export is now complete + """ + + When I run `wp mu-migration export tables tables1.sql --tables={DB_PREFIX}posts` + Then the tables1.sql file should contain: + """ + CREATE TABLE `{DB_PREFIX}posts` + """ + Then the tables1.sql file should not contain: + """ + CREATE TABLE `{DB_PREFIX}postmeta` |AND| + CREATE TABLE `{DB_PREFIX}terms` |AND| + CREATE TABLE `{DB_PREFIX}termmeta` |AND| + CREATE TABLE `{DB_PREFIX}options` |AND| + CREATE TABLE `{DB_PREFIX}comments` |AND| + CREATE TABLE `{DB_PREFIX}commentmeta` |AND| + CREATE TABLE `{DB_PREFIX}term_taxonomy` |AND| + CREATE TABLE `{DB_PREFIX}term_relationships` + """ + + When I run `wp db query "CREATE TABLE {DB_PREFIX}custom_table (ID int, text longtext)"` + And I run `wp db query "CREATE TABLE custom_table_no_prefix (ID int, text longtext)"` + And I run `wp mu-migration export tables tables2.sql --non-default-tables={DB_PREFIX}custom_table,custom_table_no_prefix` + Then the tables2.sql file should contain: + """ + CREATE TABLE `{DB_PREFIX}custom_table` |AND| + CREATE TABLE `custom_table_no_prefix` |AND| + """ + Scenario: MU-Migration is able to export tables for subsites in Multisite + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + + When I run `wp db prefix --url=example.com/site-3` + And save STDOUT as {DB_PREFIX} + + When I run `wp mu-migration export tables tables-subsite.sql --blog_id=3` + Then the tables-subsite.sql file should exist + Then the tables-subsite.sql file should contain: + """ + CREATE TABLE `{DB_PREFIX}posts` |AND| + CREATE TABLE `{DB_PREFIX}postmeta` |AND| + CREATE TABLE `{DB_PREFIX}terms` |AND| + CREATE TABLE `{DB_PREFIX}termmeta` |AND| + CREATE TABLE `{DB_PREFIX}options` |AND| + CREATE TABLE `{DB_PREFIX}comments` |AND| + CREATE TABLE `{DB_PREFIX}commentmeta` |AND| + CREATE TABLE `{DB_PREFIX}term_taxonomy` |AND| + CREATE TABLE `{DB_PREFIX}term_relationships` |AND| + """ + Then the tables-subsite.sql file should not contain: + """ + CREATE TABLE `{DB_PREFIX}users` |AND| + CREATE TABLE `{DB_PREFIX}usermeta` |AND| + CREATE TABLE `{DB_PREFIX}blog_versions` |AND| + CREATE TABLE `{DB_PREFIX}blogs` |AND| + CREATE TABLE `{DB_PREFIX}site` |AND| + CREATE TABLE `{DB_PREFIX}sitemeta` |AND| + CREATE TABLE `{DB_PREFIX}registration_log` |AND| + CREATE TABLE `{DB_PREFIX}signups` + """ + Then STDOUT should be: + """ + Success: The export is now complete + """ + + When I run `wp mu-migration export tables tables-subsite1.sql --tables={DB_PREFIX}posts --blog_id=3` + Then the tables-subsite1.sql file should contain: + """ + CREATE TABLE `{DB_PREFIX}posts` + """ + Then the tables-subsite1.sql file should not contain: + """ + CREATE TABLE `{DB_PREFIX}postmeta` |AND| + CREATE TABLE `{DB_PREFIX}terms` |AND| + CREATE TABLE `{DB_PREFIX}termmeta` |AND| + CREATE TABLE `{DB_PREFIX}options` |AND| + CREATE TABLE `{DB_PREFIX}comments` |AND| + CREATE TABLE `{DB_PREFIX}commentmeta` |AND| + CREATE TABLE `{DB_PREFIX}term_taxonomy` |AND| + CREATE TABLE `{DB_PREFIX}term_relationships` + """ + When I run `wp db query "CREATE TABLE {DB_PREFIX}custom_table (ID int, text longtext)"` + And I run `wp db query "CREATE TABLE custom_table_no_prefix (ID int, text longtext)"` + And I run `wp mu-migration export tables tables-subsite2.sql --non-default-tables={DB_PREFIX}custom_table,custom_table_no_prefix` + Then the tables-subsite2.sql file should contain: + """ + CREATE TABLE `{DB_PREFIX}custom_table` |AND| + CREATE TABLE `custom_table_no_prefix` |AND| + """ + + Scenario: MU-Migration is able to export a single site into a zip package without themes,plugins and uploads + Given a WP install + + When I run `wp db prefix` + And save STDOUT as {DB_PREFIX} + And I run `wp plugin install jetpack --activate` + + When I run `wp mu-migration export all single-site.zip` + Then STDOUT should be: + """ + Exporting site meta data... + Exporting users... + Exporting tables + Zipping files.... + Success: A zip file named single-site.zip has been created + """ + + When I run `unzip single-site.zip -d temp_folder` + And I run `ls temp_folder/ | grep .csv` + And save STDOUT as {MU_CSV_FILE} + And I run `ls temp_folder/ | grep .json` + And save STDOUT as {MU_JSON_FILE} + And I run `ls temp_folder/ | grep .sql` + And save STDOUT as {MU_SQL_FILE} + Then the single-site.zip file should exist + Then the temp_folder/{MU_CSV_FILE} file should exist + Then the temp_folder/{MU_JSON_FILE} file should exist + Then the temp_folder/{MU_SQL_FILE} file should exist + Then the temp_folder/{MU_SQL_FILE} file should contain: + """ + CREATE TABLE `{DB_PREFIX}posts` |AND| + CREATE TABLE `{DB_PREFIX}postmeta` |AND| + CREATE TABLE `{DB_PREFIX}terms` |AND| + CREATE TABLE `{DB_PREFIX}termmeta` |AND| + CREATE TABLE `{DB_PREFIX}options` |AND| + CREATE TABLE `{DB_PREFIX}comments` |AND| + CREATE TABLE `{DB_PREFIX}commentmeta` |AND| + CREATE TABLE `{DB_PREFIX}term_taxonomy` |AND| + CREATE TABLE `{DB_PREFIX}term_relationships` + """ + + When I run `wp eval-file {SRC_DIR}/features/tests/csv_matches_user.php temp_folder/{MU_CSV_FILE}` + Then STDOUT should be: + """ + Success + """ + When I run `cat temp_folder/{MU_JSON_FILE}` + Then STDOUT should be JSON containing: + """ + { + "url": "http:\/\/example.com", + "name": "WP CLI Site", + "admin_email":"admin@example.com", + "site_language":"en-US", + "db_prefix": "{DB_PREFIX}", + "blog_id": 1, + "blog_plugins": ["jetpack\/jetpack.php"], + "network_plugins": [] + } + """ + Scenario: MU-Migration is able to export a single site into a zip package with themes,plugins and uploads + Given a WP install + + When I run `wp db prefix` + And save STDOUT as {DB_PREFIX} + And I run `wp plugin install jetpack --activate` + And I run `wp mu-migration export all single-site.zip --themes --plugins --uploads` + Then the single-site.zip file should exist + + When I run `unzip single-site.zip -d temp_folder` + And I run `ls temp_folder/ | grep .csv` + And save STDOUT as {MU_CSV_FILE} + And I run `ls temp_folder/ | grep .json` + And save STDOUT as {MU_JSON_FILE} + And I run `ls temp_folder/ | grep .sql` + And save STDOUT as {MU_SQL_FILE} + Then the temp_folder/{MU_CSV_FILE} file should exist + Then the temp_folder/{MU_JSON_FILE} file should exist + Then the temp_folder/{MU_SQL_FILE} file should exist + Then the temp_folder/wp-content directory should exist + Then the temp_folder/wp-content/themes directory should exist + Then the temp_folder/wp-content/plugins directory should exist + Then the temp_folder/wp-content/plugins/jetpack directory should exist + Then the temp_folder/wp-content/uploads directory should exist + + Scenario: MU-Migration is able to export a subsite without themes, plugins and uploads + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + + When I run `wp db prefix --url=example.com/site-2` + And save STDOUT as {DB_PREFIX} + And I run `wp plugin install jetpack shortcode-ui --activate --url=example.com/site-2` + + When I run `wp mu-migration export all subsite-2.zip --blog_id=2` + Then STDOUT should be: + """ + Exporting site meta data... + Exporting users... + Exporting tables + Zipping files.... + Success: A zip file named subsite-2.zip has been created + """ + + When I run `unzip subsite-2.zip -d temp_folder_subsite` + And I run `ls temp_folder_subsite/ | grep .csv` + And save STDOUT as {MU_CSV_FILE} + And I run `ls temp_folder_subsite/ | grep .json` + And save STDOUT as {MU_JSON_FILE} + And I run `ls temp_folder_subsite/ | grep .sql` + And save STDOUT as {MU_SQL_FILE} + Then the subsite-2.zip file should exist + Then the temp_folder_subsite/{MU_CSV_FILE} file should exist + Then the temp_folder_subsite/{MU_JSON_FILE} file should exist + Then the temp_folder_subsite/{MU_SQL_FILE} file should exist + Then the temp_folder_subsite/{MU_SQL_FILE} file should contain: + """ + CREATE TABLE `{DB_PREFIX}posts` |AND| + CREATE TABLE `{DB_PREFIX}postmeta` |AND| + CREATE TABLE `{DB_PREFIX}terms` |AND| + CREATE TABLE `{DB_PREFIX}termmeta` |AND| + CREATE TABLE `{DB_PREFIX}options` |AND| + CREATE TABLE `{DB_PREFIX}comments` |AND| + CREATE TABLE `{DB_PREFIX}commentmeta` |AND| + CREATE TABLE `{DB_PREFIX}term_taxonomy` |AND| + CREATE TABLE `{DB_PREFIX}term_relationships` + """ + + When I run `wp eval-file {SRC_DIR}/features/tests/csv_matches_user.php temp_folder_subsite/{MU_CSV_FILE} --url=example.com/site-2` + Then STDOUT should be: + """ + Success + """ + When I run `cat temp_folder_subsite/{MU_JSON_FILE}` + Then STDOUT should be JSON containing: + """ + { + "url": "http:\/\/example.com/site-2", + "name": "Site 2", + "admin_email":"admin@example.com", + "site_language":"en-US", + "db_prefix": "{DB_PREFIX}", + "blog_id": 2, + "blog_plugins": ["jetpack\/jetpack.php", "shortcode-ui\/shortcode-ui.php"], + "network_plugins": [] + } + """ + Scenario: MU-Migration is able to export a subsite into a zip package with themes,plugins and uploads + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + + When I run `wp db prefix` + And save STDOUT as {DB_PREFIX} + And I run `wp plugin install jetpack --activate --url=example.com/site-2` + And I run `wp media import {SRC_DIR}/features/data/images/*.jpg --url=example.com/site-2` + And I run `wp mu-migration export all subsite-2.zip --themes --plugins --uploads` + Then the subsite-2.zip file should exist + + When I run `unzip subsite-2.zip -d temp_folder_subsite` + And I run `ls temp_folder_subsite/ | grep .csv` + And save STDOUT as {MU_CSV_FILE} + And I run `ls temp_folder_subsite/ | grep .json` + And save STDOUT as {MU_JSON_FILE} + And I run `ls temp_folder_subsite/ | grep .sql` + And save STDOUT as {MU_SQL_FILE} + Then the temp_folder_subsite/{MU_CSV_FILE} file should exist + Then the temp_folder_subsite/{MU_JSON_FILE} file should exist + Then the temp_folder_subsite/{MU_SQL_FILE} file should exist + Then the temp_folder_subsite/wp-content directory should exist + Then the temp_folder_subsite/wp-content/themes directory should exist + Then the temp_folder_subsite/wp-content/plugins directory should exist + Then the temp_folder_subsite/wp-content/plugins/jetpack directory should exist + Then the temp_folder_subsite/wp-content/uploads directory should exist + Then the temp_folder_subsite/wp-content/uploads/sites/2 directory should exist + diff --git a/inc/site-exporter/mu-migration/features/04-import.feature b/inc/site-exporter/mu-migration/features/04-import.feature new file mode 100644 index 00000000..e703a94a --- /dev/null +++ b/inc/site-exporter/mu-migration/features/04-import.feature @@ -0,0 +1,189 @@ +Feature: Test MU-Migration import commands. + + Scenario: MU-Migration is able to import the users from a single site into a subsite + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + Given a WP install in 'singlesite/' + + When I run `wp user generate --count=100 --path=singlesite` + And I run `wp user list --format=count --path=singlesite` + And save STDOUT as {SINGLE_SITE_USERS_COUNT} + And I run `wp mu-migration export users users.csv --path=singlesite` + Then the users.csv file should exist + Then STDOUT should be: + """ + Success: {SINGLE_SITE_USERS_COUNT} users have been exported + """ + When I run `wp user list --format=count --url=example.com/site-2` + Then STDOUT should be: + """ + 11 + """ + When I run `wp mu-migration import users users.csv --blog_id=2 --map_file=users-mapping.json` + Then STDOUT should contain: + """ + Parsing users.csv... + Success: A map file has been created: users-mapping.json + Success: 90 users have been imported and 11 users already existed + """ + Then the users-mapping.json file should exist + When I run `wp eval-file {SRC_DIR}/features/tests/csv_matches_user.php users.csv users-mapping.json --url=example.com/site-2` + Then STDOUT should be: + """ + Success + """ + Scenario: MU-Migration is able to import the users from a subsite into a single site + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + Given a WP install in 'singlesite/' + + When I run `wp user list --format=count --blog_id=2` + And save STDOUT as {SUBSITE_USERS_COUNT} + And I run `wp mu-migration export users users.csv --blog_id=2` + Then the users.csv file should exist + Then STDOUT should be: + """ + Success: {SUBSITE_USERS_COUNT} users have been exported + """ + When I run `wp user list --format=count --path=singlesite` + Then STDOUT should be: + """ + 1 + """ + When I run `wp mu-migration import users users.csv --path=singlesite --map_file=users-mapping.json` + Then STDOUT should contain: + """ + Parsing users.csv... + Success: A map file has been created: users-mapping.json + Success: 10 users have been imported and 1 users already existed + """ + Then the users-mapping.json file should exist + When I run `wp eval-file {SRC_DIR}/features/tests/csv_matches_user.php users.csv users-mapping.json --path=singlesite` + Then STDOUT should be: + """ + Success + """ + Scenario: MU-Migration is able to import the users from a subsite into another subsite + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + + When I run `wp user list --format=count --blog_id=2` + And save STDOUT as {SUBSITE_USERS_COUNT} + And I run `wp mu-migration export users users.csv --blog_id=2` + Then the users.csv file should exist + Then STDOUT should be: + """ + Success: {SUBSITE_USERS_COUNT} users have been exported + """ + When I run `wp user list --format=count --url=example.com/site-3` + Then STDOUT should be: + """ + 11 + """ + When I run `wp mu-migration import users users.csv --blog_id=3 --map_file=users-mapping.json` + Then STDOUT should contain: + """ + Parsing users.csv... + Success: A map file has been created: users-mapping.json + Success: 0 users have been imported and 11 users already existed + """ + Then the users-mapping.json file should exist + When I run `wp eval-file {SRC_DIR}/features/tests/csv_matches_user.php users.csv users-mapping.json --url=example.com/site-3` + Then STDOUT should be: + """ + Success + """ + Scenario: MU-Migration is able to import tables from single site into a subsite + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + Given a WP install in 'singlesite/' + + When I run `wp post create --post_type=page --post_title='Test Post' --post_status=publish --post_date='2016-12-01 07:00:00' --path=singlesite` + And I run `wp mu-migration export tables tables.sql --path=singlesite` + And I run `wp db prefix --path=singlesite` + And save STDOUT as {DB_PREFIX} + And I run `wp db prefix --url=example.com/site-2` + And save STDOUT as {SUB_DB_PREFIX} + And I run `wp mu-migration import tables tables.sql --blog_id=2 --old_prefix={DB_PREFIX} --new_prefix={SUB_DB_PREFIX} --old_url=http://singlesite.com --new_url=http://example.com/site-2` + Then STDOUT should be: + """ + Database imported + Running search-replace + Search and Replace has been successfully executed + Running Search and Replace for uploads paths + Uploads paths have been successfully updated: wp-content/uploads -> wp-content/uploads/sites/2 + """ + + When I run `wp option get siteurl --url=http://example.com/site-2` + Then STDOUT should be: + """ + http://example.com/site-2 + """ + Scenario: MU-Migration is able to import tables from a subsite into another subsite + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + + When I run `wp mu-migration export tables tables.sql --blog_id=3` + And I run `wp db prefix --url=example.com/site-3` + And save STDOUT as {DB_PREFIX} + And I run `wp db prefix --url=example.com/site-2` + And save STDOUT as {SUB_DB_PREFIX} + And I run `wp mu-migration import tables tables.sql --blog_id=2 --original_blog_id=3 --old_prefix={DB_PREFIX} --new_prefix={SUB_DB_PREFIX} --old_url=http://example.com/site-3 --new_url=http://example.com/site-2` + Then STDOUT should be: + """ + Database imported + Running search-replace + Search and Replace has been successfully executed + Running Search and Replace for uploads paths + Uploads paths have been successfully updated: wp-content/uploads/sites/3 -> wp-content/uploads/sites/2 + """ + When I run `wp option get siteurl --url=http://example.com/site-2` + Then STDOUT should be: + """ + http://example.com/site-2 + """ + + When I run `wp mu-migration export tables tables.sql --blog_id=3` + And I run `wp db prefix --url=example.com/site-3` + And save STDOUT as {DB_PREFIX} + And I run `wp db prefix --url=example.com` + And save STDOUT as {SUB_DB_PREFIX} + And I run `wp mu-migration import tables tables.sql --blog_id=1 --original_blog_id=3 --old_prefix={DB_PREFIX} --new_prefix={SUB_DB_PREFIX} --old_url=http://example.com/site-3 --new_url=http://example.com` + Then STDOUT should be: + """ + Database imported + Running search-replace + Search and Replace has been successfully executed + Running Search and Replace for uploads paths + Uploads paths have been successfully updated: wp-content/uploads/sites/3 -> wp-content/uploads + """ + When I run `wp option get siteurl --url=http://example.com/` + Then STDOUT should be: + """ + http://example.com + """ + + Scenario: MU-Migration is able to import tables from a subsite into a single site + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + Given a WP install in 'singlesite/' + + When I run `wp mu-migration export tables tables.sql --blog_id=3` + And I run `wp db prefix --url=example.com/site-3` + And save STDOUT as {DB_PREFIX} + And I run `wp db prefix --path=singlesite` + And save STDOUT as {SINGLE_DB_PREFIX} + And I run `wp mu-migration import tables tables.sql --blog_id=1 --original_blog_id=3 --old_prefix={DB_PREFIX} --new_prefix={SINGLE_DB_PREFIX} --old_url=http://example.com/site-3 --new_url=http://singlesite.com --path=singlesite` + Then STDOUT should be: + """ + Database imported + Running search-replace + Search and Replace has been successfully executed + Running Search and Replace for uploads paths + Uploads paths have been successfully updated: wp-content/uploads/sites/3 -> wp-content/uploads + """ + When I run `wp option get siteurl --path=singlesite` + Then STDOUT should be: + """ + http://singlesite.com + """ \ No newline at end of file diff --git a/inc/site-exporter/mu-migration/features/05-posts.feature b/inc/site-exporter/mu-migration/features/05-posts.feature new file mode 100644 index 00000000..64ad7ac7 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/05-posts.feature @@ -0,0 +1,37 @@ +Feature: Test MU-Migration posts command. + + Scenario: MU-Migration is able to import the users and tables from a single site into a subsite and update the authors + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + Given a WP install in 'singlesite/' + + When I run `wp user create ann ann@example.com --path=singlesite` + And I run `wp post generate --count=10 --post_type=post --post_author=ann --path=singlesite` + And I insert arbitrary UID postmeta data for user "ann@example.com" in site "singlesite" + And I run `wp mu-migration export users users.csv --path=singlesite` + And I run `wp mu-migration export tables tables.sql --path=singlesite` + And I run `wp mu-migration import users users.csv --blog_id=2 --map_file=users-mapping.json` + Then the users-mapping.json file should exist + + When I run `wp db prefix --path=singlesite` + And save STDOUT as {DB_PREFIX} + And I run `wp db prefix --url=example.com/site-2` + And save STDOUT as {SUB_DB_PREFIX} + And I run `wp mu-migration import tables tables.sql --blog_id=2 --old_prefix={DB_PREFIX} --new_prefix={SUB_DB_PREFIX} --old_url=http://singlesite.com --new_url=http://example.com/site-2` + And I run `wp mu-migration posts update_author users-mapping.json --blog_id=2 --uid_fields='_a_userid_field'` + Then STDOUT should not contain: + """ + records failed to update its post_author: + """ + + When I run `wp user get $(wp post get 5 --url=example.com/site-2 --field=post_author) --field=login` + Then STDOUT should be: + """ + ann + """ + + When I run `wp user get $(wp post meta get 5 --url=example.com/site-2 _a_userid_field) --field=login` + Then STDOUT should be: + """ + ann + """ diff --git a/inc/site-exporter/mu-migration/features/06-migration.feature b/inc/site-exporter/mu-migration/features/06-migration.feature new file mode 100644 index 00000000..d9186676 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/06-migration.feature @@ -0,0 +1,126 @@ +Feature: MU-Migration import all command + Scenario: MU-Migration is able to export a single site into a zip package with themes,plugins and uploads and import into a Multisite Network + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + Given a WP install in 'singlesite/' + + When I run `wp theme install pixgraphy --activate --path=singlesite` + And I run `wp plugin install jetpack --activate --path=singlesite` + And I run `wp plugin install shortcode-ui --path=singlesite` + And I run `wp media import {SRC_DIR}/features/data/images/*.jpg --path=singlesite` + And I run `wp mu-migration export all single-site.zip --themes --plugins --uploads --path=singlesite` + Then the single-site.zip file should exist + + When I run `wp mu-migration import all single-site.zip --new_url=http://singlesite2.com` + And I run `wp site list --fields=blog_id,url` + Then STDOUT should be a table containing rows: + | blog_id | url | + | 4 | http://singlesite2.com/ | + + When I run `wp mu-migration import all single-site.zip --blog_id=4` + And I run `wp site list --fields=blog_id,url` + Then STDOUT should be a table containing rows: + | blog_id | url | + | 4 | http://singlesite.com/ | + + When I run `wp mu-migration import all single-site.zip --blog_id=4 --new_url=http://singlesite2.com --verbose` + Then STDOUT should contain: + """ + Uploads paths have been successfully updated: wp-content/uploads -> wp-content/uploads/sites/4 |AND| + Success: All done, your new site is available at http://singlesite2.com. Remember to flush the cache (memcache, redis etc). + """ + + When I run `wp site list --fields=blog_id,url` + Then STDOUT should be a table containing rows: + | blog_id | url | + | 4 | http://singlesite2.com/ | + + Then the wp-content/themes/pixgraphy directory should exist + Then the wp-content/plugins/jetpack directory should exist + Then the wp-content/plugins/shortcode-ui directory should not exist + + When I run `wp theme list --status=active --field=name --url=singlesite2.com` + Then STDOUT should be: + """ + pixgraphy + """ + When I run `wp plugin list --status-active --field=name --url=singlesite2.com` + Then STDOUT should contain: + """ + jetpack + """ + Scenario: MU-Migration is able to export a subsite into a zip package with themes,plugins and uploads and import into a single site + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + Given a WP install in 'singlesite/' + + When I run `wp theme install pixgraphy --activate --url=example.com/site-2` + And I run `wp plugin install jetpack --activate --url=example.com/site-2` + And I run `wp plugin install shortcode-ui --activate-network` + And I run `wp media import {SRC_DIR}/features/data/images/*.jpg --url=example.com/site-2` + And I run `wp mu-migration export all sub-site.zip --themes --plugins --uploads --blog_id=2` + Then the sub-site.zip file should exist + + When I run `wp mu-migration import all sub-site.zip --new_url=http://subsite.com --verbose --path=singlesite` + Then STDOUT should contain: + """ + Uploads paths have been successfully updated: wp-content/uploads/sites/2 -> wp-content/uploads |AND| + Success: All done, your new site is available at http://subsite.com. Remember to flush the cache (memcache, redis etc). + """ + Then the wp-content/themes/pixgraphy directory should exist + Then the wp-content/plugins/jetpack directory should exist + + When I run `wp option get siteurl --path=singlesite` + Then STDOUT should be: + """ + http://subsite.com + """ + + When I run `wp theme list --status=active --field=name --path=singlesite` + Then STDOUT should be: + """ + pixgraphy + """ + When I run `wp plugin list --status-active --field=name --path=singlesite` + Then STDOUT should contain: + """ + jetpack |AND| + shortcode-ui + """ + Scenario: MU-Migration is able to export a subsite into a zip package with themes,plugins and uploads and import into another subsite + Given a WP multisite subdirectory install + Given I create multiple sites with dummy content + + When I run `wp theme install pixgraphy --activate --url=example.com/site-2` + And I run `wp plugin install jetpack --activate --url=example.com/site-2` + And I run `wp plugin install shortcode-ui --activate-network` + And I run `wp media import {SRC_DIR}/features/data/images/*.jpg --url=example.com/site-2` + And I run `wp mu-migration export all sub-site.zip --themes --plugins --uploads --blog_id=2` + Then the sub-site.zip file should exist + + When I run `wp mu-migration import all sub-site.zip --blog_id=1 --new_url=http://example.com --verbose` + Then STDOUT should contain: + """ + Uploads paths have been successfully updated: wp-content/uploads/sites/2 -> wp-content/uploads |AND| + Success: All done, your new site is available at http://example.com. Remember to flush the cache (memcache, redis etc). + """ + Then the wp-content/themes/pixgraphy directory should exist + Then the wp-content/plugins/jetpack directory should exist + + When I run `wp option get siteurl` + Then STDOUT should be: + """ + http://example.com + """ + + When I run `wp theme list --status=active --field=name` + Then STDOUT should be: + """ + pixgraphy + """ + When I run `wp plugin list --status-active --field=name` + Then STDOUT should contain: + """ + jetpack |AND| + shortcode-ui + """ \ No newline at end of file diff --git a/inc/site-exporter/mu-migration/features/bootstrap/FeatureContext.php b/inc/site-exporter/mu-migration/features/bootstrap/FeatureContext.php new file mode 100644 index 00000000..cc1f5efe --- /dev/null +++ b/inc/site-exporter/mu-migration/features/bootstrap/FeatureContext.php @@ -0,0 +1,946 @@ +autoload->files) ) { + $contents = 'require:' . PHP_EOL; + foreach ( $composer->autoload->files as $file ) { + $contents .= ' - ' . dirname(dirname(__DIR__)) . '/' . $file . PHP_EOL; + } + @mkdir(sys_get_temp_dir() . '/wp-cli-package-test/'); + $project_config = sys_get_temp_dir() . '/wp-cli-package-test/config.yml'; + file_put_contents($project_config, $contents); + putenv('WP_CLI_CONFIG_PATH=' . $project_config); + } + } + // Inside WP-CLI +} else { + require_once __DIR__ . '/../../php/utils.php'; + require_once __DIR__ . '/../../php/WP_CLI/Process.php'; + require_once __DIR__ . '/../../php/WP_CLI/ProcessRun.php'; + if ( file_exists(__DIR__ . '/../../vendor/autoload.php') ) { + require_once __DIR__ . '/../../vendor/autoload.php'; + } elseif ( file_exists(__DIR__ . '/../../../../autoload.php') ) { + require_once __DIR__ . '/../../../../autoload.php'; + } +} + +/** + * Features context. + */ +class FeatureContext extends BehatContext implements ClosuredContextInterface { + + /** + * The current working directory for scenarios that have a "Given a WP install" or "Given an empty directory" step. Variable RUN_DIR. Lives until the end of the scenario. + */ + private static $run_dir; + + /** + * Where WordPress core is downloaded to for caching, and which is copied to RUN_DIR during a "Given a WP install" step. Lives until manually deleted. + */ + private static $cache_dir; + + /** + * The directory that holds the install cache, and which is copied to RUN_DIR during a "Given a WP install" step. Recreated on each suite run. + */ + private static $install_cache_dir; + + /** + * The directory that the WP-CLI cache (WP_CLI_CACHE_DIR, normally "$HOME/.wp-cli/cache") is set to on a "Given an empty cache" step. + * Variable SUITE_CACHE_DIR. Lives until the end of the scenario (or until another "Given an empty cache" step within the scenario). + */ + private static $suite_cache_dir; + + /** + * Where the current WP-CLI source repository is copied to for Composer-based tests with a "Given a dependency on current wp-cli" step. + * Variable COMPOSER_LOCAL_REPOSITORY. Lives until the end of the suite. + */ + private static $composer_local_repository; + + /** + * The test database settings. All but `dbname` can be set via environment variables. The database is dropped at the start of each scenario and created on a "Given a WP install" step. + */ + private static $db_settings = [ + 'dbname' => 'wp_cli_test', + 'dbuser' => 'wp_cli_test', + 'dbpass' => 'password1', + 'dbhost' => '127.0.0.1', + ]; + + /** + * Array of background process ids started by the current scenario. Used to terminate them at the end of the scenario. + */ + private $running_procs = []; + + /** + * Array of variables available as {VARIABLE_NAME}. Some are always set: CORE_CONFIG_SETTINGS, SRC_DIR, CACHE_DIR, WP_VERSION-version-latest. Some are step-dependent: + * RUN_DIR, SUITE_CACHE_DIR, COMPOSER_LOCAL_REPOSITORY, PHAR_PATH. Scenarios can define their own variables using "Given save" steps. Variables are reset for each scenario. + */ + public $variables = []; + + /** + * The current feature file and scenario line number as '.'. Used in RUN_DIR and SUITE_CACHE_DIR directory names. Set at the start of each scenario. + */ + private static $temp_dir_infix; + + /** + * Settings and variables for WP_CLI_TEST_LOG_RUN_TIMES run time logging. + */ + private static $log_run_times; // Whether to log run times - WP_CLI_TEST_LOG_RUN_TIMES env var. Set on `@BeforeScenario'. + private static $suite_start_time; // When the suite started, set on `@BeforeScenario'. + private static $output_to; // Where to output log - stdout|error_log. Set on `@BeforeSuite`. + private static $num_top_processes; // Number of processes/methods to output by longest run times. Set on `@BeforeSuite`. + private static $num_top_scenarios; // Number of scenarios to output by longest run times. Set on `@BeforeSuite`. + + private static $scenario_run_times = []; // Scenario run times (top `self::$num_top_scenarios` only). + private static $scenario_count = 0; // Scenario count, incremented on `@AfterScenario`. + private static $proc_method_run_times = []; // Array of run time info for proc methods, keyed by method name and arg, each a 2-element array containing run time and run count. + + /** + * Get the environment variables required for launched `wp` processes + */ + private static function get_process_env_variables() { + // Ensure we're using the expected `wp` binary + $bin_dir = getenv('WP_CLI_BIN_DIR') ?: realpath(__DIR__ . '/../../bin'); + $vendor_dir = realpath(__DIR__ . '/../../vendor/bin'); + $env = [ + 'PATH' => $bin_dir . ':' . $vendor_dir . ':' . getenv('PATH'), + 'BEHAT_RUN' => 1, + 'HOME' => sys_get_temp_dir() . '/wp-cli-home', + ]; + if ( $config_path = getenv('WP_CLI_CONFIG_PATH') ) { + $env['WP_CLI_CONFIG_PATH'] = $config_path; + } + if ( $term = getenv('TERM') ) { + $env['TERM'] = $term; + } + if ( $php_args = getenv('WP_CLI_PHP_ARGS') ) { + $env['WP_CLI_PHP_ARGS'] = $php_args; + } + if ( $travis_build_dir = getenv('TRAVIS_BUILD_DIR') ) { + $env['TRAVIS_BUILD_DIR'] = $travis_build_dir; + } + if ( $github_token = getenv('GITHUB_TOKEN') ) { + $env['GITHUB_TOKEN'] = $github_token; + } + return $env; + } + + /** + * We cache the results of `wp core download` to improve test performance. + * Ideally, we'd cache at the HTTP layer for more reliable tests. + */ + private static function cache_wp_files() { + $wp_version_suffix = ($wp_version = getenv('WP_VERSION')) ? "-$wp_version" : ''; + self::$cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix; + + if ( is_readable(self::$cache_dir . '/wp-config-sample.php') ) { + return; + } + + $cmd = Utils\esc_cmd('wp core download --force --path=%s', self::$cache_dir); + if ( $wp_version ) { + $cmd .= Utils\esc_cmd(' --version=%s', $wp_version); + } + Process::create($cmd, null, self::get_process_env_variables())->run_check(); + } + + /** + * @BeforeSuite + */ + public static function prepare(SuiteEvent $event) { + // Test performance statistics - useful for detecting slow tests. + if ( self::$log_run_times = getenv('WP_CLI_TEST_LOG_RUN_TIMES') ) { + self::log_run_times_before_suite($event); + } + + $result = Process::create('wp cli info', null, self::get_process_env_variables())->run_check(); + echo PHP_EOL; + echo $result->stdout; + echo PHP_EOL; + self::cache_wp_files(); + $result = Process::create(Utils\esc_cmd('wp core version --path=%s', self::$cache_dir), null, self::get_process_env_variables())->run_check(); + echo 'WordPress ' . $result->stdout; + echo PHP_EOL; + + // Remove install cache if any (not setting the static var). + $wp_version_suffix = ($wp_version = getenv('WP_VERSION')) ? "-$wp_version" : ''; + $wp_version_suffix = ($wp_version = getenv('WP_VERSION')) ? "-$wp_version" : ''; + $install_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-install-cache' . $wp_version_suffix; + if ( file_exists($install_cache_dir) ) { + self::remove_dir($install_cache_dir); + } + } + + /** + * @AfterSuite + */ + public static function afterSuite(SuiteEvent $event) { + if ( self::$composer_local_repository ) { + self::remove_dir(self::$composer_local_repository); + self::$composer_local_repository = null; + } + + if ( self::$log_run_times ) { + self::log_run_times_after_suite($event); + } + } + + /** + * @BeforeScenario + */ + public function beforeScenario($event) { + if ( self::$log_run_times ) { + self::log_run_times_before_scenario($event); + } + + $this->variables['SRC_DIR'] = realpath(__DIR__ . '/../..'); + + // Used in the names of the RUN_DIR and SUITE_CACHE_DIR directories. + self::$temp_dir_infix = null; + if ( $file = self::get_event_file($event, $line) ) { + self::$temp_dir_infix = basename($file) . '.' . $line; + } + } + + /** + * @AfterScenario + */ + public function afterScenario($event) { + + if ( self::$run_dir ) { + // remove altered WP install, unless there's an error + if ( $event->getResult() < 4 ) { + self::remove_dir(self::$run_dir); + } + self::$run_dir = null; + } + + // Remove WP-CLI package directory if any. Set to `wp package path` by package-command and scaffold-package-command features, and by cli-info.feature. + if ( isset($this->variables['PACKAGE_PATH']) ) { + self::remove_dir($this->variables['PACKAGE_PATH']); + } + + // Remove SUITE_CACHE_DIR if any. + if ( self::$suite_cache_dir ) { + self::remove_dir(self::$suite_cache_dir); + self::$suite_cache_dir = null; + } + + // Remove any background processes. + foreach ( $this->running_procs as $proc ) { + $status = proc_get_status($proc); + self::terminate_proc($status['pid']); + } + + if ( self::$log_run_times ) { + self::log_run_times_after_scenario($event); + } + } + + /** + * Terminate a process and any of its children. + */ + private static function terminate_proc($master_pid) { + + $output = `ps -o ppid,pid,command | grep $master_pid`; + + foreach ( explode(PHP_EOL, $output) as $line ) { + if ( preg_match('/^\s*(\d+)\s+(\d+)/', $line, $matches) ) { + $parent = $matches[1]; + $child = $matches[2]; + + if ( $parent == $master_pid ) { + self::terminate_proc($child); + } + } + } + + if ( ! posix_kill((int) $master_pid, 9) ) { + $errno = posix_get_last_error(); + // Ignore "No such process" error as that's what we want. + if ( 3 /*ESRCH*/ !== $errno ) { + throw new RuntimeException(posix_strerror($errno)); + } + } + } + + /** + * Create a temporary WP_CLI_CACHE_DIR. Exposed as SUITE_CACHE_DIR in "Given an empty cache" step. + */ + public static function create_cache_dir() { + if ( self::$suite_cache_dir ) { + self::remove_dir(self::$suite_cache_dir); + } + self::$suite_cache_dir = sys_get_temp_dir() . '/' . uniqid('wp-cli-test-suite-cache-' . self::$temp_dir_infix . '-', true); + mkdir(self::$suite_cache_dir); + return self::$suite_cache_dir; + } + + /** + * Initializes context. + * Every scenario gets its own context object. + * + * @param array $parameters context parameters (set them up through behat.yml) + */ + public function __construct(array $parameters) { + if ( getenv('WP_CLI_TEST_DBUSER') ) { + self::$db_settings['dbuser'] = getenv('WP_CLI_TEST_DBUSER'); + } + + if ( false !== getenv('WP_CLI_TEST_DBPASS') ) { + self::$db_settings['dbpass'] = getenv('WP_CLI_TEST_DBPASS'); + } + + if ( getenv('WP_CLI_TEST_DBHOST') ) { + self::$db_settings['dbhost'] = getenv('WP_CLI_TEST_DBHOST'); + } + + // load constants + require_once 'mu-migration.php'; + + $this->drop_db(); + $this->set_cache_dir(); + $this->variables['CORE_CONFIG_SETTINGS'] = Utils\assoc_args_to_str(self::$db_settings); + $this->variables['MU_MIGRATION_VERSION'] = TENUP_MU_MIGRATION_VERSION; + } + + public function getStepDefinitionResources() { + return glob(__DIR__ . '/../steps/*.php'); + } + + public function getHookDefinitionResources() { + return []; + } + + /** + * Replace {VARIABLE_NAME}. Note that variable names can only contain uppercase letters and underscores (no numbers). + */ + public function replace_variables($str) { + $ret = preg_replace_callback('/\{([A-Z_]+)\}/', [$this, '_replace_var'], $str); + if ( false !== strpos($str, '{WP_VERSION-') ) { + $ret = $this->_replace_wp_versions($ret); + } + return $ret; + } + + /** + * Replace variables callback. + */ + private function _replace_var($matches) { + $cmd = $matches[0]; + + foreach ( array_slice($matches, 1) as $key ) { + $cmd = str_replace('{' . $key . '}', $this->variables[ $key ], $cmd); + } + + return $cmd; + } + + /** + * Substitute "{WP_VERSION-version-latest}" variables. + */ + private function _replace_wp_versions($str) { + static $wp_versions = null; + if ( null === $wp_versions ) { + $wp_versions = []; + + $response = Requests::get('https://api.wordpress.org/core/version-check/1.7/', null, ['timeout' => 30]); + if ( 200 === $response->status_code && ($body = json_decode($response->body)) && is_object($body) && isset($body->offers) && is_array($body->offers) ) { + // Latest version alias. + $wp_versions['{WP_VERSION-latest}'] = count($body->offers) ? $body->offers[0]->version : ''; + foreach ( $body->offers as $offer ) { + $sub_ver = preg_replace('/(^[0-9]+\.[0-9]+)\.[0-9]+$/', '$1', $offer->version); + $sub_ver_key = "{WP_VERSION-{$sub_ver}-latest}"; + + $main_ver = preg_replace('/(^[0-9]+)\.[0-9]+$/', '$1', $sub_ver); + $main_ver_key = "{WP_VERSION-{$main_ver}-latest}"; + + if ( ! isset($wp_versions[ $main_ver_key ]) ) { + $wp_versions[ $main_ver_key ] = $offer->version; + } + if ( ! isset($wp_versions[ $sub_ver_key ]) ) { + $wp_versions[ $sub_ver_key ] = $offer->version; + } + } + } + } + return strtr($str, $wp_versions); + } + + /** + * Get the file and line number for the current behat event. + */ + private static function get_event_file($event, &$line) { + if ( method_exists($event, 'getScenario') ) { + $scenario_feature = $event->getScenario(); + } elseif ( method_exists($event, 'getFeature') ) { + $scenario_feature = $event->getFeature(); + } elseif ( method_exists($event, 'getOutline') ) { + $scenario_feature = $event->getOutline(); + } else { + return null; + } + $line = $scenario_feature->getLine(); + return $scenario_feature->getFile(); + } + + /** + * Create the RUN_DIR directory, unless already set for this scenario. + */ + public function create_run_dir() { + if ( ! isset($this->variables['RUN_DIR']) ) { + self::$run_dir = $this->variables['RUN_DIR'] = sys_get_temp_dir() . '/' . uniqid('wp-cli-test-run-' . self::$temp_dir_infix . '-', true); + mkdir($this->variables['RUN_DIR']); + } + } + + public function build_phar($version = 'same') { + $this->variables['PHAR_PATH'] = $this->variables['RUN_DIR'] . '/' . uniqid('wp-cli-build-', true) . '.phar'; + + // Test running against a package installed as a WP-CLI dependency + // WP-CLI installed as a project dependency + $make_phar_path = __DIR__ . '/../../../../../utils/make-phar.php'; + if ( ! file_exists($make_phar_path) ) { + // Test running against WP-CLI proper + $make_phar_path = __DIR__ . '/../../utils/make-phar.php'; + if ( ! file_exists($make_phar_path) ) { + // WP-CLI as a dependency of this project + $make_phar_path = __DIR__ . '/../../vendor/wp-cli/wp-cli/utils/make-phar.php'; + } + } + + $this->proc( + Utils\esc_cmd( + 'php -dphar.readonly=0 %1$s %2$s --version=%3$s && chmod +x %2$s', + $make_phar_path, + $this->variables['PHAR_PATH'], + $version + ) + )->run_check(); + } + + public function download_phar($version = 'same') { + if ( 'same' === $version ) { + $version = WP_CLI_VERSION; + } + + $download_url = sprintf( + 'https://github.com/wp-cli/wp-cli/releases/download/v%1$s/wp-cli-%1$s.phar', + $version + ); + + $this->variables['PHAR_PATH'] = $this->variables['RUN_DIR'] . '/' + . uniqid('wp-cli-download-', true) + . '.phar'; + + Process::create( + Utils\esc_cmd( + 'curl -sSfL %1$s > %2$s && chmod +x %2$s', + $download_url, + $this->variables['PHAR_PATH'] + ) + )->run_check(); + } + + /** + * CACHE_DIR is a cache for downloaded test data such as images. Lives until manually deleted. + */ + private function set_cache_dir() { + $path = sys_get_temp_dir() . '/wp-cli-test-cache'; + if ( ! file_exists($path) ) { + mkdir($path); + } + $this->variables['CACHE_DIR'] = $path; + } + + /** + * Run a MySQL command with `$db_settings`. + * + * @param string $sql_cmd Command to run. + * @param array $assoc_args Optional. Associative array of options. Default empty. + * @param bool $add_database Optional. Whether to add dbname to the $sql_cmd. Default false. + */ + private static function run_sql($sql_cmd, $assoc_args = [], $add_database = false) { + $default_assoc_args = [ + 'host' => self::$db_settings['dbhost'], + 'user' => self::$db_settings['dbuser'], + 'pass' => self::$db_settings['dbpass'], + ]; + if ( $add_database ) { + $sql_cmd .= ' ' . escapeshellarg(self::$db_settings['dbname']); + } + $start_time = microtime(true); + Utils\run_mysql_command($sql_cmd, array_merge($assoc_args, $default_assoc_args)); + if ( self::$log_run_times ) { + self::log_proc_method_run_time('run_sql ' . $sql_cmd, $start_time); + } + } + + public function create_db() { + $dbname = self::$db_settings['dbname']; + self::run_sql('mysql --no-defaults', ['execute' => "CREATE DATABASE IF NOT EXISTS $dbname"]); + } + + public function drop_db() { + $dbname = self::$db_settings['dbname']; + self::run_sql('mysql --no-defaults', ['execute' => "DROP DATABASE IF EXISTS $dbname"]); + } + + public function proc($command, $assoc_args = [], $path = '') { + if ( ! empty($assoc_args) ) { + $command .= Utils\assoc_args_to_str($assoc_args); + } + + $env = self::get_process_env_variables(); + if ( isset($this->variables['SUITE_CACHE_DIR']) ) { + $env['WP_CLI_CACHE_DIR'] = $this->variables['SUITE_CACHE_DIR']; + } + + if ( isset($this->variables['RUN_DIR']) ) { + $cwd = "{$this->variables['RUN_DIR']}/{$path}"; + } else { + $cwd = null; + } + + return Process::create($command, $cwd, $env); + } + + /** + * Start a background process. Will automatically be closed when the tests finish. + */ + public function background_proc($cmd) { + $descriptors = [ + 0 => STDIN, + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $proc = proc_open($cmd, $descriptors, $pipes, $this->variables['RUN_DIR'], self::get_process_env_variables()); + + sleep(1); + + $status = proc_get_status($proc); + + if ( ! $status['running'] ) { + throw new RuntimeException(stream_get_contents($pipes[2])); + } else { + $this->running_procs[] = $proc; + } + } + + public function move_files($src, $dest) { + rename($this->variables['RUN_DIR'] . "/$src", $this->variables['RUN_DIR'] . "/$dest"); + } + + /** + * Remove a directory (recursive). + */ + public static function remove_dir($dir) { + Process::create(Utils\esc_cmd('rm -rf %s', $dir))->run_check(); + } + + /** + * Copy a directory (recursive). Destination directory must exist. + */ + public static function copy_dir($src_dir, $dest_dir) { + Process::create(Utils\esc_cmd('cp -r %s/* %s', $src_dir, $dest_dir))->run_check(); + } + + public function add_line_to_wp_config(&$wp_config_code, $line) { + $token = "/* That's all, stop editing!"; + + $wp_config_code = str_replace($token, "$line\n\n$token", $wp_config_code); + } + + public function download_wp($subdir = '') { + $dest_dir = $this->variables['RUN_DIR'] . "/$subdir"; + + if ( $subdir ) { + mkdir($dest_dir); + } + + self::copy_dir(self::$cache_dir, $dest_dir); + + // disable emailing + mkdir($dest_dir . '/wp-content/mu-plugins'); + copy(__DIR__ . '/../extra/no-mail.php', $dest_dir . '/wp-content/mu-plugins/no-mail.php'); + } + + public function create_config($subdir = '', $extra_php = false) { + $params = self::$db_settings; + + // Replaces all characters that are not alphanumeric or an underscore into an underscore. + $params['dbprefix'] = $subdir ? preg_replace('#[^a-zA-Z\_0-9]#', '_', $subdir) : 'wp_'; + + $params['skip-salts'] = true; + + if ( false !== $extra_php ) { + $params['extra-php'] = $extra_php; + } + + $config_cache_path = ''; + if ( self::$install_cache_dir ) { + $config_cache_path = self::$install_cache_dir . '/config_' . md5(implode(':', $params) . ':subdir=' . $subdir); + $run_dir = '' !== $subdir ? ($this->variables['RUN_DIR'] . "/$subdir") : $this->variables['RUN_DIR']; + } + + if ( $config_cache_path && file_exists($config_cache_path) ) { + copy($config_cache_path, $run_dir . '/wp-config.php'); + } else { + $this->proc('wp config create', $params, $subdir)->run_check(); + if ( $config_cache_path && file_exists($run_dir . '/wp-config.php') ) { + copy($run_dir . '/wp-config.php', $config_cache_path); + } + } + } + + public function install_wp($subdir = '') { + $wp_version_suffix = ($wp_version = getenv('WP_VERSION')) ? "-$wp_version" : ''; + self::$install_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-install-cache' . $wp_version_suffix; + if ( ! file_exists(self::$install_cache_dir) ) { + mkdir(self::$install_cache_dir); + } + + $subdir = $this->replace_variables($subdir); + + $this->create_db(); + $this->create_run_dir(); + $this->download_wp($subdir); + $this->create_config($subdir); + + $url = empty($subdir) ? 'http://example.com' : 'http://' . basename($subdir) . '.com'; + $install_args = [ + 'url' => $url, + 'title' => 'WP CLI Site', + 'admin_user' => 'admin', + 'admin_email' => 'admin@example.com', + 'admin_password' => 'password1', + ]; + + $install_cache_path = ''; + if ( self::$install_cache_dir ) { + $install_cache_path = self::$install_cache_dir . '/install_' . md5(implode(':', $install_args) . ':subdir=' . $subdir); + $run_dir = '' !== $subdir ? ($this->variables['RUN_DIR'] . "/$subdir") : $this->variables['RUN_DIR']; + } + + if ( $install_cache_path && file_exists($install_cache_path) ) { + self::copy_dir($install_cache_path, $run_dir); + self::run_sql('mysql --no-defaults', ['execute' => "source {$install_cache_path}.sql"], true /*add_database*/); + } else { + $this->proc('wp core install', $install_args, $subdir)->run_check(); + if ( $install_cache_path ) { + mkdir($install_cache_path); + self::dir_diff_copy($run_dir, self::$cache_dir, $install_cache_path); + self::run_sql('mysqldump --no-defaults', ['result-file' => "{$install_cache_path}.sql"], true /*add_database*/); + } + } + } + + public function install_wp_with_composer($vendor_directory = 'vendor') { + $this->create_run_dir(); + $this->create_db(); + + $yml_path = $this->variables['RUN_DIR'] . '/wp-cli.yml'; + file_put_contents($yml_path, 'path: WordPress'); + + $this->composer_command('init --name="wp-cli/composer-test" --type="project" --no-interaction'); + $this->composer_command('config vendor-dir ' . $vendor_directory); + $this->composer_command('require johnpbloch/wordpress --optimize-autoloader --no-interaction'); + + $config_extra_php = "require_once dirname(__DIR__) . '/" . $vendor_directory . "/autoload.php';"; + $this->create_config('WordPress', $config_extra_php); + + $install_args = [ + 'url' => 'http://localhost:8080', + 'title' => 'WP CLI Site with both WordPress and wp-cli as Composer dependencies', + 'admin_user' => 'admin', + 'admin_email' => 'admin@example.com', + 'admin_password' => 'password1', + ]; + + $this->proc('wp core install', $install_args)->run_check(); + } + + public function composer_add_wp_cli_local_repository() { + if ( ! self::$composer_local_repository ) { + self::$composer_local_repository = sys_get_temp_dir() . '/' . uniqid('wp-cli-composer-local-', true); + mkdir(self::$composer_local_repository); + + $env = self::get_process_env_variables(); + $src = isset($env['TRAVIS_BUILD_DIR']) ? $env['TRAVIS_BUILD_DIR'] : realpath(__DIR__ . '/../../'); + + self::copy_dir($src, self::$composer_local_repository . '/'); + self::remove_dir(self::$composer_local_repository . '/.git'); + self::remove_dir(self::$composer_local_repository . '/vendor'); + } + $dest = self::$composer_local_repository . '/'; + $this->composer_command("config repositories.wp-cli '{\"type\": \"path\", \"url\": \"$dest\", \"options\": {\"symlink\": false}}'"); + $this->variables['COMPOSER_LOCAL_REPOSITORY'] = self::$composer_local_repository; + } + + public function composer_require_current_wp_cli() { + $this->composer_add_wp_cli_local_repository(); + $this->composer_command('require wp-cli/wp-cli:dev-master --optimize-autoloader --no-interaction'); + } + + public function get_php_binary() { + if ( getenv('WP_CLI_PHP_USED') ) { + return getenv('WP_CLI_PHP_USED'); + } + + if ( getenv('WP_CLI_PHP') ) { + return getenv('WP_CLI_PHP'); + } + + if ( defined('PHP_BINARY') ) { + return PHP_BINARY; + } + + return 'php'; + } + + public function start_php_server() { + $cmd = Utils\esc_cmd( + '%s -S %s -t %s -c %s %s', + $this->get_php_binary(), + 'localhost:8080', + $this->variables['RUN_DIR'] . '/wordpress/', + get_cfg_var('cfg_file_path'), + $this->variables['RUN_DIR'] . '/vendor/wp-cli/server-command/router.php' + ); + $this->background_proc($cmd); + } + + private function composer_command($cmd) { + if ( ! isset($this->variables['COMPOSER_PATH']) ) { + $this->variables['COMPOSER_PATH'] = exec('which composer'); + } + $this->proc($this->variables['COMPOSER_PATH'] . ' ' . $cmd)->run_check(); + } + + /** + * Initialize run time logging. + */ + private static function log_run_times_before_suite($event) { + self::$suite_start_time = microtime(true); + + Process::$log_run_times = true; + + $travis = getenv('TRAVIS'); + + // Default output settings. + self::$output_to = 'stdout'; + self::$num_top_processes = $travis ? 10 : 40; + self::$num_top_scenarios = $travis ? 10 : 20; + + // Allow setting of above with "WP_CLI_TEST_LOG_RUN_TIMES=[,][,]" formatted env var. + if ( preg_match('/^(stdout|error_log)?(,[0-9]+)?(,[0-9]+)?$/i', self::$log_run_times, $matches) ) { + if ( isset($matches[1]) ) { + self::$output_to = strtolower($matches[1]); + } + if ( isset($matches[2]) ) { + self::$num_top_processes = max((int) substr($matches[2], 1), 1); + } + if ( isset($matches[3]) ) { + self::$num_top_scenarios = max((int) substr($matches[3], 1), 1); + } + } + } + + /** + * Record the start time of the scenario into the `$scenario_run_times` array. + */ + private static function log_run_times_before_scenario($event) { + if ( $scenario_key = self::get_scenario_key($event) ) { + self::$scenario_run_times[ $scenario_key ] = -microtime(true); + } + } + + /** + * Save the run time of the scenario into the `$scenario_run_times` array. Only the top `self::$num_top_scenarios` are kept. + */ + private static function log_run_times_after_scenario($event) { + if ( $scenario_key = self::get_scenario_key($event) ) { + self::$scenario_run_times[ $scenario_key ] += microtime(true); + ++self::$scenario_count; + if ( count(self::$scenario_run_times) > self::$num_top_scenarios ) { + arsort(self::$scenario_run_times); + array_pop(self::$scenario_run_times); + } + } + } + + /** + * Copy files in updated directory that are not in source directory to copy directory. ("Incremental backup".) + * Note: does not deal with changed files (ie does not compare file contents for changes), for speed reasons. + * + * @param string $upd_dir The directory to search looking for files/directories not in `$src_dir`. + * @param string $src_dir The directory to be compared to `$upd_dir`. + * @param string $cop_dir Where to copy any files/directories in `$upd_dir` but not in `$src_dir` to. + */ + private static function dir_diff_copy($upd_dir, $src_dir, $cop_dir) { + if ( false === ($files = scandir($upd_dir)) ) { + $error = error_get_last(); + throw new \RuntimeException(sprintf("Failed to open updated directory '%s': %s. " . __FILE__ . ':' . __LINE__, $upd_dir, $error['message'])); + } + foreach ( array_diff($files, ['.', '..']) as $file ) { + $upd_file = $upd_dir . '/' . $file; + $src_file = $src_dir . '/' . $file; + $cop_file = $cop_dir . '/' . $file; + if ( ! file_exists($src_file) ) { + if ( is_dir($upd_file) ) { + if ( ! file_exists($cop_file) && ! mkdir($cop_file, 0777, true /*recursive*/) ) { + $error = error_get_last(); + throw new \RuntimeException(sprintf("Failed to create copy directory '%s': %s. " . __FILE__ . ':' . __LINE__, $cop_file, $error['message'])); + } + self::copy_dir($upd_file, $cop_file); + } elseif ( ! copy($upd_file, $cop_file) ) { + $error = error_get_last(); + throw new \RuntimeException(sprintf("Failed to copy '%s' to '%s': %s. " . __FILE__ . ':' . __LINE__, $upd_file, $cop_file, $error['message'])); + } + } elseif ( is_dir($upd_file) ) { + self::dir_diff_copy($upd_file, $src_file, $cop_file); + } + } + } + + /** + * Get the scenario key used for `$scenario_run_times` array. + * Format " :", eg "core-command core-update.feature:221". + */ + private static function get_scenario_key($event) { + $scenario_key = ''; + if ( $file = self::get_event_file($event, $line) ) { + $scenario_grandparent = Utils\basename(dirname(dirname($file))); + $scenario_key = $scenario_grandparent . ' ' . Utils\basename($file) . ':' . $line; + } + return $scenario_key; + } + + /** + * Print out stats on the run times of processes and scenarios. + */ + private static function log_run_times_after_suite($event) { + + $suite = ''; + if ( self::$scenario_run_times ) { + // Grandparent directory is first part of key. + $keys = array_keys(self::$scenario_run_times); + $suite = substr($keys[0], 0, strpos($keys[0], ' ')); + } + + $run_from = Utils\basename(dirname(dirname(__DIR__))); + + // Format same as Behat, if have minutes. + $fmt = function ($time) { + $mins = floor($time / 60); + return round($time, 3) . ($mins ? (' (' . $mins . 'm' . round($time - ($mins * 60), 3) . 's)') : ''); + }; + + $time = microtime(true) - self::$suite_start_time; + + $log = PHP_EOL . str_repeat('(', 80) . PHP_EOL; + + // Process and proc method run times. + $run_times = array_merge(Process::$run_times, self::$proc_method_run_times); + + list( $ptime, $calls ) = array_reduce( + $run_times, + function ($carry, $item) { + return [$carry[0] + $item[0], $carry[1] + $item[1]]; + }, + [0, 0] + ); + + $overhead = $time - $ptime; + $pct = round(($overhead / $time) * 100); + $unique = count($run_times); + + $log .= sprintf( + PHP_EOL . "Total process run time %s (tests %s, overhead %.3f %d%%), calls %d (%d unique) for '%s' run from '%s'" . PHP_EOL, + $fmt($ptime), + $fmt($time), + $overhead, + $pct, + $calls, + $unique, + $suite, + $run_from + ); + + uasort( + $run_times, + function ($a, $b) { + return $a[0] === $b[0] ? 0 : ($a[0] < $b[0] ? 1 : -1); // Reverse sort. + } + ); + + $tops = array_slice($run_times, 0, self::$num_top_processes, true); + + $log .= PHP_EOL . 'Top ' . self::$num_top_processes . " process run times for '$suite'"; + $log .= PHP_EOL . implode( + PHP_EOL, + array_map( + function ($k, $v, $i) { + return sprintf(' %3d. %7.3f %3d %s', $i + 1, round($v[0], 3), $v[1], $k); + }, + array_keys($tops), + $tops, + array_keys(array_keys($tops)) + ) + ) . PHP_EOL; + + // Scenario run times. + arsort(self::$scenario_run_times); + + $tops = array_slice(self::$scenario_run_times, 0, self::$num_top_scenarios, true); + + $log .= PHP_EOL . 'Top ' . self::$num_top_scenarios . ' (of ' . self::$scenario_count . ") scenario run times for '$suite'"; + $log .= PHP_EOL . implode( + PHP_EOL, + array_map( + function ($k, $v, $i) { + return sprintf(' %3d. %7.3f %s', $i + 1, round($v, 3), substr($k, strpos($k, ' ') + 1)); + }, + array_keys($tops), + $tops, + array_keys(array_keys($tops)) + ) + ) . PHP_EOL; + + $log .= PHP_EOL . str_repeat(')', 80); + + if ( 'error_log' === self::$output_to ) { + error_log($log); + } else { + echo PHP_EOL . $log; + } + } + + /** + * Log the run time of a proc method (one that doesn't use Process but does (use a function that does) a `proc_open()`). + */ + private static function log_proc_method_run_time($key, $start_time) { + $run_time = microtime(true) - $start_time; + if ( ! isset(self::$proc_method_run_times[ $key ]) ) { + self::$proc_method_run_times[ $key ] = [0, 0]; + } + self::$proc_method_run_times[ $key ][0] += $run_time; + ++self::$proc_method_run_times[ $key ][1]; + } +} diff --git a/inc/site-exporter/mu-migration/features/bootstrap/Process.php b/inc/site-exporter/mu-migration/features/bootstrap/Process.php new file mode 100644 index 00000000..6777b14a --- /dev/null +++ b/inc/site-exporter/mu-migration/features/bootstrap/Process.php @@ -0,0 +1,118 @@ + STDIN, + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + /** + * @var bool Whether to log run time info or not. + */ + public static $log_run_times = false; + + /** + * @var array Array of process run time info, keyed by process command, each a 2-element array containing run time and run count. + */ + public static $run_times = []; + + /** + * @param string $command Command to execute. + * @param string $cwd Directory to execute the command in. + * @param array $env Environment variables to set when running the command. + * + * @return Process + */ + public static function create($command, $cwd = null, $env = []) { + $proc = new self(); + + $proc->command = $command; + $proc->cwd = $cwd; + $proc->env = $env; + + return $proc; + } + + private function __construct() {} + + /** + * Run the command. + * + * @return ProcessRun + */ + public function run() { + $start_time = microtime(true); + + $proc = proc_open($this->command, self::$descriptors, $pipes, $this->cwd, $this->env); + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $return_code = proc_close($proc); + + $run_time = microtime(true) - $start_time; + + if ( self::$log_run_times ) { + if ( ! isset(self::$run_times[ $this->command ]) ) { + self::$run_times[ $this->command ] = [0, 0]; + } + self::$run_times[ $this->command ][0] += $run_time; + ++self::$run_times[ $this->command ][1]; + } + + return new ProcessRun( + [ + 'stdout' => $stdout, + 'stderr' => $stderr, + 'return_code' => $return_code, + 'command' => $this->command, + 'cwd' => $this->cwd, + 'env' => $this->env, + 'run_time' => $run_time, + ] + ); + } + + /** + * Run the command, but throw an Exception on error. + * + * @return ProcessRun + */ + public function run_check() { + $r = $this->run(); + + // $r->STDERR is incorrect, but kept incorrect for backwards-compat + if ( $r->return_code || ! empty($r->STDERR) ) { + throw new \RuntimeException($r); + } + + return $r; + } +} diff --git a/inc/site-exporter/mu-migration/features/bootstrap/ProcessRun.php b/inc/site-exporter/mu-migration/features/bootstrap/ProcessRun.php new file mode 100644 index 00000000..1c20e33a --- /dev/null +++ b/inc/site-exporter/mu-migration/features/bootstrap/ProcessRun.php @@ -0,0 +1,67 @@ + $value ) { + $this->$key = $value; + } + } + + /** + * Return properties of executed command as a string. + * + * @return string + */ + public function __toString() { + $out = "$ $this->command\n"; + $out .= "$this->stdout\n$this->stderr"; + $out .= "cwd: $this->cwd\n"; + $out .= "run time: $this->run_time\n"; + $out .= "exit status: $this->return_code"; + + return $out; + } +} diff --git a/inc/site-exporter/mu-migration/features/bootstrap/support.php b/inc/site-exporter/mu-migration/features/bootstrap/support.php new file mode 100644 index 00000000..59e38da3 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/bootstrap/support.php @@ -0,0 +1,218 @@ + $value ) { + if ( ! compareContents($value, $actual->$name) ) { + return false; + } + } + } elseif ( is_array($expected) ) { + foreach ( $expected as $key => $value ) { + if ( ! compareContents($value, $actual[ $key ]) ) { + return false; + } + } + } else { + return $expected === $actual; + } + + return true; +} + + +/** + * Compare two strings containing JSON to ensure that @a $actualJson contains at + * least what the JSON string @a $expectedJson contains. + * + * @return whether or not @a $actualJson contains @a $expectedJson + * @retval true @a $actualJson contains @a $expectedJson + * @retval false @a $actualJson does not contain @a $expectedJson + * + * @param[in] $actualJson the JSON string to be tested + * @param[in] $expectedJson the expected JSON string + * + * Examples: + * expected: {'a':1,'array':[1,3,5]} + * + * 1 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: true + * + * 2 ) + * actual: {'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * element 'a' is missing from the root object + * + * 3 ) + * actual: {'a':0,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * the value of element 'a' is not 1 + * + * 4 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,4,5]} + * return: false + * the contents of 'array' does not include 3 + */ +function checkThatJsonStringContainsJsonString($actualJson, $expectedJson) { + $actualValue = json_decode($actualJson); + $expectedValue = json_decode($expectedJson); + + if ( ! $actualValue ) { + return false; + } + + return compareContents($expectedValue, $actualValue); +} + + +/** + * Compare two strings to confirm $actualCSV contains $expectedCSV + * Both strings are expected to have headers for their CSVs. + * $actualCSV must match all data rows in $expectedCSV + * + * @param string A CSV string + * @param array A nested array of values + * @return bool Whether $actualCSV contains $expectedCSV + */ +function checkThatCsvStringContainsValues($actualCSV, $expectedCSV) { + $actualCSV = array_map('str_getcsv', explode(PHP_EOL, $actualCSV)); + + if ( empty($actualCSV) ) { + return false; + } + + // Each sample must have headers + $actualHeaders = array_values(array_shift($actualCSV)); + $expectedHeaders = array_values(array_shift($expectedCSV)); + + // Each expectedCSV must exist somewhere in actualCSV in the proper column + $expectedResult = 0; + foreach ( $expectedCSV as $expected_row ) { + $expected_row = array_combine($expectedHeaders, $expected_row); + foreach ( $actualCSV as $actual_row ) { + if ( count($actualHeaders) != count($actual_row) ) { + continue; + } + + $actual_row = array_intersect_key(array_combine($actualHeaders, $actual_row), $expected_row); + if ( $actual_row == $expected_row ) { + ++$expectedResult; + } + } + } + + return $expectedResult >= count($expectedCSV); +} + + +/** + * Compare two strings containing YAML to ensure that @a $actualYaml contains at + * least what the YAML string @a $expectedYaml contains. + * + * @return whether or not @a $actualYaml contains @a $expectedJson + * @retval true @a $actualYaml contains @a $expectedJson + * @retval false @a $actualYaml does not contain @a $expectedJson + * + * @param[in] $actualYaml the YAML string to be tested + * @param[in] $expectedYaml the expected YAML string + */ +function checkThatYamlStringContainsYamlString($actualYaml, $expectedYaml) { + $actualValue = Mustangostang\Spyc::YAMLLoad($actualYaml); + $expectedValue = Mustangostang\Spyc::YAMLLoad($expectedYaml); + + if ( ! $actualValue ) { + return false; + } + + return compareContents($expectedValue, $actualValue); +} + + +function file_exists_regex($regex, $dir = './') { + $open = opendir($dir); + var_dump($regex); + while ( ($file = readdir($open)) !== false ) { + if ( preg_match("/$regex/", $file) ) { + return true; + } + } + return false; +} diff --git a/inc/site-exporter/mu-migration/features/bootstrap/utils.php b/inc/site-exporter/mu-migration/features/bootstrap/utils.php new file mode 100644 index 00000000..2084d053 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/bootstrap/utils.php @@ -0,0 +1,1111 @@ +config) && ! empty($composer->config->{'vendor-dir'}) ) { + array_unshift($vendor_paths, WP_CLI_ROOT . '/../../../' . $composer->config->{'vendor-dir'}); + } + } + return $vendor_paths; +} + +// Using require() directly inside a class grants access to private methods to the loaded code +function load_file($path) { + require_once $path; +} + +function load_command($name) { + $path = WP_CLI_ROOT . "/php/commands/$name.php"; + + if ( is_readable($path) ) { + include_once $path; + } +} + +/** + * Like array_map(), except it returns a new iterator, instead of a modified array. + * + * Example: + * + * $arr = array('Football', 'Socker'); + * + * $it = iterator_map($arr, 'strtolower', function($val) { + * return str_replace('foo', 'bar', $val); + * }); + * + * foreach ( $it as $val ) { + * var_dump($val); + * } + * + * @param array|object Either a plain array or another iterator + * @param callback The function to apply to an element + * @return object An iterator that applies the given callback(s) + */ +function iterator_map($it, $fn) { + if ( is_array($it) ) { + $it = new \ArrayIterator($it); + } + + if ( ! method_exists($it, 'add_transform') ) { + $it = new Transform($it); + } + + foreach ( array_slice(func_get_args(), 1) as $fn ) { + $it->add_transform($fn); + } + + return $it; +} + +/** + * Search for file by walking up the directory tree until the first file is found or until $stop_check($dir) returns true + * + * @param string|array The files (or file) to search for + * @param string|null The directory to start searching from; defaults to CWD + * @param callable Function which is passed the current dir each time a directory level is traversed + * @return null|string Null if the file was not found + */ +function find_file_upward($files, $dir = null, $stop_check = null) { + $files = (array) $files; + if ( is_null($dir) ) { + $dir = getcwd(); + } + while ( @is_readable($dir) ) { + // Stop walking up when the supplied callable returns true being passed the $dir + if ( is_callable($stop_check) && call_user_func($stop_check, $dir) ) { + return null; + } + + foreach ( $files as $file ) { + $path = $dir . DIRECTORY_SEPARATOR . $file; + if ( file_exists($path) ) { + return $path; + } + } + + $parent_dir = dirname($dir); + if ( empty($parent_dir) || $parent_dir === $dir ) { + break; + } + $dir = $parent_dir; + } + return null; +} + +function is_path_absolute($path) { + // Windows + if ( isset($path[1]) && ':' === $path[1] ) { + return true; + } + + return $path[0] === '/'; +} + +/** + * Composes positional arguments into a command string. + * + * @param array + * @return string + */ +function args_to_str($args) { + return ' ' . implode(' ', array_map('escapeshellarg', $args)); +} + +/** + * Composes associative arguments into a command string. + * + * @param array + * @return string + */ +function assoc_args_to_str($assoc_args) { + $str = ''; + + foreach ( $assoc_args as $key => $value ) { + if ( true === $value ) { + $str .= " --$key"; + } elseif ( is_array($value) ) { + foreach ( $value as $_ => $v ) { + $str .= assoc_args_to_str([$key => $v]); + } + } else { + $str .= " --$key=" . escapeshellarg($value); + } + } + + return $str; +} + +/** + * Given a template string and an arbitrary number of arguments, + * returns the final command, with the parameters escaped. + */ +function esc_cmd($cmd) { + if ( func_num_args() < 2 ) { + trigger_error('esc_cmd() requires at least two arguments.', E_USER_WARNING); + } + + $args = func_get_args(); + + $cmd = array_shift($args); + + return vsprintf($cmd, array_map('escapeshellarg', $args)); +} + +function locate_wp_config() { + static $path; + + if ( null === $path ) { + if ( file_exists(ABSPATH . 'wp-config.php') ) { + $path = ABSPATH . 'wp-config.php'; + } elseif ( file_exists(ABSPATH . '../wp-config.php') && ! file_exists(ABSPATH . '/../wp-settings.php') ) { + $path = ABSPATH . '../wp-config.php'; + } else { + $path = false; + } + + if ( $path ) { + $path = realpath($path); + } + } + + return $path; +} + +function wp_version_compare($since, $operator) { + return version_compare(str_replace(['-src'], '', $GLOBALS['wp_version']), $since, $operator); +} + +/** + * Render a collection of items as an ASCII table, JSON, CSV, YAML, list of ids, or count. + * + * Given a collection of items with a consistent data structure: + * + * ``` + * $items = array( + * array( + * 'key' => 'foo', + * 'value' => 'bar', + * ) + * ); + * ``` + * + * Render `$items` as an ASCII table: + * + * ``` + * WP_CLI\Utils\format_items( 'table', $items, array( 'key', 'value' ) ); + * + * # +-----+-------+ + * # | key | value | + * # +-----+-------+ + * # | foo | bar | + * # +-----+-------+ + * ``` + * + * Or render `$items` as YAML: + * + * ``` + * WP_CLI\Utils\format_items( 'yaml', $items, array( 'key', 'value' ) ); + * + * # --- + * # - + * # key: foo + * # value: bar + * ``` + * + * @access public + * @category Output + * + * @param string $format Format to use: 'table', 'json', 'csv', 'yaml', 'ids', 'count' + * @param array $items An array of items to output. + * @param array|string $fields Named fields for each item of data. Can be array or comma-separated list. + * @return null + */ +function format_items($format, $items, $fields) { + $assoc_args = compact('format', 'fields'); + $formatter = new \WP_CLI\Formatter($assoc_args); + $formatter->display_items($items); +} + +/** + * Write data as CSV to a given file. + * + * @access public + * + * @param resource $fd File descriptor + * @param array $rows Array of rows to output + * @param array $headers List of CSV columns (optional) + */ +function write_csv($fd, $rows, $headers = []) { + if ( ! empty($headers) ) { + fputcsv($fd, $headers); + } + + foreach ( $rows as $row ) { + if ( ! empty($headers) ) { + $row = pick_fields($row, $headers); + } + + fputcsv($fd, array_values($row)); + } +} + +/** + * Pick fields from an associative array or object. + * + * @param array|object Associative array or object to pick fields from + * @param array List of fields to pick + * @return array + */ +function pick_fields($item, $fields) { + $item = (object) $item; + + $values = []; + + foreach ( $fields as $field ) { + $values[ $field ] = isset($item->$field) ? $item->$field : null; + } + + return $values; +} + +/** + * Launch system's $EDITOR for the user to edit some text. + * + * @access public + * @category Input + * + * @param string $content Some form of text to edit (e.g. post content) + * @return string|bool Edited text, if file is saved from editor; false, if no change to file. + */ +function launch_editor_for_input($input, $filename = 'WP-CLI') { + + check_proc_available('launch_editor_for_input'); + + $tmpdir = get_temp_dir(); + + do { + $tmpfile = basename($filename); + $tmpfile = preg_replace('|\.[^.]*$|', '', $tmpfile); + $tmpfile .= '-' . substr(md5(rand()), 0, 6); + $tmpfile = $tmpdir . $tmpfile . '.tmp'; + $fp = @fopen($tmpfile, 'x'); + if ( ! $fp && is_writable($tmpdir) && file_exists($tmpfile) ) { + $tmpfile = ''; + continue; + } + if ( $fp ) { + fclose($fp); + } + } while ( ! $tmpfile ); + + if ( ! $tmpfile ) { + \WP_CLI::error('Error creating temporary file.'); + } + + $output = ''; + file_put_contents($tmpfile, $input); + + $editor = getenv('EDITOR'); + if ( ! $editor ) { + if ( isset($_SERVER['OS']) && false !== strpos($_SERVER['OS'], 'indows') ) { + $editor = 'notepad'; + } else { + $editor = 'vi'; + } + } + + $descriptorspec = [STDIN, STDOUT, STDERR]; + $process = proc_open("$editor " . escapeshellarg($tmpfile), $descriptorspec, $pipes); + $r = proc_close($process); + if ( $r ) { + exit($r); + } + + $output = file_get_contents($tmpfile); + + unlink($tmpfile); + + if ( $output === $input ) { + return false; + } + + return $output; +} + +/** + * @param string MySQL host string, as defined in wp-config.php + * @return array + */ +function mysql_host_to_cli_args($raw_host) { + $assoc_args = []; + + $host_parts = explode(':', $raw_host); + if ( count($host_parts) == 2 ) { + list( $assoc_args['host'], $extra ) = $host_parts; + $extra = trim($extra); + if ( is_numeric($extra) ) { + $assoc_args['port'] = intval($extra); + $assoc_args['protocol'] = 'tcp'; + } elseif ( $extra !== '' ) { + $assoc_args['socket'] = $extra; + } + } else { + $assoc_args['host'] = $raw_host; + } + + return $assoc_args; +} + +function run_mysql_command($cmd, $assoc_args, $descriptors = null) { + check_proc_available('run_mysql_command'); + + if ( ! $descriptors ) { + $descriptors = [STDIN, STDOUT, STDERR]; + } + + if ( isset($assoc_args['host']) ) { + $assoc_args = array_merge($assoc_args, mysql_host_to_cli_args($assoc_args['host'])); + } + + $pass = $assoc_args['pass']; + unset($assoc_args['pass']); + + $old_pass = getenv('MYSQL_PWD'); + putenv('MYSQL_PWD=' . $pass); + + $final_cmd = force_env_on_nix_systems($cmd) . assoc_args_to_str($assoc_args); + + $proc = proc_open($final_cmd, $descriptors, $pipes); + if ( ! $proc ) { + exit(1); + } + + $r = proc_close($proc); + + putenv('MYSQL_PWD=' . $old_pass); + + if ( $r ) { + exit($r); + } +} + +/** + * Render PHP or other types of files using Mustache templates. + * + * IMPORTANT: Automatic HTML escaping is disabled! + */ +function mustache_render($template_name, $data = []) { + if ( ! file_exists($template_name) ) { + $template_name = WP_CLI_ROOT . "/templates/$template_name"; + } + + $template = file_get_contents($template_name); + + $m = new \Mustache_Engine( + [ + 'escape' => function ($val) { + return $val; }, + ] + ); + + return $m->render($template, $data); +} + +/** + * Create a progress bar to display percent completion of a given operation. + * + * Progress bar is written to STDOUT, and disabled when command is piped. Progress + * advances with `$progress->tick()`, and completes with `$progress->finish()`. + * Process bar also indicates elapsed time and expected total time. + * + * ``` + * # `wp user generate` ticks progress bar each time a new user is created. + * # + * # $ wp user generate --count=500 + * # Generating users 22 % [=======> ] 0:05 / 0:23 + * + * $progress = \WP_CLI\Utils\make_progress_bar( 'Generating users', $count ); + * for ( $i = 0; $i < $count; $i++ ) { + * // uses wp_insert_user() to insert the user + * $progress->tick(); + * } + * $progress->finish(); + * ``` + * + * @access public + * @category Output + * + * @param string $message Text to display before the progress bar. + * @param integer $count Total number of ticks to be performed. + * @return cli\progress\Bar|WP_CLI\NoOp + */ +function make_progress_bar($message, $count) { + if ( \cli\Shell::isPiped() ) { + return new \WP_CLI\NoOp(); + } + + return new \cli\progress\Bar($message, $count); +} + +function parse_url($url) { + $url_parts = \parse_url($url); + + if ( ! isset($url_parts['scheme']) ) { + $url_parts = parse_url('http://' . $url); + } + + return $url_parts; +} + +/** + * Check if we're running in a Windows environment (cmd.exe). + * + * @return bool + */ +function is_windows() { + return false !== ($test_is_windows = getenv('WP_CLI_TEST_IS_WINDOWS')) ? (bool) $test_is_windows : strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; +} + +/** + * Replace magic constants in some PHP source code. + * + * @param string $source The PHP code to manipulate. + * @param string $path The path to use instead of the magic constants + */ +function replace_path_consts($source, $path) { + $replacements = [ + '__FILE__' => "'$path'", + '__DIR__' => "'" . dirname($path) . "'", + ]; + + $old = array_keys($replacements); + $new = array_values($replacements); + + return str_replace($old, $new, $source); +} + +/** + * Make a HTTP request to a remote URL. + * + * Wraps the Requests HTTP library to ensure every request includes a cert. + * + * ``` + * # `wp core download` verifies the hash for a downloaded WordPress archive + * + * $md5_response = Utils\http_request( 'GET', $download_url . '.md5' ); + * if ( 20 != substr( $md5_response->status_code, 0, 2 ) ) { + * WP_CLI::error( "Couldn't access md5 hash for release (HTTP code {$response->status_code})" ); + * } + * ``` + * + * @access public + * + * @param string $method HTTP method (GET, POST, DELETE, etc.) + * @param string $url URL to make the HTTP request to. + * @param array $headers Add specific headers to the request. + * @param array $options + * @return object + */ +function http_request($method, $url, $data = null, $headers = [], $options = []) { + + $cert_path = '/rmccue/requests/library/Requests/Transport/cacert.pem'; + if ( inside_phar() ) { + // cURL can't read Phar archives + $options['verify'] = extract_from_phar( + WP_CLI_VENDOR_DIR . $cert_path + ); + } else { + foreach ( get_vendor_paths() as $vendor_path ) { + if ( file_exists($vendor_path . $cert_path) ) { + $options['verify'] = $vendor_path . $cert_path; + break; + } + } + if ( empty($options['verify']) ) { + WP_CLI::error('Cannot find SSL certificate.'); + } + } + + try { + $request = \Requests::request($url, $headers, $data, $method, $options); + return $request; + } catch ( \Requests_Exception $ex ) { + // CURLE_SSL_CACERT_BADFILE only defined for PHP >= 7. + if ( 'curlerror' !== $ex->getType() || ! in_array(curl_errno($ex->getData()), [CURLE_SSL_CONNECT_ERROR, CURLE_SSL_CERTPROBLEM, 77 /*CURLE_SSL_CACERT_BADFILE*/], true) ) { + \WP_CLI::error(sprintf("Failed to get url '%s': %s.", $url, $ex->getMessage())); + } + // Handle SSL certificate issues gracefully + \WP_CLI::warning(sprintf("Re-trying without verify after failing to get verified url '%s' %s.", $url, $ex->getMessage())); + $options['verify'] = false; + try { + return \Requests::request($url, $headers, $data, $method, $options); + } catch ( \Requests_Exception $ex ) { + \WP_CLI::error(sprintf("Failed to get non-verified url '%s' %s.", $url, $ex->getMessage())); + } + } +} + +/** + * Increments a version string using the "x.y.z-pre" format + * + * Can increment the major, minor or patch number by one + * If $new_version == "same" the version string is not changed + * If $new_version is not a known keyword, it will be used as the new version string directly + * + * @param string $current_version + * @param string $new_version + * @return string + */ +function increment_version($current_version, $new_version) { + // split version assuming the format is x.y.z-pre + $current_version = explode('-', $current_version, 2); + $current_version[0] = explode('.', $current_version[0]); + + switch ( $new_version ) { + case 'same': + // do nothing + break; + + case 'patch': + ++$current_version[0][2]; + + $current_version = [$current_version[0]]; // drop possible pre-release info + break; + + case 'minor': + ++$current_version[0][1]; + $current_version[0][2] = 0; + + $current_version = [$current_version[0]]; // drop possible pre-release info + break; + + case 'major': + ++$current_version[0][0]; + $current_version[0][1] = 0; + $current_version[0][2] = 0; + + $current_version = [$current_version[0]]; // drop possible pre-release info + break; + + default: // not a keyword + $current_version = [[$new_version]]; + break; + } + + // reconstruct version string + $current_version[0] = implode('.', $current_version[0]); + $current_version = implode('-', $current_version); + + return $current_version; +} + +/** + * Compare two version strings to get the named semantic version. + * + * @access public + * + * @param string $new_version + * @param string $original_version + * @return string $name 'major', 'minor', 'patch' + */ +function get_named_sem_ver($new_version, $original_version) { + + if ( ! Comparator::greaterThan($new_version, $original_version) ) { + return ''; + } + + $parts = explode('-', $original_version); + $bits = explode('.', $parts[0]); + $major = $bits[0]; + if ( isset($bits[1]) ) { + $minor = $bits[1]; + } + if ( isset($bits[2]) ) { + $patch = $bits[2]; + } + + if ( ! is_null($minor) && Semver::satisfies($new_version, "{$major}.{$minor}.x") ) { + return 'patch'; + } elseif ( Semver::satisfies($new_version, "{$major}.x.x") ) { + return 'minor'; + } else { + return 'major'; + } +} + +/** + * Return the flag value or, if it's not set, the $default value. + * + * Because flags can be negated (e.g. --no-quiet to negate --quiet), this + * function provides a safer alternative to using + * `isset( $assoc_args['quiet'] )` or similar. + * + * @access public + * @category Input + * + * @param array $assoc_args Arguments array. + * @param string $flag Flag to get the value. + * @param mixed $default Default value for the flag. Default: NULL + * @return mixed + */ +function get_flag_value($assoc_args, $flag, $default = null) { + return isset($assoc_args[ $flag ]) ? $assoc_args[ $flag ] : $default; +} + +/** + * Get the home directory. + * + * @access public + * @category System + * + * @return string + */ +function get_home_dir() { + $home = getenv('HOME'); + if ( ! $home ) { + // In Windows $HOME may not be defined + $home = getenv('HOMEDRIVE') . getenv('HOMEPATH'); + } + + return rtrim($home, '/\\'); +} + +/** + * Appends a trailing slash. + * + * @access public + * @category System + * + * @param string $string What to add the trailing slash to. + * @return string String with trailing slash added. + */ +function trailingslashit($string) { + return rtrim($string, '/\\') . '/'; +} + +/** + * Get the system's temp directory. Warns user if it isn't writable. + * + * @access public + * @category System + * + * @return string + */ +function get_temp_dir() { + static $temp = ''; + + if ( $temp ) { + return $temp; + } + + // `sys_get_temp_dir()` introduced PHP 5.2.1. + if ( $try = sys_get_temp_dir() ) { + $temp = trailingslashit($try); + } elseif ( $try = ini_get('upload_tmp_dir') ) { + $temp = trailingslashit($try); + } else { + $temp = '/tmp/'; + } + + if ( ! @is_writable($temp) ) { + \WP_CLI::warning("Temp directory isn't writable: {$temp}"); + } + + return $temp; +} + +/** + * Parse a SSH url for its host, port, and path. + * + * Similar to parse_url(), but adds support for defined SSH aliases. + * + * ``` + * host OR host/path/to/wordpress OR host:port/path/to/wordpress + * ``` + * + * @access public + * + * @return mixed + */ +function parse_ssh_url($url, $component = -1) { + preg_match('#^((docker|docker\-compose|ssh|vagrant):)?(([^@:]+)@)?([^:/~]+)(:([\d]*))?((/|~)(.+))?$#', $url, $matches); + $bits = []; + foreach ( [ + 2 => 'scheme', + 4 => 'user', + 5 => 'host', + 7 => 'port', + 8 => 'path', + ] as $i => $key ) { + if ( ! empty($matches[ $i ]) ) { + $bits[ $key ] = $matches[ $i ]; + } + } + switch ( $component ) { + case PHP_URL_SCHEME: + return isset($bits['scheme']) ? $bits['scheme'] : null; + case PHP_URL_USER: + return isset($bits['user']) ? $bits['user'] : null; + case PHP_URL_HOST: + return isset($bits['host']) ? $bits['host'] : null; + case PHP_URL_PATH: + return isset($bits['path']) ? $bits['path'] : null; + case PHP_URL_PORT: + return isset($bits['port']) ? $bits['port'] : null; + default: + return $bits; + } +} + +/** + * Report the results of the same operation against multiple resources. + * + * @access public + * @category Input + * + * @param string $noun Resource being affected (e.g. plugin) + * @param string $verb Type of action happening to the noun (e.g. activate) + * @param integer $total Total number of resource being affected. + * @param integer $successes Number of successful operations. + * @param integer $failures Number of failures. + */ +function report_batch_operation_results($noun, $verb, $total, $successes, $failures) { + $plural_noun = $noun . 's'; + $past_tense_verb = past_tense_verb($verb); + $past_tense_verb_upper = ucfirst($past_tense_verb); + if ( $failures ) { + if ( $successes ) { + WP_CLI::error("Only {$past_tense_verb} {$successes} of {$total} {$plural_noun}."); + } else { + WP_CLI::error("No {$plural_noun} {$past_tense_verb}."); + } + } elseif ( $successes ) { + WP_CLI::success("{$past_tense_verb_upper} {$successes} of {$total} {$plural_noun}."); + } else { + $message = $total > 1 ? ucfirst($plural_noun) : ucfirst($noun); + WP_CLI::success("{$message} already {$past_tense_verb}."); + } +} + +/** + * Parse a string of command line arguments into an $argv-esqe variable. + * + * @access public + * @category Input + * + * @param string $arguments + * @return array + */ +function parse_str_to_argv($arguments) { + preg_match_all('/(?<=^|\s)([\'"]?)(.+?)(? 'reset']; + if ( isset($irregular[ $verb ]) ) { + return $irregular[ $verb ]; + } + $last = substr($verb, -1); + if ( 'e' === $last ) { + $verb = substr($verb, 0, -1); + } elseif ( 'y' === $last && ! preg_match('/[aeiou]y$/', $verb) ) { + $verb = substr($verb, 0, -1) . 'i'; + } elseif ( preg_match('/^[^aeiou]*[aeiou][^aeiouhwxy]$/', $verb) ) { + // Rule of thumb that most (all?) one-voweled regular verbs ending in vowel + consonant (excluding "h", "w", "x", "y") double their final consonant - misses many cases (eg "submit"). + $verb .= $last; + } + return $verb . 'ed'; +} diff --git a/inc/site-exporter/mu-migration/features/data/images/beach.jpg b/inc/site-exporter/mu-migration/features/data/images/beach.jpg new file mode 100644 index 00000000..8501e583 Binary files /dev/null and b/inc/site-exporter/mu-migration/features/data/images/beach.jpg differ diff --git a/inc/site-exporter/mu-migration/features/data/images/mossoro.jpg b/inc/site-exporter/mu-migration/features/data/images/mossoro.jpg new file mode 100644 index 00000000..8479fd26 Binary files /dev/null and b/inc/site-exporter/mu-migration/features/data/images/mossoro.jpg differ diff --git a/inc/site-exporter/mu-migration/features/data/images/wp_chara.jpg b/inc/site-exporter/mu-migration/features/data/images/wp_chara.jpg new file mode 100644 index 00000000..6c3d5abf Binary files /dev/null and b/inc/site-exporter/mu-migration/features/data/images/wp_chara.jpg differ diff --git a/inc/site-exporter/mu-migration/features/extra/no-mail.php b/inc/site-exporter/mu-migration/features/extra/no-mail.php new file mode 100644 index 00000000..fa4565a3 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/extra/no-mail.php @@ -0,0 +1,6 @@ +Given( + '/^an empty directory$/', + function ($world) { + $world->create_run_dir(); + } +); + +$steps->Given( + '/^an? (empty|non-existent) ([^\s]+) directory$/', + function ($world, $empty_or_nonexistent, $dir) { + $dir = $world->replace_variables($dir); + if ( ! WP_CLI\Utils\is_path_absolute($dir) ) { + $dir = $world->variables['RUN_DIR'] . "/$dir"; + } + if ( 0 !== strpos($dir, sys_get_temp_dir()) ) { + throw new RuntimeException(sprintf("Attempted to delete directory '%s' that is not in the temp directory '%s'. " . __FILE__ . ':' . __LINE__, $dir, sys_get_temp_dir())); + } + $world->remove_dir($dir); + if ( 'empty' === $empty_or_nonexistent ) { + mkdir($dir, 0777, true /*recursive*/); + } + } +); + +$steps->Given( + '/^an empty cache/', + function ($world) { + $world->variables['SUITE_CACHE_DIR'] = FeatureContext::create_cache_dir(); + } +); + +$steps->Given( + '/^an? ([^\s]+) file:$/', + function ($world, $path, PyStringNode $content) { + $content = (string) $content . "\n"; + $full_path = $world->variables['RUN_DIR'] . "/$path"; + $dir = dirname($full_path); + if ( ! file_exists($dir) ) { + mkdir($dir, 0777, true /*recursive*/); + } + file_put_contents($full_path, $content); + } +); + +$steps->Given( + '/^"([^"]+)" replaced with "([^"]+)" in the ([^\s]+) file$/', + function ($world, $search, $replace, $path) { + $full_path = $world->variables['RUN_DIR'] . "/$path"; + $contents = file_get_contents($full_path); + $contents = str_replace($search, $replace, $contents); + file_put_contents($full_path, $contents); + } +); + +$steps->Given( + '/^WP files$/', + function ($world) { + $world->download_wp(); + } +); + +$steps->Given( + '/^wp-config\.php$/', + function ($world) { + $world->create_config(); + } +); + +$steps->Given( + '/^a database$/', + function ($world) { + $world->create_db(); + } +); + +$steps->Given( + '/^a WP install$/', + function ($world) { + $world->install_wp(); + } +); + +$steps->Given( + "/^a WP install in '([^\s]+)'$/", + function ($world, $subdir) { + $world->install_wp($subdir); + } +); + +$steps->Given( + '/^a WP install with Composer$/', + function ($world) { + $world->install_wp_with_composer(); + } +); + +$steps->Given( + "/^a WP install with Composer and a custom vendor directory '([^\s]+)'$/", + function ($world, $vendor_directory) { + $world->install_wp_with_composer($vendor_directory); + } +); + +$steps->Given( + '/^a WP multisite (subdirectory|subdomain)?\s?install$/', + function ($world, $type = 'subdirectory') { + $world->install_wp(); + $subdomains = ! empty($type) && 'subdomain' === $type ? 1 : 0; + $world->proc( + 'wp core install-network', + [ + 'title' => 'WP CLI Network', + 'subdomains' => $subdomains, + ] + )->run_check(); + } +); + +$steps->Given( + '/^I create multiple sites with dummy content$/', + function ($world) { + $world->proc('wp user generate --count=10')->run_check(); + $world->proc('wp post generate --count=50 --post_type=post')->run_check(); + + $sites = [ + [ + 'slug' => 'site-2', + 'title' => 'Site 2', + ], + [ + 'slug' => 'site-3', + 'title' => 'Site 3', + ], + ]; + + foreach ( $sites as $site ) { + $world->proc(sprintf('wp site create --slug=%s --title="%s"', $site['slug'], $site['title']))->run_check(); + $world->proc(sprintf('wp user generate --format=ids --count=10 --url=example.com/%s', $site['slug']))->run_check(); + $world->proc(sprintf('wp post generate --count=50 --post_type=post --url=example.com/%s', $site['slug']))->run_check(); + } + } +); + +$steps->Given( + '/^these installed and active plugins:$/', + function ($world, $stream) { + $plugins = implode(' ', array_map('trim', explode(PHP_EOL, (string) $stream))); + $world->proc("wp plugin install $plugins --activate")->run_check(); + } +); + +$steps->Given( + '/^a custom wp-content directory$/', + function ($world) { + $wp_config_path = $world->variables['RUN_DIR'] . '/wp-config.php'; + + $wp_config_code = file_get_contents($wp_config_path); + + $world->move_files('wp-content', 'my-content'); + $world->add_line_to_wp_config( + $wp_config_code, + "define( 'WP_CONTENT_DIR', dirname(__FILE__) . '/my-content' );" + ); + + $world->move_files('my-content/plugins', 'my-plugins'); + $world->add_line_to_wp_config( + $wp_config_code, + "define( 'WP_PLUGIN_DIR', __DIR__ . '/my-plugins' );" + ); + + file_put_contents($wp_config_path, $wp_config_code); + } +); + +$steps->Given( + '/^download:$/', + function ($world, TableNode $table) { + foreach ( $table->getHash() as $row ) { + $path = $world->replace_variables($row['path']); + if ( file_exists($path) ) { + // assume it's the same file and skip re-download + continue; + } + + Process::create(\WP_CLI\Utils\esc_cmd('curl -sSL %s > %s', $row['url'], $path))->run_check(); + } + } +); + +$steps->Given( + '/^save (STDOUT|STDERR) ([\'].+[^\'])?\s?as \{(\w+)\}$/', + function ($world, $stream, $output_filter, $key) { + + $stream = strtolower($stream); + + if ( $output_filter ) { + $output_filter = '/' . trim(str_replace('%s', '(.+[^\b])', $output_filter), "' ") . '/'; + if ( false !== preg_match($output_filter, $world->result->$stream, $matches) ) { + $output = array_pop($matches); + } else { + $output = ''; + } + } else { + $output = $world->result->$stream; + } + $world->variables[ $key ] = trim($output, "\n"); + } +); + +$steps->Given( + '/^a new Phar with (?:the same version|version "([^"]+)")$/', + function ($world, $version = 'same') { + $world->build_phar($version); + } +); + +$steps->Given( + '/^a downloaded Phar with (?:the same version|version "([^"]+)")$/', + function ($world, $version = 'same') { + $world->download_phar($version); + } +); + +$steps->Given( + '/^save the (.+) file ([\'].+[^\'])?as \{(\w+)\}$/', + function ($world, $filepath, $output_filter, $key) { + $full_file = file_get_contents($world->replace_variables($filepath)); + + if ( $output_filter ) { + $output_filter = '/' . trim(str_replace('%s', '(.+[^\b])', $output_filter), "' ") . '/'; + if ( false !== preg_match($output_filter, $full_file, $matches) ) { + $output = array_pop($matches); + } else { + $output = ''; + } + } else { + $output = $full_file; + } + $world->variables[ $key ] = trim($output, "\n"); + } +); + +$steps->Given( + '/^a misconfigured WP_CONTENT_DIR constant directory$/', + function ($world) { + $wp_config_path = $world->variables['RUN_DIR'] . '/wp-config.php'; + + $wp_config_code = file_get_contents($wp_config_path); + + $world->add_line_to_wp_config( + $wp_config_code, + "define( 'WP_CONTENT_DIR', '' );" + ); + + file_put_contents($wp_config_path, $wp_config_code); + } +); + +$steps->Given( + '/^a dependency on current wp-cli$/', + function ($world) { + $world->composer_require_current_wp_cli(); + } +); + +$steps->Given( + '/^a PHP built-in web server$/', + function ($world) { + $world->start_php_server(); + } +); + +$steps->Give( + '/^I insert arbitrary UID postmeta data for user "([a-zA-Z0-9.@]+)" in site "(.*)"$/', + function ($world, $user, $site) { + $postids = $world->proc(sprintf('wp post list --field=ID --path=%s', $site))->run_check(); + $postids = explode("\n", $postids->stdout); + $userid = $world->proc(sprintf('wp user get %s --field=ID --path=%s', $user, $site))->run_check(); + foreach ( $postids as $pid ) { + $pid = trim($pid); + if ( ! empty($pid) ) { + $world->proc(sprintf('wp post meta add %s _a_userid_field %s --path=%s', $pid, trim($userid->stdout), $site))->run_check(); + } + } + } +); diff --git a/inc/site-exporter/mu-migration/features/steps/then.php b/inc/site-exporter/mu-migration/features/steps/then.php new file mode 100644 index 00000000..c4e56198 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/steps/then.php @@ -0,0 +1,260 @@ +Then( + '/^the return code should be (\d+)$/', + function ($world, $return_code) { + if ( $return_code != $world->result->return_code ) { + throw new RuntimeException($world->result); + } + } +); + +$steps->Then( + '/^(STDOUT|STDERR) should (be|contain|not contain):$/', + function ($world, $stream, $action, PyStringNode $expected) { + + $stream = strtolower($stream); + + $expected = $world->replace_variables((string) $expected); + + $expected_pieces = explode('|AND|', $expected); + foreach ( $expected_pieces as $expected_piece ) { + $expected_piece = trim($expected_piece); + + if ( ! empty($expected_piece) ) { + checkString($world->result->$stream, $expected_piece, $action, $world->result); + } + } + } +); + +$steps->Then( + '/^(STDOUT|STDERR) should be a number$/', + function ($world, $stream) { + + $stream = strtolower($stream); + + assertNumeric(trim($world->result->$stream, "\n")); + } +); + +$steps->Then( + '/^(STDOUT|STDERR) should not be a number$/', + function ($world, $stream) { + + $stream = strtolower($stream); + + assertNotNumeric(trim($world->result->$stream, "\n")); + } +); + +$steps->Then( + '/^STDOUT should be a table containing rows:$/', + function ($world, TableNode $expected) { + $output = $world->result->stdout; + $actual_rows = explode("\n", rtrim($output, "\n")); + + $expected_rows = []; + foreach ( $expected->getRows() as $row ) { + $expected_rows[] = $world->replace_variables(implode("\t", $row)); + } + + compareTables($expected_rows, $actual_rows, $output); + } +); + +$steps->Then( + '/^STDOUT should end with a table containing rows:$/', + function ($world, TableNode $expected) { + $output = $world->result->stdout; + $actual_rows = explode("\n", rtrim($output, "\n")); + + $expected_rows = []; + foreach ( $expected->getRows() as $row ) { + $expected_rows[] = $world->replace_variables(implode("\t", $row)); + } + + $start = array_search($expected_rows[0], $actual_rows); + + if ( false === $start ) { + throw new \Exception($world->result); + } + + compareTables($expected_rows, array_slice($actual_rows, $start), $output); + } +); + +$steps->Then( + '/^STDOUT should be JSON containing:$/', + function ($world, PyStringNode $expected) { + $output = $world->result->stdout; + $expected = $world->replace_variables((string) $expected); + + if ( ! checkThatJsonStringContainsJsonString($output, $expected) ) { + throw new \Exception($world->result); + } + } +); + +$steps->Then( + '/^STDOUT should be a JSON array containing:$/', + function ($world, PyStringNode $expected) { + $output = $world->result->stdout; + $expected = $world->replace_variables((string) $expected); + + $actualValues = json_decode($output); + $expectedValues = json_decode($expected); + + $missing = array_diff($expectedValues, $actualValues); + if ( ! empty($missing) ) { + throw new \Exception($world->result); + } + } +); + +$steps->Then( + '/^STDOUT should be CSV containing:$/', + function ($world, TableNode $expected) { + $output = $world->result->stdout; + + $expected_rows = $expected->getRows(); + foreach ( $expected as &$row ) { + foreach ( $row as &$value ) { + $value = $world->replace_variables($value); + } + } + + if ( ! checkThatCsvStringContainsValues($output, $expected_rows) ) { + throw new \Exception($world->result); + } + } +); + +$steps->Then( + '/^STDOUT should be YAML containing:$/', + function ($world, PyStringNode $expected) { + $output = $world->result->stdout; + $expected = $world->replace_variables((string) $expected); + + if ( ! checkThatYamlStringContainsYamlString($output, $expected) ) { + throw new \Exception($world->result); + } + } +); + +$steps->Then( + '/^(STDOUT|STDERR) should be empty$/', + function ($world, $stream) { + + $stream = strtolower($stream); + + if ( ! empty($world->result->$stream) ) { + throw new \Exception($world->result); + } + } +); + +$steps->Then( + '/^(STDOUT|STDERR) should not be empty$/', + function ($world, $stream) { + + $stream = strtolower($stream); + + if ( '' === rtrim($world->result->$stream, "\n") ) { + throw new Exception($world->result); + } + } +); + +$steps->Then( + '/^(STDOUT|STDERR) should be a version string (<|<=|>|>=|==|=|!=|<>) ([+\w.{}-]+)$/', + function ($world, $stream, $operator, $goal_ver) { + $goal_ver = $world->replace_variables($goal_ver); + $stream = strtolower($stream); + if ( false === version_compare(trim($world->result->$stream, "\n"), $goal_ver, $operator) ) { + throw new Exception($world->result); + } + } +); + +$steps->Then( + '/(the|the regex) (.+) (file|directory) should (exist|not exist|be:|contain:|not contain:)$/', + function ($world, $strategy, $path, $type, $action, $expected = null) { + $path = $world->replace_variables($path); + + // If it's a relative path, make it relative to the current test dir + if ( '/' !== $path[0] ) { + $path = $world->variables['RUN_DIR'] . "/$path"; + } + + if ( 'file' == $type ) { + if ( 'the regex' === $strategy ) { + $test = 'file_exists_regex'; + } else { + $test = 'file_exists'; + } + } elseif ( 'directory' == $type ) { + $test = 'is_dir'; + } + + switch ( $action ) { + case 'exist': + if ( ! $test($path) ) { + throw new Exception("$path doesn't exist."); + } + break; + case 'not exist': + if ( $test($path) ) { + throw new Exception("$path exists."); + } + break; + default: + if ( ! $test($path) ) { + throw new Exception("$path doesn't exist."); + } + $action = substr($action, 0, -1); + $expected = $world->replace_variables((string) $expected); + if ( 'file' == $type ) { + $contents = file_get_contents($path); + } elseif ( 'directory' == $type ) { + $files = glob(rtrim($path, '/') . '/*'); + foreach ( $files as &$file ) { + $file = str_replace($path . '/', '', $file); + } + $contents = implode(PHP_EOL, $files); + } + $expected_pieces = explode('|AND|', $expected); + foreach ( $expected_pieces as $expected_piece ) { + $expected_piece = trim($expected_piece); + + if ( ! empty($expected_piece) ) { + checkString($contents, $expected_piece, $action); + } + } + } + } +); + +$steps->Then( + '/^an email should (be sent|not be sent)$/', + function ($world, $expected) { + if ( 'be sent' === $expected ) { + assertNotEquals(0, $world->email_sends); + } elseif ( 'not be sent' === $expected ) { + assertEquals(0, $world->email_sends); + } else { + throw new Exception('Invalid expectation'); + } + } +); + +$steps->Then( + '/^the HTTP status code should be (\d+)$/', + function ($world, $return_code) { + $response = \Requests::request('http://localhost:8080'); + assertEquals($return_code, $response->status_code); + } +); diff --git a/inc/site-exporter/mu-migration/features/steps/when.php b/inc/site-exporter/mu-migration/features/steps/when.php new file mode 100644 index 00000000..68994f88 --- /dev/null +++ b/inc/site-exporter/mu-migration/features/steps/when.php @@ -0,0 +1,58 @@ + 'run_check', + 'try' => 'run', + ]; + $method = $map[ $mode ]; + + return $proc->$method(); +} + +function capture_email_sends($stdout) { + $stdout = preg_replace('#WP-CLI test suite: Sent email to.+\n?#', '', $stdout, -1, $email_sends); + return [$stdout, $email_sends]; +} + +$steps->When( + '/^I launch in the background `([^`]+)`$/', + function ($world, $cmd) { + $world->background_proc($cmd); + } +); + +$steps->When( + '/^I (run|try) `([^`]+)`$/', + function ($world, $mode, $cmd) { + $cmd = $world->replace_variables($cmd); + $world->result = invoke_proc($world->proc($cmd), $mode); + list( $world->result->stdout, $world->email_sends ) = capture_email_sends($world->result->stdout); + } +); + +$steps->When( + "/^I (run|try) `([^`]+)` from '([^\s]+)'$/", + function ($world, $mode, $cmd, $subdir) { + $cmd = $world->replace_variables($cmd); + $world->result = invoke_proc($world->proc($cmd, [], $subdir), $mode); + list( $world->result->stdout, $world->email_sends ) = capture_email_sends($world->result->stdout); + } +); + +$steps->When( + '/^I (run|try) the previous command again$/', + function ($world, $mode) { + if ( ! isset($world->result) ) { + throw new \Exception('No previous command.'); + } + + $proc = Process::create($world->result->command, $world->result->cwd, $world->result->env); + $world->result = invoke_proc($proc, $mode); + list( $world->result->stdout, $world->email_sends ) = capture_email_sends($world->result->stdout); + } +); diff --git a/inc/site-exporter/mu-migration/features/tests/csv_matches_user.php b/inc/site-exporter/mu-migration/features/tests/csv_matches_user.php new file mode 100644 index 00000000..8de8a2ae --- /dev/null +++ b/inc/site-exporter/mu-migration/features/tests/csv_matches_user.php @@ -0,0 +1,75 @@ +{$user_id}) ) { + $user_id = (int) $map_file->{$user_id}; + } + $actual_user_data = get_userdata($user_id); + $actual_user_meta = get_user_meta($user_id); + + if ( is_multisite() && ! is_user_member_of_blog($user_id, get_current_blog_id()) ) { + throw new Exception(sprintf('User does not belong to the exported site: %d:%d', $expected_user_data['ID'], $user_id)); + } + + $skip_fields = ['ID', 'user_pass', 'user_registered']; + foreach ( $expected_user_data as $key => $value ) { + // if the user already existed, let's skip some fields + if ( $user_id !== $expected_user_data['ID'] && in_array($key, $skip_fields, true) ) { + continue; + } + if ( isset($actual_user_data->$key) && $actual_user_data->$key != $expected_user_data[ $key ] ) { + throw new Exception( + sprintf( + 'User data does not match: #%d:#%d %s -> %s:%s', + $expected_user_data['ID'], + $user_id, + $key, + $actual_user_data->$key, + $expected_user_data[ $key ] + ) + ); + } + + if ( isset($actual_user_meta[ $key ]) && $actual_user_meta[ $key ][0] != $expected_user_data[ $key ] ) { + throw new Exception( + sprintf( + 'User meta does not match: #%d:#%d %s -> %s:%s', + $expected_user_data['ID'], + $user_id, + $key, + $actual_user_meta[ $key ][0], + $expected_user_data[ $key ] + ) + ); + } + } + } + fclose($handle); + } else { + throw new Exception('Cannot open file'); + } + + echo 'Success'; +} catch ( Exception $e ) { + echo 'Faiure: ' . $e->getMessage(); +} diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-base.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-base.php new file mode 100644 index 00000000..8deb69a5 --- /dev/null +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-base.php @@ -0,0 +1,202 @@ +args = $args + $default_args; + $this->assoc_args = wp_parse_args($assoc_args, $default_assoc_args); + } + + /** + * Runs through all posts and executes the provided callback for each post. + * + * @param array $query_args + * @param callable $callback + * @param bool $verbose + */ + protected function all_posts($query_args, $callback, $verbose = true) { + if ( ! is_callable($callback) ) { + self::error(__('The provided callback is invalid', 'mu-migration')); + } + + $default_args = [ + 'post_type' => 'post', + 'posts_per_page' => 1000, + 'post_status' => ['publish', 'pending', 'draft', 'future', 'private'], + 'cache_results ' => false, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'offset' => 0, + ]; + + /** + * Filters the default args for querying posts in the all_posts method. + * + * @since 0.2.0 + * + * @param array $default_args The default args array. + * @return array Default args array. + */ + $default_args = apply_filters('mu-migration/all_posts/default_args', $default_args); + + $query_args = wp_parse_args($query_args, $default_args); + $query = new \WP_Query($query_args); + + $counter = 0; + $found_posts = 0; + while ( $query->have_posts() ) { + $query->the_post(); + + $callback(); + + if ( 0 === $counter ) { + $found_posts = $query->found_posts; + } + + ++$counter; + + if ( 0 === $counter % $query_args['posts_per_page'] ) { + Helpers\stop_the_insanity(); + + $this->log(sprintf(__('Posts Updated: %1$d/%2$d', 'mu-migration'), $counter, $found_posts), true); + $query_args['offset'] += $query_args['posts_per_page']; + $query = new \WP_Query($query_args); + } + } + + wp_reset_postdata(); + + $this->success( + sprintf( + __('%d posts were updated', 'mu-migration'), + $counter + ), + $verbose + ); + } + + /** + * Runs through all records on a specific table. + * + * @param string $message + * @param string $table + * @param callable $callback + * @return bool + */ + protected function all_records($message, $table, $callback) { + global $wpdb; + + $offset = 0; + $step = 1000; + + $found_posts = $wpdb->get_col("SELECT COUNT(ID) FROM {$table}"); + + if ( ! $found_posts ) { + return false; + } + + $found_posts = $found_posts[0]; + + $progress_bar = \WP_CLI\Utils\make_progress_bar(sprintf('[%d] %s', $found_posts, $message), (int) $found_posts, 1); + $progress_bar->display(); + + do { + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} LIMIT %d OFFSET %d", + [ + $step, + $offset, + ] + ) + ); + + if ( $results ) { + foreach ( $results as $result ) { + $callback($result); + $progress_bar->tick(); + } + } + + $offset += $step; + } while ( $results ); + } + + /** + * Outputs a line. + * + * @param string $msg + * @param bool $verbose + */ + protected function line($msg, $verbose) { + if ( $verbose ) { + WP_CLI::line($msg); + } + } + + /** + * Outputs a log message. + * + * @param string $msg + * @param bool $verbose + */ + protected function log($msg, $verbose) { + if ( $verbose ) { + WP_CLI::log($msg); + } + } + + /** + * Outputs a success message. + * + * @param string $msg + * @param bool $verbose + */ + protected function success($msg, $verbose) { + if ( $verbose ) { + WP_CLI::success($msg); + } + } + + /** + * Outputs a warning. + * + * @param string $msg + * @param bool $verbose + */ + protected function warning($msg, $verbose) { + if ( $verbose ) { + WP_CLI::warning($msg); + } + } +} diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php new file mode 100644 index 00000000..47baecd3 --- /dev/null +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php @@ -0,0 +1,532 @@ + + * : The name of the exported sql file + * + * ## EXAMPLES + * + * wp mu-migration export tables output.sql + * + * @synopsis [--blog_id=] [--tables=] [--non-default-tables=] + * + * @param array $args + * @param array $assoc_args + * @param bool $verbose + */ + public function tables($args = [], $assoc_args = [], $verbose = true) { + global $wpdb; + + $this->process_args( + [ + 0 => '', // output file name + ], + $args, + [ + 'blog_id' => 1, + 'tables' => '', + 'non-default-tables' => '', + ], + $assoc_args + ); + + $filename = $this->args[0]; + + if ( isset($this->assoc_args['blog_id']) ) { + $url = get_home_url((int) $this->assoc_args['blog_id']); + } + + /* + * If the user hasn't supplied the tables he wants to export, let's get them automatically + */ + if ( empty($this->assoc_args['tables']) ) { + $assoc_args = ['format' => 'csv']; + + if ( empty($this->assoc_args['non-default-tables']) && ($this->assoc_args['blog_id'] != 1 || ! is_multisite()) ) { + $assoc_args['all-tables-with-prefix'] = 1; + } + + $tables = Helpers\runcommand('db tables', [], $assoc_args, ['url' => $url]); + + if ( 0 === $tables->return_code ) { + $tables = $tables->stdout; + $tables = explode(',', $tables); + + $tables_to_remove = [ + $wpdb->prefix . 'users', + $wpdb->prefix . 'usermeta', + $wpdb->prefix . 'blog_versions', + $wpdb->prefix . 'blogs', + $wpdb->prefix . 'site', + $wpdb->prefix . 'sitemeta', + $wpdb->prefix . 'registration_log', + $wpdb->prefix . 'signups', + $wpdb->prefix . 'sitecategories', + ]; + + foreach ( $tables as $key => &$table ) { + $table = trim($table); + + if ( in_array($table, $tables_to_remove) ) { + unset($tables[ $key ]); + } + } + } + + if ( ! empty($this->assoc_args['non-default-tables']) ) { + $non_default_tables = explode(',', $this->assoc_args['non-default-tables']); + + $tables = array_unique(array_merge($tables, $non_default_tables)); + } + } else { + // get the user supplied tables list + $tables = explode(',', $this->assoc_args['tables']); + } + + if ( is_array($tables) && ! empty($tables) ) { + $export = Helpers\runcommand('db export', [$filename], ['tables' => implode(',', $tables)]); + + if ( 0 === $export->return_code ) { + $this->success(__('The export is now complete', 'mu-migration'), $verbose); + } else { + \WP_CLI::error(__('Something went wrong while trying to export the database', 'mu-migration')); + } + } else { + \WP_CLI::error(__('Unable to get the list of tables to be exported', 'mu-migration')); + } + } + + /** + * Exports all users to a .csv file. + * + * ## OPTIONS + * + * + * : The name of the exported .csv file + * + * ## EXAMPLES + * + * wp mu-migration export users output.dev --blog_id=2 --woocomerce + * + * @synopsis [--blog_id=] [--woocomerce] + * + * @param array $args + * @param array $assoc_args + * @param bool $verbose + */ + public function users($args = [], $assoc_args = [], $verbose = true) { + $this->process_args( + [ + 0 => 'users.csv', + ], + $args, + [ + 'blog_id' => '', + ], + $assoc_args + ); + + $filename = $this->args[0]; + $delimiter = ','; + + $file_handler = fopen($filename, 'w+'); + + if ( ! $file_handler ) { + \WP_CLI::error(__('Impossible to create the file', 'mu-migration')); + } + + $headers = self::getCSVHeaders(); + + $users_args = [ + 'fields' => 'all', + ]; + + if ( ! empty($this->assoc_args['blog_id']) ) { + $users_args['blog_id'] = (int) $this->assoc_args['blog_id']; + } + + $excluded_meta_keys = [ + 'session_tokens' => true, + 'primary_blog' => true, + 'source_domain' => true, + ]; + + /* + * We don't want meta keys that depends on the db prefix + * + * @see http://stackoverflow.com/a/25316090 + */ + $excluded_meta_keys_regex = [ + '/capabilities$/', + '/user_level$/', + '/dashboard_quick_press_last_post_id$/', + '/user-settings$/', + '/user-settings-time$/', + ]; + + $count = 0; + $users = get_users($users_args); + $user_data_arr = []; + + /* + * This first foreach will pragmatically find all users meta stored in the usersmeta table. + */ + foreach ( $users as $user ) { + $role = isset($user->roles[0]) ? $user->roles[0] : ''; + + $user_data = [ + // General Info. + $user->data->ID, + $user->data->user_login, + $user->data->user_pass, + $user->data->user_nicename, + $user->data->user_email, + $user->data->user_url, + $user->data->user_registered, + $role, + $user->data->user_status, + $user->data->display_name, + + // User Meta. + $user->get('rich_editing'), + $user->get('admin_color'), + $user->get('show_admin_bar_front'), + $user->get('first_name'), + $user->get('last_name'), + $user->get('nickname'), + $user->get('aim'), + $user->get('yim'), + $user->get('jabber'), + $user->get('description'), + ]; + + /* + * Keeping arrays consistent, not all users have the same meta, so it's possible to have some users who + * don't even have a given meta key. We must assure that these users have an empty column for these fields. + */ + if ( count($headers) - count($user_data) > 0 ) { + $user_temp_data_arr = array_fill(0, count($headers) - count($user_data), ''); + $user_data = array_merge($user_data, $user_temp_data_arr); + } + + $user_data = array_combine($headers, $user_data); + + $user_meta = get_user_meta($user->data->ID); + $meta_keys = array_keys($user_meta); + + /* + * Removing all unwanted meta keys. + */ + foreach ( $meta_keys as $user_meta_key ) { + if ( ! isset($excluded_meta_keys[ $user_meta_key ]) ) { + $can_add = true; + + /* + * Checking for unwanted meta keys. + */ + foreach ( $excluded_meta_keys_regex as $regex ) { + if ( preg_match($regex, $user_meta_key) ) { + $can_add = false; + } + } + + if ( ! $can_add ) { + unset($user_meta[ $user_meta_key ]); + } + } else { + unset($user_meta[ $user_meta_key ]); + } + } + + // Get the meta keys again. + $meta_keys = array_keys($user_meta); + + foreach ( $meta_keys as $user_meta_key ) { + $value = $user_meta[ $user_meta_key ]; + + // get_user_meta always return an array whe no $key is passed. + if ( is_array($value) && 1 === count($value) ) { + $value = $value[0]; + } + + // If it's still an array or object, then we need to serialize. + if ( is_array($value) || is_object($value) ) { + $value = serialize($value); + } + + $user_data[ $user_meta_key ] = $value; + } + + // Adding the meta_keys that aren't in the $headers variable to the $headers variable. + $diff = array_diff($meta_keys, $headers); + $headers = array_merge($headers, $diff); + + /** + * Filters the default set of user data to be exported/imported. + * + * @since 0.1.0 + * + * @param array $custom_user_data The custom user data array. + * @param \WP_User $user The user object. + * @return array Array data user. + */ + $custom_user_data = apply_filters('mu_migration/export/user/data', [], $user); + + if ( ! empty($custom_user_data) ) { + $user_data = array_merge($user_data, $custom_user_data); + } + + if ( count(array_values($user_data)) !== count($headers) ) { + \WP_CLI::error(__('The headers and data length are not matching', 'mu-migration')); + } + + $user_data_arr[] = $user_data; + ++$count; + } + + /* + * Now that we have all users meta keys, we can save everything into a csv file. + */ + fputcsv($file_handler, $headers, $delimiter); + + foreach ( $user_data_arr as $user_data ) { + if ( count($headers) - count($user_data) > 0 ) { + $user_temp_data_arr = array_fill(0, count($headers) - count($user_data), ''); + $user_data = array_merge(array_values($user_data), $user_temp_data_arr); + } + fputcsv($file_handler, $user_data, $delimiter); + } + + fclose($file_handler); + + $this->success( + sprintf( + __('%d users have been exported', 'mu-migration'), + absint($count) + ), + $verbose + ); + } + + /** + * Exports the whole site into a zip file. + * + * ## OPTIONS + * + * + * : The name of the exported .zip file + * + * ## EXAMPLES + * + * wp mu-migration export all site.zip + * + * @synopsis [] [--blog_id=] [--tables=] [--non-default-tables=] [--plugins] [--themes] [--uploads] [--verbose] + * + * @param array $args + * @param array $assoc_args + */ + public function all($args = [], $assoc_args = []) { + global $wpdb; + + $switched = false; + + if ( isset($assoc_args['blog_id']) ) { + Helpers\maybe_switch_to_blog((int) $assoc_args['blog_id']); + $switched = true; + } + + $verbose = false; + + if ( isset($assoc_args['verbose']) ) { + $verbose = true; + } + + $site_data = [ + 'url' => esc_url(home_url()), + 'name' => sanitize_text_field(get_bloginfo('name')), + 'admin_email' => sanitize_text_field(get_bloginfo('admin_email')), + 'site_language' => sanitize_text_field(get_bloginfo('language')), + 'db_prefix' => $wpdb->prefix, + 'plugins' => get_plugins(), + 'blog_plugins' => get_option('active_plugins'), + 'network_plugins' => is_multisite() ? get_site_option('active_sitewide_plugins') : [], + 'blog_id' => 1, + ]; + + if ( isset($assoc_args['blog_id']) ) { + $site_data['blog_id'] = get_current_blog_id(); + } + + $this->process_args( + [ + 0 => 'mu-migration-' . sanitize_title($site_data['name']) . '.zip', + ], + $args, + [ + 'blog_id' => false, + 'tables' => '', + 'non-default-tables' => '', + ], + $assoc_args + ); + + $zip_file = $this->args[0]; + + $include_plugins = isset($this->assoc_args['plugins']) ? true : false; + $include_themes = isset($this->assoc_args['themes']) ? true : false; + $include_uploads = isset($this->assoc_args['uploads']) ? true : false; + + $users_assoc_args = []; + $tables_assoc_args = [ + 'tables' => $this->assoc_args['tables'], + 'non-default-tables' => $this->assoc_args['non-default-tables'], + ]; + + if ( $this->assoc_args['blog_id'] ) { + $users_assoc_args['blog_id'] = (int) $this->assoc_args['blog_id']; + $tables_assoc_args['blog_id'] = (int) $this->assoc_args['blog_id']; + } + + $rand = rand(); + + /* + * Adding rand() to the temporary file names to guarantee uniqueness. + */ + $users_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.csv'; + $tables_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.sql'; + $meta_data_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.json'; + + \WP_CLI::log(__('Exporting site meta data...', 'mu-migration')); + file_put_contents($meta_data_file, wp_json_encode($site_data)); + + \WP_CLI::log(__('Exporting users...', 'mu-migration')); + $this->users([$users_file], $users_assoc_args, $verbose); + + \WP_CLI::log(__('Exporting tables', 'mu-migration')); + $this->tables([$tables_file], $tables_assoc_args, $verbose); + + $zip = null; + + /* + * Removing previous $zip_file, if any. + */ + if ( file_exists($zip_file) ) { + unlink($zip_file); + } + + $files_to_zip = [ + $users_file => $users_file, + $tables_file => $tables_file, + $meta_data_file => $meta_data_file, + ]; + + if ( $include_plugins ) { + $files_to_zip['wp-content/plugins'] = WP_PLUGIN_DIR; + } + + if ( $include_themes ) { + $theme_dir = get_template_directory(); + $files_to_zip[ 'wp-content/themes/' . basename($theme_dir) ] = $theme_dir; + if ( get_template_directory() !== get_stylesheet_directory() ) { + $child_theme_dir = get_stylesheet_directory(); + $files_to_zip[ 'wp-content/themes/' . basename($child_theme_dir) ] = $child_theme_dir; + } + } + + if ( $include_uploads ) { + $upload_dir = wp_upload_dir(); + $files_to_zip['wp-content/uploads'] = $upload_dir['basedir']; + } + + try { + \WP_CLI::log(__('Zipping files....', 'mu-migration')); + $zip = Helpers\zip($zip_file, $files_to_zip); + } catch ( \Exception $e ) { + \WP_CLI::warning($e->getMessage()); + } + + if ( file_exists($users_file) ) { + unlink($users_file); + } + + if ( file_exists($tables_file) ) { + unlink($tables_file); + } + + if ( file_exists($meta_data_file) ) { + unlink($meta_data_file); + } + + if ( $zip !== null ) { + \WP_CLI::success(sprintf(__('A zip file named %s has been created', 'mu-migration'), $zip_file)); + } + + if ( $switched ) { + Helpers\maybe_restore_current_blog(); + } + } +} + +\WP_CLI::add_command('mu-migration export', __NAMESPACE__ . '\\ExportCommand'); diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php new file mode 100644 index 00000000..7de11c3a --- /dev/null +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php @@ -0,0 +1,738 @@ + + * : The name of the exported .csv file + * + * ## EXAMPLES + * + * wp mu-migration import users users.csv --map_file=ids_maps.json + * + * @synopsis --map_file= [--blog_id=] + * + * @param array $args + * @param array $assoc_args + * @param bool $verbose + */ + public function users($args = [], $assoc_args = [], $verbose = true) { + global $wpdb; + + $is_multisite = is_multisite(); + + $this->process_args( + [ + 0 => '', // .csv to import users. + ], + $args, + [ + 'blog_id' => 1, + 'map_file' => 'ids_maps.json', + ], + $assoc_args + ); + + $filename = $this->args[0]; + + if ( empty($filename) || ! file_exists($filename) ) { + WP_CLI::error(__('Invalid input file', 'mu-migration')); + } + + $input_file_handler = fopen($filename, 'r'); + + $delimiter = ','; + + /** + * This array will hold the new id for each old id. + * + * Example: + * array( + * 'OLD_ID' => 'NEW_ID' + * ); + */ + $ids_maps = []; + $labels = []; + $count = 0; + $existing_users = 0; + + if ( false !== $input_file_handler ) { + $this->line(sprintf(__('Parsing %s...', 'mu-migration'), $filename), $verbose); + + $line = 0; + + Helpers\maybe_switch_to_blog($this->assoc_args['blog_id']); + + wp_suspend_cache_addition(true); + while ( false !== ($data = fgetcsv($input_file_handler, 0, $delimiter)) ) { + // Read the labels and skip. + if ( 0 === $line++ ) { + $labels = $data; + continue; + } + + $user_data = array_combine($labels, $data); + + $old_id = $user_data['ID']; + unset($user_data['ID']); + + $user_exists = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->users} WHERE user_login = %s OR (user_email = %s AND user_email != '');", + $user_data['user_login'], + $user_data['user_email'] + ) + ); + + $user_exists = $user_exists ? $user_exists[0] : false; + + if ( ! $user_exists ) { + + /* + * wp_insert_users accepts only the default user meta keys. + */ + $default_user_data = []; + foreach ( ExportCommand::getCSVHeaders() as $key ) { + if ( isset($user_data[ $key ]) ) { + $default_user_data[ $key ] = $user_data[ $key ]; + } + } + + // All custom user meta data. + $user_meta_data = array_diff_assoc($user_data, $default_user_data); + + $new_id = wp_insert_user($default_user_data); + + if ( ! is_wp_error($new_id) ) { + $wpdb->update($wpdb->users, ['user_pass' => $user_data['user_pass']], ['ID' => $new_id]); + + $user = new \WP_User($new_id); + + // Inserts all custom meta data + foreach ( $user_meta_data as $meta_key => $meta_value ) { + update_user_meta($new_id, $meta_key, maybe_unserialize($meta_value)); + } + + /** + * Fires before exporting the custom user data. + * + * @since 0.1.0 + * + * @param array $user_data The user data array. + * @param \WP_User $user The user object. + */ + do_action('mu_migration/import/user/custom_data_before', $user_data, $user); + + /** + * Ignores it is already documented in + * inc/mu-migration/includes/commands/class-mu-migration-export.php:335. + * + * @ignore + */ + $custom_user_data = apply_filters('mu_migration/export/user/data', [], $user); + + if ( ! empty($custom_user_data) ) { + foreach ( $custom_user_data as $meta_key => $meta_value ) { + if ( isset($user_data[ $meta_key ]) ) { + update_user_meta($new_id, $meta_key, sanitize_text_field($meta_value)); + } + } + } + + /** + * Fires after exporting the custom user data. + * + * @since 0.1.0 + * + * @param array $user_data The user data array. + * @param \WP_User $user The user object. + */ + do_action('mu_migration/import/user/custom_data_after', $user_data, $user); + + ++$count; + $ids_maps[ $old_id ] = $new_id; + if ( $is_multisite ) { + Helpers\light_add_user_to_blog($this->assoc_args['blog_id'], $new_id, $user_data['role']); + } + } else { + $this->warning( + sprintf( + __('An error has occurred when inserting %1$s: %2$s.', 'mu-migration'), + $user_data['user_login'], + implode(', ', $new_id->get_error_messages()) + ), + $verbose + ); + } + } else { + $this->warning( + sprintf( + __('%1$s exists, using his ID (%2$d)...', 'mu-migration'), + $user_data['user_login'], + $user_exists + ), + $verbose + ); + + ++$existing_users; + $ids_maps[ $old_id ] = $user_exists; + if ( $is_multisite ) { + Helpers\light_add_user_to_blog($this->assoc_args['blog_id'], $user_exists, $user_data['role']); + } + } + + unset($user_exists); + unset($user_data); + unset($data); + } + + wp_suspend_cache_addition(false); + + Helpers\maybe_restore_current_blog(); + + if ( ! empty($ids_maps) ) { + // Saving the ids_maps to a file. + $output_file_handler = fopen($this->assoc_args['map_file'], 'w+'); + fwrite($output_file_handler, json_encode($ids_maps)); + fclose($output_file_handler); + + $this->success( + sprintf( + __('A map file has been created: %s', 'mu-migration'), + $this->assoc_args['map_file'] + ), + $verbose + ); + } + + $this->success( + sprintf( + __('%1$d users have been imported and %2$d users already existed', 'mu-migration'), + absint($count), + absint($existing_users) + ), + $verbose + ); + } else { + WP_CLI::error( + sprintf( + __('Can not read the file %s', 'mu-migration'), + $filename + ) + ); + } + } + + /** + * Imports the tables from a single site instance. + * + * This command will perform the search-replace as well as + * the necessary updates to make the new tables work with multisite. + * + * ## OPTIONS + * + * + * : The name of the exported .sql file + * + * ## EXAMPLES + * + * wp mu-migration import tables site.sql --old_prefix=wp_ --old_url=old_domain.com --new_url=new_domain.com + * + * @synopsis --blog_id= --old_prefix= --new_prefix= [--original_blog_id=] [--old_url=] [--new_url=] + * + * @param array $args + * @param array $assoc_args + * @param bool $verbose + */ + public function tables($args = [], $assoc_args = [], $verbose = true) { + global $wpdb, $wu_site_exporter_site_id; + + $this->process_args( + [ + 0 => '', // .sql file to import. + ], + $args, + [ + 'blog_id' => '', + 'old_url' => '', + 'new_url' => '', + 'old_prefix' => $wpdb->prefix, + 'new_prefix' => '', + ], + $assoc_args + ); + + $filename = $this->args[0]; + + if ( empty($filename) || ! file_exists($filename) ) { + WP_CLI::error(__('Invalid input file', 'mu-migration')); + } + + if ( empty($this->assoc_args['blog_id']) ) { + WP_CLI::error(__('Please, provide a blog_id ', 'mu-migration')); + } + + $wu_site_exporter_site_id = $this->assoc_args['blog_id']; + + // Terminates the script if sed is not installed. + $this->check_for_sed_presence(true); + + // Replaces the db prefix and saves back the modifications to the sql file. + if ( ! empty($this->assoc_args['new_prefix']) ) { + $this->replace_db_prefix($filename, $this->assoc_args['old_prefix'], $this->assoc_args['new_prefix']); + } + + $import = Helpers\runcommand('db import', [$filename]); + + if ( 0 === $import->return_code ) { + $this->log(__('Database imported', 'mu-migration'), $verbose); + + // Perform search and replace. + if ( ! empty($this->assoc_args['old_url']) && ! empty($this->assoc_args['new_url']) ) { + $this->log(__('Running search-replace', 'mu-migration'), $verbose); + + $old_url = Helpers\parse_url_for_search_replace($this->assoc_args['old_url']); + $new_url = Helpers\parse_url_for_search_replace($this->assoc_args['new_url']); + + $search_replace = Helpers\launch_self( + 'search-replace', + [ + $old_url, + $new_url, + ], + [], + false, + false, + ['url' => $new_url] + ); + + if ( 0 === $search_replace ) { + $this->log(__('Search and Replace has been successfully executed', 'mu-migration'), $verbose); + } + + $this->log(__('Running Search and Replace for uploads paths', 'mu-migration'), $verbose); + + $from = $to = 'wp-content/uploads'; + + if ( isset($this->assoc_args['original_blog_id']) && $this->assoc_args['original_blog_id'] > 1 ) { + $from = 'wp-content/uploads/sites/' . (int) $this->assoc_args['original_blog_id']; + } + + if ( $this->assoc_args['blog_id'] > 1 ) { + $to = 'wp-content/uploads/sites/' . (int) $this->assoc_args['blog_id']; + } + + if ( $from && $to ) { + $search_replace = Helpers\launch_self( + 'search-replace', + [$from , $to], + [], + false, + false, + ['url' => $new_url] + ); + + if ( 0 === $search_replace ) { + $this->log(sprintf(__('Uploads paths have been successfully updated: %1$s -> %2$s', 'mu-migration'), $from, $to), $verbose); + } + } + } + + Helpers\maybe_switch_to_blog((int) $this->assoc_args['blog_id']); + + // Update the new tables to work properly with multisite. + $new_wp_roles_option_key = $wpdb->prefix . 'user_roles'; + $old_wp_roles_option_key = $this->assoc_args['old_prefix'] . 'user_roles'; + + // Updating user_roles option key. + $wpdb->update( + $wpdb->options, + [ + 'option_name' => $new_wp_roles_option_key, + ], + [ + 'option_name' => $old_wp_roles_option_key, + ], + [ + '%s', + ], + [ + '%s', + ] + ); + + Helpers\maybe_restore_current_blog(); + } + } + + /** + * Imports a new site into multisite from a zip package. + * + * ## OPTIONS + * + * + * : The name of the exported .zip file + * + * ## EXAMPLES + * + * wp mu-migration import all site.zip --uid_fields=_content_audit_owner + * + * @synopsis [--blog_id=] [--new_url=] [--verbose] [--mysql-single-transaction] [--uid_fields=] + * + * @param array $args + * @param array $assoc_args + */ + public function all($args = [], $assoc_args = []) { + $this->process_args( + [], + $args, + [ + 'blog_id' => '', + 'new_url' => '', + 'mysql-single-transaction' => false, + 'uid_fields' => '', + ], + $assoc_args + ); + + $is_multisite = is_multisite(); + + $verbose = false; + + if ( isset($assoc_args['verbose']) ) { + $verbose = true; + } + + $assoc_args = $this->assoc_args; + + $filename = $this->args[0]; + + if ( ! Helpers\is_zip_file($filename) ) { + WP_CLI::error(__('The provided file does not appear to be a zip file', 'mu-migration')); + } + + $temp_dir = 'mu-migration' . time() . '/'; + + WP_CLI::log(__('Extracting zip package...', 'mu-migration')); + + /* + * Extract the file to the $temp_dir + */ + Helpers\extract($filename, $temp_dir); + + /* + * Looks for required (.json, .csv and .sql) files and for the optional folders + * that can live in the zip package (plugins, themes and uploads). + */ + $site_meta_data = glob($temp_dir . '/*.json'); + $users = glob($temp_dir . '/*.csv'); + $sql = glob($temp_dir . '/*.sql'); + $plugins_folder = glob($temp_dir . '/wp-content/plugins'); + $themes_folder = glob($temp_dir . '/wp-content/themes'); + $uploads_folder = glob($temp_dir . '/wp-content/uploads'); + + if ( empty($site_meta_data) || empty($users) || empty($sql) ) { + WP_CLI::error(__("There's something wrong with your zip package, unable to find required files", 'mu-migration')); + } + + $site_meta_data = json_decode(file_get_contents($site_meta_data[0])); + + $old_url = $site_meta_data->url; + + if ( ! empty($assoc_args['new_url']) ) { + $site_meta_data->url = $assoc_args['new_url']; + } + + if ( empty($assoc_args['blog_id']) && $is_multisite ) { + $blog_id = $this->create_new_site($site_meta_data); + } elseif ( $is_multisite ) { + $blog_id = (int) $assoc_args['blog_id']; + } else { + $blog_id = 1; + } + + if ( ! $blog_id ) { + WP_CLI::error(__('Unable to create new site', 'mu-migration')); + } + + $tables_assoc_args = [ + 'blog_id' => $blog_id, + 'original_blog_id' => $site_meta_data->blog_id, + 'old_prefix' => $site_meta_data->db_prefix, + 'new_prefix' => Helpers\get_db_prefix($blog_id), + ]; + + /* + * If changing URL, then set the proper params to force search-replace in the tables method. + */ + if ( ! empty($assoc_args['new_url']) ) { + $tables_assoc_args['new_url'] = esc_url($assoc_args['new_url']); + $tables_assoc_args['old_url'] = esc_url($old_url); + } + + WP_CLI::log(__('Importing tables...', 'mu-migration')); + + /* + * If the flag --mysql-single-transaction is passed, then the SQL is wrapped with + * START TRANSACTION and COMMIT to insert in one single transaction. + */ + if ( $assoc_args['mysql-single-transaction'] ) { + Helpers\addTransaction($sql[0]); + } + + $this->tables([$sql[0]], $tables_assoc_args, $verbose); + + $map_file = $temp_dir . '/users_map.json'; + + $users_assoc_args = [ + 'map_file' => $map_file, + 'blog_id' => $blog_id, + ]; + + WP_CLI::log(__('Moving files...', 'mu-migration')); + + if ( ! empty($plugins_folder) ) { + $blog_plugins = isset($site_meta_data->blog_plugins) ? (array) $site_meta_data->blog_plugins : false; + $network_plugins = isset($site_meta_data->network_plugins) ? array_keys((array) $site_meta_data->network_plugins) : false; + $this->move_and_activate_plugins($plugins_folder[0], (array) $site_meta_data->plugins, $blog_plugins, $network_plugins); + } + + if ( ! empty($uploads_folder) ) { + $this->move_uploads($uploads_folder[0], $blog_id); + } + + if ( ! empty($themes_folder) ) { + $this->move_themes($themes_folder[0]); + } + + WP_CLI::log(__('Importing Users...', 'mu-migration')); + + $this->users([$users[0]], $users_assoc_args, $verbose); + + if ( file_exists($map_file) ) { + $postsCommand = new PostsCommand(); + + $postsCommand->update_author( + [$map_file], + [ + 'blog_id' => $blog_id, + 'uid_fields' => $assoc_args['uid_fields'], + ], + $verbose + ); + } + + WP_CLI::log(__('Flushing rewrite rules...', 'mu-migration')); + + add_action( + 'init', + function () use ($blog_id) { + /* + * Flush the rewrite rules for the newly created site, just in case. + */ + Helpers\maybe_switch_to_blog($blog_id); + flush_rewrite_rules(); + Helpers\maybe_restore_current_blog(); + }, + 9999 + ); + + WP_CLI::log(__('Removing temporary files....', 'mu-migration')); + + Helpers\delete_folder($temp_dir); + + WP_CLI::success( + sprintf( + __('All done, your new site is available at %s. Remember to flush the cache (memcache, redis etc).', 'mu-migration'), + esc_url($site_meta_data->url) + ) + ); + } + + /** + * Moves the plugins to the right location. + * + * @param string $plugins_dir + * @param array|bool $blog_plguins + * @param array|bool $network_plugins + */ + private function move_and_activate_plugins($plugins_dir, $plugins, $blog_plugins, $network_plugins) { + if ( file_exists($plugins_dir) ) { + WP_CLI::log(__('Moving Plugins...', 'mu-migration')); + $installed_plugins = WP_PLUGIN_DIR; + $check_plugins = false !== $blog_plugins && false !== $network_plugins; + foreach ( $plugins as $plugin_name => $plugin ) { + $plugin_folder = dirname($plugin_name); + $fullPluginPath = $plugins_dir . '/' . $plugin_folder; + + if ( $check_plugins && ! in_array($plugin_name, $blog_plugins, true) && + ! in_array($plugin_name, $network_plugins, true) ) { + continue; + } + + if ( ! file_exists($installed_plugins . '/' . $plugin_folder) ) { + WP_CLI::log(sprintf(__('Moving %s to plugins folder'), $plugin_name)); + rename($fullPluginPath, $installed_plugins . '/' . $plugin_folder); + } + + if ( $check_plugins && in_array($plugin_name, $blog_plugins, true) ) { + WP_CLI::log(sprintf(__('Activating plugin: %s '), $plugin_name)); + activate_plugin($installed_plugins . '/' . $plugin_name); + } elseif ( $check_plugins && in_array($plugin_name, $network_plugins, true) ) { + WP_CLI::log(sprintf(__('Activating plugin network-wide: %s '), $plugin_name)); + activate_plugin($installed_plugins . '/' . $plugin_name, '', true); + } + } + } + } + + /** + * Moves the uploads folder to the right location. + * + * @param string $uploads_dir + * @param int $blog_id + */ + private function move_uploads($uploads_dir, $blog_id) { + if ( file_exists($uploads_dir) ) { + \WP_CLI::log(__('Moving Uploads...', 'mu-migration')); + Helpers\maybe_switch_to_blog($blog_id); + $dest_uploads_dir = wp_upload_dir(); + Helpers\maybe_restore_current_blog(); + + Helpers\move_folder($uploads_dir, $dest_uploads_dir['basedir']); + } + } + + /** + * Moves the themes to the right location. + * + * @param string $themes_dir + */ + private function move_themes($themes_dir) { + if ( file_exists($themes_dir) ) { + WP_CLI::log(__('Moving Themes...', 'mu-migration')); + $themes = new \DirectoryIterator($themes_dir); + $installed_themes = get_theme_root(); + + foreach ( $themes as $theme ) { + if ( $theme->isDir() ) { + $fullPluginPath = $themes_dir . '/' . $theme->getFilename(); + + if ( ! file_exists($installed_themes . '/' . $theme->getFilename()) ) { + WP_CLI::log(sprintf(__('Moving %s to themes folder'), $theme->getFilename())); + rename($fullPluginPath, $installed_themes . '/' . $theme->getFilename()); + + Helpers\runcommand('theme enable', [$theme->getFilename()]); + } + } + } + } + } + + /** + * Creates a new site within multisite. + * + * @param object $meta_data + * @return bool|false|int + */ + private function create_new_site($meta_data) { + $parsed_url = parse_url(esc_url($meta_data->url)); + $site_id = get_main_network_id(); + + $parsed_url['path'] = isset($parsed_url['path']) ? $parsed_url['path'] : '/'; + + if ( domain_exists($parsed_url['host'], $parsed_url['path'], $site_id) ) { + return false; + } + + $blog_id = wp_insert_site( + [ + 'domain' => $parsed_url['host'], + 'path' => $parsed_url['path'], + 'network_id' => $site_id, + ] + ); + + if ( ! $blog_id ) { + return false; + } + + return $blog_id; + } + + /** + * Replaces the db_prefix with a new one using sed. + * + * @param string $filename The filename of the sql file to which the db prefix should be replaced. + * @param string $old_db_prefix The db prefix to be replaced. + * @param string $new_db_prefix The new db prefix. + */ + private function replace_db_prefix($filename, $old_db_prefix, $new_db_prefix) { + $new_prefix = $new_db_prefix; + + if ( ! empty($new_prefix) ) { + $mysql_chunks_regex = [ + 'DROP TABLE IF EXISTS', + 'CREATE TABLE', + 'LOCK TABLES', + 'INSERT INTO', + 'CREATE TABLE IF NOT EXISTS', + 'ALTER TABLE', + 'CONSTRAINT', + 'REFERENCES', + ]; + + // build sed expressions + $sed_commands = []; + foreach ( $mysql_chunks_regex as $regex ) { + $sed_commands[] = "s/{$regex} `{$old_db_prefix}/{$regex} `{$new_prefix}/g"; + } + + foreach ( $sed_commands as $sed_command ) { + $full_command = "sed '$sed_command' -i $filename"; + $sed_result = \WP_CLI::launch($full_command, false, false); + + if ( 0 !== $sed_result ) { + \WP_CLI::warning(__('Something went wrong while running sed', 'mu-migration')); + } + } + } + } + + /** + * Checks whether sed is available or not. + * + * @param bool $exit_on_error If set to true the script will be terminated if sed is not available. + * @return bool + */ + private function check_for_sed_presence($exit_on_error = false) { + $sed = \WP_CLI::launch('echo "wp_" | sed "s/wp_/wp_5_/g"', false, true); + + if ( 'wp_5_' !== trim($sed->stdout, "\x0A") ) { + if ( $exit_on_error ) { + \WP_CLI::error(__('sed not present, please install sed', 'mu-migration')); + } + + return false; + } + + return true; + } +} + +WP_CLI::add_command('mu-migration import', __NAMESPACE__ . '\\ImportCommand'); diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-posts.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-posts.php new file mode 100644 index 00000000..1b60bb13 --- /dev/null +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-posts.php @@ -0,0 +1,179 @@ + + * : The name of the json map file + * + * ## EXAMPLES + * + * wp mu-migration posts update_author map_users.json --blog_id=2 --uid_fields=_content_audit_owner + * + * @synopsis --blog_id= [--uid_fields=] + * + * @param array $args + * @param array $assoc_args + * @param bool $verbose + */ + public function update_author($args = [], $assoc_args = [], $verbose = true) { + global $wpdb; + + $this->process_args( + [ + 0 => '', // .json map file + ], + $args, + [ + 'blog_id' => '', + 'uid_fields' => '', + ], + $assoc_args + ); + + $filename = $this->args[0]; + + $is_multisite = is_multisite(); + + if ( empty($filename) || ! file_exists($filename) ) { + WP_CLI::error(__('Invalid input file', 'mu-migration')); + } + + if ( $is_multisite ) { + switch_to_blog((int) $this->assoc_args['blog_id']); + } + + $is_woocommerce = Helpers\is_woocommerce_active(); + + $ids_map = json_decode(file_get_contents($filename)); + + if ( null === $ids_map ) { + WP_CLI::error( + __('An error has occurred when parsing the json file', 'mu-migration') + ); + } + + $equals_id = []; + $author_not_found = []; + + $this->all_records( + __('Updating posts authors', 'mu-migration'), + $wpdb->posts, + function ($result) use (&$equals_id, &$author_not_found, $ids_map, $verbose, $is_woocommerce) { + $author = $result->post_author; + + if ( isset($ids_map->{$author}) ) { + if ( $author != $ids_map->{$author} ) { + global $wpdb; + + $wpdb->update( + $wpdb->posts, + ['post_author' => $ids_map->{$author}], + ['ID' => $result->ID], + ['%d'], + ['%d'] + ); + + $this->log( + sprintf( + __('Updated post_author for "%1$s" (ID #%2$d)', 'mu-migration'), + $result->post_title, + absint($result->ID) + ), + $verbose + ); + } else { + $this->log( + sprintf( + __('#%d New user ID equals to the old user ID'), + $result->ID + ), + $verbose + ); + $equals_id[] = absint($result->ID); + } + } else { + $this->log( + sprintf( + __("#%d New user ID not found or it's already been updated", 'mu-migration'), + absint($result->ID) + ), + $verbose + ); + + $author_not_found[] = absint($result->ID); + } + + // Parse uid_fields + $uid_fields = explode(',', $this->assoc_args['uid_fields']); + // Automatically add Woocommerce user id field + if ( $is_woocommerce ) { + $uid_fields[] = '_customer_user'; + } + // Iterate over fields and update them. + foreach ( array_filter($uid_fields) as $f ) { + $f = trim($f); + $old_user = get_post_meta((int) $result->ID, $f, true); + + if ( isset($ids_map->{$old_user}) && $old_user != $ids_map->{$old_user} ) { + $new_user = $ids_map->{$old_user}; + + update_post_meta((int) $result->ID, $f, $new_user); + + $this->log( + sprintf( + __('Updated %1$s for "%2$s" (ID #%3$d)', 'mu-migration'), + $f, + $result->post_title, + absint($result->ID) + ), + $verbose + ); + } + } + } + ); + + // Report. + if ( ! empty($author_not_found) ) { + $this->warning( + sprintf( + __('%1$d records failed to update its post_author: %2$s', 'mu-migration'), + count($author_not_found), + implode(',', $author_not_found) + ), + $verbose + ); + } + + if ( ! empty($equals_id) ) { + $this->warning( + sprintf( + __('The following records have the new ID equal to the old ID: %s', 'mu-migration'), + implode(',', $equals_id) + ), + $verbose + ); + } + + if ( $is_multisite ) { + restore_current_blog(); + } + } +} + +WP_CLI::add_command('mu-migration posts', __NAMESPACE__ . '\\PostsCommand'); diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-users.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-users.php new file mode 100644 index 00000000..189f1c15 --- /dev/null +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-users.php @@ -0,0 +1,159 @@ + + * : The new password for the users set + * + * ## EXAMPLES + * + * wp mu-migration users update_passwords --reset --blog_id=2 --send_email + * + * @synopsis [] [--blog_id=] [--reset] [--send_email] [--include=] [--exclude=] + * + * @param array $args + * @param array $assoc_args + */ + public function update_passwords($args = [], $assoc_args = []) { + $this->process_args( + [ + 0 => '', // New password. + ], + $args, + [ + 'blog_id' => '', + 'role' => '', + 'exclude' => '', + 'include' => '', + ], + $assoc_args + ); + + $new_password = $this->args[0]; + + $reset_passwords = false; + + if ( isset($this->assoc_args['reset']) ) { + $reset_passwords = true; + } + + if ( ! $reset_passwords && empty($new_password) ) { + WP_CLI::error(__('Please, provide a new password for the users', 'mu-migration')); + } + + $send_email = false; + + if ( isset($this->assoc_args['send_email']) ) { + $send_email = true; + } + + $users_args = [ + 'fields' => 'all', + 'role' => $this->assoc_args['role'], + 'include' => ! empty($this->assoc_args['include']) ? explode(',', $this->assoc_args['include']) : [], + 'exclude' => ! empty($this->assoc_args['exclude']) ? explode(',', $this->assoc_args['exclude']) : [], + ]; + + if ( ! empty($this->assoc_args['blog_id']) ) { + $users_args['blog_id'] = (int) $this->assoc_args['blog_id']; + } + + $users = get_users($users_args); + + foreach ( $users as $user ) { + if ( $reset_passwords ) { + $new_password = wp_generate_password(12, false); + } + + wp_set_password($new_password, $user->data->ID); + + WP_CLI::log(sprintf(__('Password updated for user #%1$d:%2$s', 'mu-migration'), $user->data->ID, $user->data->user_login)); + + if ( $send_email ) { + $this->send_reset_link($user->data); + } + } + } + + /** + * Handles sending password retrieval email to user (based on retrieve_password). + * + * @param $user_data + * @return bool|\WP_Error + */ + private function send_reset_link($user_data) { + + $user_login = $user_data->user_login; + $user_email = $user_data->user_email; + $key = get_password_reset_key($user_data); + + if ( is_wp_error($key) ) { + return $key; + } + + $message = __('A password reset has been requested for the following account:') . "\r\n\r\n"; + $message .= network_home_url('/') . "\r\n\r\n"; + $message .= sprintf(__('Username: %s'), $user_login) . "\r\n\r\n"; + $message .= __('In order to log in again you have to reset your password.') . "\r\n\r\n"; + $message .= __('To reset your password, visit the following address:') . "\r\n\r\n"; + $message .= '<' . network_site_url("wp-login.php?action=rp&key=$key&login=" . rawurlencode($user_login), 'login') . ">\r\n"; + + if ( is_multisite() ) { + $blogname = $GLOBALS['current_site']->site_name; + } else { + /* + * The blogname option is escaped with esc_html on the way into the database + * in sanitize_option we want to reverse this for the plain text arena of emails. + */ + $blogname = wp_specialchars_decode(get_option('blogname'), ENT_QUOTES); + } + + $title = sprintf(__('[%s] Password Reset'), $blogname); + + /** + * Filters the subject of the password reset email. + * + * @since 2.8.0 + * @since 4.4.0 Added the `$user_login` and `$user_data` parameters. + * + * @param string $title Default email title. + * @param string $user_login The username for the user. + * @param \WP_User $user_data WP_User object. + * @return string Default email title. + */ + $title = apply_filters('retrieve_password_title', $title, $user_login, $user_data); + + /** + * Filters the message body of the password reset mail. + * + * @since 2.8.0 + * @since 4.1.0 Added `$user_login` and `$user_data` parameters. + * + * @param string $message Default mail message. + * @param string $key The activation key. + * @param string $user_login The username for the user. + * @param \WP_User $user_data WP_User object. + * @return string Default mail message. + */ + $message = apply_filters('retrieve_password_message', $message, $key, $user_login, $user_data); + + if ( $message && ! wp_mail($user_email, wp_specialchars_decode($title), $message) ) { + WP_CLI::log(__('The email could not be sent', 'mu-migration')); + } + + return true; + } +} + +WP_CLI::add_command('mu-migration users', __NAMESPACE__ . '\\UsersCommand'); diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration.php new file mode 100644 index 00000000..2a7246bb --- /dev/null +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration.php @@ -0,0 +1,26 @@ +isFile() ? @unlink($path->getPathname()) : @rmdir($path->getPathname()); + } + + if ( $deleteParent ) { + rmdir($dirPath); + } + } +} + +/** + * Recursively copies a directory and its files. + * + * @param string $source + * @param string $dest + */ +function move_folder($source, $dest) { + if ( ! file_exists($dest) ) { + mkdir($dest); + } + + foreach ( + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ) as $item + ) { + if ( $item->isDir() ) { + $dir = $dest . DIRECTORY_SEPARATOR . $iterator->getSubPathName(); + if ( ! file_exists($dir) ) { + mkdir($dir); + } + } else { + $dest_file = $dest . DIRECTORY_SEPARATOR . $iterator->getSubPathName(); + if ( ! file_exists($dest_file) ) { + rename($item, $dest_file); + } + } + } +} + +/** + * Retrieves the db prefix based on the $blog_id. + * + * @uses wpdb + * + * @param int $blog_id + * @return string + */ +function get_db_prefix($blog_id) { + global $wpdb; + + if ( $blog_id > 1 ) { + $new_db_prefix = $wpdb->base_prefix . $blog_id . '_'; + } else { + $new_db_prefix = $wpdb->prefix; + } + + return $new_db_prefix; +} + +/** + * Does the same thing that add_user_to_blog does, but without calling switch_to_blog(). + * + * @param int $blog_id + * @param int $user_id + * @param string $role + * @return \WP_Error + */ +function light_add_user_to_blog($blog_id, $user_id, $role) { + $user = get_userdata($user_id); + + if ( ! $user ) { + restore_current_blog(); + return new \WP_Error('user_does_not_exist', __('The requested user does not exist.')); + } + + if ( ! get_user_meta($user_id, 'primary_blog', true) ) { + update_user_meta($user_id, 'primary_blog', $blog_id); + $details = get_blog_details($blog_id); + update_user_meta($user_id, 'source_domain', $details->domain); + } + + $user->set_role($role); + + /** + * Fires immediately after a user is added to a site. + * + * @since MU + * + * @param int $user_id User ID. + * @param string $role User role. + * @param int $blog_id Blog ID. + */ + do_action('add_user_to_blog', $user_id, $role, $blog_id); + wp_cache_delete($user_id, 'users'); + wp_cache_delete($blog_id . '_user_count', 'blog-details'); +} + +/** + * Frees up memory for long running processes. + */ +function stop_the_insanity() { + global $wpdb, $wp_actions, $wp_filter, $wp_object_cache; + + // reset queries + $wpdb->queries = []; + // Prevent wp_actions from growing out of control + $wp_actions = []; + + if ( is_object($wp_object_cache) ) { + $wp_object_cache->group_ops = []; + $wp_object_cache->stats = []; + $wp_object_cache->memcache_debug = []; + $wp_object_cache->cache = []; + + if ( method_exists($wp_object_cache, '__remoteset') ) { + $wp_object_cache->__remoteset(); + } + } + + /* + * The WP_Query class hooks a reference to one of its own methods + * onto filters if update_post_term_cache or + * update_post_meta_cache are true, which prevents PHP's garbage + * collector from cleaning up the WP_Query instance on long- + * running processes. + * + * By manually removing these callbacks (often created by things + * like get_posts()), we're able to properly unallocate memory + * once occupied by a WP_Query object. + * + */ + if ( isset($wp_filter['get_term_metadata']) ) { + /* + * WordPress 4.7 has a new Hook infrastructure, so we need to make sure + * we're accessing the global array properly. + */ + if ( class_exists('WP_Hook') && $wp_filter['get_term_metadata'] instanceof \WP_Hook ) { + $filter_callbacks = &$wp_filter['get_term_metadata']->callbacks; + } else { + $filter_callbacks = &$wp_filter['get_term_metadata']; + } + + if ( isset($filter_callbacks[10]) ) { + foreach ( $filter_callbacks[10] as $hook => $content ) { + if ( preg_match('#^[0-9a-f]{32}lazyload_term_meta$#', $hook) ) { + unset($filter_callbacks[10][ $hook ]); + } + } + } + } +} + +/** + * Add START TRANSACTION and COMMIT to the sql export. + * shamelessly stolen from http://stackoverflow.com/questions/1760525/need-to-write-at-beginning-of-file-with-php + * + * @param string $orig_filename SQL dump file name. + */ +function addTransaction($orig_filename) { + $context = stream_context_create(); + $orig_file = fopen($orig_filename, 'r', 1, $context); + + $temp_filename = tempnam(sys_get_temp_dir(), 'php_prepend_'); + file_put_contents($temp_filename, 'START TRANSACTION;' . PHP_EOL); + file_put_contents($temp_filename, $orig_file, FILE_APPEND); + file_put_contents($temp_filename, 'COMMIT;', FILE_APPEND); + + fclose($orig_file); + unlink($orig_filename); + rename($temp_filename, $orig_filename); +} + +/** + * Switches to another blog if on Multisite + * + * @param $blog_id + */ +function maybe_switch_to_blog($blog_id) { + if ( is_multisite() ) { + switch_to_blog($blog_id); + } +} + +/** + * Restore the current blog if on multisite + */ +function maybe_restore_current_blog() { + if ( is_multisite() ) { + restore_current_blog(); + } +} + + +/** + * Extracts a zip file to the $dest_dir. + * + * @uses Zippy + * + * @param string $filename + * @param string $dest_dir + */ +function extract($filename, $dest_dir) { + $zippy = Zippy::load(); + + $site_package = $zippy->open($filename); + mkdir($dest_dir); + $site_package->extract($dest_dir); +} + +/** + * Creates a zip files with the provided files/folder to zip + * + * @param string $zip_files The name of the zip file + * @param array $files_to_zip The files to include in the zip file + * + * @return void + */ +function zip($zip_file, $files_to_zip) { + $files_to_zip = apply_filters('wu_site_exporter_files_to_zip', $files_to_zip); + return Zippy::load()->create($zip_file, $files_to_zip, true); +} + +/** + * Run a command within WP_CLI + * + * @param string $command The command to run + * @param array $args The command arguments + * @param array $assoc_args The associative arguments + * @param array $global_args The global arguments + * + * @return object Result object with stdout, stderr, and return_code properties + */ +function runcommand($command, $args = [], $assoc_args = [], $global_args = []) { + global $wpdb, $wu_site_exporter_site_id; + + $assoc_args = array_merge($assoc_args, $global_args); + + $transformed_assoc_args = []; + + foreach ( $assoc_args as $key => $arg ) { + $transformed_assoc_args[] = '--' . $key . '=' . $arg; + } + $params = sprintf('%s %s', implode(' ', $args), implode(' ', $transformed_assoc_args)); + + $full_command = sprintf('%s %s', $command, $params); + + /** + * If we're in CLI context with real WP-CLI available, use it. + * Otherwise, use pure PHP implementation for web/AJAX context. + */ + if (PHP_SAPI === 'cli' && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'runcommand')) { + $options = [ + 'return' => 'all', + 'launch' => false, + 'exit_error' => false, + ]; + return \WP_CLI::runcommand($full_command, $options); + } + + /** + * Web/AJAX context - implement commands using pure PHP + * This is the polyfill implementation that works without WP-CLI + */ + $stdout = ''; + + if (strpos($full_command, 'db tables') === 0) { + // Get list of tables for the current site + $sql = $wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($wpdb->prefix) . '%'); + $results = $wpdb->get_col($sql); // phpcs:ignore + $stdout = implode(',', $results); + } elseif (strpos($full_command, 'db export') === 0) { + // Export database using mysqldump-php + $table_list = []; + preg_match('/--tables=(.+)/', $full_command, $table_list); + + $dump = new \Ifsnop\Mysqldump\Mysqldump( + sprintf('mysql:dbname=%s;host=%s', DB_NAME, DB_HOST), + DB_USER, + DB_PASSWORD, + [ + 'compress' => \Ifsnop\Mysqldump\Mysqldump::NONE, + 'include-tables' => explode(',', $table_list[1]), + ] + ); + + $file_name = str_replace('db export', '', $full_command); + $file_name = preg_replace('/--tables=(.+)/', '', $file_name); + $file_name = trim($file_name); + + $dump->start($file_name); + } elseif (strpos($full_command, 'db import') === 0) { + // Import database using custom importer + $file_name = str_replace('db import', '', $full_command); + $file_name = trim($file_name); + + $import = new \WP_Ultimo\Site_Exporter\Database\Import( + $file_name, + DB_USER, + DB_PASSWORD, + DB_NAME, + DB_HOST, + true, // drop_tables + true, // force_drop_tables + $wu_site_exporter_site_id + ); + } + + return (object) [ + 'stdout' => $stdout, + 'stderr' => '', + 'return_code' => 0, + ]; +} + +/** + * Run a WP-CLI command in the same process (launch_self wrapper) + * + * @param string $command WP-CLI command to call + * @param array $args Positional arguments + * @param array $assoc_args Associative arguments + * @param bool $exit_on_error Whether to exit if command returns elevated return code + * @param bool $return_detailed Whether to return exit status or detailed results + * @param array $runtime_args Override global args (path, url, user, allow-root) + * + * @return int Return code (0 for success) + */ +function launch_self($command, $args = [], $assoc_args = [], $exit_on_error = true, $return_detailed = false, $runtime_args = []) { + global $wpdb, $wu_site_exporter_site_id; + + /** + * If we're in CLI context with real WP-CLI available, use it + */ + if (PHP_SAPI === 'cli' && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'launch_self')) { + return \WP_CLI::launch_self($command, $args, $assoc_args, $exit_on_error, $return_detailed, $runtime_args); + } + + /** + * Web/AJAX context - implement commands using pure PHP + */ + if ($command === 'search-replace') { + $search = $args[0]; + $replace = $args[1]; + + $site = is_multisite() ? get_site($wu_site_exporter_site_id) : (object) [ + 'blog_id' => 1, + ]; + + if (! $site) { + return 1; + } + + $max_execution = new \WP_Ultimo\Site_Exporter\Database\Max_Execution_Time(); + $dbm = new \WP_Ultimo\Site_Exporter\Database\Manager($wpdb); + $replacer = new \WP_Ultimo\Site_Exporter\Database\Replace($dbm, $max_execution); + $tables = $dbm->get_tables($site->blog_id); + + $replacer->set_dry_run(false); + + $report = $replacer->run_search_replace($search, $replace, $tables); + + if (is_wp_error($report)) { + error_log('Site Exporter Error: ' . $report->get_error_message()); + return 1; + } elseif (count($report['changes']) === 0) { + error_log('Site Exporter Warning: Search pattern not found'); + } + + return 0; + } elseif ($command === 'theme enable') { + if (isset($args[0])) { + switch_theme($args[0]); + } + return 0; + } + + // Unknown command + return 1; +} diff --git a/inc/site-exporter/mu-migration/mu-migration.php b/inc/site-exporter/mu-migration/mu-migration.php new file mode 100644 index 00000000..4e0a4dba --- /dev/null +++ b/inc/site-exporter/mu-migration/mu-migration.php @@ -0,0 +1,40 @@ += 7.1'); +} + +if ( file_exists(__DIR__ . '/vendor/autoload.php') ) { + require_once 'vendor/autoload.php'; +} + +require_once 'includes/helpers.php'; + +require_once TENUP_MU_MIGRATION_COMMANDS_PATH . 'class-mu-migration.php'; +require_once TENUP_MU_MIGRATION_COMMANDS_PATH . 'class-mu-migration-base.php'; +require_once TENUP_MU_MIGRATION_COMMANDS_PATH . 'class-mu-migration-export.php'; +require_once TENUP_MU_MIGRATION_COMMANDS_PATH . 'class-mu-migration-import.php'; +require_once TENUP_MU_MIGRATION_COMMANDS_PATH . 'class-mu-migration-posts.php'; +require_once TENUP_MU_MIGRATION_COMMANDS_PATH . 'class-mu-migration-users.php'; diff --git a/inc/site-exporter/mu-migration/utils/behat-tags.php b/inc/site-exporter/mu-migration/utils/behat-tags.php new file mode 100644 index 00000000..0f8990c5 --- /dev/null +++ b/inc/site-exporter/mu-migration/utils/behat-tags.php @@ -0,0 +1,75 @@ +') +); + +// Skip Github API tests by default because of rate limiting. See https://github.com/wp-cli/wp-cli/issues/1612 +$skip_tags[] = '@github-api'; + +// Skip tests known to be broken. +$skip_tags[] = '@broken'; + +// Require PHP extension, eg 'imagick'. +function extension_tags() { + $extension_tags = []; + exec("grep '@require-extension-[A-Za-z_]*' -h -o features/*.feature | uniq", $extension_tags); + + $skip_tags = []; + + $substr_start = strlen('@require-extension-'); + foreach ( $extension_tags as $tag ) { + $extension = substr($tag, $substr_start); + if ( ! extension_loaded($extension) ) { + $skip_tags[] = $tag; + } + } + + return $skip_tags; +} + +$skip_tags = array_merge($skip_tags, extension_tags()); + +if ( ! empty($skip_tags) ) { + echo '--tags=~' . implode('&&~', $skip_tags); +} diff --git a/inc/sso/class-admin-bar-magic-links.php b/inc/sso/class-admin-bar-magic-links.php index a21195ea..318beda8 100644 --- a/inc/sso/class-admin-bar-magic-links.php +++ b/inc/sso/class-admin-bar-magic-links.php @@ -108,7 +108,7 @@ public function show_access_denied_with_magic_links(): void { wp_die( sprintf( /* translators: 1: Site title. */ - __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.'), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.', 'ultimate-multisite'), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $blog_name // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ), 403 @@ -122,12 +122,12 @@ public function show_access_denied_with_magic_links(): void { $output = '

' . sprintf( /* translators: 1: Site title. */ - __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.'), + __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.', 'ultimate-multisite'), $blog_name ) . '

'; - $output .= '

' . __('If you reached this screen by accident and meant to visit one of your own sites, here are some shortcuts to help you find your way.') . '

'; + $output .= '

' . __('If you reached this screen by accident and meant to visit one of your own sites, here are some shortcuts to help you find your way.', 'ultimate-multisite') . '

'; - $output .= '

' . __('Your Sites') . '

'; + $output .= '

' . __('Your Sites', 'ultimate-multisite') . '

'; $output .= ''; foreach ( $blogs as $blog ) { @@ -141,8 +141,8 @@ public function show_access_denied_with_magic_links(): void { $output .= ''; $output .= ''; - $output .= ''; + $output .= ''; $output .= ''; } diff --git a/inc/tax/class-dashboard-taxes-tab.php b/inc/tax/class-dashboard-taxes-tab.php index 727bab61..4693f421 100644 --- a/inc/tax/class-dashboard-taxes-tab.php +++ b/inc/tax/class-dashboard-taxes-tab.php @@ -103,7 +103,7 @@ public function add_back_link($links) { $back_link = [ [ - 'url' => wu_network_admin_url('wp-ultimo'), + 'url' => wu_network_admin_url('ultimate-multisite'), 'label' => __('Go Back', 'ultimate-multisite'), 'icon' => 'wu-reply', ], diff --git a/inc/template-library/class-api-client.php b/inc/template-library/class-api-client.php new file mode 100644 index 00000000..68755e6a --- /dev/null +++ b/inc/template-library/class-api-client.php @@ -0,0 +1,214 @@ +base_url = trailingslashit($base_url); + } + + /** + * Executes the HTTP request. + * + * @since 2.5.0 + * @param string $endpoint The request URL. + * @param array $params Query parameters. + * @param string $method HTTP method. + * @return array|WP_Error API response or WP_Error on failure. + */ + private function execute_request(string $endpoint, array $params = [], string $method = 'GET') { + + $url = $this->base_url . 'wp-json/wc/store/v1/' . ltrim($endpoint, '/'); + + if ('GET' === $method) { + $url = add_query_arg($params, $url); + $args = [ + 'method' => 'GET', + 'timeout' => 30, + ]; + } else { + $args = [ + 'method' => $method, + 'body' => wp_json_encode($params), + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'timeout' => 30, + ]; + } + + // Add authorization if available + $addon_repo = \WP_Ultimo::get_instance()->get_addon_repository(); + + $access_token = $addon_repo->get_access_token(); + if ($access_token) { + $args['headers']['Authorization'] = 'Bearer ' . $access_token; + } + + $response = wp_remote_request($url, $args); + + if (is_wp_error($response)) { + return $response; + } + + $response_code = wp_remote_retrieve_response_code($response); + $response_body = wp_remote_retrieve_body($response); + + if ($response_code < 200 || $response_code >= 300) { + return new WP_Error( + 'template_api_error', + sprintf( + /* translators: %1$s: HTTP response code, %2$s: response body */ + __('Template API request failed with status %1$s: %2$s', 'ultimate-multisite'), + $response_code, + $response_body + ) + ); + } + + $data = json_decode($response_body, true); + + if (null === $data) { + return new WP_Error( + 'json_decode_error', + __('Failed to decode API response JSON', 'ultimate-multisite') + ); + } + + return $data; + } + + /** + * Gets template products with specific metadata. + * + * @since 2.5.0 + * @return array|WP_Error Array of template products or WP_Error on failure. + */ + public function get_templates() { + + $params = [ + 'per_page' => 100, + 'status' => 'publish', + 'downloadable' => true, + 'tag' => 'template', + ]; + + $result = $this->execute_request('products', $params); + + if (is_wp_error($result)) { + return $result; + } + + // Parse and enhance template data + return array_map([$this, 'parse_template_data'], $result); + } + + /** + * Parses and enhances template data from the API response. + * + * @since 2.5.0 + * @param array $template Raw template data from API. + * @return array Enhanced template data. + */ + private function parse_template_data(array $template): array { + + $extensions = $template['extensions']['wp-update-server-plugin'] ?? []; + + return [ + 'id' => $template['id'] ?? 0, + 'slug' => $template['slug'] ?? '', + 'name' => $template['name'] ?? '', + 'description' => $template['description'] ?? '', + 'short_description' => $template['short_description'] ?? '', + 'price_html' => $template['price_html'] ?? '', + 'permalink' => $template['permalink'] ?? '', + 'is_free' => empty($template['prices']['price'] ?? 0), + 'prices' => $template['prices'] ?? [], + 'images' => $template['images'] ?? [], + 'categories' => $template['categories'] ?? [], + + // Template-specific metadata from extensions + 'icon' => $extensions['icon'] ?? '', + 'download_url' => $extensions['download_url'] ?? '', + 'author' => $extensions['author']['display_name'] ?? 'Ultimate Multisite Team', + 'demo_url' => $extensions['demo_url'] ?? '', + 'industry_type' => $extensions['industry_type'] ?? '', + 'page_count' => (int) ($extensions['page_count'] ?? 0), + 'included_plugins' => $extensions['included_plugins'] ?? [], + 'included_themes' => $extensions['included_themes'] ?? [], + 'template_version' => $extensions['template_version'] ?? '1.0.0', + 'compatibility' => [ + 'wp_version' => $extensions['compatibility']['wp_version'] ?? '', + 'wu_version' => $extensions['compatibility']['wu_version'] ?? '', + ], + + // Internal tracking + 'installed' => false, + ]; + } + + /** + * Gets a single template by slug. + * + * @since 2.5.0 + * @param string $slug The template slug. + * @return array|WP_Error Template data or WP_Error on failure. + */ + public function get_template(string $slug) { + + $templates = $this->get_templates(); + + if (is_wp_error($templates)) { + return $templates; + } + + foreach ($templates as $template) { + if ($template['slug'] === $slug) { + return $template; + } + } + + return new WP_Error( + 'template_not_found', + sprintf( + /* translators: %s: template slug */ + __('Template "%s" not found.', 'ultimate-multisite'), + $slug + ) + ); + } +} diff --git a/inc/template-library/class-template-installer.php b/inc/template-library/class-template-installer.php new file mode 100644 index 00000000..686c9964 --- /dev/null +++ b/inc/template-library/class-template-installer.php @@ -0,0 +1,245 @@ +import_template($tmp_file, $options); + + // Clean up temp file + wp_delete_file($tmp_file); + + return $result; + } + + /** + * Adds authentication headers to the download request. + * + * @since 2.5.0 + * @param array $args HTTP request arguments. + * @param string $url Request URL. + * @return array Modified arguments. + */ + public function add_auth_headers(array $args, string $url): array { + + if (strpos($url, 'ultimatemultisite.com') !== false) { + $addon_repo = \WP_Ultimo::get_instance()->get_addon_repository(); + $access_token = $addon_repo->get_access_token(); + + if ($access_token) { + $args['headers']['Authorization'] = 'Bearer ' . $access_token; + } + } + + return $args; + } + + /** + * Imports a template ZIP file as a new template site. + * + * @since 2.5.0 + * @param string $zip_path Path to the ZIP file. + * @param array $options Import options. + * @return array|WP_Error Import result or error. + */ + private function import_template(string $zip_path, array $options) { + + $defaults = [ + 'slug' => 'template', + 'name' => __('Imported Template', 'ultimate-multisite'), + 'version' => '1.0.0', + 'delete_zip' => true, + 'as_template' => true, + ]; + + $args = wp_parse_args($options, $defaults); + + // Generate a URL for the new template site + $new_url = $this->generate_template_url($args['slug']); + + // Use the core importer + $import_result = wu_exporter_import( + $zip_path, + [ + 'new_url' => $new_url, + 'delete_zip' => $args['delete_zip'], + ], + false // Sync import for now + ); + + if (is_wp_error($import_result)) { + return $import_result; + } + + // Get the created site + $site = wu_exporter_url_to_site($new_url); + + if (! $site) { + return new WP_Error( + 'site_not_found', + __('Could not find the imported template site.', 'ultimate-multisite') + ); + } + + $site_id = $site->blog_id; + + // Mark site as template + $wu_site = wu_get_site($site_id); + if ($wu_site) { + $wu_site->set_type('site_template'); + $wu_site->save(); + } + + // Record the installation + $this->record_installation( + $args['slug'], + $args['version'], + $site_id, + $new_url + ); + + return [ + 'success' => true, + 'site_id' => $site_id, + 'site_url' => $new_url, + 'message' => __('Template installed successfully!', 'ultimate-multisite'), + ]; + } + + /** + * Generates a unique URL for a template site. + * + * @since 2.5.0 + * @param string $slug The template slug. + * @return string The generated URL. + */ + private function generate_template_url(string $slug): string { + + $network_url = network_home_url(); + $path = sanitize_title('template-' . $slug . '-' . time()); + + return trailingslashit($network_url) . $path . '/'; + } + + /** + * Records a template installation. + * + * @since 2.5.0 + * @param string $template_slug Template slug. + * @param string $version Template version. + * @param int $site_id Site ID. + * @param string $site_url Site URL. + * @return bool + */ + public function record_installation(string $template_slug, string $version, int $site_id, string $site_url): bool { + + $installed_templates = $this->get_installed_templates(); + + $installed_templates[ $template_slug ] = [ + 'version' => $version, + 'installed_at' => current_time('mysql'), + 'site_id' => $site_id, + 'site_url' => $site_url, + ]; + + return wu_save_option('installed_templates', $installed_templates); + } + + /** + * Gets list of installed templates. + * + * @since 2.5.0 + * @return array + */ + public function get_installed_templates(): array { + + return wu_get_option('installed_templates', []); + } + + /** + * Checks if a template is installed. + * + * @since 2.5.0 + * @param string $template_slug Template slug. + * @return bool + */ + public function is_installed(string $template_slug): bool { + + $installed = $this->get_installed_templates(); + + return isset($installed[ $template_slug ]); + } + + /** + * Gets installed template info. + * + * @since 2.5.0 + * @param string $template_slug Template slug. + * @return array|null + */ + public function get_installed_template(string $template_slug): ?array { + + $installed = $this->get_installed_templates(); + + return $installed[ $template_slug ] ?? null; + } +} diff --git a/inc/template-library/class-template-library.php b/inc/template-library/class-template-library.php new file mode 100644 index 00000000..2f7e6756 --- /dev/null +++ b/inc/template-library/class-template-library.php @@ -0,0 +1,142 @@ +repository = new Template_Repository(); + + /** + * Fires when the Template Library is loaded. + * + * @since 2.5.0 + */ + do_action('wu_template_library_loaded'); + } + + /** + * Gets the Template Repository instance. + * + * @since 2.5.0 + * @return Template_Repository + */ + public function get_repository(): Template_Repository { + + return $this->repository; + } + + /** + * Gets templates from the repository. + * + * @since 2.5.0 + * @param bool $force_refresh Force refresh from API. + * @return array|\WP_Error + */ + public function get_templates(bool $force_refresh = false) { + + return $this->repository->get_templates($force_refresh); + } + + /** + * Gets a single template by slug. + * + * @since 2.5.0 + * @param string $slug Template slug. + * @return array|\WP_Error + */ + public function get_template(string $slug) { + + return $this->repository->get_template($slug); + } + + /** + * Installs a template. + * + * @since 2.5.0 + * @param string $slug Template slug. + * @param array $options Installation options. + * @return array|\WP_Error Installation result or error. + */ + public function install_template(string $slug, array $options = []) { + + $template = $this->get_template($slug); + + if (is_wp_error($template)) { + return $template; + } + + if (empty($template['download_url'])) { + return new \WP_Error( + 'no_download_url', + __('No download URL available for this template.', 'ultimate-multisite') + ); + } + + // Merge template info into options + $options = array_merge( + [ + 'slug' => $template['slug'], + 'name' => $template['name'], + 'version' => $template['template_version'], + ], + $options + ); + + return $this->repository->get_installer()->install($template['download_url'], $options); + } + + /** + * Checks if a template is installed. + * + * @since 2.5.0 + * @param string $slug Template slug. + * @return bool + */ + public function is_template_installed(string $slug): bool { + + return $this->repository->get_installer()->is_installed($slug); + } + + /** + * Clears the template cache. + * + * @since 2.5.0 + * @return bool + */ + public function clear_cache(): bool { + + return $this->repository->clear_cache(); + } +} diff --git a/inc/template-library/class-template-repository.php b/inc/template-library/class-template-repository.php new file mode 100644 index 00000000..95219125 --- /dev/null +++ b/inc/template-library/class-template-repository.php @@ -0,0 +1,317 @@ +api_client = new API_Client(MULTISITE_ULTIMATE_UPDATE_URL); + $this->installer = new Template_Installer(); + } + + /** + * Gets templates with caching. + * + * @since 2.5.0 + * @param bool $force_refresh Force refresh from API. + * @return array|WP_Error Array of templates or WP_Error. + */ + public function get_templates(bool $force_refresh = false) { + + // Return in-memory cache if available + if (! $force_refresh && null !== $this->templates) { + return $this->templates; + } + + // Try to get from transient cache + if (! $force_refresh && ! wu_is_debug()) { + $cached = get_site_transient(self::CACHE_KEY); + if (false !== $cached) { + $this->templates = $this->mark_installed_templates($cached); + return $this->templates; + } + } + + // Fetch from API + $templates = $this->api_client->get_templates(); + + if (is_wp_error($templates)) { + return $templates; + } + + // Cache the result + set_site_transient(self::CACHE_KEY, $templates, self::CACHE_DURATION); + + // Mark installed templates and store in memory + $this->templates = $this->mark_installed_templates($templates); + + return $this->templates; + } + + /** + * Marks templates that are already installed. + * + * @since 2.5.0 + * @param array $templates Array of templates. + * @return array Templates with installed flag. + */ + private function mark_installed_templates(array $templates): array { + + $installed = $this->installer->get_installed_templates(); + + return array_map( + function ($template) use ($installed) { + $template['installed'] = isset($installed[ $template['slug'] ]); + + // Add installed info if available + if ($template['installed']) { + $template['installed_info'] = $installed[ $template['slug'] ]; + } + + return $template; + }, + $templates + ); + } + + /** + * Gets a single template by slug. + * + * @since 2.5.0 + * @param string $slug Template slug. + * @return array|WP_Error Template data or error. + */ + public function get_template(string $slug) { + + $templates = $this->get_templates(); + + if (is_wp_error($templates)) { + return $templates; + } + + foreach ($templates as $template) { + if ($template['slug'] === $slug) { + return $template; + } + } + + return new WP_Error( + 'template_not_found', + sprintf( + /* translators: %s: template slug */ + __('Template "%s" not found.', 'ultimate-multisite'), + $slug + ) + ); + } + + /** + * Gets templates filtered by category. + * + * @since 2.5.0 + * @param string $category Category slug. + * @return array|WP_Error Filtered templates or error. + */ + public function get_templates_by_category(string $category) { + + $templates = $this->get_templates(); + + if (is_wp_error($templates)) { + return $templates; + } + + if ('all' === $category) { + return $templates; + } + + return array_filter( + $templates, + function ($template) use ($category) { + foreach ($template['categories'] as $cat) { + if ($cat['slug'] === $category) { + return true; + } + } + return false; + } + ); + } + + /** + * Searches templates by keyword. + * + * @since 2.5.0 + * @param string $search Search keyword. + * @return array|WP_Error Matching templates or error. + */ + public function search_templates(string $search) { + + $templates = $this->get_templates(); + + if (is_wp_error($templates)) { + return $templates; + } + + $search = strtolower($search); + + return array_filter( + $templates, + function ($template) use ($search) { + // Search in name + if (stripos($template['name'], $search) !== false) { + return true; + } + + // Search in description + if (stripos($template['description'], $search) !== false) { + return true; + } + + // Search in slug + if (stripos($template['slug'], $search) !== false) { + return true; + } + + // Search in industry type + if (stripos($template['industry_type'], $search) !== false) { + return true; + } + + // Search in categories + foreach ($template['categories'] as $cat) { + if (stripos($cat['name'], $search) !== false) { + return true; + } + } + + return false; + } + ); + } + + /** + * Gets all unique categories from templates. + * + * @since 2.5.0 + * @return array|WP_Error Categories or error. + */ + public function get_categories() { + + $templates = $this->get_templates(); + + if (is_wp_error($templates)) { + return $templates; + } + + $categories = []; + + foreach ($templates as $template) { + foreach ($template['categories'] as $category) { + $slug = $category['slug']; + if (! isset($categories[ $slug ])) { + $categories[ $slug ] = $category; + } + } + } + + return array_values($categories); + } + + /** + * Clears the template cache. + * + * @since 2.5.0 + * @return bool + */ + public function clear_cache(): bool { + + $this->templates = null; + return delete_site_transient(self::CACHE_KEY); + } + + /** + * Gets the installer instance. + * + * @since 2.5.0 + * @return Template_Installer + */ + public function get_installer(): Template_Installer { + + return $this->installer; + } + + /** + * Gets the API client instance. + * + * @since 2.5.0 + * @return API_Client + */ + public function get_api_client(): API_Client { + + return $this->api_client; + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ff17651b..d8365ed0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,6 +10,8 @@ parameters: - ./views - ./inc - ./ultimate-multisite.php + excludePaths: + - ./inc/site-exporter/mu-migration/ ignoreErrors: - message: '#Variable \$.* might not be defined.#' diff --git a/tests/WP_Ultimo/Helpers/Hash_Test.php b/tests/WP_Ultimo/Helpers/Hash_Test.php index c525c133..edeebe43 100644 --- a/tests/WP_Ultimo/Helpers/Hash_Test.php +++ b/tests/WP_Ultimo/Helpers/Hash_Test.php @@ -118,7 +118,7 @@ public function test_default_group_encoding() { $id = 555; $hash1 = Hash::encode($id); - $hash2 = Hash::encode($id, 'wp-ultimo'); + $hash2 = Hash::encode($id, 'ultimate-multisite'); $this->assertEquals($hash1, $hash2); } diff --git a/tests/WP_Ultimo/Helpers/Unique_Customer_Email_Test.php b/tests/WP_Ultimo/Helpers/Unique_Customer_Email_Test.php new file mode 100644 index 00000000..d836753f --- /dev/null +++ b/tests/WP_Ultimo/Helpers/Unique_Customer_Email_Test.php @@ -0,0 +1,146 @@ +assertTrue($rule->check('')); + $this->assertTrue($rule->check(null)); + } + + /** + * Test that a new email (no user or customer exists) passes validation. + * + * @since 2.3.0 + */ + public function test_new_email_passes() { + + $rule = new Unique_Customer_Email(); + + // Use a random email that doesn't exist + $this->assertTrue($rule->check('nonexistent-' . wp_generate_uuid4() . '@example.com')); + } + + /** + * Test that an email belonging to a WordPress user but not a customer passes validation. + * + * @since 2.3.0 + */ + public function test_email_with_user_but_no_customer_passes() { + + // Create a WordPress user without a customer using a unique email + $email = 'user-only-' . wp_generate_uuid4() . '@example.com'; + $user_id = $this->factory()->user->create([ + 'user_email' => $email, + ]); + + $user = get_user_by('id', $user_id); + $this->assertEquals($email, $user->user_email, 'User email should match created email'); + + // Delete any existing customer linked to this user (cleanup from previous test runs) + $existing_customer = wu_get_customer_by_user_id($user_id); + if ($existing_customer) { + $existing_customer->delete(); + } + + // Verify no customer is linked to this user now + $customer = wu_get_customer_by_user_id($user_id); + $this->assertFalse($customer, 'No customer should be linked to this user'); + + $rule = new Unique_Customer_Email(); + + // Should pass because no customer is linked to this user + $this->assertTrue($rule->check($user->user_email)); + } + + /** + * Test that an email belonging to an existing customer fails validation. + * + * @since 2.3.0 + */ + public function test_email_with_existing_customer_fails() { + + $email = 'customer-' . wp_generate_uuid4() . '@example.com'; + + // Create a customer using wu_create_customer + $customer = wu_create_customer([ + 'email' => $email, + 'username' => 'testcustomer' . wp_generate_password(8, false), + 'password' => 'password123', + ]); + + $this->assertInstanceOf(\WP_Ultimo\Models\Customer::class, $customer); + + $user = $customer->get_user(); + $this->assertInstanceOf(\WP_User::class, $user); + + $rule = new Unique_Customer_Email(); + + // Should fail because a customer exists with this email + $this->assertFalse($rule->check($user->user_email)); + } + + /** + * Test that the validation rule works with the Validator class. + * + * @since 2.3.0 + */ + public function test_validation_rule_works_with_validator() { + + $validator = new \WP_Ultimo\Helpers\Validator(); + + // Test with non-existent email (should pass) + $data = [ + 'email' => 'new-email-' . wp_generate_uuid4() . '@example.com', + ]; + + $rules = [ + 'email' => 'unique_customer_email', + ]; + + $result = $validator->validate($data, $rules); + $this->assertFalse($result->fails(), 'Validation should pass for new email'); + + $email = 'existing-customer-' . wp_generate_uuid4() . '@example.com'; + + // Create a customer + $customer = wu_create_customer([ + 'email' => $email, + 'username' => 'validatorcust' . wp_generate_password(8, false), + 'password' => 'password123', + ]); + + $user = $customer->get_user(); + + // Test with existing customer email (should fail) + $data = [ + 'email' => $user->user_email, + ]; + + $result = $validator->validate($data, $rules); + $this->assertTrue($result->fails(), 'Validation should fail for existing customer email'); + } +} diff --git a/views/template-library/details.php b/views/template-library/details.php new file mode 100644 index 00000000..ff989ae7 --- /dev/null +++ b/views/template-library/details.php @@ -0,0 +1,216 @@ + + +
+ +
+ +
images[0]['thumbnail'])) : + ?> +style="background-image:url(images[0]['thumbnail']); ?>);background-position:center;"> +
+

name); ?>

+
+ +
+ + + + + + + +
+ +
+ +
+ +
    +
  • + + author ?? 'Ultimate Multisite Team'); ?> +
  • + + template_version)) : ?> +
  • + + template_version); ?> +
  • + + + industry_type)) : ?> +
  • + + industry_type); ?> +
  • + + + page_count)) : ?> +
  • + + page_count); ?> +
  • + + + included_plugins) && is_array($template->included_plugins)) : ?> +
  • + +
      + included_plugins as $plugin) : ?> +
    • + + + +
    • + +
    +
  • + + + included_themes) && is_array($template->included_themes)) : ?> +
  • + + included_themes[0] ?? []; + echo esc_html(is_array($theme) ? ($theme['name'] ?? '') : $theme); + ?> +
  • + + + compatibility['wu_version'])) : ?> +
  • + + compatibility['wu_version'])); + ?> +
  • + + + compatibility['wp_version'])) : ?> +
  • + + compatibility['wp_version'])); + ?> +
  • + + + demo_url)) : ?> +
  • + + + +
  • + + + permalink)) : ?> +
  • + + + +
  • + + +
+
+
+ + +
+ + description); ?> + +
+ +
+ +
+ +
+ + + +
diff --git a/views/template-library/template-library.php b/views/template-library/template-library.php new file mode 100644 index 00000000..6d582e97 --- /dev/null +++ b/views/template-library/template-library.php @@ -0,0 +1,284 @@ + + +
+ +

+ + get_title()); ?> + + get_title_links() as $action_link) : + $action_classes = isset($action_link['classes']) ? $action_link['classes'] : ''; + + ?> + + + + + + +   + + + + + + + + + + + + +

+ + + +
+

+
+ + + +
+ +

+
+ +
+

+
+
    +
  • + +
  • +
+
+
+ + +
+ +
+
+ + 0 + +
+ +
+ $section) : ?> + + + + + +
+ +
+ +
+
+ +
+ +
+
+ + + + + +
+
+ +
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ v{{ template.template_version }} +
+ + +
+ {{ template.industry_type }} +
+
+ +
+ +

{{ template.name }}

+

+ {{ template.author }} +

+ + +
+ + +
+ + + {{ template.page_count }} + + + + {{ template.included_plugins.length }} + + + + {{ template.included_themes[0].name || template.included_themes[0] }} + +
+
+ + +
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ +
+
+
+ +
+

+

+
+
+ +
+ + + +
diff --git a/views/wizards/host-integrations/ready.php b/views/wizards/host-integrations/ready.php index a01e91c1..b27aad10 100644 --- a/views/wizards/host-integrations/ready.php +++ b/views/wizards/host-integrations/ready.php @@ -26,7 +26,7 @@ - +
' . esc_html($blog->blogname) . '' . __('Visit Dashboard') . ' | ' . - '' . __('View Site') . '' . __('Visit Dashboard', 'ultimate-multisite') . ' | ' . + '' . __('View Site', 'ultimate-multisite') . '