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-list-table.php
wp-sweep / includes Last commit date
class-wp-sweep-admin.php 4 weeks ago class-wp-sweep-api.php 4 weeks ago class-wp-sweep-command.php 4 weeks ago class-wp-sweep-list-table.php 4 weeks ago class-wp-sweep.php 4 weeks ago index.php 4 weeks ago
class-wp-sweep-list-table.php
644 lines
1 <?php
2 /**
3 * The list of sweeps.
4 *
5 * @package WP-Sweep
6 */
7
8 defined( 'ABSPATH' ) || exit;
9
10 if ( ! class_exists( 'WP_List_Table' ) ) {
11 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
12 }
13
14 /**
15 * Lists every sweep, what it would remove, and how to remove it.
16 *
17 * Until 2.0.0 this screen was six hand-written tables carrying the same eleven
18 * lines of markup nineteen times, with the column widths set by an inline
19 * <style> block. One list table replaces all of it, which is also what makes
20 * the bulk action possible: every row on this screen deletes data, and offering
21 * that one row at a time was the reason a full clean-up meant nineteen clicks.
22 *
23 * Every row action is a real, nonced link that works with JavaScript turned
24 * off. The script intercepts them so the page does not reload nineteen times.
25 */
26 class WP_Sweep_List_Table extends WP_List_Table {
27
28 /**
29 * Rows per page.
30 *
31 * **It must stay larger than the number of sweeps the plugin ships**, and
32 * that is a requirement rather than a preference. The unfiltered view groups
33 * its rows under headings, and a heading means "everything below me is a
34 * Post Sweep" -- which stops being true the moment a group is split across a
35 * page boundary. The reader would see a heading on page one and its
36 * remaining rows on page two under no heading at all.
37 *
38 * It is also what keeps "select all" meaning all. §4.3 records the same
39 * reasoning for why wp-dbmanager refuses to paginate its tables list:
40 * paging reduces a select-all to select-this-page, silently.
41 *
42 * The sweep list is a fixed set this plugin ships -- nineteen today -- not
43 * user data that grows, so there is no page size a site can outgrow. Fifty
44 * leaves room for a good many more. `test_the_page_size_cannot_split_a_group`
45 * asserts the relationship rather than the number, so adding sweeps past it
46 * fails the suite instead of quietly breaking the headings.
47 *
48 * @var int
49 */
50 const PER_PAGE = 50;
51
52 /**
53 * Build the table.
54 */
55 public function __construct() {
56 parent::__construct(
57 array(
58 'singular' => 'sweep',
59 'plural' => 'sweeps',
60 'ajax' => false,
61 'screen' => WP_Sweep_Admin::PAGE,
62 )
63 );
64 }
65
66 /**
67 * The table's CSS classes.
68 *
69 * `fixed` is dropped deliberately. It forces every column to the same width
70 * and the details list is far longer than the counts beside it, so keeping
71 * it meant shipping a stylesheet to undo it -- which is how the inline
72 * <style> block got there in the first place.
73 *
74 * @return array
75 */
76 protected function get_table_classes() {
77 return array( 'widefat', 'striped', $this->_args['plural'], 'table-sweep' );
78 }
79
80 /**
81 * The columns, in order.
82 *
83 * @return array
84 */
85 public function get_columns() {
86 return array(
87 'cb' => '<input type="checkbox" />',
88 'name' => _x( 'Sweep', 'Column heading', 'wp-sweep' ),
89 'group' => __( 'Group', 'wp-sweep' ),
90 'count' => __( 'Count', 'wp-sweep' ),
91 'percentage' => __( '%', 'wp-sweep' ),
92 'actions' => __( 'Actions', 'wp-sweep' ),
93 );
94 }
95
96 /**
97 * The Sweep and Details buttons, in a column of their own.
98 *
99 * Not row actions. WordPress hides those until the row is hovered, which is
100 * right for Edit and Trash on a list of posts, where the row itself is the
101 * subject and the actions are secondary. Here the action *is* the subject:
102 * there is nothing else to do with a row but sweep it, and 1.2.0 put a
103 * visible button on every row. Hiding the only verb on the screen behind a
104 * hover -- and behind nothing at all on a touch screen -- was a real
105 * regression, and this is the column that undoes it.
106 *
107 * @param array $item Row.
108 * @return string
109 */
110 public function column_actions( $item ) {
111 if ( 0 === $item['count'] ) {
112 return '<span class="sweep-nothing" aria-hidden="true">&mdash;</span><span class="screen-reader-text">'
113 . esc_html__( 'Nothing to sweep', 'wp-sweep' ) . '</span>';
114 }
115
116 // aria-controls names the region the script reports this sweep's result
117 // into. Naming it here is what stops the script having to find it by
118 // walking the markup: the region is above the form and the row is
119 // inside it, and the walk that used to bridge that gap never once
120 // found the region on a real screen.
121 return $this->action_link( $item, 'sweep', __( 'Sweep', 'wp-sweep' ), 'button button-primary', array( 'aria-controls' => WP_Sweep_Admin::MESSAGE_ID ) )
122 . ' '
123 . $this->action_link( $item, 'sweep_details', __( 'Details', 'wp-sweep' ), 'button', array( 'aria-expanded' => 'false' ) );
124 }
125
126 /**
127 * The columns a user can sort by.
128 *
129 * @return array
130 */
131 protected function get_sortable_columns() {
132 return array(
133 'name' => array( 'name', false ),
134 'group' => array( 'group', false ),
135 'count' => array( 'count', true ),
136 );
137 }
138
139 /**
140 * The column row actions hang off.
141 *
142 * @return string
143 */
144 protected function get_primary_column_name() {
145 return 'name';
146 }
147
148 /**
149 * The nonce action guarding the bulk form.
150 *
151 * WP_List_Table::display_tablenav() prints wp_nonce_field() for this action
152 * itself, in a field named _wpnonce. Printing a second _wpnonce beside it --
153 * which this screen used to do -- does not add a check, it replaces one:
154 * PHP keeps the last field of a repeated name, so whichever the plugin chose
155 * was thrown away and every bulk sweep failed with "The link you followed has
156 * expired". Verify the one core actually emits.
157 *
158 * @return string
159 */
160 public function bulk_nonce_action() {
161 return 'bulk-' . $this->_args['plural'];
162 }
163
164 /**
165 * The actions offered for the checked rows.
166 *
167 * @return array
168 */
169 protected function get_bulk_actions() {
170 return array( 'sweep' => __( 'Sweep', 'wp-sweep' ) );
171 }
172
173 /**
174 * Whether the rows are shown under group headings.
175 *
176 * Only in the unfiltered view, and only while no column is doing the
177 * ordering. Inside a single group the heading would repeat the filter the
178 * reader just clicked, and under a column sort it would be a lie.
179 *
180 * @return bool
181 */
182 private function is_grouped() {
183 $args = self::request_args();
184
185 return 'all' === self::current_group()
186 && ! array_key_exists( $args['orderby'], $this->get_sortable_columns() );
187 }
188
189 /**
190 * Put the rows in group order, keeping each group's own order intact.
191 *
192 * The order of get_sweep_groups() rather than alphabetical, so the headings
193 * read Post, Comment, User, Term, Option, Database -- the order the groups
194 * are declared in and the order the screen has always listed them. Within a
195 * group the rows keep the order get_sweeps() gave them, which is dependency
196 * order: posts are swept before the sweeps that hunt the meta deleting them
197 * just orphaned.
198 *
199 * @param array $rows Rows to order.
200 * @return array
201 */
202 private function order_by_group( $rows ) {
203 $ordered = array();
204
205 foreach ( array_keys( WP_Sweep::get_instance()->get_sweep_groups() ) as $group ) {
206 foreach ( $rows as $row ) {
207 if ( $row['group'] === $group ) {
208 $ordered[] = $row;
209 }
210 }
211 }
212
213 // A row whose group is not one of the six would vanish otherwise. There
214 // is no such row today and nothing should add one, but silently dropping
215 // it would be a worse way to find out.
216 foreach ( $rows as $row ) {
217 if ( ! array_key_exists( $row['group'], WP_Sweep::get_instance()->get_sweep_groups() ) ) {
218 $ordered[] = $row;
219 }
220 }
221
222 return $ordered;
223 }
224
225 /**
226 * The rows, with a heading before each group.
227 *
228 * WP_List_Table has no notion of row groups, so the heading is emitted as an
229 * ordinary row spanning every column. It carries `role="presentation"` on
230 * the cell's checkbox position rather than an empty `<th>`, so the table's
231 * column count stays honest for assistive technology.
232 *
233 * Deliberately one `<tbody>` and not one per group: core's common.js binds
234 * select-all to `#the-list`, and splitting the body would leave the header
235 * checkbox toggling only whichever group came first.
236 *
237 * @return void
238 */
239 public function display_rows() {
240 if ( ! $this->is_grouped() ) {
241 parent::display_rows();
242
243 return;
244 }
245
246 $sweep = WP_Sweep::get_instance();
247 $groups = $sweep->get_sweep_groups();
248 $columns = count( $this->get_columns() );
249 $current = null;
250
251 foreach ( $this->items as $item ) {
252 if ( $item['group'] !== $current ) {
253 $current = $item['group'];
254 $icon = $sweep->get_sweep_group_icon( $current );
255 $label = isset( $groups[ $current ] ) ? $groups[ $current ] : $current;
256
257 printf(
258 '<tr class="wp-sweep-group-heading"><td colspan="%1$s"><strong>%2$s%3$s</strong></td></tr>',
259 esc_attr( $columns ),
260 '' === $icon
261 ? ''
262 : '<span class="dashicons dashicons-' . esc_attr( $icon ) . '" aria-hidden="true"></span> ',
263 esc_html( $label )
264 );
265 }
266
267 $this->single_row( $item );
268 }
269 }
270
271 /**
272 * The group filters above the table.
273 *
274 * @return array
275 */
276 protected function get_views() {
277 $sweeps = WP_Sweep::get_instance()->get_sweeps();
278 $current = self::current_group();
279
280 $views = array(
281 'all' => $this->view_link( 'all', __( 'All', 'wp-sweep' ), count( $sweeps ), $current ),
282 );
283
284 foreach ( WP_Sweep::get_instance()->get_sweep_groups() as $group => $label ) {
285 $views[ $group ] = $this->view_link(
286 $group,
287 $label,
288 count( wp_list_filter( $sweeps, array( 'group' => $group ) ) ),
289 $current
290 );
291 }
292
293 return $views;
294 }
295
296 /**
297 * One group filter link.
298 *
299 * @param string $group Group name, or 'all'.
300 * @param string $label Translated label.
301 * @param int $total Number of sweeps in the group.
302 * @param string $current The group currently being shown.
303 * @return string
304 */
305 private function view_link( $group, $label, $total, $current ) {
306 $url = add_query_arg(
307 array(
308 'page' => WP_Sweep_Admin::PAGE,
309 'group' => $group,
310 ),
311 admin_url( 'tools.php' )
312 );
313
314 return sprintf(
315 '<a href="%1$s"%2$s>%3$s <span class="count">(%4$s)</span></a>',
316 esc_url( $url ),
317 $group === $current ? ' class="current" aria-current="page"' : '',
318 esc_html( $label ),
319 esc_html( number_format_i18n( $total ) )
320 );
321 }
322
323 /**
324 * The three navigation parameters this screen reads off the URL.
325 *
326 * Every read of the query string is here, so there is one place to look.
327 * None of them is form data: they choose which rows are shown and in what
328 * order, and they change nothing. Core builds its own sortable column
329 * headers by swapping orderby and order on the current URL, so the link
330 * carries no nonce and there is nothing to verify -- which is why phpcs.xml
331 * excuses the sniff for *-table.php across the whole collection.
332 *
333 * @return array The group, orderby and order, each already sanitised.
334 */
335 private static function request_args() {
336 $query = wp_unslash( $_GET );
337
338 return array(
339 'group' => isset( $query['group'] ) ? sanitize_key( $query['group'] ) : 'all',
340 'orderby' => isset( $query['orderby'] ) ? sanitize_key( $query['orderby'] ) : '',
341 'order' => isset( $query['order'] ) && 'desc' === strtolower( sanitize_key( $query['order'] ) ) ? 'desc' : 'asc',
342 );
343 }
344
345 /**
346 * The group the request asked for.
347 *
348 * @return string Group name, or 'all'.
349 */
350 public static function current_group() {
351 $group = self::request_args()['group'];
352
353 return array_key_exists( $group, WP_Sweep::get_instance()->get_sweep_groups() ) ? $group : 'all';
354 }
355
356 /**
357 * Gather, filter, sort and paginate the rows.
358 *
359 * @return void
360 */
361 public function prepare_items() {
362 $sweep = WP_Sweep::get_instance();
363 $group = self::current_group();
364
365 $rows = array();
366
367 foreach ( $sweep->get_sweeps() as $name => $args ) {
368 if ( 'all' !== $group && $args['group'] !== $group ) {
369 continue;
370 }
371
372 $count = (int) $sweep->count( $name );
373
374 $rows[] = array(
375 'name' => $name,
376 'label' => $args['label'],
377 'description' => isset( $args['description'] ) ? (string) $args['description'] : '',
378 'type' => $args['type'],
379 'group' => $args['group'],
380 'count' => $count,
381 'percentage' => $sweep->format_percentage( $count, $sweep->total_count( $args['type'] ) ),
382 );
383 }
384
385 $rows = $this->sort( $rows );
386
387 // Group the rows only when nothing else is ordering them. A reader who
388 // has clicked Count wants the biggest sweep first, and headings under
389 // that order would claim a grouping the rows no longer have.
390 if ( $this->is_grouped() ) {
391 $rows = $this->order_by_group( $rows );
392 }
393
394 $total = count( $rows );
395 $page = $this->get_pagenum();
396
397 $this->items = array_slice( $rows, ( $page - 1 ) * self::PER_PAGE, self::PER_PAGE );
398
399 $this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns(), 'name' );
400
401 $this->set_pagination_args(
402 array(
403 'total_items' => $total,
404 'per_page' => self::PER_PAGE,
405 'total_pages' => (int) ceil( $total / self::PER_PAGE ),
406 )
407 );
408 }
409
410 /**
411 * Order the rows by whatever the column headers were clicked for.
412 *
413 * The default is the order the sweeps have to run in, which is the order
414 * get_sweeps() declares them: posts are deleted before the sweeps that hunt
415 * for the meta that deleting them just orphaned.
416 *
417 * @param array $rows Rows to sort.
418 * @return array
419 */
420 private function sort( $rows ) {
421 $args = self::request_args();
422 $orderby = $args['orderby'];
423 $order = $args['order'];
424
425 if ( ! array_key_exists( $orderby, $this->get_sortable_columns() ) ) {
426 return $rows;
427 }
428
429 usort(
430 $rows,
431 static function ( $a, $b ) use ( $orderby ) {
432 if ( 'count' === $orderby ) {
433 return $a['count'] <=> $b['count'];
434 }
435
436 if ( 'group' === $orderby ) {
437 return strcmp( $a['group'], $b['group'] );
438 }
439
440 return strcmp( $a['label'], $b['label'] );
441 }
442 );
443
444 return 'desc' === $order ? array_reverse( $rows ) : $rows;
445 }
446
447 /**
448 * The message shown when a filter leaves nothing to show.
449 *
450 * @return void
451 */
452 public function no_items() {
453 esc_html_e( 'No sweeps to show. Nothing here needs cleaning up.', 'wp-sweep' );
454 }
455
456 /**
457 * The row's checkbox.
458 *
459 * A sweep with nothing to remove gets no checkbox, so "select all" cannot
460 * queue up eighteen no-ops to run one at a time.
461 *
462 * @param array $item Row.
463 * @return string
464 */
465 public function column_cb( $item ) {
466 /*
467 * Every row gets one, including a row with nothing to sweep.
468 *
469 * This used to return an empty string for an empty row, on the reasoning
470 * that a bulk sweep should not be able to queue a no-op. It never
471 * actually prevented one -- the count is a snapshot taken when the page
472 * rendered, and anything that empties or fills a sweep between then and
473 * the form post leaves the checkbox saying the wrong thing either way --
474 * and sweeping an empty sweep removes nothing regardless. What it did do
475 * was leave gaps down the checkbox column and make the header's select-all
476 * claim more rows than it selects, which no core list table does.
477 */
478 return sprintf(
479 '<label class="screen-reader-text" for="sweep_%1$s">%2$s</label><input type="checkbox" id="sweep_%1$s" name="sweep[]" value="%1$s" />',
480 esc_attr( $item['name'] ),
481 /* translators: %s is the name of a sweep. */
482 esc_html( sprintf( __( 'Select %s', 'wp-sweep' ), $item['label'] ) )
483 );
484 }
485
486 /**
487 * The name column, with its row actions and its details container.
488 *
489 * @param array $item Row.
490 * @return string
491 */
492 public function column_name( $item ) {
493 $name = '<strong>' . esc_html( $item['label'] ) . '</strong>';
494
495 // Every sweep says what it removes. These screens delete data that does
496 // not come back, and "Orphaned Term Relationships" tells a site owner
497 // nothing about whether it is safe to tick.
498 if ( ! empty( $item['description'] ) ) {
499 $name .= '<p class="description">' . esc_html( $item['description'] ) . '</p>';
500 }
501
502 $details = WP_Sweep_Admin::requested_details();
503
504 if ( isset( $details[ $item['name'] ] ) && ! empty( $details[ $item['name'] ] ) ) {
505 $list = '';
506
507 foreach ( $details[ $item['name'] ] as $detail ) {
508 // Every entry here came out of the database -- post titles,
509 // comment author names, meta keys. A comment author name is
510 // supplied by whoever left the comment.
511 $list .= '<li>' . esc_html( $detail ) . '</li>';
512 }
513
514 // A div rather than the p this used to be. An <ol> inside a <p> is
515 // not valid, and a browser does not merely tolerate it: the parser
516 // closes the paragraph before the list, so .sweep-details ended up
517 // empty and the <ol> became its sibling. Anyone with JavaScript
518 // never saw the bug, because renderDetails() builds the same list
519 // through the DOM and gets what it asked for -- so only visitors
520 // without it lost the details entirely.
521 //
522 // The <ol> stays inside rather than becoming .sweep-details itself,
523 // so that this markup and the one renderDetails() produces are the
524 // same shape. The two paths disagreeing is what the bug was.
525 $name .= '<div class="sweep-details"><ol>' . $list . '</ol></div>';
526 } else {
527 $name .= '<div class="sweep-details" hidden></div>';
528 }
529
530 if ( 0 === $item['count'] ) {
531 return $name;
532 }
533
534 // No row_actions() here: the buttons live in their own always-visible
535 // column. See column_actions().
536 return $name;
537 }
538
539 /**
540 * One row action.
541 *
542 * The href is a working, nonced request. The script reads the same name,
543 * type and nonce back off the data attributes and calls admin-ajax.php
544 * instead, so the row updates in place rather than reloading the screen.
545 *
546 * @param array $item Row.
547 * @param string $action Either sweep or sweep_details.
548 * @param string $label Translated link text.
549 * @param string $classes Extra CSS classes, so the same link can be drawn
550 * as a button.
551 * @param array $attributes Extra attributes, already-escaped values keyed
552 * by attribute name.
553 * @return string
554 */
555 private function action_link( $item, $action, $label, $classes = '', $attributes = array() ) {
556 $nonce = 'sweep' === $action ? 'wp_sweep_' . $item['name'] : 'wp_sweep_details_' . $item['name'];
557
558 $url = wp_nonce_url(
559 add_query_arg(
560 array(
561 'page' => WP_Sweep_Admin::PAGE,
562 'group' => self::current_group(),
563 $action => $item['name'],
564 ),
565 admin_url( 'tools.php' )
566 ),
567 $nonce
568 );
569
570 $extra = '';
571
572 foreach ( $attributes as $attribute => $value ) {
573 $extra .= sprintf( ' %1$s="%2$s"', esc_attr( $attribute ), esc_attr( $value ) );
574 }
575
576 return sprintf(
577 '<a href="%1$s" class="%2$s" data-action="%3$s" data-sweep-name="%4$s" data-sweep-type="%5$s" data-nonce="%6$s"%7$s>%8$s</a>',
578 esc_url( $url ),
579 trim( ( 'sweep' === $action ? 'btn-sweep' : 'btn-sweep-details' ) . ' ' . $classes ),
580 esc_attr( $action ),
581 esc_attr( $item['name'] ),
582 esc_attr( $item['type'] ),
583 esc_attr( wp_create_nonce( $nonce ) ),
584 $extra,
585 esc_html( $label )
586 );
587 }
588
589 /**
590 * The count column.
591 *
592 * @param array $item Row.
593 * @return string
594 */
595 public function column_count( $item ) {
596 // Bold only a count there is something to do about. A run of bold zeroes
597 // reads as emphasis on nothing, and the whole point of this column is to
598 // show at a glance which rows are worth ticking. <strong> rather than a
599 // stylesheet: this plugin ships no CSS, and one rule does not earn the
600 // first file.
601 //
602 // The emphasis is the element carrying the class, never a tag nested
603 // inside it. js/wp-sweep-admin.js updates this cell after a sweep with
604 // `.textContent =`, which replaces everything between the tags -- so a
605 // <strong> *within* the span would survive exactly until the first sweep
606 // and then vanish, on a screen nobody reloads. The script demotes the
607 // element to a <span> itself once the count reaches zero.
608 $tag = $item['count'] > 0 ? 'strong' : 'span';
609
610 return sprintf(
611 '<%1$s class="sweep-count">%2$s</%1$s>',
612 $tag,
613 esc_html( number_format_i18n( $item['count'] ) )
614 );
615 }
616
617 /**
618 * The percentage column.
619 *
620 * @param array $item Row.
621 * @return string
622 */
623 public function column_percentage( $item ) {
624 return '<span class="sweep-percentage">' . esc_html( $item['percentage'] ) . '</span>';
625 }
626
627 /**
628 * Anything without a column method of its own.
629 *
630 * @param array $item Row.
631 * @param string $column_name Column being rendered.
632 * @return string
633 */
634 public function column_default( $item, $column_name ) {
635 if ( 'group' === $column_name ) {
636 $groups = WP_Sweep::get_instance()->get_sweep_groups();
637
638 return esc_html( isset( $groups[ $item['group'] ] ) ? $groups[ $item['group'] ] : $item['group'] );
639 }
640
641 return isset( $item[ $column_name ] ) ? esc_html( $item[ $column_name ] ) : '';
642 }
643 }
644