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 |