diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100755 index b1fbf926b..000000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -# Commit message hook for enforcing conventional commit format -# This hook validates commit message format - -commit_regex='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{1,50}' - -error_msg="Aborting commit. Your commit message is missing either a type of change or the description of changes." - -if ! grep -qE "$commit_regex" "$1"; then - echo "$error_msg" >&2 - echo "" >&2 - echo "Valid format: (): " >&2 - echo "Example: feat(auth): add login functionality" >&2 - echo "" >&2 - echo "Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert" >&2 - echo "" >&2 - echo "To bypass this check, use: git commit --no-verify" >&2 - exit 1 -fi \ No newline at end of file diff --git a/assets/css/admin.css b/assets/css/admin.css index 2df8c945c..c9230e0cd 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -5359,4 +5359,12 @@ td.column-id { #addon_more_info #plugin-information-content #section-description img { width: 100%; +} + +.multisite .plugins .column-auto-updates { + width: 9em; +} + +.multisite .plugins details > summary { + cursor: pointer; } \ No newline at end of file diff --git a/assets/js/thank-you.js b/assets/js/thank-you.js index 2bad261cf..7cf6c9897 100644 --- a/assets/js/thank-you.js +++ b/assets/js/thank-you.js @@ -69,10 +69,13 @@ document.addEventListener("DOMContentLoaded", () => { wu_thank_you.ajaxurl, { method: "POST", - body: JSON.stringify({ - action: "wu_resend_verification_email", - _ajax_nonce: wu_thank_you.resend_verification_email_nonce - }) + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + action: "wu_resend_verification_email", + _ajax_nonce: wu_thank_you.resend_verification_email_nonce + }), } ); const response = await request.json(); diff --git a/composer.json b/composer.json index 71587846b..d6cea7266 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "devstone/ultimate-multisite", "url": "https://MultisiteUltimate.com", "description": "The Multisite Website as a Service (WaaS) plugin.", - "version": "2.4.4", + "version": "2.4.5", "authors": [ { "name": "Arindo Duque", diff --git a/composer.lock b/composer.lock index d8631a651..4e245581e 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": "a10cd50ceae8b9db61dcf75456c2695a", + "content-hash": "c261f86b4d227998f2793614e78aae62", "packages": [ { "name": "amphp/amp", diff --git a/inc/admin-pages/class-base-admin-page.php b/inc/admin-pages/class-base-admin-page.php index e8def9d06..2019ccafc 100644 --- a/inc/admin-pages/class-base-admin-page.php +++ b/inc/admin-pages/class-base-admin-page.php @@ -199,6 +199,7 @@ public function __construct() { * * @since 2.0.0 * @param string $page_id The ID of this page. + * @param string $page_hook The hook name of this page. * @return void */ do_action('wu_page_added', $this->id, $this->page_hook); @@ -358,7 +359,8 @@ function ($vars) { * Allow plugin developers to add additional content before we print the page. * * @since 1.8.2 - * @param string $this->id The id of this page. + * @param string $page_id The id of this page. + * @param object $page The page object. * @return void */ do_action('wu_page_before_render', $this->id, $this); @@ -367,7 +369,8 @@ function ($vars) { * Allow plugin developers to add additional content before we print the page. * * @since 1.8.2 - * @param string $this->id The id of this page. + * @param string $page_id The id of this page. + * @param object $page The page object. * @return void */ do_action("wu_page_{$this->id}_before_render", $this->id, $this); @@ -381,7 +384,8 @@ function ($vars) { * Allow plugin developers to add additional content after we print the page * * @since 1.8.2 - * @param string $this->id The id of this page + * @param string $page_id The id of this page + * @param object $page The page object. * @return void */ do_action('wu_page_after_render', $this->id, $this); @@ -390,7 +394,8 @@ function ($vars) { * Allow plugin developers to add additional content after we print the page * * @since 1.8.2 - * @param string $this->id The id of this page + * @param string $page_id The id of this page + * @param object $page The page object. * @return void */ do_action("wu_page_{$this->id}_after_render", $this->id, $this); @@ -548,7 +553,7 @@ public function brand_footer(): void { */ public function add_admin_body_classes(): void { - add_action( + add_filter( 'admin_body_class', function ($classes) { @@ -609,7 +614,7 @@ final public function enqueue_default_hooks(): void { * Allow plugin developers to add additional hooks * * @since 1.8.2 - * @param string + * @param string $page_hook The page hook. */ do_action('wu_enqueue_extra_hooks', $this->page_hook); } @@ -635,7 +640,8 @@ public function get_title_links() { * Allow plugin developers, and ourselves, to add action links to our edit pages * * @since 1.8.2 - * @param WU_Page_Edit $this This instance + * @param array $action_links The action links. + * @param Base_Admin_Page $page This instance. * @return array */ return apply_filters('wu_page_get_title_links', $this->action_links, $this); diff --git a/inc/admin-pages/class-base-customer-facing-admin-page.php b/inc/admin-pages/class-base-customer-facing-admin-page.php index d455fd4b3..5740ee1a8 100644 --- a/inc/admin-pages/class-base-customer-facing-admin-page.php +++ b/inc/admin-pages/class-base-customer-facing-admin-page.php @@ -309,9 +309,9 @@ public function additional_on_page_load(): void { add_filter('wu_element_display_super_admin_notice', [$this, 'is_edit_mode']); - add_action("get_user_option_meta-box-order_{$this->page_hook}", [$this, 'get_settings'], 10, 3); + add_filter("get_user_option_meta-box-order_{$this->page_hook}", [$this, 'get_settings'], 10, 3); - add_action("get_user_option_screen_layout_{$this->page_hook}", [$this, 'get_settings'], 10, 3); + add_filter("get_user_option_screen_layout_{$this->page_hook}", [$this, 'get_settings'], 10, 3); /** * 'Hack-y' solution for the customer facing title problem... but good enough for now. @@ -338,7 +338,7 @@ function ($vars) { */ public function add_additional_body_classes(): void { - add_action( + add_filter( 'admin_body_class', function ($classes) { diff --git a/inc/admin-pages/class-customer-edit-admin-page.php b/inc/admin-pages/class-customer-edit-admin-page.php index 72d5d60ca..b7455cbd7 100644 --- a/inc/admin-pages/class-customer-edit-admin-page.php +++ b/inc/admin-pages/class-customer-edit-admin-page.php @@ -106,9 +106,8 @@ public function page_loaded() { // Handle delete meta field action if (isset($_GET['delete_meta_key']) && isset($_GET['_wpnonce'])) { - $meta_key = sanitize_key($_GET['delete_meta_key']); - $nonce = sanitize_text_field($_GET['_wpnonce']); + $nonce = sanitize_text_field($_GET['_wpnonce']); // Verify nonce for security if ( ! wp_verify_nonce($nonce, 'delete_customer_meta_' . $meta_key)) { @@ -121,11 +120,11 @@ public function page_loaded() { $redirect_args = [ 'updated' => $deleted ? 'meta_deleted' : 'meta_delete_failed', - 'tab' => 'custom_meta' + 'tab' => 'custom_meta', ]; $redirect_url = add_query_arg($redirect_args, wu_network_admin_url('wp-ultimo-edit-customer', ['id' => $customer->get_id()])); - + wp_safe_redirect($redirect_url); exit; } @@ -483,10 +482,12 @@ public function generate_customer_meta_fields() { // Add simple delete link for orphaned fields (those without form reference) $delete_link = ''; if ( ! $form) { - $delete_url = add_query_arg([ - 'delete_meta_key' => $key, - '_wpnonce' => wp_create_nonce('delete_customer_meta_' . $key), - ]); + $delete_url = add_query_arg( + [ + 'delete_meta_key' => $key, + '_wpnonce' => wp_create_nonce('delete_customer_meta_' . $key), + ] + ); $delete_link = sprintf( '%s', diff --git a/inc/admin-pages/class-email-template-customize-admin-page.php b/inc/admin-pages/class-email-template-customize-admin-page.php index 5162aab73..f35cf297f 100644 --- a/inc/admin-pages/class-email-template-customize-admin-page.php +++ b/inc/admin-pages/class-email-template-customize-admin-page.php @@ -720,6 +720,8 @@ public function get_setting($setting, $default_value = false) { return $return; } + + return $default_value; } /** diff --git a/inc/admin-pages/class-product-edit-admin-page.php b/inc/admin-pages/class-product-edit-admin-page.php index 9b27f7140..9d6c27e5d 100644 --- a/inc/admin-pages/class-product-edit-admin-page.php +++ b/inc/admin-pages/class-product-edit-admin-page.php @@ -95,7 +95,7 @@ public function register_forms(): void { add_action('wu_after_delete_product_modal', [$this, 'product_after_delete_actions']); - add_filter("wu_page_{$this->id}_load", [$this, 'add_new_product_warning_message']); + add_action("wu_page_{$this->id}_load", [$this, 'add_new_product_warning_message']); } /** diff --git a/inc/admin-pages/class-setup-wizard-admin-page.php b/inc/admin-pages/class-setup-wizard-admin-page.php index 659aea58a..495dc72ac 100644 --- a/inc/admin-pages/class-setup-wizard-admin-page.php +++ b/inc/admin-pages/class-setup-wizard-admin-page.php @@ -15,6 +15,7 @@ use WP_Ultimo\Installers\Migrator; use WP_Ultimo\Installers\Core_Installer; use WP_Ultimo\Installers\Default_Content_Installer; +use WP_Ultimo\Installers\Recommended_Plugins_Installer; use WP_Ultimo\Logger; use WP_Ultimo\Requirements; @@ -137,6 +138,7 @@ public function __construct() { */ add_action('wu_handle_ajax_installers', [Core_Installer::get_instance(), 'handle'], 10, 3); add_action('wu_handle_ajax_installers', [Default_Content_Installer::get_instance(), 'handle'], 10, 3); + add_action('wu_handle_ajax_installers', [Recommended_Plugins_Installer::get_instance(), 'handle'], 10, 3); add_action('wu_handle_ajax_installers', [Migrator::get_instance(), 'handle'], 10, 3); /* @@ -485,6 +487,19 @@ public function get_sections() { ], ]; } + // Recommended Plugins step (runs like other installer steps) + $sections['recommended-plugins'] = [ + 'title' => __('Recommended Plugins', 'ultimate-multisite'), + 'description' => __('Optionally install helpful plugins. We will install them one by one and report progress.', 'ultimate-multisite'), + 'next_label' => Recommended_Plugins_Installer::get_instance()->all_done() ? __('Go to the Next Step →', 'ultimate-multisite') : __('Install', 'ultimate-multisite'), + 'disable_next' => true, + 'fields' => [ + 'plugins' => [ + 'type' => 'note', + 'desc' => fn() => $this->render_installation_steps(Recommended_Plugins_Installer::get_instance()->get_steps()), + ], + ], + ]; $sections['done'] = [ 'title' => __('Ready!', 'ultimate-multisite'), @@ -498,7 +513,7 @@ public function get_sections() { * * @param array $sections Current sections. * @param bool $is_migration If this is a migration or not. - * @param object $this The current instance. + * @param object $wizard The current instance. * @return array */ return apply_filters('wu_setup_wizard', $sections, $this->is_migration(), $this); diff --git a/inc/admin-pages/class-site-edit-admin-page.php b/inc/admin-pages/class-site-edit-admin-page.php index 2f212fd97..0d88afd8c 100644 --- a/inc/admin-pages/class-site-edit-admin-page.php +++ b/inc/admin-pages/class-site-edit-admin-page.php @@ -130,7 +130,7 @@ public function register_forms(): void { ] ); - add_filter("wu_page_{$this->id}_load", [$this, 'add_new_site_template_warning_message']); + add_action("wu_page_{$this->id}_load", [$this, 'add_new_site_template_warning_message']); } /** diff --git a/inc/admin-pages/class-system-info-admin-page.php b/inc/admin-pages/class-system-info-admin-page.php index c9bf09c09..26aae0ded 100644 --- a/inc/admin-pages/class-system-info-admin-page.php +++ b/inc/admin-pages/class-system-info-admin-page.php @@ -736,7 +736,7 @@ public function get_all_wp_ultimo_settings() { $return_settings = []; - $settings = new \WP_Ultimo\Settings(); + $settings = \WP_Ultimo\Settings::get_instance(); foreach ($settings->get_all() as $setting => $value) { $add = true; diff --git a/inc/admin/class-network-usage-columns.php b/inc/admin/class-network-usage-columns.php new file mode 100644 index 000000000..840f8cc77 --- /dev/null +++ b/inc/admin/class-network-usage-columns.php @@ -0,0 +1,365 @@ + list of child theme names. + * Built on first use to keep is_parent() O(1) per call after O(n) init. + * + * @var array + */ + private array $children_by_parent = []; + + + /** + * Initialize the class. + */ + public function init(): void { + + add_action('activated_plugin', [$this, 'clear_site_transient'], 10, 2); + add_action('deactivated_plugin', [$this, 'clear_site_transient'], 10, 2); + add_action('switch_theme', [$this, 'clear_site_transient'], 10, 1); + add_action('update_site_option_allowedthemes', [$this, 'clear_site_transient'], 10, 1); + + if ( ! is_network_admin() ) { + return; + } + + /** + * Filter to change the value for get pluginssites inside the network. + * + * @type integer $sites_limit + */ + $this->sites_limit = (int) apply_filters('wu_sites_column_limit', $this->sites_limit); + + add_filter('manage_plugins-network_columns', array($this, 'add_plugins_column'), 10, 1); + add_action('manage_plugins_custom_column', array($this, 'manage_plugins_custom_column'), 10, 3); + + add_filter('manage_themes-network_columns', array($this, 'add_themes_column'), 10, 1); + add_action('manage_themes_custom_column', array($this, 'manage_themes_custom_column'), 10, 3); + } + + /** + * Add in a column header. + * + * @since 0.0.1 + * + * @param array $columns An array of displayed site columns. + * + * @return array + */ + public function add_plugins_column(array $columns): array { + + global $status; + + if ( empty($status) || ! in_array($status, ['dropins', 'mustuse'], true) ) { + $columns['active_blogs'] = __('Usage', 'ultimate-multisite'); + } + + return $columns; + } + + /** + * Get data for each row on each plugin. + * Echo the string. + * + * @since 0.0.1 + * + * @param string $column_name Name of the column. + * @param string $plugin_file Path to the plugin file. + * @param array $plugin_data An array of plugin data. + */ + public function manage_plugins_custom_column(string $column_name, string $plugin_file, array $plugin_data): void { + if ( 'active_blogs' !== $column_name ) { + return; + } + // Is this plugin network activated. + if ( ! function_exists('is_plugin_active_for_network') ) { + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + } + $active_on_network = is_plugin_active_for_network($plugin_file); + if ( $active_on_network ) { + // Translators: The plugin is network wide active, the string is for each plugin possible. + echo '' . esc_html__('Network Activated', 'ultimate-multisite') . ''; + } else { + $active_on_blogs = $this->get_blogs_with_plugin($plugin_file); + $this->output_blog_list($active_on_blogs); + } + if ( ! empty($plugin_data['Network']) ) { + echo '
' + . esc_attr__('Network Only', 'ultimate-multisite') + . ''; + } + } + + + /** + * Add in a column header. + * + * @param array $columns An array of displayed site columns. + * + * @return array + */ + public function add_themes_column(array $columns): array { + $columns['active_blogs'] = '' . __('Usage', 'ultimate-multisite') . ''; + + return $columns; + } + + /** + * Get data for each row on each theme. + * Print the string about the usage. + * + * @param string $column_name Name of the column. + * @param string $theme_key Path to the theme file. + * @param WP_Theme $theme_data An array of theme data. + */ + public function manage_themes_custom_column(string $column_name, string $theme_key, WP_Theme $theme_data): void { + if ( 'active_blogs' !== $column_name ) { + return; + } + + $active_on_blogs = $this->get_blogs_with_theme($theme_key); + + $this->output_blog_list($active_on_blogs); + + // Check, if is a child theme and return parent. + $child_context = ''; + 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($theme_data->parent()->Name) . '' + ); + } + + // Check if used as a parent theme for a child. + $used_as_parent = $this->is_parent($theme_key); + if ( count($used_as_parent) ) { + echo '
' . esc_attr__( + 'This is used as a parent theme by:', + 'multisite-enhancements' + ) . ' '; + echo esc_html(implode(', ', $used_as_parent)); + } + } + + /** + * Get child themes that use the given theme as parent (O(n)). + * + * @param string $theme_key Parent theme stylesheet (directory) key. + * @return array List of child theme names (escaped for output). + */ + public function is_parent(string $theme_key): array { + if (isset($this->children_by_parent[ $theme_key ])) { + return $this->children_by_parent[ $theme_key ]; + } + + if ( ! function_exists('wp_get_themes') ) { + return []; + } + + // Build cache once in O(n) over installed themes. + if (empty($this->children_by_parent)) { + $map = []; + $themes = wp_get_themes(); // Array of \WP_Theme keyed by stylesheet. + foreach ($themes as $stylesheet => $theme) { + if ( ! ($theme instanceof WP_Theme) ) { + continue; + } + $parent_stylesheet = (string) $theme->get_template(); + // Only child themes have a parent template different from their own stylesheet. + if ($parent_stylesheet && $parent_stylesheet !== $stylesheet) { + $map[ $parent_stylesheet ][] = esc_html($theme->get('Name')); + } + } + // Sort children lists for stable output. + foreach ($map as &$children) { + sort($children, SORT_STRING | SORT_FLAG_CASE); + } + $this->children_by_parent = $map; + } + + return $this->children_by_parent[ $theme_key ] ?? []; + } + + /** + * Output Blog List in cell. + * + * @param array $blogs The list of blogs. + * + * @return void + */ + private function output_blog_list(array $blogs): void { + + if ( ! $blogs ) { + // Translators: The plugin is not activated, the string is for each plugin possible. + echo '' . esc_html__('Not Activated', 'ultimate-multisite') . ''; + } else { + $active_count = count($blogs); + echo '
4 ? '' : 'open') . ' >'; + printf( + // Translators: The placeholder will be replaced by the count and the toggle link of sites there use that plugin. + esc_html(_n('Active on %1$d site', 'Active on %1$d sites', $active_count, 'ultimate-multisite')), + esc_html($active_count), + ); + echo ''; + echo '
    '; + foreach ($blogs as $blog_id => $blog) { + // Check the site for archived and deleted. + $class = ''; + $hint = ''; + if ($blog['archived']) { + $class = 'site-archived'; + $hint = ', ' . esc_attr__('Archived', 'ultimate-multisite'); + } + if ($blog['deleted']) { + $class = 'site-deleted'; + $hint .= ', ' . esc_attr__('Deleted', 'ultimate-multisite'); + } + echo '
  • '; + echo '' + . esc_html(trim($blog['name']) ?: $blog['path']) . '' . esc_html($hint) . '
  • '; + } + echo '
'; + } + } + + /** + * Is plugin active in blogs. + * + * @param string $plugin_file A name of the plugin file. + * + * @return array $active_in_plugins Which Blog ID and Name of Blog for each item in Array. + */ + public function get_blogs_with_plugin(string $plugin_file): array { + $blogs_data = $this->get_blogs_data(); + if (empty($blogs_data['plugins_blogs'][ $plugin_file ])) { + return []; + } + return array_intersect_key($blogs_data['blogs'], array_flip((array) $blogs_data['plugins_blogs'][ $plugin_file ])); + } + + /** + * Get all blogs with theme active. + * + * @param string $theme_file A name of the plugin file. + * + * @return array all blogs with theme. + */ + public function get_blogs_with_theme(string $theme_file): array { + $blogs_data = $this->get_blogs_data(); + if (empty($blogs_data['themes_blogs'][ $theme_file ])) { + return []; + } + return array_intersect_key($blogs_data['blogs'], array_flip((array) $blogs_data['themes_blogs'][ $theme_file ])); + } + + /** + * Gets an array of blog data including active plugins for each blog. + * + * @return array + */ + public function get_blogs_data(): array { + + if ( $this->blogs_data ) { + return $this->blogs_data; + } + + $blogs_plugins = get_site_transient(self::SITE_TRANSIENT_BLOGS_PLUGINS); + if ( false === $blogs_plugins ) { + $this->blogs_data = [ + 'blogs' => [], + 'plugins_blogs' => [], + 'themes_blogs' => [], + ]; + + $blogs = get_sites( + [ + 'fields' => 'ids', + 'number' => $this->sites_limit, + ] + ); + + foreach ( $blogs as $blog_id ) { + $blog_details = get_blog_details( + $blog_id + ); + + $this->blogs_data['blogs'][ $blog_id ] = [ + 'path' => $blog_details->path, + 'name' => $blog_details->blogname, + 'archived' => (bool) $blog_details->archived, + 'deleted' => (bool) $blog_details->deleted, + ]; + foreach (get_blog_option($blog_id, 'active_plugins', array()) as $plugin) { + $this->blogs_data['plugins_blogs'][ $plugin ][] = $blog_id; + } + $theme_file = (string) get_blog_option($blog_id, 'stylesheet', ''); + if ($theme_file) { + $this->blogs_data['themes_blogs'][ $theme_file ][] = $blog_id; + } + } + + set_site_transient(self::SITE_TRANSIENT_BLOGS_PLUGINS, $this->blogs_data); + } else { + $this->blogs_data = $blogs_plugins; + } + + return $this->blogs_data; + } + + /** + * Clears the $blogs_plugins site transient when any plugins are activated/deactivated. + * + * @param string $plugin The plugin being activated. + * @param bool $network_wide If it's network wide. + */ + public function clear_site_transient($plugin, $network_wide = false): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + delete_site_transient(self::SITE_TRANSIENT_BLOGS_PLUGINS); + } +} diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 6aefa68b8..21ae1ade8 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -18,8 +18,10 @@ use WP_Ultimo\Database\Memberships\Membership_Status; use WP_Ultimo\Checkout\Checkout_Pages; use WP_Ultimo\Managers\Payment_Manager; +use WP_Ultimo\Models\Customer; use WP_Ultimo\Objects\Billing_Address; use WP_Ultimo\Models\Site; +use WP_User; /** * Handles the processing of new membership purchases. @@ -651,9 +653,7 @@ public function process_order() { if ($cart->should_collect_payment() === false) { $gateway = wu_get_gateway('free'); } elseif ( ! $gateway || $gateway->get_id() === 'free') { - $this->errors = new \WP_Error('no-gateway', __('Payment gateway not registered.', 'ultimate-multisite')); - - return false; + return new \WP_Error('no-gateway', __('Payment gateway not registered.', 'ultimate-multisite')); } /* @@ -941,13 +941,6 @@ protected function maybe_create_customer() { */ if ($this->request_or_session('auto_generate_username') === 'email') { $username = wu_username_from_email($this->request_or_session('email_address')); - - /* - * Case where the site title is also auto-generated, based on the username. - */ - if ($this->request_or_session('auto_generate_site_title') && $this->request_or_session('site_title', '') === '') { - $_REQUEST['site_title'] = $username; - } } /* @@ -1047,7 +1040,7 @@ protected function maybe_create_customer() { * * @since 2.0.0 * @param Customer $customer The customer that was maybe created. - * @param Checkout $this The current checkout class. + * @param Checkout $checkout The current checkout class. */ do_action('wu_maybe_create_customer', $customer, $this); @@ -1102,7 +1095,7 @@ protected function handle_customer_meta_fields($customer, $form_slug) { * @since 2.0.0 * @param array $meta_repository The list of meta fields, key => value structured. * @param Customer $customer The Ultimate Multisite customer object. - * @param Checkout $this The checkout class. + * @param Checkout $checkout The checkout class. */ do_action('wu_handle_customer_meta_fields', $meta_repository, $customer, $this); @@ -1133,9 +1126,9 @@ protected function handle_customer_meta_fields($customer, $form_slug) { * * @since 2.0.4 * @param array $meta_repository The list of meta fields, key => value structured. - * @param \WP_User $user The WordPress user object. + * @param WP_User $user The WordPress user object. * @param Customer $customer The Ultimate Multisite customer object. - * @param Checkout $this The checkout class. + * @param Checkout $checkout The checkout class. */ do_action('wu_handle_user_meta_fields', $user_meta_repository, $user, $customer, $this); } @@ -1228,6 +1221,22 @@ protected function maybe_create_site() { $site_url = $this->request_or_session('site_url'); $site_title = $this->request_or_session('site_title'); + // Handle special auto-generation values passed from form fields + if ('autogenerate' === $site_title) { + if ($this->customer) { + $site_title = $this->customer->get_username(); + } else { + $email = $this->request_or_session('email_address'); + $site_title = $email ? wu_generate_site_title_from_email($email) : ''; + } + } + + if ('autogenerate' === $site_url && $site_title) { + $site_url = wu_generate_unique_site_url($site_title, $this->request_or_session('site_domain')); + } elseif ('autogenerate' === $site_url && $this->customer) { + $site_url = wu_generate_unique_site_url($this->customer->get_username(), $this->request_or_session('site_domain')); + } + if ( ! $site_url && ! $site_title) { return false; } @@ -1240,31 +1249,26 @@ protected function maybe_create_site() { * Let's handle auto-generation of site URLs. * * To decide if we need to auto-generate the site URL, - * we'll check the request for the auto_generate_site_url = username request value. + * we'll check the request for the auto_generate_site_url value. * * If that's present and no site_url is present, then we need to auto-generate this. - * The strategy here is simple, we basically set the site_url to the username and - * check if it is already taken. + * We support generating from either username or site_title. */ - if (empty($site_url) || 'username' === $auto_generate_url) { + if (empty($site_url) || in_array($auto_generate_url, ['username', 'site_title'], true)) { if ('username' === $auto_generate_url) { - $site_url = $this->customer->get_username(); - + $site_url = $this->customer->get_username(); $site_title = $site_title ?: $site_url; + } elseif ('site_title' === $auto_generate_url && $site_title) { + $site_url = wu_generate_unique_site_url($site_title, $this->request_or_session('site_domain')); } else { - $site_url = strtolower(str_replace(' ', '', preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities(trim((string) $site_title))))); - } - - $d = wu_get_site_domain_and_path($site_url, $this->request_or_session('site_domain')); - - $n = 0; - - while (domain_exists($d->domain, $d->path)) { - ++$n; - - $site_url = $this->customer->get_username() . $n; + // Fallback to legacy behavior - generate from site title if available + $site_url = wu_generate_site_url_from_title($site_title); + if (empty($site_url)) { + $site_url = $this->customer->get_username(); + } - $d = wu_get_site_domain_and_path($site_url, $this->request_or_session('site_domain')); + // Ensure uniqueness + $site_url = wu_generate_unique_site_url($site_url, $this->request_or_session('site_domain')); } } @@ -1691,7 +1695,7 @@ public function get_checkout_variables() { * * @since 2.0.0 * @param array $variables Localized variables. - * @param Checkout $this The checkout class. + * @param Checkout $checkout The checkout class. * @return array The new variables array. */ return apply_filters('wu_get_checkout_variables', $variables, $this); @@ -1814,7 +1818,7 @@ public function get_validation_rules() { * * @since 2.0.20 * @param array $validation_rules The validation rules to be used. - * @param Checkout $this The checkout class. + * @param Checkout $checkout The checkout class. */ return apply_filters('wu_checkout_validation_rules', $validation_rules, $this); } @@ -1875,7 +1879,7 @@ public function validate($rules = null) { * * @since 2.1 * @param array $validation_aliases The array with id => alias. - * @param Checkout $this The checkout class. + * @param Checkout $checkout The checkout class. */ $validation_aliases = apply_filters('wu_checkout_validation_aliases', $validation_aliases, $this); @@ -2122,7 +2126,7 @@ public function process_checkout() { * In that case, we simply return. */ if (false === $status) { - return; + return true; } /* diff --git a/inc/checkout/class-legacy-checkout.php b/inc/checkout/class-legacy-checkout.php index 18845f87b..992b6381b 100644 --- a/inc/checkout/class-legacy-checkout.php +++ b/inc/checkout/class-legacy-checkout.php @@ -404,13 +404,13 @@ public function get_steps($include_hidden = true, $filtered = true) { // Plan Selector $steps['plan'] = [ - 'name' => __('Pick a Plan', 'ultimate-multisite'), - 'desc' => __('Which one of our amazing plans you want to get?', 'ultimate-multisite'), - 'view' => 'step-plans', -// 'handler' => [$this, 'plans_save'], - 'order' => 10, - 'fields' => false, - 'core' => true, + 'name' => __('Pick a Plan', 'ultimate-multisite'), + 'desc' => __('Which one of our amazing plans you want to get?', 'ultimate-multisite'), + 'view' => 'step-plans', + // 'handler' => [$this, 'plans_save'], + 'order' => 10, + 'fields' => false, + 'core' => true, ]; $site_templates = [ @@ -431,13 +431,13 @@ public function get_steps($include_hidden = true, $filtered = true) { // Domain registering $steps['domain'] = [ - 'name' => __('Site Details', 'ultimate-multisite'), - 'desc' => __('Ok, now it\'s time to pick your site url and title!', 'ultimate-multisite'), -// 'handler' => [$this, 'domain_save'], - 'view' => false, - 'order' => 30, - 'core' => true, - 'fields' => apply_filters( + 'name' => __('Site Details', 'ultimate-multisite'), + 'desc' => __('Ok, now it\'s time to pick your site url and title!', 'ultimate-multisite'), + // 'handler' => [$this, 'domain_save'], + 'view' => false, + 'order' => 30, + 'core' => true, + 'fields' => apply_filters( 'wu_signup_fields_domain', [ 'blog_title' => [ @@ -641,12 +641,12 @@ public function get_steps($include_hidden = true, $filtered = true) { */ $begin_signup = [ 'begin-signup' => [ - 'name' => __('Begin Signup Process', 'ultimate-multisite'), -// 'handler' => [$this, 'begin_signup'], - 'view' => false, - 'hidden' => true, - 'order' => 0, - 'core' => true, + 'name' => __('Begin Signup Process', 'ultimate-multisite'), + // 'handler' => [$this, 'begin_signup'], + 'view' => false, + 'hidden' => true, + 'order' => 0, + 'core' => true, ], ]; @@ -805,11 +805,11 @@ public function get_site_url_for_previewer() { * Allow plugin developers to filter the URL used in the previewer * * @since 1.7.2 - * @param string Default domain being used right now, useful for manipulations - * @param array List of all the domain options entered in the Ultimate Multisite Settings -> Network Settings -> Domain Options + * @param string $domain Default domain being used right now, useful for manipulations + * @param array $domain_options List of all the domain options entered in the Ultimate Multisite Settings -> Network Settings -> Domain Options * @return string New domain to be used */ - return apply_filters('get_site_url_for_previewer', $domain, $domain_options); // phpcs:ignore + return apply_filters('get_site_url_for_previewer', $domain, $domain_options); } /** diff --git a/inc/checkout/signup-fields/class-signup-field-order-bump.php b/inc/checkout/signup-fields/class-signup-field-order-bump.php index 24a810b98..376235eca 100644 --- a/inc/checkout/signup-fields/class-signup-field-order-bump.php +++ b/inc/checkout/signup-fields/class-signup-field-order-bump.php @@ -249,7 +249,7 @@ public function to_fields_array($attributes) { $template_class = Field_Templates_Manager::get_instance()->get_template_class('order_bump', $attributes['order_bump_template']); - $desc = function() use($attributes, $template_class) { + $desc = function () use ($attributes, $template_class) { if ($template_class) { $template_class->render_container($attributes); } else { diff --git a/inc/checkout/signup-fields/class-signup-field-order-summary.php b/inc/checkout/signup-fields/class-signup-field-order-summary.php index 5d9b96d22..5e3930602 100644 --- a/inc/checkout/signup-fields/class-signup-field-order-summary.php +++ b/inc/checkout/signup-fields/class-signup-field-order-summary.php @@ -223,7 +223,7 @@ public function to_fields_array($attributes) { $template_class = Field_Templates_Manager::get_instance()->get_template_class('order_summary', $attributes['order_summary_template']); - $desc = function() use($attributes, $template_class) { + $desc = function () use ($attributes, $template_class) { if ($template_class) { $template_class->render_container($attributes); } else { diff --git a/inc/checkout/signup-fields/class-signup-field-period-selection.php b/inc/checkout/signup-fields/class-signup-field-period-selection.php index cf0fbb24a..88386baab 100644 --- a/inc/checkout/signup-fields/class-signup-field-period-selection.php +++ b/inc/checkout/signup-fields/class-signup-field-period-selection.php @@ -297,7 +297,7 @@ public function to_fields_array($attributes) { $template_class = Field_Templates_Manager::get_instance()->get_template_class('period_selection', $attributes['period_selection_template']); - $desc = function() use($attributes, $template_class) { + $desc = function () use ($attributes, $template_class) { if ($template_class) { $template_class->render_container($attributes); } else { diff --git a/inc/checkout/signup-fields/class-signup-field-pricing-table.php b/inc/checkout/signup-fields/class-signup-field-pricing-table.php index 0d9f1a3a8..d16c42105 100644 --- a/inc/checkout/signup-fields/class-signup-field-pricing-table.php +++ b/inc/checkout/signup-fields/class-signup-field-pricing-table.php @@ -279,7 +279,7 @@ public function to_fields_array($attributes) { $template_class = Field_Templates_Manager::get_instance()->get_template_class('pricing_table', $attributes['pricing_table_template']); - $desc = function() use($template_attributes, $template_class) { + $desc = function () use ($template_attributes, $template_class) { if ($template_class) { $template_class->render_container($template_attributes); } else { diff --git a/inc/checkout/signup-fields/class-signup-field-site-title.php b/inc/checkout/signup-fields/class-signup-field-site-title.php index 8263d0f4d..28880e2a0 100644 --- a/inc/checkout/signup-fields/class-signup-field-site-title.php +++ b/inc/checkout/signup-fields/class-signup-field-site-title.php @@ -195,11 +195,9 @@ public function to_fields_array($attributes) { 'value' => 'username', ], 'site_title' => [ - 'type' => 'hidden', - 'id' => 'site_title', - 'html_attr' => [ - 'v-bind:value' => 'username', - ], + 'type' => 'hidden', + 'id' => 'site_title', + 'value' => 'autogenerate', ], ]; } diff --git a/inc/checkout/signup-fields/class-signup-field-site-url.php b/inc/checkout/signup-fields/class-signup-field-site-url.php index cb629c7d2..898ed04d0 100644 --- a/inc/checkout/signup-fields/class-signup-field-site-url.php +++ b/inc/checkout/signup-fields/class-signup-field-site-url.php @@ -272,12 +272,12 @@ public function to_fields_array($attributes) { 'auto_generate_site_url' => [ 'type' => 'hidden', 'id' => 'auto_generate_site_url', - 'value' => 'username', + 'value' => 'site_title', ], 'site_url' => [ 'type' => 'hidden', 'id' => 'site_url', - 'value' => uniqid(), + 'value' => 'autogenerate', ], ]; } diff --git a/inc/checkout/signup-fields/class-signup-field-steps.php b/inc/checkout/signup-fields/class-signup-field-steps.php index b2a4a7015..fb853d173 100644 --- a/inc/checkout/signup-fields/class-signup-field-steps.php +++ b/inc/checkout/signup-fields/class-signup-field-steps.php @@ -209,7 +209,7 @@ public function to_fields_array($attributes) { $template_class = Field_Templates_Manager::get_instance()->get_template_class('steps', $attributes['steps_template']); - $desc = function() use($attributes, $template_class) { + $desc = function () use ($attributes, $template_class) { if ($template_class) { $template_class->render_container($attributes); } else { diff --git a/inc/checkout/signup-fields/field-templates/period-selection/class-legacy-period-selection-field-template.php b/inc/checkout/signup-fields/field-templates/period-selection/class-legacy-period-selection-field-template.php index 4de45bf98..bf7edf2ff 100644 --- a/inc/checkout/signup-fields/field-templates/period-selection/class-legacy-period-selection-field-template.php +++ b/inc/checkout/signup-fields/field-templates/period-selection/class-legacy-period-selection-field-template.php @@ -77,7 +77,7 @@ public function get_title() { */ public function get_description() { - return __('Implementation of the layout that shipped with Ultimate Multisite < 1.10.X.', 'ultimate-multisite'); + return __('Implementation of the layout that shipped with WP Ultimo < 1.10.X.', 'ultimate-multisite'); } /** diff --git a/inc/class-credits.php b/inc/class-credits.php new file mode 100644 index 000000000..b107890f0 --- /dev/null +++ b/inc/class-credits.php @@ -0,0 +1,306 @@ + __('Footer Credits', 'ultimate-multisite'), + 'desc' => __('Optional footer credit for public site and admin.', 'ultimate-multisite'), + 'type' => 'header', + ], + 2000 + ); + + // Enable/disable powered by (global) + wu_register_settings_field( + 'general', + 'credits_enable', + [ + 'title' => __('Show Footer Credits', 'ultimate-multisite'), + 'desc' => __('Adds a small "Powered By..." message in admin and front-end footers.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + ], + 2010 + ); + + // Footer credit type selection + wu_register_settings_field( + 'general', + 'credits_type', + [ + 'title' => __('Footer Credit Type', 'ultimate-multisite'), + 'desc' => __('Choose the type of footer credit to display.', 'ultimate-multisite'), + 'type' => 'select', + 'options' => [ + 'default' => __('Default "Powered by Ultimate Multisite" with logo', 'ultimate-multisite'), + 'custom' => __('Custom "Powered by [Network Name]" with company logo', 'ultimate-multisite'), + 'html' => __('Custom HTML (enter below)', 'ultimate-multisite'), + ], + 'default' => 'default', + 'require' => [ + 'credits_enable' => 1, + ], + ], + 2020 + ); + + // Custom HTML text (only for html option) + wu_register_settings_field( + 'general', + 'credits_custom_html', + [ + 'title' => __('Custom Footer HTML', 'ultimate-multisite'), + 'desc' => __('HTML allowed. Use any text or link you prefer.', 'ultimate-multisite'), + 'type' => 'textarea', + 'default' => function () { + $name = (string) get_network_option(null, 'site_name'); + $name = $name ?: __('this network', 'ultimate-multisite'); + $url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); + return sprintf( + /* translators: 1: Opening anchor tag with URL to main site. 2: Network name. */ + __('Powered by %1$s%2$s', 'ultimate-multisite'), + '', + esc_html($name) + ); + }, + 'placeholder' => __('Powered by Your Company', 'ultimate-multisite'), + 'require' => [ + 'credits_enable' => 1, + 'credits_type' => 'html', + ], + ], + 2030 + ); + } + + /** + * Build the credit text (HTML) based on settings. + */ + protected function build_credit_html(): string { + $enabled = (bool) wu_get_setting('credits_enable', 0); + if (! $enabled) { + return ''; + } + + $type = wu_get_setting('credits_type', 'default'); + + switch ($type) { + case 'custom': + return $this->build_custom_credit(); + + case 'html': + $html = (string) wu_get_setting('credits_custom_html', ''); + return wp_kses_post($html); + + default: + return $this->build_default_credit(); + } + } + + /** + * Build the default "Powered by Ultimate Multisite" credit with logo. + */ + protected function build_default_credit(): string { + $logo_html = $this->get_plugin_logo_html(); + $text = sprintf( + '%s', + esc_url('https://ultimatemultisite.com'), + esc_html__('Ultimate Multisite', 'ultimate-multisite') + ); + + if ($logo_html) { + return $logo_html . esc_html__('Powered by', 'ultimate-multisite') . ' ' . $text; + } + + return esc_html__('Powered by', 'ultimate-multisite') . ' ' . $text; + } + + /** + * Build the custom "Powered by [Network Name]" credit with company logo. + */ + protected function build_custom_credit(): string { + $logo_html = $this->get_company_logo_html(); + $network_name = (string) get_network_option(null, 'site_name'); + $network_name = $network_name ?: __('this network', 'ultimate-multisite'); + $network_url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); + + $text = sprintf( + '%s', + esc_url($network_url), + esc_html($network_name) + ); + + if ($logo_html) { + return $logo_html . esc_html__('Powered by', 'ultimate-multisite') . ' ' . $text; + } + + return esc_html__('Powered by', 'ultimate-multisite') . ' ' . $text; + } + + /** + * Get the Ultimate Multisite plugin logo HTML. + */ + protected function get_plugin_logo_html(): string { + $logo_url = wu_get_asset('badge.webp', 'img'); + if (! $logo_url) { + return ''; + } + + return sprintf( + '%s', + esc_url($logo_url), + esc_attr__('Ultimate Multisite', 'ultimate-multisite') + ); + } + + /** + * Get the company logo HTML from settings. + */ + protected function get_company_logo_html(): string { + $logo_url = wu_get_network_logo('thumbnail'); + + $company_name = wu_get_setting('company_name', get_network_option(null, 'site_name')); + + return sprintf( + '%s', + esc_url($logo_url), + esc_attr($company_name ?: __('Company Logo', 'ultimate-multisite')) + ); + } + + /** + * Check if current site is allowed to show footer credit. + * + * Sites can hide credits if their membership/product has the 'hide_credits' limit enabled. + */ + protected function site_allows_credit(): bool { + // Check if the site has the limitation to hide footer credits + $site = function_exists('wu_get_current_site') ? \wu_get_current_site() : null; + if (! $site || ! in_array($site->get_type(), [Site_Type::CUSTOMER_OWNED, Site_Type::SITE_TEMPLATE], true)) { + return false; + } + if ($site->has_limitations()) { + $limitations = $site->get_limitations(); + + // If the hide_footer_credits limit is enabled and set to true, the site can hide credits + if ($limitations->hide_credits && $limitations->hide_credits->allowed(true)) { + return false; + } + } + + return true; + } + + /** + * Admin footer replacement. + * + * Only show on customer-owned site admins (not network admin or main site admin). + * + * @param string $text Default footer text. + * @return string + */ + public function filter_admin_footer_text($text): string { + if (is_network_admin()) { + return $text; + } + + $site = function_exists('wu_get_current_site') ? \wu_get_current_site() : null; + if (! $site || ($site->get_type() !== Site_Type::CUSTOMER_OWNED)) { + return $text; + } + + $credit = $this->build_credit_html(); + if ($credit && $this->site_allows_credit()) { + return $credit; + } + return $text; + } + + /** + * Remove default update footer text when our credit is enabled. + * + * @param string $text Default Text. + */ + public function filter_update_footer_text($text): string { + if (is_network_admin()) { + return $text; + } + + $site = function_exists('wu_get_current_site') ? \wu_get_current_site() : null; + if (! $site || ($site->get_type() !== Site_Type::CUSTOMER_OWNED)) { + return $text; + } + + $enabled = (bool) wu_get_setting('credits_enable', 0); + if ($enabled && $this->site_allows_credit()) { + return ''; + } + return $text; + } + + /** + * Front-end footer output (appended near wp_footer). + */ + public function render_frontend_footer(): void { + if (is_admin()) { + return; + } + + if (! $this->site_allows_credit()) { + return; + } + + $credit = $this->build_credit_html(); + if (! $credit) { + return; + } + echo '
' . $credit . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } +} diff --git a/inc/class-hooks.php b/inc/class-hooks.php index 2ab38d2ae..3844bf71f 100644 --- a/inc/class-hooks.php +++ b/inc/class-hooks.php @@ -56,7 +56,7 @@ public static function init(): void { */ public static function on_activation(): void { - wu_log_add('wp-ultimo-core', __('Activating Ultimate Multisite...', 'ultimate-multisite')); + wu_log_add(\WP_Ultimo::LOG_HANDLE, __('Activating Ultimate Multisite...', 'ultimate-multisite')); /* * Set the activation flag @@ -100,7 +100,7 @@ public static function on_activation_do(): void { */ public static function on_deactivation(): void { - wu_log_add('wp-ultimo-core', __('Deactivating Ultimate Multisite...', 'ultimate-multisite')); + wu_log_add(\WP_Ultimo::LOG_HANDLE, __('Deactivating Ultimate Multisite...', 'ultimate-multisite')); /* * Update the sunrise meta file. diff --git a/inc/class-scripts.php b/inc/class-scripts.php index d8d3519ea..615dad142 100644 --- a/inc/class-scripts.php +++ b/inc/class-scripts.php @@ -224,7 +224,7 @@ public function register_default_scripts(): void { 'wu-functions', 'wu_selectizer', [ - 'ajaxurl' => wu_ajax_url(), + 'ajaxurl' => wu_ajax_url('init'), ] ); diff --git a/inc/class-sunrise.php b/inc/class-sunrise.php index 2b441f935..a6ac77718 100644 --- a/inc/class-sunrise.php +++ b/inc/class-sunrise.php @@ -159,6 +159,7 @@ public static function load_dependencies(): void { require_once __DIR__ . '/limitations/class-limit-site-templates.php'; require_once __DIR__ . '/limitations/class-limit-domain-mapping.php'; require_once __DIR__ . '/limitations/class-limit-customer-user-role.php'; + require_once __DIR__ . '/limitations/class-limit-hide-footer-credits.php'; } /** diff --git a/inc/class-whitelabel.php b/inc/class-whitelabel.php index 66bc0ba9b..71511db24 100644 --- a/inc/class-whitelabel.php +++ b/inc/class-whitelabel.php @@ -65,9 +65,7 @@ public function init(): void { add_action('admin_init', [$this, 'clear_footer_texts']); - add_action('init', [$this, 'hooks']); - - add_filter('gettext', [$this, 'replace_text'], 10, 3); + add_action('init', [$this, 'hooks'], 1); } /** @@ -89,6 +87,50 @@ public function hooks(): void { if (wu_get_setting('hide_sites_menu', true)) { add_action('network_admin_menu', [$this, 'remove_sites_admin_menu']); } + + if (wu_get_setting('rename_site_plural') || + wu_get_setting('rename_site_singular') || + wu_get_setting('rename_wordpress') + ) { + $this->allowed_domains = apply_filters( + 'wu_replace_text_allowed_domains', + [ + 'default', + 'wp-ultimo', + 'ultimate-multisite', + ] + ); + + $search_and_replace = []; + $site_plural = wu_get_setting('rename_site_plural'); + + if ($site_plural) { + $search_and_replace['sites'] = strtolower((string) $site_plural); + $search_and_replace['Sites'] = ucfirst((string) $site_plural); + } + + $site_singular = wu_get_setting('rename_site_singular'); + + if ($site_singular) { + $search_and_replace['site'] = strtolower((string) $site_singular); + $search_and_replace['Site'] = ucfirst((string) $site_singular); + } + + $wordpress = wu_get_setting('rename_wordpress'); + + if ($wordpress) { + $search_and_replace['wordpress'] = strtolower((string) $wordpress); + $search_and_replace['WordPress'] = ucfirst((string) $wordpress); + $search_and_replace['Wordpress'] = ucfirst((string) $wordpress); + $search_and_replace['wordPress'] = ucfirst((string) $wordpress); + } + + if ($search_and_replace) { + $this->search = array_keys($search_and_replace); + $this->replace = array_values($search_and_replace); + } + add_filter('gettext', [$this, 'replace_text'], 10, 3); + } } /** @@ -116,16 +158,6 @@ public function enqueue_styles(): void { */ public function replace_text($translation, $text, $domain) { - if (null === $this->allowed_domains) { - $this->allowed_domains = apply_filters( - 'wu_replace_text_allowed_domains', - [ - 'default', - 'wp-ultimo', - ] - ); - } - if ( ! in_array($domain, $this->allowed_domains, true)) { return $translation; } @@ -144,40 +176,6 @@ public function replace_text($translation, $text, $domain) { return $translation; } - if (false === $this->init) { - $search_and_replace = []; - - $site_plural = wu_get_setting('rename_site_plural'); - - if ($site_plural) { - $search_and_replace['sites'] = strtolower((string) $site_plural); - $search_and_replace['Sites'] = ucfirst((string) $site_plural); - } - - $site_singular = wu_get_setting('rename_site_singular'); - - if ($site_singular) { - $search_and_replace['site'] = strtolower((string) $site_singular); - $search_and_replace['Site'] = ucfirst((string) $site_singular); - } - - $wordpress = wu_get_setting('rename_wordpress'); - - if ($wordpress) { - $search_and_replace['wordpress'] = strtolower((string) $wordpress); - $search_and_replace['WordPress'] = ucfirst((string) $wordpress); - $search_and_replace['Wordpress'] = ucfirst((string) $wordpress); - $search_and_replace['wordPress'] = ucfirst((string) $wordpress); - } - - if ($search_and_replace) { - $this->search = array_keys($search_and_replace); - $this->replace = array_values($search_and_replace); - } - - $this->init = true; - } - if ( ! empty($this->search)) { return str_replace($this->search, $this->replace, $translation); } diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index b1c7b5069..e0c8bc69d 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -33,6 +33,14 @@ final class WP_Ultimo { */ const VERSION = '2.4.4'; + /** + * Core log handle for Ultimate Multisite. + * + * @since 2.4.4 + * @var string + */ + const LOG_HANDLE = 'ultimate-multisite-core'; + /** * Version of the Plugin. * @@ -211,6 +219,7 @@ public function after_init() { /** * Loads admin pages + * * @todo: Move this to a manager in the future? */ $this->load_admin_pages(); @@ -562,6 +571,11 @@ function () { */ \WP_Ultimo\Dashboard_Statistics::get_instance(); + /* + * Network Plugins/Themes usage columns + */ + \WP_Ultimo\Admin\Network_Usage_Columns::get_instance(); + /* * Loads User Switching */ @@ -596,6 +610,11 @@ function () { */ \WP_Ultimo\Whitelabel::get_instance(); + /* + * Optional Footer Credits (opt-in, defaults OFF) + */ + \WP_Ultimo\Credits::get_instance(); + /* * Adds support to multiple accounts. * @@ -615,6 +634,12 @@ function () { */ \WP_Ultimo\Admin_Themes_Compatibility::get_instance(); + add_filter( + 'action_scheduler_lock_class', + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + fn ($class_name) => \WP_Ultimo\Compat\ActionScheduler_OptionLock_UM::class + ); + /* * Cron Schedules */ diff --git a/inc/compat/class-actionscheduler-optionlock-um.php b/inc/compat/class-actionscheduler-optionlock-um.php new file mode 100644 index 000000000..0ab8ee78a --- /dev/null +++ b/inc/compat/class-actionscheduler-optionlock-um.php @@ -0,0 +1,144 @@ +get_key($lock_type); + $existing_lock_value = $this->get_existing_lock($lock_type); + $new_lock_value = $this->new_lock_value($lock_type); + + // The lock may not exist yet, or may have been deleted. + if (null === $existing_lock_value) { + return (bool) $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->options, + array( + 'option_name' => $lock_key, + 'option_value' => $new_lock_value, + 'autoload' => 'no', + ) + ); + } + + if ( $this->get_expiration_from($existing_lock_value) >= time() ) { + return false; + } + + // Otherwise, try to obtain the lock by replacing the existing value. + return (bool) $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->options, + array('option_value' => $new_lock_value), + array( + 'option_name' => $lock_key, + 'option_value' => $existing_lock_value, + ) + ); + } + + /** + * If a lock is set, return the timestamp it was set to expiry. + * + * @param string $lock_type A string to identify different lock types. + * @return bool|int False if no lock is set, otherwise the timestamp for when the lock is set to expire. + */ + public function get_expiration($lock_type) { + return $this->get_expiration_from((string) $this->get_existing_lock($lock_type)); + } + + /** + * Given the lock string, derives the lock expiration timestamp (or false if it cannot be determined). + * + * @param string $lock_value String containing a timestamp, or pipe-separated combination of unique value and timestamp. + * + * @return false|int + */ + private function get_expiration_from($lock_value) { + $lock_string = explode('|', $lock_value); + + // Old style lock? + if ( count($lock_string) === 1 && is_numeric($lock_string[0]) ) { + return (int) $lock_string[0]; + } + + // New style lock? + if ( count($lock_string) === 2 && is_numeric($lock_string[1]) ) { + return (int) $lock_string[1]; + } + + return false; + } + + /** + * Get the key to use for storing the lock in the options table. + * + * @param string $lock_type A string to identify different lock types. + * @return string + */ + protected function get_key($lock_type) { + return sprintf('action_scheduler_lock_%s', $lock_type); + } + + /** + * Supplies the existing lock value, or null if not set. + * + * @param string $lock_type A string to identify different lock types. + * + * @return string|null + */ + private function get_existing_lock($lock_type) { + global $wpdb; + + // get_val() returns null for the empty string ('') so we must use get_row(). + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT option_value FROM $wpdb->options WHERE option_name = %s", + $this->get_key($lock_type) + ) + ); + + if ($row) { + return $row->option_value; + } + return null; + } + + /** + * Supplies a lock value consisting of a unique value and the current timestamp, separated by a pipe. + * + * Example: "649de012e6b262.09774912|1688068114" + * + * @param string $lock_type A string to identify different lock types. + * + * @return string + */ + private function new_lock_value($lock_type) { + return uniqid('', true) . '|' . (time() + $this->get_duration($lock_type)); + } +} diff --git a/inc/functions/limitations.php b/inc/functions/limitations.php index b1ee8f2ce..2b7297a7d 100644 --- a/inc/functions/limitations.php +++ b/inc/functions/limitations.php @@ -50,7 +50,7 @@ * Will return true if ANY slug on the array is present. * @param bool $blocking When set to true, this flag also validates the active status of the membership. * @param string $site_id The site ID to test. - * @return boolean + * @return boolean|WP_Error */ function wu_has_product($product_slug, $blocking = false, $site_id = '') { @@ -93,7 +93,7 @@ function wu_has_product($product_slug, $blocking = false, $site_id = '') { * @since 2.0.0 * * @param string $site_id The site ID to test. - * @return bool + * @return bool|\WP_Error */ function wu_is_membership_active($site_id = '') { diff --git a/inc/functions/site.php b/inc/functions/site.php index 7f7d235e7..bda90a60a 100644 --- a/inc/functions/site.php +++ b/inc/functions/site.php @@ -208,3 +208,170 @@ function wu_get_site_domain_and_path($path_or_subdomain = '/', $base_domain = fa */ return apply_filters('wu_get_site_domain_and_path', $d, $path_or_subdomain); } + +/** + * Generates a URL-safe slug from a site title. + * + * Takes a site title like "Your Cool Site" and converts it to "yourcoolsite" + * for use as a subdomain or path. + * + * @since 2.0.0 + * + * @param string $site_title The site title to convert. + * @return string URL-safe slug. + */ +function wu_generate_site_url_from_title($site_title) { + + if (empty($site_title)) { + return ''; + } + + // Convert to lowercase and remove HTML entities + $slug = strtolower(html_entity_decode(trim((string) $site_title), ENT_QUOTES, 'UTF-8')); + + // Remove or replace common special characters + $slug = str_replace( + ['&', '+', '@', '#', '$', '%', '^', '*', '(', ')', '=', '[', ']', '{', '}', '|', '\\', ':', ';', '"', "'", '<', '>', ',', '.', '?', '/', '~', '`'], + '', + $slug + ); + + // Replace spaces and underscores with nothing (no separators) + $slug = str_replace([' ', '_', '-'], '', $slug); + + // Remove any remaining non-alphanumeric characters + $slug = preg_replace('/[^a-z0-9]/', '', $slug); + + // Ensure it starts with a letter (WordPress requirement) + if (! empty($slug) && is_numeric(substr($slug, 0, 1))) { + $slug = 'site' . $slug; + } + + // Fallback if empty after cleaning + if (empty($slug)) { + $slug = 'site' . wp_rand(1000, 9999); + } + + return $slug; +} + +/** + * Generates a site title from an email address. + * + * Takes an email like "john.doe@example.com" and converts it to "John Doe Site" + * or falls back to using the domain part if the username is generic. + * + * @since 2.0.0 + * + * @param string $email The email address to use for generation. + * @return string Generated site title. + */ +function wu_generate_site_title_from_email($email) { + + if (empty($email) || ! is_email($email)) { + return ''; + } + + $email_parts = explode('@', $email); + $username = $email_parts[0]; + $domain = $email_parts[1]; + + // Common generic email prefixes to avoid + $generic_prefixes = [ + 'admin', + 'administrator', + 'info', + 'contact', + 'support', + 'help', + 'sales', + 'marketing', + 'hello', + 'hi', + 'mail', + 'email', + 'test', + 'demo', + 'sample', + 'example', + 'noreply', + 'no-reply', + ]; + + $title_parts = []; + + // Check if username is not generic + if (! in_array(strtolower($username), $generic_prefixes, true)) { + // Split on common separators + $name_parts = preg_split('/[._\-+]/', $username); + + foreach ($name_parts as $part) { + $part = trim($part); + if (! empty($part) && ! is_numeric($part)) { + // Capitalize first letter of each part + $title_parts[] = ucfirst(strtolower($part)); + } + } + } + + // If we don't have good name parts, use domain + if (empty($title_parts)) { + $domain_part = strtok($domain, '.'); + $title_parts[] = ucfirst($domain_part); + } + + // Create title + $title = implode(' ', $title_parts); + + // Add "Site" suffix if title is short + if (strlen($title) < 8) { + $title .= ' Site'; + } + + return $title; +} + +/** + * Generates a unique site URL with collision detection. + * + * Takes a base URL slug and ensures it's unique by checking against existing sites. + * Appends numbers if needed to avoid collisions. + * + * @since 2.0.0 + * + * @param string $base_url The base URL slug to use. + * @param string $domain The domain to check against (optional). + * @return string Unique site URL. + */ +function wu_generate_unique_site_url($base_url, $domain = null) { + + if (empty($base_url)) { + $base_url = 'site' . wp_rand(1000, 9999); + } + + // Clean the base URL + $base_url = wu_generate_site_url_from_title($base_url); + + $site_url = $base_url; + $counter = 0; + + // Keep checking until we find a unique URL + while (true) { + $d = wu_get_site_domain_and_path($site_url, $domain); + + if (! domain_exists($d->domain, $d->path)) { + break; + } + + ++$counter; + $site_url = $base_url . $counter; + + // Safety net to prevent infinite loops + if ($counter > 9999) { + $site_url = $base_url . wp_rand(10000, 99999); + break; + } + } + + return $site_url; +} diff --git a/inc/installers/class-base-installer.php b/inc/installers/class-base-installer.php index f47a3eb3f..6a77d0ddd 100644 --- a/inc/installers/class-base-installer.php +++ b/inc/installers/class-base-installer.php @@ -10,6 +10,8 @@ namespace WP_Ultimo\Installers; // Exit if accessed directly +use Psr\Log\LogLevel; + defined('ABSPATH') || exit; /** @@ -69,7 +71,7 @@ public function all_done() { * @param bool|\WP_Error $status Status of the installer. * @param string $installer The installer name. * @param object $wizard Wizard class. - * @return bool|\WP_Error + * @return void */ public function handle($status, $installer, $wizard) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter @@ -83,7 +85,7 @@ public function handle($status, $installer, $wizard) { // phpcs:ignore Generic.C * No installer on this class. */ if ( ! is_callable($callable)) { - return $status; + return; } try { @@ -92,12 +94,10 @@ public function handle($status, $installer, $wizard) { // phpcs:ignore Generic.C call_user_func($callable); } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - - return new \WP_Error(esc_html($installer), wp_kses_post($e->getMessage())); + wu_log_add(\WP_Ultimo::LOG_HANDLE, $e->getMessage(), LogLevel::ERROR); + return; } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - - return $status; } } diff --git a/inc/installers/class-migrator.php b/inc/installers/class-migrator.php index fb354c292..291b3f121 100644 --- a/inc/installers/class-migrator.php +++ b/inc/installers/class-migrator.php @@ -459,7 +459,7 @@ protected function bypass_server_limits() { * @param string $installer The installer name. * @param object $wizard Wizard class. * - * @return bool|WP_Error + * @return void */ public function handle($status, $installer, $wizard) { @@ -478,7 +478,7 @@ public function handle($status, $installer, $wizard) { * No installer on this class. */ if ( ! is_callable($callable)) { - return $status; + return; } /* @@ -534,8 +534,6 @@ public function handle($status, $installer, $wizard) { * Log errors to later reference. */ wu_log_add(self::LOG_FILE_NAME, $e->__toString(), LogLevel::ERROR); - - return $this->handle_error_messages($e, $session, $this->dry_run, $installer); } /* @@ -559,8 +557,6 @@ public function handle($status, $installer, $wizard) { $session->set('back_traces', []); } - - return $status; } /** diff --git a/inc/installers/class-recommended-plugins-installer.php b/inc/installers/class-recommended-plugins-installer.php new file mode 100644 index 000000000..f8eae9c5f --- /dev/null +++ b/inc/installers/class-recommended-plugins-installer.php @@ -0,0 +1,167 @@ + $this->is_plugin_installed($user_switching_slug), + 'title' => __('User Switching', 'ultimate-multisite'), + 'description' => __('Quickly switch between users for testing and support.', 'ultimate-multisite'), + 'pending' => __('Pending', 'ultimate-multisite'), + 'installing' => __('Installing User Switching...', 'ultimate-multisite'), + 'success' => __('Installed!', 'ultimate-multisite'), + 'help' => 'https://wordpress.org/plugins/user-switching/', + 'checked' => true, + ]; + + return $steps; + } + + /** + * Handle AJAX for our plugin install steps. Matches slugs starting with + * `install_plugin_` and installs from WordPress.org by plugin slug. + * + * @since 2.0.0 + * + * @param bool|\WP_Error $status Current status passed through the filter chain. + * @param string $installer The installer slug (e.g. `install_plugin_user-switching`). + * @param object $wizard Wizard page instance. + * @return void + */ + public function handle($status, $installer, $wizard) { + + if (strpos($installer, 'install_plugin_') !== 0) { + return; // Not ours. + } + + $plugin_slug = substr($installer, strlen('install_plugin_')); + + try { + $result = $this->install_wporg_plugin($plugin_slug); + + if (is_wp_error($result)) { + return; + } + } catch (\Throwable $e) { + wu_log_add(\WP_Ultimo::LOG_HANDLE, $e->getMessage(), LogLevel::ERROR); + } + } + + /** + * Determine if a plugin is already installed by slug. + * + * @since 2.0.0 + * @param string $plugin_slug Plugin slug (e.g. 'user-switching'). + * @return bool + */ + protected function is_plugin_installed($plugin_slug) { + $installed = get_plugins(); + + foreach ($installed as $file => $data) { + if (strpos($file, $plugin_slug . '/') === 0) { + return true; + } + } + + return false; + } + + /** + * Install a plugin from WordPress.org by slug. + * + * @since 2.0.0 + * @param string $plugin_slug Plugin slug on wp.org. + * @return bool|\WP_Error + */ + protected function install_wporg_plugin($plugin_slug) { + + // If already installed, succeed early. + if ($this->is_plugin_installed($plugin_slug)) { + return true; + } + + // Load required WordPress admin includes for installing plugins. + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/misc.php'; + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + + // Query plugin info to get the download link. + $api = plugins_api( + 'plugin_information', + [ + 'slug' => $plugin_slug, + 'fields' => [ + 'sections' => false, + ], + ] + ); + + if (is_wp_error($api)) { + return $api; + } + + $download_url = $api->download_link ?? ''; + + if (! $download_url) { + return new \WP_Error('no-download-link', __('Unable to resolve plugin download link.', 'ultimate-multisite')); + } + + $skin = new \Automatic_Upgrader_Skin([]); + $upgrader = new \Plugin_Upgrader($skin); + + $results = $upgrader->install($download_url); + + if (is_wp_error($results)) { + return $results; + } + + $messages = $upgrader->skin->get_upgrade_messages(); + + if (! in_array($upgrader->strings['process_success'], $messages, true)) { + $error_message = array_pop($messages); + return new \WP_Error('installation-failed', $error_message ?: __('Installation failed.', 'ultimate-multisite')); + } + + return true; + } +} diff --git a/inc/integrations/host-providers/class-base-host-provider.php b/inc/integrations/host-providers/class-base-host-provider.php index 22d9b40fd..8ddd4e0b1 100644 --- a/inc/integrations/host-providers/class-base-host-provider.php +++ b/inc/integrations/host-providers/class-base-host-provider.php @@ -192,7 +192,7 @@ public function add_to_integration_list(): void { $slug = $this->get_id(); - $html = $this->is_enabled() ? sprintf(' %s', __('Activated', 'ultimate-multisite')) : ''; + $html = $this->is_enabled() ? sprintf('
%s
', __('Activated', 'ultimate-multisite')) : ''; $url = wu_network_admin_url( 'wp-ultimo-hosting-integration-wizard', diff --git a/inc/integrations/host-providers/class-closte-host-provider.php b/inc/integrations/host-providers/class-closte-host-provider.php index 7e6b49e1a..8397f04e5 100644 --- a/inc/integrations/host-providers/class-closte-host-provider.php +++ b/inc/integrations/host-providers/class-closte-host-provider.php @@ -111,7 +111,7 @@ public function on_add_domain($domain, $site_id): void { wu_log_add('integration-closte', sprintf('Failed to add domain %s. Response: %s', $domain, wp_json_encode($domain_response))); // Only try SSL if it's not a domain validation error - if (!isset($domain_response['error']) || !str_contains($domain_response['error'], 'Invalid or empty domain')) { + if (! isset($domain_response['error']) || ! str_contains($domain_response['error'], 'Invalid or empty domain')) { wu_log_add('integration-closte', sprintf('Attempting SSL certificate request for %s despite domain addition failure', $domain)); $this->request_ssl_certificate($domain); } @@ -194,7 +194,7 @@ private function request_ssl_certificate($domain) { ); // If we get something other than 400/404, we found a working endpoint - if (!isset($ssl_response['error']) || !preg_match('/HTTP [45]\d\d/', $ssl_response['error'])) { + if (! isset($ssl_response['error']) || ! preg_match('/HTTP [45]\d\d/', $ssl_response['error'])) { wu_log_add('integration-closte', sprintf('SSL endpoint %s responded, stopping search', $endpoint)); break; } @@ -292,8 +292,8 @@ public function send_closte_api_request($endpoint, $data) { 'timeout' => 45, 'method' => 'POST', 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - 'User-Agent' => 'WP-Ultimo-Closte-Integration/2.0', + 'Content-Type' => 'application/x-www-form-urlencoded', + 'User-Agent' => 'WP-Ultimo-Closte-Integration/2.0', ], 'body' => array_merge( [ @@ -325,8 +325,8 @@ public function send_closte_api_request($endpoint, $data) { if ($response_code >= 400) { wu_log_add('integration-closte', sprintf('HTTP error %d for endpoint %s', $response_code, $endpoint)); return (object) [ - 'success' => false, - 'error' => sprintf('HTTP %d error', $response_code), + 'success' => false, + 'error' => sprintf('HTTP %d error', $response_code), 'response_body' => $response_body, ]; } @@ -347,8 +347,8 @@ public function send_closte_api_request($endpoint, $data) { wu_log_add('integration-closte', sprintf('JSON decode error: %s', json_last_error_msg())); return (object) [ - 'success' => false, - 'error' => 'Invalid JSON response', + 'success' => false, + 'error' => 'Invalid JSON response', 'json_error' => json_last_error_msg(), ]; } @@ -381,6 +381,4 @@ public function get_logo() { return wu_get_asset('closte.svg', 'img/hosts'); } - - } diff --git a/inc/limitations/class-limit-domain-mapping.php b/inc/limitations/class-limit-domain-mapping.php index e9a3cc354..11a24034e 100644 --- a/inc/limitations/class-limit-domain-mapping.php +++ b/inc/limitations/class-limit-domain-mapping.php @@ -37,14 +37,6 @@ class Limit_Domain_Mapping extends Limit { */ protected $mode = 'default'; - /** - * Allows sub-type limits to set their own default value for enabled. - * - * @since 2.0.0 - * @var bool - */ - private bool $enabled_default_value = true; - /** * Sets up the module based on the module data. * @@ -182,21 +174,6 @@ public function get_current_domain_count($site_id = null) { return $active_count; } - /** - * Returns default permissions. - * - * @since 2.0.0 - * - * @param string $type Type for sub-checking. - * @return array - */ - public function get_default_permissions($type) { - - return [ - 'behavior' => 'available', - ]; - } - /** * Returns a default state. * diff --git a/inc/limitations/class-limit-hide-footer-credits.php b/inc/limitations/class-limit-hide-footer-credits.php new file mode 100644 index 000000000..7f699600e --- /dev/null +++ b/inc/limitations/class-limit-hide-footer-credits.php @@ -0,0 +1,81 @@ +is_enabled()) { + return false; + } + + // For boolean limits (enabled/disabled to hide credits) + if (is_bool($limit)) { + return $limit; + } + + // Default to not allowed if limit is not properly set + return false; + } + + /** + * Returns a default state. + * + * @since 2.4.5 + * @return array + */ + public static function default_state(): array { + + return [ + 'enabled' => false, + 'limit' => false, + ]; + } +} diff --git a/inc/limitations/class-limit-site-templates.php b/inc/limitations/class-limit-site-templates.php index 4bc21421b..57166ad5d 100644 --- a/inc/limitations/class-limit-site-templates.php +++ b/inc/limitations/class-limit-site-templates.php @@ -35,14 +35,6 @@ class Limit_Site_Templates extends Limit { */ protected $mode = 'default'; - /** - * Allows sub-type limits to set their own default value for enabled. - * - * @since 2.0.0 - * @var bool - */ - private bool $enabled_default_value = true; - /** * Sets up the module based on the module data. * diff --git a/inc/limitations/class-limit.php b/inc/limitations/class-limit.php index db97b1597..c7bcd67b0 100644 --- a/inc/limitations/class-limit.php +++ b/inc/limitations/class-limit.php @@ -78,7 +78,7 @@ abstract class Limit implements \JsonSerializable { * @since 2.0.0 * @var bool */ - private bool $enabled_default_value = true; + protected bool $enabled_default_value = true; /** * Constructs the limit module. @@ -97,9 +97,9 @@ public function __construct($data) { * @since 2.0.0 * @return string */ - public function __serialize() { // phpcs:ignore + public function __serialize() { - return serialize($this->to_array()); + return serialize($this->to_array()); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize } /** @@ -110,9 +110,9 @@ public function __serialize() { // phpcs:ignore * @param string $data The un-serialized data. * @return void */ - public function __unserialize($data) { // phpcs:ignore + public function __unserialize($data) { - $this->setup(unserialize($data)); + $this->setup(unserialize($data)); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize } /** diff --git a/inc/managers/class-limitation-manager.php b/inc/managers/class-limitation-manager.php index 762a1de4d..695a1b1bb 100644 --- a/inc/managers/class-limitation-manager.php +++ b/inc/managers/class-limitation-manager.php @@ -62,7 +62,7 @@ public function init(): void { * @param string|array $plugins The plugin or list of plugins to (de)activate. * @param boolean $network_wide If we want to (de)activate it network-wide. * @param boolean $silent IF we should do the process silently - true by default. - * @return bool + * @return void */ public function async_handle_plugins($action, $site_id, $plugins, $network_wide = false, $silent = true) { @@ -70,7 +70,7 @@ public function async_handle_plugins($action, $site_id, $plugins, $network_wide // Avoid doing anything on the main site. if (wu_get_main_site_id() === $site_id) { - return $results; + return; } switch_to_blog($site_id); @@ -78,7 +78,7 @@ public function async_handle_plugins($action, $site_id, $plugins, $network_wide if ('activate' === $action) { $results = activate_plugins($plugins, '', $network_wide, $silent); } elseif ('deactivate' === $action) { - $results = deactivate_plugins($plugins, $silent, $network_wide); + deactivate_plugins($plugins, $silent, $network_wide); } if (is_wp_error($results)) { @@ -86,8 +86,6 @@ public function async_handle_plugins($action, $site_id, $plugins, $network_wide } restore_current_blog(); - - return $results; } /** @@ -97,17 +95,15 @@ public function async_handle_plugins($action, $site_id, $plugins, $network_wide * * @param int $site_id The site ID. * @param string $theme_stylesheet The theme stylesheet. - * @return true + * @return void */ - public function async_switch_theme($site_id, $theme_stylesheet): bool { + public function async_switch_theme($site_id, $theme_stylesheet): void { switch_to_blog($site_id); switch_theme($theme_stylesheet); restore_current_blog(); - - return true; } /** @@ -460,7 +456,7 @@ public function add_limitation_sections($sections, $object_model) { $sections['custom_domain'] = [ 'title' => __('Custom Domains', 'ultimate-multisite'), - 'desc' => __('Limit the number of users on each role, posts, pages, and more.', 'ultimate-multisite'), + 'desc' => __('Allow customers to setup custom domains.', 'ultimate-multisite'), 'icon' => 'dashicons-wu-link1', 'v-show' => "get_state_value('product_type', 'none') !== 'service'", 'state' => [ @@ -486,9 +482,33 @@ public function add_limitation_sections($sections, $object_model) { $sections['custom_domain']['fields']['custom_domain_override'] = $this->override_notice($object_model->get_limitations(false)->domain_mapping->has_own_enabled(), ['allow_domain_mapping']); } + $sections['hide_credits'] = [ + 'title' => __('Hide Credits', 'ultimate-multisite'), + 'desc' => __('Hide the "Powered By" footer credits.', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-eye-off', + 'v-show' => "get_state_value('product_type', 'none') !== 'service'", + 'state' => [ + 'allow_hide_credits' => $object_model->get_limitations()->hide_credits->is_enabled(), + ], + 'fields' => [ + 'modules[hide_credits][enabled]' => [ + 'type' => 'toggle', + 'title' => __('Hide Footer Credits', 'ultimate-multisite'), + 'desc' => __('Toggle this option on to hide the "Powered by..." footer credits on this plan.', 'ultimate-multisite'), + 'value' => $object_model->get_limitations()->hide_credits->is_enabled(), + 'wrapper_html_attr' => [ + 'v-cloak' => '1', + ], + 'html_attr' => [ + 'v-model' => 'allow_hide_credits', + ], + ], + ], + ]; + $sections['allowed_themes'] = [ 'title' => __('Themes', 'ultimate-multisite'), - 'desc' => __('Limit the number of users on each role, posts, pages, and more.', 'ultimate-multisite'), + 'desc' => __('You can choose which themes are allowed to be used on the platform.', 'ultimate-multisite'), 'icon' => 'dashicons-wu-palette', 'v-show' => "get_state_value('product_type', 'none') !== 'service'", 'state' => [ diff --git a/inc/models/class-customer.php b/inc/models/class-customer.php index 7a377d15d..268ea3d72 100644 --- a/inc/models/class-customer.php +++ b/inc/models/class-customer.php @@ -15,6 +15,7 @@ use WP_Ultimo\Models\Membership; use WP_Ultimo\Models\Site; use WP_Ultimo\Models\Payment; +use WP_User; // Exit if accessed directly defined('ABSPATH') || exit; @@ -129,7 +130,7 @@ class Customer extends Base_Model implements Billable, Notable { * @since 2.2.0 * @var string */ - public $_user; + public $_user; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore /** * Set the validation rules for this particular model. diff --git a/inc/objects/class-limitations.php b/inc/objects/class-limitations.php index c6085b9f6..8172e7a5e 100644 --- a/inc/objects/class-limitations.php +++ b/inc/objects/class-limitations.php @@ -20,6 +20,18 @@ * This class centralizes the limitation modules. * * @since 2.0.0 + * + * @property-read \WP_Ultimo\Limitations\Limit_Post_Types $post_types + * @property-read \WP_Ultimo\Limitations\Limit_Plugins $plugins + * @property-read \WP_Ultimo\Limitations\Limit_Sites $sites + * @property-read \WP_Ultimo\Limitations\Limit_Themes $themes + * @property-read \WP_Ultimo\Limitations\Limit_Visits $visits + * @property-read \WP_Ultimo\Limitations\Limit_Disk_Space $disk_space + * @property-read \WP_Ultimo\Limitations\Limit_Users $users + * @property-read \WP_Ultimo\Limitations\Limit_Site_Templates $site_templates + * @property-read \WP_Ultimo\Limitations\Limit_Domain_Mapping $domain_mapping + * @property-read \WP_Ultimo\Limitations\Limit_Customer_User_Role $customer_user_role + * @property-read \WP_Ultimo\Limitations\Limit_Hide_Footer_Credits $hide_credits */ class Limitations { @@ -225,8 +237,7 @@ public function merge($override = false, ...$limitations) { $results = $this->to_array(); foreach ($limitations as $limitation) { - if (is_a($limitation, self::class)) { // @phpstan-ignore-line - + if (is_a($limitation, self::class)) { $limitation = $limitation->to_array(); } @@ -482,6 +493,7 @@ public static function repository() { 'site_templates' => \WP_Ultimo\Limitations\Limit_Site_Templates::class, 'domain_mapping' => \WP_Ultimo\Limitations\Limit_Domain_Mapping::class, 'customer_user_role' => \WP_Ultimo\Limitations\Limit_Customer_User_Role::class, + 'hide_credits' => \WP_Ultimo\Limitations\Limit_Hide_Footer_Credits::class, ]; return apply_filters('wu_limit_classes', $classes); diff --git a/inc/ui/class-account-summary-element.php b/inc/ui/class-account-summary-element.php index 68e111f17..ccaf8d5a0 100644 --- a/inc/ui/class-account-summary-element.php +++ b/inc/ui/class-account-summary-element.php @@ -296,7 +296,7 @@ public function output($atts, $content = null): void { // Return empty if no site available (e.g., during SEO processing) if ( ! $this->site) { - return ; + return; } $atts = array_merge($atts, $this->atts); diff --git a/inc/ui/class-base-element.php b/inc/ui/class-base-element.php index c29a1fa85..9eb05a0b0 100644 --- a/inc/ui/class-base-element.php +++ b/inc/ui/class-base-element.php @@ -528,7 +528,8 @@ protected function contains_current_element($content, $post = null) { * @since 2.0.0 * @param bool $contains_elements If the element is contained on the content. * @param string $content The content being examined. - * @param self The current element. + * @param self $element The current element. + * @param null|\WP_Post $post post to check. */ return apply_filters('wu_contains_element', $contains_element, $content, $this, $post); } @@ -1005,15 +1006,17 @@ public function display($atts) { } /** - * @param $atts + * Get's the content of the element as a string. + * + * @param array $atts The element attributes. * * @return string */ - public function get_content($atts): string { - ob_start(); - $this->display($atts); - return ob_get_clean(); - } + public function get_content($atts): string { + ob_start(); + $this->display($atts); + return ob_get_clean(); + } /** * Retrieves a cleaned up version of the content. diff --git a/package.json b/package.json index 6378e924f..b8450b611 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "2.4.4", + "version": "2.4.5", "name": "ultimate-multisite", "title": "Ultimate Multisite", "homepage": "https://ultimatemultisite.com/", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b54b92b40..ff17651bc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,6 +2,10 @@ parameters: level: 0 inferPrivatePropertyTypeFromConstructor: true treatPhpDocTypesAsCertain: false + bootstrapFiles: + - vendor/szepeviktor/phpstan-wordpress/bootstrap.php + scanDirectories: + - vendor/woocommerce/action-scheduler paths: - ./views - ./inc @@ -9,4 +13,7 @@ parameters: ignoreErrors: - message: '#Variable \$.* might not be defined.#' - path: ./views/* \ No newline at end of file + path: ./views/* + - + message: '#Path in require_once\(\) "\./?/wp-admin/includes/.*" is not a file or it does not exist\.#' + path: ./inc/* \ No newline at end of file diff --git a/readme.txt b/readme.txt index 84af8ccad..0adc0cd7d 100644 --- a/readme.txt +++ b/readme.txt @@ -6,7 +6,7 @@ Requires at least: 5.3 Requires PHP: 7.4.30 Tested up to: 6.8 -Stable tag: 2.4.4 +Stable tag: 2.4.5 License: GPLv2 License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -14,20 +14,58 @@ The Complete Network Solution for transforming your WordPress Multisite into a W == Description == -**Ultimate Multisite** helps you transform your WordPress Multisite installation into a powerful Website as a Service (WaaS) platform. This plugin enables you to offer website creation, hosting, and management services to your customers through a streamlined interface. +**Ultimate Multisite** turns your WordPress Multisite into a full WaaS (Website as a Service) platform—so you can sell plans, provision new sites from templates, map custom domains, and manage customers and billing with confidence. -This plugin was formerly known as WP Ultimo and is now community maintained. +Launch niche site builders, productized services, or large, branded networks without stitching together dozens of tools. From checkout to provisioning to ongoing management, Ultimate Multisite gives you the building blocks to create a modern, scalable website platform on top of WordPress. + +Formerly known as WP Ultimo, now community‑maintained and actively improved. = Key Features = -* **Site Creation** - Allow customers to create their own sites in your network -* **Domain Mapping** - Support for custom domains with automated DNS verification -* **Payment Processing** - Integrations with popular payment gateways like Stripe and PayPal -* **Plan Management** - Create and manage subscription plans with different features and limitations -* **Template Sites** - Easily clone and use template sites for new customer websites -* **Customer Dashboard** - Provide a professional management interface for your customers -* **White Labeling** - Brand the platform as your own -* **Hosting Integrations** - Connect with popular hosting control panels like cPanel, RunCloud, and more +Build, sell, and scale with a feature set designed for WaaS operators: + +- **Fast Site Creation** – Self‑serve signup that provisions new sites instantly from templates +- **Domain Mapping** – Custom domains with automated DNS verification and clear guidance +- **Payments & Subscriptions** – Stripe and PayPal support for recurring plans and one‑time fees +- **Flexible Plans & Limits** – Package features and enforce quotas/limitations across your network +- **Template Library** – Create high‑converting templates your customers can launch in minutes +- **Customer Dashboard** – Clean, branded UI for managing billing, sites, domains, and settings +- **White‑Label Ready** – Rename, rebrand, and tailor the experience to your business +- **Hosting Integrations** – Cloudflare, GridPane, Cloudways, WPMU DEV, and more +- **Developer‑Friendly** – Hooks, filters, and an add‑on system for deep customization + += Who Is It For? = + +- Agencies productizing WordPress into packages and recurring plans +- Creators launching niche site builders (local business sites, portfolios, courses, communities) +- Hosts and MSPs offering white‑label WordPress at scale +- Franchises, universities, and multi‑location brands with many similar sites +- Internal teams rolling out microsites and campaigns on shared infrastructure + += Popular Use Cases = + +- Productized website services with recurring billing and templates +- Franchise and multi‑location networks with brand‑consistent starter sites +- “Site builder” offerings for a specific industry or niche +- Private networks for internal departments, events, or communities +- Educational institutions provisioning class, club, or program sites + += Why Ultimate Multisite = + +- **Open & Community‑Maintained** – Transparent development, active updates +- **WordPress‑Native** – Built specifically for Multisite; no heavy SaaS lock‑in +- **Proven Architecture** – Templates, plans, and domain mapping built‑in +- **Extensible** – Add‑on system, actions/filters, and hosting integrations +- **Owned Infrastructure** – Run your WaaS on your stack, your way + += Try It In Minutes = + +1. Enable WordPress Multisite on a staging site +2. Install and Network Activate Ultimate Multisite +3. Run the setup wizard, create a plan, and add a template +4. Share your signup page and start selling + +Ready to build your WaaS? Install the plugin and launch your first customer site today. = Where to find help = @@ -193,6 +231,10 @@ We recommend running this in a staging environment before updating your producti == Changelog == +Version [2.4.5] - Released on 2025-09-23 +Fixed: Unable to setup integrations. +Fixed: Custom domain check when downgrading. + Version [2.4.4] - Released on 2025-09-17 - Fixed: Saving email templates without stripping html - New: Option to allow site owners to edit users on their site diff --git a/scripts/translate.php b/scripts/translate.php index f9b3c3994..eb194d3a9 100644 --- a/scripts/translate.php +++ b/scripts/translate.php @@ -1,317 +1,317 @@ 'Arabic', - 'bg' => 'Bulgarian', - 'cs' => 'Czech', - 'da' => 'Danish', - 'de' => 'German', - 'el' => 'Greek', - 'en-GB' => 'English (British)', - 'en-US' => 'English (American)', - 'es' => 'Spanish', - 'et' => 'Estonian', - 'fi' => 'Finnish', - 'fr' => 'French', - 'hu' => 'Hungarian', - 'id' => 'Indonesian', - 'it' => 'Italian', - 'ja' => 'Japanese', - 'ko' => 'Korean', - 'lt' => 'Lithuanian', - 'lv' => 'Latvian', - 'nb' => 'Norwegian (Bokmål)', - 'nl' => 'Dutch', - 'pl' => 'Polish', - 'pt-BR' => 'Portuguese (Brazilian)', - 'pt-PT' => 'Portuguese (European)', - 'ro' => 'Romanian', - 'ru' => 'Russian', - 'sk' => 'Slovak', - 'sl' => 'Slovenian', - 'sv' => 'Swedish', - 'tr' => 'Turkish', - 'uk' => 'Ukrainian', - 'zh-CN' => 'Chinese (Simplified)', - 'zh-TW' => 'Chinese (Traditional)' - ]; - - /** - * WordPress locale mapping for DeepL language codes - */ - private $wp_locale_mapping = [ - 'ar' => 'ar', - 'bg' => 'bg_BG', - 'cs' => 'cs_CZ', - 'da' => 'da_DK', - 'de' => 'de_DE', - 'el' => 'el', - 'en-GB' => 'en_GB', - 'en-US' => 'en_US', - 'es' => 'es_ES', - 'et' => 'et', - 'fi' => 'fi', - 'fr' => 'fr_FR', - 'hu' => 'hu_HU', - 'id' => 'id_ID', - 'it' => 'it_IT', - 'ja' => 'ja', - 'ko' => 'ko_KR', - 'lt' => 'lt_LT', - 'lv' => 'lv', - 'nb' => 'nb_NO', - 'nl' => 'nl_NL', - 'pl' => 'pl_PL', - 'pt-BR' => 'pt_BR', - 'pt-PT' => 'pt_PT', - 'ro' => 'ro_RO', - 'ru' => 'ru_RU', - 'sk' => 'sk_SK', - 'sl' => 'sl_SI', - 'sv' => 'sv_SE', - 'tr' => 'tr_TR', - 'uk' => 'uk', - 'zh-CN' => 'zh_CN', - 'zh-TW' => 'zh_TW' - ]; - - private $source_pot_file; - private $lang_dir; - private $api_key; - private $force; - private $vendor_dir; - - public function __construct($api_key = null, $force = false) { - $this->source_pot_file = dirname(__DIR__) . '/lang/ultimate-multisite.pot'; - $this->lang_dir = dirname(__DIR__) . '/lang'; - $this->vendor_dir = dirname(__DIR__) . '/vendor'; - $this->api_key = $api_key ?: getenv('DEEPL_API_KEY'); - $this->force = $force; - - if (!$this->api_key) { - throw new Exception('DeepL API key is required. Set DEEPL_API_KEY environment variable or use --api-key parameter.'); - } - - if (!file_exists($this->source_pot_file)) { - throw new Exception("Source POT file not found: {$this->source_pot_file}"); - } - - if (!is_dir($this->vendor_dir)) { - throw new Exception("Vendor directory not found. Run 'composer install' first."); - } - } - - public function translateAll() { - echo "Starting translation process for Ultimate Multisite\n"; - echo "Source POT: {$this->source_pot_file}\n"; - echo "Output directory: {$this->lang_dir}\n\n"; - - $success_count = 0; - $total_count = count($this->deepl_languages); - - foreach ($this->deepl_languages as $deepl_code => $language_name) { - // Skip English since that's our source language - if (strpos($deepl_code, 'en') === 0) { - continue; - } - - $wp_locale = $this->wp_locale_mapping[$deepl_code]; - $output_file = $this->lang_dir . '/ultimate-multisite-' . $wp_locale . '.po'; - - echo "Translating to {$language_name} ({$deepl_code} -> {$wp_locale})... "; - - // Skip if file exists and we're not forcing re-translation - if (file_exists($output_file) && !$this->force) { - echo "SKIPPED (file exists, use --force to overwrite)\n"; - continue; - } - - try { - $result = $this->translateLanguage($deepl_code, $output_file); - if ($result) { - echo "SUCCESS\n"; - $success_count++; - } else { - echo "FAILED\n"; - } - } catch (Exception $e) { - echo "ERROR: " . $e->getMessage() . "\n"; - } - } - - echo "\nTranslation complete!\n"; - echo "Successfully translated: {$success_count}/" . ($total_count - 2) . " languages\n"; // -2 for English variants - } - - private function translateLanguage($deepl_code, $output_file) { - // Try to find potrans binary in multiple locations - $potrans_binary = $this->findPotrancBinary(); - - if (!$potrans_binary) { - throw new Exception("potrans binary not found. Install it globally via 'composer global require om/potrans' or set POTRANS_PATH environment variable."); - } - - $cmd = sprintf( - 'php %s deepl %s %s --apikey=%s --target-lang=%s --source-lang=EN', - escapeshellarg($potrans_binary), - escapeshellarg($this->source_pot_file), - escapeshellarg(dirname($output_file)), - escapeshellarg($this->api_key), - escapeshellarg($deepl_code) - ); - - if ($this->force) { - $cmd .= ' --force'; - } - - // Execute the translation command - $output = []; - $return_code = 0; - exec($cmd . ' 2>&1', $output, $return_code); - - if ($return_code !== 0) { - throw new Exception("potrans command failed: " . implode("\n", $output)); - } - - // potrans outputs with different naming, we need to rename the file - $potrans_output = dirname($output_file) . '/' . basename($this->source_pot_file, '.pot') . '-' . strtolower(str_replace('-', '_', $deepl_code)) . '.po'; - - if (file_exists($potrans_output) && $potrans_output !== $output_file) { - rename($potrans_output, $output_file); - } - - return file_exists($output_file); - } - - public function generateMoFiles() { - echo "Generating .mo files from .po files...\n"; - - $po_files = glob($this->lang_dir . '/ultimate-multisite-*.po'); - $success_count = 0; - - foreach ($po_files as $po_file) { - $mo_file = str_replace('.po', '.mo', $po_file); - echo "Generating " . basename($mo_file) . "... "; - - $cmd = sprintf('msgfmt %s -o %s', escapeshellarg($po_file), escapeshellarg($mo_file)); - $output = []; - $return_code = 0; - exec($cmd . ' 2>&1', $output, $return_code); - - if ($return_code === 0 && file_exists($mo_file)) { - echo "SUCCESS\n"; - $success_count++; - } else { - echo "FAILED\n"; - } - } - - echo "Generated {$success_count} .mo files\n"; - } - - private function findPotrancBinary() { - // 1. Check POTRANS_PATH environment variable - $env_path = getenv('POTRANS_PATH'); - if ($env_path && file_exists($env_path)) { - return $env_path; - } - - // 2. Check vendor/bin/potrans (local installation) - $local_path = $this->vendor_dir . '/bin/potrans'; - if (file_exists($local_path)) { - return $local_path; - } - - // 3. Check global composer installation - $home = getenv('HOME') ?: getenv('USERPROFILE'); - if ($home) { - $global_vendor_path = $home . '/.composer/vendor/bin/potrans'; - if (file_exists($global_vendor_path)) { - return $global_vendor_path; - } - - // Alternative global composer path - $global_vendor_path2 = $home . '/.config/composer/vendor/bin/potrans'; - if (file_exists($global_vendor_path2)) { - return $global_vendor_path2; - } - } - - // 4. Check if potrans is in PATH - $which_result = null; - $return_code = 0; - exec('which potrans 2>/dev/null', $which_result, $return_code); - if ($return_code === 0 && !empty($which_result[0]) && file_exists($which_result[0])) { - return $which_result[0]; - } - - // 5. Try whereis on Linux systems - $whereis_result = null; - $return_code = 0; - exec('whereis potrans 2>/dev/null', $whereis_result, $return_code); - if ($return_code === 0 && !empty($whereis_result[0])) { - $parts = explode(' ', $whereis_result[0]); - if (count($parts) > 1 && file_exists($parts[1])) { - return $parts[1]; - } - } - - return false; - } + /** + * DeepL supported languages with their locale codes + * Based on DeepL API documentation as of 2025 + */ + private $deepl_languages = [ + 'ar' => 'Arabic', + 'bg' => 'Bulgarian', + 'cs' => 'Czech', + 'da' => 'Danish', + 'de' => 'German', + 'el' => 'Greek', + 'en-GB' => 'English (British)', + 'en-US' => 'English (American)', + 'es' => 'Spanish', + 'et' => 'Estonian', + 'fi' => 'Finnish', + 'fr' => 'French', + 'hu' => 'Hungarian', + 'id' => 'Indonesian', + 'it' => 'Italian', + 'ja' => 'Japanese', + 'ko' => 'Korean', + 'lt' => 'Lithuanian', + 'lv' => 'Latvian', + 'nb' => 'Norwegian (Bokmål)', + 'nl' => 'Dutch', + 'pl' => 'Polish', + 'pt-BR' => 'Portuguese (Brazilian)', + 'pt-PT' => 'Portuguese (European)', + 'ro' => 'Romanian', + 'ru' => 'Russian', + 'sk' => 'Slovak', + 'sl' => 'Slovenian', + 'sv' => 'Swedish', + 'tr' => 'Turkish', + 'uk' => 'Ukrainian', + 'zh-CN' => 'Chinese (Simplified)', + 'zh-TW' => 'Chinese (Traditional)', + ]; + + /** + * WordPress locale mapping for DeepL language codes + */ + private $wp_locale_mapping = [ + 'ar' => 'ar', + 'bg' => 'bg_BG', + 'cs' => 'cs_CZ', + 'da' => 'da_DK', + 'de' => 'de_DE', + 'el' => 'el', + 'en-GB' => 'en_GB', + 'en-US' => 'en_US', + 'es' => 'es_ES', + 'et' => 'et', + 'fi' => 'fi', + 'fr' => 'fr_FR', + 'hu' => 'hu_HU', + 'id' => 'id_ID', + 'it' => 'it_IT', + 'ja' => 'ja', + 'ko' => 'ko_KR', + 'lt' => 'lt_LT', + 'lv' => 'lv', + 'nb' => 'nb_NO', + 'nl' => 'nl_NL', + 'pl' => 'pl_PL', + 'pt-BR' => 'pt_BR', + 'pt-PT' => 'pt_PT', + 'ro' => 'ro_RO', + 'ru' => 'ru_RU', + 'sk' => 'sk_SK', + 'sl' => 'sl_SI', + 'sv' => 'sv_SE', + 'tr' => 'tr_TR', + 'uk' => 'uk', + 'zh-CN' => 'zh_CN', + 'zh-TW' => 'zh_TW', + ]; + + private $source_pot_file; + private $lang_dir; + private $api_key; + private $force; + private $vendor_dir; + + public function __construct($api_key = null, $force = false) { + $this->source_pot_file = dirname(__DIR__) . '/lang/ultimate-multisite.pot'; + $this->lang_dir = dirname(__DIR__) . '/lang'; + $this->vendor_dir = dirname(__DIR__) . '/vendor'; + $this->api_key = $api_key ?: getenv('DEEPL_API_KEY'); + $this->force = $force; + + if (! $this->api_key) { + throw new Exception('DeepL API key is required. Set DEEPL_API_KEY environment variable or use --api-key parameter.'); + } + + if (! file_exists($this->source_pot_file)) { + throw new Exception("Source POT file not found: {$this->source_pot_file}"); + } + + if (! is_dir($this->vendor_dir)) { + throw new Exception("Vendor directory not found. Run 'composer install' first."); + } + } + + public function translateAll() { + echo "Starting translation process for Ultimate Multisite\n"; + echo "Source POT: {$this->source_pot_file}\n"; + echo "Output directory: {$this->lang_dir}\n\n"; + + $success_count = 0; + $total_count = count($this->deepl_languages); + + foreach ($this->deepl_languages as $deepl_code => $language_name) { + // Skip English since that's our source language + if (strpos($deepl_code, 'en') === 0) { + continue; + } + + $wp_locale = $this->wp_locale_mapping[ $deepl_code ]; + $output_file = $this->lang_dir . '/ultimate-multisite-' . $wp_locale . '.po'; + + echo "Translating to {$language_name} ({$deepl_code} -> {$wp_locale})... "; + + // Skip if file exists and we're not forcing re-translation + if (file_exists($output_file) && ! $this->force) { + echo "SKIPPED (file exists, use --force to overwrite)\n"; + continue; + } + + try { + $result = $this->translateLanguage($deepl_code, $output_file); + if ($result) { + echo "SUCCESS\n"; + ++$success_count; + } else { + echo "FAILED\n"; + } + } catch (Exception $e) { + echo 'ERROR: ' . $e->getMessage() . "\n"; + } + } + + echo "\nTranslation complete!\n"; + echo "Successfully translated: {$success_count}/" . ($total_count - 2) . " languages\n"; // -2 for English variants + } + + private function translateLanguage($deepl_code, $output_file) { + // Try to find potrans binary in multiple locations + $potrans_binary = $this->findPotrancBinary(); + + if (! $potrans_binary) { + throw new Exception("potrans binary not found. Install it globally via 'composer global require om/potrans' or set POTRANS_PATH environment variable."); + } + + $cmd = sprintf( + 'php %s deepl %s %s --apikey=%s --target-lang=%s --source-lang=EN', + escapeshellarg($potrans_binary), + escapeshellarg($this->source_pot_file), + escapeshellarg(dirname($output_file)), + escapeshellarg($this->api_key), + escapeshellarg($deepl_code) + ); + + if ($this->force) { + $cmd .= ' --force'; + } + + // Execute the translation command + $output = []; + $return_code = 0; + exec($cmd . ' 2>&1', $output, $return_code); + + if ($return_code !== 0) { + throw new Exception('potrans command failed: ' . implode("\n", $output)); + } + + // potrans outputs with different naming, we need to rename the file + $potrans_output = dirname($output_file) . '/' . basename($this->source_pot_file, '.pot') . '-' . strtolower(str_replace('-', '_', $deepl_code)) . '.po'; + + if (file_exists($potrans_output) && $potrans_output !== $output_file) { + rename($potrans_output, $output_file); + } + + return file_exists($output_file); + } + + public function generateMoFiles() { + echo "Generating .mo files from .po files...\n"; + + $po_files = glob($this->lang_dir . '/ultimate-multisite-*.po'); + $success_count = 0; + + foreach ($po_files as $po_file) { + $mo_file = str_replace('.po', '.mo', $po_file); + echo 'Generating ' . basename($mo_file) . '... '; + + $cmd = sprintf('msgfmt %s -o %s', escapeshellarg($po_file), escapeshellarg($mo_file)); + $output = []; + $return_code = 0; + exec($cmd . ' 2>&1', $output, $return_code); + + if ($return_code === 0 && file_exists($mo_file)) { + echo "SUCCESS\n"; + ++$success_count; + } else { + echo "FAILED\n"; + } + } + + echo "Generated {$success_count} .mo files\n"; + } + + private function findPotrancBinary() { + // 1. Check POTRANS_PATH environment variable + $env_path = getenv('POTRANS_PATH'); + if ($env_path && file_exists($env_path)) { + return $env_path; + } + + // 2. Check vendor/bin/potrans (local installation) + $local_path = $this->vendor_dir . '/bin/potrans'; + if (file_exists($local_path)) { + return $local_path; + } + + // 3. Check global composer installation + $home = getenv('HOME') ?: getenv('USERPROFILE'); + if ($home) { + $global_vendor_path = $home . '/.composer/vendor/bin/potrans'; + if (file_exists($global_vendor_path)) { + return $global_vendor_path; + } + + // Alternative global composer path + $global_vendor_path2 = $home . '/.config/composer/vendor/bin/potrans'; + if (file_exists($global_vendor_path2)) { + return $global_vendor_path2; + } + } + + // 4. Check if potrans is in PATH + $which_result = null; + $return_code = 0; + exec('which potrans 2>/dev/null', $which_result, $return_code); + if ($return_code === 0 && ! empty($which_result[0]) && file_exists($which_result[0])) { + return $which_result[0]; + } + + // 5. Try whereis on Linux systems + $whereis_result = null; + $return_code = 0; + exec('whereis potrans 2>/dev/null', $whereis_result, $return_code); + if ($return_code === 0 && ! empty($whereis_result[0])) { + $parts = explode(' ', $whereis_result[0]); + if (count($parts) > 1 && file_exists($parts[1])) { + return $parts[1]; + } + } + + return false; + } } // Parse command line arguments $api_key = null; -$force = false; -$help = false; +$force = false; +$help = false; for ($i = 1; $i < $argc; $i++) { - $arg = $argv[$i]; - - if (strpos($arg, '--api-key=') === 0) { - $api_key = substr($arg, strlen('--api-key=')); - } elseif ($arg === '--force') { - $force = true; - } elseif ($arg === '--help' || $arg === '-h') { - $help = true; - } + $arg = $argv[ $i ]; + + if (strpos($arg, '--api-key=') === 0) { + $api_key = substr($arg, strlen('--api-key=')); + } elseif ($arg === '--force') { + $force = true; + } elseif ($arg === '--help' || $arg === '-h') { + $help = true; + } } if ($help) { - echo "Ultimate Multisite Translation Script\n"; - echo "=====================================\n\n"; - echo "Usage: php scripts/translate.php [options]\n\n"; - echo "Options:\n"; - echo " --api-key=KEY DeepL API key (or set DEEPL_API_KEY environment variable)\n"; - echo " --force Force re-translation of existing files\n"; - echo " --help Show this help message\n\n"; - echo "This script will:\n"; - echo "1. Translate the main POT file to all DeepL supported languages\n"; - echo "2. Generate .mo files from the translated .po files\n\n"; - exit(0); + echo "Ultimate Multisite Translation Script\n"; + echo "=====================================\n\n"; + echo "Usage: php scripts/translate.php [options]\n\n"; + echo "Options:\n"; + echo " --api-key=KEY DeepL API key (or set DEEPL_API_KEY environment variable)\n"; + echo " --force Force re-translation of existing files\n"; + echo " --help Show this help message\n\n"; + echo "This script will:\n"; + echo "1. Translate the main POT file to all DeepL supported languages\n"; + echo "2. Generate .mo files from the translated .po files\n\n"; + exit(0); } try { - $translator = new MultisiteUltimateTranslator($api_key, $force); - $translator->translateAll(); - $translator->generateMoFiles(); - - echo "\nAll done! 🎉\n"; + $translator = new MultisiteUltimateTranslator($api_key, $force); + $translator->translateAll(); + $translator->generateMoFiles(); + + echo "\nAll done! 🎉\n"; } catch (Exception $e) { - echo "Error: " . $e->getMessage() . "\n"; - exit(1); -} \ No newline at end of file + echo 'Error: ' . $e->getMessage() . "\n"; + exit(1); +} diff --git a/ultimate-multisite.php b/ultimate-multisite.php index ab1035ecc..5daffa095 100644 --- a/ultimate-multisite.php +++ b/ultimate-multisite.php @@ -4,7 +4,7 @@ * Description: Transform your WordPress Multisite into a Website as a Service (WaaS) platform supporting site cloning, re-selling, and domain mapping integrations with many hosting providers. * Plugin URI: https://multisiteultimate.com * Text Domain: ultimate-multisite - * Version: 2.4.4 + * Version: 2.4.5 * Author: Ultimate Multisite Community * Author URI: https://github.com/superdav42/wp-multisite-waas * GitHub Plugin URI: https://github.com/superdav42/wp-multisite-waas @@ -30,7 +30,7 @@ * @author Arindo Duque and NextPress and the Ultimate Multisite Community * @category Core * @package WP_Ultimo - * @version 2.4.4 + * @version 2.4.5 */ // Exit if accessed directly diff --git a/views/base/addons.php b/views/base/addons.php index fc95e1095..d7b11a2c1 100644 --- a/views/base/addons.php +++ b/views/base/addons.php @@ -49,7 +49,7 @@ * Allow plugin developers to add additional buttons to list pages * * @since 1.8.2 - * @param WU_Page WP Ultimo Page instance + * @param WU_Page $page WP Ultimo Page instance */ do_action('wu_page_addon_after_title', $page); ?> @@ -279,7 +279,7 @@ class="wu-text-center wu-py-12" * Allow plugin developers to add scripts to the bottom of the page * * @since 1.8.2 - * @param WU_Page WP Ultimo Page instance + * @param WU_Page $page WP Ultimo Page instance */ do_action('wu_page_addon_footer', $page); ?> diff --git a/views/base/dash.php b/views/base/dash.php index fe3f926ec..23b2531ee 100644 --- a/views/base/dash.php +++ b/views/base/dash.php @@ -44,7 +44,7 @@ * Allow plugin developers to add additional buttons to list pages * * @since 1.8.2 - * @param WU_Page Ultimate Multisite Page instance + * @param WU_Page $page Ultimate Multisite Page instance */ do_action('wu_page_dash_after_title', $page); ?> diff --git a/views/base/edit.php b/views/base/edit.php index 9fee2b623..a87215c91 100644 --- a/views/base/edit.php +++ b/views/base/edit.php @@ -78,8 +78,8 @@ * Allow plugin developers to add additional information below the text input * * @since 1.8.2 - * @param object Object holding the information - * @param WU_Page Ultimate Multisite Page instance + * @param object $object Object holding the information + * @param WU_Page $page Ultimate Multisite Page instance */ do_action('wu_edit_page_after_title_input', $object, $page); ?> @@ -124,7 +124,7 @@ * Allow plugin developers to add new metaboxes * * @since 1.8.2 - * @param object Object being edited right now + * @param object $object Object being edited right now */ do_meta_boxes($screen->id, 'side', $object); ?> @@ -136,7 +136,7 @@ * Allow plugin developers to add new metaboxes * * @since 1.8.2 - * @param object Object being edited right now + * @param object $object Object being edited right now */ do_meta_boxes($screen->id, 'side-bottom', $object); ?> @@ -153,7 +153,7 @@ * Allow plugin developers to add new metaboxes * * @since 1.8.2 - * @param object Object being edited right now + * @param object $object Object being edited right now */ do_meta_boxes($screen->id, 'normal', $object); @@ -161,7 +161,7 @@ * Allow developers to add additional elements after the modals are printed. * * @since 2.0.0 - * @param object Object being edited right now + * @param object $object Object being edited right now */ do_action("wu_edit_{$screen->id}_after_normal", $object); @@ -171,7 +171,7 @@ * Allow plugin developers to add new metaboxes * * @since 1.8.2 - * @param object Object being edited right now + * @param object $object Object being edited right now */ do_meta_boxes($screen->id, 'advanced', $object); @@ -212,8 +212,8 @@ * Allow plugin developers to add scripts to the bottom of the page * * @since 1.8.2 - * @param object Object holding the information - * @param WU_Page Ultimate Multisite Page instance + * @param object $object Object holding the information + * @param WU_Page $page Ultimate Multisite Page instance */ do_action('wu_page_edit_footer', $object, $page); ?> diff --git a/views/base/list.php b/views/base/list.php index 90d3bb6c0..15a9cd087 100644 --- a/views/base/list.php +++ b/views/base/list.php @@ -45,7 +45,7 @@ * Allow plugin developers to add additional buttons to list pages * * @since 1.8.2 - * @param WU_Page Ultimate Multisite Page instance + * @param WU_Page $page Ultimate Multisite Page instance */ do_action('wu_page_list_after_title', $page); ?> @@ -110,7 +110,7 @@ * Allow plugin developers to add scripts to the bottom of the page * * @since 1.8.2 - * @param WU_Page Ultimate Multisite Page instance + * @param WU_Page $page Ultimate Multisite Page instance */ do_action('wu_page_list_footer', $page); ?> diff --git a/views/base/responsive-table-row.php b/views/base/responsive-table-row.php index 1c5c77b4a..d8121c1f1 100644 --- a/views/base/responsive-table-row.php +++ b/views/base/responsive-table-row.php @@ -55,31 +55,44 @@ $w_classes = wu_get_isset($item, 'wrapper_classes', ''); ?> - + - > + > - + - + - - + - > + - + > - + - + - + + + diff --git a/views/base/settings.php b/views/base/settings.php index 936013f36..1505a3544 100644 --- a/views/base/settings.php +++ b/views/base/settings.php @@ -45,7 +45,7 @@ * Allow plugin developers to add additional buttons to list pages * * @since 1.8.2 - * @param WU_Page Ultimate Multisite Page instance + * @param WU_Page $page Ultimate Multisite Page instance */ do_action('wu_page_wizard_after_title', $page); ?> @@ -291,7 +291,7 @@ class="wu-block wu-py-2 wu-px-4 wu-no-underline wu-text-sm wu-rounded diff --git a/views/checkout/templates/template-selection/clean.php b/views/checkout/templates/template-selection/clean.php index f38981dbf..67570651b 100644 --- a/views/checkout/templates/template-selection/clean.php +++ b/views/checkout/templates/template-selection/clean.php @@ -102,6 +102,7 @@
@@ -134,7 +135,7 @@ class="wu-site-template-selector wu-cursor-pointer wu-no-underline"
-