PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.1
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.1
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
← All changes | includes/ajax/dashboard.php +650 -6 2.9.02.13.1 View file →
@@ -8,8 +8,11 @@
8 8
9 9 use ABlocks\Classes\AbstractAjaxHandler;
10 10 use ABlocks\Classes\FileUpload;
11 11 use ABlocks\Classes\FontLoadLocally;
12 +use ABlocks\Classes\FontCollector;
13 +use ABlocks\Classes\FontStack;
14 +use ABlocks\Classes\DesignAudit;
12 15 use ABlocks\Helper;
13 16
14 17 class Dashboard extends AbstractAjaxHandler {
15 18 public function __construct() {
@@ -15,36 +18,316 @@
15 18 public function __construct() {
16 19 $this->actions = array(
17 20 'get_admin_menu_items' => array(
18 21 'callback' => array( $this, 'get_admin_menu_items' ),
19 - 'capability' => 'manage_options',
22 + 'capability' => 'ablocks_access',
20 23 ),
21 24 'regenerate_assets' => array(
22 25 'callback' => array( $this, 'regenerate_assets' ),
23 - 'capability' => 'manage_options',
26 + 'capability' => 'ablocks_manage_performance',
24 27 ),
28 + 'page_cache_status' => array(
29 + 'callback' => array( $this, 'page_cache_status' ),
30 + 'capability' => 'ablocks_manage_performance',
31 + ),
32 + 'purge_page_cache' => array(
33 + 'callback' => array( $this, 'purge_page_cache' ),
34 + 'capability' => 'ablocks_manage_performance',
35 + ),
36 + 'run_scanner' => array(
37 + 'callback' => array( $this, 'run_scanner' ),
38 + 'capability' => 'ablocks_run_scanner',
39 + ),
40 + 'scanner_dismiss' => array(
41 + 'callback' => array( $this, 'scanner_dismiss' ),
42 + 'capability' => 'ablocks_run_scanner',
43 + 'fields' => array(
44 + 'id' => 'string',
45 + 'dismiss' => 'boolean',
46 + ),
47 + ),
48 + 'scanner_apply_fix' => array(
49 + 'callback' => array( $this, 'scanner_apply_fix' ),
50 + 'capability' => 'ablocks_run_scanner',
51 + 'fields' => array(
52 + 'field' => 'string',
53 + ),
54 + ),
55 + 'image_stats' => array(
56 + 'callback' => array( $this, 'image_stats' ),
57 + 'capability' => 'ablocks_manage_performance',
58 + ),
59 + 'image_optimize_batch' => array(
60 + 'callback' => array( $this, 'image_optimize_batch' ),
61 + 'capability' => 'ablocks_manage_performance',
62 + 'fields' => array(
63 + 'level' => 'string',
64 + 'webp' => 'boolean',
65 + 'all' => 'boolean',
66 + ),
67 + ),
68 + 'image_discard_originals' => array(
69 + 'callback' => array( $this, 'image_discard_originals' ),
70 + 'capability' => 'ablocks_manage_performance',
71 + ),
72 + 'image_restore_all' => array(
73 + 'callback' => array( $this, 'image_restore_all' ),
74 + 'capability' => 'ablocks_manage_performance',
75 + ),
76 + 'image_scan_unused' => array(
77 + 'callback' => array( $this, 'image_scan_unused' ),
78 + 'capability' => 'ablocks_manage_performance',
79 + 'fields' => array(
80 + 'offset' => 'integer',
81 + ),
82 + ),
83 + 'image_scan_duplicates' => array(
84 + 'callback' => array( $this, 'image_scan_duplicates' ),
85 + 'capability' => 'ablocks_manage_performance',
86 + 'fields' => array(
87 + 'offset' => 'integer',
88 + ),
89 + ),
90 + 'image_quarantine' => array(
91 + 'callback' => array( $this, 'image_quarantine' ),
92 + 'capability' => 'ablocks_manage_performance',
93 + 'fields' => array(
94 + 'ids' => 'json',
95 + ),
96 + ),
97 + 'image_quarantine_action' => array(
98 + 'callback' => array( $this, 'image_quarantine_action' ),
99 + 'capability' => 'ablocks_manage_performance',
100 + 'fields' => array(
101 + 'do' => 'string',
102 + 'id' => 'integer',
103 + ),
104 + ),
25 105 'clear_demo_transients' => array(
26 106 'callback' => array( $this, 'clear_demo_transients' ),
27 - 'capability' => 'manage_options',
107 + 'capability' => 'ablocks_import_templates',
28 108 ),
29 109 'install_academy_lms' => array(
30 110 'callback' => array( $this, 'install_academy_lms' ),
31 - 'capability' => 'manage_options',
111 + 'capability' => 'install_plugins',
32 112 ),
33 113 'install_storeengine' => array(
34 114 'callback' => array( $this, 'install_storeengine' ),
35 - 'capability' => 'manage_options',
115 + 'capability' => 'install_plugins',
36 116 ),
37 117 'install_ecm' => array(
38 118 'callback' => array( $this, 'install_ecm' ),
119 + 'capability' => 'install_plugins',
39 120 ),
40 121 'download_google_fonts' => array(
41 122 'callback' => array( $this, 'download_google_fonts' ),
42 - 'capability' => 'manage_options',
123 + 'capability' => 'ablocks_manage_global_styles',
43 124 ),
125 + 'font_audit' => array(
126 + 'callback' => array( $this, 'font_audit' ),
127 + 'capability' => 'ablocks_manage_global_styles',
128 + ),
129 + 'design_audit' => array(
130 + 'callback' => array( $this, 'design_audit' ),
131 + 'capability' => 'ablocks_manage_global_styles',
132 + ),
44 133 );
45 134 }
46 135
136 + /**
137 + * Which content still carries hand-picked typography or colours instead of a
138 + * global preset.
139 + *
140 + * The editor lock stops new ones being made; it deliberately leaves existing
141 + * content alone so turning the setting on can't restyle a live site. This is
142 + * how you find what is already there and fix it on purpose.
143 + */
144 + public function design_audit() {
145 + $post_ids = get_posts(
146 + [
147 + 'post_type' => array_merge(
148 + [ 'post', 'page' ],
149 + FontCollector::get_site_font_post_types()
150 + ),
151 + 'post_status' => [ 'publish', 'draft', 'pending', 'private' ],
152 + 'posts_per_page' => 200,
153 + 'fields' => 'ids',
154 + 'no_found_rows' => true,
155 + 'update_post_meta_cache' => false,
156 + 'update_post_term_cache' => false,
157 + ]
158 + );
159 +
160 + $pages = [];
161 + $families = 0;
162 + $typography = 0;
163 + $colors = 0;
164 +
165 + foreach ( $post_ids as $post_id ) {
166 + $post = get_post( $post_id );
167 + if ( ! $post instanceof \WP_Post || false === strpos( (string) $post->post_content, 'wp:ablocks' ) ) {
168 + continue;
169 + }
170 +
171 + $found = DesignAudit::scan_blocks( parse_blocks( $post->post_content ) );
172 + if ( empty( $found['families'] ) && empty( $found['typography'] ) && empty( $found['colors'] ) ) {
173 + continue;
174 + }
175 +
176 + $families += count( $found['families'] );
177 + $typography += count( $found['typography'] );
178 + $colors += count( $found['colors'] );
179 +
180 + $pages[] = [
181 + 'id' => $post_id,
182 + 'title' => get_the_title( $post_id ),
183 + 'type' => get_post_type( $post_id ),
184 + 'edit_link' => get_edit_post_link( $post_id, 'raw' ),
185 + 'families' => array_values( array_unique( $found['families'] ) ),
186 + 'typography' => array_values( array_unique( $found['typography'] ) ),
187 + 'colors' => array_values( array_unique( $found['colors'] ) ),
188 + // Sorted by the things the lock actually governs.
189 + 'total' => count( $found['families'] ) + count( $found['colors'] ),
190 + ];
191 + }//end foreach
192 +
193 + // Worst offenders first — that is where cleanup pays.
194 + usort(
195 + $pages,
196 + function ( $a, $b ) {
197 + return $b['total'] <=> $a['total'];
198 + }
199 + );
200 +
201 + wp_send_json_success(
202 + [
203 + 'pages' => array_slice( $pages, 0, 50 ),
204 + 'total_pages' => count( $pages ),
205 + 'total_families' => $families,
206 + 'total_typography' => $typography,
207 + 'total_colors' => $colors,
208 + 'scanned' => count( $post_ids ),
209 + 'typography_locked' => (bool) Helper::get_settings( 'lock_global_typography', false ),
210 + 'strict_locked' => (bool) Helper::get_settings( 'lock_global_typography_strict', false ),
211 + 'colors_locked' => (bool) Helper::get_settings( 'lock_global_colors', false ),
212 + ]
213 + );
214 + }
215 +
216 + /**
217 + * Which fonts this site loads, where they come from, and where they are used.
218 + *
219 + * Answers the questions that make font problems hard to debug: is this family
220 + * self-hosted or still coming from Google, does it have a zero-shift fallback,
221 + * and which page is pulling in the eleventh weight.
222 + */
223 + public function font_audit() {
224 + $loader = new FontLoadLocally();
225 +
226 + $global_fonts = FontCollector::get_global_fonts();
227 + $site_fonts = FontCollector::get_site_fonts();
228 +
229 + $post_ids = get_posts(
230 + [
231 + 'post_type' => 'any',
232 + 'post_status' => [ 'publish', 'draft', 'pending', 'private' ],
233 + 'posts_per_page' => 100,
234 + 'fields' => 'ids',
235 + 'no_found_rows' => true,
236 + 'update_post_term_cache' => false,
237 + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
238 + [
239 + 'key' => FontCollector::POST_META_KEY,
240 + 'compare' => 'EXISTS',
241 + ],
242 + ],
243 + ]
244 + );
245 +
246 + $all_fonts = FontCollector::merge( $global_fonts, $site_fonts );
247 + $pages = [];
248 +
249 + foreach ( $post_ids as $post_id ) {
250 + $fonts = get_post_meta( $post_id, FontCollector::POST_META_KEY, true );
251 + if ( empty( $fonts ) || ! is_array( $fonts ) ) {
252 + continue;
253 + }
254 +
255 + $all_fonts = FontCollector::merge( $all_fonts, $fonts );
256 +
257 + $weight_count = 0;
258 + foreach ( $fonts as $weights ) {
259 + $weight_count += count( (array) $weights );
260 + }
261 +
262 + $pages[] = [
263 + 'id' => $post_id,
264 + 'title' => get_the_title( $post_id ),
265 + 'type' => get_post_type( $post_id ),
266 + 'edit_link' => get_edit_post_link( $post_id, 'raw' ),
267 + 'families' => array_keys( $fonts ),
268 + 'weights' => $weight_count,
269 + ];
270 + }//end foreach
271 +
272 + // Heaviest pages first — that is where a fix is worth making.
273 + usort(
274 + $pages,
275 + function ( $a, $b ) {
276 + return $b['weights'] <=> $a['weights'];
277 + }
278 + );
279 +
280 + $families = [];
281 + foreach ( $all_fonts as $family => $weights ) {
282 + $weights = array_values( array_unique( array_map( 'strval', (array) $weights ) ) );
283 + sort( $weights );
284 +
285 + $missing = $loader->get_missing( [ $family => $weights ] );
286 +
287 + $families[] = [
288 + 'family' => $family,
289 + 'weights' => $weights,
290 + 'category' => FontStack::get_category( $family ),
291 + 'stack' => FontStack::build( $family ),
292 + 'self_hosted' => empty( $missing ),
293 + 'has_metrics' => FontStack::has_metrics( $family ),
294 + 'in_global' => isset( $global_fonts[ $family ] ),
295 + 'in_site' => isset( $site_fonts[ $family ] ),
296 + ];
297 + }
298 +
299 + usort(
300 + $families,
301 + function ( $a, $b ) {
302 + return strcasecmp( $a['family'], $b['family'] );
303 + }
304 + );
305 +
306 + $total_weights = 0;
307 + foreach ( $all_fonts as $weights ) {
308 + $total_weights += count( (array) $weights );
309 + }
310 +
311 + wp_send_json_success(
312 + [
313 + 'families' => $families,
314 + 'pages' => array_slice( $pages, 0, 25 ),
315 + 'total_families' => count( $families ),
316 + 'total_weights' => $total_weights,
317 + 'remote_families' => count(
318 + array_filter(
319 + $families,
320 + function ( $item ) {
321 + return ! $item['self_hosted'];
322 + }
323 + )
324 + ),
325 + 'metric_fallbacks' => FontStack::metric_fallback_enabled(),
326 + ]
327 + );
328 + }
329 +
47 330 public function get_admin_menu_items() {
48 331 $menu_items = wp_json_encode( Helper::get_admin_menu_list() );
49 332 wp_send_json_success( $menu_items );
50 333 }
@@ -53,8 +336,369 @@
53 336 $has_delete = $FileUpload->delete_files();
54 337 Helper::clear_third_party_plugin_cache();
55 338 update_option( ABLOCKS_FONTS_SETTINGS_NAME, '{}' );
56 339 wp_send_json_success( $has_delete );
340 + }
341 +
342 + /**
343 + * Cache occupancy and environment, for the Performance settings tab.
344 + *
345 + * `object_cache` is reported because the template cache is inert without a
346 + * persistent one — its toggle would otherwise appear to do nothing, and the
347 + * UI needs to say why rather than leave the user guessing.
348 + */
349 + public function page_cache_status() {
350 + $stats = \ABlocks\Classes\PageCache\Store::stats();
351 +
352 + $css_dir = \ABlocks\Performance\StyleConsolidator::base_dir();
353 + $css_files = is_dir( $css_dir ) ? glob( $css_dir . '/*.css' ) : [];
354 +
355 + wp_send_json_success(
356 + array(
357 + 'pages' => (int) $stats['pages'],
358 + 'files' => (int) $stats['files'],
359 + 'bytes' => (int) $stats['bytes'],
360 + 'size' => size_format( $stats['bytes'], 2 ),
361 + 'css_files' => is_array( $css_files ) ? count( $css_files ) : 0,
362 + 'object_cache' => \ABlocks\Classes\CacheBackend::is_persistent(),
363 + )
364 + );
365 + }
366 +
367 + /**
368 + * Empty every cache this plugin owns, and report what is left.
369 + *
370 + * Consolidated stylesheets are deliberately NOT removed here. They are
371 + * content-addressed and referenced by cached HTML, so deleting them while a
372 + * cached page still points at one would strip that page's styling. They are
373 + * reclaimed by age instead, via StyleConsolidator::prune().
374 + */
375 + public function purge_page_cache() {
376 + $removed = \ABlocks\Classes\PageCache\Store::flush();
377 +
378 + // Fragments and resolved templates live behind generation counters, so
379 + // bumping those invalidates them without a scan.
380 + \ABlocks\Performance\FragmentCache::bump_version();
381 + \ABlocks\Performance\TemplateCache::maybe_bump_version();
382 +
383 + Helper::clear_third_party_plugin_cache();
384 +
385 + $stats = \ABlocks\Classes\PageCache\Store::stats();
386 +
387 + wp_send_json_success(
388 + array(
389 + 'removed' => (int) $removed,
390 + 'pages' => (int) $stats['pages'],
391 + 'size' => size_format( $stats['bytes'], 2 ),
392 + )
393 + );
394 + }
395 +
396 + /**
397 + * Audit the site and report what to fix.
398 + */
399 + public function run_scanner() {
400 + // is_plugin_active() lives in an admin include that is not loaded for
401 + // AJAX requests, and the conflicting-cache check depends on it.
402 + if ( ! function_exists( 'is_plugin_active' ) ) {
403 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
404 + }
405 +
406 + wp_send_json_success( \ABlocks\Classes\Scanner\Scanner::run() );
407 + }
408 +
409 + /**
410 + * Ignore a check, or bring an ignored one back.
411 + *
412 + * Kept rather than deleted: an ignored check still appears in its own list
413 + * with an undo, so a decision made months ago stays visible instead of
414 + * quietly disappearing from the report.
415 + *
416 + * @param array $payload Request fields.
417 + */
418 + public function scanner_dismiss( $payload ) {
419 + $id = ! empty( $payload['id'] ) ? sanitize_key( $payload['id'] ) : '';
420 + if ( '' === $id ) {
421 + wp_send_json_error( __( 'No check given.', 'ablocks' ) );
422 + }
423 +
424 + if ( ! function_exists( 'is_plugin_active' ) ) {
425 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
426 + }
427 +
428 + \ABlocks\Classes\Scanner\Scanner::set_dismissed( $id, ! empty( $payload['dismiss'] ) );
429 +
430 + wp_send_json_success( array( 'report' => \ABlocks\Classes\Scanner\Scanner::run() ) );
431 + }
432 +
433 + /**
434 + * Switch on one setting the scanner recommended.
435 + *
436 + * The allowed fields are taken from the scanner's own current output rather
437 + * than from a hard-coded list. That means this endpoint can only ever change
438 + * something the scanner is, right now, asking the user to change — a request
439 + * naming any other option is rejected, and the two can never drift apart.
440 + *
441 + * @param array $payload Request fields.
442 + */
443 + public function scanner_apply_fix( $payload ) {
444 + $field = ! empty( $payload['field'] ) ? sanitize_key( $payload['field'] ) : '';
445 + if ( '' === $field ) {
446 + wp_send_json_error( __( 'No setting given.', 'ablocks' ) );
447 + }
448 +
449 + if ( ! function_exists( 'is_plugin_active' ) ) {
450 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
451 + }
452 +
453 + $report = \ABlocks\Classes\Scanner\Scanner::run();
454 + $allowed = array();
455 + foreach ( $report['checks'] as $check ) {
456 + if ( ! empty( $check['field'] ) ) {
457 + $allowed[] = $check['field'];
458 + }
459 + }
460 +
461 + if ( ! in_array( $field, $allowed, true ) ) {
462 + wp_send_json_error( __( 'That setting is not one the scanner offers to change.', 'ablocks' ) );
463 + }
464 +
465 + $settings = json_decode( get_option( ABLOCKS_SETTINGS_NAME, '{}' ), true );
466 + $settings = is_array( $settings ) ? $settings : array();
467 + $settings[ $field ] = true;
468 +
469 + update_option( ABLOCKS_SETTINGS_NAME, wp_json_encode( $settings ) );
470 +
471 + // The scanner reads settings through the global that is populated at
472 + // bootstrap, so refresh it before re-running or the fix appears not to
473 + // have taken effect.
474 + $GLOBALS['ablocks_settings'] = json_decode( wp_json_encode( $settings ) );
475 +
476 + wp_send_json_success(
477 + array(
478 + 'field' => $field,
479 + 'report' => \ABlocks\Classes\Scanner\Scanner::run(),
480 + )
481 + );
482 + }
483 +
484 + /**
485 + * Totals for the Images panel.
486 + */
487 + public function image_stats() {
488 + wp_send_json_success( \ABlocks\Performance\ImageTools::stats() );
489 + }
490 +
491 + /**
492 + * Optimize one batch of images.
493 + *
494 + * Deliberately a small batch per request rather than one long job: PHP
495 + * timeouts and memory limits are the normal failure mode for bulk media
496 + * work, and a browser that walks batches can report progress and resume.
497 + *
498 + * @param array $payload Request fields.
499 + */
500 + public function image_optimize_batch( $payload ) {
501 + $level = ! empty( $payload['level'] ) ? (string) $payload['level'] : '2x';
502 + $webp = ! empty( $payload['webp'] );
503 + $all = ! empty( $payload['all'] );
504 +
505 + $ids = \ABlocks\Classes\Images\Compressor::pending_ids( 5, $all );
506 + $before = 0;
507 + $after = 0;
508 + $done = array();
509 +
510 + foreach ( $ids as $id ) {
511 + $result = \ABlocks\Classes\Images\Compressor::optimize_attachment( $id, $level, $webp );
512 + $before += (int) $result['before'];
513 + $after += (int) $result['after'];
514 +
515 + // Named so the UI can show what it is working through rather than an
516 + // anonymous counter.
517 + $done[] = array(
518 + 'id' => (int) $id,
519 + 'title' => get_the_title( $id ),
520 + 'thumb' => wp_get_attachment_image_url( $id, 'thumbnail' ),
521 + 'before' => size_format( (int) $result['before'], 1 ),
522 + 'after' => size_format( (int) $result['after'], 1 ),
523 + 'saved' => $result['before'] > 0
524 + ? round( 100 * ( $result['before'] - $result['after'] ) / $result['before'] )
525 + : 0,
526 + );
527 + }
528 +
529 + $stats = \ABlocks\Performance\ImageTools::stats();
530 +
531 + wp_send_json_success(
532 + array(
533 + 'processed' => count( $ids ),
534 + 'done' => count( $ids ) < 5,
535 + 'items' => $done,
536 + 'saved' => size_format( max( 0, $before - $after ), 1 ),
537 + 'stats' => $stats,
538 + )
539 + );
540 + }
541 +
542 + /**
543 + * Delete every stored original, reclaiming the disk they occupy.
544 + *
545 + * Irreversible by definition: it removes the only copy that "restore" could
546 + * have restored, and the only clean source a different strength could have
547 + * been applied from. Each affected attachment is flagged so a later run
548 + * refuses rather than silently adopting the compressed file as its baseline.
549 + */
550 + public function image_discard_originals() {
551 + $ids = \ABlocks\Classes\Images\Compressor::ids_with_originals();
552 + $freed = 0;
553 +
554 + foreach ( $ids as $id ) {
555 + $freed += \ABlocks\Classes\Images\Compressor::discard_originals( $id );
556 + }
557 +
558 + wp_send_json_success(
559 + array(
560 + 'cleared' => count( $ids ),
561 + 'freed' => size_format( $freed, 1 ),
562 + 'stats' => \ABlocks\Performance\ImageTools::stats(),
563 + )
564 + );
565 + }
566 +
567 + /**
568 + * Put every optimized image back to its original.
569 + */
570 + public function image_restore_all() {
571 + $ids = \ABlocks\Classes\Images\Compressor::pending_ids( 10000, true );
572 + $count = 0;
573 + foreach ( $ids as $id ) {
574 + $count += \ABlocks\Classes\Images\Compressor::restore_attachment( $id );
575 + }
576 +
577 + wp_send_json_success(
578 + array(
579 + 'restored' => $count,
580 + 'stats' => \ABlocks\Performance\ImageTools::stats(),
581 + )
582 + );
583 + }
584 +
585 + /**
586 + * Scan a slice of the media library for unreferenced images.
587 + *
588 + * @param array $payload Request fields.
589 + */
590 + public function image_scan_unused( $payload ) {
591 + $offset = isset( $payload['offset'] ) ? (int) $payload['offset'] : 0;
592 + $batch = \ABlocks\Classes\Images\UnusedScanner::scan( 25, $offset );
593 +
594 + foreach ( $batch['items'] as &$item ) {
595 + $item['size'] = size_format( $item['bytes'], 1 );
596 + }
597 + unset( $item );
598 +
599 + wp_send_json_success( $batch );
600 + }
601 +
602 + /**
603 + * Hash a slice of the library, and return the duplicate groups once done.
604 + *
605 + * Hashing reads whole files, so it is batched like the other bulk tools —
606 + * the browser walks it rather than one request trying to read the entire
607 + * media library before PHP gives up.
608 + *
609 + * @param array $payload Request fields.
610 + */
611 + public function image_scan_duplicates( $payload ) {
612 + $offset = isset( $payload['offset'] ) ? (int) $payload['offset'] : 0;
613 + $batch = \ABlocks\Classes\Images\DuplicateScanner::scan( 40, $offset );
614 +
615 + // Groups are only assembled at the end: reporting them mid-scan would
616 + // show duplicates that turn out to have a third copy further down.
617 + $batch['groups'] = $batch['done'] ? \ABlocks\Classes\Images\DuplicateScanner::groups() : array();
618 + $batch['summary'] = $batch['done'] ? \ABlocks\Classes\Images\DuplicateScanner::summary() : null;
619 +
620 + wp_send_json_success( $batch );
621 + }
622 +
623 + /**
624 + * Move reviewed images into quarantine.
625 + *
626 + * @param array $payload Request fields.
627 + */
628 + public function image_quarantine( $payload ) {
629 + $ids = isset( $payload['ids'] ) ? (array) $payload['ids'] : array();
630 + $ids = array_filter( array_map( 'intval', $ids ) );
631 +
632 + $held = 0;
633 + $bytes = 0;
634 + $skipped = array();
635 +
636 + foreach ( $ids as $id ) {
637 + $result = \ABlocks\Classes\Images\Quarantine::hold( $id );
638 + if ( $result['ok'] ) {
639 + $held++;
640 + $bytes += (int) $result['bytes'];
641 + } else {
642 + $skipped[] = array(
643 + 'id' => $id,
644 + 'reason' => $result['message'],
645 + );
646 + }
647 + }
648 +
649 + wp_send_json_success(
650 + array(
651 + 'held' => $held,
652 + 'freed' => size_format( $bytes, 1 ),
653 + 'skipped' => $skipped,
654 + 'stats' => \ABlocks\Performance\ImageTools::stats(),
655 + )
656 + );
657 + }
658 +
659 + /**
660 + * List, restore from, or empty the quarantine.
661 + *
662 + * @param array $payload Request fields.
663 + */
664 + public function image_quarantine_action( $payload ) {
665 + $do = ! empty( $payload['do'] ) ? sanitize_key( $payload['do'] ) : 'list';
666 +
667 + if ( 'restore' === $do ) {
668 + $ok = \ABlocks\Classes\Images\Quarantine::restore( (int) $payload['id'] );
669 + wp_send_json_success(
670 + array(
671 + 'ok' => (bool) $ok,
672 + 'items' => array_values( \ABlocks\Classes\Images\Quarantine::records() ),
673 + 'stats' => \ABlocks\Performance\ImageTools::stats(),
674 + )
675 + );
676 + }
677 +
678 + if ( 'sweep' === $do ) {
679 + // Zero days: the user asked to empty it now, so retention does not
680 + // apply. Retention protects against forgetting, not against intent.
681 + $result = \ABlocks\Classes\Images\Quarantine::sweep( 0 );
682 + wp_send_json_success(
683 + array(
684 + 'deleted' => (int) $result['deleted'],
685 + 'freed' => size_format( (int) $result['bytes'], 1 ),
686 + 'items' => array_values( \ABlocks\Classes\Images\Quarantine::records() ),
687 + 'stats' => \ABlocks\Performance\ImageTools::stats(),
688 + )
689 + );
690 + }
691 +
692 + $items = array_values( \ABlocks\Classes\Images\Quarantine::records() );
693 + foreach ( $items as &$item ) {
694 + $item['size'] = size_format( isset( $item['bytes'] ) ? (int) $item['bytes'] : 0, 1 );
695 + $item['held'] = isset( $item['time'] ) ? gmdate( 'Y-m-d', (int) $item['time'] ) : '';
696 + unset( $item['files'] );
697 + }
698 + unset( $item );
699 +
700 + wp_send_json_success( array( 'items' => $items ) );
57 701 }
58 702
59 703 public function clear_demo_transients() {
60 704 global $wpdb;