PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.2
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.2
6.2.3 6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 All 156 releases
mainwp / pages / page-mainwp-settings-premium-updates.php

page-mainwp-settings-premium-updates.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.2, at pages/page-mainwp-settings-premium-updates.php

800 lines 41.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP Premium Updates Settings page.
4 *
5 * Settings subpage for the built-in premium plugin & theme update
6 * compatibility (MWP-1660): license notice, custom identifiers, and a
7 * read-only overview of detected products.
8 *
9 * @package MainWP/Dashboard
10 */
11
12 namespace MainWP\Dashboard;
13
14 // Exit if accessed directly.
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Class MainWP_Settings_Premium_Updates
21 *
22 * @package MainWP\Dashboard
23 */
24 class MainWP_Settings_Premium_Updates { // phpcs:ignore Generic.Classes.OpeningBraceSameLine.ContentAfterBrace -- NOSONAR.
25
26 /**
27 * Transient used to cache the detected-products scan.
28 *
29 * @var string
30 */
31 const SCAN_TRANSIENT = 'mainwp_premium_updates_scan';
32
33 /**
34 * Method get_class_name()
35 *
36 * Get Class Name.
37 *
38 * @return string
39 */
40 public static function get_class_name() {
41 return __CLASS__;
42 }
43
44 /**
45 * Handle custom-identifier form submissions.
46 *
47 * @return array|false Notice data on submission, false otherwise.
48 */
49 public static function handle_settings_post() { // phpcs:ignore -- NOSONAR - complex.
50 $action = isset( $_POST['premium_updates_action'] ) ? sanitize_key( wp_unslash( $_POST['premium_updates_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
51
52 if ( '' === $action || ! MainWP_System_Utility::is_admin() ) {
53 return false;
54 }
55
56 if ( 'add_custom' === $action ) {
57 return static::handle_add_custom_entry();
58 }
59
60 if ( 'remove_custom' === $action ) {
61 return static::handle_remove_custom_entry();
62 }
63
64 return false;
65 }
66
67 /**
68 * Add one custom premium-update identifier.
69 *
70 * @return array Submission notice data.
71 */
72 private static function handle_add_custom_entry() {
73 if ( ! isset( $_POST['premium_updates_add_nonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['premium_updates_add_nonce'] ) ), 'PremiumUpdatesCustomAdd' ) ) {
74 return static::get_post_notice( 'error', __( 'The security check failed. Please reload the page and try again.', 'mainwp' ), 'add_custom' );
75 }
76
77 $type = isset( $_POST['premium_updates_new_type'] ) && 'theme' === sanitize_key( wp_unslash( $_POST['premium_updates_new_type'] ) ) ? 'theme' : 'plugin';
78 $id = isset( $_POST['premium_updates_new_id'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['premium_updates_new_id'] ) ) ) : '';
79
80 if ( '' === $id ) {
81 return static::get_post_notice( 'error', __( 'Enter a plugin or theme identifier.', 'mainwp' ), 'add_custom' );
82 }
83
84 if ( ! preg_match( '/^[A-Za-z0-9\-_.\/]+$/', $id ) ) {
85 return static::get_post_notice( 'error', __( 'The identifier is invalid. Use a plugin basename or theme folder containing only letters, numbers, hyphens, underscores, periods, and forward slashes.', 'mainwp' ), 'add_custom' );
86 }
87
88 $custom = MainWP_Premium_Update_Registry::get_custom_entries();
89 foreach ( $custom as $item ) {
90 if ( $item['type'] === $type && 0 === strcasecmp( $item['id'], $id ) ) {
91 return static::get_post_notice( 'error', __( 'That custom identifier already exists.', 'mainwp' ), 'add_custom' );
92 }
93 }
94
95 $custom[] = array(
96 'type' => $type,
97 'id' => $id,
98 );
99 MainWP_Premium_Update_Registry::save_custom_entries( $custom );
100 delete_transient( static::SCAN_TRANSIENT . '_' . get_current_user_id() );
101
102 return static::get_post_notice( 'success', __( 'Custom identifier added.', 'mainwp' ), 'add_custom' );
103 }
104
105 /**
106 * Remove one custom premium-update identifier.
107 *
108 * @return array Submission notice data.
109 */
110 private static function handle_remove_custom_entry() {
111 if ( ! isset( $_POST['premium_updates_remove_nonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['premium_updates_remove_nonce'] ) ), 'PremiumUpdatesCustomRemove' ) ) {
112 return static::get_post_notice( 'error', __( 'The security check failed. Please reload the page and try again.', 'mainwp' ), 'remove_custom' );
113 }
114
115 $type = isset( $_POST['premium_updates_remove_type'] ) && 'theme' === sanitize_key( wp_unslash( $_POST['premium_updates_remove_type'] ) ) ? 'theme' : 'plugin';
116 $id = isset( $_POST['premium_updates_remove_id'] ) ? sanitize_text_field( wp_unslash( $_POST['premium_updates_remove_id'] ) ) : '';
117 $custom = MainWP_Premium_Update_Registry::get_custom_entries();
118 $found = false;
119
120 $custom = array_filter(
121 $custom,
122 function ( $item ) use ( $type, $id, &$found ) {
123 if ( $item['type'] === $type && 0 === strcasecmp( $item['id'], $id ) ) {
124 $found = true;
125 return false;
126 }
127 return true;
128 }
129 );
130
131 if ( ! $found ) {
132 return static::get_post_notice( 'error', __( 'The custom identifier could not be found.', 'mainwp' ), 'remove_custom' );
133 }
134
135 MainWP_Premium_Update_Registry::save_custom_entries( array_values( $custom ) );
136 delete_transient( static::SCAN_TRANSIENT . '_' . get_current_user_id() );
137
138 return static::get_post_notice( 'success', __( 'Custom identifier removed.', 'mainwp' ), 'remove_custom' );
139 }
140
141 /**
142 * Build submission notice data.
143 *
144 * @param string $type Notice type: success|error.
145 * @param string $message Notice message.
146 * @param string $action Optional related action.
147 *
148 * @return array Notice data.
149 */
150 private static function get_post_notice( $type, $message, $action = '' ) {
151 return array(
152 'type' => 'error' === $type ? 'error' : 'success',
153 'message' => $message,
154 'action' => $action,
155 );
156 }
157
158 /**
159 * Render the Premium Updates settings page.
160 */
161 public static function render() { // phpcs:ignore -- NOSONAR - complex render method.
162 if ( ! \mainwp_current_user_can( 'dashboard', 'manage_dashboard_settings' ) ) {
163 \mainwp_do_not_have_permissions( esc_html__( 'manage dashboard settings', 'mainwp' ) );
164 return;
165 }
166
167 $notice = static::handle_settings_post();
168
169 MainWP_Settings::render_header( 'PremiumUpdates' );
170
171 $custom = MainWP_Premium_Update_Registry::get_custom_entries();
172 $scan = static::get_detected_products();
173 $suggestions = static::get_identifier_suggestions();
174 $suggestion_sources = array(
175 'plugin' => array(),
176 'theme' => array(),
177 );
178 foreach ( $suggestions as $suggestion ) {
179 $suggestion_sources[ $suggestion['type'] ][] = array(
180 'title' => $suggestion['name'],
181 'identifier' => $suggestion['identifier'],
182 'description' => sprintf(
183 /* translators: 1: Plugin or theme identifier, 2: Number of child sites. */
184 _n( '%1$s - installed on %2$d site', '%1$s - installed on %2$d sites', $suggestion['sites'], 'mainwp' ),
185 $suggestion['identifier'],
186 $suggestion['sites']
187 ),
188 );
189 }
190 $add_error = is_array( $notice ) && 'error' === $notice['type'] && 'add_custom' === $notice['action'];
191 $notice_class = is_array( $notice ) && 'error' === $notice['type'] ? 'red' : 'green';
192 $new_type = $add_error && isset( $_POST['premium_updates_new_type'] ) && 'theme' === sanitize_key( wp_unslash( $_POST['premium_updates_new_type'] ) ) ? 'theme' : 'plugin'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- repopulating sanitized form input.
193 $new_id = $add_error && isset( $_POST['premium_updates_new_id'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['premium_updates_new_id'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- repopulating sanitized form input.
194 ?>
195 <div id="mainwp-premium-updates-settings" class="ui padded segment">
196 <?php if ( is_array( $notice ) && ! $add_error ) : ?>
197 <div class="ui <?php echo esc_attr( $notice_class ); ?> message"><i class="close icon"></i><?php echo esc_html( $notice['message'] ); ?></div>
198 <?php endif; ?>
199 <div class="ui info message">
200 <div class="header"><?php esc_html_e( 'Premium products usually need an active license on each child site.', 'mainwp' ); ?></div>
201 <p><?php esc_html_e( 'MainWP detects and starts these updates, but vendors only deliver the update download to sites where the product license, account connection, or registration is active. If a premium update appears but fails to install, check the product licensing on that child site first.', 'mainwp' ); ?></p>
202 </div>
203
204 <div class="ui hidden divider"></div>
205
206 <div class="ui form">
207
208 <div class="ui basic accordion mainwp-blank-accordion mainwp-sidebar-accordion" id="mainwp-premium-updates-custom-accordion">
209 <h2 class="ui dividing header active title">
210 <i class="right dropdown icon"></i>
211 <?php esc_html_e( 'Custom Identifiers', 'mainwp' ); ?>
212 <div class="sub header"><?php esc_html_e( 'For premium products not on the built-in list, or for theme folders that were renamed. Added identifiers get the same treatment as built-in ones.', 'mainwp' ); ?></div>
213 </h2>
214 <div class="content active">
215 <?php if ( $add_error ) : ?>
216 <div class="ui red message"><?php echo esc_html( $notice['message'] ); ?></div>
217 <?php endif; ?>
218
219 <form method="POST" action="<?php echo esc_url( admin_url( 'admin.php?page=PremiumUpdates' ) ); ?>">
220 <?php wp_nonce_field( 'PremiumUpdatesCustomAdd', 'premium_updates_add_nonce' ); ?>
221 <input type="hidden" name="premium_updates_action" value="add_custom" />
222 <div class="fields" id="mainwp-premium-updates-identifier-fields">
223 <div class="six wide field">
224 <label for="premium_updates_new_type"><?php esc_html_e( 'Type', 'mainwp' ); ?></label>
225 <select name="premium_updates_new_type" id="premium_updates_new_type" class="ui dropdown">
226 <option value="plugin" <?php selected( $new_type, 'plugin' ); ?>><?php esc_html_e( 'Plugin', 'mainwp' ); ?></option>
227 <option value="theme" <?php selected( $new_type, 'theme' ); ?>><?php esc_html_e( 'Theme', 'mainwp' ); ?></option>
228 </select>
229 </div>
230 <div class="ten wide field">
231 <label for="premium_updates_new_id"><?php esc_html_e( 'Identifier', 'mainwp' ); ?> <span class="ui small red text"><?php esc_html_e( '(Required)', 'mainwp' ); ?></span></label>
232 <div class="fields">
233 <div class="thirteen wide field">
234 <div class="ui fluid search" id="mainwp-premium-updates-identifier-search">
235 <input type="text" name="premium_updates_new_id" id="premium_updates_new_id" placeholder="<?php echo esc_attr( 'theme' === $new_type ? __( 'Search installed themes or enter the theme folder', 'mainwp' ) : __( 'Search installed plugins or enter plugin-folder/main-file.php', 'mainwp' ) ); ?>" data-plugin-placeholder="<?php esc_attr_e( 'Search installed plugins or enter plugin-folder/main-file.php', 'mainwp' ); ?>" data-theme-placeholder="<?php esc_attr_e( 'Search installed themes or enter the theme folder', 'mainwp' ); ?>" value="<?php echo esc_attr( $new_id ); ?>" pattern="[A-Za-z0-9._/-]+" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="mainwp-premium-updates-identifier-results" aria-describedby="mainwp-premium-updates-identifier-help" autocomplete="off" required />
236 <div class="results" id="mainwp-premium-updates-identifier-results" role="listbox" aria-label="<?php esc_attr_e( 'Identifier suggestions', 'mainwp' ); ?>"></div>
237 </div>
238 </div>
239 <div class="three wide field">
240 <button type="submit" class="ui fluid basic green button" id="mainwp-premium-updates-add-custom"><i class="plus icon"></i><?php esc_html_e( 'Add New', 'mainwp' ); ?></button>
241 </div>
242 </div>
243 </div>
244 </div>
245 <div class="ui small text" id="mainwp-premium-updates-identifier-help">
246 <div><?php esc_html_e( 'Sync your child sites first so MainWP Dashboard has the latest installed plugin and theme data.', 'mainwp' ); ?></div>
247 <div><?php esc_html_e( 'Start typing to search all detected products. Results also include non-premium plugins and themes, so ignore those and select only the premium product you want to add.', 'mainwp' ); ?></div>
248 <div><?php esc_html_e( 'You can also enter an identifier manually. Plugin identifiers look like plugin-folder/main-file.php; theme identifiers are the installed theme folder.', 'mainwp' ); ?></div>
249 </div>
250 </form>
251
252 <div class="ui hidden divider"></div>
253
254 <table class="ui unstackable table" id="mainwp-premium-updates-custom-table">
255 <thead>
256 <tr>
257 <th scope="col"><?php esc_html_e( 'Type', 'mainwp' ); ?></th>
258 <th scope="col"><?php esc_html_e( 'Identifier', 'mainwp' ); ?></th>
259 <th scope="col" class="collapsing"><span class="screen-reader-text"><?php esc_html_e( 'Actions', 'mainwp' ); ?></span></th>
260 </tr>
261 </thead>
262 <tbody>
263 <?php if ( empty( $custom ) ) : ?>
264 <tr>
265 <td colspan="3"><?php esc_html_e( 'No custom identifiers added yet.', 'mainwp' ); ?></td>
266 </tr>
267 <?php else : ?>
268 <?php foreach ( $custom as $item ) : ?>
269 <tr>
270 <td><?php echo 'theme' === $item['type'] ? esc_html__( 'Theme', 'mainwp' ) : esc_html__( 'Plugin', 'mainwp' ); ?></td>
271 <td><code><?php echo esc_html( $item['id'] ); ?></code></td>
272 <td class="right aligned">
273 <form method="POST" action="<?php echo esc_url( admin_url( 'admin.php?page=PremiumUpdates' ) ); ?>" class="mainwp-premium-updates-remove-form">
274 <input type="hidden" name="premium_updates_remove_nonce" value="<?php echo esc_attr( wp_create_nonce( 'PremiumUpdatesCustomRemove' ) ); ?>" />
275 <input type="hidden" name="premium_updates_action" value="remove_custom" />
276 <input type="hidden" name="premium_updates_remove_type" value="<?php echo esc_attr( $item['type'] ); ?>" />
277 <input type="hidden" name="premium_updates_remove_id" value="<?php echo esc_attr( $item['id'] ); ?>" />
278 <button type="button" class="ui mini icon basic button mainwp-premium-updates-remove" data-inverted="" data-position="top right" data-tooltip="<?php esc_attr_e( 'Remove custom identifier.', 'mainwp' ); ?>" data-confirm="<?php esc_attr_e( 'Are you sure you want to remove this custom identifier?', 'mainwp' ); ?>" aria-label="<?php echo esc_attr( sprintf( /* translators: %s: Custom identifier. */ __( 'Remove custom identifier %s', 'mainwp' ), $item['id'] ) ); ?>"><i class="trash icon"></i></button>
279 </form>
280 </td>
281 </tr>
282 <?php endforeach; ?>
283 <?php endif; ?>
284 </tbody>
285 </table>
286 </div>
287 </div>
288
289 <div class="ui hidden divider"></div>
290
291 <div class="ui basic accordion mainwp-blank-accordion mainwp-sidebar-accordion" id="mainwp-premium-updates-plugins-accordion">
292 <h2 class="ui dividing header title">
293 <i class="right dropdown icon"></i>
294 <?php esc_html_e( 'Detected Plugins', 'mainwp' ); ?>
295 <div class="sub header"><?php esc_html_e( 'Read-only overview of supported premium plugins found across your child sites. Use Ignore Updates to stop tracking individual updates.', 'mainwp' ); ?></div>
296 </h2>
297 <div class="content">
298 <?php static::render_detected_table( 'plugin', $scan ); ?>
299 </div>
300 </div>
301
302 <div class="ui hidden divider"></div>
303
304 <div class="ui basic accordion mainwp-blank-accordion mainwp-sidebar-accordion" id="mainwp-premium-updates-themes-accordion">
305 <h2 class="ui dividing header title">
306 <i class="right dropdown icon"></i>
307 <?php esc_html_e( 'Detected Themes', 'mainwp' ); ?>
308 <div class="sub header"><?php esc_html_e( 'Read-only overview of supported premium themes found across your child sites. Use Ignore Updates to stop tracking individual updates.', 'mainwp' ); ?></div>
309 </h2>
310 <div class="content">
311 <?php static::render_detected_table( 'theme', $scan ); ?>
312 </div>
313 </div>
314 </div>
315
316 <script type="text/javascript">
317 jQuery( function( $ ) {
318 const identifierSources = <?php echo wp_json_encode( $suggestion_sources, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- safely encoded JSON for an inline script. ?>;
319 const identifierSearch = $( '#mainwp-premium-updates-identifier-search' );
320 const identifierInput = $( '#premium_updates_new_id' );
321 const typeInput = $( '#premium_updates_new_type' );
322 const identifierResults = $( '#mainwp-premium-updates-identifier-results' );
323
324 if ( identifierSearch.length && identifierInput.length && typeInput.length && identifierResults.length && 'function' === typeof $.fn.search ) {
325 const getIdentifierSource = function() {
326 const type = 'theme' === typeInput.val() ? 'theme' : 'plugin';
327 return identifierSources[ type ] || [];
328 };
329 const syncActiveIdentifierResult = function() {
330 const activeResult = identifierResults.find( '.result.active' ).first();
331 identifierResults.find( '.result' ).attr( 'aria-selected', 'false' );
332 if ( activeResult.length ) {
333 activeResult.attr( 'aria-selected', 'true' );
334 identifierInput.attr( 'aria-activedescendant', activeResult.attr( 'id' ) );
335 } else {
336 identifierInput.removeAttr( 'aria-activedescendant' );
337 }
338 };
339 const prepareIdentifierResults = function() {
340 identifierResults.find( '.result' ).each( function( index ) {
341 $( this ).attr( {
342 id: 'mainwp-premium-updates-identifier-option-' + index,
343 role: 'option',
344 'aria-selected': 'false'
345 } );
346 } );
347 syncActiveIdentifierResult();
348 };
349 let identifierSearchTimer = null;
350
351 identifierSearch.search( {
352 source: getIdentifierSource(),
353 selector: {
354 prompt: '#premium_updates_new_id'
355 },
356 searchFields: [ 'title', 'identifier' ],
357 fullTextSearch: 'exact',
358 minCharacters: 2,
359 maxResults: 12,
360 cache: false,
361 automatic: false,
362 duration: 0,
363 searchOnFocus: false,
364 showNoResults: false,
365 preserveHTML: false,
366 onSelect: function( result ) {
367 window.clearTimeout( identifierSearchTimer );
368 identifierInput.attr( 'aria-expanded', 'false' ).removeAttr( 'aria-activedescendant' );
369 if ( ! result || ! result.identifier ) {
370 return false;
371 }
372 identifierInput.val( result.identifier ).trigger( 'change' );
373 identifierSearch.search( 'hide results' );
374 return false;
375 },
376 onResultsAdd: function( html ) {
377 identifierInput.removeAttr( 'aria-activedescendant' );
378 if ( html ) {
379 window.setTimeout( prepareIdentifierResults, 0 );
380 }
381 },
382 onResultsOpen: function() {
383 identifierInput.attr( 'aria-expanded', 'true' );
384 },
385 onResultsClose: function() {
386 identifierInput.attr( 'aria-expanded', 'false' ).removeAttr( 'aria-activedescendant' );
387 }
388 } );
389
390 // Bind directly instead of relying on delegated form input events.
391 identifierInput.on( 'input.mainwpPremiumUpdatesIdentifierSearch', function() {
392 window.clearTimeout( identifierSearchTimer );
393 identifierInput.removeAttr( 'aria-activedescendant' );
394 if ( identifierInput.val().length < 2 ) {
395 identifierSearch.search( 'hide results' );
396 return;
397 }
398 identifierSearchTimer = window.setTimeout( function() {
399 if ( document.activeElement !== identifierInput[0] ) {
400 return;
401 }
402 identifierSearch.search( 'query' );
403 }, 100 );
404 } );
405 identifierInput.on( 'blur.mainwpPremiumUpdatesIdentifierSearch', function() {
406 window.clearTimeout( identifierSearchTimer );
407 } );
408 identifierInput.on( 'keydown.mainwpPremiumUpdatesIdentifierSearch', function( event ) {
409 if ( 38 === event.which || 40 === event.which ) {
410 window.setTimeout( syncActiveIdentifierResult, 0 );
411 }
412 } );
413
414 typeInput.on( 'change', function() {
415 const type = 'theme' === typeInput.val() ? 'theme' : 'plugin';
416 window.clearTimeout( identifierSearchTimer );
417 identifierInput
418 .val( '' )
419 .attr( 'placeholder', identifierInput.attr( 'data-' + type + '-placeholder' ) )
420 .trigger( 'change' );
421 identifierSearch.search( 'setting', 'source', getIdentifierSource() );
422 identifierSearch.search( 'clear cache' );
423 identifierSearch.search( 'hide results' );
424 } );
425 }
426
427 $( '.mainwp-premium-updates-remove' ).on( 'click', function() {
428 const form = this.form;
429 if ( ! form ) {
430 return false;
431 }
432 mainwp_confirm( $( this ).attr( 'data-confirm' ), function() {
433 form.submit();
434 } );
435 return false;
436 } );
437 } );
438 </script>
439 </div>
440 <?php
441 MainWP_Settings::render_footer();
442 }
443
444 /**
445 * Render one read-only detected-products table.
446 *
447 * @param string $type 'plugin' | 'theme'.
448 * @param array $scan Scan results from get_detected_products().
449 */
450 private static function render_detected_table( $type, $scan ) {
451 $rows = array();
452 foreach ( $scan as $row ) {
453 if ( $row['type'] === $type && $row['sites'] > 0 ) {
454 $rows[] = $row;
455 }
456 }
457 usort(
458 $rows,
459 function ( $a, $b ) {
460 return strcasecmp( $a['name'], $b['name'] );
461 }
462 );
463 ?>
464 <table class="ui unstackable table">
465 <thead>
466 <tr>
467 <th scope="col"><?php esc_html_e( 'Name', 'mainwp' ); ?></th>
468 <th scope="col"><?php esc_html_e( 'Identifier', 'mainwp' ); ?></th>
469 <th scope="col"><?php esc_html_e( 'Installed on', 'mainwp' ); ?></th>
470 </tr>
471 </thead>
472 <tbody>
473 <?php if ( empty( $rows ) ) : ?>
474 <tr>
475 <td colspan="3"><?php 'theme' === $type ? esc_html_e( 'No supported premium themes detected on your sites yet. Products appear here automatically once detected during a sync.', 'mainwp' ) : esc_html_e( 'No supported premium plugins detected on your sites yet. Products appear here automatically once detected during a sync.', 'mainwp' ); ?></td>
476 </tr>
477 <?php else : ?>
478 <?php foreach ( $rows as $row ) : ?>
479 <tr>
480 <td>
481 <strong><?php echo esc_html( $row['name'] ); ?></strong>
482 <?php if ( ! empty( $row['products'] ) && $row['products'] > 1 ) : ?>
483 <div><em><?php printf( esc_html__( 'One rule covers every matching product (%d found across your sites).', 'mainwp' ), (int) $row['products'] ); ?></em></div>
484 <?php endif; ?>
485 <?php if ( ! empty( $row['inactive'] ) ) : ?>
486 <div><em>
487 <?php
488 if ( 'theme' === $type ) {
489 printf( esc_html__( 'Inactive on %d sites; theme updates cannot be detected while the theme is inactive.', 'mainwp' ), (int) $row['inactive'] );
490 } else {
491 printf( esc_html__( 'Inactive on %d sites; premium updates cannot be detected while the plugin is inactive.', 'mainwp' ), (int) $row['inactive'] );
492 }
493 ?>
494 </em></div>
495 <?php endif; ?>
496 </td>
497 <td><code><?php echo esc_html( $row['identifier'] ); ?></code></td>
498 <td>
499 <?php printf( esc_html( _n( '%d site', '%d sites', $row['sites'], 'mainwp' ) ), (int) $row['sites'] ); ?>
500 </td>
501 </tr>
502 <?php endforeach; ?>
503 <?php endif; ?>
504 </tbody>
505 </table>
506 <?php
507 }
508
509 /**
510 * Build selectable custom-identifier suggestions from synced inventories.
511 *
512 * Built-in and already-custom identifiers are excluded. Passing inventories
513 * is intended for focused tests; normal page rendering reads accessible child
514 * sites from the Dashboard database.
515 *
516 * @param array|null $site_inventories Optional site inventories. Each item contains id, plugin, and theme keys.
517 *
518 * @return array[] Rows: name, identifier, type, sites.
519 */
520 public static function get_identifier_suggestions( $site_inventories = null ) { // phpcs:ignore -- NOSONAR - inventory normalization and aggregation.
521 $catalog = array();
522 $rules = self::get_identifier_exclusion_rules();
523 if ( null === $site_inventories ) {
524 $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user() );
525 while ( $websites && ( $website = MainWP_DB::fetch_object( $websites ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- iterating DB result.
526 $inventories = array(
527 'plugin' => ! empty( $website->plugins ) ? json_decode( $website->plugins, true ) : array(),
528 'theme' => ! empty( $website->themes ) ? json_decode( $website->themes, true ) : array(),
529 );
530 self::collect_identifier_suggestions( $catalog, $inventories, (string) $website->id, $rules );
531 }
532 MainWP_DB::free_result( $websites );
533 } else {
534 if ( ! is_array( $site_inventories ) ) {
535 return array();
536 }
537 foreach ( $site_inventories as $index => $inventories ) {
538 if ( ! is_array( $inventories ) ) {
539 continue;
540 }
541 $site_id = isset( $inventories['id'] ) ? (string) $inventories['id'] : 'site-' . $index;
542 self::collect_identifier_suggestions( $catalog, $inventories, $site_id, $rules );
543 }
544 }
545
546 $rows = array();
547 foreach ( $catalog as $item ) {
548 $rows[] = array(
549 'name' => $item['name'],
550 'identifier' => $item['identifier'],
551 'type' => $item['type'],
552 'sites' => count( $item['site_ids'] ),
553 );
554 }
555 usort(
556 $rows,
557 function ( $a, $b ) {
558 $name_comparison = strcasecmp( $a['name'], $b['name'] );
559 if ( 0 !== $name_comparison ) {
560 return $name_comparison;
561 }
562 $type_comparison = strcmp( $a['type'], $b['type'] );
563 return 0 !== $type_comparison ? $type_comparison : strcasecmp( $a['identifier'], $b['identifier'] );
564 }
565 );
566
567 return $rows;
568 }
569
570 /**
571 * Get identifiers which should not be offered as new custom entries.
572 *
573 * @return array Rules grouped by product type and match style.
574 */
575 private static function get_identifier_exclusion_rules() {
576 $rules = array(
577 'plugin' => array(
578 'exact' => array(),
579 'prefixes' => array(),
580 ),
581 'theme' => array(
582 'exact' => array(),
583 'prefixes' => array(),
584 ),
585 );
586
587 foreach ( MainWP_Premium_Update_Registry::get_entries() as $entry ) {
588 if ( empty( $entry['id'] ) || ! isset( $rules[ $entry['type'] ] ) ) {
589 continue;
590 }
591 if ( 'prefix' === $entry['match'] ) {
592 $rules[ $entry['type'] ]['prefixes'][] = strtolower( $entry['id'] );
593 } else {
594 $rules[ $entry['type'] ]['exact'][ strtolower( $entry['id'] ) ] = true;
595 }
596 }
597 foreach ( MainWP_Premium_Update_Registry::get_custom_entries() as $entry ) {
598 if ( ! empty( $entry['id'] ) && isset( $rules[ $entry['type'] ] ) ) {
599 $rules[ $entry['type'] ]['exact'][ strtolower( $entry['id'] ) ] = true;
600 }
601 }
602
603 return $rules;
604 }
605
606 /**
607 * Merge one site's inventory into the identifier suggestion catalog.
608 *
609 * @param array $catalog Suggestion catalog, updated by reference.
610 * @param array $inventories Plugin and theme inventories for one child site.
611 * @param string $site_id Stable site identifier used for deduplication.
612 * @param array $rules Built-in and custom exclusion rules.
613 */
614 private static function collect_identifier_suggestions( &$catalog, $inventories, $site_id, $rules ) { // phpcs:ignore -- NOSONAR - inventory normalization.
615 foreach ( array( 'plugin', 'theme' ) as $type ) {
616 $items = isset( $inventories[ $type ] ) && is_array( $inventories[ $type ] ) ? $inventories[ $type ] : array();
617 foreach ( $items as $inventory_key => $info ) {
618 if ( ! is_array( $info ) ) {
619 continue;
620 }
621
622 $identifier = MainWP_Premium_Update_Registry::get_inventory_identifier( $inventory_key, $info );
623 if ( '' === $identifier ) {
624 continue;
625 }
626 $normalized_identifier = strtolower( $identifier );
627 if ( isset( $rules[ $type ]['exact'][ $normalized_identifier ] ) || self::identifier_matches_prefix( $normalized_identifier, $rules[ $type ]['prefixes'] ) ) {
628 continue;
629 }
630
631 $name = self::get_inventory_name( $info, $identifier );
632
633 $catalog_key = $type . '|' . $normalized_identifier;
634 if ( ! isset( $catalog[ $catalog_key ] ) ) {
635 $catalog[ $catalog_key ] = array(
636 'name' => $name,
637 'identifier' => $identifier,
638 'type' => $type,
639 'site_ids' => array(),
640 );
641 } elseif ( 0 === strcasecmp( $catalog[ $catalog_key ]['name'], $catalog[ $catalog_key ]['identifier'] ) && 0 !== strcasecmp( $name, $identifier ) ) {
642 $catalog[ $catalog_key ]['name'] = $name;
643 }
644 $catalog[ $catalog_key ]['site_ids'][ $site_id ] = true;
645 }
646 }
647 }
648
649 /**
650 * Resolve a synced product name, falling back to its identifier.
651 *
652 * @param array $info Installed product data.
653 * @param string $identifier Canonical product identifier.
654 * @return string Display name.
655 */
656 private static function get_inventory_name( $info, $identifier ) {
657 foreach ( array( 'name', 'Name', 'title' ) as $name_key ) {
658 if ( ! isset( $info[ $name_key ] ) || ! is_scalar( $info[ $name_key ] ) ) {
659 continue;
660 }
661 $name = sanitize_text_field( wp_strip_all_tags( (string) $info[ $name_key ] ) );
662 if ( '' !== $name ) {
663 return $name;
664 }
665 }
666 return $identifier;
667 }
668
669 /**
670 * Check an already-normalized identifier against normalized prefixes.
671 *
672 * @param string $identifier Lowercase identifier.
673 * @param string[] $prefixes Lowercase prefixes.
674 *
675 * @return bool
676 */
677 private static function identifier_matches_prefix( $identifier, $prefixes ) {
678 foreach ( $prefixes as $prefix ) {
679 if ( '' !== $prefix && 0 === strncmp( $identifier, $prefix, strlen( $prefix ) ) ) {
680 return true;
681 }
682 }
683 return false;
684 }
685
686 /**
687 * Scan synced site inventories for registry-matched products.
688 *
689 * Aggregates per registry entry (and per custom identifier): site count,
690 * inactive count, display name, and for prefix rules the number of distinct
691 * matching products. Results are cached briefly; the cache is cleared on save.
692 *
693 * @return array[] Rows: name, identifier, type, sites, inactive, products.
694 */
695 public static function get_detected_products() { // phpcs:ignore -- NOSONAR - complex.
696 $cache_key = static::SCAN_TRANSIENT . '_' . get_current_user_id();
697 $cached = get_transient( $cache_key );
698 if ( is_array( $cached ) ) {
699 return $cached;
700 }
701
702 $trackers = array();
703
704 foreach ( MainWP_Premium_Update_Registry::get_entries() as $entry ) {
705 if ( empty( $entry['detect'] ) ) {
706 continue;
707 }
708 $trackers[] = array(
709 'name' => $entry['name'],
710 'identifier' => 'prefix' === $entry['match'] ? $entry['id'] . '*' : $entry['id'],
711 'match' => $entry['match'],
712 'id' => $entry['id'],
713 'type' => $entry['type'],
714 'sites' => 0,
715 'inactive' => 0,
716 'slugs' => array(),
717 );
718 }
719 foreach ( MainWP_Premium_Update_Registry::get_custom_entries() as $item ) {
720 $trackers[] = array(
721 'name' => $item['id'],
722 'identifier' => $item['id'],
723 'match' => 'exact',
724 'id' => $item['id'],
725 'type' => $item['type'],
726 'sites' => 0,
727 'inactive' => 0,
728 'slugs' => array(),
729 );
730 }
731
732 $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user() );
733 while ( $websites && ( $website = MainWP_DB::fetch_object( $websites ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- iterating DB result.
734 $inventories = array(
735 'plugin' => ! empty( $website->plugins ) ? json_decode( $website->plugins, true ) : array(),
736 'theme' => ! empty( $website->themes ) ? json_decode( $website->themes, true ) : array(),
737 );
738 foreach ( $trackers as $index => $tracker ) {
739 $items = isset( $inventories[ $tracker['type'] ] ) ? $inventories[ $tracker['type'] ] : array();
740 if ( ! is_array( $items ) || empty( $items ) ) {
741 continue;
742 }
743 $site_matched = false;
744 $site_has_active_match = false;
745 foreach ( $items as $inventory_key => $info ) {
746 $identifier = MainWP_Premium_Update_Registry::get_inventory_identifier( $inventory_key, $info );
747 if ( '' === $identifier ) {
748 continue;
749 }
750 $matched = 'prefix' === $tracker['match']
751 ? 0 === strncasecmp( $identifier, $tracker['id'], strlen( $tracker['id'] ) )
752 : 0 === strcasecmp( $identifier, $tracker['id'] );
753 if ( ! $matched ) {
754 continue;
755 }
756 $site_matched = true;
757
758 $trackers[ $index ]['slugs'][ strtolower( $identifier ) ] = true;
759
760 if ( 'theme' === $tracker['type'] ) {
761 if ( ! empty( $info['active'] ) || ! empty( $info['parent_active'] ) ) {
762 $site_has_active_match = true;
763 }
764 } elseif ( ! isset( $info['active'] ) || ! empty( $info['active'] ) ) {
765 $site_has_active_match = true;
766 }
767
768 $reported_name = self::get_inventory_name( $info, '' );
769 if ( empty( $trackers[ $index ]['reported_name'] ) && '' !== $reported_name && 'exact' === $tracker['match'] ) {
770 $trackers[ $index ]['reported_name'] = $reported_name;
771 }
772 }
773 if ( $site_matched ) {
774 ++$trackers[ $index ]['sites'];
775 if ( ! $site_has_active_match ) {
776 ++$trackers[ $index ]['inactive'];
777 }
778 }
779 }
780 }
781 MainWP_DB::free_result( $websites );
782
783 $rows = array();
784 foreach ( $trackers as $tracker ) {
785 $rows[] = array(
786 'name' => ! empty( $tracker['reported_name'] ) ? $tracker['reported_name'] : $tracker['name'],
787 'identifier' => $tracker['identifier'],
788 'type' => $tracker['type'],
789 'sites' => $tracker['sites'],
790 'inactive' => $tracker['inactive'],
791 'products' => count( $tracker['slugs'] ),
792 );
793 }
794
795 set_transient( $cache_key, $rows, 10 * MINUTE_IN_SECONDS );
796
797 return $rows;
798 }
799 }
800