= 2`, never the plugin version. */ define('WPBR_ENGINE_SERVICES', 2); /** * Is the `actor` column in place on THIS site (DB 3.1)? The version option is bumped only after the columns are verified, so the option is * the whole answer; a site that could not add them reads false and records no actor, exactly as before. */ function wpbr_actor_available() { return version_compare((string) get_option('wpbr_db_version', '0'), '3.1', '>='); } /** * Who is acting right now when it is not simply the logged-in person: 'assistant' (the built-in Assistant), 'agent' (a connected app), * or '' for a person at the keyboard. Set by an add-on around the work it does on someone's behalf; it NEVER replaces the person who * asked: `created_by` keeps recording them. * * @param string|null $set A new actor ('' to clear), or null to only read. * @return string The actor in force after the call. */ function wpbr_acting_as($set = null) { static $actor = ''; if (null !== $set) { $actor = substr(sanitize_key((string) $set), 0, 40); // the column holds 40 characters } return $actor; } /** * Run a callback as an actor and put the previous one back, whatever happens. * * @param string $actor 'assistant', 'agent' or ''. * @param callable $callback The work. * @return mixed What the callback returns. */ function wpbr_as_actor($actor, $callback) { $before = wpbr_acting_as(); wpbr_acting_as($actor); try { return call_user_func($callback); } finally { wpbr_acting_as($before); } } /** What a person reads for an actor: "Assistant", "Connected app", or '' for a person. */ function wpbr_actor_label($actor) { switch ((string) $actor) { case 'assistant': return __('Assistant', 'wpbr-bulk-replace'); case 'agent': return __('Connected app', 'wpbr-bulk-replace'); default: return ''; } } /** * Create a batch. * * A post type that does not exist drops BOTH the type and the key: a key means nothing without the type it was chosen for. * * @param string $label Name shown for the batch. * @param string $post_type Post type the files belong to, or ''. * @param string $meta_key Where the file reference lives (one or more locations, comma separated), or ''. * @param int $user_id Who is recorded as the creator. * @return int The new batch id. */ function wpbr_create_batch($label, $post_type = '', $meta_key = '', $user_id = 0) { global $wpdb; $label = sanitize_text_field((string) $label); $post_type = sanitize_key((string) $post_type); $meta_key = sanitize_text_field((string) $meta_key); if ($post_type === '' || !post_type_exists($post_type)) { $post_type = ''; $meta_key = ''; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin-owned table with no WordPress API; an insert, so there is no cached read to invalidate. $wpdb->insert(wpbr_table_batches(), array( 'label' => $label, 'post_type' => $post_type !== '' ? $post_type : null, 'meta_key_name' => $meta_key !== '' ? $meta_key : null, 'status' => 'staged', 'source_path' => wpbr_staging_dir(), 'created_at' => current_time('mysql'), 'created_by' => (int) $user_id, ) + (wpbr_actor_available() && wpbr_acting_as() !== '' ? array('actor' => wpbr_acting_as()) : array())); return (int) $wpdb->insert_id; } /** * Register a file already sitting in the staging folder as a row of a batch. * * The caller has judged and stored the file (wpbr_receive_upload); this only records it. Checking that the batch exists comes BEFORE the * file is accepted (wpbr_stage_file), because a row against a batch that is not there is invisible on every screen. * * @param int $batch_id Batch id. * @param string $filename The stored basename. * @param string $path The stored absolute path. * @return int The new row id. */ function wpbr_add_staged_item($batch_id, $filename, $path) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin-owned table with no WordPress API; an insert, so there is no cached read to invalidate. $wpdb->insert(wpbr_table_items(), array( 'batch_id' => (int) $batch_id, 'staged_filename' => $filename, 'staged_path' => $path, 'status' => 'staged', )); return (int) $wpdb->insert_id; } /** * Accept one file into a batch: the batch must exist, the file is judged and stored by wpbr_receive_upload(), then registered. * * @param int $batch_id Batch id. * @param mixed $file An entry shaped like one of $_FILES (name, tmp_name, error, size). * @param bool $sideload True when the file did not arrive over HTTP (see wpbr_stage_local_file()). * @return array `ok` true with `file`, `original`, `path` and `item_id`; or `ok` false with `code` (batch_missing | rejected) and a worded `error`. */ function wpbr_stage_file($batch_id, $file, $sideload = false) { // A caller that is not an admin screen (a REST route, a cron event) has not loaded the file helpers the upload judgement uses. require_once ABSPATH . 'wp-admin/includes/file.php'; if (!wpbr_get_batch($batch_id)) { return array('ok' => false, 'code' => 'batch_missing', 'error' => __('That batch no longer exists.', 'wpbr-bulk-replace')); } $received = wpbr_receive_upload($file, wpbr_staging_dir(), $sideload); if (!$received['ok']) { $received['code'] = 'rejected'; return $received; } $received['item_id'] = wpbr_add_staged_item($batch_id, $received['file'], $received['path']); return $received; } /** * Accept a file that is already on this server (an inbox folder, a media-library file) into a batch, exactly as an upload would be. * * Same judgement as an upload (allowed extension, contents agree with it, staging folder not readable from the web, a name nobody is * using), but the file is COPIED: the source is never moved, changed or deleted. A symbolic link, a directory or an unreadable path is * refused, so a link cannot smuggle another file into staging. * * @param int $batch_id Batch id. * @param string $source_path Absolute path of the file to copy. * @param string $name Name to judge and store it under; defaults to the source's basename. * @return array As wpbr_stage_file(), plus `code` 'source_unreadable' when the source is not a plain readable file. */ function wpbr_stage_local_file($batch_id, $source_path, $name = '') { require_once ABSPATH . 'wp-admin/includes/file.php'; // wp_tempnam(), which a REST route or a cron event has not loaded. $source_path = (string) $source_path; if ($source_path === '' || is_link($source_path) || !is_file($source_path) || !is_readable($source_path)) { return array('ok' => false, 'code' => 'source_unreadable', 'error' => __('Upload failed for this file.', 'wpbr-bulk-replace')); } $copy = wp_tempnam(basename($source_path)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- A byte copy of a plain file we just vetted, into a temporary file this function owns; WP_Filesystem has nothing to add here. if (!$copy || !copy($source_path, $copy)) { return array('ok' => false, 'code' => 'source_unreadable', 'error' => __('Upload failed for this file.', 'wpbr-bulk-replace')); } $result = wpbr_stage_file($batch_id, array( 'name' => $name !== '' ? $name : basename($source_path), 'tmp_name' => $copy, 'error' => UPLOAD_ERR_OK, 'size' => (int) filesize($copy), ), true); if (is_file($copy)) { wp_delete_file($copy); } return $result; } /** * Record, for one row, the match a person (or a program acting for one) chose, with the other review-form fields. * * THE BLOCK THAT USED TO BE INSIDE THE REVIEW FORM'S LOOP, unchanged: a file waiting for attention is not edited; a changed match becomes * a hand decision (`match_kind` manual, confidence cleared) so a later re-match cannot overwrite it; the review status goes through * wpbr_review_status() so a form cannot claim a row is already replaced; and the wpbr_match_confirmed / wpbr_match_cleared events fire * only for a real change. * * @param int $batch_id Batch id (the row must belong to it). * @param int $item_id Row id. * @param int|string|null $post_id The post chosen, or ''/null for no match. * @param array $fields Optional: `status` (submitted review status), `rename_to`, `rename_template` (applied when a post is chosen), * `notes`, `manual_pick` (true when the person placed it by hand even if the post did not change). * @param array $ctx Optional prefetched context for loops: `row` (id, staged_filename, matched_post_id, status) and `batch`. * @return array|false `array('changed' => bool)` when the row was updated; false when it is not a row of this batch or needs attention. */ function wpbr_set_row_match($batch_id, $item_id, $post_id, array $fields = array(), array $ctx = array()) { global $wpdb; $batch_id = (int) $batch_id; $item_id = (int) $item_id; $row = isset($ctx['row']) ? $ctx['row'] : null; if (!$row) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; this read decides what the next write does, so it is never cached (docs/PLUGIN-CHECK.md). $row = $wpdb->get_row($wpdb->prepare("SELECT id, staged_filename, matched_post_id, status FROM {$wpdb->prefix}wpbr_batch_items WHERE id = %d AND batch_id = %d", $item_id, $batch_id)); } if (!$row) return false; // A file waiting for attention is not edited from the review form: not its match, its name, its notes or its status. if ($row->status === 'needs_attention') return false; $batch_row = array_key_exists('batch', $ctx) ? $ctx['batch'] : wpbr_get_batch($batch_id); $post_id = ($post_id !== '' && $post_id !== null) ? intval($post_id) : null; $post_title = $post_id ? wpbr_clean_title(get_the_title($post_id)) : null; $rename_to = sanitize_file_name(isset($fields['rename_to']) ? (string) $fields['rename_to'] : ''); $template = isset($fields['rename_template']) ? trim((string) $fields['rename_template']) : ''; if ($template && $post_id && $row->staged_filename !== null) { $original = $row->staged_filename; $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION)); $rename_to = wpbr_apply_rename_template($template, $post_id, $original, $ext); } // intval() on both sides: a NULL prior (no match found) and a cleared post compare as 0 and 0, which is "unchanged". $changed = (intval($row->matched_post_id) !== intval($post_id)) || !empty($fields['manual_pick']); $row_update = array( 'matched_post_id' => $post_id, 'matched_post_title' => $post_title, 'status' => wpbr_review_status( isset($fields['status']) ? (string) $fields['status'] : 'needs_review', $row->status ), 'rename_to' => $rename_to, 'notes' => isset($fields['notes']) ? sanitize_text_field((string) $fields['notes']) : '', ); if ($changed) { // BOTH COLUMNS, AND THE SECOND ONE IS NOT COSMETIC. A row that had been matched by identifier and is then reassigned by a // person would otherwise keep `match_kind = exact`: the screen would call somebody's own decision an exact identifier, and // the re-match loop excludes `exact` from its hand-set protection, so the next run would overwrite the decision it was // meant to keep. $row_update['confidence_score'] = null; $row_update['match_kind'] = WPBR_MATCH_MANUAL; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->update(wpbr_table_items(), $row_update, array('id' => $item_id, 'batch_id' => $batch_id)); if ($changed && $batch_row && $batch_row->post_type && $row->staged_filename !== null) { if ($post_id) { /** Fires when somebody confirms a match by hand. Free does nothing with it; an add-on that remembers decisions listens. @since 1.0.0 */ do_action('wpbr_match_confirmed', $row->staged_filename, $post_id, $batch_row->post_type); } else { /** Fires when somebody clears a match by hand: the other half of wpbr_match_confirmed. @since 1.0.0 */ do_action('wpbr_match_cleared', $row->staged_filename, $batch_row->post_type); } } return array('changed' => $changed); } /** * Confirm that a row whose post has no file yet may be attached as a NEW file. * * @param int $item_id Row id. * @return int|WP_Error Rows changed (0 for a row that does not exist: nothing to do, as before), or an error `needs_attention`. */ function wpbr_confirm_new_attach($item_id) { global $wpdb; $item_id = (int) $item_id; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; this read decides whether the write is allowed, so it is never cached. $current = $wpdb->get_var($wpdb->prepare("SELECT status FROM {$wpdb->prefix}wpbr_batch_items WHERE id = %d", $item_id)); if ($current === 'needs_attention') { return new WP_Error('needs_attention', __('That file needs attention first.', 'wpbr-bulk-replace')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $changed = $wpdb->update(wpbr_table_items(), array('status' => 'confirmed_new_attach'), array('id' => $item_id)); return $changed === false ? 0 : (int) $changed; } /** * What the engine would decide for one row, WITHOUT writing anything. * * READ-ONLY, and built from the SAME predicates wpbr_process_one_item() calls, in the SAME order, so the description cannot drift from * the engine: a test (tests/integration/DescribeRowAgreementTest.php) runs the real engine over a matrix of rows and compares every * outcome with this description. A change to the engine's order of checks that is not made here fails that test. * * It decides only what can be known before writing. What can only be known while writing is listed in `write_time_checks` and is * decided by the engine at that moment: the privacy gate for a new backup, free space, the per-file lock and the read-back of the * written bytes. * * `fingerprint` holds the facts a caller can hash to notice that the world changed since it looked. It contains server paths; a caller * that shows results to people or programs must not show them. * * @param object $item The batch row. * @param object $batch The batch. * @param array $all_items Every row of the batch (the engine passes them; needed to tell sharing inside the batch from outside). * @param array $shared_paths Destination paths shared inside the batch (wpbr_detect_collisions()['shared_paths']). * @return array { * @type string $decision refused | skipped | overwrite | rename | repair_missing | attach_new | unshare_attach | adapter | already_in_place | recover_pending * @type string $code An outcome code from wpbr_outcome_codes() for refused/skipped/already_in_place, else ''. * @type string $result success | skipped | failed | '' - the class the engine will report for a refusal, skip or no-op. * @type array $reference location and value of the live reference ('' when none). * @type array $sharers_outside Post id => field for pages outside the batch that share the file. * @type string[] $write_time_checks What only the engine can decide, while writing. * @type array $fingerprint Facts about the row and its files, for change detection. * } */ function wpbr_describe_row($item, $batch, array $all_items = array(), array $shared_paths = array()) { $codes = wpbr_outcome_codes(); $out = array( 'decision' => '', 'code' => '', 'result' => '', 'reference' => array('location' => '', 'value' => ''), 'sharers_outside' => array(), 'write_time_checks' => array('backup_privacy_gate', 'backup_room', 'file_lock', 'verified_write'), 'fingerprint' => array( 'item_id' => (int) $item->id, 'post_id' => (int) $item->matched_post_id, 'reference' => '', 'live_path' => '', 'live_exists' => false, 'live_size' => null, 'live_mtime' => null, 'staged_path' => (string) $item->staged_path, 'staged_exists' => false, 'staged_size' => null, 'sharers_outside' => array(), 'adapter' => '', 'rename_to' => (string) $item->rename_to, 'status' => (string) $item->status, ), ); $stop = function ($decision, $code = '') use (&$out, $codes) { $out['decision'] = $decision; $out['code'] = $code; $out['result'] = ($code !== '' && isset($codes[$code])) ? $codes[$code] : ''; return $out; }; if (!$item->matched_post_id) { return $stop('skipped', 'skipped_no_match'); } $meta_keys = wpbr_reference_locations($batch); $current = wpbr_reference_current($batch, $item->matched_post_id); $file_url = $current['value']; $out['reference'] = array('location' => (string) $current['location'], 'value' => (string) $file_url); $out['fingerprint']['reference'] = (string) $file_url; $out['fingerprint']['staged_exists'] = file_exists($item->staged_path); if ($out['fingerprint']['staged_exists']) { $out['fingerprint']['staged_size'] = (int) @filesize($item->staged_path); } if (!$file_url) { if ($item->status !== 'confirmed_new_attach') { return $stop('skipped', 'skipped_no_existing_file'); } return $stop('attach_new'); } $shared_target = wpbr_url_to_upload_path($file_url); if ($shared_target) { $outside = wpbr_outside_batch_sharers($meta_keys, $shared_target, $item->matched_post_id, $all_items); if ($outside) { $out['sharers_outside'] = $outside; $out['fingerprint']['sharers_outside'] = $outside; return $stop('refused', 'refused_shared_with_outside_page'); } } if ($all_items && $shared_paths && wpbr_destination_shared_with_another_post($item, $batch, $shared_paths)) { return $stop('unshare_attach'); } $live_path = wpbr_url_to_upload_path($file_url); if (!$live_path) { return $stop('skipped', 'skipped_path_unresolvable'); } $out['fingerprint']['live_path'] = (string) $live_path; if (wpbr_ref_is_stream_uri($live_path)) { $adapter = wpbr_storage_adapter_for($live_path); $out['fingerprint']['adapter'] = $adapter ? (string) $adapter->id() : ''; return $stop('adapter'); } if (!wpbr_path_is_in_uploads($live_path)) { return $stop('skipped', 'skipped_outside_uploads'); } if (wpbr_live_path_is_held($live_path)) { return $stop('refused', 'blocked_needs_attention'); } if (!$out['fingerprint']['staged_exists']) { return $stop('skipped', 'skipped_staged_missing'); } $out['fingerprint']['live_exists'] = file_exists($live_path); if ($out['fingerprint']['live_exists']) { $out['fingerprint']['live_size'] = (int) @filesize($live_path); $out['fingerprint']['live_mtime'] = (int) @filemtime($live_path); } else { if (wpbr_attachment_id_for_path($live_path, $file_url)) { return $stop('refused', 'blocked_offloaded'); } return $stop('repair_missing'); } if (!wpbr_can_overwrite_safely($live_path)) { return $stop('refused', 'blocked_directory_not_writable'); } $backup_path = ($item->backup_path && file_exists($item->backup_path)) ? $item->backup_path : ''; if ($backup_path && !empty($item->original_sha256) && wpbr_file_sha256($backup_path) !== $item->original_sha256) { return $stop('refused', 'blocked_backup_changed'); } $adapter = wpbr_storage_adapter_for($live_path); if (!$adapter) { return $stop('refused', 'blocked_unsupported_storage'); } $out['fingerprint']['adapter'] = (string) $adapter->id(); if (wpbr_intent_pending($item)) { return $stop('recover_pending'); } if (!$backup_path && wpbr_files_are_identical($live_path, $item->staged_path)) { return $stop('already_in_place', 'replaced_already_in_place'); } $desired = $item->rename_to ? sanitize_file_name($item->rename_to) : ''; return $stop(($desired && $desired !== basename($live_path)) ? 'rename' : 'overwrite'); } /** * What a matching run decides for ONE filename: the score, the learned-match fallback and the row fields that follow. Pure: reads * posts and meta, writes nothing, which is what lets a preview and the real run share it. * * @param string $filename The staged filename. * @param array $candidates wpbr_get_post_candidates() for the post type and locations. * @param string $post_type The post type matched against. * @param string[] $meta_keys The locations that hold the files. * @param object|null $remembered A previously confirmed match (`matched_post_id`) from the wpbr_remembered_match filter, or null. * @return array `status` (matched|needs_review), `update` (the row fields to store, without status and notes), `match` (the scorer's * result, or the remembered one), `from_memory` (true when a remembered decision supplied it). */ function wpbr_decide_match($filename, array $candidates, $post_type, $meta_keys, $remembered = null) { $match = wpbr_match_file($filename, $candidates, $post_type); $from_memory = false; $decision = wpbr_match_row_update($match); if (!$decision['settled'] && $remembered) { $old_filename = ''; $learned_live = wpbr_reference_current(wpbr_reference_context($meta_keys), $remembered->matched_post_id); if ($learned_live['found']) { $old_filename = basename(wp_parse_url($learned_live['value'], PHP_URL_PATH)); } $match = array( 'post_id' => $remembered->matched_post_id, 'post_title' => wpbr_clean_title(get_the_title($remembered->matched_post_id)), 'score' => null, 'rename_to' => $old_filename ? $old_filename : null, 'kind' => WPBR_MATCH_REMEMBERED, 'identifier' => '', 'rival_ids' => array(), ); $from_memory = true; } if ($from_memory) { return array( 'status' => 'matched', 'update' => array( 'matched_post_id' => $match['post_id'], 'matched_post_title' => $match['post_title'], 'confidence_score' => null, 'match_kind' => WPBR_MATCH_REMEMBERED, 'rename_to' => $match['rename_to'], ), 'match' => $match, 'from_memory' => true, ); } return array( 'status' => $decision['settled'] ? 'matched' : 'needs_review', 'update' => $decision['update'], 'match' => $match, 'from_memory' => false, ); } /** * Match every open row of a batch against the posts of a post type: the matching run, without the admin screen around it. * * Rows a person already decided are left alone (re-running is safe), a remembered decision is never overwritten by a fresh score, * skipped and replaced rows are not touched. Writes the row decisions and one `run_matching` log line; prints nothing. * * @param int $batch_id The batch. * @param string $post_type The post type to match against. * @param string[] $meta_keys The meta fields that hold the files. * @param string[]|null $statuses Row statuses to look at (staged, matched, needs_review); null for all three, as the Match step does. * @return array{matched:int, review:int, learned:int, protected:int} Counts for this run. */ function wpbr_match_batch_items($batch_id, $post_type, $meta_keys, $statuses = null) { global $wpdb; // Which rows a run looks at. The Match step scores every open row; a caller that must leave rows that already have a decision alone asks for // 'staged' only. $allowed = array('staged', 'matched', 'needs_review'); $statuses = is_array($statuses) ? array_values(array_intersect($allowed, $statuses)) : $allowed; if (!$statuses) $statuses = array('staged'); $status_placeholders = implode(', ', array_fill(0, count($statuses), '%s')); $items_table = wpbr_table_items(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; this read decides what the next write does, so it is never cached (docs/PLUGIN-CHECK.md). $items = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d AND status IN ($status_placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The placeholders are generated from the number of allowed statuses; all status values are separately bound below. array_merge(array($batch_id), $statuses) )); $candidates = wpbr_get_post_candidates($post_type, $meta_keys); $threshold = wpbr_confidence_threshold(); $matched_count = 0; $review_count = 0; $learned_count = 0; $protected_count = 0; foreach ($items as $item) { /** * A previously confirmed match for this filename, if anything knows one. * * Free never does: remembering corrections is Pro's layer. The * seam stays here because the behaviour around it is Free's - a * remembered decision must not be overwritten by a fresh score, and * that rule belongs with the matching run rather than with the add-on. * * @since 1.0.0 * @param object|null $remembered Row-like object with matched_post_id, or null. * @param string $filename The staged filename being matched. * @param string $post_type The post type being matched against. */ $remembered = apply_filters('wpbr_remembered_match', null, $item->staged_filename, $post_type); // A row somebody set by hand, and Free's own record that they did: // every path that confirms or corrects a match clears the confidence // score, so a row holding a match with no score was decided by a // person rather than by the scorer. The promise that re-running is // safe is made in four places, readme.txt included, and it has to hold // with nothing installed but this plugin - which is why it is read off // the row here rather than off anything remembering the decision. // AN EXACT MATCH IS ALSO SCORELESS, so "no score" stopped being enough // to mean "a person did this". Without the kind check every identifier // match would be counted as protected and skipped on the next run, // and the "N protected" line would be telling the reader something // untrue about their own decisions. $hand_set = $item->matched_post_id && null === $item->confidence_score && !in_array(isset($item->match_kind) ? $item->match_kind : '', array(WPBR_MATCH_EXACT, WPBR_MATCH_REMEMBERED), true); if ($hand_set || ($remembered && $item->matched_post_id && intval($item->matched_post_id) === intval($remembered->matched_post_id))) { $protected_count++; continue; } $decided = wpbr_decide_match($item->staged_filename, $candidates, $post_type, $meta_keys, $remembered); $match = $decided['match']; $from_memory = $decided['from_memory']; $status = $decided['status']; $update = $decided['update']; if ($from_memory) $learned_count++; if ($status === 'matched' && !$from_memory) $matched_count++; elseif (!$from_memory) $review_count++; $update['status'] = $status; if ($from_memory) { $update['notes'] = trim(($item->notes ? $item->notes.' | ' : '').__('Matched from a previous manual decision (learned mapping)', 'wpbr-bulk-replace')); } elseif (isset($match['kind']) && $match['kind'] === WPBR_MATCH_AMBIGUOUS) { $update['notes'] = trim(($item->notes ? $item->notes.' | ' : '').wpbr_ambiguous_match_note($match)); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->update($items_table, $update, array('id' => $item->id)); } wpbr_log($batch_id, null, null, 'run_matching', 'info', sprintf( /* translators: 1: number matched, 2: number needing review, 3: number matched from remembered decisions, 4: number left untouched. */ __('%1$s matched, %2$s need review, %3$s from memory, %4$s protected', 'wpbr-bulk-replace'), number_format_i18n($matched_count), number_format_i18n($review_count), number_format_i18n($learned_count), number_format_i18n($protected_count) )); return array( 'matched' => $matched_count, 'review' => $review_count, 'learned' => $learned_count, 'protected' => $protected_count, ); } /** * Run matching for a batch that already knows its post type and file locations. * * The same run the Match step starts: open rows are matched, rows a person decided are left alone, nothing is replaced. The batch * must have its post type and locations (wpbr_create_batch, or set on the Match step) because a match means nothing without them. * * @param int $batch_id The batch. * @param string[]|null $statuses Row statuses to match (default all open rows, as the Match step); pass array('staged') to leave rows that already have a decision untouched. * @return array|WP_Error Counts `matched`, `review`, `learned`, `protected`, or an error `not_found` / `needs_match_setup`. */ function wpbr_start_matching($batch_id, $statuses = null) { $batch = wpbr_get_batch((int) $batch_id); if (!$batch) { return new WP_Error('not_found', __('That batch does not exist.', 'wpbr-bulk-replace')); } if (!$batch->post_type || !$batch->meta_key_name || !post_type_exists($batch->post_type)) { return new WP_Error('needs_match_setup', __('This batch has no matching configuration yet. Set the post type and meta field first.', 'wpbr-bulk-replace')); } return wpbr_match_batch_items((int) $batch_id, $batch->post_type, wpbr_split_meta_keys($batch->meta_key_name), $statuses); } /** * What matching decided for each row of a batch, for someone who must review it. READ-ONLY. * * One entry per row, in row order, with the facts a reviewer needs and no server paths. `decided_by` says who made the call: * `scorer` (a similar match with a score), `exact` (an identifier matched), `remembered` (a previous manual decision), * `person` (matched with no score and not exact or remembered: set by hand) or '' (nothing decided: no match yet, or a weak suggestion * still waiting for review; its post and score are listed so a reviewer can judge it). * * @param int $batch_id The batch. * @return array|WP_Error `rows` (each: item_id, filename, status, post_id, post_title, score, match_kind, decided_by, rename_to, notes) and * `counts` per status, or an error `not_found`. */ function wpbr_get_match_review($batch_id) { $batch = wpbr_get_batch((int) $batch_id); if (!$batch) { return new WP_Error('not_found', __('That batch does not exist.', 'wpbr-bulk-replace')); } $rows = array(); $counts = array(); foreach (wpbr_get_batch_items((int) $batch_id) as $item) { $kind = isset($item->match_kind) ? (string) $item->match_kind : ''; if (!$item->matched_post_id) { $by = ''; } elseif ($kind === WPBR_MATCH_EXACT || $kind === WPBR_MATCH_REMEMBERED) { $by = $kind; } elseif (null === $item->confidence_score) { $by = 'person'; } else { $by = $item->status === 'needs_review' ? '' : 'scorer'; } $rows[] = array( 'item_id' => (int) $item->id, 'filename' => (string) $item->staged_filename, 'status' => (string) $item->status, 'post_id' => $item->matched_post_id ? (int) $item->matched_post_id : 0, 'post_title' => (string) $item->matched_post_title, 'score' => null === $item->confidence_score ? null : (float) $item->confidence_score, 'match_kind' => $kind, 'decided_by' => $by, 'rename_to' => (string) $item->rename_to, 'notes' => (string) $item->notes, ); $counts[$item->status] = isset($counts[$item->status]) ? $counts[$item->status] + 1 : 1; } return array('rows' => $rows, 'counts' => $counts); } /** * What matching WOULD decide for some filenames, without a batch and without writing anything. * * Uses the same decision as a run (wpbr_decide_match), including a remembered decision where an add-on knows one. Nothing is stored: * no batch, no row, no log line. The caller decides what to do with the answer. * * @param string $post_type The post type to match against. * @param string[] $meta_keys The locations that hold the files. * @param string[] $filenames Filenames to match (at most 100). * @return array|WP_Error A list in the order given, each: `filename`, `status` (matched | needs_review | no_match), `post_id` (0 for * none), `post_title`, `score` (null without one), `match_kind`, `decided_by` (scorer | exact | remembered | ''), * `note` (why a match is ambiguous, else ''); or an error `needs_match_setup` / `too_many`. */ function wpbr_preview_matches($post_type, $meta_keys, array $filenames) { $post_type = sanitize_key((string) $post_type); $meta_keys = array_values(array_filter(array_map('sanitize_text_field', (array) $meta_keys), 'strlen')); if ($post_type === '' || !post_type_exists($post_type) || !$meta_keys) { return new WP_Error('needs_match_setup', __('Choose a post type and the field that holds the file first.', 'wpbr-bulk-replace')); } if (count($filenames) > 100) { return new WP_Error('too_many', __('Match at most 100 files at a time.', 'wpbr-bulk-replace')); } $candidates = wpbr_get_post_candidates($post_type, $meta_keys); $out = array(); foreach ($filenames as $filename) { $filename = (string) $filename; // The seam is documented on wpbr_match_batch_items(); asked here with the same arguments. $remembered = apply_filters('wpbr_remembered_match', null, $filename, $post_type); $d = wpbr_decide_match($filename, $candidates, $post_type, $meta_keys, $remembered); $post_id = !empty($d['update']['matched_post_id']) ? (int) $d['update']['matched_post_id'] : 0; $kind = isset($d['update']['match_kind']) ? (string) $d['update']['match_kind'] : ''; $score = isset($d['update']['confidence_score']) && null !== $d['update']['confidence_score'] ? (float) $d['update']['confidence_score'] : null; $out[] = array( 'filename' => $filename, 'status' => !$post_id ? 'no_match' : $d['status'], 'post_id' => $post_id, 'post_title' => $post_id && isset($d['update']['matched_post_title']) ? (string) $d['update']['matched_post_title'] : '', 'score' => $score, 'match_kind' => $kind, 'decided_by' => !$post_id || $d['status'] !== 'matched' ? '' : ($d['from_memory'] ? 'remembered' : ($kind === WPBR_MATCH_EXACT ? 'exact' : 'scorer')), 'note' => isset($d['match']['kind']) && $d['match']['kind'] === WPBR_MATCH_AMBIGUOUS ? wpbr_ambiguous_match_note($d['match']) : '', ); } return $out; } /** * Record, on a batch, where its files live: the post type and the meta fields that hold them. What the Match step stores when it is run. * * Overwrites any earlier choice. The caller decides that the post type and fields are ones it may use (the Match step offers only real * ones; an add-on checks its own allowlist); the field list is checked here because a list the column cannot hold would be cut short. * * @param int $batch_id The batch. * @param string $post_type The post type. * @param string[] $meta_keys The meta fields (one or more). * @return true|WP_Error An error `not_found`, `empty_fields` or `fields_too_long`. */ function wpbr_set_batch_matching_config($batch_id, $post_type, $meta_keys) { global $wpdb; if (!wpbr_get_batch((int) $batch_id)) { return new WP_Error('not_found', __('That batch does not exist.', 'wpbr-bulk-replace')); } $problem = wpbr_meta_keys_problem($meta_keys); if ('empty' === $problem) { return new WP_Error('empty_fields', __('At least one meta field is required.', 'wpbr-bulk-replace')); } if ('too_long' === $problem) { return new WP_Error('fields_too_long', __('Those field names are too long to save together.', 'wpbr-bulk-replace')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->update(wpbr_table_batches(), array( 'post_type' => sanitize_key((string) $post_type), 'meta_key_name' => wpbr_join_meta_keys($meta_keys), ), array('id' => (int) $batch_id)); return true; } /** * The address of a batch in the normal admin, for a person to open it. A link only: opening it still needs the capability the screen * asks for, and nothing here checks it. * * @param int $batch_id The batch. * @param string $step The step to open: `review` (the default), `match`, `run` or '' for the batch's own page. * @return string */ function wpbr_batch_admin_url($batch_id, $step = 'review') { return $step === '' ? admin_url('admin.php?page=wpbr-batch-detail&batch_id=' . (int) $batch_id) : wpbr_step_url((int) $batch_id, $step); }