| @@ -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; |