PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
xspeed / includes / modules / Database / DatabaseModule.php

DatabaseModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.2, at includes/modules/Database/DatabaseModule.php

306 lines 10.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Database cleanup module — read-first scan + selective cleanup +
4 * OPTIMIZE TABLE + optional auto-cleanup schedule.
5 *
6 * Tier: Free per FEATURES.md Database §1–11. (DB-side activity log
7 * Database §12 stays Pro.)
8 *
9 * @package XSpeed
10 */
11
12 declare(strict_types=1);
13
14 namespace XSpeed\Modules\Database;
15
16 defined( 'ABSPATH' ) || exit;
17
18 use XSpeed\Database_Cleaner;
19 use XSpeed\Module;
20 use XSpeed\Settings_Manager;
21
22 final class DatabaseModule extends Module {
23
24 public const SLUG = 'database';
25 public const TIER = self::TIER_FREE;
26 public const VERSION = '1.0.0';
27
28 public function ui_metadata(): array {
29 return array(
30 'label' => __( 'Database', 'xspeed' ),
31 'icon' => 'Trash2',
32 'description' => __( 'Remove old revisions, spam and other leftovers, and optimize database tables.', 'xspeed' ),
33 'custom_panel' => 'DatabaseCleanerPanel',
34 'group' => 'tools',
35 );
36 }
37
38 public function settings_schema(): array {
39 return array(
40 'schedule' => array(
41 'type' => 'enum',
42 'default' => 'manual',
43 'options' => array( 'manual', 'hourly', 'daily', 'weekly' ),
44 'option_labels' => array(
45 'manual' => 'Manual',
46 'hourly' => 'Hourly',
47 'daily' => 'Daily',
48 'weekly' => 'Weekly',
49 ),
50 'label' => __( 'Automatic cleanup', 'xspeed' ),
51 'description' => __( 'How often xSpeed cleans the database on its own. Manual means it only cleans when you press the button.', 'xspeed' ),
52 ),
53 'included_types' => array(
54 'type' => 'list',
55 'default' => array(),
56 'item_type' => 'string',
57 'label' => __( 'What to clean automatically', 'xspeed' ),
58 'description' => __( 'The items the scheduled cleanup removes. If you leave this empty, nothing is cleaned, even with a schedule set.', 'xspeed' ),
59 'dependsOn' => array(
60 'any' => array(
61 array( 'field' => 'schedule', 'value' => 'hourly' ),
62 array( 'field' => 'schedule', 'value' => 'daily' ),
63 array( 'field' => 'schedule', 'value' => 'weekly' ),
64 ),
65 ),
66 ),
67 'limit_revisions' => array(
68 'type' => 'bool',
69 'default' => false,
70 'label' => __( 'Limit post revisions', 'xspeed' ),
71 'description' => __( 'Cap how many revisions WordPress keeps for each post. Cleanup removes revisions that already exist; this stops them piling up again.', 'xspeed' ),
72 ),
73 'revisions_to_keep' => array(
74 'type' => 'int',
75 'default' => 5,
76 'min' => 0,
77 'max' => 500,
78 'label' => __( 'Revisions to keep', 'xspeed' ),
79 'description' => __( '0 turns revisions off completely. Older revisions are trimmed the next time each post is saved; to remove the backlog now, clean Post Revisions above.', 'xspeed' ),
80 'dependsOn' => array( 'field' => 'limit_revisions' ),
81 ),
82 );
83 }
84
85 /**
86 * WP_POST_REVISIONS as wp-config.php (or an earlier plugin) set it, or
87 * null when nobody did. Read in boot(), on plugins_loaded: after that,
88 * core defines it as `true` itself, so defined() stops telling the two
89 * cases apart.
90 *
91 * Not the schema's `constants` pinning (#398): that tests defined() at
92 * request time, when core's own default makes it always true.
93 *
94 * @var bool|int|null
95 */
96 private static $configured_revisions = null;
97
98 /**
99 * @param int $num Revisions WordPress would keep; -1 means all.
100 * @param \WP_Post $post
101 * @return int
102 */
103 public static function filter_revisions_to_keep( $num, $post ) {
104 // A wp-config.php value is the site owner's decision; leave it alone.
105 if ( null !== self::$configured_revisions ) {
106 return $num;
107 }
108 // Core already answered 0 for post types without revision support.
109 if ( ! $post instanceof \WP_Post || ! post_type_supports( $post->post_type, 'revisions' ) ) {
110 return $num;
111 }
112 $opts = Settings_Manager::get( self::SLUG );
113 if ( empty( $opts['limit_revisions'] ) ) {
114 return $num;
115 }
116 return max( 0, (int) ( $opts['revisions_to_keep'] ?? 5 ) );
117 }
118
119 public function ui_notices(): array {
120 if ( null === self::$configured_revisions ) {
121 return array();
122 }
123 $value = self::$configured_revisions;
124 if ( true === $value ) {
125 $shown = 'true';
126 } elseif ( false === $value ) {
127 $shown = 'false';
128 } else {
129 $shown = (string) (int) $value;
130 }
131 return array(
132 array(
133 'tone' => 'info',
134 'title' => __( 'The revision limit is set outside xSpeed', 'xspeed' ),
135 'body' => sprintf(
136 /* translators: %s: value of WP_POST_REVISIONS, e.g. 5 or false. */
137 __( 'WP_POST_REVISIONS is set to %s, usually in wp-config.php, so Limit Post Revisions below has no effect. Remove that definition to manage the limit here.', 'xspeed' ),
138 $shown
139 ),
140 ),
141 );
142 }
143
144 public function rest_routes(): array {
145 // Schema POST/GET come from the base; we add three custom routes.
146 $default = parent::rest_routes();
147 return array_merge(
148 $default,
149 array(
150 array(
151 'path' => '/scan',
152 'methods' => 'GET',
153 'callback' => array( $this, 'rest_scan' ),
154 ),
155 array(
156 'path' => '/clean',
157 'methods' => 'POST',
158 'callback' => array( $this, 'rest_clean' ),
159 ),
160 array(
161 'path' => '/optimize',
162 'methods' => 'POST',
163 'callback' => array( $this, 'rest_optimize' ),
164 ),
165 )
166 );
167 }
168
169 public function cli_commands(): array {
170 return array(
171 array(
172 'name' => 'xspeed db',
173 'callback' => array( $this, 'cli_handler' ),
174 'shortdesc' => 'Scan or clean WordPress bloat. Use --types=a,b to clean specific categories.',
175 'ai_hint' => 'Find and remove database bloat (post revisions, spam comments, expired transients, orphaned metadata). Always `scan` first to see what would go; `clean` is destructive and requires confirmation.',
176 'synopsis' => array(
177 array(
178 'type' => 'positional',
179 'name' => 'action',
180 'options' => array( 'scan', 'clean', 'optimize' ),
181 'optional' => false,
182 ),
183 array(
184 'type' => 'assoc',
185 'name' => 'types',
186 'optional' => true,
187 ),
188 ),
189 ),
190 );
191 }
192
193 public function boot(): void {
194 self::$configured_revisions = defined( 'WP_POST_REVISIONS' ) ? constant( 'WP_POST_REVISIONS' ) : null;
195 add_filter( 'wp_revisions_to_keep', array( __CLASS__, 'filter_revisions_to_keep' ), 10, 2 );
196 add_action( Database_Cleaner::CRON_HOOK, array( Database_Cleaner::class, 'cron_tick' ) );
197 add_action( 'update_option_xspeed_module_database', array( $this, 'on_settings_change' ), 10, 2 );
198 add_action( 'add_option_xspeed_module_database', array( $this, 'on_settings_added' ), 10, 2 );
199 }
200
201 public function deactivate(): void {
202 wp_clear_scheduled_hook( Database_Cleaner::CRON_HOOK );
203 }
204
205 /**
206 * The schedule or the selected types changed: reschedule, and drop the
207 * scheduled clean's resume point. A cursor left in place would carry on
208 * deleting a category the user has just switched off. Any other save
209 * returns early and keeps both. clean() also discards a cursor written for
210 * a different selection, so this is the belt to that brace.
211 */
212 public function on_settings_change( $old, $new ): void {
213 // A revision-limit save must not reschedule cleanup: apply_schedule()
214 // restarts the timer, which pulled a weekly run forward on every save.
215 $old = is_array( $old ) ? $old : array();
216 if ( is_array( $new )
217 && ( $old['schedule'] ?? null ) === ( $new['schedule'] ?? null )
218 && ( $old['included_types'] ?? null ) === ( $new['included_types'] ?? null ) ) {
219 return;
220 }
221 $schedule = is_array( $new ) ? (string) ( $new['schedule'] ?? 'manual' ) : 'manual';
222 $types = is_array( $new ) && isset( $new['included_types'] ) && is_array( $new['included_types'] ) ? $new['included_types'] : array();
223 Database_Cleaner::reset_cursor();
224 Database_Cleaner::apply_schedule( $schedule, $types );
225 }
226
227 public function on_settings_added( $name, $value ): void {
228 $schedule = is_array( $value ) ? (string) ( $value['schedule'] ?? 'manual' ) : 'manual';
229 $types = is_array( $value ) && isset( $value['included_types'] ) && is_array( $value['included_types'] ) ? $value['included_types'] : array();
230 Database_Cleaner::reset_cursor();
231 Database_Cleaner::apply_schedule( $schedule, $types );
232 }
233
234 public function rest_scan( \WP_REST_Request $request ) {
235 return rest_ensure_response(
236 array(
237 'scan' => Database_Cleaner::scan(),
238 'labels' => Database_Cleaner::type_labels(),
239 )
240 );
241 }
242
243 public function rest_clean( \WP_REST_Request $request ) {
244 $params = $request->get_json_params();
245 if ( ! is_array( $params ) ) {
246 $params = $request->get_params();
247 }
248 $types = isset( $params['types'] ) && is_array( $params['types'] ) ? $params['types'] : array();
249 if ( empty( $types ) ) {
250 return new \WP_Error( 'xspeed_db_no_types', __( 'Provide at least one cleanup type.', 'xspeed' ), array( 'status' => 400 ) );
251 }
252 return rest_ensure_response( Database_Cleaner::clean( $types ) );
253 }
254
255 public function rest_optimize( \WP_REST_Request $request ) {
256 return rest_ensure_response(
257 array(
258 'optimized' => Database_Cleaner::optimize_tables(),
259 )
260 );
261 }
262
263 public function cli_handler( array $args, array $assoc ): void {
264 $action = $args[0] ?? 'scan';
265
266 switch ( $action ) {
267 case 'scan':
268 foreach ( Database_Cleaner::scan() as $key => $row ) {
269 \WP_CLI::log( sprintf( '%-26s %s — %d rows', $key, $row['label'], $row['count'] ) );
270 }
271 return;
272
273 case 'clean':
274 $types = isset( $assoc['types'] ) ? array_filter( array_map( 'trim', explode( ',', (string) $assoc['types'] ) ) ) : array_keys( Database_Cleaner::type_labels() );
275 $res = Database_Cleaner::clean( $types );
276 foreach ( $res['skipped'] as $key ) {
277 \WP_CLI::warning( sprintf( 'Unknown type "%s" skipped. Run `wp xspeed db scan` for the list.', $key ) );
278 }
279 foreach ( $res['remaining'] as $key => $left ) {
280 \WP_CLI::log( sprintf( '%-26s %d removed, %d left', $key, (int) ( $res['cleaned'][ $key ] ?? 0 ), $left ) );
281 }
282 $left = array_sum( $res['remaining'] );
283 if ( $res['partial'] && $left > 0 ) {
284 // Each run is time-boxed so a large trash can't outlive the
285 // request. Nothing carries over: the next run starts over
286 // on whatever still matches.
287 \WP_CLI::warning( sprintf( 'Stopped at the time limit. %d left — run it again to continue.', $left ) );
288 return;
289 }
290 \WP_CLI::success( 'Cleanup complete.' );
291 return;
292
293 case 'optimize':
294 $res = Database_Cleaner::optimize_tables();
295 foreach ( $res as $table => $ok ) {
296 \WP_CLI::log( sprintf( '%-40s %s', $table, $ok ? 'ok' : 'failed' ) );
297 }
298 \WP_CLI::success( 'Tables optimized.' );
299 return;
300
301 default:
302 \WP_CLI::error( "Unknown action: $action" );
303 }
304 }
305 }
306