PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 7.2.6
Media Cleaner: Clean your WordPress! v7.2.6
7.2.6 7.2.5 7.2.4 7.2.3 7.2.2 7.2.1 7.2.0 7.1.1 7.1.0 7.0.9 7.0.8 trunk 3.6.8 3.6.9 3.7.0 3.8.0 3.9.0 4.0.0 4.0.2 4.0.4 4.0.6 4.0.7 4.1.0 4.2.0 4.2.2 4.2.3 4.2.4 4.2.5 4.4.0 4.4.2 4.4.4 4.4.6 4.4.7 4.4.8 4.5.0 4.5.4 4.5.6 4.5.7 4.5.8 4.6.2 4.6.3 4.8.0 4.8.4 5.0.0 5.0.1 5.1.0 5.1.1 5.1.3 5.2.0 5.2.1 5.2.4 5.4.0 5.4.1 5.4.2 5.4.3 5.4.4 5.4.5 5.4.6 5.4.9 5.5.0 5.5.1 5.5.2 5.5.3 5.5.4 5.5.7 5.5.8 5.6.1 5.6.2 5.6.3 5.6.4 6.0.1 6.0.2 6.0.3 6.0.4 6.0.5 6.0.6 6.0.7 6.0.8 6.0.9 6.1.2 6.1.3 6.1.4 6.1.5 6.1.6 6.1.7 6.1.8 6.1.9 6.2.0 6.2.1 6.2.3 6.2.4 6.2.5 6.2.6 6.2.7 6.2.8 6.3.0 6.3.1 6.3.2 6.3.4 6.3.5 6.3.7 6.3.8 6.3.9 6.4.0 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.4.7 6.4.8 6.4.9 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.5.8 6.5.9 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.6.6 6.6.7 6.6.8 6.6.9 6.7.0 6.7.1 6.7.2 6.7.3 6.7.4 6.7.5 6.7.6 6.7.7 6.7.8 6.7.9 6.8.0 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.8.6 6.8.7 6.8.8 6.8.9 6.9.0 6.9.1 6.9.2 6.9.3 6.9.4 6.9.5 6.9.6 6.9.7 6.9.8 6.9.9 7.0.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.0.6 7.0.7
media-cleaner / classes / mcp.php
media-cleaner / classes Last commit date
parsers 1 month ago admin.php 1 month ago buffers.php 2 weeks ago core.php 2 weeks ago engine.php 1 month ago init.php 9 months ago mcp.php 1 month ago parsers.php 1 month ago rest.php 2 weeks ago runs.php 1 month ago support.php 1 month ago ui.php 1 month ago
mcp.php
967 lines
1 <?php
2
3 /**
4 * MCP tools for Media Cleaner, served through AI Engine.
5 *
6 * Media Cleaner decides whether a file is used by looking at the content it can
7 * read statically. That is never a proof of non-usage, so every tool here is
8 * written to keep the assistant sceptical: capabilities and blind spots are
9 * reported up front, results carry their warnings, and anything destructive
10 * states what it will do before it is asked to do it.
11 */
12 class Meow_WPMC_MCP {
13
14 const MAX_ITEMS = 100;
15 const SAFETY_NOTE = 'Media Cleaner scans content statically. Files used by page builders, themes, custom code, JavaScript, CSS or external sites can be reported as unused even though they are needed. Never delete on the sole basis of these results.';
16
17 private $core;
18
19 public function __construct( $core ) {
20 $this->core = $core;
21 add_action( 'init', array( $this, 'init' ), 20 );
22 }
23
24 public function init() {
25 global $mwai;
26 if ( !$this->core->get_option( 'mcp_support' ) || !isset( $mwai ) ) {
27 return;
28 }
29 add_filter( 'mwai_mcp_tools', array( $this, 'register_tools' ) );
30 add_filter( 'mwai_mcp_callback', array( $this, 'handle_tool_execution' ), 10, 4 );
31 }
32
33 #region Tools Definitions
34
35 public function register_tools( $tools ) {
36 $category = 'Media Cleaner';
37
38 $tools[] = array(
39 'name' => 'wpmc_get_capabilities',
40 'description' => 'Report what Media Cleaner can and cannot detect on this site: which parsers are active, which installed builders or plugins are NOT supported, and the current blind spots. ALWAYS call this first, before scanning or deleting anything. Its "unsupported" and "limitations" fields tell you which media must be verified by hand instead of trusted from a scan.',
41 'category' => $category,
42 'accessLevel' => 'read',
43 'inputSchema' => array( 'type' => 'object' ),
44 );
45
46 $tools[] = array(
47 'name' => 'wpmc_get_status',
48 'description' => 'Report the current state of Media Cleaner: the configured scan method, the counts of issues, ignored and trashed items from the last completed scan, whether a scan is staged or paused, and whether cleanup is currently allowed (and why not, when it is locked).',
49 'category' => $category,
50 'accessLevel' => 'read',
51 'inputSchema' => array( 'type' => 'object' ),
52 );
53
54 $tools[] = array(
55 'name' => 'wpmc_scan_start',
56 'description' => 'Stage a new scan and return its run id. The scan is only staged: it does not touch the published results, and nothing is deleted. Follow with wpmc_scan_step repeatedly until finished is true, then wpmc_scan_publish. Only one scan can be staged at a time.',
57 'category' => $category,
58 'accessLevel' => 'write',
59 'inputSchema' => array(
60 'type' => 'object',
61 'properties' => array(
62 'method' => array(
63 'type' => 'string',
64 'enum' => array( 'media', 'files', 'duplicates' ),
65 'description' => 'media = list the Media Library and find entries not referenced in the content. files = walk the uploads folder and find files not referenced. duplicates = find files with identical content. Defaults to the method configured in the settings.',
66 ),
67 'content' => array(
68 'type' => 'boolean',
69 'description' => 'Analyze the posts content to collect references. Strongly recommended: without it, almost everything looks unused.',
70 ),
71 ),
72 ),
73 );
74
75 $tools[] = array(
76 'name' => 'wpmc_scan_step',
77 'description' => 'Run the next bounded batch of the staged scan and return the progress. Call it again while finished is false; next_action tells you what to do. A scan of a large site needs many calls, this is normal and each call is deliberately time-bounded so the server is never overloaded.',
78 'category' => $category,
79 'accessLevel' => 'write',
80 'inputSchema' => array(
81 'type' => 'object',
82 'properties' => array(
83 'run_id' => array( 'type' => 'integer', 'description' => 'The run id returned by wpmc_scan_start.' ),
84 ),
85 'required' => array( 'run_id' ),
86 ),
87 );
88
89 $tools[] = array(
90 'name' => 'wpmc_scan_publish',
91 'description' => 'Publish a finished scan so its results replace the previous ones and become visible in the dashboard. It fails if the scan did not complete every phase, which protects you from publishing partial evidence.',
92 'category' => $category,
93 'accessLevel' => 'write',
94 'inputSchema' => array(
95 'type' => 'object',
96 'properties' => array(
97 'run_id' => array( 'type' => 'integer', 'description' => 'The run id returned by wpmc_scan_start.' ),
98 ),
99 'required' => array( 'run_id' ),
100 ),
101 );
102
103 $tools[] = array(
104 'name' => 'wpmc_scan_cancel',
105 'description' => 'Cancel a staged scan and discard its temporary data. The previously published results are kept untouched.',
106 'category' => $category,
107 'accessLevel' => 'write',
108 'inputSchema' => array(
109 'type' => 'object',
110 'properties' => array(
111 'run_id' => array( 'type' => 'integer', 'description' => 'The run id to cancel.' ),
112 ),
113 'required' => array( 'run_id' ),
114 ),
115 );
116
117 $tools[] = array(
118 'name' => 'wpmc_get_issues',
119 'description' => 'List the media reported as unused by the last published scan. These are SUSPICIONS, not proof: read the warnings, and use wpmc_explain_issue before acting on anything that matters.',
120 'category' => $category,
121 'accessLevel' => 'read',
122 'inputSchema' => array(
123 'type' => 'object',
124 'properties' => array(
125 'filter' => array(
126 'type' => 'string',
127 'enum' => array( 'issues', 'ignored', 'trash' ),
128 'description' => 'issues = reported as unused (default). ignored = kept on purpose. trash = already moved to the Media Cleaner trash.',
129 ),
130 'search' => array( 'type' => 'string', 'description' => 'Filter by file path.' ),
131 'limit' => array( 'type' => 'integer', 'description' => 'How many items to return, 1 to 100. Defaults to 25.' ),
132 'skip' => array( 'type' => 'integer', 'description' => 'How many items to skip, for paging.' ),
133 ),
134 ),
135 );
136
137 $tools[] = array(
138 'name' => 'wpmc_explain_issue',
139 'description' => 'Explain why one item was reported as unused: the references that were found for it, the parsers that ran, and the reasons the result could be wrong. Use it before deleting anything, and report its "verify_manually" advice to the user.',
140 'category' => $category,
141 'accessLevel' => 'read',
142 'inputSchema' => array(
143 'type' => 'object',
144 'properties' => array(
145 'entry_id' => array( 'type' => 'integer', 'description' => 'The id of the entry, as returned by wpmc_get_issues.' ),
146 ),
147 'required' => array( 'entry_id' ),
148 ),
149 );
150
151 $tools[] = array(
152 'name' => 'wpmc_ignore',
153 'description' => 'Mark entries as ignored, so they are kept and no longer reported. This is the safe way to dismiss a false positive. Nothing is deleted.',
154 'category' => $category,
155 'accessLevel' => 'write',
156 'inputSchema' => array(
157 'type' => 'object',
158 'properties' => array(
159 'entry_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'description' => 'Entry ids, 100 maximum per call.' ),
160 'ignore' => array( 'type' => 'boolean', 'description' => 'True to ignore (default), false to stop ignoring.' ),
161 ),
162 'required' => array( 'entry_ids' ),
163 ),
164 );
165
166 $tools[] = array(
167 'name' => 'wpmc_trash',
168 'description' => 'Move entries to the Media Cleaner trash. This is REVERSIBLE with wpmc_recover: the files are moved to a private folder, not erased. Ask the user for an explicit confirmation before calling this, and tell them how many files, and which, are concerned. If the site uses a builder listed as unsupported by wpmc_get_capabilities, say so first.',
169 'category' => $category,
170 'accessLevel' => 'write',
171 'inputSchema' => array(
172 'type' => 'object',
173 'properties' => array(
174 'entry_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'description' => 'Entry ids, 100 maximum per call.' ),
175 ),
176 'required' => array( 'entry_ids' ),
177 ),
178 );
179
180 $tools[] = array(
181 'name' => 'wpmc_recover',
182 'description' => 'Restore entries from the Media Cleaner trash back to their original place. This is the undo of wpmc_trash, and the right answer whenever a doubt appears.',
183 'category' => $category,
184 'accessLevel' => 'write',
185 'inputSchema' => array(
186 'type' => 'object',
187 'properties' => array(
188 'entry_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'description' => 'Entry ids, 100 maximum per call.' ),
189 ),
190 'required' => array( 'entry_ids' ),
191 ),
192 );
193
194 $tools[] = array(
195 'name' => 'wpmc_delete_permanently',
196 'description' => 'PERMANENTLY delete entries. The files are erased and CANNOT be recovered by Media Cleaner, only from a backup. Never call this on your own initiative, never to "clean up" after a scan, and never in a loop over a list. It requires the user to have explicitly asked for a permanent deletion, and requires confirm to be exactly PERMANENT. Prefer wpmc_trash in every other case.',
197 'category' => $category,
198 'accessLevel' => 'admin',
199 'inputSchema' => array(
200 'type' => 'object',
201 'properties' => array(
202 'entry_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'description' => 'Entry ids, 100 maximum per call.' ),
203 'confirm' => array( 'type' => 'string', 'description' => 'Must be exactly PERMANENT. Only set it after the user asked for a permanent deletion, being aware the files cannot be restored.' ),
204 ),
205 'required' => array( 'entry_ids', 'confirm' ),
206 ),
207 );
208
209 return $tools;
210 }
211
212 #endregion
213
214 #region Execution
215
216 public function handle_tool_execution( $result, $tool, $args, $id ) {
217 if ( strpos( (string) $tool, 'wpmc_' ) !== 0 ) {
218 return $result;
219 }
220 $args = is_array( $args ) ? $args : array();
221 try {
222 switch ( $tool ) {
223 case 'wpmc_get_capabilities': return $this->tool_get_capabilities();
224 case 'wpmc_get_status': return $this->tool_get_status();
225 case 'wpmc_scan_start': return $this->tool_scan_start( $args );
226 case 'wpmc_scan_step': return $this->tool_scan_step( $args );
227 case 'wpmc_scan_publish': return $this->tool_scan_publish( $args );
228 case 'wpmc_scan_cancel': return $this->tool_scan_cancel( $args );
229 case 'wpmc_get_issues': return $this->tool_get_issues( $args );
230 case 'wpmc_explain_issue': return $this->tool_explain_issue( $args );
231 case 'wpmc_ignore': return $this->tool_ignore( $args );
232 case 'wpmc_trash': return $this->tool_operate( $args, 'trash' );
233 case 'wpmc_recover': return $this->tool_operate( $args, 'recover' );
234 case 'wpmc_delete_permanently': return $this->tool_delete_permanently( $args );
235 }
236 return $result;
237 }
238 catch ( Throwable $e ) {
239 return array( 'success' => false, 'error' => $e->getMessage() );
240 }
241 }
242
243 #endregion
244
245 #region Discovery
246
247 private function tool_get_capabilities() {
248 $is_pro = $this->core->admin && $this->core->admin->is_pro_user();
249 $shortcodes = !$this->core->get_option( 'shortcodes_disabled' );
250 // get_natives() are handled by the free version, get_issues() are the ones
251 // detected on this site whose parser only ships with the Pro version.
252 $free_supported = class_exists( 'Meow_WPMC_Support' ) ? Meow_WPMC_Support::get_natives() : array();
253 $pro_only = class_exists( 'Meow_WPMC_Support' ) ? Meow_WPMC_Support::get_issues() : array();
254 $supported = $is_pro ? array_merge( $free_supported, $pro_only ) : $free_supported;
255 $needs_pro = $is_pro ? array() : $pro_only;
256
257 $limitations = array(
258 'Media referenced only by PHP code, a theme template or a custom field without a dedicated parser is not detected.',
259 'Media built dynamically in JavaScript, or injected by an external service, is not detected.',
260 'Media used on another site of a multisite, or on a staging copy sharing the uploads, is not detected.',
261 'Any plugin or theme absent from the supported list stores its data in its own format, which is not read. The list below only covers what Media Cleaner knows about: a plugin it never heard of is invisible to it.',
262 );
263 if ( !$shortcodes ) {
264 $limitations[] = 'Shortcode analysis is DISABLED in the settings: everything rendered by a shortcode (galleries, sliders) is currently invisible to the scan. This is a major source of false positives.';
265 }
266 if ( !empty( $needs_pro ) ) {
267 $limitations[] = 'These plugins are installed but their parser is only in the Pro version, so they are NOT covered here: ' . implode( ', ', $needs_pro ) . '. Media used only by them will be reported as unused.';
268 }
269
270 return array(
271 'success' => true,
272 'is_pro' => $is_pro,
273 'shortcode_analysis_enabled' => $shortcodes,
274 'supported_plugins_detected' => array_values( array_unique( $supported ) ),
275 'detected_but_needs_pro' => array_values( $needs_pro ),
276 'active_parsers' => $this->list_active_parsers(),
277 'limitations' => $limitations,
278 'how_to_be_safe' => array(
279 'Ask the user for a full backup before any deletion.',
280 'Ask the user which plugins, themes or custom code use their media, and check that they are in supported_plugins_detected.',
281 'Use wpmc_explain_issue on a sample of the results and check the reasons.',
282 'Prefer wpmc_ignore for anything doubtful, and wpmc_trash (reversible) over wpmc_delete_permanently.',
283 ),
284 'warning' => self::SAFETY_NOTE,
285 );
286 }
287
288 private function list_active_parsers() {
289 global $wp_filter;
290 // The parsers register themselves on this hook, which normally only fires
291 // when a scan starts. It has to run here to be able to list them.
292 $this->core->safe_do_action( 'wpmc_initialize_parsers' );
293 $parsers = array();
294 foreach ( array( 'wpmc_scan_post', 'wpmc_scan_postmeta', 'wpmc_scan_once', 'wpmc_scan_widget' ) as $hook ) {
295 if ( empty( $wp_filter[ $hook ] ) || !( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
296 continue;
297 }
298 foreach ( $wp_filter[ $hook ]->callbacks as $callbacks ) {
299 foreach ( $callbacks as $callback ) {
300 $name = $callback['function'];
301 if ( is_string( $name ) ) {
302 $parsers[] = $name;
303 }
304 else if ( is_array( $name ) && count( $name ) === 2 ) {
305 $owner = is_object( $name[0] ) ? get_class( $name[0] ) : $name[0];
306 $parsers[] = $owner . '::' . $name[1];
307 }
308 }
309 }
310 }
311 return array_values( array_unique( $parsers ) );
312 }
313
314 private function tool_get_status() {
315 $runs = $this->core->runs;
316 if ( !$runs ) {
317 return array( 'success' => false, 'error' => 'The Media Cleaner run manager is unavailable.' );
318 }
319 $active = $runs->get( $runs->get_active_id() );
320 $resumable = $runs->get_resumable();
321 $stats = $this->get_counts();
322 $cleanup_allowed = $this->core->can_cleanup();
323 $cleanup_blocked_because = null;
324 if ( !$cleanup_allowed ) {
325 // Only trashing waits for this. Recovering, emptying the trash and ignoring
326 // always work, so the reason says what is actually refused.
327 $cleanup_blocked_because = $resumable
328 ? 'A scan is staged or paused, so wpmc_trash is refused. Publish it with wpmc_scan_publish, or cancel it with wpmc_scan_cancel. Recovering and emptying the trash still work.'
329 : 'No completed scan from this version of Media Cleaner is published, so wpmc_trash is refused. Run one with wpmc_scan_start. Recovering and emptying the trash still work.';
330 }
331
332 return array(
333 'success' => true,
334 'method_configured' => $this->core->get_option( 'method' ),
335 'content_analysis_enabled' => (bool) $this->core->get_option( 'content' ),
336 'shortcode_analysis_enabled' => !$this->core->get_option( 'shortcodes_disabled' ),
337 'last_published_scan' => $active ? array(
338 'run_id' => (int) $active->id,
339 'method' => $active->method,
340 'published_at' => $active->published_at,
341 ) : null,
342 'staged_scan' => $resumable ? array(
343 'run_id' => (int) $resumable->id,
344 'status' => $resumable->status,
345 'phase' => $resumable->phase,
346 'hint' => 'Continue it with wpmc_scan_step, or cancel it with wpmc_scan_cancel.',
347 ) : null,
348 'counts' => $stats,
349 'cleanup_allowed' => $cleanup_allowed,
350 'cleanup_blocked_because' => $cleanup_blocked_because,
351 'warning' => self::SAFETY_NOTE,
352 );
353 }
354
355 private function get_counts() {
356 global $wpdb;
357 $table = $wpdb->prefix . 'mclean_scan';
358 $run_id = $this->core->get_run_id();
359 $row = $wpdb->get_row( $wpdb->prepare(
360 "SELECT
361 SUM(CASE WHEN ignored = 0 AND deleted = 0 THEN 1 ELSE 0 END) AS issues,
362 SUM(CASE WHEN ignored = 1 THEN 1 ELSE 0 END) AS ignored,
363 SUM(CASE WHEN deleted = 1 THEN 1 ELSE 0 END) AS trashed
364 FROM $table WHERE run_id = %d",
365 $run_id
366 ) );
367 return array(
368 'issues' => $row ? (int) $row->issues : 0,
369 'ignored' => $row ? (int) $row->ignored : 0,
370 'trashed' => $row ? (int) $row->trashed : 0,
371 );
372 }
373
374 #endregion
375
376 #region Scanning
377
378 private function tool_scan_start( $args ) {
379 $runs = $this->core->runs;
380 if ( !$runs ) {
381 return array( 'success' => false, 'error' => 'The Media Cleaner run manager is unavailable.' );
382 }
383 $existing = $runs->get_resumable();
384 if ( $existing ) {
385 return array(
386 'success' => false,
387 'error' => 'A scan is already staged.',
388 'run_id' => (int) $existing->id,
389 'hint' => 'Continue it with wpmc_scan_step, or cancel it with wpmc_scan_cancel.',
390 );
391 }
392
393 $options = $this->core->get_all_options();
394 $method = isset( $args['method'] ) ? sanitize_key( $args['method'] ) : $options['method'];
395 if ( !in_array( $method, array( 'media', 'files', 'duplicates' ), true ) ) {
396 return array( 'success' => false, 'error' => 'Unsupported method. Use media, files or duplicates.' );
397 }
398 $config = $this->core->sanitize_scan_config( $options );
399 if ( isset( $args['content'] ) ) {
400 $content = rest_sanitize_boolean( $args['content'] );
401 $config['content'] = $content;
402 $config['filesystem_content'] = $content;
403 }
404
405 $storage = $this->core->prepare_private_storage();
406 if ( is_wp_error( $storage ) ) {
407 return array( 'success' => false, 'error' => $storage->get_error_message() );
408 }
409
410 $run = $runs->start( $method, $config, 'mcp-' . wp_generate_uuid4() );
411 if ( is_wp_error( $run ) ) {
412 return array( 'success' => false, 'error' => $run->get_error_message() );
413 }
414 $context = $this->core->set_run_context( $run->id );
415 if ( is_wp_error( $context ) ) {
416 return array( 'success' => false, 'error' => $context->get_error_message() );
417 }
418 $scan_type = $this->get_scan_steps( $method, $config );
419 $runs->checkpoint( $run->id, 'ready', null, array( 'mcp' => array(
420 'steps' => $scan_type,
421 'index' => 0,
422 'offset' => 0,
423 ) ) );
424
425 $warnings = array( self::SAFETY_NOTE );
426 if ( empty( $config['content'] ) ) {
427 $warnings[] = 'Content analysis is DISABLED for this scan: nearly every file will be reported as unused. This is almost certainly not what the user wants.';
428 }
429 if ( !empty( $config['shortcodes_disabled'] ) ) {
430 $warnings[] = 'Shortcode analysis is disabled: galleries and sliders rendered by shortcodes will look unused.';
431 }
432
433 return array(
434 'success' => true,
435 'run_id' => (int) $run->id,
436 'method' => $method,
437 'config_used' => array(
438 'content_analysis' => (bool) $config['content'],
439 'media_library_check' => (bool) $config['media_library'],
440 'shortcode_analysis' => empty( $config['shortcodes_disabled'] ),
441 ),
442 'steps' => $scan_type,
443 'next_action' => 'Call wpmc_scan_step with this run_id, and repeat while finished is false.',
444 'nothing_deleted' => true,
445 'warnings' => $warnings,
446 );
447 }
448
449 private function get_scan_steps( $method, $config ) {
450 $steps = array( 'resetIssuesAndReferences' );
451 $content = $method === 'files' ? !empty( $config['filesystem_content'] ) : !empty( $config['content'] );
452 if ( $content ) {
453 $steps[] = 'extractReferencesFromContent';
454 }
455 if ( $method === 'files' && !empty( $config['media_library'] ) ) {
456 $steps[] = 'extractReferencesFromMedia';
457 }
458 if ( $method === 'duplicates' ) {
459 $steps[] = 'extractReferencesFromDuplicates';
460 }
461 $steps[] = 'retrieveTargets';
462 return $steps;
463 }
464
465 private function read_cursor( $run ) {
466 $counters = json_decode( (string) $run->counters, true );
467 $cursor = is_array( $counters ) && isset( $counters['mcp'] ) && is_array( $counters['mcp'] )
468 ? $counters['mcp'] : array();
469 return array(
470 'steps' => isset( $cursor['steps'] ) && is_array( $cursor['steps'] ) ? $cursor['steps'] : null,
471 'index' => isset( $cursor['index'] ) ? (int) $cursor['index'] : 0,
472 'offset' => isset( $cursor['offset'] ) ? (int) $cursor['offset'] : 0,
473 );
474 }
475
476 private function tool_scan_step( $args ) {
477 $run_id = isset( $args['run_id'] ) ? (int) $args['run_id'] : 0;
478 $run = $this->core->set_run_context( $run_id );
479 if ( is_wp_error( $run ) ) {
480 return array( 'success' => false, 'error' => $run->get_error_message() );
481 }
482 // The cursor lives in the counters, not in the checkpoint. The engines write the
483 // checkpoint themselves during a step, replacing whatever was there, so a cursor
484 // kept in it would be erased mid-step: if the request then died, the next call
485 // would read no cursor, start again from step zero and reset the references it
486 // had already collected, while the work journals still counted as done. The
487 // counters are merged instead of replaced, so both can write freely.
488 $cursor = $this->read_cursor( $run );
489 $steps = is_array( $cursor['steps'] ) && $cursor['steps']
490 ? $cursor['steps']
491 : $this->get_scan_steps( $run->method, json_decode( (string) $run->config, true ) ?: array() );
492 $index = (int) $cursor['index'];
493 $offset = (int) $cursor['offset'];
494
495 if ( $index >= count( $steps ) ) {
496 return array(
497 'success' => true,
498 'run_id' => $run_id,
499 'finished' => true,
500 'next_action' => 'Call wpmc_scan_publish with this run_id to make these results the published ones.',
501 );
502 }
503
504 $step = $steps[ $index ];
505 $engine = $this->core->engine;
506 $message = '';
507 $processed = 0;
508 $step_finished = true;
509
510 try {
511 switch ( $step ) {
512 case 'resetIssuesAndReferences':
513 $this->core->reset_issues();
514 $this->core->reset_references();
515 $this->core->save_progress( 'resetIssuesAndReferences' );
516 break;
517 case 'extractReferencesFromContent':
518 $step_finished = $engine->extractRefsFromContent( $offset, (int) $this->core->get_option( 'posts_buffer' ), $message, null, $processed );
519 break;
520 case 'extractReferencesFromMedia':
521 $step_finished = $engine->extractRefsFromLibrary( $offset, (int) $this->core->get_option( 'posts_buffer' ), $message, null, $processed );
522 break;
523 case 'extractReferencesFromDuplicates':
524 $step_finished = $engine->extractRefsFromDuplicates( $offset, (int) $this->core->get_option( 'medias_buffer' ), $message, null, $processed );
525 break;
526 case 'retrieveTargets':
527 $result = $this->step_retrieve_targets( $run, $offset, $processed );
528 $step_finished = $result['finished'];
529 $message = $result['message'];
530 break;
531 }
532 }
533 catch ( Meow_WPMC_Transient_Exception $e ) {
534 return array(
535 'success' => true,
536 'run_id' => $run_id,
537 'finished' => false,
538 'retry' => true,
539 'message' => $e->getMessage(),
540 'next_action' => 'The server asked for a pause. Call wpmc_scan_step again with the same run_id.',
541 );
542 }
543 catch ( Throwable $e ) {
544 $this->core->runs->fail( $run_id, 'mcp_scan_failed', $e->getMessage() );
545 return array( 'success' => false, 'run_id' => $run_id, 'error' => $e->getMessage(), 'scan_failed' => true );
546 }
547
548 // The engines check the time limit before handling their first item, so a step
549 // can come back having processed nothing. Advancing the offset here would step
550 // over an item, its references would never be collected, and the media it uses
551 // could later be reported as unused. The offset is kept instead and the step is
552 // retried on a fresh time budget, which is enough to get past it.
553 if ( !$step_finished && $processed < 1 ) {
554 return array(
555 'success' => true,
556 'run_id' => $run_id,
557 'finished' => false,
558 'retry' => true,
559 'step' => $step,
560 'message' => 'The server ran out of time before this step processed anything. Nothing was skipped.',
561 'next_action' => 'Call wpmc_scan_step again with the same run_id.',
562 );
563 }
564
565 $next_offset = $step_finished ? 0 : $offset + $processed;
566 $next_index = $step_finished ? $index + 1 : $index;
567 $finished = $next_index >= count( $steps );
568 // The last checkpoint has to carry the phase name the run manager expects for
569 // this method, otherwise the coverage is incomplete and publishing is refused.
570 $phase = $finished ? $this->final_phase( $run->method ) : $step;
571 $counters = array( 'mcp' => array(
572 'steps' => $steps,
573 'index' => $next_index,
574 'offset' => $next_offset,
575 ) );
576 // checkpoint() answers false when the database refused the write. Carrying on
577 // would report a step as done, or the scan as finished, while the cursor still
578 // points at the previous position.
579 $saved = $this->core->runs->checkpoint( $run_id, $phase, null, $counters );
580 if ( is_wp_error( $saved ) || $saved === false ) {
581 return array(
582 'success' => false,
583 'run_id' => $run_id,
584 'error' => is_wp_error( $saved ) ? $saved->get_error_message()
585 : 'The scan progress could not be saved, so the scan was stopped instead of reporting progress that was not recorded.',
586 );
587 }
588
589 return array(
590 'success' => true,
591 'run_id' => $run_id,
592 'step' => $step,
593 'step_number' => $index + 1,
594 'total_steps' => count( $steps ),
595 'processed_in_this_call' => $processed,
596 'message' => $message,
597 'finished' => $finished,
598 'next_action' => $finished
599 ? 'Call wpmc_scan_publish with this run_id to make these results the published ones.'
600 : 'Call wpmc_scan_step again with the same run_id.',
601 'nothing_deleted' => true,
602 );
603 }
604
605 private function final_phase( $method ) {
606 if ( $method === 'files' ) return 'retrieveFiles_finished';
607 if ( $method === 'duplicates' ) return 'retrieveDuplicates_finished';
608 return 'retrieveMedia_finished';
609 }
610
611 private function step_retrieve_targets( $run, $offset, &$processed ) {
612 $engine = $this->core->engine;
613 $processed = 0;
614 if ( $run->method === 'media' ) {
615 $buffer = (int) $this->core->get_option( 'medias_buffer' );
616 $ids = $engine->get_media_entries( $offset, $buffer, $this->core->get_option( 'attach_is_use' ) );
617 $this->core->timeout_check_start( count( $ids ) );
618 foreach ( $ids as $media_id ) {
619 if ( $this->core->timeout_should_yield() ) break;
620 $engine->check_media( $media_id );
621 $this->core->timeout_check_additem();
622 $processed++;
623 }
624 $finished = count( $ids ) < $buffer && $processed === count( $ids );
625 return array( 'finished' => $finished, 'message' => sprintf( 'Checked %d media.', $processed ) );
626 }
627 if ( $run->method === 'duplicates' ) {
628 $buffer = min( 100, max( 1, (int) $this->core->get_option( 'analysis_buffer' ) ) );
629 $hashes = $engine->get_hash_duplicates( $offset, $buffer );
630 $this->core->timeout_check_start( count( $hashes ) );
631 foreach ( $hashes as $hash ) {
632 if ( $this->core->timeout_should_yield() ) break;
633 $engine->check_duplicates( $hash );
634 $this->core->timeout_check_additem();
635 $processed++;
636 }
637 $finished = count( $hashes ) < $buffer && $processed === count( $hashes );
638 return array( 'finished' => $finished, 'message' => sprintf( 'Checked %d duplicate groups.', $processed ) );
639 }
640 // Filesystem: the REST layer owns the directory queue, reuse it as is.
641 $rest = new Meow_WPMC_Rest( $this->core, $this->core->admin );
642 $request = new WP_REST_Request( 'POST', '/media-cleaner/v1/retrieve_files' );
643 $request->set_header( 'content-type', 'application/json' );
644 $request->set_body( wp_json_encode( array( 'runId' => (int) $run->id, 'initialize' => $offset === 0, 'root' => '' ) ) );
645 $response = $rest->rest_retrieve_files( $request );
646 $data = $response->get_data();
647 if ( empty( $data['success'] ) ) {
648 throw new RuntimeException( isset( $data['message'] ) ? $data['message'] : 'The filesystem scan failed.' );
649 }
650 $processed = isset( $data['data']['checked'] ) ? (int) $data['data']['checked'] : 0;
651 return array(
652 'finished' => !empty( $data['data']['finished'] ),
653 'message' => sprintf( 'Checked %d files.', $processed ),
654 );
655 }
656
657 private function tool_scan_publish( $args ) {
658 $run_id = isset( $args['run_id'] ) ? (int) $args['run_id'] : 0;
659 $runs = $this->core->runs;
660 $run = $runs->complete( $run_id );
661 if ( is_wp_error( $run ) ) {
662 return array(
663 'success' => false,
664 'error' => $run->get_error_message(),
665 'hint' => 'The scan is incomplete. Keep calling wpmc_scan_step until finished is true.',
666 );
667 }
668 $this->core->clear_run_context();
669 $counts = $this->get_counts();
670 return array(
671 'success' => true,
672 'run_id' => (int) $run->id,
673 'counts' => $counts,
674 'next_action' => 'Use wpmc_get_issues to read the results, and wpmc_explain_issue before acting on them.',
675 'warning' => self::SAFETY_NOTE,
676 );
677 }
678
679 private function tool_scan_cancel( $args ) {
680 $run_id = isset( $args['run_id'] ) ? (int) $args['run_id'] : 0;
681 $result = $this->core->runs->discard( $run_id );
682 if ( is_wp_error( $result ) ) {
683 return array( 'success' => false, 'error' => $result->get_error_message() );
684 }
685 $this->core->clear_run_context();
686 return array(
687 'success' => (bool) $result,
688 'run_id' => $run_id,
689 'message' => $result ? 'The staged scan was cancelled, the published results are untouched.' : 'This scan is no longer cancellable.',
690 );
691 }
692
693 #endregion
694
695 #region Results
696
697 private function tool_get_issues( $args ) {
698 global $wpdb;
699 $filter = isset( $args['filter'] ) ? sanitize_key( $args['filter'] ) : 'issues';
700 $filters = array(
701 'issues' => 'ignored = 0 AND deleted = 0',
702 'ignored' => 'ignored = 1',
703 'trash' => 'deleted = 1',
704 );
705 if ( !isset( $filters[ $filter ] ) ) {
706 $filter = 'issues';
707 }
708 $limit = isset( $args['limit'] ) ? max( 1, min( self::MAX_ITEMS, (int) $args['limit'] ) ) : 25;
709 $skip = isset( $args['skip'] ) ? max( 0, (int) $args['skip'] ) : 0;
710 $search = isset( $args['search'] ) ? sanitize_text_field( $args['search'] ) : '';
711 $table = $wpdb->prefix . 'mclean_scan';
712 // Run 0 is where the results made before the runs existed live. They are read
713 // like any others: the dashboard shows them, so refusing here would hide a
714 // trash the user still needs to recover.
715 $run_id = $this->core->get_run_id();
716 $condition = $filters[ $filter ];
717 $search_sql = $search === '' ? '' : $wpdb->prepare( 'AND path LIKE %s', '%' . $wpdb->esc_like( $search ) . '%' );
718 $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE run_id = %d AND $condition $search_sql", $run_id ) );
719 $rows = $wpdb->get_results( $wpdb->prepare(
720 "SELECT id, type, postId, path, size, issue FROM $table
721 WHERE run_id = %d AND $condition $search_sql
722 ORDER BY size DESC LIMIT %d, %d",
723 $run_id, $skip, $limit
724 ) );
725
726 $items = array();
727 foreach ( $rows as $row ) {
728 $items[] = array(
729 'entry_id' => (int) $row->id,
730 'media_id' => $row->postId ? (int) $row->postId : null,
731 'path' => $row->path,
732 'size_bytes' => (int) $row->size,
733 'issue' => $row->issue,
734 'is_media_library_entry' => (int) $row->type === 1,
735 );
736 }
737 return array(
738 'success' => true,
739 'filter' => $filter,
740 'total' => $total,
741 'returned' => count( $items ),
742 'items' => $items,
743 'warning' => self::SAFETY_NOTE,
744 'advice' => 'Use wpmc_explain_issue on the items that matter before proposing any deletion, and check wpmc_get_capabilities for the plugins that are not supported on this site.',
745 );
746 }
747
748 private function tool_explain_issue( $args ) {
749 global $wpdb;
750 $entry_id = isset( $args['entry_id'] ) ? (int) $args['entry_id'] : 0;
751 $issue = $this->core->get_issue( $entry_id );
752 if ( !$issue ) {
753 return array( 'success' => false, 'error' => 'This entry does not exist in the published results.' );
754 }
755 $table_refs = $wpdb->prefix . 'mclean_refs';
756 $run_id = $this->core->get_run_id();
757 $paths = (int) $issue->type === 1 ? $this->core->get_paths_from_attachment( $issue->postId ) : array( $issue->path );
758 $found = array();
759 foreach ( array_slice( (array) $paths, 0, 20 ) as $path ) {
760 $rows = $wpdb->get_results( $wpdb->prepare(
761 "SELECT originType, origin FROM $table_refs WHERE run_id = %d AND mediaUrl = %s LIMIT 5",
762 $run_id, $path
763 ) );
764 foreach ( $rows as $row ) {
765 $found[] = array( 'path' => $path, 'origin_type' => $row->originType, 'origin' => $row->origin );
766 }
767 }
768 if ( $issue->postId ) {
769 $rows = $wpdb->get_results( $wpdb->prepare(
770 "SELECT originType, origin FROM $table_refs WHERE run_id = %d AND mediaId = %d LIMIT 5",
771 $run_id, (int) $issue->postId
772 ) );
773 foreach ( $rows as $row ) {
774 $found[] = array( 'media_id' => (int) $issue->postId, 'origin_type' => $row->originType, 'origin' => $row->origin );
775 }
776 }
777
778 $is_pro = $this->core->admin && $this->core->admin->is_pro_user();
779 $needs_pro = !$is_pro && class_exists( 'Meow_WPMC_Support' ) ? Meow_WPMC_Support::get_issues() : array();
780 $shortcodes_off = (bool) $this->core->get_option( 'shortcodes_disabled' );
781 $verify = array();
782 if ( !empty( $needs_pro ) ) {
783 $verify[] = 'These plugins are installed but their parser needs the Pro version, so they were not read: ' . implode( ', ', $needs_pro ) . '. If this media is used by one of them, the result is wrong.';
784 }
785 if ( $shortcodes_off ) {
786 $verify[] = 'Shortcode analysis is disabled, so a gallery or slider using this media would not have been seen.';
787 }
788 $verify[] = 'Search the media file name in the theme files, the custom CSS and the custom code of the site.';
789 $verify[] = 'Open the media in the Media Library and look at the "Used in" information.';
790 $verify[] = 'Media Cleaner only knows the plugins it has a parser for. If this site uses anything else to display media, check there too.';
791
792 return array(
793 'success' => true,
794 'entry_id' => $entry_id,
795 'path' => $issue->path,
796 'media_id' => $issue->postId ? (int) $issue->postId : null,
797 'issue' => $issue->issue,
798 'issue_meaning' => $this->explain_issue_code( $issue->issue ),
799 'references_found' => $found,
800 'why_it_is_reported' => empty( $found )
801 ? 'No reference to this media was found in anything Media Cleaner was able to read.'
802 : 'Some references exist but did not protect this entry: read them, this result is suspicious and should not be deleted without checking.',
803 'confidence' => empty( $found ) && empty( $needs_pro ) && !$shortcodes_off ? 'reasonable' : 'low',
804 'confidence_meaning' => 'reasonable means nothing contradicts the result, it is never a proof. low means a known blind spot could explain it.',
805 'verify_manually' => $verify,
806 'warning' => self::SAFETY_NOTE,
807 );
808 }
809
810 private function explain_issue_code( $code ) {
811 $codes = array(
812 'NO_CONTENT' => 'No reference was found in the content that was analyzed. It does not mean the file is unused.',
813 'ORPHAN_MEDIA' => 'This Media Library entry is not attached to any post.',
814 'ORPHAN_FILE' => 'This file is in the uploads folder but not in the Media Library.',
815 'ORPHAN_RETINA' => 'This is a retina file whose original is missing.',
816 'ORPHAN_WEBP' => 'This is a WebP file whose original is missing.',
817 'DUPLICATE' => 'Another file has exactly the same content. One copy is always kept.',
818 'NOT_NEEDED_THUMB' => 'This thumbnail size is not registered by the theme or WordPress anymore.',
819 );
820 return isset( $codes[ $code ] ) ? $codes[ $code ] : $code;
821 }
822
823 #endregion
824
825 #region Cleanup
826
827 private function read_entry_ids( $args ) {
828 $ids = isset( $args['entry_ids'] ) ? (array) $args['entry_ids'] : array();
829 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
830 if ( empty( $ids ) ) {
831 throw new RuntimeException( 'entry_ids is required and must contain at least one valid id.' );
832 }
833 if ( count( $ids ) > self::MAX_ITEMS ) {
834 throw new RuntimeException( sprintf( 'Too many items: %d. Send %d at most per call.', count( $ids ), self::MAX_ITEMS ) );
835 }
836 return $ids;
837 }
838
839 private function tool_ignore( $args ) {
840 $ids = $this->read_entry_ids( $args );
841 $ignore = isset( $args['ignore'] ) ? rest_sanitize_boolean( $args['ignore'] ) : true;
842 $results = array();
843 $done = 0;
844 foreach ( $ids as $id ) {
845 $result = $this->core->ignore( $id, $ignore );
846 $ok = !is_wp_error( $result ) && $result === true;
847 if ( $ok ) $done++;
848 $results[] = array( 'entry_id' => $id, 'success' => $ok, 'error' => is_wp_error( $result ) ? $result->get_error_message() : null );
849 }
850 return array(
851 'success' => $done === count( $ids ),
852 'ignored' => $ignore,
853 'succeeded' => $done,
854 'failed' => count( $ids ) - $done,
855 'results' => $results,
856 'nothing_deleted' => true,
857 );
858 }
859
860 private function tool_operate( $args, $operation ) {
861 $ids = $this->read_entry_ids( $args );
862 // With Skip Trash on, trashing deletes the file outright. This tool promises
863 // something reversible, so it refuses rather than quietly destroying files.
864 if ( $operation === 'trash' && $this->core->get_option( 'skip_trash' ) ) {
865 throw new RuntimeException( 'Media Cleaner is set to skip the trash, so trashing would delete these files permanently and wpmc_trash will not do that. Use wpmc_delete_permanently if that is really what is wanted, or turn Skip Trash off in the settings.' );
866 }
867 $results = array();
868 $done = 0;
869 $skipped = 0;
870 foreach ( $ids as $id ) {
871 // delete() erases an item that is already in the trash, because that is how
872 // the trash is emptied. Trashing must never reach that: a retried call, or an
873 // id that is already trashed, would destroy the file for good while this
874 // reports it as reversible. Both operations are no-ops when there is nothing
875 // to do, so repeating a call is always safe.
876 $issue = $this->core->get_issue( $id );
877 if ( $issue ) {
878 $in_trash = (int) $issue->deleted === 1;
879 if ( ( $operation === 'trash' && $in_trash ) || ( $operation === 'recover' && !$in_trash ) ) {
880 $done++;
881 $skipped++;
882 $results[] = array(
883 'entry_id' => $id,
884 'success' => true,
885 'skipped' => $operation === 'trash' ? 'already_in_trash' : 'not_in_trash',
886 'error' => null,
887 );
888 continue;
889 }
890 }
891 // initial_deleted says "this was not in the trash", which pins delete() to
892 // the quarantine branch. Without it, an overlapping call could trash the row
893 // between the check above and the read inside delete(), and the file would be
894 // erased instead. It fails loudly rather than deleting.
895 $result = $operation === 'trash'
896 ? $this->core->delete( $id, array( 'initial_deleted' => false ) )
897 : $this->core->recover( $id );
898 $ok = !is_wp_error( $result ) && $result === true;
899 if ( $ok ) $done++;
900 $results[] = array( 'entry_id' => $id, 'success' => $ok, 'error' => is_wp_error( $result ) ? $result->get_error_message() : null );
901 }
902 return array(
903 'success' => $done === count( $ids ),
904 'operation' => $operation,
905 'succeeded' => $done,
906 'failed' => count( $ids ) - $done,
907 'skipped' => $skipped,
908 'results' => $results,
909 'reversible' => true,
910 'how_to_undo' => $operation === 'trash'
911 ? 'These files are in the Media Cleaner trash. wpmc_recover restores them at any time, as long as the trash is not emptied.'
912 : 'These files are back in place.',
913 );
914 }
915
916 private function tool_delete_permanently( $args ) {
917 $confirm = isset( $args['confirm'] ) ? (string) $args['confirm'] : '';
918 if ( $confirm !== 'PERMANENT' ) {
919 return array(
920 'success' => false,
921 'error' => 'Permanent deletion refused: confirm must be exactly PERMANENT, and only after the user explicitly asked for it, knowing the files cannot be restored.',
922 'safer_alternative' => 'Use wpmc_trash instead, it is reversible.',
923 );
924 }
925 $ids = $this->read_entry_ids( $args );
926 // core->delete() moves an untouched entry to the trash, and erases it when it
927 // is already trashed, so it is called until the entry is really gone. With the
928 // "skip trash" setting the very first call already erases it.
929 $results = array();
930 $done = 0;
931 foreach ( $ids as $id ) {
932 $issue = $this->core->get_issue( $id );
933 if ( !$issue ) {
934 $results[] = array( 'entry_id' => $id, 'success' => false, 'error' => 'This entry no longer exists.' );
935 continue;
936 }
937 $error = null;
938 for ( $pass = 0; $pass < 2; $pass++ ) {
939 $result = $this->core->delete( $id );
940 if ( is_wp_error( $result ) || $result !== true ) {
941 $error = is_wp_error( $result ) ? $result->get_error_message() : 'The entry could not be deleted.';
942 break;
943 }
944 // The entry row is removed by a permanent deletion, which is the proof it is gone.
945 if ( !$this->core->get_issue( $id ) ) {
946 $error = null;
947 break;
948 }
949 $error = 'The entry is still present after the deletion.';
950 }
951 $ok = $error === null;
952 if ( $ok ) $done++;
953 $results[] = array( 'entry_id' => $id, 'success' => $ok, 'error' => $error );
954 }
955 return array(
956 'success' => $done === count( $ids ),
957 'permanently_deleted' => $done,
958 'failed' => count( $ids ) - $done,
959 'results' => $results,
960 'reversible' => false,
961 'warning' => 'These files are gone. Only a backup can bring them back.',
962 );
963 }
964
965 #endregion
966 }
967