PluginProbe ʕ •ᴥ•ʔ
WP-Sweep / 2.0.0
WP-Sweep v2.0.0
2.0.1 2.0.0 1.2.0 trunk 1.0.10 1.0.11 1.0.12 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9
wp-sweep / includes / class-wp-sweep-admin.php
wp-sweep / includes Last commit date
class-wp-sweep-admin.php 3 weeks ago class-wp-sweep-api.php 3 weeks ago class-wp-sweep-command.php 3 weeks ago class-wp-sweep-list-table.php 3 weeks ago class-wp-sweep.php 3 weeks ago index.php 3 weeks ago
class-wp-sweep-admin.php
663 lines
1 <?php
2 /**
3 * The Sweep screen.
4 *
5 * @package WP-Sweep
6 */
7
8 defined( 'ABSPATH' ) || exit;
9
10 /**
11 * Registers the admin menu and renders the screen every sweep is run from.
12 *
13 * Until 2.0.0 this was includes/admin.php, a template required at global scope
14 * that reached for globals it never declared and repeated the same eleven
15 * lines of markup nineteen times. The rows are now generated from
16 * WP_Sweep::get_sweeps(), so adding a sweep is one entry in one list.
17 */
18 class WP_Sweep_Admin {
19
20 /**
21 * The menu and page slug.
22 *
23 * Until 2.0.0 the screen was registered with 'wp-sweep/admin.php' -- the
24 * legacy "plugin file as menu slug" form. That put the installation
25 * directory name into the page URL and into the hook suffix handed back to
26 * admin_enqueue_scripts, so both broke for anyone who installed the plugin
27 * under a different directory name.
28 *
29 * @var string
30 */
31 const PAGE = 'wp-sweep';
32
33 /**
34 * The id of the region a finished sweep reports into.
35 *
36 * Written once, here, and used twice: on the region itself and in the
37 * aria-controls of every Sweep link the list table draws. The script
38 * follows that association rather than looking for the region in the
39 * markup around the row, because the two are not near each other -- the
40 * region sits above the form, the rows are inside it -- and a script that
41 * navigates the gap breaks the day the gap changes. It did: see
42 * messageContainer() in js/wp-sweep-admin.js.
43 *
44 * @var string
45 */
46 const MESSAGE_ID = 'wp-sweep-message';
47
48 /**
49 * The hook suffix WordPress handed back when the menu was registered.
50 *
51 * Recorded rather than assumed. get_plugin_page_hookname() derives the
52 * prefix from $admin_page_hooks, so the suffix is 'toplevel_page_wp-sweep'
53 * on a real admin request but something else anywhere the admin menu has
54 * not been built. Comparing against a hardcoded string means the script
55 * silently fails to load in the cases that do not match, and the screen
56 * renders with dead buttons.
57 *
58 * @var string
59 */
60 private static $hook_suffix = '';
61
62 /**
63 * The details a row action asked for, keyed by sweep name.
64 *
65 * @var array
66 */
67 private static $details = array();
68
69 /**
70 * Hook the screen into WordPress.
71 *
72 * @return void
73 */
74 public static function init() {
75 add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
76 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_enqueue_scripts' ) );
77 add_action( 'wp_ajax_sweep', array( __CLASS__, 'ajax_sweep' ) );
78 add_action( 'wp_ajax_sweep_details', array( __CLASS__, 'ajax_sweep_details' ) );
79 }
80
81 /**
82 * The list table, built once the screen is being rendered.
83 *
84 * @return WP_Sweep_List_Table
85 */
86 private static function list_table() {
87 require_once WP_SWEEP_DIR . 'includes/class-wp-sweep-list-table.php';
88
89 return new WP_Sweep_List_Table();
90 }
91
92 /**
93 * Register the menu.
94 *
95 * @return void
96 */
97 public static function admin_menu() {
98 // Tools, where this screen lived for its whole released life. Sweeping is
99 // maintenance against the installation, which is what core keeps under
100 // Tools beside Site Health, Export and Erase Personal Data.
101 //
102 // This is only possible because there is no settings screen: the one
103 // setting became the wp_sweep_limit_details filter. Tools has no submenus,
104 // so a second screen would have had to become a tab of this one, and that
105 // would have made WP-Sweep the only plugin of the nineteen where a data
106 // screen and a settings screen share a page.
107 self::$hook_suffix = add_management_page(
108 _x( 'Sweep', 'Page title', 'wp-sweep' ),
109 _x( 'WP-Sweep', 'Menu title', 'wp-sweep' ),
110 WP_Sweep::capability( 'sweep' ),
111 self::PAGE,
112 array( __CLASS__, 'render_page' )
113 );
114 }
115
116 /**
117 * The hook suffix WordPress gave the Sweep screen.
118 *
119 * @return string Hook suffix, or an empty string before admin_menu runs.
120 */
121 public static function get_hook_suffix() {
122 return self::$hook_suffix;
123 }
124
125 /**
126 * Load the screen's script, and only on the screen that needs it.
127 *
128 * @param string $hook Hook suffix of the screen being rendered.
129 * @return void
130 */
131 public static function admin_enqueue_scripts( $hook ) {
132 if ( '' === self::$hook_suffix || self::$hook_suffix !== $hook ) {
133 return;
134 }
135
136 /*
137 * One unminified file, no dependencies. There is no build step in this
138 * plugin, so a hand-minified twin drifts out of sync with the source it
139 * is supposed to mirror -- and under a kilobyte gzipped the saving was
140 * noise.
141 *
142 * The URL comes from WP_SWEEP_URL, which is derived from the main file.
143 * Building it from the literal 'wp-sweep/js/...' meant the script 404ed
144 * for anyone who installed the plugin under a different directory name.
145 */
146 wp_enqueue_script( 'wp-sweep-admin', WP_SWEEP_URL . 'js/wp-sweep-admin.js', array(), WP_SWEEP_VERSION, true );
147
148 wp_localize_script(
149 'wp-sweep-admin',
150 'wpSweepL10n',
151 array(
152 'textCloseWarning' => __( 'Sweeping is in progress. If you leave now, the process won\'t be completed.', 'wp-sweep' ),
153 'textSweep' => __( 'Sweep', 'wp-sweep' ),
154 'textSweeping' => __( 'Sweeping...', 'wp-sweep' ),
155 'textNa' => __( 'N/A', 'wp-sweep' ),
156 'textNothingToSweep' => __( 'Nothing to sweep', 'wp-sweep' ),
157 )
158 );
159 }
160
161 /**
162 * Render the screen.
163 *
164 * @return void
165 */
166 public static function render_page() {
167 if ( ! current_user_can( WP_Sweep::capability( 'sweep' ) ) ) {
168 wp_die( esc_html__( 'You do not have permission to sweep this site.', 'wp-sweep' ) );
169 }
170
171 $sweep = WP_Sweep::get_instance();
172
173 self::handle_request();
174
175 $table = self::list_table();
176 $table->prepare_items();
177
178 ?>
179 <div class="wrap">
180 <h1><?php echo esc_html_x( 'Sweep', 'Page title', 'wp-sweep' ); ?></h1>
181
182 <?php
183 /*
184 * Core's common.js gathers every notice on the screen and re-inserts
185 * the lot of them directly after this marker, falling back to the
186 * first heading it can find when a screen has not printed one. So the
187 * marker, not the source order, is what decides where the reload
188 * path's message ends up -- and printing no marker was how the
189 * permanent warning below came to sit between the heading and the
190 * region, above a message the script had written underneath it.
191 */
192 ?>
193 <hr class="wp-header-end">
194
195 <?php settings_errors( 'wp_sweep' ); ?>
196
197 <?php
198 /*
199 * One region per screen, immediately after the marker, so the two
200 * ways a sweep reports are neighbours: a bulk sweep posts and
201 * reloads, and settings_errors() above has just printed into the
202 * space the marker anchors; a row's Sweep button does not reload and
203 * the script writes in here. Nothing may be printed between the two,
204 * or one screen answers the same question in two places. This region
205 * sat below the warning, the description and the totals table, which
206 * is where it was when the screen was a page of separate forms.
207 *
208 * role="status" is what tells a screen reader anything happened at
209 * all, since nothing reloads on the script's path.
210 */
211 ?>
212 <div class="sweep-message" id="<?php echo esc_attr( self::MESSAGE_ID ); ?>" role="status"></div>
213
214 <?php
215 /*
216 * `inline` is core's opt-out from that gathering, and this warning is
217 * the one notice on the screen that has to take it. It reports
218 * nothing and never changes, so hoisting it to the marker only puts
219 * it between the reload path's message and the region -- which is
220 * exactly where it was, and why the two paths still did not agree
221 * once the region had been moved up here.
222 */
223 ?>
224 <div class="notice notice-warning inline">
225 <p>
226 <?php
227 echo wp_kses_post(
228 sprintf(
229 /* translators: %s is the URL of the WP-DBManager plugin. */
230 __( 'Before you do any sweep, please <a href="%s">backup your database</a> first, because any sweep done is irreversible.', 'wp-sweep' ),
231 'https://wordpress.org/plugins/wp-dbmanager/'
232 )
233 );
234 ?>
235 </p>
236 </div>
237
238 <p class="description">
239 <?php
240 echo esc_html(
241 sprintf(
242 /* translators: %s is the maximum number of sample items. */
243 __( 'Details lists a sample of up to %s items. Filter wp_sweep_limit_details to change it.', 'wp-sweep' ),
244 number_format_i18n( $sweep->limit_details() )
245 )
246 );
247 ?>
248 </p>
249
250 <?php self::render_totals(); ?>
251
252 <?php $table->views(); ?>
253
254 <form method="post" action="<?php echo esc_url( self::page_url() ); ?>">
255 <?php
256 // No wp_nonce_field() here: the list table prints its own, under
257 // the same _wpnonce name. See WP_Sweep_List_Table::bulk_nonce_action().
258 $table->display();
259 ?>
260 </form>
261
262 <?php self::fire_group_actions(); ?>
263 </div>
264 <?php
265 }
266
267 /**
268 * The screen's own URL, carrying the group currently being shown.
269 *
270 * @return string
271 */
272 private static function page_url() {
273 return add_query_arg(
274 array(
275 'page' => self::PAGE,
276 'group' => WP_Sweep_List_Table::current_group(),
277 ),
278 admin_url( 'tools.php' )
279 );
280 }
281
282 /**
283 * Render the running totals each group is measured against.
284 *
285 * The spans carry the class the script writes updated totals back into
286 * after a sweep, so the numbers do not go stale without a reload.
287 *
288 * @return void
289 */
290 private static function render_totals() {
291 $sweep = WP_Sweep::get_instance();
292
293 // A widefat table rather than a bullet list. These are six rows of
294 // numbers, and as a list they ran together as prose with nothing lining
295 // up; in a table the counts sit in a column and can be read down. It uses
296 // core's own admin classes, so it needs no stylesheet of its own.
297 echo '<table class="widefat striped sweep-totals">';
298 echo '<thead><tr>';
299 printf( '<th scope="col">%s</th>', esc_html__( 'Group', 'wp-sweep' ) );
300 printf( '<th scope="col">%s</th>', esc_html__( 'Currently in your database', 'wp-sweep' ) );
301 echo '</tr></thead><tbody>';
302
303 foreach ( self::totals() as $label => $types ) {
304 $parts = array();
305
306 foreach ( $types as $type => $type_label ) {
307 // Bold unconditionally here, unlike the sweep list's Count
308 // column. There a zero means "nothing to do about this row" and
309 // emphasising it would draw the eye to the one thing not worth
310 // looking at; here the number is simply the content of the cell,
311 // and a site with none of something is as much a fact as a site
312 // with a thousand.
313 $parts[] = sprintf(
314 '<strong class="sweep-count-type-%1$s">%2$s</strong> %3$s',
315 esc_attr( $type ),
316 esc_html( number_format_i18n( $sweep->total_count( $type ) ) ),
317 esc_html( $type_label )
318 );
319 }
320
321 printf(
322 '<tr><th scope="row">%1$s</th><td>%2$s</td></tr>',
323 esc_html( $label ),
324 wp_kses_post( implode( ', ', $parts ) )
325 );
326 }
327
328 echo '</tbody></table>';
329 }
330
331 /**
332 * The table types each group reports a total for.
333 *
334 * @return array Type label keyed by type, keyed by group label.
335 */
336 private static function totals() {
337 /*
338 * Labelled with plain nouns, not with get_sweep_groups().
339 *
340 * Those read "Post Sweep", "Comment Sweep" and so on, which is right
341 * above the table where they filter which sweeps are listed. Here they
342 * would be wrong: these are counts of what is in the database right now,
343 * not of anything a sweep would remove. A row saying "Post Sweep 3 Posts"
344 * reads as three posts waiting to be deleted.
345 */
346 return array(
347 __( 'Posts', 'wp-sweep' ) => array(
348 'posts' => __( 'Posts', 'wp-sweep' ),
349 'postmeta' => __( 'Post Meta', 'wp-sweep' ),
350 ),
351 __( 'Comments', 'wp-sweep' ) => array(
352 'comments' => __( 'Comments', 'wp-sweep' ),
353 'commentmeta' => __( 'Comment Meta', 'wp-sweep' ),
354 ),
355 __( 'Users', 'wp-sweep' ) => array(
356 'users' => __( 'Users', 'wp-sweep' ),
357 'usermeta' => __( 'User Meta', 'wp-sweep' ),
358 ),
359 __( 'Terms', 'wp-sweep' ) => array(
360 'terms' => __( 'Terms', 'wp-sweep' ),
361 'termmeta' => __( 'Term Meta', 'wp-sweep' ),
362 'term_taxonomy' => __( 'Term Taxonomy', 'wp-sweep' ),
363 'term_relationships' => __( 'Term Relationships', 'wp-sweep' ),
364 ),
365 __( 'Options', 'wp-sweep' ) => array(
366 'options' => __( 'Options', 'wp-sweep' ),
367 ),
368 __( 'Database', 'wp-sweep' ) => array(
369 'tables' => __( 'Tables', 'wp-sweep' ),
370 ),
371 );
372 }
373
374 /**
375 * Fire the extension points other plugins hang their own rows off.
376 *
377 * @return void
378 */
379 private static function fire_group_actions() {
380 /**
381 * Fires below the sweep table, where the post sweeps used to end.
382 *
383 * @since 1.0.4
384 */
385 do_action( 'wp_sweep_admin_post_sweep' );
386
387 /**
388 * Fires below the sweep table, where the comment sweeps used to end.
389 *
390 * @since 1.0.4
391 */
392 do_action( 'wp_sweep_admin_comment_sweep' );
393
394 /**
395 * Fires below the sweep table, where the user sweeps used to end.
396 *
397 * @since 1.0.4
398 */
399 do_action( 'wp_sweep_admin_user_sweep' );
400
401 /**
402 * Fires below the sweep table, where the term sweeps used to end.
403 *
404 * @since 1.0.4
405 */
406 do_action( 'wp_sweep_admin_term_sweep' );
407
408 /**
409 * Fires below the sweep table, where the option sweeps used to end.
410 *
411 * @since 1.0.4
412 */
413 do_action( 'wp_sweep_admin_option_sweep' );
414
415 /**
416 * Fires below the sweep table, where the database sweeps used to end.
417 *
418 * @since 1.0.4
419 */
420 do_action( 'wp_sweep_admin_database_sweep' );
421 }
422
423 /**
424 * Act on whatever the request asked for, before anything is rendered.
425 *
426 * Every one of these paths works with JavaScript turned off: the row
427 * actions are real nonced links and the bulk action is a real form post.
428 * The script intercepts the links so the screen does not reload nineteen
429 * times, but nothing depends on it being there.
430 *
431 * @return void
432 */
433 private static function handle_request() {
434 self::$details = array();
435
436 self::handle_bulk_sweep();
437 self::handle_single_sweep();
438 self::handle_details();
439 }
440
441 /**
442 * Sweep everything that was checked.
443 *
444 * @return void
445 */
446 private static function handle_bulk_sweep() {
447 $table = self::list_table();
448
449 if ( 'sweep' !== $table->current_action() ) {
450 return;
451 }
452
453 check_admin_referer( $table->bulk_nonce_action() );
454
455 $sweep = WP_Sweep::get_instance();
456 $posted = isset( $_POST['sweep'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['sweep'] ) ) : array();
457 $messages = array();
458
459 // Filtered from the canonical list rather than taken as given, so the
460 // sweeps run in the order they have to and an unknown name is dropped
461 // rather than passed on.
462 $selected = array_intersect( $sweep->get_sweep_names(), $posted );
463
464 foreach ( $selected as $name ) {
465 $message = $sweep->sweep( $name );
466
467 if ( '' !== $message ) {
468 $messages[] = $message;
469 }
470 }
471
472 /*
473 * Two different outcomes, and they used to share one message.
474 *
475 * Ticking nothing and ticking a sweep that had nothing left in it both
476 * ended with an empty $messages and the screen saying "Nothing was
477 * selected", which is simply false in the second case -- and reachable
478 * now that every row carries a checkbox whether it has anything to sweep
479 * or not.
480 */
481 if ( empty( $selected ) ) {
482 add_settings_error( 'wp_sweep', 'wp_sweep_nothing', __( 'Nothing was selected, so nothing was swept.', 'wp-sweep' ), 'warning' );
483
484 return;
485 }
486
487 if ( empty( $messages ) ) {
488 add_settings_error( 'wp_sweep', 'wp_sweep_nothing', __( 'There was nothing left to sweep.', 'wp-sweep' ), 'warning' );
489
490 return;
491 }
492
493 /*
494 * Joined with wp_sprintf( '%l' ), which builds a localised list -- "a, b
495 * and c" in English, and whatever the locale does instead everywhere
496 * else. Imploding on a space ran them together into "11 Transient Options
497 * Processed 20 Tables Processed", which reads as one broken sentence.
498 *
499 * Escaped here, not by settings_errors(), which prints the message
500 * straight into the page. Every one of these comes back through the
501 * wp_sweep_sweep filter, which is public API and can return anything.
502 */
503 add_settings_error( 'wp_sweep', 'wp_sweep_swept', esc_html( wp_sprintf( '%l', $messages ) ), 'success' );
504 }
505
506 /**
507 * Run the one sweep a row action asked for.
508 *
509 * The name is validated against the plugin's own list before the referer is
510 * checked, so a crafted value never reaches sweep(). The resulting message
511 * is rendered by render_page(); before 2.0.0 it was assigned and then
512 * silently discarded, which meant this path deleted data and told the user
513 * nothing at all.
514 *
515 * @return void
516 */
517 private static function handle_single_sweep() {
518 $sweep = WP_Sweep::get_instance();
519 $name = isset( $_GET['sweep'] ) ? sanitize_key( wp_unslash( $_GET['sweep'] ) ) : '';
520
521 if ( '' === $name || ! $sweep->is_sweep_name_valid( $name ) ) {
522 return;
523 }
524
525 check_admin_referer( 'wp_sweep_' . $name );
526
527 $message = $sweep->sweep( $name );
528
529 if ( '' === $message ) {
530 add_settings_error( 'wp_sweep', 'wp_sweep_nothing', __( 'There was nothing left to sweep.', 'wp-sweep' ), 'warning' );
531
532 return;
533 }
534
535 // Escaped for the reason given in handle_bulk_sweep().
536 add_settings_error( 'wp_sweep', 'wp_sweep_swept', esc_html( $message ), 'success' );
537 }
538
539 /**
540 * Collect the details a row action asked for.
541 *
542 * @return void
543 */
544 private static function handle_details() {
545 $sweep = WP_Sweep::get_instance();
546 $name = isset( $_GET['sweep_details'] ) ? sanitize_key( wp_unslash( $_GET['sweep_details'] ) ) : '';
547
548 if ( '' === $name || ! $sweep->is_sweep_name_valid( $name ) ) {
549 return;
550 }
551
552 check_admin_referer( 'wp_sweep_details_' . $name );
553
554 self::$details = array( $name => (array) $sweep->details( $name ) );
555 }
556
557 /**
558 * The details a row action asked for, keyed by sweep name.
559 *
560 * @return array
561 */
562 public static function requested_details() {
563 return self::$details;
564 }
565
566 /**
567 * Return the details of a sweep over AJAX.
568 *
569 * @return void
570 */
571 public static function ajax_sweep_details() {
572 $sweep = WP_Sweep::get_instance();
573
574 $name = isset( $_GET['sweep_name'] ) ? sanitize_key( wp_unslash( $_GET['sweep_name'] ) ) : '';
575
576 // Permissions and the name are checked before the referer, so a caller
577 // without the capability gets a JSON error rather than the nonce
578 // failure screen.
579 if ( ! current_user_can( WP_Sweep::capability( 'ajax' ) ) || ! $sweep->is_sweep_name_valid( $name ) ) {
580 wp_send_json_error( array( 'error' => __( 'Invalid AJAX request.', 'wp-sweep' ) ) );
581 }
582
583 check_admin_referer( 'wp_sweep_details_' . $name );
584
585 wp_send_json_success( $sweep->details( $name ) );
586 }
587
588 /**
589 * Run a sweep over AJAX and hand back the numbers the screen has to update.
590 *
591 * @return void
592 */
593 public static function ajax_sweep() {
594 $sweep = WP_Sweep::get_instance();
595
596 $name = isset( $_GET['sweep_name'] ) ? sanitize_key( wp_unslash( $_GET['sweep_name'] ) ) : '';
597 $type = isset( $_GET['sweep_type'] ) ? sanitize_key( wp_unslash( $_GET['sweep_type'] ) ) : '';
598
599 if (
600 ! current_user_can( WP_Sweep::capability( 'ajax' ) )
601 || ! $sweep->is_sweep_name_valid( $name )
602 || ! $sweep->is_sweep_type_valid( $type )
603 ) {
604 wp_send_json_error( array( 'error' => __( 'Invalid AJAX request.', 'wp-sweep' ) ) );
605 }
606
607 check_admin_referer( 'wp_sweep_' . $name );
608
609 $message = $sweep->sweep( $name );
610 $count = (int) $sweep->count( $name );
611 $total_count = (int) $sweep->total_count( $type );
612
613 wp_send_json_success(
614 array(
615 'sweep' => $message,
616 'count' => $count,
617 'total' => $total_count,
618 'percentage' => $sweep->format_percentage( $count, $total_count ),
619 'stats' => self::related_totals( $type ),
620 )
621 );
622 }
623
624 /**
625 * The totals that move when a sweep of a given type runs.
626 *
627 * Deleting a post takes its meta with it, so the postmeta total is stale
628 * the moment a post sweep finishes even though nothing swept postmeta.
629 *
630 * @param string $type Sweep type.
631 * @return array Row counts keyed by type.
632 */
633 private static function related_totals( $type ) {
634 $related = array(
635 'posts' => array( 'posts', 'postmeta' ),
636 'postmeta' => array( 'posts', 'postmeta' ),
637 'comments' => array( 'comments', 'commentmeta' ),
638 'commentmeta' => array( 'comments', 'commentmeta' ),
639 'users' => array( 'users', 'usermeta' ),
640 'usermeta' => array( 'users', 'usermeta' ),
641 'term_relationships' => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
642 'term_taxonomy' => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
643 'terms' => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
644 'termmeta' => array( 'term_relationships', 'term_taxonomy', 'terms', 'termmeta' ),
645 'options' => array( 'options' ),
646 'tables' => array( 'tables' ),
647 );
648
649 if ( ! isset( $related[ $type ] ) ) {
650 return array();
651 }
652
653 $sweep = WP_Sweep::get_instance();
654 $totals = array();
655
656 foreach ( $related[ $type ] as $related_type ) {
657 $totals[ $related_type ] = (int) $sweep->total_count( $related_type );
658 }
659
660 return $totals;
661 }
662 }
663