From 13e948cc829d1ae04049d66df09a695115f7c261 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 20 Sep 2025 09:03:38 -0600 Subject: [PATCH 01/26] Add options for powered by --- inc/class-credits.php | 239 ++++++++++++++++++++++++++++++++++++++++ inc/class-wp-ultimo.php | 5 + 2 files changed, 244 insertions(+) create mode 100644 inc/class-credits.php diff --git a/inc/class-credits.php b/inc/class-credits.php new file mode 100644 index 000000000..17503cf51 --- /dev/null +++ b/inc/class-credits.php @@ -0,0 +1,239 @@ + __('Footer Credits', 'ultimate-multisite'), + 'desc' => __('Optional footer attribution on the public site and admin. Per WordPress.org rules, this is opt-in and does not show by default.', 'ultimate-multisite'), + 'type' => 'header', + ], + 2000 + ); + + // Enable/disable powered by (global) + wu_register_settings_field( + 'general', + 'credits_enable', + [ + 'title' => __('Show "Powered by Ultimate Multisite"', 'ultimate-multisite'), + 'desc' => __('When enabled, a small credit replaces the default WordPress credit in admin and is added to the front-end footer. Default is OFF.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + ], + 2010 + ); + + // Allow custom text instead of the link + wu_register_settings_field( + 'general', + 'credits_custom_enable', + [ + 'title' => __('Use Custom Footer Text', 'ultimate-multisite'), + 'desc' => __('When enabled, use the custom text below instead of the default link.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + 'require' => [ + 'credits_enable' => 1, + ], + ], + 2020 + ); + + // Custom text value + wu_register_settings_field( + 'general', + 'credits_custom_text', + [ + 'title' => __('Custom Footer Text', 'ultimate-multisite'), + 'desc' => __('Supports {Network Name} placeholder. Example: "Powered by {Network Name}"', 'ultimate-multisite'), + 'type' => 'text', + 'default' => function () { + $network_name = get_network_option(null, 'site_name'); + $network_name = $network_name ? (string) $network_name : __('this network', 'ultimate-multisite'); + return sprintf(__('Powered by %s', 'ultimate-multisite'), $network_name); + }, + 'placeholder' => __('Powered by {Network Name}', 'ultimate-multisite'), + 'require' => [ + 'credits_enable' => 1, + 'credits_custom_enable' => 1, + ], + ], + 2030 + ); + + // Allow sites to remove (per-site opt-out) + wu_register_settings_field( + 'general', + 'credits_site_can_hide', + [ + 'title' => __('Allow Sites to Remove Credit', 'ultimate-multisite'), + 'desc' => __('When enabled, individual sites can opt out (if a per-site option is set).', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 1, + 'require' => [ + 'credits_enable' => 1, + ], + ], + 2040 + ); + } + + /** + * 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 ''; + } + + $use_custom = (bool) wu_get_setting('credits_custom_enable', 0); + + if ($use_custom) { + $text = (string) wu_get_setting('credits_custom_text', ''); + $network_name = (string) get_network_option(null, 'site_name'); + $text = str_replace('{Network Name}', $network_name ?: __('this network', 'ultimate-multisite'), $text); + return wp_kses_post($text); + } + + // Default: "Powered by Ultimate Multisite" with link (only when explicitly opted-in). + $label = esc_html__('Powered by', 'ultimate-multisite') . ' '; + $link = sprintf( + '%s', + esc_url('https://ultimatemultisite.com'), + esc_html__('Ultimate Multisite', 'ultimate-multisite') + ); + return $label . $link; + } + + /** + * Check if current site is allowed to show footer credit. + */ + protected function site_allows_credit(): bool { + // If network disables site removable option, then always allowed. + $allow_site_hide = (bool) wu_get_setting('credits_site_can_hide', 1); + if (! $allow_site_hide) { + return true; + } + + // Respect a per-site opt-out flag if present. + $blog_id = get_current_blog_id(); + $hidden = (bool) get_blog_option($blog_id, 'wu_hide_footer_credit', false); + return ! $hidden; + } + + /** + * Admin footer replacement. + */ + public function filter_admin_footer_text($text): string { + $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. + */ + public function filter_update_footer_text($text): string { + $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; + } + $credit = $this->build_credit_html(); + if (! $credit || ! $this->site_allows_credit()) { + return; + } + echo '
' . $credit . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Replace specific front-end theme strings like "Powered by WordPress" with our credit when enabled. + */ + public function filter_powered_by_wordpress_string($translation, $text, $domain) { + if (is_admin()) { + return $translation; + } + + $enabled = (bool) wu_get_setting('credits_enable', 0); + if (! $enabled || ! $this->site_allows_credit()) { + return $translation; + } + + // Only target common theme strings and only in default domain. + if ('default' !== $domain) { + return $translation; + } + + $targets = [ + 'Proudly powered by WordPress', + 'Powered by WordPress', + ]; + + if (in_array($text, $targets, true)) { + return wp_strip_all_tags($this->build_credit_html()); + } + + return $translation; + } +} + diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index cecd811d1..1fc6131bf 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -596,6 +596,11 @@ function () { */ \WP_Ultimo\Whitelabel::get_instance(); + /* + * Optional Footer Credits (opt-in, defaults OFF) + */ + \WP_Ultimo\Credits::get_instance(); + /* * Adds support to multiple accounts. * From 9a624df518491596ebc82bb0053cc748b413f504 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 23 Sep 2025 16:28:31 -0600 Subject: [PATCH 02/26] feat: bettter --- inc/class-credits.php | 424 +++++++++++++++++++++--------------------- 1 file changed, 208 insertions(+), 216 deletions(-) diff --git a/inc/class-credits.php b/inc/class-credits.php index 17503cf51..9b802fcc2 100644 --- a/inc/class-credits.php +++ b/inc/class-credits.php @@ -16,224 +16,216 @@ * Handles optional display of "Powered by" credits. * * - Opt-in via settings and setup wizard (default OFF). - * - Optional custom text with {Network Name} placeholder. + * - Optional custom HTML for the credit text. * - Optional allowance for per-site removal. */ class Credits { - use \WP_Ultimo\Traits\Singleton; - - /** - * Boot hooks. - */ - public function init(): void { - // Register settings into the General section so they show in wizard + settings page. - add_action('init', [$this, 'register_settings'], 20); - - // Hook admin footer replacement. - add_filter('admin_footer_text', [$this, 'filter_admin_footer_text'], 100); - add_filter('update_footer', [$this, 'filter_update_footer_text'], 100); - - // Hook front-end/footer rendering. - add_action('wp_footer', [$this, 'render_frontend_footer'], 100); - add_action('login_footer', [$this, 'render_frontend_footer'], 100); - - // Replace specific front-end theme strings like "Powered by WordPress" when opted-in. - add_filter('gettext', [$this, 'filter_powered_by_wordpress_string'], 10, 3); - } - - /** - * Register settings controls. - */ - public function register_settings(): void { - // Header - wu_register_settings_field( - 'general', - 'footer_credits_header', - [ - 'title' => __('Footer Credits', 'ultimate-multisite'), - 'desc' => __('Optional footer attribution on the public site and admin. Per WordPress.org rules, this is opt-in and does not show by default.', 'ultimate-multisite'), - 'type' => 'header', - ], - 2000 - ); - - // Enable/disable powered by (global) - wu_register_settings_field( - 'general', - 'credits_enable', - [ - 'title' => __('Show "Powered by Ultimate Multisite"', 'ultimate-multisite'), - 'desc' => __('When enabled, a small credit replaces the default WordPress credit in admin and is added to the front-end footer. Default is OFF.', 'ultimate-multisite'), - 'type' => 'toggle', - 'default' => 0, - ], - 2010 - ); - - // Allow custom text instead of the link - wu_register_settings_field( - 'general', - 'credits_custom_enable', - [ - 'title' => __('Use Custom Footer Text', 'ultimate-multisite'), - 'desc' => __('When enabled, use the custom text below instead of the default link.', 'ultimate-multisite'), - 'type' => 'toggle', - 'default' => 0, - 'require' => [ - 'credits_enable' => 1, - ], - ], - 2020 - ); - - // Custom text value - wu_register_settings_field( - 'general', - 'credits_custom_text', - [ - 'title' => __('Custom Footer Text', 'ultimate-multisite'), - 'desc' => __('Supports {Network Name} placeholder. Example: "Powered by {Network Name}"', 'ultimate-multisite'), - 'type' => 'text', - 'default' => function () { - $network_name = get_network_option(null, 'site_name'); - $network_name = $network_name ? (string) $network_name : __('this network', 'ultimate-multisite'); - return sprintf(__('Powered by %s', 'ultimate-multisite'), $network_name); - }, - 'placeholder' => __('Powered by {Network Name}', 'ultimate-multisite'), - 'require' => [ - 'credits_enable' => 1, - 'credits_custom_enable' => 1, - ], - ], - 2030 - ); - - // Allow sites to remove (per-site opt-out) - wu_register_settings_field( - 'general', - 'credits_site_can_hide', - [ - 'title' => __('Allow Sites to Remove Credit', 'ultimate-multisite'), - 'desc' => __('When enabled, individual sites can opt out (if a per-site option is set).', 'ultimate-multisite'), - 'type' => 'toggle', - 'default' => 1, - 'require' => [ - 'credits_enable' => 1, - ], - ], - 2040 - ); - } - - /** - * 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 ''; - } - - $use_custom = (bool) wu_get_setting('credits_custom_enable', 0); - - if ($use_custom) { - $text = (string) wu_get_setting('credits_custom_text', ''); - $network_name = (string) get_network_option(null, 'site_name'); - $text = str_replace('{Network Name}', $network_name ?: __('this network', 'ultimate-multisite'), $text); - return wp_kses_post($text); - } - - // Default: "Powered by Ultimate Multisite" with link (only when explicitly opted-in). - $label = esc_html__('Powered by', 'ultimate-multisite') . ' '; - $link = sprintf( - '%s', - esc_url('https://ultimatemultisite.com'), - esc_html__('Ultimate Multisite', 'ultimate-multisite') - ); - return $label . $link; - } - - /** - * Check if current site is allowed to show footer credit. - */ - protected function site_allows_credit(): bool { - // If network disables site removable option, then always allowed. - $allow_site_hide = (bool) wu_get_setting('credits_site_can_hide', 1); - if (! $allow_site_hide) { - return true; - } - - // Respect a per-site opt-out flag if present. - $blog_id = get_current_blog_id(); - $hidden = (bool) get_blog_option($blog_id, 'wu_hide_footer_credit', false); - return ! $hidden; - } - - /** - * Admin footer replacement. - */ - public function filter_admin_footer_text($text): string { - $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. - */ - public function filter_update_footer_text($text): string { - $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; - } - $credit = $this->build_credit_html(); - if (! $credit || ! $this->site_allows_credit()) { - return; - } - echo '
' . $credit . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } - - /** - * Replace specific front-end theme strings like "Powered by WordPress" with our credit when enabled. - */ - public function filter_powered_by_wordpress_string($translation, $text, $domain) { - if (is_admin()) { - return $translation; - } - - $enabled = (bool) wu_get_setting('credits_enable', 0); - if (! $enabled || ! $this->site_allows_credit()) { - return $translation; - } - - // Only target common theme strings and only in default domain. - if ('default' !== $domain) { - return $translation; - } - - $targets = [ - 'Proudly powered by WordPress', - 'Powered by WordPress', - ]; - - if (in_array($text, $targets, true)) { - return wp_strip_all_tags($this->build_credit_html()); - } - - return $translation; - } + use \WP_Ultimo\Traits\Singleton; + + /** + * Boot hooks. + */ + public function init(): void { + // Register settings into the General section so they show in wizard + settings page. + add_action('init', [$this, 'register_settings'], 20); + + // Hook admin footer replacement. + add_filter('admin_footer_text', [$this, 'filter_admin_footer_text'], 100); + add_filter('update_footer', [$this, 'filter_update_footer_text'], 100); + + // Hook front-end/footer rendering. + add_action('wp_footer', [$this, 'render_frontend_footer'], 100); + add_action('login_footer', [$this, 'render_frontend_footer'], 100); + } + + /** + * Register settings controls. + */ + public function register_settings(): void { + // Header + wu_register_settings_field( + 'general', + 'footer_credits_header', + [ + 'title' => __('Footer Credits', 'ultimate-multisite'), + 'desc' => __('Optional footer credit for public site and admin. Off by default.', 'ultimate-multisite'), + 'type' => 'header', + ], + 2000 + ); + + // Enable/disable powered by (global) + wu_register_settings_field( + 'general', + 'credits_enable', + [ + 'title' => __('Show "Powered by Ultimate Multisite"', 'ultimate-multisite'), + 'desc' => __('Adds a small credit in admin and front-end footers.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + ], + 2010 + ); + + // Allow custom text instead of the link + wu_register_settings_field( + 'general', + 'credits_custom_enable', + [ + 'title' => __('Use Custom Footer Text', 'ultimate-multisite'), + 'desc' => __('Use the custom HTML below instead of the default link.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + 'require' => [ + 'credits_enable' => 1, + ], + ], + 2020 + ); + + // Custom text value + wu_register_settings_field( + 'general', + 'credits_custom_text', + [ + 'title' => __('Custom Footer Text', 'ultimate-multisite'), + 'desc' => __('HTML allowed. Use any text or link you prefer.', 'ultimate-multisite'), + 'type' => 'text', + '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_custom_enable' => 1, + ], + ], + 2030 + ); + + // Allow sites to remove (per-site opt-out) + wu_register_settings_field( + 'general', + 'credits_site_can_hide', + [ + 'title' => __('Allow Sites to Remove Credit', 'ultimate-multisite'), + 'desc' => __('Allow individual sites to opt out.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 1, + 'require' => [ + 'credits_enable' => 1, + ], + ], + 2040 + ); + } + + /** + * 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 ''; + } + + $use_custom = (bool) wu_get_setting('credits_custom_enable', 0); + + if ($use_custom) { + $text = (string) wu_get_setting('credits_custom_text', ''); + return wp_kses_post($text); + } + + // Default: "Powered by Ultimate Multisite" with link (only when explicitly opted-in). + $label = esc_html__('Powered by', 'ultimate-multisite') . ' '; + $link = sprintf( + '%s', + esc_url('https://ultimatemultisite.com'), + esc_html__('Ultimate Multisite', 'ultimate-multisite') + ); + return $label . $link; + } + + /** + * Check if current site is allowed to show footer credit. + */ + protected function site_allows_credit(): bool { + // If network disables site removable option, then always allowed. + $allow_site_hide = (bool) wu_get_setting('credits_site_can_hide', 1); + if (! $allow_site_hide) { + return true; + } + + // Respect a per-site opt-out flag if present. + $blog_id = get_current_blog_id(); + $hidden = (bool) get_blog_option($blog_id, 'wu_hide_footer_credit', false); + return ! $hidden; + } + + /** + * Admin footer replacement. + * + * Only show on customer-owned site admins (not network admin or main site admin). + */ + 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() !== \WP_Ultimo\Database\Sites\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() !== \WP_Ultimo\Database\Sites\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; + } + $credit = $this->build_credit_html(); + if (! $credit || ! $this->site_allows_credit()) { + return; + } + echo '
' . $credit . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } } - From 61cbcb4b0491476ff01994b8b8c0fb58c0e77f71 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 12:14:27 -0600 Subject: [PATCH 03/26] fix: work in progress --- composer.json | 2 +- .../class-setup-wizard-admin-page.php | 35 +++++-- inc/class-scripts.php | 2 +- inc/class-whitelabel.php | 92 +++++++++---------- inc/class-wp-ultimo.php | 11 +++ inc/compat/class-edit-users-compat.php | 2 +- package.json | 2 +- readme.txt | 64 ++++++++++--- ultimate-multisite.php | 4 +- 9 files changed, 140 insertions(+), 74 deletions(-) diff --git a/composer.json b/composer.json index f815bb0d2..82b7811ad 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/inc/admin-pages/class-setup-wizard-admin-page.php b/inc/admin-pages/class-setup-wizard-admin-page.php index acec18f04..a5ef67a36 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; @@ -135,9 +136,10 @@ public function __construct() { /* * Load installers */ - 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', [Migrator::get_instance(), 'handle'], 10, 3); + 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); /* * Redirect on activation @@ -359,7 +361,7 @@ public function get_sections() { /* * In case of migrations, add different sections. */ - if ($this->is_migration()) { + if ($this->is_migration()) { $dry_run = wu_request('dry-run', true); $next = true; @@ -484,12 +486,25 @@ public function get_sections() { ], ], ]; - } - - $sections['done'] = [ - 'title' => __('Ready!', 'ultimate-multisite'), - 'view' => [$this, 'section_ready'], - ]; + } + // 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'), + 'view' => [$this, 'section_ready'], + ]; /** * Allow developers to add additional setup wizard steps. 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-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 1fc6131bf..330f4599b 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -562,6 +562,11 @@ function () { */ \WP_Ultimo\Dashboard_Statistics::get_instance(); + /* + * Network Plugins/Themes usage columns + */ + \WP_Ultimo\Admin\Network_Usage_Columns::get_instance(); + /* * Loads User Switching */ @@ -620,6 +625,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-edit-users-compat.php b/inc/compat/class-edit-users-compat.php index a497ad8d7..48a3156a1 100644 --- a/inc/compat/class-edit-users-compat.php +++ b/inc/compat/class-edit-users-compat.php @@ -140,7 +140,7 @@ public function add_noconfirmation_field($context) { ?> - +
diff --git a/package.json b/package.json index a7a6f4a32..0aa2d4489 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/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/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 From a9f4291bf84b9d7308a3d3a7b90a56e3c35a7426 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 13:20:29 -0600 Subject: [PATCH 04/26] Fix bug in action scheduler --- inc/class-wp-ultimo.php | 1 + .../class-actionscheduler-optionlock-um.php | 144 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 inc/compat/class-actionscheduler-optionlock-um.php diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 767974613..cee0efd27 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -211,6 +211,7 @@ public function after_init() { /** * Loads admin pages + * * @todo: Move this to a manager in the future? */ $this->load_admin_pages(); 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)); + } +} From bd28d2096c04db66c0651b3c77cab19cd936f57e Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 13:21:22 -0600 Subject: [PATCH 05/26] Scan actions scheduler dir since it don't use normal autoloader --- phpstan.neon.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b54b92b40..d9aa725fd 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,6 +2,8 @@ parameters: level: 0 inferPrivatePropertyTypeFromConstructor: true treatPhpDocTypesAsCertain: false + scanDirectories: + - vendor/woocommerce/action-scheduler paths: - ./views - ./inc From dc7ee72a3acd98fc2e2e4b7d4a0b8030dfcf9a8a Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 14:56:57 -0600 Subject: [PATCH 06/26] Remove dump githook --- .githooks/commit-msg | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100755 .githooks/commit-msg 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 From a0397ae74c5ef7a80f17fa1bfe6e12a1608d6c08 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 14:57:23 -0600 Subject: [PATCH 07/26] ignore a error I can't seem to get to pass --- phpstan.neon.dist | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d9aa725fd..9d4ebe3c7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,6 +2,8 @@ parameters: level: 0 inferPrivatePropertyTypeFromConstructor: true treatPhpDocTypesAsCertain: false + bootstrapFiles: + - vendor/szepeviktor/phpstan-wordpress/bootstrap.php scanDirectories: - vendor/woocommerce/action-scheduler paths: @@ -11,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 From a3223e0706aaae7b0beab35165ebf6100e01a00c Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 15:41:19 -0600 Subject: [PATCH 08/26] code style --- .../class-customer-edit-admin-page.php | 17 +- .../class-setup-wizard-admin-page.php | 50 +- inc/checkout/class-legacy-checkout.php | 46 +- .../class-signup-field-order-bump.php | 2 +- .../class-signup-field-order-summary.php | 2 +- .../class-signup-field-period-selection.php | 2 +- .../class-signup-field-pricing-table.php | 2 +- .../class-signup-field-steps.php | 2 +- .../class-closte-host-provider.php | 18 +- inc/ui/class-account-summary-element.php | 2 +- inc/ui/class-base-element.php | 17 +- scripts/translate.php | 588 +++++++++--------- 12 files changed, 375 insertions(+), 373 deletions(-) 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-setup-wizard-admin-page.php b/inc/admin-pages/class-setup-wizard-admin-page.php index 82d97dac9..495dc72ac 100644 --- a/inc/admin-pages/class-setup-wizard-admin-page.php +++ b/inc/admin-pages/class-setup-wizard-admin-page.php @@ -136,10 +136,10 @@ public function __construct() { /* * Load installers */ - 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); + 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); /* * Redirect on activation @@ -361,7 +361,7 @@ public function get_sections() { /* * In case of migrations, add different sections. */ - if ($this->is_migration()) { + if ($this->is_migration()) { $dry_run = wu_request('dry-run', true); $next = true; @@ -486,25 +486,25 @@ 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'), - 'view' => [$this, 'section_ready'], - ]; + } + // 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'), + 'view' => [$this, 'section_ready'], + ]; /** * Allow developers to add additional setup wizard steps. @@ -513,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/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-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/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/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/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); +} From 5c47550cbc1d8fcadcd2f15da2b909ca32661beb Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 15:42:45 -0600 Subject: [PATCH 09/26] return void in an action --- inc/class-hooks.php | 4 ++-- inc/class-wp-ultimo.php | 8 ++++++++ inc/installers/class-base-installer.php | 12 ++++++------ inc/installers/class-migrator.php | 8 ++------ 4 files changed, 18 insertions(+), 14 deletions(-) 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-wp-ultimo.php b/inc/class-wp-ultimo.php index cee0efd27..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. * 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; } /** From c1a6da6ca0b83aaeb3215ef35d42f2e17458ad19 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 26 Sep 2025 15:43:06 -0600 Subject: [PATCH 10/26] Ad recommended plugins --- .../class-recommended-plugins-installer.php | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 inc/installers/class-recommended-plugins-installer.php 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; + } +} From 34dff077626b58a5156306d37f4ade899799074b Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 28 Sep 2025 17:32:23 -0600 Subject: [PATCH 11/26] Add usage column to themes and plugins pages --- inc/admin/class-network-usage-columns.php | 365 ++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 inc/admin/class-network-usage-columns.php 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); + } +} From aa515f4a6ddcf7c65046d3fb6dbb4bdd291ef5ee Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 28 Sep 2025 17:42:04 -0600 Subject: [PATCH 12/26] add missing doc params --- inc/admin-pages/class-base-admin-page.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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); From 19548af9dd2af51605e9439433bb1f00cb59c39d Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 28 Sep 2025 17:44:28 -0600 Subject: [PATCH 13/26] use correct hook function --- inc/admin-pages/class-base-customer-facing-admin-page.php | 6 +++--- .../class-email-template-customize-admin-page.php | 2 ++ inc/admin-pages/class-product-edit-admin-page.php | 2 +- inc/admin-pages/class-site-edit-admin-page.php | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) 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-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-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']); } /** From 838f7beb701c6e2857313c9cd8c0413c8e099972 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 28 Sep 2025 17:46:45 -0600 Subject: [PATCH 14/26] fix status page --- inc/admin-pages/class-system-info-admin-page.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 484a3b80b256d9ececec0d0a5dc93dc3aa7e56c6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 29 Sep 2025 15:05:49 -0600 Subject: [PATCH 15/26] Use correct old name --- .../class-legacy-period-selection-field-template.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'); } /** From 1befc882b57d803321cd2914020f2ffe4f09e711 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 29 Sep 2025 15:07:19 -0600 Subject: [PATCH 16/26] Add wu-selected-template class to selected button --- views/checkout/templates/template-selection/clean.php | 3 ++- views/checkout/templates/template-selection/legacy.php | 2 ++ views/checkout/templates/template-selection/minimal.php | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) 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"
-