*/ function abcw_initial_sources() { $sources = ['home' => 1, 'archives' => 1]; $internal = abcw_internal_post_types(); foreach (get_post_types(['public' => true], 'names') as $post_type) { if ('attachment' === $post_type || in_array($post_type, $internal, true)) { continue; } $sources['pt_' . $post_type] = 1; } return $sources; } /** * Taxonomies that are machinery rather than content. * * Post formats are a dead letter, the archives exist, but no theme this * decade sends visitors to them, and the builder taxonomies only organise * templates. None of these produce a page worth warming. * * @return array */ function abcw_internal_taxonomies() { return (array) apply_filters( 'abcw_internal_taxonomies', [ 'post_format', 'elementor_library_type', 'elementor_library_category', 'elementor_font_type', ] ); } /** * Whether WordPress serves attachment pages at all. * * Since 6.4 a fresh install redirects attachment URLs straight to the file, so * there is no page to warm, offering thousands of redirects as a source would * be a trap, not an option. * * @return bool */ function abcw_attachment_pages_enabled() { // Missing option means pre-6.4, where attachment pages always existed. return (bool) get_option('wp_attachment_pages_enabled', true); } /** * Post types that are machinery rather than content. * * Page builders register their template and global-style post types as public, * so they turn up alongside real content and get warmed as URLs like * /?elementor_library=default-kit, pages no visitor ever requests. They are * still listed on the settings screen, just switched off to begin with, because * the odd site really does surface them. * * @return array */ function abcw_internal_post_types() { return (array) apply_filters( 'abcw_internal_post_types', [ // Elementor 'elementor_library', 'e-landing-page', 'e-floating-buttons', 'elementor_font', 'elementor_icons', // Beaver Builder 'fl-builder-template', 'fl-theme-layout', // Divi 'et_pb_layout', 'et_theme_builder', // Oxygen, Brizy 'ct_template', 'brizy_template', // Core internals 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'oembed_cache', 'user_request', 'custom_css', 'customize_changeset', ] ); } /** * Default plugin settings. * * Concurrency and delay here are placeholders only, they are replaced by the * calibrated values from abcw_server_probe() the first time the page loads. * * @return array */ function abcw_default_settings() { return [ 'sources' => abcw_initial_sources(), 'concurrency' => 5, 'delay' => 100, 'timeout' => 15, 'adaptive' => 1, 'ssl_verify' => 0, 'schedule' => 'none', 'schedule_time' => '03:00', 'schedule_time2' => '15:00', 'schedule_day' => 0, // Warming after a purge is the job this plugin exists to do, so it is on // out of the box. Publishing usually purges anyway, which would make the // publish trigger a duplicate, that one is opt-in. 'on_purge' => 1, 'on_publish' => 0, 'purge_delay' => 60, 'keep_days' => 30, 'keep_runs' => 20, 'include' => '', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- This plugin's own "URLs to skip" setting, not a WP_Query argument. 'exclude' => '', ]; } /** * Get merged plugin settings. * * @return array */ function abcw_get_settings() { $saved = get_option('abcw_settings', []); if (!is_array($saved)) { $saved = []; } $settings = array_merge(abcw_default_settings(), $saved); if (!is_array($settings['sources'])) { $settings['sources'] = abcw_initial_sources(); } // A source registered since the last save, a newly installed custom post // type, say, falls back to its default instead of silently staying cold. $settings['sources'] += abcw_initial_sources(); return $settings; } /** * Sanitise and persist settings coming from the admin screen. * * @param array $raw Raw settings payload. * @return array The stored settings. */ function abcw_save_settings($raw) { $settings = abcw_get_settings(); if (isset($raw['sources']) && is_array($raw['sources'])) { $valid = array_keys(abcw_source_definitions()); $sources = []; foreach ($valid as $key) { $sources[$key] = !empty($raw['sources'][$key]) ? 1 : 0; } $settings['sources'] = $sources; } if (isset($raw['concurrency'])) { $settings['concurrency'] = max(1, min(20, (int) $raw['concurrency'])); } if (isset($raw['delay'])) { $settings['delay'] = max(0, min(5000, (int) $raw['delay'])); } if (isset($raw['timeout'])) { $settings['timeout'] = max(2, min(60, (int) $raw['timeout'])); } if (isset($raw['adaptive'])) { $settings['adaptive'] = !empty($raw['adaptive']) ? 1 : 0; } if (isset($raw['ssl_verify'])) { $settings['ssl_verify'] = !empty($raw['ssl_verify']) ? 1 : 0; } $schedule_dirty = false; if (isset($raw['schedule_time']) && preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', trim((string) $raw['schedule_time']))) { $time = trim((string) $raw['schedule_time']); if ($time !== $settings['schedule_time']) { $settings['schedule_time'] = $time; $schedule_dirty = true; } } if (isset($raw['schedule_time2']) && preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', trim((string) $raw['schedule_time2']))) { $time2 = trim((string) $raw['schedule_time2']); if ($time2 !== $settings['schedule_time2']) { $settings['schedule_time2'] = $time2; $schedule_dirty = true; } } if (isset($raw['schedule_day'])) { $day = max(0, min(6, (int) $raw['schedule_day'])); if ($day !== (int) $settings['schedule_day']) { $settings['schedule_day'] = $day; $schedule_dirty = true; } } if (isset($raw['schedule'])) { $interval = sanitize_key($raw['schedule']); if (!array_key_exists($interval, abcw_schedule_options())) { $interval = 'none'; } if ($interval !== $settings['schedule']) { $settings['schedule'] = $interval; $schedule_dirty = true; } } // Only touch WP-Cron when something about the schedule actually changed, // so saving an unrelated setting does not silently push the next run back. if ($schedule_dirty) { abcw_apply_schedule($settings['schedule'], $settings['schedule_time'], (int) $settings['schedule_day'], $settings['schedule_time2']); } if (isset($raw['on_purge'])) { $settings['on_purge'] = !empty($raw['on_purge']) ? 1 : 0; } if (isset($raw['on_publish'])) { $settings['on_publish'] = !empty($raw['on_publish']) ? 1 : 0; } if (isset($raw['purge_delay'])) { $settings['purge_delay'] = max(0, min(HOUR_IN_SECONDS, (int) $raw['purge_delay'])); } if (isset($raw['keep_days'])) { $settings['keep_days'] = max(1, min(365, (int) $raw['keep_days'])); } if (isset($raw['keep_runs'])) { $settings['keep_runs'] = max(1, min(100, (int) $raw['keep_runs'])); } if (isset($raw['include'])) { $settings['include'] = sanitize_textarea_field($raw['include']); } if (isset($raw['exclude'])) { // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Plugin setting (URL skip list), not a WP_Query argument. $settings['exclude'] = sanitize_textarea_field($raw['exclude']); } // Nothing left to listen for, so drop anything a purge already queued. if (empty($settings['on_purge']) && empty($settings['on_publish'])) { wp_unschedule_hook('abcw_auto_warm'); wp_unschedule_hook('abcw_warm_pages'); delete_option('abcw_page_queue'); } update_option('abcw_settings', $settings, false); return $settings; } // ─── Server profiling ───────────────────────────────────────────────────────── /** * Best-effort CPU core count. Returns 0 when it cannot be determined. * * Cached for the request: this is now read once per chunk to judge server load, * and re-reading a virtual file that cannot change mid-request would be waste. * * @return int */ function abcw_cpu_cores() { static $cores = null; if (null !== $cores) { return $cores; } $cores = 0; if (!@is_readable('/proc/cpuinfo')) { return 0; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- Virtual kernel file, not part of the WP filesystem. $info = @file_get_contents('/proc/cpuinfo'); if (!$info) { return 0; } $cores = (int) preg_match_all('/^processor\s*:/mi', $info); return $cores; } /** * How hard the server is working right now, as load per CPU core. * * 1.0 means the machine has exactly as much work queued as it has cores, the * point where everything still runs but nothing is spare. Below 0.5 there is * real headroom; above 2.0 requests are already queueing and the last thing the * site needs is a crawler adding more. * * Returns null when the figure cannot be trusted: no load average (Windows, or * disabled by the host) or no core count, in which case the response-time * throttle is left to do the work on its own. * * @return float|null Load per core, or null if unavailable. */ function abcw_load_pressure() { if (!function_exists('sys_getloadavg')) { return null; } $load = @sys_getloadavg(); if (!is_array($load) || !isset($load[0])) { return null; } $cores = abcw_cpu_cores(); if ($cores < 1) { return null; } return (float) $load[0] / $cores; } /** * Translate server load into a crawl ceiling. * * The response-time throttle already reacts to the site getting slower. This * reacts to the *machine* being busy, which is not the same thing: on shared * hosting the box can be flat out serving someone else's site while yours still * answers quickly, and piling requests on regardless is how a cache warmer ends * up making things worse. Load average is a one-minute figure, so it is the * slow, steady signal, response time stays the fast one. * * @param float|null $pressure Load per core, from abcw_load_pressure(). * @param int $ceiling Concurrency the settings allow. * @return array{concurrency: int, delay: int, pause: bool} */ function abcw_load_ceiling($pressure, $ceiling) { $steps = (array) apply_filters( 'abcw_load_steps', [ // [load per core, share of concurrency allowed, minimum delay ms] ['at' => 2.0, 'share' => 0.0, 'delay' => 1000], ['at' => 1.0, 'share' => 0.2, 'delay' => 500], ['at' => 0.75, 'share' => 0.35, 'delay' => 250], ['at' => 0.5, 'share' => 0.6, 'delay' => 100], ] ); if (null === $pressure) { return ['concurrency' => $ceiling, 'delay' => 0, 'pause' => false]; } // The match below takes the first threshold the pressure clears, so the // steps must run highest-first, enforced here rather than assumed, since // the list is filterable. usort( $steps, function ($a, $b) { return $b['at'] <=> $a['at']; } ); foreach ($steps as $step) { if ($pressure < $step['at']) { continue; } return [ 'concurrency' => max(1, (int) floor($ceiling * $step['share'])), 'delay' => (int) $step['delay'], // A share of zero means there is no safe amount to run right now. 'pause' => $step['share'] <= 0, ]; } return ['concurrency' => $ceiling, 'delay' => 0, 'pause' => false]; } /** * Convert a PHP shorthand byte value (128M, 1G, -1) to bytes. * * @param string $value Shorthand value. * @return int Bytes, or 0 for unlimited/unknown. */ function abcw_bytes_from_ini($value) { $value = trim((string) $value); if ('' === $value || '-1' === $value) { return 0; } $unit = strtolower(substr($value, -1)); $number = (int) $value; switch ($unit) { case 'g': return $number * 1024 * 1024 * 1024; case 'm': return $number * 1024 * 1024; case 'k': return $number * 1024; } return $number; } /** * Detect a known managed WordPress host, if any. * * @return string Host label, or an empty string. */ function abcw_detect_host() { if (defined('KINSTA_CACHE_ZONE') || defined('KINSTAMU_VERSION')) { return 'Kinsta'; } if (defined('WPE_APIKEY') || defined('WPE_CLUSTER_ID')) { return 'WP Engine'; } if (defined('IS_PRESSABLE') && IS_PRESSABLE) { return 'Pressable'; } if (defined('FLYWHEEL_CONFIG_DIR')) { return 'Flywheel'; } if (defined('SG_CACHEPRESS_VERSION')) { return 'SiteGround'; } if (defined('GD_SYSTEM_PLUGIN_DIR')) { return 'GoDaddy'; } if (defined('WPCOMSH_VERSION')) { return 'WordPress.com'; } return ''; } /** * Measure the site's real time-to-first-byte with a few loopback requests. * * Requests are cache-busted so we measure genuine PHP + DB response time rather * than an already-cached page. This is the single most useful signal for * deciding how hard we can afford to crawl. * * @param int $samples Number of probes to take. * @return float|null Median seconds, or null if every probe failed. */ function abcw_measure_ttfb($samples = 3) { $times = []; for ($i = 0; $i < $samples; $i++) { $url = add_query_arg('abcw-probe', $i . '-' . wp_rand(1000, 9999), home_url('/')); $start = microtime(true); $response = wp_remote_get( $url, [ 'timeout' => 10, 'redirection' => 2, 'sslverify' => false, 'user-agent' => abcw_user_agent(), 'headers' => ['Cache-Control' => 'no-cache'], ] ); if (!is_wp_error($response)) { $times[] = microtime(true) - $start; } } if (empty($times)) { return null; } sort($times); return $times[(int) floor((count($times) - 1) / 2)]; } /** * Build a server profile and derive safe crawl defaults from it. * * @return array */ function abcw_server_probe() { $memory_bytes = abcw_bytes_from_ini(ini_get('memory_limit')); $cores = abcw_cpu_cores(); $load = function_exists('sys_getloadavg') ? sys_getloadavg() : null; $ttfb = abcw_measure_ttfb(); // Base tier from measured response time. if (null === $ttfb) { $concurrency = 2; $delay = 500; $tier = 'unknown'; } elseif ($ttfb < 0.3) { $concurrency = 8; $delay = 0; $tier = 'fast'; } elseif ($ttfb < 0.8) { $concurrency = 5; $delay = 100; $tier = 'good'; } elseif ($ttfb < 2.0) { $concurrency = 3; $delay = 250; $tier = 'modest'; } else { $concurrency = 2; $delay = 500; $tier = 'slow'; } $notes = []; // A memory-starved process cannot hold many concurrent response bodies. if ($memory_bytes > 0 && $memory_bytes < 128 * 1024 * 1024) { $concurrency = max(1, (int) floor($concurrency / 2)); $notes[] = __('Low PHP memory limit, concurrency reduced', 'abcode-cache-warmer'); } // Back off when the box is already busy. if (is_array($load) && isset($load[0]) && $cores > 0 && $load[0] > ($cores * 0.7)) { $concurrency = max(1, (int) floor($concurrency / 2)); $delay = max($delay, 250); $notes[] = __('Server load is high right now, crawling gently', 'abcode-cache-warmer'); } $host = abcw_detect_host(); if ('' !== $host) { /* translators: %s: hosting provider name */ $notes[] = sprintf(__('%s detected', 'abcode-cache-warmer'), $host); } $profile = [ 'tier' => $tier, 'ttfb' => null === $ttfb ? null : (int) round($ttfb * 1000), 'concurrency' => $concurrency, 'delay' => $delay, 'memory_limit' => ini_get('memory_limit'), 'max_exec' => (int) ini_get('max_execution_time'), 'cores' => $cores, 'load' => is_array($load) && isset($load[0]) ? round($load[0], 2) : null, 'host' => $host, 'parallel' => abcw_supports_parallel(), 'object_cache' => function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache(), 'notes' => $notes, 'measured_at' => time(), ]; update_option('abcw_server_profile', $profile, false); return $profile; } /** * Get the stored server profile, measuring one if it is missing. * * @param bool $force Force a fresh measurement. * @return array */ function abcw_get_profile($force = false) { $profile = get_option('abcw_server_profile', null); if ($force || !is_array($profile) || empty($profile['measured_at'])) { return abcw_server_probe(); } return $profile; } /** * Whether the Requests library bundled with WordPress can issue parallel requests. * * @return bool */ function abcw_supports_parallel() { return function_exists('curl_multi_init') && (class_exists('WpOrg\Requests\Requests') || class_exists('Requests')); } /** * Request headers sent with every warming request. * * The user agent has to look like a browser or several caching layers refuse to * build an entry, which means server-side analytics cannot spot the warmer by * UA alone. So the requests declare themselves two other ways: a DNT header, * which privacy-respecting PHP analytics already honour, and an identifying * header that log tooling can filter on. Caches vary on neither, so cache * entries built by the warmer are identical to ones built by real visitors. * * @return array */ function abcw_request_headers() { return (array) apply_filters( 'abcw_request_headers', [ 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language' => 'en-US,en;q=0.9', 'Upgrade-Insecure-Requests' => '1', 'DNT' => '1', 'X-ABCode-Cache-Warmer' => ABCW_VERSION, ] ); } /** * The user agent used for warming requests. * * A normal browser string is deliberate: several caching layers refuse to build * a cache entry for requests that look like a bot. * * @return string */ function abcw_user_agent() { return (string) apply_filters( 'abcw_user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ); } // ─── URL sources ────────────────────────────────────────────────────────────── /** * Every selectable URL source, with a label and a live count. * * @return array */ function abcw_source_definitions() { $sources = []; $sources['home'] = [ 'label' => __('Homepage', 'abcode-cache-warmer'), 'count' => 1, 'group' => 'core', ]; // Builder template types are not offered at all. Their URLs are editor // machinery, rendering one outside its context caches a junk entry, and // there is no site where warming them is the right call. Anything genuinely // exotic can still be warmed through the manual include list. foreach (get_post_types(['public' => true], 'objects') as $post_type) { if ('attachment' === $post_type->name || in_array($post_type->name, abcw_internal_post_types(), true)) { continue; } $counts = wp_count_posts($post_type->name); $sources['pt_' . $post_type->name] = [ 'label' => $post_type->labels->name, 'count' => isset($counts->publish) ? (int) $counts->publish : 0, 'group' => 'content', ]; } foreach (get_taxonomies(['public' => true], 'objects') as $taxonomy) { if (in_array($taxonomy->name, abcw_internal_taxonomies(), true)) { continue; } $count = wp_count_terms(['taxonomy' => $taxonomy->name, 'hide_empty' => true]); $sources['tax_' . $taxonomy->name] = [ 'label' => $taxonomy->labels->name, 'count' => is_wp_error($count) ? 0 : (int) $count, 'group' => 'archives', ]; } $sources['archives'] = [ 'label' => __('Post Type Archives', 'abcode-cache-warmer'), 'count' => count(abcw_archive_urls()), 'group' => 'archives', ]; if (abcw_attachment_pages_enabled()) { $attachment_counts = wp_count_posts('attachment'); $sources['attachments'] = [ 'label' => __('Attachment Pages', 'abcode-cache-warmer'), 'count' => isset($attachment_counts->inherit) ? (int) $attachment_counts->inherit : 0, 'group' => 'media', 'hint' => __('The WordPress page for each media item. Skip this unless your theme actually uses attachment pages.', 'abcode-cache-warmer'), ]; } $sources['images'] = [ 'label' => __('Images On Warmed Pages', 'abcode-cache-warmer'), 'count' => 0, 'group' => 'media', 'hint' => __('Warms the image files your pages reference, found as each page is crawled. Only worth turning on if a plugin generates images on first request, ShortPixel Adaptive Images, Optimole, EWWW Easy IO or a WebP/AVIF converter. Plain image files are served without PHP and need no warming.', 'abcode-cache-warmer'), ]; $sources['sitemap'] = [ 'label' => __('XML Sitemap', 'abcode-cache-warmer'), 'count' => 0, 'group' => 'core', ]; return $sources; } /** * Archive landing pages: one per post type that has an archive, plus the blog * page when a static front page is in use. * * These are frequently the busiest URLs a site has, /products/, /portfolio/, * /blog/, and they are rebuilt from an expensive query on every cache miss. * * @return array */ function abcw_archive_urls() { $urls = []; foreach (get_post_types(['public' => true], 'objects') as $post_type) { if (empty($post_type->has_archive)) { continue; } $link = get_post_type_archive_link($post_type->name); if ($link) { $urls[] = $link; } } // With a static front page, the posts page is a separate URL that nothing // else in the collector would ever reach. if ('page' === get_option('show_on_front')) { $posts_page = (int) get_option('page_for_posts'); if ($posts_page > 0) { $link = get_permalink($posts_page); if ($link) { $urls[] = $link; } } } return $urls; } /** * Attachment (media) page URLs. * * These are PHP-rendered WordPress pages, so unlike the image files themselves * they genuinely benefit from warming. * * @return array */ function abcw_attachment_urls() { $urls = []; $paged = 1; do { $query = new WP_Query( [ 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 200, 'paged' => $paged, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, 'cache_results' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] ); foreach ($query->posts as $attachment_id) { $link = get_permalink($attachment_id); if ($link) { $urls[] = $link; } } $batch_size = count($query->posts); $paged++; } while (200 === $batch_size); return $urls; } /** * Pull image URLs out of a page we have already downloaded. * * Warming the media library wholesale is mostly wasted effort: raw files are * served straight off disk without PHP, and most libraries are full of images * no live page references. Reading the images out of the HTML we fetched anyway * warms exactly the files (and exactly the responsive sizes) that pages * actually use. That matters on sites where a plugin such as ShortPixel * Adaptive Images, Optimole, EWWW Easy IO or a WebP/AVIF converter builds each * derivative on first request. * * @param string $body Page HTML. * @return array Same-host image URLs. */ function abcw_extract_image_urls($body) { if ('' === $body || false === stripos($body, ', and srcset="..." on both and . if (preg_match_all('~<(?:img|source)\b[^>]*>~i', $body, $tags)) { foreach ($tags[0] as $tag) { if (preg_match('~\ssrc\s*=\s*["\']([^"\']+)["\']~i', $tag, $src)) { $found[] = $src[1]; } if (preg_match('~\ssrcset\s*=\s*["\']([^"\']+)["\']~i', $tag, $srcset)) { foreach (explode(',', $srcset[1]) as $candidate) { $candidate = trim($candidate); if ('' === $candidate) { continue; } // "image-300x200.jpg 300w", the URL is the first token. $parts = preg_split('~\s+~', $candidate); $found[] = $parts[0]; } } } } $urls = []; foreach ($found as $candidate) { $candidate = html_entity_decode(trim($candidate)); // Ignore inline data URIs and SVG placeholders used by lazy loaders. if ('' === $candidate || 0 === stripos($candidate, 'data:')) { continue; } // Protocol-relative and root-relative URLs need completing first. if (0 === strpos($candidate, '//')) { $candidate = (is_ssl() ? 'https:' : 'http:') . $candidate; } elseif (0 === strpos($candidate, '/')) { $candidate = home_url($candidate); } if (!preg_match('~\.(jpe?g|png|gif|webp|avif|svg)(\?|$)~i', $candidate)) { continue; } $normalized = abcw_normalize_url($candidate); if ('' !== $normalized) { $urls[] = $normalized; } } return array_values(array_unique($urls)); } /** * Normalise a URL for comparison and de-duplication. * * @param string $url Raw URL. * @return string Normalised URL, or an empty string if unusable. */ function abcw_normalize_url($url) { $url = trim(esc_url_raw((string) $url)); if ('' === $url) { return ''; } // Strip fragments; keep query strings, since they can be distinct pages. $url = strtok($url, '#'); if (false === $url || '' === $url) { return ''; } $parts = wp_parse_url($url); if (empty($parts['host'])) { return ''; } // Only ever warm our own site. $home = wp_parse_url(home_url('/')); if (empty($home['host']) || strtolower($parts['host']) !== strtolower($home['host'])) { return ''; } // Collapse "." and ".." segments and doubled slashes. A theme or plugin // building a link as home_url() . './news/' produces /./news/, which is the // same page as /news/ but de-duplicates as though it were a different one, // so it gets warmed twice and cached twice. $path = isset($parts['path']) ? $parts['path'] : '/'; if (preg_match('~(^|/)\.\.?(/|$)~', $path) || false !== strpos($path, '//')) { $leading = 0 === strpos($path, '/'); $trailing = strlen($path) > 1 && '/' === substr($path, -1); $segments = []; foreach (explode('/', $path) as $segment) { if ('' === $segment || '.' === $segment) { continue; } if ('..' === $segment) { array_pop($segments); continue; } $segments[] = $segment; } $clean = ($leading ? '/' : '') . implode('/', $segments) . ($trailing && $segments ? '/' : ''); if ('' === $clean) { $clean = '/'; } $url = (isset($parts['scheme']) ? $parts['scheme'] : 'https') . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '') . $clean . (empty($parts['query']) ? '' : '?' . $parts['query']); $parts['path'] = $clean; } // Trailing-slash the path unless it looks like a file or carries a query. if (empty($parts['query']) && !preg_match('/\.[a-z0-9]{2,5}$/i', isset($parts['path']) ? $parts['path'] : '/')) { $url = trailingslashit($url); } return $url; } /** * Turn a user-supplied pattern into a regex. `*` acts as a wildcard. * * @param string $pattern User pattern. * @return string Regex. */ function abcw_pattern_to_regex($pattern) { $quoted = preg_quote(trim($pattern), '~'); $quoted = str_replace('\*', '.*', $quoted); return '~' . $quoted . '~i'; } /** * Split a textarea of one-per-line values into a clean array. * * @param string $text Raw textarea contents. * @return array */ function abcw_lines_to_array($text) { $lines = preg_split('/\r\n|\r|\n/', (string) $text); if (!is_array($lines)) { return []; } return array_values(array_filter(array_map('trim', $lines))); } /** * Fetch a URL and return its body, or null on failure. * * @param string $url URL to fetch. * @return string|null */ function abcw_fetch_body($url) { $response = wp_remote_get( $url, [ 'timeout' => 15, 'redirection' => 2, 'sslverify' => false, 'user-agent' => abcw_user_agent(), ] ); if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) { return null; } return (string) wp_remote_retrieve_body($response); } /** * Extract values from a sitemap document. * * @param string $xml Sitemap XML. * @return array */ function abcw_extract_locs($xml) { if (!preg_match_all('~\s*([^<]+?)\s*~i', $xml, $matches)) { return []; } return array_map('html_entity_decode', $matches[1]); } /** * Pull URLs out of the site's XML sitemap (core, Yoast or Rank Math). * * The sitemap already excludes noindex and private content, which makes it a * more accurate picture of what visitors actually reach than re-querying. * * @return array */ function abcw_fetch_sitemap_urls() { $index_xml = null; foreach ([home_url('/wp-sitemap.xml'), home_url('/sitemap_index.xml'), home_url('/sitemap.xml')] as $candidate) { $body = abcw_fetch_body($candidate); if (null !== $body && false !== stripos($body, '')) { $index_xml = $body; break; } } if (null === $index_xml) { return []; } $locs = abcw_extract_locs($index_xml); // A plain sitemap already holds the page URLs. if (false === stripos($index_xml, ' $urls Normalised URLs. * @return array */ function abcw_sort_by_hierarchy($urls) { $ranked = []; foreach (array_values($urls) as $position => $url) { $path = (string) wp_parse_url($url, PHP_URL_PATH); $depth = substr_count(trim($path, '/'), '/'); // '' (the homepage) trims to nothing and must stay at depth 0. if ('' === trim($path, '/')) { $depth = 0; } else { $depth++; } $ranked[] = [ 'url' => $url, 'depth' => $depth, 'position' => $position, ]; } usort( $ranked, function ($a, $b) { if ($a['depth'] !== $b['depth']) { return $a['depth'] <=> $b['depth']; } // Explicit tiebreak keeps the result identical regardless of // whether the PHP version in use has a stable sort. return $a['position'] <=> $b['position']; } ); return wp_list_pluck($ranked, 'url'); } /** * Build the full, ordered, de-duplicated list of URLs to warm. * * Order matters: the homepage goes first, then the most recently modified * content, so the URLs a visitor is most likely to request are warm within the * first few seconds of a run rather than the last. * * @param array $settings Plugin settings. * @return array */ function abcw_collect_urls($settings) { $sources = isset($settings['sources']) && is_array($settings['sources']) ? $settings['sources'] : []; $urls = []; if (!empty($sources['home'])) { $urls[] = home_url('/'); } // Archive landing pages rank just behind the homepage for traffic, so they // go near the front of the queue rather than the back. if (!empty($sources['archives'])) { $urls = array_merge($urls, abcw_archive_urls()); } // Post types, newest-modified first. foreach ($sources as $key => $enabled) { if (!$enabled || 0 !== strpos($key, 'pt_')) { continue; } $post_type = substr($key, 3); // The exists check catches a removed plugin; the internal check catches // a setting saved before builder template types stopped being offered. if (!post_type_exists($post_type) || in_array($post_type, abcw_internal_post_types(), true)) { continue; } $paged = 1; do { $query = new WP_Query( [ 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => 200, 'paged' => $paged, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'cache_results' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] ); foreach ($query->posts as $post_id) { $permalink = get_permalink($post_id); if ($permalink) { $urls[] = $permalink; } } $batch_size = count($query->posts); $paged++; } while (200 === $batch_size); } // Taxonomy archives, busiest terms first. foreach ($sources as $key => $enabled) { if (!$enabled || 0 !== strpos($key, 'tax_')) { continue; } $taxonomy = substr($key, 4); if (!taxonomy_exists($taxonomy) || in_array($taxonomy, abcw_internal_taxonomies(), true)) { continue; } $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => true, 'orderby' => 'count', 'order' => 'DESC', ] ); if (is_wp_error($terms)) { continue; } foreach ($terms as $term) { $link = get_term_link($term); if (!is_wp_error($link)) { $urls[] = $link; } } } if (!empty($sources['attachments']) && abcw_attachment_pages_enabled()) { $urls = array_merge($urls, abcw_attachment_urls()); } if (!empty($sources['sitemap'])) { $urls = array_merge($urls, abcw_fetch_sitemap_urls()); } $urls = array_merge($urls, abcw_lines_to_array(isset($settings['include']) ? $settings['include'] : '')); // Normalise and de-duplicate while preserving priority order. $seen = []; $clean = []; foreach ($urls as $url) { $normalized = abcw_normalize_url($url); if ('' === $normalized || isset($seen[$normalized])) { continue; } $seen[$normalized] = true; $clean[] = $normalized; } $clean = abcw_sort_by_hierarchy($clean); // Exclusions. $patterns = abcw_lines_to_array(isset($settings['exclude']) ? $settings['exclude'] : ''); if (!empty($patterns)) { $regexes = array_map('abcw_pattern_to_regex', $patterns); $clean = array_values( array_filter( $clean, function ($url) use ($regexes) { foreach ($regexes as $regex) { if (preg_match($regex, $url)) { return false; } } return true; } ) ); } return (array) apply_filters('abcw_collect_urls', $clean, $settings); } // ─── Cache status detection ─────────────────────────────────────────────────── /** * Work out whether a response came from a cache, and from which layer. * * During warming a MISS is the good outcome, it means we just built the cache * entry. A HIT means the page was already warm. * * @param array $headers Lower-cased response headers. * @param string $body Response body. * @return array{state: string, layer: string} */ function abcw_detect_cache_status($headers, $body) { if (!empty($headers['x-stackcache-cacheable']) && 'no' === strtolower(trim($headers['x-stackcache-cacheable']))) { return ['state' => 'bypass', 'layer' => 'StackCache']; } // Ordered outermost layer first, and that ordering is load-bearing. When an // edge cache serves a response, every inner-layer header inside it is a // frozen copy from the moment the entry was built, a StackCDN hit still // carries the "x-origin-cache-status: MISS" the origin sent while the entry // was being made, and a Cloudflare hit still carries LiteSpeed's original // verdict. So a HIT is only live at the outermost layer that reports one, // and the inner headers only mean anything once the layers outside them // missed and the request genuinely passed through. $layers = [ 'cache-status' => 'CDN / proxy', 'cf-cache-status' => 'Cloudflare', 'x-sucuri-cache' => 'Sucuri', 'x-cdn-cache-status' => 'StackCDN', 'x-qc-cache' => 'QUIC.cloud', 'x-cache' => 'CDN / proxy', 'x-proxy-cache' => 'Proxy', 'x-cache-status' => 'Nginx', 'x-srcache-fetch-status' => 'SRCache', 'x-fastcgi-cache' => 'Nginx FastCGI', 'x-nginx-cache' => 'Nginx', 'x-kinsta-cache' => 'Kinsta', 'x-cache-enabled' => 'Server cache', 'x-origin-cache-status' => 'StackCache', 'x-litespeed-cache' => 'LiteSpeed', ]; $miss = null; $bypass = null; foreach ($layers as $header => $layer) { if (empty($headers[$header])) { continue; } $value = strtolower($headers[$header]); // A hit anywhere means the page was already cached, and outer-first // ordering means this is the layer that really served it. Stale, // updating and revalidated all mean a cached copy was served too; // Cloudflare uses all three. if (false !== strpos($value, 'hit') || false !== strpos($value, 'stale') || false !== strpos($value, 'updating') || false !== strpos($value, 'revalidated')) { return ['state' => 'hit', 'layer' => $layer]; } // A miss is not a verdict yet: the layer inside may still report a // hit (edge cold, origin warm, still an already-cached page). Keep // walking inward, remembering the innermost miss, since that is the // entry a warm actually built. if (false !== strpos($value, 'miss') || false !== strpos($value, 'expired')) { $miss = $layer; continue; } // Bypass at an outer layer is normal, Cloudflare says DYNAMIC for // every page it will not cache, so it only stands if no layer // further in has something more concrete to say. if (false !== strpos($value, 'bypass') || false !== strpos($value, 'dynamic') || false !== strpos($value, 'no-cache')) { if (null === $bypass) { $bypass = $layer; } continue; } } // Some caches signal a hit without ever using the word, and they sit at // the origin, so their live verdict has to be heard BEFORE an outer // layer's miss is allowed to stand, or an edge miss over a warm origin // reads as a cold page. // SiteGround reports a plain 1/0. if (isset($headers['x-sg-cachehit']) && '1' === trim($headers['x-sg-cachehit'])) { return ['state' => 'hit', 'layer' => 'SiteGround']; } // Batcache (WordPress.com, Pressable, VIP) stamps served-from-cache pages. if (!empty($headers['x-nananana'])) { return ['state' => 'hit', 'layer' => 'Batcache']; } // Varnish speaks in transaction IDs: two on a hit, this request's and the // one that originally stored the object, one on a miss. if (!empty($headers['x-varnish'])) { if (preg_match('/^\s*\d+\s+\d+/', $headers['x-varnish'])) { return ['state' => 'hit', 'layer' => 'Varnish']; } if (null === $miss) { $miss = 'Varnish'; } } if (null !== $miss) { return ['state' => 'miss', 'layer' => $miss]; } if (isset($headers['x-sg-cachehit'])) { return ['state' => 'miss', 'layer' => 'SiteGround']; } // A positive Age header means an upstream cache served this. if (!empty($headers['age']) && (int) $headers['age'] > 0) { return ['state' => 'hit', 'layer' => 'CDN / proxy']; } if (null !== $bypass) { return ['state' => 'bypass', 'layer' => $bypass]; } // Plugins that only leave a footprint in the HTML. if ('' !== $body) { $tail = substr($body, -2000); if (false !== stripos($tail, 'Cached page generated by WP-Super-Cache')) { return ['state' => 'hit', 'layer' => 'WP Super Cache']; } if (false !== stripos($tail, 'Dynamic page generated in')) { return ['state' => 'miss', 'layer' => 'WP Super Cache']; } if (false !== stripos($tail, 'Performance optimized by WP Rocket')) { return ['state' => 'hit', 'layer' => 'WP Rocket']; } if (false !== stripos($tail, 'Performance optimized by W3 Total Cache')) { return ['state' => 'hit', 'layer' => 'W3 Total Cache']; } if (false !== stripos($tail, 'WP Fastest Cache file was created')) { return ['state' => 'hit', 'layer' => 'WP Fastest Cache']; } if (false !== stripos($tail, 'Cache served by breeze')) { return ['state' => 'hit', 'layer' => 'Breeze']; } if (false !== stripos($tail, 'Cached by WP-Optimize') || false !== stripos($tail, 'Cached by WP Optimize')) { return ['state' => 'hit', 'layer' => 'WP-Optimize']; } } return ['state' => 'unknown', 'layer' => '']; } // ─── Crawl engine ───────────────────────────────────────────────────────────── /** * Turn a raw response into a normalised result row for the UI. * * @param string $url Requested URL. * @param int $code HTTP status code. * @param array $headers Lower-cased headers. * @param string $body Response body. * @param float $time Seconds taken. * @param string $error Transport error message, if any. * @return array */ function abcw_build_result($url, $code, $headers, $body, $time, $error, $discover_images = false) { $result = [ 'url' => $url, 'code' => $code, 'time' => round((float) $time, 2), 'layer' => '', // cURL messages arrive with a trailing newline, which would show up in // the results table as a stray gap. 'error' => trim((string) $error), ]; // Harvested by the batch handler, then stripped before the row is sent to // the browser, the UI never needs to see it. if ($discover_images && '' !== $body && false !== stripos($body, '= 400) { $result['outcome'] = 'failed'; /* translators: %d: HTTP status code */ $result['error'] = sprintf(__('HTTP %d', 'abcode-cache-warmer'), $code); // 429 (and 503, its blunter cousin) are not failures so much as // instructions: the server is asking to be left alone for a bit. The // batch runner retries these after a pause instead of writing the URL // off, and Retry-After (seconds or an HTTP date) says how long. if (429 === $code || 503 === $code) { $result['rate_limited'] = true; $result['retry_after'] = 0; if (!empty($headers['retry-after'])) { $after = trim($headers['retry-after']); if (is_numeric($after)) { $result['retry_after'] = max(0, (int) $after); } else { $stamp = strtotime($after); if (false !== $stamp) { $result['retry_after'] = max(0, $stamp - time()); } } } } return $result; } $cache = abcw_detect_cache_status($headers, $body); $result['layer'] = $cache['layer']; switch ($cache['state']) { case 'hit': $result['outcome'] = 'cached'; break; case 'bypass': $result['outcome'] = 'bypassed'; break; case 'miss': default: $result['outcome'] = 'warmed'; break; } return $result; } /** * Warm a single URL through the WordPress HTTP API. * * @param string $url URL to warm. * @param array $settings Plugin settings. * @return array */ function abcw_request_single($url, $settings, $discover_images = false) { $start = microtime(true); $response = wp_remote_get( $url, [ 'timeout' => (int) $settings['timeout'], 'redirection' => 2, 'sslverify' => !empty($settings['ssl_verify']), 'user-agent' => abcw_user_agent(), 'headers' => abcw_request_headers(), ] ); $elapsed = microtime(true) - $start; if (is_wp_error($response)) { return abcw_build_result($url, 0, [], '', $elapsed, $response->get_error_message()); } $raw = wp_remote_retrieve_headers($response); if (is_object($raw) && method_exists($raw, 'getAll')) { $raw = $raw->getAll(); } $headers = []; if (is_array($raw)) { foreach ($raw as $name => $value) { $headers[strtolower($name)] = is_array($value) ? implode(', ', $value) : (string) $value; } } return abcw_build_result( $url, (int) wp_remote_retrieve_response_code($response), $headers, (string) wp_remote_retrieve_body($response), $elapsed, '', $discover_images ); } /** * Turn a Requests response object into a result row. * * @param string $url Requested URL. * @param mixed $response Response object, Throwable, or anything else. * @param float $time Seconds taken. * @param bool $discover_images Whether to harvest image URLs from the body. * @return array */ function abcw_result_from_response($url, $response, $time, $discover_images = false) { if ($response instanceof \Throwable) { return abcw_build_result($url, 0, [], '', $time, $response->getMessage()); } if (!is_object($response) || !isset($response->status_code)) { return abcw_build_result($url, 0, [], '', $time, __('No response', 'abcode-cache-warmer')); } $headers = []; if (isset($response->headers)) { foreach ($response->headers as $name => $value) { $headers[strtolower($name)] = is_array($value) ? implode(', ', $value) : (string) $value; } } return abcw_build_result( $url, (int) $response->status_code, $headers, isset($response->body) ? (string) $response->body : '', $time, '', $discover_images ); } /** * Parallel chunk fetch using the Requests library bundled with WordPress. * * This is the main speed win over warming one URL at a time, and it needs no * third-party HTTP client, Requests wraps curl_multi already. * * Requests dispatches its `multiple.request.complete` hook from inside the * curl_multi loop, as each individual response lands. Building the row there * rather than after the whole chunk returns is what lets the admin table fill * in one URL at a time instead of jumping several rows at once. * * @param array $urls URLs for this chunk. * @param array $settings Plugin settings. * @param bool $discover_images Whether to harvest image URLs. * @param callable|null $emit Called with each row as it completes. * @return array> */ function abcw_request_chunk_parallel($urls, $settings, $discover_images = false, $emit = null) { $requests_class = class_exists('WpOrg\Requests\Requests') ? 'WpOrg\Requests\Requests' : 'Requests'; $hooks_class = class_exists('WpOrg\Requests\Hooks') ? 'WpOrg\Requests\Hooks' : 'Requests_Hooks'; $timings = []; $requests = []; foreach (array_values($urls) as $index => $url) { $request = [ 'url' => $url, 'type' => 'GET', 'headers' => abcw_request_headers(), ]; // Per-request hook so cURL timing can be attributed back to a URL. if (class_exists($hooks_class)) { $hooks = new $hooks_class(); $hooks->register( 'curl.after_request', function (&$headers, &$info) use (&$timings, $index) { if (is_array($info) && isset($info['total_time'])) { $timings[$index] = (float) $info['total_time']; } } ); $request['options'] = ['hooks' => $hooks]; } $requests[$index] = $request; } $indexed = array_values($urls); $results = []; $chunk_started = microtime(true); // Shared by the live callback and the sweep below, so a URL can never be // recorded twice however its response arrived. $record = function ($index, $response) use ($indexed, &$results, &$timings, $chunk_started, $discover_images, $emit) { if (!isset($indexed[$index]) || isset($results[$index])) { return; } $time = isset($timings[$index]) ? $timings[$index] : (microtime(true) - $chunk_started); $results[$index] = abcw_result_from_response($indexed[$index], $response, $time, $discover_images); if (is_callable($emit)) { call_user_func($emit, $results[$index]); } }; $options = [ 'timeout' => (int) $settings['timeout'], 'connect_timeout' => (int) $settings['timeout'], 'useragent' => abcw_user_agent(), 'follow_redirects' => true, 'redirects' => 2, 'verify' => !empty($settings['ssl_verify']), 'blocking' => true, 'complete' => function (&$response, $index) use ($record) { $record($index, $response); }, ]; try { $responses = $requests_class::request_multiple($requests, $options); } catch (\Throwable $e) { // Fall back to sequential rather than failing the whole run. Anything // the callback already recorded is kept; the rest is fetched one by one. foreach ($indexed as $index => $url) { if (isset($results[$index])) { continue; } $results[$index] = abcw_request_single($url, $settings, $discover_images); if (is_callable($emit)) { call_user_func($emit, $results[$index]); } } ksort($results); return array_values($results); } // Anything the callback did not see, a transport that skipped the hook, or // a response Requests handed back as a raw string. foreach ($indexed as $index => $url) { $record($index, isset($responses[$index]) ? $responses[$index] : null); } ksort($results); return array_values($results); } /** * Warm a chunk of URLs, in parallel where the server allows it. * * @param array $urls URLs for this chunk. * @param array $settings Plugin settings. * @param bool $discover_images Whether to harvest image URLs. * @param callable|null $emit Called with each row as it completes. * @return array> One result row per URL. */ function abcw_request_chunk($urls, $settings, $discover_images = false, $emit = null) { if (empty($urls)) { return []; } if (abcw_supports_parallel() && count($urls) > 1) { return abcw_request_chunk_parallel($urls, $settings, $discover_images, $emit); } $results = []; foreach ($urls as $url) { $result = abcw_request_single($url, $settings, $discover_images); $results[] = $result; if (is_callable($emit)) { call_user_func($emit, $result); } } return $results; } /** * Median of a numeric array. * * @param array $values Values. * @return float */ function abcw_median($values) { $values = array_values(array_filter(array_map('floatval', (array) $values))); if (empty($values)) { return 0.0; } sort($values); return $values[(int) floor((count($values) - 1) / 2)]; } // ─── Session state ──────────────────────────────────────────────────────────── /** * Load the current crawl session. * * @return array|null */ function abcw_session_get() { $session = get_option('abcw_session', null); if (!is_array($session)) { return null; } // A finished run is kept around briefly so the browser can collect the last // rows and show the run actually reaching 100%. Once nobody could still be // watching, it goes. if (!empty($session['finished'])) { $ttl = (int) apply_filters('abcw_finished_session_ttl', 10 * MINUTE_IN_SECONDS); if ((time() - (int) $session['finished']) > $ttl) { abcw_session_clear(); return null; } } return $session; } /** * Persist the crawl session progress. * * The URL list lives in its own option so each batch only rewrites the small * progress record rather than re-serialising thousands of URLs. * * @param array $session Session data. * @return void */ function abcw_session_set($session) { $session['updated'] = time(); update_option('abcw_session', $session, false); } /** * Read the URL list for the active session. * * @return array */ function abcw_session_urls() { $urls = get_option('abcw_session_urls', []); return is_array($urls) ? $urls : []; } /** * Append newly discovered image URLs to the back of the run queue. * * Images go behind every page on purpose: a cached page is what a visitor waits * for, an image is what their browser fetches afterwards. The cap exists because * a large media-heavy site can reference tens of thousands of files, and an * uncapped image pass would dwarf the page warming it is meant to support. When * the cap bites we record it on the session so the UI can say so out loud * rather than quietly warming a fraction. * * @param array $urls Current queue. * @param array $discovered Newly found image URLs. * @param array $session Session, updated by reference. * @return int Number of URLs actually queued. */ function abcw_queue_images($urls, $discovered, &$session) { $limit = (int) apply_filters('abcw_image_limit', 2000); $already_queued = (int) (isset($session['images_queued']) ? $session['images_queued'] : 0); $remaining = $limit - $already_queued; if ($remaining <= 0) { $session['images_capped'] = $limit; return 0; } $existing = array_flip($urls); $fresh = []; foreach (array_unique($discovered) as $image_url) { if (isset($existing[$image_url])) { continue; } if (count($fresh) >= $remaining) { $session['images_capped'] = $limit; break; } $existing[$image_url] = true; $fresh[] = $image_url; } if (empty($fresh)) { return 0; } $session['images_queued'] = $already_queued + count($fresh); update_option('abcw_session_urls', array_merge($urls, $fresh), false); return count($fresh); } /** * Whether the user has asked the current run to stop. * * This reads the flag straight from the database on purpose. The stop request * arrives in a *different* PHP process to the batch that has to honour it, and * get_option() would keep serving the value cached at the start of this * request, so a cached read would leave the stop button doing nothing. * * @return bool */ function abcw_stop_requested() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Must bypass the options cache to see a flag set by a concurrent request. $value = $wpdb->get_var( $wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'abcw_stop_flag') ); return !empty($value); } /** * Clear all crawl session state. * * @return void */ function abcw_session_clear() { delete_option('abcw_session'); delete_option('abcw_session_log'); delete_option('abcw_session_urls'); delete_option('abcw_stop_flag'); } /** * Record the run summary and tear down session state. * * @param array $session Finished session. * @return void */ function abcw_finish_session($session) { $summary = [ 'time' => time(), 'trigger' => isset($session['trigger']) ? $session['trigger'] : 'manual', 'status' => $session['status'], 'total' => (int) $session['total'], 'warmed' => (int) $session['warmed'], 'cached' => (int) $session['cached'], 'bypassed' => (int) $session['bypassed'], 'failed' => (int) $session['failed'], 'duration' => round(microtime(true) - (float) $session['started'], 1), 'date' => wp_date(get_option('date_format') . ' ' . get_option('time_format')), ]; // Move the run's rows from the working buffer to the run's own record. // Each run is stored separately so any retained run can be opened in full // on the Results tab, and old ones can be dropped without rewriting the // rest. The index holds only summaries, light enough to list every run // without touching a single row. $buffer = get_option('abcw_results_buffer', null); $rows = (is_array($buffer) && isset($buffer['id'], $buffer['rows']) && $buffer['id'] === $session['id']) ? $buffer['rows'] : []; $run_id = preg_replace('/[^a-zA-Z0-9]/', '', (string) $session['id']); update_option('abcw_run_' . $run_id, $rows, false); delete_option('abcw_results_buffer'); $index = get_option('abcw_run_index', []); if (!is_array($index)) { $index = []; } array_unshift($index, $summary + ['id' => $run_id, 'rows_stored' => count($rows)]); update_option('abcw_run_index', $index, false); // Sentinels for the watchdog: a handful of pages spread evenly across the // run, so a cold homepage can be cross-checked against the rest of the // site before anything drastic is concluded from it. $warmable = []; foreach ($rows as $row) { if ('failed' !== $row['outcome']) { $warmable[] = $row['url']; } } if (count($warmable) > 1) { $step = max(1, (int) floor(count($warmable) / 8)); $sentinels = []; for ($i = 0; $i < count($warmable) && count($sentinels) < 8; $i += $step) { $sentinels[] = $warmable[$i]; } update_option('abcw_sentinels', $sentinels, false); } abcw_prune_runs(); update_option( 'abcw_last_run', [ 'time' => time(), 'total' => (int) $session['total'], 'warmed' => (int) $session['warmed'], 'cached' => (int) $session['cached'], 'bypassed' => (int) $session['bypassed'], 'failed' => (int) $session['failed'], 'duration' => round(microtime(true) - (float) $session['started'], 1), 'status' => $session['status'], 'trigger' => isset($session['trigger']) ? $session['trigger'] : 'manual', 'date' => wp_date(get_option('date_format') . ' ' . get_option('time_format')), ], false ); // Deliberately *not* cleared here. Wiping the session the moment the run // ended took the last few rows with it, and left the progress bar stuck a // URL short of the end, the browser polled, found nothing, and had nothing // to finish with. The record stays until abcw_session_get() ages it out, or // the next run replaces it. $session['finished'] = time(); $session['delay'] = (int) $session['delay']; update_option('abcw_session', $session, false); // The queue itself is the big option and nothing needs it now. delete_option('abcw_session_urls'); delete_option('abcw_stop_flag'); } // ─── AJAX ───────────────────────────────────────────────────────────────────── /** * Guard every AJAX endpoint with a capability and nonce check. * * @return void */ function abcw_verify_request() { if (!current_user_can('manage_options')) { wp_send_json_error(__('Unauthorized', 'abcode-cache-warmer'), 403); } check_ajax_referer('abcw_nonce', '_nonce'); } add_action('wp_ajax_abcw_bootstrap', function () { abcw_verify_request(); $settings = abcw_get_settings(); $profile = abcw_get_profile(); // First run: adopt the calibrated values instead of the placeholder defaults. $stored = get_option('abcw_settings', []); if (!is_array($stored) || !isset($stored['concurrency'])) { $settings['concurrency'] = $profile['concurrency']; $settings['delay'] = $profile['delay']; update_option('abcw_settings', $settings, false); } $session = abcw_session_get(); $sources = []; foreach (abcw_source_definitions() as $key => $definition) { $sources[] = [ 'key' => $key, 'label' => $definition['label'], 'count' => $definition['count'], 'group' => $definition['group'], 'hint' => isset($definition['hint']) ? $definition['hint'] : '', 'enabled' => !empty($settings['sources'][$key]), ]; } wp_send_json_success( [ 'sources' => $sources, 'settings' => $settings, 'profile' => $profile, 'lastRun' => get_option('abcw_last_run', null), 'schedule' => abcw_schedule_status(), 'purge' => abcw_purge_status(), 'session' => $session ? abcw_session_summary($session) : null, ] ); }); add_action('wp_ajax_abcw_calibrate', function () { abcw_verify_request(); $profile = abcw_get_profile(true); $settings = abcw_get_settings(); $settings['concurrency'] = $profile['concurrency']; $settings['delay'] = $profile['delay']; update_option('abcw_settings', $settings, false); wp_send_json_success(['profile' => $profile, 'settings' => $settings]); }); add_action('wp_ajax_abcw_save_settings', function () { abcw_verify_request(); // Nonce + capability are verified in abcw_verify_request() above. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON payload; every field is individually validated in abcw_save_settings(). $raw = isset($_POST['settings']) ? wp_unslash($_POST['settings']) : ''; $raw = json_decode(is_string($raw) ? $raw : '', true); if (!is_array($raw)) { wp_send_json_error(__('Invalid settings payload', 'abcode-cache-warmer')); } wp_send_json_success( [ 'settings' => abcw_save_settings($raw), 'schedule' => abcw_schedule_status(), 'purge' => abcw_purge_status(), ] ); }); add_action('wp_ajax_abcw_start', function () { abcw_verify_request(); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- URL collection on large sites can exceed the default limit. @set_time_limit(120); $session = abcw_start_run('manual'); if (is_wp_error($session)) { wp_send_json_error($session->get_error_message()); } wp_send_json_success(['session' => abcw_session_summary($session)]); }); add_action('wp_ajax_abcw_stop', function () { abcw_verify_request(); update_option('abcw_stop_flag', 1, false); wp_unschedule_hook('abcw_continue_run'); wp_send_json_success(['stopped' => true]); }); /** * Drive one batch from the browser. * * The browser is the fast path: while the tab is open it requests batches back * to back, which is far more responsive than waiting on cron. It returns only * counters, the finished rows stream through wp_ajax_abcw_progress instead, so * they appear as each URL completes rather than in a clump when the batch ends. * * @return void */ add_action('wp_ajax_abcw_batch', function () { abcw_verify_request(); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Batch is time-boxed internally; this only prevents a hard kill mid-request. @set_time_limit(90); $session = abcw_process_batch(); $locked = false; if (null === $session) { // Either the run finished and cleared itself, or another process holds // the lock. Neither is an error, reporting one would flash a failure // banner at the end of a perfectly good run. $locked = true; $session = abcw_session_get(); } wp_send_json_success( [ 'session' => null === $session ? null : abcw_session_summary($session), 'locked' => $locked, ] ); }); /** * Read-only progress feed. * * Polled on a short interval while a run is active so completed URLs reach the * table within a second of finishing. Deliberately does no work and never * touches the lock, so it stays fast and cannot interfere with whichever * process is doing the warming. * * @return void */ add_action('wp_ajax_abcw_progress', function () { abcw_verify_request(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in abcw_verify_request() above; value is cast with absint(). $since = isset($_POST['since']) ? absint($_POST['since']) : 0; $session = abcw_session_get(); if (null === $session) { wp_send_json_success( [ 'session' => null, 'rows' => [], 'lastRun' => get_option('abcw_last_run', null), ] ); } $log = get_option('abcw_session_log', null); $rows = []; if (is_array($log) && !empty($log['rows']) && $log['id'] === $session['id']) { foreach ($log['rows'] as $row) { if ((int) $row['id'] > $since) { $rows[] = $row; } } } wp_send_json_success( [ 'session' => abcw_session_summary($session), 'rows' => $rows, 'lastRun' => get_option('abcw_last_run', null), // A run on a big slow site can outlive the nonce the page loaded // with; handing back a fresh one keeps the tab working all the way. 'nonce' => wp_create_nonce('abcw_nonce'), ] ); }); /** * Drop stored runs past their keep-by date or beyond the keep-count. * * Rolling on purpose: runs are not wiped in a batch on some cleanup day, they * fall off one at a time as each crosses the age the user chose, newest ones * always safe. Runs once, cheaply, from the two places the list changes or is * looked at, after a run finishes, and when the Results tab loads. * * @return void */ function abcw_prune_runs() { $index = get_option('abcw_run_index', []); if (!is_array($index) || empty($index)) { return; } $settings = abcw_get_settings(); $cutoff = time() - (max(1, (int) $settings['keep_days']) * DAY_IN_SECONDS); $keep = max(1, (int) $settings['keep_runs']); $kept = []; $changed = false; foreach ($index as $entry) { if (!is_array($entry) || empty($entry['id'])) { $changed = true; continue; } if ((int) $entry['time'] >= $cutoff && count($kept) < $keep) { $kept[] = $entry; continue; } delete_option('abcw_run_' . $entry['id']); $changed = true; } if ($changed) { update_option('abcw_run_index', $kept, false); } } /** * The stored record of completed runs, for the Results tab. * * Loaded on demand rather than with everything else, it is the largest thing * the plugin stores, and most visits to the screen never open the tab. * * @return void */ /** * Warm activity that has not finished yet, for the Results tab to show live. * * One of: a run in progress, a full run scheduled (a purge-everything, an * auto-warm or the schedule), or a targeted page warm queued and waiting out * its delay. Returns status 'none' when nothing is pending. Countdowns are sent * as seconds-remaining, computed here, so client clock skew never shows a wrong * time. * * @return array */ function abcw_pending_warm() { $now = time(); $out = ['running' => null, 'queue' => null, 'scheduled' => null]; // A run in progress. $session = abcw_session_get(); if (is_array($session) && 'running' === $session['status']) { $summary = abcw_session_summary($session); $out['running'] = [ 'done' => $summary['done'], 'total' => $summary['total'], 'trigger' => $summary['trigger'], ]; } // An imminent warm waiting out its delay, the actionable "about to happen" // that a purge or edit just triggered. A full re-warm (manual purge, or a // detected full purge) takes precedence over a targeted page warm. $full_at = wp_next_scheduled('abcw_warm_all_run'); if (!$full_at) { $full_at = wp_next_scheduled('abcw_auto_warm'); } if ($full_at) { $out['queue'] = ['kind' => 'full', 'in' => max(0, (int) $full_at - $now)]; } else { $queue = get_option('abcw_page_queue', []); $at = wp_next_scheduled('abcw_warm_pages'); if (is_array($queue) && !empty($queue) && $at) { $out['queue'] = [ 'kind' => 'targeted', 'in' => max(0, (int) $at - $now), 'count' => count($queue), 'urls' => array_slice(array_values($queue), 0, 50), ]; } } // The recurring schedule, background information, not an imminent event. $sched_at = wp_next_scheduled('abcw_scheduled_warm'); if ($sched_at) { $out['scheduled'] = ['in' => max(0, (int) $sched_at - $now)]; } return $out; } add_action('wp_ajax_abcw_results', function () { abcw_verify_request(); // Viewing the tab is one of the two moments retention is enforced, so an // expired run disappears from the list on schedule even if no new run has // happened since. abcw_prune_runs(); $index = get_option('abcw_run_index', []); $index = is_array($index) ? array_values($index) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Verified in abcw_verify_request() above; reduced to alphanumerics on the same line. $wanted = isset($_POST['run']) ? preg_replace('/[^a-zA-Z0-9]/', '', (string) wp_unslash($_POST['run'])) : ''; $run = null; $rows = []; foreach ($index as $entry) { if ('' === $wanted ? true : $entry['id'] === $wanted) { $run = $entry; $rows = get_option('abcw_run_' . $entry['id'], []); break; } } wp_send_json_success( [ 'run' => $run, 'rows' => is_array($rows) ? $rows : [], // Anything past the cap was warmed, just not written down. 'omitted' => is_array($run) ? max(0, (int) $run['total'] - (int) $run['rows_stored']) : 0, 'history' => $index, 'pending' => abcw_pending_warm(), ] ); }); // ─── Run engine ─────────────────────────────────────────────────────────────── /** * Finished rows kept on the session for the UI to collect. * * The browser polls faster than this fills, so it only needs to be deep enough * to survive a slow poll, not to hold the whole run. It is deliberately modest: * the log is rewritten as rows complete, and every row kept is bytes written to * the options table several times a second. */ define('ABCW_LOG_LIMIT', 150); /** * Rows kept from a completed run for the Results tab. * * High enough that every real site keeps its full results, the cap exists * only so a pathological queue cannot turn one option row into a memory * problem. If it ever bites, the run still completes and the tab says how * many rows are not shown. */ define('ABCW_RESULT_LIMIT', 10000); /** * Collect finished rows for the permanent record. * * Written once per chunk rather than once per URL: the live feed already has * the per-URL path covered, and this one only has to survive the run. * * @param array> $results Finished rows. * @param string $session_id Run this belongs to. * @return void */ function abcw_buffer_results($results, $session_id) { if (empty($results)) { return; } $buffer = get_option('abcw_results_buffer', null); if (!is_array($buffer) || !isset($buffer['id']) || $buffer['id'] !== $session_id) { $buffer = ['id' => $session_id, 'rows' => []]; } // At the cap there is nothing more to record, so stop writing entirely: // rewriting five hundred rows every chunk to no purpose is exactly the kind // of thing that makes a plugin expensive on a big site. How many were left // out is worked out at the end, from the run total. if (count($buffer['rows']) >= ABCW_RESULT_LIMIT) { return; } $now = time(); foreach ($results as $row) { if (count($buffer['rows']) >= ABCW_RESULT_LIMIT) { break; } unset($row['images']); $row['at'] = $now; $buffer['rows'][] = $row; } update_option('abcw_results_buffer', $buffer, false); } /** * Collect URLs and open a new run. * * @param string $trigger What started this run: manual, scheduled, etc. * @return array|WP_Error */ function abcw_start_run($trigger = 'manual') { $existing = abcw_session_get(); if (is_array($existing) && 'running' === $existing['status']) { return $existing; } // Take the batch lock before resetting anything. Without it, a background // batch that is mid-chunk would write its own copy of the old session back // over the new one the moment it finished, resurrecting a finished run. if (!abcw_acquire_lock()) { return new WP_Error( 'abcw_busy', __('A run is still finishing in the background. Try again in a moment.', 'abcode-cache-warmer') ); } abcw_session_clear(); $settings = abcw_get_settings(); $urls = abcw_collect_urls($settings); if (empty($urls)) { abcw_release_lock(); return new WP_Error('abcw_no_urls', __('No URLs found. Enable at least one source first.', 'abcode-cache-warmer')); } $session = [ 'id' => wp_generate_password(12, false), 'trigger' => $trigger, 'index' => 0, 'total' => count($urls), 'warmed' => 0, 'cached' => 0, 'bypassed' => 0, 'failed' => 0, 'status' => 'running', 'started' => microtime(true), 'concurrency' => (int) $settings['concurrency'], 'delay' => (int) $settings['delay'], 'baseline' => null, 'images_queued' => 0, 'images_capped' => 0, 'layers_seen' => 0, 'pauses' => 0, ]; update_option('abcw_session_urls', $urls, false); abcw_session_set($session); abcw_release_lock(); // Hand the run to cron as well, so closing the tab does not end it. abcw_schedule_continuation(0); return $session; } /** * Trim a session down to what the browser actually needs. * * @param array $session Session data. * @return array */ function abcw_session_summary($session) { $done = (int) $session['warmed'] + (int) $session['cached'] + (int) $session['bypassed'] + (int) $session['failed']; return [ 'id' => isset($session['id']) ? $session['id'] : '', 'trigger' => isset($session['trigger']) ? $session['trigger'] : 'manual', 'status' => $session['status'], // Finished URLs, which moves with every response. 'index' only moves at // the end of a chunk, so the progress bar tracks this instead. 'done' => $done, 'index' => (int) $session['index'], 'total' => (int) $session['total'], 'warmed' => (int) $session['warmed'], 'cached' => (int) $session['cached'], 'bypassed' => (int) $session['bypassed'], 'failed' => (int) $session['failed'], 'started' => (float) $session['started'], 'concurrency' => (int) $session['concurrency'], 'images_queued' => (int) (isset($session['images_queued']) ? $session['images_queued'] : 0), 'images_capped' => (int) (isset($session['images_capped']) ? $session['images_capped'] : 0), // Seconds until a load pause lifts, so the browser can say why nothing // is happening instead of looking stalled. 'layers_seen' => (int) (isset($session['layers_seen']) ? $session['layers_seen'] : 0), 'resume_in' => empty($session['resume_after']) ? 0 : max(0, (int) $session['resume_after'] - time()), 'pause_reason' => isset($session['pause_reason']) ? $session['pause_reason'] : '', 'pressure' => isset($session['pressure']) ? $session['pressure'] : null, ]; } /** * Read an option straight from the database, bypassing the options cache. * * Needed wherever two concurrent PHP processes must agree on a value, the * options cache is per-request, so a cached read would never see the other * process's write. * * @param string $name Option name. * @return string|null */ function abcw_raw_option($name) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Must bypass the cache to observe concurrent writes. $value = $wpdb->get_var( $wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $name) ); return null === $value ? null : (string) $value; } /** * Claim the right to process a batch. * * The browser and WP-Cron can both try to drive the same run, and without a * lock they would double-warm URLs and race on the queue position. The INSERT * is atomic because option_name carries a unique index, so exactly one caller * wins. A lock older than the stale window is assumed to belong to a process * that died mid-batch, and is taken over. * * @return bool True if this process may process a batch. */ function abcw_acquire_lock() { global $wpdb; $now = time(); $stale = 120; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic claim; relies on the unique index on option_name. $claimed = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", 'abcw_batch_lock', (string) $now ) ); if ($claimed) { return true; } $held = (int) abcw_raw_option('abcw_batch_lock'); if ($held > 0 && ($now - $held) > $stale) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Conditional takeover; succeeds only if nobody refreshed the lock first. $taken = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", (string) $now, 'abcw_batch_lock', (string) $held ) ); return (bool) $taken; } return false; } /** * Release the batch lock. * * @return void */ function abcw_release_lock() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Paired with the direct INSERT in abcw_acquire_lock(). $wpdb->delete($wpdb->options, ['option_name' => 'abcw_batch_lock']); wp_cache_delete('abcw_batch_lock', 'options'); } /** * Load the live row log for a session, starting a fresh one if none matches. * * The log lives apart from the session on purpose. The session row is written * constantly (every chunk, every throttle tick) and when the log rode inside * it, each of those writes re-serialised up to 150 result rows that had not * changed. Split out, the session write is a kilobyte of counters, and the log * is only written when there are new rows to record. * * Only the lock-holder ever writes the log; the progress feed just reads it. * * @param string $session_id Session the log must belong to. * @return array{id: string, next_id: int, rows: array>} */ function abcw_log_load($session_id) { $log = get_option('abcw_session_log', null); if (!is_array($log) || !isset($log['id'], $log['next_id'], $log['rows']) || $log['id'] !== $session_id) { $log = ['id' => $session_id, 'next_id' => 1, 'rows' => []]; } return $log; } /** * Process one time-boxed batch of the active run. * * Called by the browser while the tab is open, and by WP-Cron when it is not. * Whichever calls it, the lock guarantees only one runs at a time. * * @return array|null Updated session, or null if it could not run. */ function abcw_process_batch() { $session = abcw_session_get(); if (null === $session || 'running' !== $session['status']) { return $session; } if (abcw_stop_requested()) { $session['status'] = 'stopped'; abcw_finish_session($session); return $session; } if (!abcw_acquire_lock()) { // Another process is mid-batch and is already writing progress. return null; } try { // Re-read now the lock is held, so we start from the true position. $session = abcw_session_get(); if (null === $session || 'running' !== $session['status']) { return $session; } // Standing down after finding the server overloaded. Do nothing at all // until the window passes, including not warming a single URL, which is // the whole point of having paused. if (!empty($session['resume_after']) && time() < (int) $session['resume_after']) { return $session; } $session['resume_after'] = 0; $settings = abcw_get_settings(); $urls = abcw_session_urls(); $total = count($urls); $discover_images = !empty($settings['sources']['images']); $budget_started = microtime(true); $budget = 15.0; $concurrency = max(1, (int) $session['concurrency']); $log = abcw_log_load($session['id']); $batch_rows = []; // Called the instant each URL comes back, mid-chunk. Tallying and // logging here (rather than once the whole chunk lands) is what puts // a row on screen as it happens instead of five at a time. $flushed = microtime(true); $emit = function ($result) use (&$session, &$log, &$flushed) { unset($result['images']); // Rate-limited responses are not results yet, the batch loop // either requeues them for another attempt or, out of retries, // sends them back through here with the flag cleared. if (!empty($result['rate_limited'])) { return; } unset($result['retry_after']); // Used to tell a genuinely warmed site apart from one with no page // cache at all, where every response looks like a fresh render. if ('' !== $result['layer']) { $session['layers_seen'] = (int) (isset($session['layers_seen']) ? $session['layers_seen'] : 0) + 1; } switch ($result['outcome']) { case 'warmed': $session['warmed']++; break; case 'cached': $session['cached']++; break; case 'bypassed': $session['bypassed']++; break; default: $session['failed']++; break; } $result['id'] = $log['next_id']++; $log['rows'][] = $result; $log['rows'] = array_slice($log['rows'], -ABCW_LOG_LIMIT); // Throttled: the browser polls several times a second, so writing // more often than this would cost database round trips nobody sees. $now = microtime(true); if (($now - $flushed) >= 0.2) { $flushed = $now; abcw_session_set($session); update_option('abcw_session_log', $log, false); } }; while ($session['index'] < $total && (microtime(true) - $budget_started) < $budget) { // Check the machine before every chunk, not just once at the start // of the run, a run can last a long time, and the traffic it has to // share the server with will not be the same at the end as it was at // the beginning. $chunk_ceiling = $concurrency; if (!empty($settings['adaptive'])) { $pressure = abcw_load_pressure(); $limit = abcw_load_ceiling($pressure, $concurrency); $session['pressure'] = null === $pressure ? null : round($pressure, 2); if ($limit['pause']) { $session['pause_reason'] = 'load'; $pauses = (int) (isset($session['pauses']) ? $session['pauses'] : 0) + 1; $give_up = (int) apply_filters('abcw_load_pause_limit', 3); $session['pauses'] = $pauses; if ($pauses <= $give_up) { // Genuinely overloaded. Stand down and let cron bring the // run back once the machine has had a chance to recover. $session['resume_after'] = time() + (int) apply_filters('abcw_load_pause', 2 * MINUTE_IN_SECONDS); abcw_session_set($session); break; } // On shared hosting the load average covers every site on the // box, not just this one, and it may never drop to anything // this plugin would call quiet. After a few goes at waiting, // crawl at the gentlest setting there is, one URL at a time, // maximum delay, rather than leaving the run stalled for good. $limit['concurrency'] = 1; } else { $session['pauses'] = 0; } $chunk_ceiling = max(1, min($concurrency, $limit['concurrency'])); $session['delay'] = max((int) $session['delay'], (int) $limit['delay']); } $chunk = array_slice($urls, $session['index'], $chunk_ceiling); if (empty($chunk)) { break; } $chunk_results = abcw_request_chunk($chunk, $settings, $discover_images, $emit); if ($discover_images) { $discovered = []; foreach ($chunk_results as $key => $result) { if (!empty($result['images'])) { $discovered = array_merge($discovered, $result['images']); } unset($chunk_results[$key]['images']); } if (!empty($discovered) && abcw_queue_images($urls, $discovered, $session) > 0) { $urls = abcw_session_urls(); $total = count($urls); } } $rate_limited = []; $kept_results = []; foreach ($chunk_results as $chunk_row) { if (!empty($chunk_row['rate_limited'])) { $rate_limited[] = $chunk_row; } else { $kept_results[] = $chunk_row; $batch_rows[] = $chunk_row; } } // Tallies and rows were recorded live by $emit as each URL landed; // the queue position only moves once the whole chunk is accounted // for, so an interrupted chunk resumes rather than skipping URLs. $session['index'] += count($chunk); $session['total'] = $total - (int) (isset($session['retry_slots']) ? $session['retry_slots'] : 0); // Adaptive throttling: react to how the server is coping right now. if (!empty($settings['adaptive']) && !empty($kept_results)) { $chunk_median = abcw_median(wp_list_pluck($kept_results, 'time')); if (null === $session['baseline'] && $chunk_median > 0) { $session['baseline'] = $chunk_median; } elseif ($chunk_median > 0 && $session['baseline'] > 0) { // The baseline is a rolling blend, not a frozen first // measurement. Frozen, it read every heavier section of a // site as a struggling server, a run that started on the // light homepage throttled itself to nothing the moment it // reached the heavy listing pages, and stayed there. Rolled // at 20% a chunk it works out to: back off now, and if the // slowness turns out to be what this part of the site just // costs, accept it as normal within a few chunks and come // back up. A one-chunk spike barely moves it, so genuine // hiccups still get the full backoff, and machine-wide // stress is the load check's job, not this one's. $session['baseline'] = (0.8 * (float) $session['baseline']) + (0.2 * $chunk_median); if ($chunk_median > ($session['baseline'] * 2)) { $concurrency = max(1, $concurrency - 1); $session['concurrency'] = $concurrency; $session['delay'] = min(1000, (int) $session['delay'] + 250); } elseif ($chunk_median < ($session['baseline'] * 1.2)) { $concurrency = min((int) $settings['concurrency'], $concurrency + 1); $session['concurrency'] = $concurrency; $session['delay'] = max((int) $settings['delay'], (int) $session['delay'] - 100); } } } // A 429 means the server is refusing the pace, and instantly // rejected requests make an unthrottled loop FASTER, one bad run // burned through hundreds of URLs in seconds, hammering a server // that had just asked for mercy. So: each refused URL goes back on // the queue for up to two more tries, the crawl drops to a single // request with a full second between, and the run stands down for // the Retry-After window before touching the server again. if (!empty($rate_limited)) { $retries = isset($session['retry_counts']) && is_array($session['retry_counts']) ? $session['retry_counts'] : []; $requeue = []; $wait = 0; foreach ($rate_limited as $refused) { $wait = max($wait, (int) $refused['retry_after']); $attempts = isset($retries[$refused['url']]) ? (int) $retries[$refused['url']] : 0; if ($attempts < 2) { $retries[$refused['url']] = $attempts + 1; $requeue[] = $refused['url']; continue; } // Three refusals is an answer, not bad luck. unset($refused['rate_limited'], $refused['retry_after']); /* translators: %d: HTTP status code */ $refused['error'] = sprintf(__('HTTP %d: the server rate limited the warmer. Lower the concurrency in Advanced settings.', 'abcode-cache-warmer'), $refused['code']); $emit($refused); $batch_rows[] = $refused; } $session['retry_counts'] = $retries; if (!empty($requeue)) { $urls = array_merge($urls, $requeue); $total = count($urls); update_option('abcw_session_urls', $urls, false); // Queue slots, not new URLs, the display total must not // count the same page twice just because it gets retried. $session['retry_slots'] = (int) (isset($session['retry_slots']) ? $session['retry_slots'] : 0) + count($requeue); } $concurrency = 1; $session['concurrency'] = 1; $session['delay'] = max((int) $session['delay'], 1000); $session['pause_reason'] = 'rate'; $session['resume_after'] = time() + max(30, min(600, $wait > 0 ? $wait : 60)); abcw_session_set($session); update_option('abcw_session_log', $log, false); break; } $session['pause_reason'] = ''; // $emit has already been persisting rows and tallies mid-chunk; // this is what commits the queue position and the retuned pacing. abcw_session_set($session); if ($session['delay'] > 0 && $session['index'] < $total) { usleep((int) $session['delay'] * 1000); } if (abcw_stop_requested()) { $session['status'] = 'stopped'; break; } } if ('stopped' !== $session['status'] && $session['index'] >= $total) { $session['status'] = 'completed'; } // One write per batch, not one per chunk. The permanent record loses at // most a batch's rows to a crash, the warms themselves are unaffected, // and the Results tab's own maths reports any rows that went missing. abcw_buffer_results($batch_rows, $session['id']); update_option('abcw_session_log', $log, false); abcw_session_set($session); if ('running' !== $session['status']) { abcw_finish_session($session); } return $session; } finally { abcw_release_lock(); } } // ─── Background continuation ────────────────────────────────────────────────── /** * Queue the next background batch. * * @param int $delay Seconds from now. * @return void */ function abcw_schedule_continuation($delay = 10) { if (wp_next_scheduled('abcw_continue_run')) { return; } wp_schedule_single_event(time() + max(0, $delay), 'abcw_continue_run'); } /** * Keep an in-progress run moving when no browser is driving it. * * This is what makes it safe to close the tab on a large site. Cron is slower * than the browser loop, WordPress only fires it when someone visits the site, * unless a real system cron is configured, but the run keeps advancing, and * reopening the page picks it straight back up. * * @return void */ function abcw_continue_run_handler() { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Batch is time-boxed internally. @set_time_limit(90); $session = abcw_session_get(); if (null === $session || 'running' !== $session['status']) { return; } abcw_process_batch(); $session = abcw_session_get(); if (is_array($session) && 'running' === $session['status']) { // Come back when a load pause lifts rather than every few seconds, so a // stood-down run is not quietly waking the server up to ask again. $next = empty($session['resume_after']) ? time() + 5 : max(time() + 5, (int) $session['resume_after']); wp_schedule_single_event($next, 'abcw_continue_run'); } } add_action('abcw_continue_run', 'abcw_continue_run_handler'); // ─── Schedules ──────────────────────────────────────────────────────────────── /** * Selectable warming intervals. * * @return array */ function abcw_schedule_options() { return [ 'none' => __('Off: warm manually', 'abcode-cache-warmer'), 'hourly' => __('Every hour', 'abcode-cache-warmer'), 'twicedaily' => __('Twice a day', 'abcode-cache-warmer'), 'daily' => __('Once a day', 'abcode-cache-warmer'), 'abcw_weekly' => __('Once a week', 'abcode-cache-warmer'), ]; } add_filter('cron_schedules', function ($schedules) { if (!isset($schedules['abcw_ten_minutes'])) { $schedules['abcw_ten_minutes'] = [ 'interval' => 10 * MINUTE_IN_SECONDS, 'display' => __('Every Ten Minutes', 'abcode-cache-warmer'), ]; } if (!isset($schedules['abcw_weekly'])) { $schedules['abcw_weekly'] = [ 'interval' => WEEK_IN_SECONDS, 'display' => __('Once Weekly', 'abcode-cache-warmer'), ]; } return $schedules; }); /** * When the first run of a schedule should fire, in the site's own timezone. * * Without an anchor a daily schedule runs at whatever time the setting * happened to be saved, save at 2pm, warm at 2pm forever. Most sites want * this in the early hours, so daily, twice-daily and weekly runs anchor to a * chosen clock time (and, for weekly, a chosen day); the recurrence then * repeats from there. Hourly has no meaningful clock time and starts straight * away. * * @param string $interval One of abcw_schedule_options(). * @param string $time Clock time as HH:MM. * @param int $day Day of week, 0 = Sunday, for weekly. * @return int Unix timestamp of the first run. */ function abcw_next_schedule_stamp($interval, $time, $day) { if ('hourly' === $interval) { return time() + MINUTE_IN_SECONDS; } if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $time)) { $time = '03:00'; } $timezone = wp_timezone(); $now = new DateTimeImmutable('now', $timezone); if ('abcw_weekly' === $interval) { $days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; $name = $days[max(0, min(6, (int) $day))]; // "this Sunday" is today when today is Sunday, so the time can still // be ahead of us; if it has already passed, next week it is. $next = new DateTimeImmutable("this {$name} {$time}", $timezone); if ($next <= $now) { $next = new DateTimeImmutable("next {$name} {$time}", $timezone); } return $next->getTimestamp(); } $next = new DateTimeImmutable("today {$time}", $timezone); if ($next <= $now) { $next = $next->modify('+1 day'); } return $next->getTimestamp(); } /** * Apply a schedule setting to WP-Cron. * * Twice a day is deliberately not WP-Cron's own twicedaily recurrence, that * is a rigid every-twelve-hours tick from whenever it was anchored. People * choosing two times mean those two times, and they need not be twelve hours * apart, so it is scheduled as two independent daily events on the same hook. * * @param string $interval One of abcw_schedule_options(). * @param string $time Clock time as HH:MM. * @param int $day Day of week, 0 = Sunday, for weekly. * @param string $time2 Second clock time, for twice a day. * @return void */ function abcw_apply_schedule($interval, $time = '03:00', $day = 0, $time2 = '15:00') { wp_unschedule_hook('abcw_scheduled_warm'); if ('none' === $interval || !array_key_exists($interval, abcw_schedule_options())) { return; } if ('twicedaily' === $interval) { wp_schedule_event(abcw_next_schedule_stamp('daily', $time, $day), 'daily', 'abcw_scheduled_warm'); // The same time twice is one run a day, not an error. if ($time2 !== $time) { wp_schedule_event(abcw_next_schedule_stamp('daily', $time2, $day), 'daily', 'abcw_scheduled_warm'); } return; } wp_schedule_event(abcw_next_schedule_stamp($interval, $time, $day), $interval, 'abcw_scheduled_warm'); } add_action('abcw_scheduled_warm', function () { $session = abcw_start_run('scheduled'); if (!is_wp_error($session)) { abcw_schedule_continuation(0); } }); /** * Whether anything is actually servicing WP-Cron. * * The DISABLE_WP_CRON constant only says the page-load trigger is off, on * managed hosting that usually means a real server cron has taken over, which * is the better setup, not a broken one. The honest test is the queue itself: * if the oldest scheduled event is long past due, nothing is running cron, * whatever the constant says. A healthy queue means it is being serviced. * * @return bool True when cron events are overdue and nothing is firing them. */ function abcw_cron_stale() { if (!function_exists('_get_cron_array')) { return false; } $crons = _get_cron_array(); if (!is_array($crons) || empty($crons)) { return false; } // Keys are timestamps in ascending order; the first is the oldest due. $oldest = (int) array_key_first($crons); // Fifteen minutes of grace: a busy queue can legitimately run a few // minutes behind without anything being wrong. return $oldest > 0 && $oldest < (time() - 15 * MINUTE_IN_SECONDS); } /** * Schedule status for the admin screen. * * @return array */ function abcw_schedule_status() { $settings = abcw_get_settings(); $next = wp_next_scheduled('abcw_scheduled_warm'); // The setting and the cron event can drift apart, a reactivated plugin, a // migrated database, a cleared cron array. The setting is the intent, so // the event is rebuilt from it rather than reported missing. if (!$next && !empty($settings['schedule']) && 'none' !== $settings['schedule']) { abcw_apply_schedule($settings['schedule'], $settings['schedule_time'], (int) $settings['schedule_day'], $settings['schedule_time2']); $next = wp_next_scheduled('abcw_scheduled_warm'); } $format = get_option('date_format') . ' ' . get_option('time_format'); $options = abcw_schedule_options(); $interval = isset($settings['schedule']) ? $settings['schedule'] : 'none'; return [ 'interval' => $interval, 'label' => isset($options[$interval]) ? $options[$interval] : $interval, 'next' => $next ? wp_date($format, $next) : '', 'nextStamp' => $next ? (int) $next : 0, 'cronDisabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON, 'cronStale' => abcw_cron_stale(), ]; } // ─── Automatic warming after a purge ────────────────────────────────────────── /** * Caching plugins we listen to, and the actions each fires when it clears. * * Clearing a cache is the moment this plugin exists for: every page is cold, * and whoever arrives next pays for the rebuild. Catching the purge itself is * the difference between warming *before* visitors hit the site and warming on * whatever schedule happens to come round next. * * Hooks are registered whether or not the matching plugin is installed, an * action nobody fires costs nothing, and detection at plugin-load time would be * unreliable anyway, since load order decides which plugins have defined their * constants by then. The `active` flag is only used to tell the user what was * found, and is evaluated later, in the admin, once everything is loaded. * * @return array}> */ function abcw_purge_integrations() { return [ 'wp-rocket' => [ 'label' => 'WP Rocket', 'active' => defined('WP_ROCKET_VERSION'), 'hooks' => ['after_rocket_clean_domain', 'after_rocket_clean_home'], ], 'litespeed' => [ 'label' => 'LiteSpeed Cache', 'active' => defined('LSCWP_V'), 'hooks' => ['litespeed_purged_all', 'litespeed_purged_all_lscache', 'litespeed_purge_all'], ], 'w3-total-cache' => [ 'label' => 'W3 Total Cache', 'active' => defined('W3TC_VERSION'), 'hooks' => ['w3tc_flush_all', 'w3tc_flush_posts'], ], 'wp-super-cache' => [ 'label' => 'WP Super Cache', 'active' => defined('WPCACHEHOME'), 'hooks' => ['wp_cache_cleared'], ], 'wp-fastest-cache' => [ 'label' => 'WP Fastest Cache', 'active' => class_exists('WpFastestCache'), 'hooks' => ['wpfc_delete_cache'], ], 'cache-enabler' => [ 'label' => 'Cache Enabler', 'active' => defined('CACHE_ENABLER_VERSION'), 'hooks' => ['cache_enabler_complete_cache_cleared', 'cache_enabler_site_cache_cleared'], ], 'sg-optimizer' => [ 'label' => 'SiteGround Optimizer', 'active' => defined('SG_CACHEPRESS_VERSION'), 'hooks' => ['siteground_optimizer_flush_cache'], ], 'breeze' => [ 'label' => 'Breeze', 'active' => defined('BREEZE_VERSION'), 'hooks' => ['breeze_clear_all_cache'], ], 'hummingbird' => [ 'label' => 'Hummingbird', 'active' => defined('WPHB_VERSION'), 'hooks' => ['wphb_clear_page_cache'], ], 'wp-optimize' => [ 'label' => 'WP-Optimize', 'active' => defined('WPO_VERSION'), 'hooks' => ['wpo_cache_flushed'], ], 'nginx-helper' => [ 'label' => 'Nginx Helper', 'active' => defined('NGINX_HELPER_BASENAME') || class_exists('Nginx_Helper'), 'hooks' => ['rt_nginx_helper_purge_all'], ], 'autoptimize' => [ 'label' => 'Autoptimize', 'active' => defined('AUTOPTIMIZE_PLUGIN_VERSION'), 'hooks' => ['autoptimize_action_cachepurged'], ], // 20i's StackCache ships as a host mu-plugin and fires no purge action // of its own, so there is nothing to listen to directly. These are the // exact core events its source registers all_purge_cache() on, // verified against /usr/share/php/wp-stack-cache.php, not just 20i's // docs. Its single-page purges (edit_post, comments) are covered by // the publish and comment triggers, and its manual purge button by the // listeners in abcw_stackcache_listeners(). // // Registering these only when StackCache is present is safe because // mu-plugins always load ahead of ordinary plugins, so the class is // guaranteed to exist by the time this runs. 'stackcache' => [ 'label' => 'StackCache (20i)', 'active' => class_exists('WPStackCache'), 'hooks' => class_exists('WPStackCache') ? ['switch_theme', 'customize_save_after', 'activated_plugin', 'deactivated_plugin'] : [], ], ]; } /** * Every action treated as "the cache was just cleared". * * Filterable so a cache this plugin has never heard of, a host's own MU plugin, * say, can be wired in with one line. * * @return array */ function abcw_purge_hooks() { $hooks = []; foreach (abcw_purge_integrations() as $integration) { $hooks = array_merge($hooks, $integration['hooks']); } return array_values(array_unique((array) apply_filters('abcw_purge_hooks', $hooks))); } /** * Which supported caching plugins are actually present, for the admin screen. * * @return array */ function abcw_purge_status() { $detected = []; foreach (abcw_purge_integrations() as $integration) { if ($integration['active']) { $detected[] = $integration['label']; } } $next = wp_next_scheduled('abcw_auto_warm'); $pages = wp_next_scheduled('abcw_warm_pages'); $queue = get_option('abcw_page_queue', []); $last_page = get_option('abcw_last_page_warm', null); return [ 'detected' => $detected, 'nextStamp' => $next ? (int) $next : 0, 'reason' => (string) get_option('abcw_auto_reason', ''), 'pagesQueued' => $pages && is_array($queue) ? count($queue) : 0, 'lastPages' => is_array($last_page) ? $last_page : null, ]; } /** * Queue an automatic run. * * Two things stop this turning into a stampede. The pending-event check folds a * burst of purges (a bulk edit fires one per post) into the single run that is * already waiting, and the cooldown keeps a site that purges constantly from * crawling itself back to back all day. * * @param string $reason Human-readable trigger, shown in the admin. * @return void */ function abcw_queue_auto_warm($reason) { if (wp_next_scheduled('abcw_auto_warm')) { return; } $settings = abcw_get_settings(); $delay = max(0, min(HOUR_IN_SECONDS, (int) $settings['purge_delay'])); $cooldown = (int) apply_filters('abcw_auto_cooldown', 5 * MINUTE_IN_SECONDS); $last = (int) get_option('abcw_auto_last', 0); update_option('abcw_auto_reason', sanitize_text_field($reason), false); wp_schedule_single_event(max(time() + $delay, $last + $cooldown), 'abcw_auto_warm'); } // ─── Single-page warming ────────────────────────────────────────────────────── /** * Caching plugins that tell us *which* page they cleared. * * Most single-page purges come with a post ID, and that changes what the right * response is entirely: one page went cold, so warm one page. Recrawling a * thousand URLs because somebody fixed a typo is the kind of thing that gives * cache warmers a bad name. * * Each of these passes the post as its first argument, either as an ID or as a * WP_Post. They are kept out of the site-wide list on purpose, a hook must * mean one thing or the other, never both. * * @return array */ function abcw_purge_post_hooks() { return array_values( array_unique( (array) apply_filters( 'abcw_purge_post_hooks', [ 'after_rocket_clean_post', 'w3tc_flush_post', // The API action third parties fire, and the event the // plugin emits after its own single-post purges. 'litespeed_purge_post', 'litespeed_purged_single', ] ) ) ); } /** * Queue URLs for a light, targeted warm. * * Kept completely apart from the main run machinery: no session, no lock, no * progress feed. A handful of URLs does not need any of it, and staying out of * the way means a page warm can never disturb a full run that is under way. * * @param array $urls URLs to warm. * @return void */ function abcw_queue_page_warm($urls) { $settings = abcw_get_settings(); $queue = get_option('abcw_page_queue', []); if (!is_array($queue)) { $queue = []; } $patterns = abcw_lines_to_array(isset($settings['exclude']) ? $settings['exclude'] : ''); $regexes = array_map('abcw_pattern_to_regex', $patterns); $limit = (int) apply_filters('abcw_page_queue_limit', 50); $added = false; foreach ((array) $urls as $url) { $url = abcw_normalize_url($url); if ('' === $url || in_array($url, $queue, true)) { continue; } // A skip list is a skip list however the URL got here. foreach ($regexes as $regex) { if (preg_match($regex, $url)) { continue 2; } } // Past the cap this stops being a targeted warm and starts being a // crawl by the back door, which is what the full run is for. if (count($queue) >= $limit) { break; } $queue[] = $url; $added = true; } if (!$added) { return; } update_option('abcw_page_queue', $queue, false); if (!wp_next_scheduled('abcw_warm_pages')) { $delay = max(10, min(5 * MINUTE_IN_SECONDS, (int) $settings['purge_delay'])); wp_schedule_single_event(time() + $delay, 'abcw_warm_pages'); } } /** * Public integration hook: warm a specific set of URLs on request. * * Any other code, a companion plugin, a host's own purge routine, a * deployment script, can say "these URLs just changed, warm them" without * knowing anything about this plugin's internals: * * do_action('abcw_warm_urls', ['https://example.com/', ...]); * * It runs each URL through the same targeted-warm queue as an edit: skip list * applied, deduped, throttled, logged. Firing it when this plugin is not active * is a harmless no-op, so callers can fire it unconditionally. This is how the * warmer rebuilds pages that something else, a cache layer that under-purges, * for instance, has just dropped. * * @param array|string $urls URL or URLs to warm. * @return void */ add_action('abcw_warm_urls', function ($urls) { abcw_queue_page_warm((array) $urls); }); /** * Public integration hook: warm the whole site. * * The counterpart to abcw_warm_urls, for when everything has been cleared, a * full cache purge, and the site needs rebuilding rather than a handful of * pages. It does not start the run inline: it waits the same settle delay as * any other purge-driven warm, so the cache has time to actually drop before * the crawl begins, and it collects URLs in the background rather than on the * request that fired the hook. Harmless no-op if this plugin is not installed. * * @return void */ add_action('abcw_warm_all', function () { if (wp_next_scheduled('abcw_warm_all_run')) { return; } $settings = abcw_get_settings(); $delay = max(0, min(HOUR_IN_SECONDS, (int) $settings['purge_delay'])); wp_schedule_single_event(time() + $delay, 'abcw_warm_all_run'); }); /** * Start the full run a moment after abcw_warm_all was fired. * * @return void */ add_action('abcw_warm_all_run', function () { $session = abcw_start_run('manual'); if (!is_wp_error($session)) { abcw_schedule_continuation(0); } }); /** * Warm the queued pages. * * @return void */ add_action('abcw_warm_pages', function () { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- A full queue of slow pages can outlast the default limit on cron. @set_time_limit(120); $queue = get_option('abcw_page_queue', []); if (!is_array($queue) || empty($queue)) { delete_option('abcw_page_queue'); return; } $settings = abcw_get_settings(); // Deliberately gentle: a couple at a time is plenty for a handful of URLs, // and it keeps this well clear of anything else running. The stored queue // is trimmed as each pair completes, so a request killed part-way loses // nothing, the remainder is still there for the next trigger to pick up. $results = []; foreach (array_chunk($queue, 2) as $chunk) { $results = array_merge($results, abcw_request_chunk($chunk, $settings, false)); $queue = array_slice($queue, count($chunk)); update_option('abcw_page_queue', $queue, false); } delete_option('abcw_page_queue'); $warmed = 0; foreach ($results as $result) { if ('failed' !== $result['outcome']) { $warmed++; } } update_option( 'abcw_last_page_warm', [ 'time' => time(), 'total' => count($results), 'warmed' => $warmed, 'urls' => array_slice(wp_list_pluck($results, 'url'), 0, 10), ], false ); // Targeted warms leave the same per-URL record full runs do, so "what got // re-warmed when I edited that page" is answerable from the Results tab. // Consecutive targeted warms within an hour share one entry, a busy // comment day extends a bucket instead of flooding the run history. if (!empty($results)) { $now = time(); $rows = []; foreach ($results as $row) { unset($row['images'], $row['retry_after'], $row['rate_limited']); $row['at'] = $now; $rows[] = $row; } $tally = ['warmed' => 0, 'cached' => 0, 'bypassed' => 0, 'failed' => 0]; foreach ($rows as $row) { $key = isset($tally[$row['outcome']]) ? $row['outcome'] : 'failed'; $tally[$key]++; } $index = get_option('abcw_run_index', []); $index = is_array($index) ? $index : []; $bucket = (!empty($index) && isset($index[0]['trigger'], $index[0]['id']) && 'targeted' === $index[0]['trigger'] && '' !== $index[0]['id'] && ($now - (int) $index[0]['time']) < HOUR_IN_SECONDS); if ($bucket) { $stored = get_option('abcw_run_' . $index[0]['id'], []); $stored = is_array($stored) ? array_merge($stored, $rows) : $rows; update_option('abcw_run_' . $index[0]['id'], $stored, false); foreach ($tally as $key => $count) { $index[0][$key] += $count; } $index[0]['total'] += count($rows); $index[0]['rows_stored'] = count($stored); } else { $run_id = 'tg' . preg_replace('/[^a-zA-Z0-9]/', '', wp_generate_password(10, false)); update_option('abcw_run_' . $run_id, $rows, false); array_unshift($index, $tally + [ 'id' => $run_id, 'time' => $now, 'trigger' => 'targeted', 'status' => 'completed', 'total' => count($rows), 'duration' => 0, 'date' => wp_date(get_option('date_format') . ' ' . get_option('time_format')), 'rows_stored' => count($rows), ]); } update_option('abcw_run_index', $index, false); abcw_prune_runs(); } }); /** * A caching plugin cleared one specific page. * * @param mixed $post Post ID or WP_Post, depending on which plugin fired. * @return void */ function abcw_purge_post_triggered($post = null) { $settings = abcw_get_settings(); if (empty($settings['on_purge'])) { return; } $post_id = 0; if ($post instanceof WP_Post) { $post_id = (int) $post->ID; } elseif (is_numeric($post)) { $post_id = (int) $post; } if ($post_id < 1) { return; } $permalink = get_permalink($post_id); if ($permalink) { abcw_queue_page_warm([$permalink]); } } foreach (abcw_purge_post_hooks() as $abcw_post_hook) { add_action($abcw_post_hook, 'abcw_purge_post_triggered', 20, 1); } /** * A caching plugin just cleared its cache. * * @return void */ function abcw_purge_triggered() { $settings = abcw_get_settings(); if (empty($settings['on_purge'])) { return; } $fired = (string) current_filter(); $label = __('a cache purge', 'abcode-cache-warmer'); foreach (abcw_purge_integrations() as $integration) { if (in_array($fired, $integration['hooks'], true)) { $label = $integration['label']; break; } } abcw_queue_auto_warm($label); } // Registered at plugins_loaded, not file scope: the StackCache entry gates its // hooks on class_exists(), and when StackCache runs as an ordinary plugin it // loads after this one alphabetically, a file-scope check would miss it and // silently register nothing, while the admin screen (which checks later, when // everything is loaded) claimed to be listening. As an mu-plugin it loads // first either way; deferring covers both installs. add_action( 'plugins_loaded', function () { foreach (abcw_purge_hooks() as $purge_hook) { add_action($purge_hook, 'abcw_purge_triggered', 20); } abcw_stackcache_listeners(); }, 20 ); /** * Catch StackCache's manual purge button the moment it is pressed. * * StackCache purges two ways from the UI, and its source shows neither fires * an action of its own. The admin-bar link calls admin-ajax with * action=purge-all-cache, and that handler die()s when done, so our listener * sits on the same ajax action at priority 1: it queues the warm and returns, * then StackCache's priority-10 handler purges and exits. The settings page * instead posts stack-cache-purge-all to init, with no exit, so a later look * at the same request is enough. * * Both listeners only queue through abcw_queue_auto_warm, so the purge delay * and cooldown apply exactly as they do to every other trigger. * * @return void */ function abcw_stackcache_listeners() { if (!class_exists('WPStackCache')) { return; } add_action( 'wp_ajax_purge-all-cache', function () { $settings = abcw_get_settings(); if (!empty($settings['on_purge'])) { abcw_queue_auto_warm(__('the StackCache purge button', 'abcode-cache-warmer')); } // No exit: StackCache's own handler runs next and does the purge. }, 1 ); add_action( 'init', function () { // Detecting StackCache's own purge form, which we cannot nonce; no // value is read, and the only effect is scheduling a throttled warm. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See above. if (!isset($_POST['stack-cache-purge-all']) || !is_user_logged_in()) { return; } $settings = abcw_get_settings(); if (!empty($settings['on_purge'])) { abcw_queue_auto_warm(__('the StackCache purge button', 'abcode-cache-warmer')); } }, 20 ); } /** * Content was published or updated. * * Ordered cheapest check first: this fires on every status change on the site, * including autosaves and revisions, so it bails long before it reads settings. * * @param string $new_status Status being moved to. * @param string $old_status Status being moved from. * @param WP_Post $post The post. * @return void */ function abcw_publish_triggered($new_status, $old_status, $post) { if ('publish' !== $new_status && 'publish' !== $old_status) { return; } if (!$post instanceof WP_Post || wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) { return; } $post_type = get_post_type_object($post->post_type); if (!$post_type || empty($post_type->public)) { return; } $settings = abcw_get_settings(); if (empty($settings['on_publish'])) { return; } // Publishing changes the post itself and the pages that list it, not the // whole site. Warming those few URLs costs almost nothing, where a full // recrawl on every edit would be indefensible on a site with any volume of // publishing going on. $urls = []; $permalink = get_permalink($post->ID); if ($permalink) { $urls[] = $permalink; } $urls[] = home_url('/'); $archive = get_post_type_archive_link($post->post_type); if ($archive) { $urls[] = $archive; } // With a static front page the blog page lists posts and the homepage does not. if ('post' === $post->post_type && 'page' === get_option('show_on_front')) { $posts_page = (int) get_option('page_for_posts'); if ($posts_page > 0) { $link = get_permalink($posts_page); if ($link) { $urls[] = $link; } } } abcw_queue_page_warm($urls); } add_action('transition_post_status', 'abcw_publish_triggered', 10, 3); /** * A comment appeared or changed on a post. * * Most page caches (StackCache's source confirms it for 20i) purge the * post's page when a comment lands, and nothing announces it. One comment is * one cold page, so this rides the same targeted queue as the publish * trigger, under the same setting: it is the same kind of event, content * changing one page. * * @param int $post_id Post the comment belongs to. * @return void */ function abcw_comment_page_warm($post_id) { $post_id = (int) $post_id; if ($post_id < 1) { return; } $settings = abcw_get_settings(); if (empty($settings['on_publish'])) { return; } $permalink = get_permalink($post_id); if ($permalink) { abcw_queue_page_warm([$permalink]); } } add_action( 'comment_post', function ($comment_id, $approved) { // Only approved comments change the public page. if (1 === (int) $approved) { $comment = get_comment($comment_id); if ($comment) { abcw_comment_page_warm($comment->comment_post_ID); } } }, 10, 2 ); add_action( 'transition_comment_status', function ($new_status, $old_status, $comment) { // Approving or unapproving both change the rendered page. if ('approved' === $new_status || 'approved' === $old_status) { abcw_comment_page_warm(is_object($comment) ? $comment->comment_post_ID : 0); } }, 10, 3 ); add_action( 'edit_comment', function ($comment_id) { // Editing an approved comment's text changes the rendered page. (Every // page cache treats a comment change as a purge of its post; this warms // the post back up to match.) $comment = get_comment($comment_id); if ($comment && 1 === (int) $comment->comment_approved) { abcw_comment_page_warm($comment->comment_post_ID); } }, 10, 1 ); /** * Start the run a purge queued. * * @return void */ add_action('abcw_auto_warm', function () { $settings = abcw_get_settings(); if (empty($settings['on_purge']) && empty($settings['on_publish'])) { return; } $session = abcw_session_get(); // A run is already going. Joining it would be wrong: the purge invalidated // pages this run may have warmed minutes ago, so those need doing again. // Wait for it to finish and start clean. The staleness check means a run // abandoned by a dead process cannot defer this forever. if (is_array($session) && 'running' === $session['status'] && (time() - (int) (isset($session['updated']) ? $session['updated'] : 0)) < 10 * MINUTE_IN_SECONDS) { wp_schedule_single_event(time() + 2 * MINUTE_IN_SECONDS, 'abcw_auto_warm'); return; } // Stamped before the run rather than after, so the cooldown is measured from // when warming started even if the run itself takes a while. update_option('abcw_auto_last', time(), false); $session = abcw_start_run('automatic'); if (!is_wp_error($session)) { abcw_schedule_continuation(0); } }); // ─── Cache watchdog ─────────────────────────────────────────────────────────── /** * Notice a purge nobody announced. * * Purge hooks only catch caches that speak up. StackCache's own purge button, * a host clearing at server level, an eviction, none of those fire anything a * plugin can listen to, and the site just sits cold until someone visits or a * schedule comes round. So every ten minutes the watchdog asks the cache * itself: it fetches the homepage and reads the same headers the warmer reads. * A hit means all is well and the check cost one edge response. A verified * miss (a real cache layer reporting it, not just an absence of headers) * means something emptied the cache, and a warm is queued through the same * gate as every other automatic trigger, cooldown and all. * * The check itself rebuilds the homepage entry as a side effect, which is why * a cold site does not trip the watchdog twice. * * @return void */ function abcw_watchdog() { $settings = abcw_get_settings(); if (empty($settings['on_purge'])) { return; } // A run in progress or already queued makes the question moot. $session = abcw_session_get(); if (is_array($session) && 'running' === $session['status']) { return; } if (wp_next_scheduled('abcw_auto_warm')) { return; } $url = (string) apply_filters('abcw_watchdog_url', home_url('/')); $result = abcw_request_single($url, $settings); if ('warmed' !== $result['outcome'] || '' === $result['layer']) { return; } // A cold homepage alone proves nothing site-wide, it may just have been // edited or evicted on its own, and the check above has already re-warmed // it. Before concluding a full purge, cross-check sentinel pages from // around the site; only a quorum of them also being cold says the whole // cache went, and a scattered result stays a local event. $sentinels = (array) apply_filters('abcw_watchdog_sentinels', get_option('abcw_sentinels', [])); $sentinels = array_values(array_diff(array_filter($sentinels), [$url])); $sentinels = array_slice($sentinels, 0, 5); if (empty($sentinels)) { // No run has stored sentinels yet, so there is nothing to confirm // against, and a full crawl on one page's say-so is not safe. return; } $checks = abcw_request_chunk($sentinels, $settings); $cold = 0; foreach ($checks as $check) { if ('warmed' === $check['outcome'] && '' !== $check['layer']) { $cold++; } } $quorum = max(1, min((int) apply_filters('abcw_watchdog_quorum', 3), count($sentinels))); if ($cold >= $quorum) { abcw_queue_auto_warm( /* translators: %s: cache layer name, e.g. StackCache */ sprintf(__('the cache watchdog, %s was cold across the site', 'abcode-cache-warmer'), $result['layer']) ); } // Below quorum: whatever went cold, the checks themselves just re-warmed it. } add_action('abcw_watchdog', 'abcw_watchdog'); /** * Keep the watchdog scheduled while the purge trigger is on, and not otherwise. * * Runs from admin_init: cheap, and any lost cron event comes back on the next * wp-admin visit. * * @return void */ function abcw_ensure_watchdog() { $settings = abcw_get_settings(); $scheduled = wp_next_scheduled('abcw_watchdog'); if (!empty($settings['on_purge']) && !$scheduled) { wp_schedule_event(time() + (10 * MINUTE_IN_SECONDS), 'abcw_ten_minutes', 'abcw_watchdog'); } elseif (empty($settings['on_purge']) && $scheduled) { wp_unschedule_hook('abcw_watchdog'); } } add_action('admin_init', 'abcw_ensure_watchdog'); // ─── Lifecycle ──────────────────────────────────────────────────────────────── /** * Bring an existing install up to date with the current version. * * @return void */ function abcw_maybe_upgrade() { $installed = (string) get_option('abcw_version', ''); if (ABCW_VERSION === $installed) { return; } $settings = get_option('abcw_settings', []); // An install with settings but no recorded version predates 1.1.0. $from = ('' === $installed && is_array($settings) && !empty($settings)) ? '1.0.0' : $installed; // 1.1.0 stopped warming page builder template post types by default. Those // were only ever on because everything public was on, nobody chose to warm // /?elementor_library=default-kit, so switching them off is finishing the // job rather than overriding a decision. Anyone who wants them can tick // them straight back on. if ('' !== $from && version_compare($from, '1.1.0', '<') && is_array($settings) && !empty($settings['sources'])) { foreach (abcw_internal_post_types() as $post_type) { if (isset($settings['sources']['pt_' . $post_type])) { $settings['sources']['pt_' . $post_type] = 0; } } update_option('abcw_settings', $settings, false); } // Older builds stored one capped last-run and bare summaries. Carry the // data into the per-run store so nothing on an existing install is lost. // Guarded on its own artefacts rather than the version, so it runs exactly // once wherever the old options exist. if (!get_option('abcw_run_index')) { $old_last = get_option('abcw_last_results', null); $old_log = get_option('abcw_run_log', []); $index = []; if (is_array($old_last) && !empty($old_last['rows'])) { $rows = $old_last['rows']; unset($old_last['rows']); update_option('abcw_run_legacy', $rows, false); $index[] = $old_last + ['id' => 'legacy', 'rows_stored' => count($rows)]; } if (is_array($old_log)) { foreach ($old_log as $entry) { if (is_array($entry) && (empty($index) || (int) $entry['time'] !== (int) $index[0]['time'])) { $index[] = $entry + ['id' => '', 'rows_stored' => 0]; } } } if (!empty($index)) { update_option('abcw_run_index', $index, false); } delete_option('abcw_last_results'); delete_option('abcw_run_log'); } update_option('abcw_version', ABCW_VERSION, false); } add_action('admin_init', 'abcw_maybe_upgrade'); register_activation_hook(__FILE__, function () { $settings = abcw_get_settings(); if (!empty($settings['schedule']) && 'none' !== $settings['schedule']) { abcw_apply_schedule($settings['schedule'], $settings['schedule_time'], (int) $settings['schedule_day'], $settings['schedule_time2']); } }); register_deactivation_hook(__FILE__, function () { foreach (['abcw_scheduled_warm', 'abcw_continue_run', 'abcw_auto_warm', 'abcw_warm_pages', 'abcw_watchdog', 'abcw_warm_all_run'] as $hook) { wp_unschedule_hook($hook); } abcw_session_clear(); delete_option('abcw_page_queue'); }); // ─── Admin menu ─────────────────────────────────────────────────────────────── add_action('admin_menu', function () { add_management_page( __('Cache Warmer', 'abcode-cache-warmer'), __('Cache Warmer', 'abcode-cache-warmer'), 'manage_options', ABCW_SLUG, 'abcw_admin_page' ); }); add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) { $url = admin_url('tools.php?page=' . ABCW_SLUG); array_unshift($links, '' . esc_html__('Warm Cache', 'abcode-cache-warmer') . ''); return $links; }); // ─── Assets ─────────────────────────────────────────────────────────────────── add_action('admin_enqueue_scripts', function ($hook) { if (ABCW_HOOK !== $hook) { return; } wp_register_style('abcw', false, [], ABCW_VERSION); wp_enqueue_style('abcw'); wp_add_inline_style('abcw', abcw_inline_css()); wp_register_script('abcw', false, [], ABCW_VERSION, true); wp_enqueue_script('abcw'); wp_localize_script( 'abcw', 'abcwConfig', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('abcw_nonce'), 'resultLimit' => ABCW_RESULT_LIMIT, 'i18n' => [ /* translators: %s: URL count label (e.g. "142 URLs") */ 'warmCacheCount' => __('Warm Cache (%s)', 'abcode-cache-warmer'), /* translators: %d: number of URLs */ 'urlsSingular' => __('%d URL', 'abcode-cache-warmer'), /* translators: %d: number of URLs */ 'urlsPlural' => __('%d URLs', 'abcode-cache-warmer'), 'selectSource' => __('Select at least one source', 'abcode-cache-warmer'), 'selectAll' => __('Select all', 'abcode-cache-warmer'), 'clearAll' => __('Clear all', 'abcode-cache-warmer'), 'stopWarming' => __('Stop Warming', 'abcode-cache-warmer'), 'warmAgain' => __('Warm Again', 'abcode-cache-warmer'), 'collecting' => __('Collecting URLs...', 'abcode-cache-warmer'), 'stopping' => __('Stopping...', 'abcode-cache-warmer'), 'stopped' => __('Warming stopped.', 'abcode-cache-warmer'), /* translators: %1$d: URLs done, %2$d: total URLs */ 'progress' => __('Warming %1$d of %2$d', 'abcode-cache-warmer'), 'warmed' => __('Warmed', 'abcode-cache-warmer'), 'alreadyCached' => __('Cached', 'abcode-cache-warmer'), 'bypassed' => __('No cache', 'abcode-cache-warmer'), 'failed' => __('Failed', 'abcode-cache-warmer'), /* translators: %s: error message */ 'error' => __('Error: %s', 'abcode-cache-warmer'), 'calibrating' => __('Measuring...', 'abcode-cache-warmer'), 'recalibrate' => __('Re-measure', 'abcode-cache-warmer'), 'settingsSaved' => __('Settings saved', 'abcode-cache-warmer'), 'networkError' => __('Network error: please try again', 'abcode-cache-warmer'), 'justNow' => __('just now', 'abcode-cache-warmer'), /* translators: %d: number of minutes */ 'minuteAgo' => __('%d minute ago', 'abcode-cache-warmer'), /* translators: %d: number of minutes */ 'minutesAgo' => __('%d minutes ago', 'abcode-cache-warmer'), /* translators: %d: number of hours */ 'hourAgo' => __('%d hour ago', 'abcode-cache-warmer'), /* translators: %d: number of hours */ 'hoursAgo' => __('%d hours ago', 'abcode-cache-warmer'), /* translators: %d: number of days */ 'dayAgo' => __('%d day ago', 'abcode-cache-warmer'), /* translators: %d: number of days */ 'daysAgo' => __('%d days ago', 'abcode-cache-warmer'), /* translators: %s: relative time (e.g. "5 minutes ago") */ 'lastRun' => __('Last run: %s', 'abcode-cache-warmer'), /* translators: %s: relative time (e.g. "5 minutes ago") */ 'lastRunAuto' => __('Last run: %s, after a cache purge', 'abcode-cache-warmer'), /* translators: %s: relative time (e.g. "5 minutes ago") */ 'lastRunSched' => __('Last run: %s, on schedule', 'abcode-cache-warmer'), /* translators: %1$d: warmed count, %2$d: cached count, %3$d: failed count, %4$s: duration in seconds */ 'runSummary' => __('%1$d warmed, %2$d already cached, %3$d failed in %4$ss', 'abcode-cache-warmer'), 'complete' => __('Cache warming complete', 'abcode-cache-warmer'), /* translators: %d: total URLs warmed in the run */ 'completeCount' => __('Cache warming complete, %d URLs', 'abcode-cache-warmer'), 'noUrls' => __('No URLs to warm', 'abcode-cache-warmer'), /* translators: %d: number of images queued */ 'imagesQueued' => __('+%d images found on your pages', 'abcode-cache-warmer'), /* translators: %d: the image limit that was reached */ 'imagesCapped' => __('Image limit of %d reached, remaining images were skipped', 'abcode-cache-warmer'), 'parallelOff' => __('parallel requests unavailable', 'abcode-cache-warmer'), /* translators: %1$d: concurrent request count, %2$d: delay in milliseconds */ 'tuned' => __('Tuned to your server: %1$d concurrent requests, %2$dms delay', 'abcode-cache-warmer'), /* translators: %d: milliseconds */ 'ttfb' => __('%dms response time', 'abcode-cache-warmer'), 'ttfbUnknown' => __('response time unavailable', 'abcode-cache-warmer'), /* translators: %1$d: URLs done, %2$d: total URLs */ 'progressAuto' => __('Warming %1$d of %2$d, started automatically', 'abcode-cache-warmer'), 'triggerManual' => __('Manual', 'abcode-cache-warmer'), 'triggerAuto' => __('After a purge', 'abcode-cache-warmer'), 'triggerTargeted' => __('Targeted', 'abcode-cache-warmer'), 'triggerScheduled' => __('Scheduled', 'abcode-cache-warmer'), 'noResults' => __('No completed run yet', 'abcode-cache-warmer'), 'noResultsHint' => __('Warm your cache once and the full result of that run will be kept here.', 'abcode-cache-warmer'), /* translators: %1$s: date and time of the run, %2$s: what started it */ 'resultsHeadline' => __('Run of %1$s (%2$s)', 'abcode-cache-warmer'), /* translators: %1$s: URLs not listed, %2$s: the row limit */ 'resultsOmitted' => __('%1$s more URLs were warmed but are not listed; only the first %2$s of a run are kept.', 'abcode-cache-warmer'), 'noRuns' => __('No runs recorded yet.', 'abcode-cache-warmer'), /* translators: %1$d: URLs done, %2$d: total URLs */ 'pendingRunning' => __('Warming %1$d of %2$d…', 'abcode-cache-warmer'), /* translators: %s: countdown, e.g. "60s" */ 'pendingFull' => __('Full warm queued, starting in %s', 'abcode-cache-warmer'), /* translators: %s: countdown */ 'pendingPageOne' => __('1 page queued, warming in %s', 'abcode-cache-warmer'), /* translators: %1$d: page count, %2$s: countdown */ 'pendingPages' => __('%1$d pages queued, warming in %2$s', 'abcode-cache-warmer'), 'pendingSoon' => __('a moment', 'abcode-cache-warmer'), /* translators: %s: countdown, e.g. "5h 16m" */ 'pendingScheduled' => __('Next scheduled warm in %s', 'abcode-cache-warmer'), 'pendingBadge' => __('Queued', 'abcode-cache-warmer'), 'rowsGone' => __('The per-URL results for this run are no longer stored.', 'abcode-cache-warmer'), 'loadingRows' => __('Loading…', 'abcode-cache-warmer'), 'colUrl' => __('URL', 'abcode-cache-warmer'), 'colResult' => __('Result', 'abcode-cache-warmer'), 'colLayer' => __('Cache Layer', 'abcode-cache-warmer'), 'colWarmedAt' => __('Warmed', 'abcode-cache-warmer'), 'noCacheLayer' => __('Done, but no cache headers were found on any URL. Check a page cache is actually running on this site.', 'abcode-cache-warmer'), 'siteTimeHint' => __('In your site\'s timezone.', 'abcode-cache-warmer'), 'cronOk' => __('WP-Cron\'s page-load trigger is disabled and a server cron is handling scheduled tasks, the recommended setup. Schedules will run on time.', 'abcode-cache-warmer'), 'cronDead' => __('WP-Cron is disabled (DISABLE_WP_CRON) and scheduled tasks are overdue, nothing is calling wp-cron.php. Add a server cron running wp-cron.php every few minutes, or scheduled and automatic warming will not fire.', 'abcode-cache-warmer'), 'cronLagging' => __('Scheduled tasks are running late. WP-Cron only fires when the site gets visits, so on a quiet site schedules drift, a real server cron calling wp-cron.php fixes this properly.', 'abcode-cache-warmer'), /* translators: %d: seconds until warming resumes */ 'paused' => __('Paused: your server is busy. Resuming in %ds.', 'abcode-cache-warmer'), /* translators: %d: seconds until warming resumes */ 'rateLimited' => __('Paused: the server asked us to slow down (HTTP 429). Retrying gently in %ds.', 'abcode-cache-warmer'), /* translators: %s: comma-separated list of caching plugin names */ 'purgeDetected' => __('Listening for purges from %s.', 'abcode-cache-warmer'), 'purgeNone' => __('No supported caching plugin detected yet. This still works with any cache that fires a standard purge action.', 'abcode-cache-warmer'), /* translators: %s: what triggered the run, e.g. a plugin name */ 'purgeQueued' => __('A run is queued, triggered by %s.', 'abcode-cache-warmer'), /* translators: %d: number of pages */ 'pagesQueued' => __('%d page queued for a targeted warm.', 'abcode-cache-warmer'), /* translators: %d: number of pages */ 'pagesQueuedPlural' => __('%d pages queued for a targeted warm.', 'abcode-cache-warmer'), /* translators: %1$d: pages warmed, %2$d: pages attempted, %3$s: relative time */ 'lastPageWarm' => __('Last targeted warm: %1$d of %2$d pages, %3$s.', 'abcode-cache-warmer'), 'scheduleOff' => __('Automatic warming is off', 'abcode-cache-warmer'), 'scheduleOffHint' => __('Pick an interval below to warm the cache without being asked.', 'abcode-cache-warmer'), /* translators: %s: formatted date and time of the next scheduled run */ 'nextRun' => __('Next run: %s', 'abcode-cache-warmer'), ], ] ); wp_add_inline_script('abcw', abcw_inline_js()); }); /** * Returns the plugin's admin CSS as a string for wp_add_inline_style(). * * @return string CSS content. */ function abcw_inline_css() { ob_start(); ?> .abcw-banner { background: #0f0d25; margin: -1px -20px 0; display: flex; align-items: center; gap: 18px; height: 60px; overflow: hidden; padding: 15px 32px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; color: #fff; position: relative; border-bottom: 3px solid rgba(255, 255, 255, 0.1); } .abcw-banner-badge { position: absolute; top: 0; left: 50%; transform: translateX(-50%); z-index: 10; background: linear-gradient(0deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.05) 100%); color: rgb(253, 251, 255); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; padding: 5px 20px; border-radius: 0 0 8px 8px; border-top: none; border-right: 0.5px solid rgba(255,255,255,0.12); border-bottom: 0.5px solid rgba(255,255,255,0.12); border-left: 0.5px solid rgba(255,255,255,0.12); line-height: 1; height: 15px; display: inline-flex; align-items: center; justify-content: center; box-shadow: rgba(0,0,0,0.18) 0 0 2px; } .abcw-banner-logo, .abcw-banner-logo svg { width: 46px; height: 46px; flex-shrink: 0; } .abcw-banner-title-wrap { display: flex; flex-direction: column; gap: 2px; } .abcw-banner-title-row { display: flex; align-items: center; gap: 10px; } .abcw-banner-title-row h1 { margin: 0; font-size: 23px; font-weight: 600; color: #fff; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: normal; position: relative; } .abcw-banner-separator { color: rgba(255,255,255,0.3); font-size: 18px; font-weight: 300; } .abcw-banner-section { background: linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.05) 100%); color: rgba(255,255,255,0.7); opacity: 0.9; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 1px; line-height: normal; padding: 6px 12px; border-radius: 20px; border: 1px solid rgba(255,255,255,0.1); align-self: center; position: relative; top: 1px; } .abcw-banner-subtitle { font-size: 11px; color: rgba(255,255,255,0.4); } .abcw-banner-subtitle a { color: rgba(255,255,255,0.4); text-decoration: none; } .abcw-banner-subtitle a:hover { color: rgba(255,255,255,0.7); } /* The hidden attribute is only display:none in the browser's own stylesheet, and any author display rule, our flex fields, the grid, an .active class, silently outranks it. This puts it back on top, so hidden means hidden everywhere in the plugin's UI. */ .abcw-wrap [hidden], .abcw-banner [hidden] { display: none !important; } .abcw-wrap { max-width: 860px; margin: 30px auto 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .abcw-tabs { display: flex; gap: 4px; margin-bottom: -1px; padding: 0 6px; } .abcw-tab { display: inline-flex; align-items: center; gap: 6px; background: transparent; border: none; border-radius: 8px 8px 0 0; padding: 11px 20px; font-size: 13px; font-weight: 600; font-family: inherit; color: #646970; cursor: pointer; transition: all 0.15s; position: relative; top: 1px; } .abcw-tab:hover { color: #0f0d25; background: rgba(255,255,255,0.6); } .abcw-tab.active { background: #fff; color: #0f0d25; box-shadow: 0 -1px 4px rgba(15,13,37,0.06); } .abcw-tab-pill { color: #1eb300; font-size: 9px; line-height: 1; } .abcw-card { background: #fff; border: none; border-radius: 10px; box-shadow: 0 1px 4px rgba(15,13,37,0.08), 0 4px 16px rgba(15,13,37,0.04); padding: 32px; } .abcw-panel { display: none; } .abcw-panel.active { display: block; } .abcw-notice { display: flex; align-items: flex-start; gap: 10px; padding: 12px 16px; background: rgba(245,166,35,0.1); border: 1px solid rgba(245,166,35,0.35); border-radius: 8px; margin-top: 16px; font-size: 13px; color: #8a5a00; } .abcw-notice svg { width: 18px; height: 18px; fill: #f5a623; flex-shrink: 0; } .abcw-notice.ok { background: rgba(30,179,0,0.08); border-color: rgba(30,179,0,0.3); color: #145a00; } .abcw-notice.ok svg { fill: #1eb300; } .abcw-muted { font-size: 13px; color: #646970; margin: 0; line-height: 1.6; } .abcw-field select { width: 100%; padding: 8px 10px; border: 1px solid #e2dff0; border-radius: 6px; font-size: 13px; font-family: inherit; background: #fff; outline: none; box-sizing: border-box; } .abcw-field select:focus { border-color: #0f0d25; box-shadow: 0 0 0 1px rgba(15,13,37,0.2); } .abcw-stat .sub { font-size: 10px; color: #646970; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .abcw-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } .abcw-stat { background: #f4f0fa; border: 1px solid #e2dff0; border-radius: 8px; padding: 16px; text-align: center; } .abcw-stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #646970; margin-bottom: 4px; } .abcw-stat .value { font-size: 22px; font-weight: 600; color: #0f0d25; } .abcw-stat .value.loading { color: #c3c4c7; } .abcw-profile { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: #f4f0fa; border: 1px solid #e2dff0; border-radius: 8px; margin-bottom: 20px; font-size: 13px; color: #0f0d25; } .abcw-profile > svg { width: 18px; height: 18px; fill: #1eb300; flex-shrink: 0; } .abcw-profile-text strong { font-weight: 600; } .abcw-profile-meta { display: block; font-size: 12px; color: #646970; margin-top: 2px; } .abcw-profile-recal { margin-left: auto; background: none; border: 1px solid #c3c4c7; border-radius: 4px; padding: 4px 10px; font-size: 11px; color: #0f0d25; cursor: pointer; white-space: nowrap; transition: all 0.15s; flex-shrink: 0; } .abcw-profile-recal:hover { background: #fff; border-color: #0f0d25; } .abcw-profile-recal:disabled { opacity: 0.4; cursor: not-allowed; } .abcw-last-run { display: none; align-items: center; gap: 8px; padding: 10px 14px; background: #f4f0fa; border: 1px solid #e2dff0; border-radius: 8px; margin-bottom: 20px; font-size: 13px; color: #0f0d25; } .abcw-last-run.active { display: flex; } .abcw-last-run svg { width: 16px; height: 16px; fill: #0f0d25; flex-shrink: 0; } .abcw-last-run-detail { margin-left: auto; color: #646970; font-size: 12px; } .abcw-section-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #646970; margin-bottom: 10px; } .abcw-scheduled-note { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: #faf9fc; border: 1px solid #f0eef5; border-radius: 8px; margin-bottom: 16px; font-size: 13px; color: #646970; } .abcw-scheduled-note svg { width: 15px; height: 15px; fill: #8a86a0; flex-shrink: 0; } .abcw-queue-wrap { margin-bottom: 24px; } .abcw-queue-item { border-bottom: 1px solid #f0eef5; } .abcw-queue-item:last-child { border-bottom: none; } .abcw-queue-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 12px 14px; font-size: 13px; font-weight: 500; color: #0f0d25; background: none; border: none; text-align: left; font-family: inherit; } .abcw-queue-head .q-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } button.abcw-queue-head { cursor: pointer; } button.abcw-queue-head:hover { background: #f4f0fa; } .abcw-queue-body { padding: 14px; } .abcw-queue-body .abcw-log { margin-top: 0; } .abcw-badge.pending { background: #f0eef5; color: #646970; border: 1px solid #e2dff0; } .abcw-spinner { width: 15px; height: 15px; border: 2px solid #d9d2f0; border-top-color: #1eb300; border-radius: 50%; animation: abcw-spin 0.7s linear infinite; flex-shrink: 0; } @keyframes abcw-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .abcw-spinner { animation-duration: 2s; } } .abcw-runs { border: 1px solid #e2dff0; border-radius: 8px; overflow: hidden; } .abcw-run { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 12px; padding: 10px 14px; font-size: 13px; color: #0f0d25; border-bottom: 1px solid #f0eef5; } .abcw-run:last-child { border-bottom: none; } .abcw-run:nth-child(even) { background: #faf9fc; } .abcw-run { display: block; padding: 0; } .abcw-run-head { display: grid; grid-template-columns: 1fr auto auto 18px; align-items: center; gap: 12px; width: 100%; padding: 10px 14px; font-size: 13px; color: #0f0d25; background: none; border: none; text-align: left; cursor: pointer; font-family: inherit; } .abcw-run-head:hover { background: #f4f0fa; } .abcw-run-head .chevron { font-size: 16px; color: #646970; transition: transform 0.15s; text-align: center; } .abcw-run.open .abcw-run-head .chevron { transform: rotate(90deg); } .abcw-run-body { padding: 14px; } .abcw-run-body .abcw-log { margin-top: 0; } .abcw-log-row a.url { color: #0f0d25; text-decoration: none; } .abcw-log-row a.url:hover { color: #4b4370; text-decoration: underline; } .abcw-log-row a.url::after { content: '\2197'; font-size: 10px; margin-left: 5px; color: #a7aaad; } .abcw-run .when { font-weight: 500; } .abcw-run .trigger { font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; color: #4b4370; background: #f4f0fa; border: 1px solid #e2dff0; border-radius: 10px; padding: 2px 8px; white-space: nowrap; } .abcw-run .counts { font-size: 12px; color: #646970; white-space: nowrap; } .abcw-runs-empty { padding: 14px; font-size: 13px; color: #646970; } .abcw-section-label-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .abcw-select-all { background: none; border: none; padding: 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-family: inherit; color: #4b4370; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } .abcw-select-all:hover { color: #0f0d25; } .abcw-select-all:disabled { opacity: 0.4; cursor: not-allowed; text-decoration: none; } .abcw-sources { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 24px; } .abcw-source { display: flex; align-items: center; gap: 9px; padding: 10px 12px; border: 1px solid #e2dff0; border-radius: 8px; font-size: 13px; color: #0f0d25; cursor: pointer; background: #fff; transition: all 0.15s; user-select: none; } .abcw-source:hover { border-color: #0f0d25; } .abcw-source.checked { background: #f4f0fa; border-color: #c9c1e4; } .abcw-source input { width: 16px; height: 16px; margin: 0; cursor: pointer; flex-shrink: 0; accent-color: #1eb300; } .abcw-source .src-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .abcw-source .src-help { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 50%; background: #e2dff0; color: #4b4370; font-size: 9px; font-weight: 700; vertical-align: middle; cursor: help; } .abcw-tally-note { margin-left: auto; color: #8a5a00; } .abcw-source .src-count { margin-left: auto; font-size: 11px; color: #646970; background: #f0eef5; border-radius: 10px; padding: 2px 8px; flex-shrink: 0; } .abcw-sources.locked { pointer-events: none; opacity: 0.55; } .abcw-btn { display: inline-flex; align-items: center; gap: 10px; background: #0f0d25; color: #fff; border: none; border-radius: 8px; padding: 14px 28px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; width: 100%; justify-content: center; } .abcw-btn:hover { background: #1a1740; } .abcw-btn:disabled { background: #a7aaad; cursor: not-allowed; } .abcw-btn.stopping { background: #d63638; } .abcw-btn.stopping:hover { background: #b32d2e; } .abcw-btn svg { width: 20px; height: 20px; fill: currentColor; } .abcw-progress-wrap { display: none; margin-top: 16px; } .abcw-progress-wrap.active { display: block; } .abcw-progress-bar-outer { width: 100%; height: 18px; background: #f4f0fa; border-radius: 9px; overflow: hidden; margin-bottom: 10px; } .abcw-progress-bar-inner { height: 100%; width: 0%; background: linear-gradient(90deg, #1eb300, #5cd645); border-radius: 9px; transition: width 0.4s ease; } .abcw-progress-status { display: flex; justify-content: space-between; font-size: 13px; color: #646970; margin-bottom: 8px; } .abcw-progress-tally { display: flex; gap: 16px; font-size: 12px; color: #646970; } .abcw-progress-tally b { font-weight: 600; color: #0f0d25; } .abcw-log { display: none; border: 1px solid #e2dff0; border-radius: 8px; overflow: hidden; margin-top: 20px; } .abcw-log.active { display: block; } .abcw-log-head { display: grid; grid-template-columns: 1fr 90px 110px 60px; align-items: center; padding: 10px 16px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.7); gap: 10px; background: #0f0d25; border-bottom: 1px solid rgba(255,255,255,0.08); } .abcw-log-scroll { max-height: 320px; overflow-y: auto; } .abcw-log-row { display: grid; grid-template-columns: 1fr 90px 110px 60px; align-items: center; padding: 9px 16px; font-size: 13px; gap: 10px; border-bottom: 1px solid #f0eef5; } .abcw-log-row:last-child { border-bottom: none; } /* Rows arrive one at a time as each URL finishes, so a short fade makes the stream readable instead of flickering. */ .abcw-log-row.is-new { animation: abcw-row-in 0.25s ease-out; } @keyframes abcw-row-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } @media (prefers-reduced-motion: reduce) { .abcw-log-row.is-new { animation: none; } } .abcw-log-row:nth-child(even) { background: #faf9fc; } .abcw-log-row .url { font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; color: #0f0d25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .abcw-log-head .layer, .abcw-log-head .time, .abcw-log-row .layer, .abcw-log-row .time { text-align: right; white-space: nowrap; } .abcw-log-row .layer, .abcw-log-row .time { font-size: 11px; color: #646970; overflow: hidden; text-overflow: ellipsis; } .abcw-badge { display: inline-flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; padding: 3px 8px; border-radius: 4px; white-space: nowrap; } .abcw-badge.warmed { background: rgba(30,179,0,0.1); color: #145a00; border: 1px solid rgba(30,179,0,0.3); } .abcw-badge.cached { background: #f4f0fa; color: #4b4370; border: 1px solid #e2dff0; } .abcw-badge.bypassed { background: rgba(245,166,35,0.12); color: #8a5a00; border: 1px solid rgba(245,166,35,0.35); } .abcw-badge.failed { background: rgba(214,54,56,0.1); color: #a01b1c; border: 1px solid rgba(214,54,56,0.3); } .abcw-success { display: none; align-items: center; gap: 10px; padding: 12px 16px; background: rgba(30, 179, 0, 0.08); border: 1px solid rgba(30, 179, 0, 0.3); border-radius: 8px; margin-top: 16px; color: #145a00; font-size: 14px; } .abcw-success.active { display: flex; } .abcw-success svg { width: 22px; height: 22px; fill: #1eb300; flex-shrink: 0; } .abcw-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; margin-top: 18px; } .abcw-field { display: flex; flex-direction: column; gap: 5px; } .abcw-field.full { grid-column: 1 / -1; } .abcw-field > label { font-size: 12px; font-weight: 500; color: #0f0d25; } .abcw-field .hint { font-size: 11px; color: #646970; } .abcw-field input[type="number"], .abcw-field input[type="time"], .abcw-field textarea { width: 100%; padding: 8px 10px; border: 1px solid #e2dff0; border-radius: 6px; font-size: 13px; outline: none; box-sizing: border-box; transition: border-color 0.15s; background: #fff; font-family: inherit; } .abcw-field textarea { font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; min-height: 74px; resize: vertical; } .abcw-field input:focus, .abcw-field textarea:focus { border-color: #0f0d25; box-shadow: 0 0 0 1px rgba(15,13,37,0.2); } .abcw-check { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 400; color: #0f0d25; cursor: pointer; } .abcw-check input { width: 16px; height: 16px; margin: 0; accent-color: #1eb300; cursor: pointer; flex-shrink: 0; } .abcw-rate { display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 16px; color: #646970; text-decoration: none; font-size: 13px; transition: color 0.15s; } .abcw-rate:hover { color: #0f0d25; } .abcw-rate svg { fill: #f5a623; } .abcw-toast { position: fixed; bottom: 24px; right: 24px; background: #0f0d25; color: #fff; padding: 10px 18px; border-radius: 6px; font-size: 13px; opacity: 0; transform: translateY(8px); transition: opacity 0.2s, transform 0.2s; z-index: 99999; pointer-events: none; } .abcw-toast.show { opacity: 1; transform: translateY(0); } @media screen and (max-width: 782px) { .abcw-stats { grid-template-columns: repeat(2, 1fr); } .abcw-sources { grid-template-columns: 1fr; } .abcw-fields { grid-template-columns: 1fr; } .abcw-log-head, .abcw-log-row { grid-template-columns: 1fr 90px 60px; } .abcw-log-head .layer, .abcw-log-row .layer { display: none; } } (function() { var ajaxUrl = abcwConfig.ajaxUrl; var nonce = abcwConfig.nonce; var i18n = abcwConfig.i18n; var resultLimit = abcwConfig.resultLimit; var sources = []; var settings = {}; var profile = {}; var running = false; var stopping = false; var startedAt = 0; var lastRowId = 0; var pollTimer = null; var driving = false; var polling = false; var seenRows = {}; var resultsPollTimer = null; var resultsSig = ''; var btn = document.getElementById('abcw-btn'); var btnText = document.getElementById('abcw-btn-text'); var btnIcon = document.getElementById('abcw-btn-icon'); var sourcesWrap = document.getElementById('abcw-sources'); var progress = document.getElementById('abcw-progress'); var bar = document.getElementById('abcw-bar'); var statusText = document.getElementById('abcw-status-text'); var statusPct = document.getElementById('abcw-status-pct'); var logWrap = document.getElementById('abcw-log'); var logRows = document.getElementById('abcw-log-rows'); var successMsg = document.getElementById('abcw-success'); var successText = document.getElementById('abcw-success-text'); var ICON_PLAY = ''; var ICON_STOP = ''; function sprintf(fmt) { var args = Array.prototype.slice.call(arguments, 1), i = 0; return String(fmt).replace(/%(\d\$)?[sd]/g, function(m, pos) { var idx = pos ? parseInt(pos, 10) - 1 : i++; return args[idx] !== undefined ? args[idx] : m; }); } function formatNumber(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } function timeAgo(timestamp) { var seconds = Math.floor(Date.now() / 1000) - timestamp; if (seconds < 60) return i18n.justNow; var minutes = Math.floor(seconds / 60); if (minutes < 60) return sprintf(minutes !== 1 ? i18n.minutesAgo : i18n.minuteAgo, minutes); var hours = Math.floor(minutes / 60); if (hours < 24) return sprintf(hours !== 1 ? i18n.hoursAgo : i18n.hourAgo, hours); var days = Math.floor(hours / 24); return sprintf(days !== 1 ? i18n.daysAgo : i18n.dayAgo, days); } function showToast(msg) { var toast = document.getElementById('abcw-toast'); toast.textContent = msg; toast.classList.add('show'); setTimeout(function() { toast.classList.remove('show'); }, 2000); } function setText(id, value) { var el = document.getElementById(id); if (!el) return; el.textContent = value; el.classList.remove('loading'); } function post(action, data) { var formData = new FormData(); formData.append('action', action); formData.append('_nonce', nonce); for (var key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { formData.append(key, data[key]); } } return fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', body: formData }).then(function(r) { return r.json(); }); } // ── Tabs ───────────────────────────────────────────────────────────── var tabs = Array.prototype.slice.call(document.querySelectorAll('.abcw-tab')); var panels = Array.prototype.slice.call(document.querySelectorAll('.abcw-panel')); tabs.forEach(function(tab) { tab.addEventListener('click', function() { var target = tab.getAttribute('data-panel'); tabs.forEach(function(t) { t.classList.toggle('active', t === tab); }); panels.forEach(function(p) { p.classList.toggle('active', p.getAttribute('data-panel') === target); }); try { window.localStorage.setItem('abcwTab', target); } catch (e) {} // The stored run is the biggest thing the plugin keeps, so it is // only fetched if somebody actually looks at it. While the tab // is open we poll so queued and running warms show live. if (target === 'results') { loadResults(); startResultsPolling(); } else { stopResultsPolling(); } // Watch for a run started elsewhere while the Warmer tab is up. if (target === 'warming') { startWarmerWatch(); } else { stopWarmerWatch(); } }); }); function restoreTab() { var saved = null; try { saved = window.localStorage.getItem('abcwTab'); } catch (e) {} if (!saved) return; var tab = document.querySelector('.abcw-tab[data-panel="' + saved + '"]'); if (tab) tab.click(); } // ── Sources ────────────────────────────────────────────────────────── function selectedSources() { var selected = {}; sources.forEach(function(s) { if (s.enabled) selected[s.key] = 1; }); return selected; } function estimatedCount() { var total = 0; sources.forEach(function(s) { if (s.enabled) total += s.count; }); return total; } function renderSources() { sourcesWrap.innerHTML = ''; sources.forEach(function(source) { var label = document.createElement('label'); label.className = 'abcw-source' + (source.enabled ? ' checked' : ''); var input = document.createElement('input'); input.type = 'checkbox'; input.checked = source.enabled; var name = document.createElement('span'); name.className = 'src-label'; name.textContent = source.label; if (source.hint) { label.title = source.hint; name.appendChild(document.createTextNode(' ')); var help = document.createElement('span'); help.className = 'src-help'; help.textContent = '?'; name.appendChild(help); } label.appendChild(input); label.appendChild(name); if (source.count > 0) { var count = document.createElement('span'); count.className = 'src-count'; count.textContent = formatNumber(source.count); label.appendChild(count); } input.addEventListener('change', function() { source.enabled = this.checked; label.classList.toggle('checked', this.checked); renderSelectAll(); updateButton(); setText('abcw-stat-queue', formatNumber(estimatedCount())); persistSettings(false); }); sourcesWrap.appendChild(label); }); } // Doubles as Clear all once everything is ticked, so the same control // undoes itself rather than becoming dead weight. var selectAllBtn = document.getElementById('abcw-select-all'); function allSelected() { return sources.length > 0 && sources.every(function(s) { return s.enabled; }); } function renderSelectAll() { selectAllBtn.textContent = allSelected() ? i18n.clearAll : i18n.selectAll; } selectAllBtn.addEventListener('click', function() { if (running) return; var turnOn = !allSelected(); sources.forEach(function(s) { s.enabled = turnOn; }); renderSources(); renderSelectAll(); updateButton(); setText('abcw-stat-queue', formatNumber(estimatedCount())); persistSettings(false); }); function updateButton() { if (running) return; var count = estimatedCount(); if (count === 0) { btn.disabled = true; btnText.textContent = i18n.selectSource; return; } btn.disabled = false; var label = sprintf(count !== 1 ? i18n.urlsPlural : i18n.urlsSingular, formatNumber(count)); btnText.textContent = sprintf(i18n.warmCacheCount, label); } // ── Settings ───────────────────────────────────────────────────────── function collectSettings() { return { sources: selectedSources(), concurrency: parseInt(document.getElementById('abcw-concurrency').value, 10) || 5, delay: parseInt(document.getElementById('abcw-delay').value, 10) || 0, timeout: parseInt(document.getElementById('abcw-timeout').value, 10) || 15, adaptive: document.getElementById('abcw-adaptive').checked ? 1 : 0, ssl_verify: document.getElementById('abcw-ssl').checked ? 1 : 0, schedule: document.getElementById('abcw-schedule').value, schedule_time: document.getElementById('abcw-schedule-time').value || '03:00', schedule_time2: document.getElementById('abcw-schedule-time2').value || '15:00', schedule_day: parseInt(document.getElementById('abcw-schedule-day').value, 10) || 0, on_purge: document.getElementById('abcw-on-purge').checked ? 1 : 0, on_publish: document.getElementById('abcw-on-publish').checked ? 1 : 0, purge_delay: parseInt(document.getElementById('abcw-purge-delay').value, 10) || 0, keep_days: parseInt(document.getElementById('abcw-keep-days').value, 10) || 30, keep_runs: parseInt(document.getElementById('abcw-keep-runs').value, 10) || 20, include: document.getElementById('abcw-include').value, exclude: document.getElementById('abcw-exclude').value }; } var saveTimer = null; function persistSettings(confirm) { clearTimeout(saveTimer); saveTimer = setTimeout(function() { post('abcw_save_settings', { settings: JSON.stringify(collectSettings()) }) .then(function(resp) { if (!resp.success) return; settings = resp.data.settings; if (resp.data.schedule) renderSchedule(resp.data.schedule); if (resp.data.purge) renderPurge(resp.data.purge); if (confirm) showToast(i18n.settingsSaved); }) .catch(function() {}); }, 400); } function applySettings(next) { settings = next; document.getElementById('abcw-concurrency').value = next.concurrency; document.getElementById('abcw-delay').value = next.delay; document.getElementById('abcw-timeout').value = next.timeout; document.getElementById('abcw-adaptive').checked = !!parseInt(next.adaptive, 10); document.getElementById('abcw-ssl').checked = !!parseInt(next.ssl_verify, 10); document.getElementById('abcw-schedule').value = next.schedule || 'none'; document.getElementById('abcw-schedule-time').value = next.schedule_time || '03:00'; document.getElementById('abcw-schedule-time2').value = next.schedule_time2 || '15:00'; document.getElementById('abcw-schedule-day').value = String(parseInt(next.schedule_day, 10) || 0); updateScheduleFields(); document.getElementById('abcw-on-purge').checked = !!parseInt(next.on_purge, 10); document.getElementById('abcw-on-publish').checked = !!parseInt(next.on_publish, 10); document.getElementById('abcw-purge-delay').value = next.purge_delay; document.getElementById('abcw-keep-days').value = next.keep_days || 30; document.getElementById('abcw-keep-runs').value = next.keep_runs || 20; document.getElementById('abcw-include').value = next.include || ''; document.getElementById('abcw-exclude').value = next.exclude || ''; } // ── Schedule ───────────────────────────────────────────────────────── function renderSchedule(status) { if (!status) return; var headline = document.getElementById('abcw-schedule-headline'); var meta = document.getElementById('abcw-schedule-meta'); var pill = document.getElementById('abcw-tab-schedule-pill'); if (status.interval === 'none') { headline.textContent = i18n.scheduleOff; meta.textContent = i18n.scheduleOffHint; pill.hidden = true; } else { headline.textContent = status.label; meta.textContent = status.next ? sprintf(i18n.nextRun, status.next) : ''; pill.hidden = false; pill.textContent = '●'; } var cron = document.getElementById('abcw-cron-notice'); var cronText = document.getElementById('abcw-cron-notice-text'); if (status.cronStale) { cronText.textContent = status.cronDisabled ? i18n.cronDead : i18n.cronLagging; cron.classList.remove('ok'); cron.hidden = false; } else if (status.cronDisabled) { // Disabled but the queue is being serviced: a server cron has // taken over, which is better than the default, not a problem. cronText.textContent = i18n.cronOk; cron.classList.add('ok'); cron.hidden = false; } else { cron.hidden = true; } } function updateScheduleFields() { var interval = document.getElementById('abcw-schedule').value; var timed = interval === 'daily' || interval === 'twicedaily' || interval === 'abcw_weekly'; document.getElementById('abcw-schedule-time-field').hidden = !timed; document.getElementById('abcw-schedule-time2-field').hidden = interval !== 'twicedaily'; document.getElementById('abcw-schedule-day-field').hidden = interval !== 'abcw_weekly'; document.getElementById('abcw-schedule-time-hint').textContent = i18n.siteTimeHint; } document.getElementById('abcw-schedule').addEventListener('change', function() { updateScheduleFields(); persistSettings(true); }); ['abcw-schedule-time', 'abcw-schedule-time2', 'abcw-schedule-day'].forEach(function(id) { document.getElementById(id).addEventListener('change', function() { persistSettings(true); }); }); // ── Automatic triggers ─────────────────────────────────────────────── function renderPurge(status) { if (!status) return; var detected = document.getElementById('abcw-purge-detected'); var queued = document.getElementById('abcw-purge-queued'); detected.textContent = status.detected && status.detected.length ? sprintf(i18n.purgeDetected, status.detected.join(', ')) : i18n.purgeNone; if (status.pagesQueued > 0) { queued.textContent = sprintf( status.pagesQueued !== 1 ? i18n.pagesQueuedPlural : i18n.pagesQueued, status.pagesQueued ); queued.hidden = false; } else if (status.nextStamp && status.reason) { queued.textContent = sprintf(i18n.purgeQueued, status.reason); queued.hidden = false; } else { queued.hidden = true; } var lastPages = document.getElementById('abcw-last-page-warm'); if (status.lastPages && status.lastPages.time) { lastPages.textContent = sprintf( i18n.lastPageWarm, status.lastPages.warmed, status.lastPages.total, timeAgo(status.lastPages.time) ); lastPages.hidden = false; } else { lastPages.hidden = true; } } ['abcw-on-purge', 'abcw-on-publish', 'abcw-purge-delay'].forEach(function(id) { document.getElementById(id).addEventListener('change', function() { persistSettings(true); }); }); // ── Server profile ─────────────────────────────────────────────────── function renderProfile() { document.getElementById('abcw-profile-headline').textContent = sprintf(i18n.tuned, settings.concurrency, settings.delay); var parts = []; parts.push(profile.ttfb ? sprintf(i18n.ttfb, profile.ttfb) : i18n.ttfbUnknown); if (profile.memory_limit) parts.push(profile.memory_limit); if (profile.cores) parts.push(profile.cores + ' CPU'); if (!profile.parallel) parts.push(i18n.parallelOff); if (profile.notes && profile.notes.length) parts = parts.concat(profile.notes); document.getElementById('abcw-profile-meta').textContent = parts.join(' • '); } document.getElementById('abcw-recalibrate').addEventListener('click', function() { var thisBtn = this; thisBtn.disabled = true; thisBtn.textContent = i18n.calibrating; post('abcw_calibrate', {}).then(function(resp) { thisBtn.disabled = false; thisBtn.textContent = i18n.recalibrate; if (!resp.success) return; profile = resp.data.profile; applySettings(resp.data.settings); renderProfile(); }).catch(function() { thisBtn.disabled = false; thisBtn.textContent = i18n.recalibrate; }); }); // ── Last run ───────────────────────────────────────────────────────── function renderLastRun(run) { if (!run || !run.time) return; var label = i18n.lastRun; if (run.trigger === 'automatic') label = i18n.lastRunAuto; else if (run.trigger === 'scheduled') label = i18n.lastRunSched; document.getElementById('abcw-last-run-text').textContent = sprintf(label, timeAgo(run.time)); document.getElementById('abcw-last-run-detail').textContent = sprintf(i18n.runSummary, run.warmed, run.cached, run.failed, run.duration); document.getElementById('abcw-last-run').classList.add('active'); setText('abcw-stat-last', timeAgo(run.time)); var sub = document.getElementById('abcw-stat-last-sub'); if (sub) sub.textContent = run.date || ''; } // ── Results table ──────────────────────────────────────────────────── var badgeLabels = { warmed: i18n.warmed, cached: i18n.alreadyCached, bypassed: i18n.bypassed, failed: i18n.failed }; // One row builder for the live feed and the stored-results tables. // timeMode 'duration' shows how long the request took; 'clock' shows // when it was warmed, with the duration on hover. function buildRow(result, timeMode, isNew) { var row = document.createElement('div'); row.className = 'abcw-log-row' + (isNew ? ' is-new' : ''); var url = document.createElement('a'); url.className = 'url'; url.href = result.url; url.target = '_blank'; url.rel = 'noopener'; url.title = result.url; url.textContent = result.url; var badge = document.createElement('span'); badge.className = 'abcw-badge ' + (badgeLabels[result.outcome] ? result.outcome : 'failed'); badge.textContent = badgeLabels[result.outcome] || result.outcome; if (result.error) badge.title = result.error; // The empty-cell placeholder is the em-dash glyph, escaped so it is // furniture rather than prose, which the house em-dash rule allows. var layer = document.createElement('span'); layer.className = 'layer'; layer.textContent = result.layer || result.error || '\u2014'; layer.title = layer.textContent; var time = document.createElement('span'); time.className = 'time'; if (timeMode === 'clock') { time.textContent = result.at ? formatClock(result.at) : '\u2014'; time.title = result.time ? result.time + 's' : ''; } else { time.textContent = result.time ? result.time + 's' : '\u2014'; } row.appendChild(url); row.appendChild(badge); row.appendChild(layer); row.appendChild(time); return row; } function appendRows(rows) { if (!rows || !rows.length) return; logWrap.classList.add('active'); var atBottom = logRows.scrollHeight - logRows.scrollTop - logRows.clientHeight < 60; rows.forEach(function(result) { if (result.id) lastRowId = Math.max(lastRowId, result.id); logRows.appendChild(buildRow(result, 'duration', true)); }); // Keep the DOM bounded on very large sites. while (logRows.children.length > 500) { logRows.removeChild(logRows.firstChild); } if (atBottom) logRows.scrollTop = logRows.scrollHeight; } // Rows finish faster than the page is polled, so a poll usually returns // a handful at once. Releasing them one at a time turns that back into // the live feed it actually is, instead of the table jumping in clumps. var rowQueue = []; var rowTimer = null; function queueRows(rows) { if (!rows || !rows.length) return; // Belt and braces alongside the in-flight guard: a row id is only // ever rendered once, whatever the network does. var fresh = []; rows.forEach(function(row) { if (row.id) { if (seenRows[row.id]) return; seenRows[row.id] = true; lastRowId = Math.max(lastRowId, row.id); } fresh.push(row); }); if (!fresh.length) return; rowQueue = rowQueue.concat(fresh); if (!rowTimer) rowTimer = setInterval(drainRows, 70); drainRows(); } function drainRows() { if (!rowQueue.length) { clearInterval(rowTimer); rowTimer = null; return; } // If a backlog builds (a background tab, or a very fast server) // release several at a time so the table never falls behind. var take = rowQueue.length > 20 ? Math.ceil(rowQueue.length / 6) : 1; appendRows(rowQueue.splice(0, take)); } function flushRows() { if (rowTimer) { clearInterval(rowTimer); rowTimer = null; } if (rowQueue.length) { appendRows(rowQueue); rowQueue = []; } } // ── Results ────────────────────────────────────────────────────────── var resultsLoaded = false; var triggerLabels = { manual: i18n.triggerManual, automatic: i18n.triggerAuto, targeted: i18n.triggerTargeted, scheduled: i18n.triggerScheduled }; function formatClock(stamp) { var d = new Date(stamp * 1000); return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) + ':' + ('0' + d.getSeconds()).slice(-2); } function runTable(rows) { var log = document.createElement('div'); log.className = 'abcw-log active'; var head = document.createElement('div'); head.className = 'abcw-log-head'; [i18n.colUrl, i18n.colResult, i18n.colLayer, i18n.colWarmedAt].forEach(function(label, i) { var cell = document.createElement('span'); if (i === 2) cell.className = 'layer'; if (i === 3) cell.className = 'time'; cell.textContent = label; head.appendChild(cell); }); var scroll = document.createElement('div'); scroll.className = 'abcw-log-scroll'; rows.forEach(function(result) { scroll.appendChild(buildRow(result, 'clock', false)); }); log.appendChild(head); log.appendChild(scroll); return log; } // Coarse (schedule, hours away): "5h 16m". Fine (imminent): "1m 30s". function fmtLong(sec) { sec = Math.max(0, sec | 0); var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); if (h) return h + 'h ' + m + 'm'; if (m) return m + 'm'; return sec + 's'; } function fmtShort(sec) { sec = Math.max(0, sec | 0); if (sec <= 0) return i18n.pendingSoon; if (sec < 60) return sec + 's'; var m = Math.floor(sec / 60), s = sec % 60; return m + 'm' + (s ? ' ' + s + 's' : ''); } // A queue entry, built to look and behave like a Runs entry: a header // with a spinner on the right, expanding to a table of the URLs waiting // to warm. Entries with no URL list (a full warm, a live run) are just // a header. function queueItem(text, urls) { var item = document.createElement('div'); item.className = 'abcw-queue-item'; var expandable = urls && urls.length; var head = document.createElement(expandable ? 'button' : 'div'); head.className = 'abcw-queue-head'; if (expandable) head.type = 'button'; var label = document.createElement('span'); label.className = 'q-label'; label.textContent = text; head.appendChild(label); var spin = document.createElement('span'); spin.className = 'abcw-spinner'; spin.setAttribute('aria-hidden', 'true'); head.appendChild(spin); item.appendChild(head); if (expandable) { var body = document.createElement('div'); body.className = 'abcw-queue-body'; body.hidden = true; body.appendChild(pendingTable(urls)); item.appendChild(body); head.addEventListener('click', function() { var open = body.hidden; body.hidden = !open; item.classList.toggle('open', open); }); } return item; } function pendingTable(urls) { var log = document.createElement('div'); log.className = 'abcw-log active'; var head = document.createElement('div'); head.className = 'abcw-log-head'; [i18n.colUrl, i18n.colResult].forEach(function(t, i) { var cell = document.createElement('span'); if (i === 1) cell.className = 'layer'; cell.textContent = t; head.appendChild(cell); }); var scroll = document.createElement('div'); scroll.className = 'abcw-log-scroll'; urls.forEach(function(u) { var row = document.createElement('div'); row.className = 'abcw-log-row'; row.style.gridTemplateColumns = '1fr auto'; var url = document.createElement('a'); url.className = 'url'; url.href = u; url.target = '_blank'; url.rel = 'noopener'; url.title = u; url.textContent = u; var badge = document.createElement('span'); badge.className = 'abcw-badge pending'; badge.textContent = i18n.pendingBadge; row.appendChild(url); row.appendChild(badge); scroll.appendChild(row); }); log.appendChild(head); log.appendChild(scroll); return log; } var pendingState = null; var pendingSig = null; // shape of what is shown; rebuild only on change var queueLabelEl = null; // the countdown label, updated in place each tick function queueLabel(queue) { if (queue.kind === 'full') { return sprintf(i18n.pendingFull, fmtShort(queue.in)); } return queue.count === 1 ? sprintf(i18n.pendingPageOne, fmtShort(queue.in)) : sprintf(i18n.pendingPages, queue.count, fmtShort(queue.in)); } // A fingerprint of what the queue is *showing* (not the countdown), so // the DOM is only rebuilt when the set of entries changes, a tick that // only moves the clock updates text in place, leaving an expanded entry // expanded and never flickering. function pendingShape(p) { if (!p) return 'none'; var parts = []; if (p.running) parts.push('run' + p.running.total); if (p.queue) parts.push('q' + p.queue.kind + (p.queue.count || 0)); if (p.scheduled) parts.push('sched'); return parts.length ? parts.join('|') : 'none'; } function renderPending(pending) { pendingState = pending || null; var sig = pendingShape(pending); if (sig === pendingSig) { updatePendingCountdowns(pending); return; } pendingSig = sig; var note = document.getElementById('abcw-scheduled-note'); var wrap = document.getElementById('abcw-queue-wrap'); var list = document.getElementById('abcw-queue'); queueLabelEl = null; // The recurring schedule, a calm line, not part of the live queue. if (pending && pending.scheduled) { document.getElementById('abcw-scheduled-text').textContent = sprintf(i18n.pendingScheduled, fmtLong(pending.scheduled.in)); note.hidden = false; } else { note.hidden = true; } var items = []; if (pending && pending.running) { items.push(queueItem(sprintf(i18n.pendingRunning, pending.running.done, pending.running.total), null)); } if (pending && pending.queue) { var item = queueItem(queueLabel(pending.queue), pending.queue.kind === 'targeted' ? (pending.queue.urls || []) : null); queueLabelEl = item.querySelector('.q-label'); items.push(item); } if (!items.length) { wrap.hidden = true; return; } list.innerHTML = ''; items.forEach(function(it) { list.appendChild(it); }); wrap.hidden = false; } // Refresh only the countdown numbers, leaving the DOM (and any expanded // entry) untouched. function updatePendingCountdowns(pending) { if (!pending) return; if (pending.scheduled) { document.getElementById('abcw-scheduled-text').textContent = sprintf(i18n.pendingScheduled, fmtLong(pending.scheduled.in)); } if (pending.queue && queueLabelEl) { queueLabelEl.textContent = queueLabel(pending.queue); } } // Tick the countdowns locally each second so they read smoothly between // the 3-second polls that correct them. function tickPending() { if (!pendingState) return; var changed = false; if (pendingState.queue && pendingState.queue.in > 0) { pendingState.queue.in--; changed = true; } if (pendingState.scheduled && pendingState.scheduled.in > 0) { pendingState.scheduled.in--; changed = true; } if (changed) updatePendingCountdowns(pendingState); } function renderResults(data) { renderPending(data.pending); var runs = document.getElementById('abcw-runs'); var label = document.getElementById('abcw-runs-label'); if (!data.history || !data.history.length) { label.hidden = true; runs.innerHTML = ''; var empty = document.createElement('div'); empty.className = 'abcw-runs-empty'; empty.textContent = i18n.noResultsHint; runs.appendChild(empty); return; } label.hidden = false; runs.innerHTML = ''; data.history.forEach(function(run, position) { var item = document.createElement('div'); item.className = 'abcw-run'; var head = document.createElement('button'); head.type = 'button'; head.className = 'abcw-run-head'; var when = document.createElement('span'); when.className = 'when'; when.textContent = run.date || timeAgo(run.time); var trigger = document.createElement('span'); trigger.className = 'trigger'; trigger.textContent = triggerLabels[run.trigger] || run.trigger; var counts = document.createElement('span'); counts.className = 'counts'; counts.textContent = sprintf( i18n.runSummary, run.warmed, run.cached, run.failed, run.duration ); var chevron = document.createElement('span'); chevron.className = 'chevron'; chevron.textContent = '\u203A'; head.appendChild(when); head.appendChild(trigger); head.appendChild(counts); head.appendChild(chevron); var body = document.createElement('div'); body.className = 'abcw-run-body'; body.hidden = true; head.addEventListener('click', function() { var open = body.hidden; body.hidden = !open; item.classList.toggle('open', open); if (!open || body.dataset.loaded) return; body.dataset.loaded = '1'; if (!run.id || !run.rows_stored) { var note = document.createElement('p'); note.className = 'abcw-muted'; note.textContent = i18n.rowsGone; body.appendChild(note); return; } body.textContent = i18n.loadingRows; post('abcw_results', { run: run.id }).then(function(resp) { body.textContent = ''; if (!resp.success || !resp.data.rows.length) { var gone = document.createElement('p'); gone.className = 'abcw-muted'; gone.textContent = i18n.rowsGone; body.appendChild(gone); return; } body.appendChild(runTable(resp.data.rows)); if (resp.data.omitted > 0) { var omitted = document.createElement('p'); omitted.className = 'abcw-muted'; omitted.textContent = sprintf(i18n.resultsOmitted, formatNumber(resp.data.omitted), formatNumber(resultLimit)); body.appendChild(omitted); } }).catch(function() { body.textContent = ''; delete body.dataset.loaded; }); }); item.appendChild(head); item.appendChild(body); runs.appendChild(item); // The newest run opens itself, it is what you came to see. if (position === 0) head.click(); }); } // A fingerprint of the completed-run list, so a poll only rebuilds the // accordion when a run actually finished, otherwise an expanded run // stays expanded and we just refresh the pending card. function historySig(history) { if (!history || !history.length) return '0'; return history.length + '|' + history[0].id + '|' + history[0].time; } function loadResults(force) { if (resultsLoaded && !force) return; resultsLoaded = true; post('abcw_results', {}).then(function(resp) { if (!resp.success) { resultsLoaded = false; return; } resultsSig = historySig(resp.data.history); renderResults(resp.data); }).catch(function() { resultsLoaded = false; }); } function pollResults() { post('abcw_results', {}).then(function(resp) { if (!resp.success) return; var sig = historySig(resp.data.history); if (sig !== resultsSig) { // A run finished, rebuild the list (newest opens itself). resultsSig = sig; renderResults(resp.data); } else { // Nothing new completed; just keep the pending card live. renderPending(resp.data.pending); } }).catch(function() {}); } var pendingTick = null; function startResultsPolling() { stopResultsPolling(); resultsPollTimer = setInterval(pollResults, 3000); pendingTick = setInterval(tickPending, 1000); } function stopResultsPolling() { if (resultsPollTimer) { clearInterval(resultsPollTimer); resultsPollTimer = null; } if (pendingTick) { clearInterval(pendingTick); pendingTick = null; } } // ── Run control ────────────────────────────────────────────────────── function setRunningUI(isRunning) { running = isRunning; sourcesWrap.classList.toggle('locked', isRunning); selectAllBtn.disabled = isRunning; if (isRunning) { btn.classList.add('stopping'); btnText.textContent = i18n.stopWarming; btnIcon.innerHTML = ICON_STOP; progress.classList.add('active'); successMsg.classList.remove('active'); } else { btn.classList.remove('stopping'); btnIcon.innerHTML = ICON_PLAY; updateButton(); } } function renderProgress(session) { // 'done' counts finished URLs, so it advances with every response // rather than once a whole batch lands. var done = typeof session.done === 'number' ? session.done : session.index; if (done > session.total) done = session.total; var pct = session.total > 0 ? Math.round((done / session.total) * 100) : 0; bar.style.width = pct + '%'; statusPct.textContent = pct + '%'; if (session.resume_in > 0) { statusText.textContent = sprintf( session.pause_reason === 'rate' ? i18n.rateLimited : i18n.paused, session.resume_in ); } else { statusText.textContent = sprintf( session.trigger && session.trigger !== 'manual' ? i18n.progressAuto : i18n.progress, done, session.total ); } document.getElementById('abcw-tally-warmed').textContent = formatNumber(session.warmed); document.getElementById('abcw-tally-cached').textContent = formatNumber(session.cached); document.getElementById('abcw-tally-failed').textContent = formatNumber(session.failed); var images = document.getElementById('abcw-tally-images'); if (session.images_capped > 0) { images.textContent = sprintf(i18n.imagesCapped, session.images_capped); images.style.display = ''; } else if (session.images_queued > 0) { images.textContent = sprintf(i18n.imagesQueued, session.images_queued); images.style.display = ''; } else { images.style.display = 'none'; } setText('abcw-stat-warmed', formatNumber(session.warmed)); setText('abcw-stat-cached', formatNumber(session.cached)); setText('abcw-stat-queue', formatNumber(Math.max(0, session.total - done))); } function showError(msg) { statusText.textContent = sprintf(i18n.error, msg); bar.style.background = '#d63638'; statusPct.textContent = ''; stopping = false; stopPolling(); setRunningUI(false); btnText.textContent = i18n.warmAgain; btn.disabled = false; } function finishRun(session, lastRun) { stopping = false; driving = false; resultsLoaded = false; stopPolling(); flushRows(); setRunningUI(false); btnText.textContent = i18n.warmAgain; btn.disabled = false; var duration = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); var summary = session || lastRun; if (session && session.status === 'stopped') { statusText.textContent = i18n.stopped; } else if (summary) { bar.style.width = '100%'; statusPct.textContent = '100%'; statusText.textContent = session && session.total ? sprintf(i18n.completeCount, session.total) : i18n.complete; successText.textContent = sprintf( i18n.runSummary, summary.warmed, summary.cached, summary.failed, lastRun && lastRun.duration ? lastRun.duration : duration ); successMsg.classList.add('active'); } // Nothing is queued any more, so the tile goes back to showing what // the next run would cover rather than a stale remainder. setText('abcw-stat-queue', formatNumber(estimatedCount())); // Every URL answered, and not one of them carried a cache header. // Worth saying out loud: it usually means no page cache is running. if (session && session.status === 'completed' && session.total > 0 && !session.layers_seen) { statusText.textContent = i18n.noCacheLayer; } if (lastRun) { renderLastRun(lastRun); } else if (session) { renderLastRun({ time: Math.floor(Date.now() / 1000), warmed: session.warmed, cached: session.cached, failed: session.failed, duration: duration }); } } // Poll for finished rows so the table fills as URLs complete, rather // than jumping a whole batch at a time. function poll() { // Without this guard a slow site double-reports: the interval fires // again before the last response landed, both requests carry the // same 'since', and every row in between arrives twice. if (polling) return; polling = true; post('abcw_progress', { since: lastRowId }).then(function(resp) { polling = false; if (!resp.success) return; if (resp.data.nonce) nonce = resp.data.nonce; queueRows(resp.data.rows); var session = resp.data.session; if (!session) { // The run finished and aged out between polls. if (running) finishRun(null, resp.data.lastRun); return; } renderProgress(session); if (session.status !== 'running') { finishRun(session, resp.data.lastRun); } }).catch(function() { polling = false; }); } function startPolling() { stopPolling(); pollTimer = setInterval(poll, 600); poll(); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } // Drive batches from the browser while the tab is open. Cron keeps the // run moving if the tab is closed, but this is much faster. function driveBatches() { if (!running || driving) return; driving = true; post('abcw_batch', {}).then(function(resp) { driving = false; if (!resp.success) { showError(resp.data || i18n.networkError); return; } if (!running || !resp.data.session || resp.data.session.status !== 'running') { return; } // Standing down because the server is busy, the whole point is // to stop asking it for things, so wait out the window here too. if (resp.data.session.resume_in > 0) { setTimeout(driveBatches, (resp.data.session.resume_in + 1) * 1000); return; } // Cron got there first and holds the lock. Wait rather than // hammering admin-ajax until it lets go. setTimeout(driveBatches, resp.data.locked ? 1500 : 0); }).catch(function() { driving = false; // A dropped batch is not fatal, cron will pick the run up. if (running) setTimeout(driveBatches, 3000); }); } function beginRunUI() { startedAt = Date.now(); setRunningUI(true); startPolling(); driveBatches(); } // Attach the live Warmer UI to a run already in progress, whether it // was left by a closed tab, or started automatically (a schedule, a // purge) while this page was open on another tab. function attachToRun(session) { if (running) return; lastRowId = 0; seenRows = {}; logRows.innerHTML = ''; logWrap.classList.remove('active'); successMsg.classList.remove('active'); bar.style.background = ''; renderProgress(session); beginRunUI(); } // While the Warmer tab is open and nothing is being driven here, check // for a run that started elsewhere and attach to it live, so switching // to the tab shows what is happening without a page refresh. var warmerWatchTimer = null; function warmerWatch() { if (running) return; post('abcw_progress', { since: 0 }).then(function(resp) { if (!resp.success || running) return; var s = resp.data.session; if (s && s.status === 'running') { attachToRun(s); } }).catch(function() {}); } function startWarmerWatch() { stopWarmerWatch(); warmerWatch(); warmerWatchTimer = setInterval(warmerWatch, 4000); } function stopWarmerWatch() { if (warmerWatchTimer) { clearInterval(warmerWatchTimer); warmerWatchTimer = null; } } btn.addEventListener('click', function() { if (running) { if (stopping) return; stopping = true; btnText.textContent = i18n.stopping; btn.disabled = true; post('abcw_stop', {}).catch(function() {}); return; } flushRows(); logRows.innerHTML = ''; logWrap.classList.remove('active'); successMsg.classList.remove('active'); bar.style.background = ''; bar.style.width = '0%'; statusPct.textContent = '0%'; statusText.textContent = i18n.collecting; lastRowId = 0; seenRows = {}; setRunningUI(true); btn.disabled = true; post('abcw_start', {}).then(function(resp) { if (!resp.success) { showError(resp.data || i18n.noUrls); return; } btn.disabled = false; renderProgress(resp.data.session); beginRunUI(); }).catch(function() { showError(i18n.networkError); }); }); ['abcw-concurrency', 'abcw-delay', 'abcw-timeout', 'abcw-include', 'abcw-exclude', 'abcw-adaptive', 'abcw-ssl', 'abcw-keep-days', 'abcw-keep-runs'] .forEach(function(id) { document.getElementById(id).addEventListener('change', function() { persistSettings(true); }); }); document.addEventListener('keydown', function(e) { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !btn.disabled) { e.preventDefault(); btn.click(); } }); // ── Boot ───────────────────────────────────────────────────────────── post('abcw_bootstrap', {}).then(function(resp) { if (!resp.success) return; sources = resp.data.sources; profile = resp.data.profile; applySettings(resp.data.settings); renderSources(); renderSelectAll(); renderProfile(); renderSchedule(resp.data.schedule); renderPurge(resp.data.purge); renderLastRun(resp.data.lastRun); updateButton(); setText('abcw-stat-queue', formatNumber(estimatedCount())); restoreTab(); // A run left going by a closed tab is picked straight back up, // exactly where it got to. if (resp.data.session && resp.data.session.status === 'running') { attachToRun(resp.data.session); } // Keep watching for later runs while the Warmer tab is the one open. var activeTab = document.querySelector('.abcw-tab.active'); if (activeTab && 'warming' === activeTab.getAttribute('data-panel')) { startWarmerWatch(); } }).catch(function() {}); })();

›
—
—
—
—
0%
0 0 0