PluginProbe ʕ •ᴥ•ʔ
WP-Sweep / 2.0.1
WP-Sweep v2.0.1
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 5 days ago class-wp-sweep-api.php 2 weeks ago class-wp-sweep-command.php 2 weeks ago class-wp-sweep-list-table.php 5 days ago class-wp-sweep.php 5 days ago index.php 2 weeks ago
class-wp-sweep-list-table.php
733 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 // Strict, so a deferred count (null) keeps its buttons: the row cannot
112 // know yet that there is nothing to sweep, and sweeping an empty sweep
113 // reports "nothing left" rather than doing any harm. The script swaps
114 // the buttons for the dash once a zero arrives.
115 if ( 0 === $item['count'] ) {
116 return '<span class="sweep-nothing" aria-hidden="true">&mdash;</span><span class="screen-reader-text">'
117 . esc_html__( 'Nothing to sweep', 'wp-sweep' ) . '</span>';
118 }
119
120 // aria-controls names the region the script reports this sweep's result
121 // into. Naming it here is what stops the script having to find it by
122 // walking the markup: the region is above the form and the row is
123 // inside it, and the walk that used to bridge that gap never once
124 // found the region on a real screen.
125 return $this->action_link( $item, 'sweep', __( 'Sweep', 'wp-sweep' ), 'button button-primary', array( 'aria-controls' => WP_Sweep_Admin::MESSAGE_ID ) )
126 . ' '
127 . $this->action_link( $item, 'sweep_details', __( 'Details', 'wp-sweep' ), 'button', array( 'aria-expanded' => 'false' ) );
128 }
129
130 /**
131 * The columns a user can sort by.
132 *
133 * @return array
134 */
135 protected function get_sortable_columns() {
136 return array(
137 'name' => array( 'name', false ),
138 'group' => array( 'group', false ),
139 'count' => array( 'count', true ),
140 );
141 }
142
143 /**
144 * The column row actions hang off.
145 *
146 * @return string
147 */
148 protected function get_primary_column_name() {
149 return 'name';
150 }
151
152 /**
153 * The nonce action guarding the bulk form.
154 *
155 * WP_List_Table::display_tablenav() prints wp_nonce_field() for this action
156 * itself, in a field named _wpnonce. Printing a second _wpnonce beside it --
157 * which this screen used to do -- does not add a check, it replaces one:
158 * PHP keeps the last field of a repeated name, so whichever the plugin chose
159 * was thrown away and every bulk sweep failed with "The link you followed has
160 * expired". Verify the one core actually emits.
161 *
162 * @return string
163 */
164 public function bulk_nonce_action() {
165 return 'bulk-' . $this->_args['plural'];
166 }
167
168 /**
169 * The actions offered for the checked rows.
170 *
171 * @return array
172 */
173 protected function get_bulk_actions() {
174 return array( 'sweep' => __( 'Sweep', 'wp-sweep' ) );
175 }
176
177 /**
178 * Whether the rows are shown under group headings.
179 *
180 * Only in the unfiltered view, and only while no column is doing the
181 * ordering. Inside a single group the heading would repeat the filter the
182 * reader just clicked, and under a column sort it would be a lie.
183 *
184 * @return bool
185 */
186 private function is_grouped() {
187 $args = self::request_args();
188
189 return 'all' === self::current_group()
190 && ! array_key_exists( $args['orderby'], $this->get_sortable_columns() );
191 }
192
193 /**
194 * Put the rows in group order, keeping each group's own order intact.
195 *
196 * The order of get_sweep_groups() rather than alphabetical, so the headings
197 * read Post, Comment, User, Term, Option, Database -- the order the groups
198 * are declared in and the order the screen has always listed them. Within a
199 * group the rows keep the order get_sweeps() gave them, which is dependency
200 * order: posts are swept before the sweeps that hunt the meta deleting them
201 * just orphaned.
202 *
203 * @param array $rows Rows to order.
204 * @return array
205 */
206 private function order_by_group( $rows ) {
207 $ordered = array();
208
209 foreach ( array_keys( WP_Sweep::get_instance()->get_sweep_groups() ) as $group ) {
210 foreach ( $rows as $row ) {
211 if ( $row['group'] === $group ) {
212 $ordered[] = $row;
213 }
214 }
215 }
216
217 // A row whose group is not one of the six would vanish otherwise. There
218 // is no such row today and nothing should add one, but silently dropping
219 // it would be a worse way to find out.
220 foreach ( $rows as $row ) {
221 if ( ! array_key_exists( $row['group'], WP_Sweep::get_instance()->get_sweep_groups() ) ) {
222 $ordered[] = $row;
223 }
224 }
225
226 return $ordered;
227 }
228
229 /**
230 * The rows, with a heading before each group.
231 *
232 * WP_List_Table has no notion of row groups, so the heading is emitted as an
233 * ordinary row spanning every column. It carries `role="presentation"` on
234 * the cell's checkbox position rather than an empty `<th>`, so the table's
235 * column count stays honest for assistive technology.
236 *
237 * Deliberately one `<tbody>` and not one per group: core's common.js binds
238 * select-all to `#the-list`, and splitting the body would leave the header
239 * checkbox toggling only whichever group came first.
240 *
241 * @return void
242 */
243 public function display_rows() {
244 if ( ! $this->is_grouped() ) {
245 parent::display_rows();
246
247 return;
248 }
249
250 $sweep = WP_Sweep::get_instance();
251 $groups = $sweep->get_sweep_groups();
252 $columns = count( $this->get_columns() );
253 $current = null;
254
255 foreach ( $this->items as $item ) {
256 if ( $item['group'] !== $current ) {
257 $current = $item['group'];
258 $icon = $sweep->get_sweep_group_icon( $current );
259 $label = isset( $groups[ $current ] ) ? $groups[ $current ] : $current;
260
261 printf(
262 '<tr class="wp-sweep-group-heading"><td colspan="%1$s"><strong>%2$s%3$s</strong></td></tr>',
263 esc_attr( $columns ),
264 '' === $icon
265 ? ''
266 : '<span class="dashicons dashicons-' . esc_attr( $icon ) . '" aria-hidden="true"></span> ',
267 esc_html( $label )
268 );
269 }
270
271 $this->single_row( $item );
272 }
273 }
274
275 /**
276 * The group filters above the table.
277 *
278 * @return array
279 */
280 protected function get_views() {
281 $sweeps = WP_Sweep::get_instance()->get_sweeps();
282 $current = self::current_group();
283
284 $views = array(
285 'all' => $this->view_link( 'all', __( 'All', 'wp-sweep' ), count( $sweeps ), $current ),
286 );
287
288 foreach ( WP_Sweep::get_instance()->get_sweep_groups() as $group => $label ) {
289 $views[ $group ] = $this->view_link(
290 $group,
291 $label,
292 count( wp_list_filter( $sweeps, array( 'group' => $group ) ) ),
293 $current
294 );
295 }
296
297 return $views;
298 }
299
300 /**
301 * One group filter link.
302 *
303 * @param string $group Group name, or 'all'.
304 * @param string $label Translated label.
305 * @param int $total Number of sweeps in the group.
306 * @param string $current The group currently being shown.
307 * @return string
308 */
309 private function view_link( $group, $label, $total, $current ) {
310 $url = add_query_arg(
311 array(
312 'page' => WP_Sweep_Admin::PAGE,
313 'group' => $group,
314 ),
315 admin_url( 'tools.php' )
316 );
317
318 return sprintf(
319 '<a href="%1$s"%2$s>%3$s <span class="count">(%4$s)</span></a>',
320 esc_url( $url ),
321 $group === $current ? ' class="current" aria-current="page"' : '',
322 esc_html( $label ),
323 esc_html( number_format_i18n( $total ) )
324 );
325 }
326
327 /**
328 * The four navigation parameters this screen reads off the URL.
329 *
330 * Every read of the query string is here, so there is one place to look.
331 * None of them is form data: they choose which rows are shown and in what
332 * order, and they change nothing. Core builds its own sortable column
333 * headers by swapping orderby and order on the current URL, so the link
334 * carries no nonce and there is nothing to verify -- which is why phpcs.xml
335 * excuses the sniff for *-table.php across the whole collection.
336 *
337 * @return array The group, orderby, order and counts, each already sanitised.
338 */
339 private static function request_args() {
340 $query = wp_unslash( $_GET );
341
342 return array(
343 'group' => isset( $query['group'] ) ? sanitize_key( $query['group'] ) : 'all',
344 'orderby' => isset( $query['orderby'] ) ? sanitize_key( $query['orderby'] ) : '',
345 'order' => isset( $query['order'] ) && 'desc' === strtolower( sanitize_key( $query['order'] ) ) ? 'desc' : 'asc',
346 'counts' => isset( $query['counts'] ) ? sanitize_key( $query['counts'] ) : '',
347 );
348 }
349
350 /**
351 * Whether the request asked for the counts to be computed with the page.
352 *
353 * This is the no-JavaScript path to the numbers: the counts are normally
354 * left out of the render and fetched afterwards, and a real link carrying
355 * counts=now is what a reader without the script clicks instead.
356 *
357 * @return bool
358 */
359 public static function counts_requested_now() {
360 return 'now' === self::request_args()['counts'];
361 }
362
363 /**
364 * Whether the counts are left out of the render and fetched afterwards.
365 *
366 * The counts are the expensive half of this screen: every one is a query
367 * against a table WordPress cannot answer for from cache, and 2.0.0
368 * computing all of them before printing a byte is what turned the screen
369 * into a white page on sites whose meta tables have grown into the
370 * millions of rows. Deferred, the screen renders at once and the script
371 * fills the numbers in one sweep at a time.
372 *
373 * Two requests still compute them with the page, because the render cannot
374 * be right without them: counts=now (the no-JavaScript link), and a sort
375 * on the Count column, which cannot order rows by numbers it does not
376 * have.
377 *
378 * @return bool
379 */
380 public static function defer_counts() {
381 if ( self::counts_requested_now() || 'count' === self::request_args()['orderby'] ) {
382 return false;
383 }
384
385 /**
386 * Filters whether the Sweep screen defers its counts to the script.
387 *
388 * @since 2.0.1
389 *
390 * @param bool $defer Whether to defer. Default true.
391 */
392 return (bool) apply_filters( 'wp_sweep_defer_counts', true );
393 }
394
395 /**
396 * The group the request asked for.
397 *
398 * @return string Group name, or 'all'.
399 */
400 public static function current_group() {
401 $group = self::request_args()['group'];
402
403 return array_key_exists( $group, WP_Sweep::get_instance()->get_sweep_groups() ) ? $group : 'all';
404 }
405
406 /**
407 * Gather, filter, sort and paginate the rows.
408 *
409 * @return void
410 */
411 public function prepare_items() {
412 $sweep = WP_Sweep::get_instance();
413 $group = self::current_group();
414 $defer = self::defer_counts();
415
416 $rows = array();
417
418 // One total per table, not one per row. Three postmeta sweeps share a
419 // denominator, and total_count() is a COUNT(*) that InnoDB answers by
420 // scanning an index -- asking three times was a third of what made
421 // loading this screen time out on large sites.
422 $totals = array();
423
424 foreach ( $sweep->get_sweeps() as $name => $args ) {
425 if ( 'all' !== $group && $args['group'] !== $group ) {
426 continue;
427 }
428
429 if ( $defer ) {
430 $count = null;
431 $percentage = null;
432 } else {
433 $count = (int) $sweep->count( $name );
434
435 if ( ! array_key_exists( $args['type'], $totals ) ) {
436 $totals[ $args['type'] ] = $sweep->total_count( $args['type'] );
437 }
438
439 $percentage = $sweep->format_percentage( $count, $totals[ $args['type'] ] );
440 }
441
442 $rows[] = array(
443 'name' => $name,
444 'label' => $args['label'],
445 'description' => isset( $args['description'] ) ? (string) $args['description'] : '',
446 'type' => $args['type'],
447 'group' => $args['group'],
448 'count' => $count,
449 'percentage' => $percentage,
450 );
451 }
452
453 $rows = $this->sort( $rows );
454
455 // Group the rows only when nothing else is ordering them. A reader who
456 // has clicked Count wants the biggest sweep first, and headings under
457 // that order would claim a grouping the rows no longer have.
458 if ( $this->is_grouped() ) {
459 $rows = $this->order_by_group( $rows );
460 }
461
462 $total = count( $rows );
463 $page = $this->get_pagenum();
464
465 $this->items = array_slice( $rows, ( $page - 1 ) * self::PER_PAGE, self::PER_PAGE );
466
467 $this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns(), 'name' );
468
469 $this->set_pagination_args(
470 array(
471 'total_items' => $total,
472 'per_page' => self::PER_PAGE,
473 'total_pages' => (int) ceil( $total / self::PER_PAGE ),
474 )
475 );
476 }
477
478 /**
479 * Order the rows by whatever the column headers were clicked for.
480 *
481 * The default is the order the sweeps have to run in, which is the order
482 * get_sweeps() declares them: posts are deleted before the sweeps that hunt
483 * for the meta that deleting them just orphaned.
484 *
485 * @param array $rows Rows to sort.
486 * @return array
487 */
488 private function sort( $rows ) {
489 $args = self::request_args();
490 $orderby = $args['orderby'];
491 $order = $args['order'];
492
493 if ( ! array_key_exists( $orderby, $this->get_sortable_columns() ) ) {
494 return $rows;
495 }
496
497 usort(
498 $rows,
499 static function ( $a, $b ) use ( $orderby ) {
500 if ( 'count' === $orderby ) {
501 return $a['count'] <=> $b['count'];
502 }
503
504 if ( 'group' === $orderby ) {
505 return strcmp( $a['group'], $b['group'] );
506 }
507
508 return strcmp( $a['label'], $b['label'] );
509 }
510 );
511
512 return 'desc' === $order ? array_reverse( $rows ) : $rows;
513 }
514
515 /**
516 * The message shown when a filter leaves nothing to show.
517 *
518 * @return void
519 */
520 public function no_items() {
521 esc_html_e( 'No sweeps to show. Nothing here needs cleaning up.', 'wp-sweep' );
522 }
523
524 /**
525 * The row's checkbox.
526 *
527 * A sweep with nothing to remove gets no checkbox, so "select all" cannot
528 * queue up eighteen no-ops to run one at a time.
529 *
530 * @param array $item Row.
531 * @return string
532 */
533 public function column_cb( $item ) {
534 /*
535 * Every row gets one, including a row with nothing to sweep.
536 *
537 * This used to return an empty string for an empty row, on the reasoning
538 * that a bulk sweep should not be able to queue a no-op. It never
539 * actually prevented one -- the count is a snapshot taken when the page
540 * rendered, and anything that empties or fills a sweep between then and
541 * the form post leaves the checkbox saying the wrong thing either way --
542 * and sweeping an empty sweep removes nothing regardless. What it did do
543 * was leave gaps down the checkbox column and make the header's select-all
544 * claim more rows than it selects, which no core list table does.
545 */
546 return sprintf(
547 '<label class="screen-reader-text" for="sweep_%1$s">%2$s</label><input type="checkbox" id="sweep_%1$s" name="sweep[]" value="%1$s" />',
548 esc_attr( $item['name'] ),
549 /* translators: %s is the name of a sweep. */
550 esc_html( sprintf( __( 'Select %s', 'wp-sweep' ), $item['label'] ) )
551 );
552 }
553
554 /**
555 * The name column, with its row actions and its details container.
556 *
557 * @param array $item Row.
558 * @return string
559 */
560 public function column_name( $item ) {
561 $name = '<strong>' . esc_html( $item['label'] ) . '</strong>';
562
563 // Every sweep says what it removes. These screens delete data that does
564 // not come back, and "Orphaned Term Relationships" tells a site owner
565 // nothing about whether it is safe to tick.
566 if ( ! empty( $item['description'] ) ) {
567 $name .= '<p class="description">' . esc_html( $item['description'] ) . '</p>';
568 }
569
570 $details = WP_Sweep_Admin::requested_details();
571
572 if ( isset( $details[ $item['name'] ] ) && ! empty( $details[ $item['name'] ] ) ) {
573 $list = '';
574
575 foreach ( $details[ $item['name'] ] as $detail ) {
576 // Every entry here came out of the database -- post titles,
577 // comment author names, meta keys. A comment author name is
578 // supplied by whoever left the comment.
579 $list .= '<li>' . esc_html( $detail ) . '</li>';
580 }
581
582 // A div rather than the p this used to be. An <ol> inside a <p> is
583 // not valid, and a browser does not merely tolerate it: the parser
584 // closes the paragraph before the list, so .sweep-details ended up
585 // empty and the <ol> became its sibling. Anyone with JavaScript
586 // never saw the bug, because renderDetails() builds the same list
587 // through the DOM and gets what it asked for -- so only visitors
588 // without it lost the details entirely.
589 //
590 // The <ol> stays inside rather than becoming .sweep-details itself,
591 // so that this markup and the one renderDetails() produces are the
592 // same shape. The two paths disagreeing is what the bug was.
593 $name .= '<div class="sweep-details"><ol>' . $list . '</ol></div>';
594 } else {
595 $name .= '<div class="sweep-details" hidden></div>';
596 }
597
598 if ( 0 === $item['count'] ) {
599 return $name;
600 }
601
602 // No row_actions() here: the buttons live in their own always-visible
603 // column. See column_actions().
604 return $name;
605 }
606
607 /**
608 * One row action.
609 *
610 * The href is a working, nonced request. The script reads the same name,
611 * type and nonce back off the data attributes and calls admin-ajax.php
612 * instead, so the row updates in place rather than reloading the screen.
613 *
614 * @param array $item Row.
615 * @param string $action Either sweep or sweep_details.
616 * @param string $label Translated link text.
617 * @param string $classes Extra CSS classes, so the same link can be drawn
618 * as a button.
619 * @param array $attributes Extra attributes, already-escaped values keyed
620 * by attribute name.
621 * @return string
622 */
623 private function action_link( $item, $action, $label, $classes = '', $attributes = array() ) {
624 $nonce = 'sweep' === $action ? 'wp_sweep_' . $item['name'] : 'wp_sweep_details_' . $item['name'];
625
626 $args = array(
627 'page' => WP_Sweep_Admin::PAGE,
628 'group' => self::current_group(),
629 $action => $item['name'],
630 );
631
632 // A reader on the counts=now view has no script following these links,
633 // so the reload the link causes has to stay on that view -- dropping
634 // the parameter would sweep correctly and then land them back on a
635 // screen of ellipses.
636 if ( self::counts_requested_now() ) {
637 $args['counts'] = 'now';
638 }
639
640 $url = wp_nonce_url( add_query_arg( $args, admin_url( 'tools.php' ) ), $nonce );
641
642 $extra = '';
643
644 foreach ( $attributes as $attribute => $value ) {
645 $extra .= sprintf( ' %1$s="%2$s"', esc_attr( $attribute ), esc_attr( $value ) );
646 }
647
648 return sprintf(
649 '<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>',
650 esc_url( $url ),
651 trim( ( 'sweep' === $action ? 'btn-sweep' : 'btn-sweep-details' ) . ' ' . $classes ),
652 esc_attr( $action ),
653 esc_attr( $item['name'] ),
654 esc_attr( $item['type'] ),
655 esc_attr( wp_create_nonce( $nonce ) ),
656 $extra,
657 esc_html( $label )
658 );
659 }
660
661 /**
662 * The count column.
663 *
664 * @param array $item Row.
665 * @return string
666 */
667 public function column_count( $item ) {
668 // A count that was deferred rather than computed. The cell carries
669 // everything the script needs to fetch the number -- the same
670 // name/type/nonce vocabulary the row actions use -- and an ellipsis for
671 // anyone reading before it lands. The no-JavaScript path to a real
672 // number is the counts=now link the screen prints in a <noscript>.
673 if ( null === $item['count'] ) {
674 return sprintf(
675 '<span class="sweep-count sweep-count-pending" data-action="wp_sweep_count" data-sweep-name="%1$s" data-sweep-type="%2$s" data-nonce="%3$s">&hellip;</span>',
676 esc_attr( $item['name'] ),
677 esc_attr( $item['type'] ),
678 esc_attr( wp_create_nonce( 'wp_sweep_count_' . $item['name'] ) )
679 );
680 }
681
682 // Bold only a count there is something to do about. A run of bold zeroes
683 // reads as emphasis on nothing, and the whole point of this column is to
684 // show at a glance which rows are worth ticking. <strong> rather than a
685 // stylesheet: this plugin ships no CSS, and one rule does not earn the
686 // first file.
687 //
688 // The emphasis is the element carrying the class, never a tag nested
689 // inside it. js/wp-sweep-admin.js updates this cell after a sweep with
690 // `.textContent =`, which replaces everything between the tags -- so a
691 // <strong> *within* the span would survive exactly until the first sweep
692 // and then vanish, on a screen nobody reloads. The script demotes the
693 // element to a <span> itself once the count reaches zero.
694 $tag = $item['count'] > 0 ? 'strong' : 'span';
695
696 return sprintf(
697 '<%1$s class="sweep-count">%2$s</%1$s>',
698 $tag,
699 esc_html( number_format_i18n( $item['count'] ) )
700 );
701 }
702
703 /**
704 * The percentage column.
705 *
706 * @param array $item Row.
707 * @return string
708 */
709 public function column_percentage( $item ) {
710 // Empty while the count is deferred: the script writes the percentage
711 // in beside the count, and an ellipsis in two columns would say
712 // "loading" twice about one fetch.
713 return '<span class="sweep-percentage">' . ( null === $item['percentage'] ? '' : esc_html( $item['percentage'] ) ) . '</span>';
714 }
715
716 /**
717 * Anything without a column method of its own.
718 *
719 * @param array $item Row.
720 * @param string $column_name Column being rendered.
721 * @return string
722 */
723 public function column_default( $item, $column_name ) {
724 if ( 'group' === $column_name ) {
725 $groups = WP_Sweep::get_instance()->get_sweep_groups();
726
727 return esc_html( isset( $groups[ $item['group'] ] ) ? $groups[ $item['group'] ] : $item['group'] );
728 }
729
730 return isset( $item[ $column_name ] ) ? esc_html( $item[ $column_name ] ) : '';
731 }
732 }
733