| 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 |
]; |
| 49 |
|
| 50 |
/** |
| 51 |
* Constructor. |
| 52 |
*/ |
| 53 |
public function __construct() { |
| 54 |
$this->id = 'thinkrank/purge-caches'; |
| 55 |
$this->label = __( 'Purge ThinkRank Caches', 'thinkrank' ); |
| 56 |
$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.', 'thinkrank' ); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* {@inheritDoc} |
| 61 |
* |
| 62 |
* @return array<string, bool|float|string> |
| 63 |
*/ |
| 64 |
public function get_annotations() { |
| 65 |
return [ |
| 66 |
'readonly' => false, |
| 67 |
// Caches only. Nothing a user authored is lost, and everything |
| 68 |
// cleared is rebuilt on demand — the cost is latency, not data. |
| 69 |
'destructive' => false, |
| 70 |
'idempotent' => true, |
| 71 |
'priority' => 0.5, |
| 72 |
'openWorldHint' => false, |
| 73 |
]; |
| 74 |
} |
| 75 |
|
| 76 |
/** |
| 77 |
* {@inheritDoc} |
| 78 |
* |
| 79 |
* @return array<string, mixed> |
| 80 |
*/ |
| 81 |
public function get_input_schema() { |
| 82 |
return [ |
| 83 |
'type' => 'object', |
| 84 |
'additionalProperties' => false, |
| 85 |
'properties' => [ |
| 86 |
'scopes' => [ |
| 87 |
'type' => 'array', |
| 88 |
'items' => [ |
| 89 |
'type' => 'string', |
| 90 |
'enum' => array_keys( self::SCOPES ), |
| 91 |
], |
| 92 |
'description' => __( 'Which caches to clear. Omit to clear all of them.', 'thinkrank' ), |
| 93 |
], |
| 94 |
], |
| 95 |
]; |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* {@inheritDoc} |
| 100 |
* |
| 101 |
* @return array<string, mixed> |
| 102 |
*/ |
| 103 |
public function get_output_schema() { |
| 104 |
return [ |
| 105 |
'type' => 'object', |
| 106 |
'properties' => [ |
| 107 |
'success' => [ 'type' => 'boolean' ], |
| 108 |
'cleared' => [ |
| 109 |
'type' => 'array', |
| 110 |
'items' => [ 'type' => 'string' ], |
| 111 |
'description' => __( 'Scopes that were cleared.', 'thinkrank' ), |
| 112 |
], |
| 113 |
'skipped' => [ |
| 114 |
'type' => 'array', |
| 115 |
'items' => [ 'type' => 'string' ], |
| 116 |
'description' => __( 'Scopes whose subsystem is not present on this install, so there was nothing to clear.', 'thinkrank' ), |
| 117 |
], |
| 118 |
], |
| 119 |
]; |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* Execute ability. |
| 124 |
* |
| 125 |
* @param array<string, mixed> $input Ability input payload. |
| 126 |
* @return array<string, mixed> |
| 127 |
*/ |
| 128 |
public function execute( $input ) { |
| 129 |
$input = (array) $input; |
| 130 |
$requested = isset( $input['scopes'] ) && is_array( $input['scopes'] ) && ! empty( $input['scopes'] ) |
| 131 |
? array_values( array_intersect( array_keys( self::SCOPES ), array_map( 'strval', $input['scopes'] ) ) ) |
| 132 |
: array_keys( self::SCOPES ); |
| 133 |
|
| 134 |
$cleared = []; |
| 135 |
$skipped = []; |
| 136 |
|
| 137 |
foreach ( $requested as $scope ) { |
| 138 |
if ( $this->purge( $scope ) ) { |
| 139 |
$cleared[] = $scope; |
| 140 |
} else { |
| 141 |
$skipped[] = $scope; |
| 142 |
} |
| 143 |
} |
| 144 |
|
| 145 |
return [ |
| 146 |
'success' => true, |
| 147 |
'cleared' => $cleared, |
| 148 |
'skipped' => $skipped, |
| 149 |
]; |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Clear one scope. |
| 154 |
* |
| 155 |
* @param string $scope Scope key. |
| 156 |
* @return bool True when the subsystem existed and was cleared. |
| 157 |
*/ |
| 158 |
private function purge( string $scope ): bool { |
| 159 |
switch ( $scope ) { |
| 160 |
case 'analyzer': |
| 161 |
if ( ! class_exists( 'ThinkRank\\SEO\\SEO_Analyzer' ) ) { |
| 162 |
return false; |
| 163 |
} |
| 164 |
( new \ThinkRank\SEO\SEO_Analyzer() )->flush_cache(); |
| 165 |
return true; |
| 166 |
|
| 167 |
case 'schema': |
| 168 |
if ( ! class_exists( 'ThinkRank\\SEO\\Schema_Cache_Manager' ) ) { |
| 169 |
return false; |
| 170 |
} |
| 171 |
( new \ThinkRank\SEO\Schema_Cache_Manager() )->clear_all(); |
| 172 |
return true; |
| 173 |
|
| 174 |
case 'ai': |
| 175 |
if ( ! class_exists( 'ThinkRank\\AI\\Cache_Manager' ) ) { |
| 176 |
return false; |
| 177 |
} |
| 178 |
( new \ThinkRank\AI\Cache_Manager() )->clear_all(); |
| 179 |
return true; |
| 180 |
|
| 181 |
case 'integrations': |
| 182 |
if ( ! method_exists( 'ThinkRank\\API\\Integrations_Endpoint', 'purge_search_console_sites_cache' ) ) { |
| 183 |
return false; |
| 184 |
} |
| 185 |
\ThinkRank\API\Integrations_Endpoint::purge_search_console_sites_cache(); |
| 186 |
return true; |
| 187 |
|
| 188 |
case 'content_type': |
| 189 |
if ( ! method_exists( 'ThinkRank\\SEO\\Content_Type_Settings', 'flush_cache' ) ) { |
| 190 |
return false; |
| 191 |
} |
| 192 |
\ThinkRank\SEO\Content_Type_Settings::flush_cache(); |
| 193 |
return true; |
| 194 |
|
| 195 |
case 'transients': |
| 196 |
$this->purge_transients(); |
| 197 |
return true; |
| 198 |
} |
| 199 |
|
| 200 |
return false; |
| 201 |
} |
| 202 |
|
| 203 |
/** |
| 204 |
* Delete ThinkRank's prefixed transients. |
| 205 |
* |
| 206 |
* Mirrors the sweep Deactivator runs, so the two cannot disagree about what |
| 207 |
* counts as a ThinkRank transient. |
| 208 |
* |
| 209 |
* @return void |
| 210 |
*/ |
| 211 |
private function purge_transients(): void { |
| 212 |
global $wpdb; |
| 213 |
|
| 214 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- deleting the cache rows themselves; there is no core API for a prefix sweep. |
| 215 |
$wpdb->query( |
| 216 |
$wpdb->prepare( |
| 217 |
"DELETE FROM {$wpdb->options} |
| 218 |
WHERE option_name LIKE %s |
| 219 |
OR option_name LIKE %s", |
| 220 |
$wpdb->esc_like( '_transient_thinkrank_' ) . '%', |
| 221 |
$wpdb->esc_like( '_transient_timeout_thinkrank_' ) . '%' |
| 222 |
) |
| 223 |
); |
| 224 |
|
| 225 |
if ( function_exists( 'wp_cache_flush_group' ) ) { |
| 226 |
wp_cache_flush_group( 'thinkrank' ); |
| 227 |
} |
| 228 |
} |
| 229 |
} |
| 230 |
|