PluginProbe ʕ •ᴥ•ʔ
Media Cleaner: Clean your WordPress! / 7.2.2
Media Cleaner: Clean your WordPress! v7.2.2
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 core.php 1 month ago engine.php 1 month ago init.php 9 months ago mcp.php 1 month ago parsers.php 1 month ago rest.php 1 month ago runs.php 1 month ago support.php 1 month ago ui.php 1 month ago
mcp.php
894 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 $cleanup_blocked_because = $resumable
326 ? 'A scan is staged or paused. Publish it with wpmc_scan_publish, or cancel it with wpmc_scan_cancel.'
327 : 'No completed scan is published yet. Run a scan first.';
328 }
329
330 return array(
331 'success' => true,
332 'method_configured' => $this->core->get_option( 'method' ),
333 'content_analysis_enabled' => (bool) $this->core->get_option( 'content' ),
334 'shortcode_analysis_enabled' => !$this->core->get_option( 'shortcodes_disabled' ),
335 'last_published_scan' => $active ? array(
336 'run_id' => (int) $active->id,
337 'method' => $active->method,
338 'published_at' => $active->published_at,
339 ) : null,
340 'staged_scan' => $resumable ? array(
341 'run_id' => (int) $resumable->id,
342 'status' => $resumable->status,
343 'phase' => $resumable->phase,
344 'hint' => 'Continue it with wpmc_scan_step, or cancel it with wpmc_scan_cancel.',
345 ) : null,
346 'counts' => $stats,
347 'cleanup_allowed' => $cleanup_allowed,
348 'cleanup_blocked_because' => $cleanup_blocked_because,
349 'warning' => self::SAFETY_NOTE,
350 );
351 }
352
353 private function get_counts() {
354 global $wpdb;
355 $table = $wpdb->prefix . 'mclean_scan';
356 $run_id = $this->core->get_run_id();
357 $row = $wpdb->get_row( $wpdb->prepare(
358 "SELECT
359 SUM(CASE WHEN ignored = 0 AND deleted = 0 THEN 1 ELSE 0 END) AS issues,
360 SUM(CASE WHEN ignored = 1 THEN 1 ELSE 0 END) AS ignored,
361 SUM(CASE WHEN deleted = 1 THEN 1 ELSE 0 END) AS trashed
362 FROM $table WHERE run_id = %d",
363 $run_id
364 ) );
365 return array(
366 'issues' => $row ? (int) $row->issues : 0,
367 'ignored' => $row ? (int) $row->ignored : 0,
368 'trashed' => $row ? (int) $row->trashed : 0,
369 );
370 }
371
372 #endregion
373
374 #region Scanning
375
376 private function tool_scan_start( $args ) {
377 $runs = $this->core->runs;
378 if ( !$runs ) {
379 return array( 'success' => false, 'error' => 'The Media Cleaner run manager is unavailable.' );
380 }
381 $existing = $runs->get_resumable();
382 if ( $existing ) {
383 return array(
384 'success' => false,
385 'error' => 'A scan is already staged.',
386 'run_id' => (int) $existing->id,
387 'hint' => 'Continue it with wpmc_scan_step, or cancel it with wpmc_scan_cancel.',
388 );
389 }
390
391 $options = $this->core->get_all_options();
392 $method = isset( $args['method'] ) ? sanitize_key( $args['method'] ) : $options['method'];
393 if ( !in_array( $method, array( 'media', 'files', 'duplicates' ), true ) ) {
394 return array( 'success' => false, 'error' => 'Unsupported method. Use media, files or duplicates.' );
395 }
396 $config = $this->core->sanitize_scan_config( $options );
397 if ( isset( $args['content'] ) ) {
398 $content = rest_sanitize_boolean( $args['content'] );
399 $config['content'] = $content;
400 $config['filesystem_content'] = $content;
401 }
402
403 $storage = $this->core->prepare_private_storage();
404 if ( is_wp_error( $storage ) ) {
405 return array( 'success' => false, 'error' => $storage->get_error_message() );
406 }
407
408 $run = $runs->start( $method, $config, 'mcp-' . wp_generate_uuid4() );
409 if ( is_wp_error( $run ) ) {
410 return array( 'success' => false, 'error' => $run->get_error_message() );
411 }
412 $context = $this->core->set_run_context( $run->id );
413 if ( is_wp_error( $context ) ) {
414 return array( 'success' => false, 'error' => $context->get_error_message() );
415 }
416 $scan_type = $this->get_scan_steps( $method, $config );
417 $runs->checkpoint( $run->id, 'ready', array( 'mcp_steps' => $scan_type, 'mcp_index' => 0 ) );
418
419 $warnings = array( self::SAFETY_NOTE );
420 if ( empty( $config['content'] ) ) {
421 $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.';
422 }
423 if ( !empty( $config['shortcodes_disabled'] ) ) {
424 $warnings[] = 'Shortcode analysis is disabled: galleries and sliders rendered by shortcodes will look unused.';
425 }
426
427 return array(
428 'success' => true,
429 'run_id' => (int) $run->id,
430 'method' => $method,
431 'config_used' => array(
432 'content_analysis' => (bool) $config['content'],
433 'media_library_check' => (bool) $config['media_library'],
434 'shortcode_analysis' => empty( $config['shortcodes_disabled'] ),
435 ),
436 'steps' => $scan_type,
437 'next_action' => 'Call wpmc_scan_step with this run_id, and repeat while finished is false.',
438 'nothing_deleted' => true,
439 'warnings' => $warnings,
440 );
441 }
442
443 private function get_scan_steps( $method, $config ) {
444 $steps = array( 'resetIssuesAndReferences' );
445 $content = $method === 'files' ? !empty( $config['filesystem_content'] ) : !empty( $config['content'] );
446 if ( $content ) {
447 $steps[] = 'extractReferencesFromContent';
448 }
449 if ( $method === 'files' && !empty( $config['media_library'] ) ) {
450 $steps[] = 'extractReferencesFromMedia';
451 }
452 if ( $method === 'duplicates' ) {
453 $steps[] = 'extractReferencesFromDuplicates';
454 }
455 $steps[] = 'retrieveTargets';
456 return $steps;
457 }
458
459 private function tool_scan_step( $args ) {
460 $run_id = isset( $args['run_id'] ) ? (int) $args['run_id'] : 0;
461 $run = $this->core->set_run_context( $run_id );
462 if ( is_wp_error( $run ) ) {
463 return array( 'success' => false, 'error' => $run->get_error_message() );
464 }
465 $checkpoint = json_decode( (string) $run->checkpoint, true );
466 $checkpoint = is_array( $checkpoint ) ? $checkpoint : array();
467 $steps = isset( $checkpoint['mcp_steps'] ) && is_array( $checkpoint['mcp_steps'] )
468 ? $checkpoint['mcp_steps']
469 : $this->get_scan_steps( $run->method, json_decode( (string) $run->config, true ) ?: array() );
470 $index = isset( $checkpoint['mcp_index'] ) ? (int) $checkpoint['mcp_index'] : 0;
471 $offset = isset( $checkpoint['mcp_offset'] ) ? (int) $checkpoint['mcp_offset'] : 0;
472
473 if ( $index >= count( $steps ) ) {
474 return array(
475 'success' => true,
476 'run_id' => $run_id,
477 'finished' => true,
478 'next_action' => 'Call wpmc_scan_publish with this run_id to make these results the published ones.',
479 );
480 }
481
482 $step = $steps[ $index ];
483 $engine = $this->core->engine;
484 $message = '';
485 $processed = 0;
486 $step_finished = true;
487
488 try {
489 switch ( $step ) {
490 case 'resetIssuesAndReferences':
491 $this->core->reset_issues();
492 $this->core->reset_references();
493 $this->core->save_progress( 'resetIssuesAndReferences' );
494 break;
495 case 'extractReferencesFromContent':
496 $step_finished = $engine->extractRefsFromContent( $offset, (int) $this->core->get_option( 'posts_buffer' ), $message, null, $processed );
497 break;
498 case 'extractReferencesFromMedia':
499 $step_finished = $engine->extractRefsFromLibrary( $offset, (int) $this->core->get_option( 'posts_buffer' ), $message, null, $processed );
500 break;
501 case 'extractReferencesFromDuplicates':
502 $step_finished = $engine->extractRefsFromDuplicates( $offset, (int) $this->core->get_option( 'medias_buffer' ), $message, null, $processed );
503 break;
504 case 'retrieveTargets':
505 $result = $this->step_retrieve_targets( $run, $offset, $processed );
506 $step_finished = $result['finished'];
507 $message = $result['message'];
508 break;
509 }
510 }
511 catch ( Meow_WPMC_Transient_Exception $e ) {
512 return array(
513 'success' => true,
514 'run_id' => $run_id,
515 'finished' => false,
516 'retry' => true,
517 'message' => $e->getMessage(),
518 'next_action' => 'The server asked for a pause. Call wpmc_scan_step again with the same run_id.',
519 );
520 }
521 catch ( Throwable $e ) {
522 $this->core->runs->fail( $run_id, 'mcp_scan_failed', $e->getMessage() );
523 return array( 'success' => false, 'run_id' => $run_id, 'error' => $e->getMessage(), 'scan_failed' => true );
524 }
525
526 $next_offset = $step_finished ? 0 : $offset + max( 1, $processed );
527 $next_index = $step_finished ? $index + 1 : $index;
528 $finished = $next_index >= count( $steps );
529 $checkpoint['mcp_steps'] = $steps;
530 $checkpoint['mcp_index'] = $next_index;
531 $checkpoint['mcp_offset'] = $next_offset;
532 // The last checkpoint has to carry the phase name the run manager expects for
533 // this method, otherwise the coverage is incomplete and publishing is refused.
534 $phase = $finished ? $this->final_phase( $run->method ) : $step;
535 $saved = $this->core->runs->checkpoint( $run_id, $phase, $checkpoint );
536 if ( is_wp_error( $saved ) ) {
537 return array( 'success' => false, 'error' => $saved->get_error_message() );
538 }
539
540 return array(
541 'success' => true,
542 'run_id' => $run_id,
543 'step' => $step,
544 'step_number' => $index + 1,
545 'total_steps' => count( $steps ),
546 'processed_in_this_call' => $processed,
547 'message' => $message,
548 'finished' => $finished,
549 'next_action' => $finished
550 ? 'Call wpmc_scan_publish with this run_id to make these results the published ones.'
551 : 'Call wpmc_scan_step again with the same run_id.',
552 'nothing_deleted' => true,
553 );
554 }
555
556 private function final_phase( $method ) {
557 if ( $method === 'files' ) return 'retrieveFiles_finished';
558 if ( $method === 'duplicates' ) return 'retrieveDuplicates_finished';
559 return 'retrieveMedia_finished';
560 }
561
562 private function step_retrieve_targets( $run, $offset, &$processed ) {
563 $engine = $this->core->engine;
564 $processed = 0;
565 if ( $run->method === 'media' ) {
566 $buffer = (int) $this->core->get_option( 'medias_buffer' );
567 $ids = $engine->get_media_entries( $offset, $buffer, $this->core->get_option( 'attach_is_use' ) );
568 $this->core->timeout_check_start( count( $ids ) );
569 foreach ( $ids as $media_id ) {
570 if ( $this->core->timeout_should_yield() ) break;
571 $engine->check_media( $media_id );
572 $this->core->timeout_check_additem();
573 $processed++;
574 }
575 $finished = count( $ids ) < $buffer && $processed === count( $ids );
576 return array( 'finished' => $finished, 'message' => sprintf( 'Checked %d media.', $processed ) );
577 }
578 if ( $run->method === 'duplicates' ) {
579 $buffer = min( 100, max( 1, (int) $this->core->get_option( 'analysis_buffer' ) ) );
580 $hashes = $engine->get_hash_duplicates( $offset, $buffer );
581 $this->core->timeout_check_start( count( $hashes ) );
582 foreach ( $hashes as $hash ) {
583 if ( $this->core->timeout_should_yield() ) break;
584 $engine->check_duplicates( $hash );
585 $this->core->timeout_check_additem();
586 $processed++;
587 }
588 $finished = count( $hashes ) < $buffer && $processed === count( $hashes );
589 return array( 'finished' => $finished, 'message' => sprintf( 'Checked %d duplicate groups.', $processed ) );
590 }
591 // Filesystem: the REST layer owns the directory queue, reuse it as is.
592 $rest = new Meow_WPMC_Rest( $this->core, $this->core->admin );
593 $request = new WP_REST_Request( 'POST', '/media-cleaner/v1/retrieve_files' );
594 $request->set_header( 'content-type', 'application/json' );
595 $request->set_body( wp_json_encode( array( 'runId' => (int) $run->id, 'initialize' => $offset === 0, 'root' => '' ) ) );
596 $response = $rest->rest_retrieve_files( $request );
597 $data = $response->get_data();
598 if ( empty( $data['success'] ) ) {
599 throw new RuntimeException( isset( $data['message'] ) ? $data['message'] : 'The filesystem scan failed.' );
600 }
601 $processed = isset( $data['data']['checked'] ) ? (int) $data['data']['checked'] : 0;
602 return array(
603 'finished' => !empty( $data['data']['finished'] ),
604 'message' => sprintf( 'Checked %d files.', $processed ),
605 );
606 }
607
608 private function tool_scan_publish( $args ) {
609 $run_id = isset( $args['run_id'] ) ? (int) $args['run_id'] : 0;
610 $runs = $this->core->runs;
611 $run = $runs->complete( $run_id );
612 if ( is_wp_error( $run ) ) {
613 return array(
614 'success' => false,
615 'error' => $run->get_error_message(),
616 'hint' => 'The scan is incomplete. Keep calling wpmc_scan_step until finished is true.',
617 );
618 }
619 $this->core->clear_run_context();
620 $counts = $this->get_counts();
621 return array(
622 'success' => true,
623 'run_id' => (int) $run->id,
624 'counts' => $counts,
625 'next_action' => 'Use wpmc_get_issues to read the results, and wpmc_explain_issue before acting on them.',
626 'warning' => self::SAFETY_NOTE,
627 );
628 }
629
630 private function tool_scan_cancel( $args ) {
631 $run_id = isset( $args['run_id'] ) ? (int) $args['run_id'] : 0;
632 $result = $this->core->runs->discard( $run_id );
633 if ( is_wp_error( $result ) ) {
634 return array( 'success' => false, 'error' => $result->get_error_message() );
635 }
636 $this->core->clear_run_context();
637 return array(
638 'success' => (bool) $result,
639 'run_id' => $run_id,
640 'message' => $result ? 'The staged scan was cancelled, the published results are untouched.' : 'This scan is no longer cancellable.',
641 );
642 }
643
644 #endregion
645
646 #region Results
647
648 private function tool_get_issues( $args ) {
649 global $wpdb;
650 $filter = isset( $args['filter'] ) ? sanitize_key( $args['filter'] ) : 'issues';
651 $filters = array(
652 'issues' => 'ignored = 0 AND deleted = 0',
653 'ignored' => 'ignored = 1',
654 'trash' => 'deleted = 1',
655 );
656 if ( !isset( $filters[ $filter ] ) ) {
657 $filter = 'issues';
658 }
659 $limit = isset( $args['limit'] ) ? max( 1, min( self::MAX_ITEMS, (int) $args['limit'] ) ) : 25;
660 $skip = isset( $args['skip'] ) ? max( 0, (int) $args['skip'] ) : 0;
661 $search = isset( $args['search'] ) ? sanitize_text_field( $args['search'] ) : '';
662 $table = $wpdb->prefix . 'mclean_scan';
663 $run_id = $this->core->get_run_id();
664 if ( $run_id < 1 ) {
665 return array( 'success' => false, 'error' => 'No scan has been published yet. Run a scan first.' );
666 }
667 $condition = $filters[ $filter ];
668 $search_sql = $search === '' ? '' : $wpdb->prepare( 'AND path LIKE %s', '%' . $wpdb->esc_like( $search ) . '%' );
669 $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE run_id = %d AND $condition $search_sql", $run_id ) );
670 $rows = $wpdb->get_results( $wpdb->prepare(
671 "SELECT id, type, postId, path, size, issue FROM $table
672 WHERE run_id = %d AND $condition $search_sql
673 ORDER BY size DESC LIMIT %d, %d",
674 $run_id, $skip, $limit
675 ) );
676
677 $items = array();
678 foreach ( $rows as $row ) {
679 $items[] = array(
680 'entry_id' => (int) $row->id,
681 'media_id' => $row->postId ? (int) $row->postId : null,
682 'path' => $row->path,
683 'size_bytes' => (int) $row->size,
684 'issue' => $row->issue,
685 'is_media_library_entry' => (int) $row->type === 1,
686 );
687 }
688 return array(
689 'success' => true,
690 'filter' => $filter,
691 'total' => $total,
692 'returned' => count( $items ),
693 'items' => $items,
694 'warning' => self::SAFETY_NOTE,
695 '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.',
696 );
697 }
698
699 private function tool_explain_issue( $args ) {
700 global $wpdb;
701 $entry_id = isset( $args['entry_id'] ) ? (int) $args['entry_id'] : 0;
702 $issue = $this->core->get_issue( $entry_id );
703 if ( !$issue ) {
704 return array( 'success' => false, 'error' => 'This entry does not exist in the published results.' );
705 }
706 $table_refs = $wpdb->prefix . 'mclean_refs';
707 $run_id = $this->core->get_run_id();
708 $paths = (int) $issue->type === 1 ? $this->core->get_paths_from_attachment( $issue->postId ) : array( $issue->path );
709 $found = array();
710 foreach ( array_slice( (array) $paths, 0, 20 ) as $path ) {
711 $rows = $wpdb->get_results( $wpdb->prepare(
712 "SELECT originType, origin FROM $table_refs WHERE run_id = %d AND mediaUrl = %s LIMIT 5",
713 $run_id, $path
714 ) );
715 foreach ( $rows as $row ) {
716 $found[] = array( 'path' => $path, 'origin_type' => $row->originType, 'origin' => $row->origin );
717 }
718 }
719 if ( $issue->postId ) {
720 $rows = $wpdb->get_results( $wpdb->prepare(
721 "SELECT originType, origin FROM $table_refs WHERE run_id = %d AND mediaId = %d LIMIT 5",
722 $run_id, (int) $issue->postId
723 ) );
724 foreach ( $rows as $row ) {
725 $found[] = array( 'media_id' => (int) $issue->postId, 'origin_type' => $row->originType, 'origin' => $row->origin );
726 }
727 }
728
729 $is_pro = $this->core->admin && $this->core->admin->is_pro_user();
730 $needs_pro = !$is_pro && class_exists( 'Meow_WPMC_Support' ) ? Meow_WPMC_Support::get_issues() : array();
731 $shortcodes_off = (bool) $this->core->get_option( 'shortcodes_disabled' );
732 $verify = array();
733 if ( !empty( $needs_pro ) ) {
734 $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.';
735 }
736 if ( $shortcodes_off ) {
737 $verify[] = 'Shortcode analysis is disabled, so a gallery or slider using this media would not have been seen.';
738 }
739 $verify[] = 'Search the media file name in the theme files, the custom CSS and the custom code of the site.';
740 $verify[] = 'Open the media in the Media Library and look at the "Used in" information.';
741 $verify[] = 'Media Cleaner only knows the plugins it has a parser for. If this site uses anything else to display media, check there too.';
742
743 return array(
744 'success' => true,
745 'entry_id' => $entry_id,
746 'path' => $issue->path,
747 'media_id' => $issue->postId ? (int) $issue->postId : null,
748 'issue' => $issue->issue,
749 'issue_meaning' => $this->explain_issue_code( $issue->issue ),
750 'references_found' => $found,
751 'why_it_is_reported' => empty( $found )
752 ? 'No reference to this media was found in anything Media Cleaner was able to read.'
753 : 'Some references exist but did not protect this entry: read them, this result is suspicious and should not be deleted without checking.',
754 'confidence' => empty( $found ) && empty( $needs_pro ) && !$shortcodes_off ? 'reasonable' : 'low',
755 'confidence_meaning' => 'reasonable means nothing contradicts the result, it is never a proof. low means a known blind spot could explain it.',
756 'verify_manually' => $verify,
757 'warning' => self::SAFETY_NOTE,
758 );
759 }
760
761 private function explain_issue_code( $code ) {
762 $codes = array(
763 'NO_CONTENT' => 'No reference was found in the content that was analyzed. It does not mean the file is unused.',
764 'ORPHAN_MEDIA' => 'This Media Library entry is not attached to any post.',
765 'ORPHAN_FILE' => 'This file is in the uploads folder but not in the Media Library.',
766 'ORPHAN_RETINA' => 'This is a retina file whose original is missing.',
767 'ORPHAN_WEBP' => 'This is a WebP file whose original is missing.',
768 'DUPLICATE' => 'Another file has exactly the same content. One copy is always kept.',
769 'NOT_NEEDED_THUMB' => 'This thumbnail size is not registered by the theme or WordPress anymore.',
770 );
771 return isset( $codes[ $code ] ) ? $codes[ $code ] : $code;
772 }
773
774 #endregion
775
776 #region Cleanup
777
778 private function read_entry_ids( $args ) {
779 $ids = isset( $args['entry_ids'] ) ? (array) $args['entry_ids'] : array();
780 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
781 if ( empty( $ids ) ) {
782 throw new RuntimeException( 'entry_ids is required and must contain at least one valid id.' );
783 }
784 if ( count( $ids ) > self::MAX_ITEMS ) {
785 throw new RuntimeException( sprintf( 'Too many items: %d. Send %d at most per call.', count( $ids ), self::MAX_ITEMS ) );
786 }
787 return $ids;
788 }
789
790 private function assert_cleanup_allowed() {
791 if ( !$this->core->can_cleanup() ) {
792 throw new RuntimeException( 'Cleanup is locked: it needs a published scan, and no other scan staged. Check wpmc_get_status.' );
793 }
794 }
795
796 private function tool_ignore( $args ) {
797 $ids = $this->read_entry_ids( $args );
798 $this->assert_cleanup_allowed();
799 $ignore = isset( $args['ignore'] ) ? rest_sanitize_boolean( $args['ignore'] ) : true;
800 $results = array();
801 $done = 0;
802 foreach ( $ids as $id ) {
803 $result = $this->core->ignore( $id, $ignore );
804 $ok = !is_wp_error( $result ) && $result === true;
805 if ( $ok ) $done++;
806 $results[] = array( 'entry_id' => $id, 'success' => $ok, 'error' => is_wp_error( $result ) ? $result->get_error_message() : null );
807 }
808 return array(
809 'success' => $done === count( $ids ),
810 'ignored' => $ignore,
811 'succeeded' => $done,
812 'failed' => count( $ids ) - $done,
813 'results' => $results,
814 'nothing_deleted' => true,
815 );
816 }
817
818 private function tool_operate( $args, $operation ) {
819 $ids = $this->read_entry_ids( $args );
820 $this->assert_cleanup_allowed();
821 $results = array();
822 $done = 0;
823 foreach ( $ids as $id ) {
824 $result = $operation === 'trash' ? $this->core->delete( $id ) : $this->core->recover( $id );
825 $ok = !is_wp_error( $result ) && $result === true;
826 if ( $ok ) $done++;
827 $results[] = array( 'entry_id' => $id, 'success' => $ok, 'error' => is_wp_error( $result ) ? $result->get_error_message() : null );
828 }
829 return array(
830 'success' => $done === count( $ids ),
831 'operation' => $operation,
832 'succeeded' => $done,
833 'failed' => count( $ids ) - $done,
834 'results' => $results,
835 'reversible' => true,
836 'how_to_undo' => $operation === 'trash'
837 ? 'These files are in the Media Cleaner trash. wpmc_recover restores them at any time, as long as the trash is not emptied.'
838 : 'These files are back in place.',
839 );
840 }
841
842 private function tool_delete_permanently( $args ) {
843 $confirm = isset( $args['confirm'] ) ? (string) $args['confirm'] : '';
844 if ( $confirm !== 'PERMANENT' ) {
845 return array(
846 'success' => false,
847 'error' => 'Permanent deletion refused: confirm must be exactly PERMANENT, and only after the user explicitly asked for it, knowing the files cannot be restored.',
848 'safer_alternative' => 'Use wpmc_trash instead, it is reversible.',
849 );
850 }
851 $ids = $this->read_entry_ids( $args );
852 $this->assert_cleanup_allowed();
853 // core->delete() moves an untouched entry to the trash, and erases it when it
854 // is already trashed, so it is called until the entry is really gone. With the
855 // "skip trash" setting the very first call already erases it.
856 $results = array();
857 $done = 0;
858 foreach ( $ids as $id ) {
859 $issue = $this->core->get_issue( $id );
860 if ( !$issue ) {
861 $results[] = array( 'entry_id' => $id, 'success' => false, 'error' => 'This entry no longer exists.' );
862 continue;
863 }
864 $error = null;
865 for ( $pass = 0; $pass < 2; $pass++ ) {
866 $result = $this->core->delete( $id );
867 if ( is_wp_error( $result ) || $result !== true ) {
868 $error = is_wp_error( $result ) ? $result->get_error_message() : 'The entry could not be deleted.';
869 break;
870 }
871 // The entry row is removed by a permanent deletion, which is the proof it is gone.
872 if ( !$this->core->get_issue( $id ) ) {
873 $error = null;
874 break;
875 }
876 $error = 'The entry is still present after the deletion.';
877 }
878 $ok = $error === null;
879 if ( $ok ) $done++;
880 $results[] = array( 'entry_id' => $id, 'success' => $ok, 'error' => $error );
881 }
882 return array(
883 'success' => $done === count( $ids ),
884 'permanently_deleted' => $done,
885 'failed' => count( $ids ) - $done,
886 'results' => $results,
887 'reversible' => false,
888 'warning' => 'These files are gone. Only a backup can bring them back.',
889 );
890 }
891
892 #endregion
893 }
894