| 1 |
<?php |
| 2 |
/** |
| 3 |
* Purge caches ability. |
| 4 |
* |
| 5 |
* @package ThinkRank\Abilities\Maintenance |
| 6 |
*/ |
| 7 |
|
| 8 |
declare(strict_types=1); |
| 9 |
|
| 10 |
namespace ThinkRank\Abilities\Maintenance; |
| 11 |
|
| 12 |
use ThinkRank\Abilities\Ability_Base; |
| 13 |
|
| 14 |
if ( ! defined( 'ABSPATH' ) ) { |
| 15 |
exit; // Exit if accessed directly. |
| 16 |
} |
| 17 |
|
| 18 |
/** |
| 19 |
* Clears ThinkRank's own caches. |
| 20 |
* |
| 21 |
* ThinkRank caches Google and analytics responses, schema output and the site |
| 22 |
* audit, so "it is stale, clear it" is a real question — and it had no |
| 23 |
* supported answer at all: no ability, and no admin control either (#676). |
| 24 |
* |
| 25 |
* The recurrence is the argument for it rather than any single report. #629 was |
| 26 |
* sitemap regeneration silently deferred to cron, and #235 was the Search |
| 27 |
* Console property list cached for thirty minutes with no way to invalidate it; |
| 28 |
* both would have been self-service with this. |
| 29 |
* |
| 30 |
* Scoped rather than a single blunt flush, so clearing a stale Search Console |
| 31 |
* list does not also throw away an hour-old site audit that costs real work to |
| 32 |
* rebuild. |
| 33 |
*/ |
| 34 |
class Purge_Caches extends Ability_Base { |
| 35 |
|
| 36 |
/** |
| 37 |
* Scope => human label, and the order they are reported in. |
| 38 |
* |
| 39 |
* @var array<string, string> |
| 40 |
*/ |
| 41 |
private const SCOPES = [ |
| 42 |
'analyzer' => 'Site SEO Analyzer audit', |
| 43 |
'schema' => 'Schema output cache', |
| 44 |
'ai' => 'AI response cache', |
| 45 |
'integrations' => 'Google integration responses', |
| 46 |
'content_type' => 'Content Type Matrix resolution', |
| 47 |
'transients' => 'ThinkRank transients', |
| 48 |
// Not a cache in the same sense — it rebuilds a published file rather |
| 49 |
// than dropping a stored value — but it is what a caller looking at a |
| 50 |
// stale sitemap reaches for, and the docblock above already named that |
| 51 |
// problem as a reason this ability exists (#764). |
| 52 |
'sitemap' => 'Served XML sitemap (rebuilt, not just cleared)', |
| 53 |
]; |
| 54 |
|
| 55 |
/** |
| 56 |
* Constructor. |
| 57 |
*/ |
| 58 |
public function __construct() { |
| 59 |
$this->id = 'thinkrank/purge-caches'; |
| 60 |
$this->label = __( 'Purge ThinkRank Caches', 'thinkrank' ); |
| 61 |
$this->description = __( 'Clear ThinkRank\'s cached data when it is showing something stale — the site audit, schema output, AI responses, Google integration responses, or all of them. Pass scopes to clear only what you need; omit it to clear everything. Nothing is deleted except caches: settings and content are untouched, and each cache rebuilds on the next request. After clearing the analyzer scope, use run-seo-analyzer to rebuild the audit immediately rather than waiting for the next request. The "sitemap" scope is the exception to "rebuilds on the next request": the sitemap is a published file, so that scope rebuilds it there and then and reports whether it succeeded.', 'thinkrank' ); |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* {@inheritDoc} |
| 66 |
* |
| 67 |
* @return array<string, bool|float|string> |
| 68 |
*/ |
| 69 |
public function get_annotations() { |
| 70 |
return [ |
| 71 |
'readonly' => false, |
| 72 |
// Caches only. Nothing a user authored is lost, and everything |
| 73 |
// cleared is rebuilt on demand — the cost is latency, not data. |
| 74 |
'destructive' => false, |
| 75 |
'idempotent' => true, |
| 76 |
'priority' => 0.5, |
| 77 |
'openWorldHint' => false, |
| 78 |
]; |
| 79 |
} |
| 80 |
|
| 81 |
/** |
| 82 |
* {@inheritDoc} |
| 83 |
* |
| 84 |
* @return array<string, mixed> |
| 85 |
*/ |
| 86 |
public function get_input_schema() { |
| 87 |
return [ |
| 88 |
'type' => 'object', |
| 89 |
'additionalProperties' => false, |
| 90 |
'properties' => [ |
| 91 |
'scopes' => [ |
| 92 |
'type' => 'array', |
| 93 |
'items' => [ |
| 94 |
'type' => 'string', |
| 95 |
'enum' => array_keys( self::SCOPES ), |
| 96 |
], |
| 97 |
'description' => __( 'Which caches to clear. Omit to clear all of them.', 'thinkrank' ), |
| 98 |
], |
| 99 |
], |
| 100 |
]; |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* {@inheritDoc} |
| 105 |
* |
| 106 |
* @return array<string, mixed> |
| 107 |
*/ |
| 108 |
public function get_output_schema() { |
| 109 |
return [ |
| 110 |
'type' => 'object', |
| 111 |
'properties' => [ |
| 112 |
'success' => [ 'type' => 'boolean' ], |
| 113 |
'cleared' => [ |
| 114 |
'type' => 'array', |
| 115 |
'items' => [ 'type' => 'string' ], |
| 116 |
'description' => __( 'Scopes that were cleared.', 'thinkrank' ), |
| 117 |
], |
| 118 |
'skipped' => [ |
| 119 |
'type' => 'array', |
| 120 |
'items' => [ 'type' => 'string' ], |
| 121 |
'description' => __( 'Scopes whose subsystem is not present on this install, so there was nothing to clear.', 'thinkrank' ), |
| 122 |
], |
| 123 |
'failed' => [ |
| 124 |
'type' => 'array', |
| 125 |
'items' => [ 'type' => 'string' ], |
| 126 |
'description' => __( 'Scopes that exist but could not be cleared or rebuilt. For "sitemap" this means the served sitemap did not change, because another rebuild held the lock or the files could not be written or removed. Try again shortly; the Sitemap settings screen shows any recorded reason.', 'thinkrank' ), |
| 127 |
], |
| 128 |
], |
| 129 |
]; |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* Execute ability. |
| 134 |
* |
| 135 |
* @param array<string, mixed> $input Ability input payload. |
| 136 |
* @return array<string, mixed> |
| 137 |
*/ |
| 138 |
public function execute( $input ) { |
| 139 |
$input = (array) $input; |
| 140 |
$requested = isset( $input['scopes'] ) && is_array( $input['scopes'] ) && ! empty( $input['scopes'] ) |
| 141 |
? array_values( array_intersect( array_keys( self::SCOPES ), array_map( 'strval', $input['scopes'] ) ) ) |
| 142 |
: array_keys( self::SCOPES ); |
| 143 |
|
| 144 |
$results = [ |
| 145 |
'cleared' => [], |
| 146 |
'skipped' => [], |
| 147 |
'failed' => [], |
| 148 |
]; |
| 149 |
|
| 150 |
foreach ( $requested as $scope ) { |
| 151 |
$results[ $this->purge( $scope ) ][] = $scope; |
| 152 |
} |
| 153 |
|
| 154 |
// `success` stays true when a scope failed: the call itself ran, and |
| 155 |
// every other requested scope was still cleared. The failure is |
| 156 |
// reported per scope in `failed`, which is where a caller has to look |
| 157 |
// to know which cache is still stale. |
| 158 |
return [ 'success' => true ] + $results; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Clear one scope. |
| 163 |
* |
| 164 |
* Three outcomes rather than a bool. A bool had only "cleared" and |
| 165 |
* "skipped" to map onto, and skipped is documented as "the subsystem is |
| 166 |
* not present", so a sitemap rebuild that lost the lock or failed to write |
| 167 |
* was reported to the caller as a sitemap that does not exist. |
| 168 |
* |
| 169 |
* @since 2.10.0 Returns 'cleared', 'skipped' or 'failed' instead of a bool. |
| 170 |
* |
| 171 |
* @param string $scope Scope key. |
| 172 |
* @return string 'cleared', 'skipped' (subsystem absent) or 'failed'. |
| 173 |
*/ |
| 174 |
private function purge( string $scope ): string { |
| 175 |
switch ( $scope ) { |
| 176 |
case 'analyzer': |
| 177 |
if ( ! class_exists( 'ThinkRank\\SEO\\SEO_Analyzer' ) ) { |
| 178 |
return 'skipped'; |
| 179 |
} |
| 180 |
( new \ThinkRank\SEO\SEO_Analyzer() )->flush_cache(); |
| 181 |
return 'cleared'; |
| 182 |
|
| 183 |
case 'schema': |
| 184 |
if ( ! class_exists( 'ThinkRank\\SEO\\Schema_Cache_Manager' ) ) { |
| 185 |
return 'skipped'; |
| 186 |
} |
| 187 |
( new \ThinkRank\SEO\Schema_Cache_Manager() )->clear_all(); |
| 188 |
return 'cleared'; |
| 189 |
|
| 190 |
case 'ai': |
| 191 |
if ( ! class_exists( 'ThinkRank\\AI\\Cache_Manager' ) ) { |
| 192 |
return 'skipped'; |
| 193 |
} |
| 194 |
( new \ThinkRank\AI\Cache_Manager() )->clear_all(); |
| 195 |
return 'cleared'; |
| 196 |
|
| 197 |
case 'integrations': |
| 198 |
if ( ! method_exists( 'ThinkRank\\API\\Integrations_Endpoint', 'purge_search_console_sites_cache' ) ) { |
| 199 |
return 'skipped'; |
| 200 |
} |
| 201 |
\ThinkRank\API\Integrations_Endpoint::purge_search_console_sites_cache(); |
| 202 |
return 'cleared'; |
| 203 |
|
| 204 |
case 'content_type': |
| 205 |
if ( ! method_exists( 'ThinkRank\\SEO\\Content_Type_Settings', 'flush_cache' ) ) { |
| 206 |
return 'skipped'; |
| 207 |
} |
| 208 |
\ThinkRank\SEO\Content_Type_Settings::flush_cache(); |
| 209 |
return 'cleared'; |
| 210 |
|
| 211 |
case 'transients': |
| 212 |
$this->purge_transients(); |
| 213 |
return 'cleared'; |
| 214 |
|
| 215 |
case 'sitemap': |
| 216 |
if ( ! class_exists( 'ThinkRank\\SEO\\Sitemap_Generator' ) ) { |
| 217 |
return 'skipped'; |
| 218 |
} |
| 219 |
|
| 220 |
// Rebuilds rather than invalidates: the sitemap is a static file |
| 221 |
// on most installs, so there is no next request that would |
| 222 |
// regenerate it. Lock-guarded, and reports false when another |
| 223 |
// process holds the lock or the write fails, so the caller is |
| 224 |
// told the served file did not change. |
| 225 |
return ( new \ThinkRank\SEO\Sitemap_Generator( false ) ) |
| 226 |
->regenerate_sitemap_from_settings() ? 'cleared' : 'failed'; |
| 227 |
} |
| 228 |
|
| 229 |
return 'skipped'; |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Delete ThinkRank's prefixed transients. |
| 234 |
* |
| 235 |
* Mirrors the sweep Deactivator runs, so the two cannot disagree about what |
| 236 |
* counts as a ThinkRank transient. |
| 237 |
* |
| 238 |
* @return void |
| 239 |
*/ |
| 240 |
private function purge_transients(): void { |
| 241 |
global $wpdb; |
| 242 |
|
| 243 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- deleting the cache rows themselves; there is no core API for a prefix sweep. |
| 244 |
$wpdb->query( |
| 245 |
$wpdb->prepare( |
| 246 |
"DELETE FROM {$wpdb->options} |
| 247 |
WHERE option_name LIKE %s |
| 248 |
OR option_name LIKE %s", |
| 249 |
$wpdb->esc_like( '_transient_thinkrank_' ) . '%', |
| 250 |
$wpdb->esc_like( '_transient_timeout_thinkrank_' ) . '%' |
| 251 |
) |
| 252 |
); |
| 253 |
|
| 254 |
if ( function_exists( 'wp_cache_flush_group' ) ) { |
| 255 |
wp_cache_flush_group( 'thinkrank' ); |
| 256 |
} |
| 257 |
} |
| 258 |
} |
| 259 |
|