update()` returns false and says * nothing on the page, so the batch keeps whatever it held before while the * matching run that follows carries on with the list it was given. Nothing is * saved and nothing says so, which is worse than refusing. The length is * therefore checked before the save, and the person is told. */ function wpbr_meta_keys_max_length() { return 191; } /** * The field list a Match step submission carries, as the engine will use it. * * The chips (`meta_keys`) first, in the order they were arranged, then whatever * was typed into the box and not yet turned into a chip (`meta_key_pending`). * One split trims, drops blanks and removes repeats, so a name typed twice, or * typed and also already a chip, is one field. * * Takes the two strings rather than reading them: the handler that has just * verified the form's nonce reads the request, and this only combines. * * @param string $chips The chips, comma-separated. * @param string $pending Text typed into the box and not yet a chip. * @return string[] */ function wpbr_combine_meta_keys($chips, $pending) { return wpbr_split_meta_keys($chips . ',' . $pending); } /** * What the Match form was just sent, held for the screen that redraws it. * * The handler reads the request once, after its nonce has passed, and leaves * the values here; the Match step draws from this instead of reading the request * a second time, so a rejected submission is shown back as it was typed and the * screen never touches $_POST. Null when no Match form was submitted. * * @param array|null $submission post_type, chips and pending, to hold; omit to read. * @return array|null */ function wpbr_match_submission($submission = null) { static $held = null; if (func_num_args() > 0) { $held = $submission; } return $held; } /** * Why a field list cannot be saved: 'empty', 'too_long', or '' when it can. * * @param string[] $keys The list from wpbr_split_meta_keys(). */ function wpbr_meta_keys_problem($keys) { if (empty($keys)) { return 'empty'; } $joined = wpbr_join_meta_keys($keys); $length = function_exists('mb_strlen') ? mb_strlen($joined, 'UTF-8') : strlen($joined); return $length > wpbr_meta_keys_max_length() ? 'too_long' : ''; } function wpbr_clean_title($title) { return html_entity_decode((string) $title, ENT_QUOTES, 'UTF-8'); } /** * Meta field names used by posts of a type, for the pickers that offer them. * * This is the one read in the plugin that is cached, because it is the one where * a stale answer cannot do harm. It is a list of suggestions for a dropdown: the * person can still type any field, nothing validates against it, and nothing is * written because of it. It is also the costly one - a DISTINCT over every * postmeta row of a post type - and a screen asks it two or three times. * * The key carries the posts cache's own change marker, so saving or deleting a * post makes the next ask a fresh one; the five-minute lifetime bounds the rest * (a field added to existing posts without touching them). Without a persistent * object cache this is simply "once per request". * * @param string $post_type Post type name. * @param int $limit Most names to return. * @return string[] Meta keys, without WordPress' own private ones. */ function wpbr_get_meta_key_suggestions($post_type, $limit = 40) { global $wpdb; $limit = (int) $limit; $cache_key = 'meta_keys:' . md5($post_type . '|' . $limit) . ':' . wp_cache_get_last_changed('posts'); $found = wp_cache_get($cache_key, 'wpbr'); if (is_array($found)) { return $found; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Core's own tables have no API that lists the distinct meta keys of a post type; the result is cached just below. $keys = $wpdb->get_col($wpdb->prepare( "SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key NOT LIKE %s LIMIT %d", $post_type, $wpdb->esc_like('_') . '%', $limit )); $keys = is_array($keys) ? $keys : array(); wp_cache_set($cache_key, $keys, 'wpbr', 5 * MINUTE_IN_SECONDS); return $keys; } function wpbr_get_post_candidates($post_type, $meta_keys) { if (!is_array($meta_keys)) $meta_keys = wpbr_split_meta_keys($meta_keys); $candidates = array(); $posts = get_posts(array( 'post_type' => $post_type, 'post_status' => 'publish', 'numberposts' => -1, )); // CANDIDATE DISCOVERY GOES THROUGH THE SEAM, because "which file does this // post currently reference" is the same question here as it is in a run, and // a provider that answered it differently on the matching screen than during // a replacement would match one file and replace another. The context is // built from the locations because this function is handed those rather than // a batch row. $context = wpbr_reference_context($meta_keys); // IDENTIFIERS FOR THE WHOLE SET IN ONE ASK, not one ask per post. The // provider best placed to answer has a lookup table; handing it the posts // one at a time turns that into a query per product. $identifiers = wpbr_candidate_identifiers(wp_list_pluck($posts, 'ID'), $post_type); // AND LET EVERY PROVIDER WARM UP FOR THE WHOLE SET BEFORE THE LOOP. Without // this a provider storing attachment IDs pays a query per post to turn one // into a URL - measured at exactly that on a real WooCommerce catalogue. // It may change nothing but how long this takes; see wpbr_reference_prime(). wpbr_reference_prime($meta_keys, wp_list_pluck($posts, 'ID')); foreach ($posts as $post) { $file_url = wpbr_reference_current($context, $post->ID)['value']; $old_filename = $file_url ? basename(wp_parse_url($file_url, PHP_URL_PATH)) : ''; $candidates[] = array( 'id' => $post->ID, 'title' => wpbr_clean_title($post->post_title), 'old_file_url' => $file_url, 'old_filename' => $old_filename, 'permalink' => get_permalink($post->ID), 'identifiers' => isset($identifiers[$post->ID]) ? $identifiers[$post->ID] : array(), ); } return $candidates; } /* * ========================================================================= * Exact identifiers * * A SIMILARITY SCORE IS THE WRONG ANSWER TO A QUESTION THAT HAS A RIGHT ONE. * Everything above this block asks how much two pieces of TEXT look alike, and * for a title against a filename that is the only question available. But some * catalogues name the thing outright - a SKU, a part number, an ISBN, a course * code - and when the filename carries that identifier there is nothing to be * confident ABOUT. It either is that item or it is not. * * So an identifier hit is a distinct KIND of match rather than a score of 1.0, * and the difference reaches the review screen. A row reading "High confidence * (100%)" invites the reader to wonder what the other 0% was; "Exact * identifier" tells them there is nothing to check. It also has to be a * separate kind for the honest reason: a 100% textual score and a SKU hit are * not equally trustworthy, and storing them in the same column as the same * number says they are. * * FREE KNOWS NOTHING ABOUT WHERE AN IDENTIFIER COMES FROM. There is no SKU in * this file, no product, no commerce vocabulary of any kind. An add-on supplies * identifiers through `wpbr_candidate_identifiers`, and a site with nothing * installed supplies none and matches exactly as it did before. * * THE FILTER IS OVER THE WHOLE SET, NOT PER POST, and that is deliberate: a * per-post filter over a thousand-product catalogue is a thousand queries, and * the provider that would most like to answer this is the one with a lookup * table it can read in one. * ====================================================================== */ /** * Identifiers each candidate is known by. * * @since 1.0.0 * * @param int[] $post_ids Candidate post IDs. * @param string $post_type The post type being matched. * @return array post_id => string[] identifiers. Empty for anything unknown. */ function wpbr_candidate_identifiers($post_ids, $post_type = '') { $post_ids = array_values(array_unique(array_map('intval', (array) $post_ids))); if (!$post_ids) return array(); /** * Filter the identifiers candidates are known by. * * An identifier is an exact, opaque string that NAMES the thing - not a * description of it. Returning something fuzzy here does not make matching * cleverer, it makes a wrong answer look certain. * * @since 1.0.0 * * @param array $identifiers post_id => string[]. Start empty. * @param int[] $post_ids The candidates being matched, all at once. * @param string $post_type The post type being matched. */ $raw = apply_filters('wpbr_candidate_identifiers', array(), $post_ids, (string) $post_type); $out = array(); foreach ((array) $raw as $post_id => $identifiers) { $post_id = (int) $post_id; if (!in_array($post_id, $post_ids, true)) continue; $clean = array(); foreach ((array) $identifiers as $identifier) { if (!is_scalar($identifier)) continue; $identifier = trim((string) $identifier); if ($identifier === '') continue; $clean[] = $identifier; } if ($clean) $out[$post_id] = array_values(array_unique($clean)); } return $out; } /** * Whether a filename stem carries this identifier. * * DELIBERATELY STRICT, AND THE STRICTNESS IS THE FEATURE. The stem either IS * the identifier, or it begins with the identifier and then a separator - * `ABC-123.pdf` and `ABC-123_manual.pdf` both name ABC-123, and `ABC-1234.pdf` * does not name it at all. * * "Contains" was the obvious alternative and is wrong: an identifier of `12` * appears in `2012-annual-report`, and the whole point of this route is that it * reports certainty. A near-miss is not this function's business - it falls * through to similarity scoring, which is built to be unsure. * * CASE IS FOLDED AND NOTHING ELSE IS. SKUs are typed in capitals and filed in * lowercase constantly, so that difference is noise. Separators are NOT * normalised away: deciding that `ABC123` names `ABC-123` is a guess, and a * guess does not belong behind a label that says exact. * * @since 1.0.0 * * @param string $stem Filename with the extension already removed. * @param string $identifier The identifier to test. * @return bool */ function wpbr_identifier_names_file($stem, $identifier) { $stem = trim((string) $stem); $identifier = trim((string) $identifier); if ($stem === '' || $identifier === '') return false; $stem_cf = function_exists('mb_strtolower') ? mb_strtolower($stem, 'UTF-8') : strtolower($stem); $id_cf = function_exists('mb_strtolower') ? mb_strtolower($identifier, 'UTF-8') : strtolower($identifier); if ($stem_cf === $id_cf) return true; $len = strlen($id_cf); if (strlen($stem_cf) <= $len) return false; if (strncmp($stem_cf, $id_cf, $len) !== 0) return false; // What follows has to be a separator rather than more of a longer // identifier. Anything that is not a letter or a digit counts. return (bool) preg_match('/^[^a-z0-9]/', substr($stem_cf, $len)); } /** * Question the identifiers before scoring any text. * * AMBIGUITY REFUSES RATHER THAN PICKS. Two candidates carrying the same SKU is * a broken catalogue, not a close call, and every way of choosing between them * is a guess dressed as an answer - WooCommerce's own * `wc_get_product_id_by_sku()` does it with `LIMIT 1` and no `ORDER BY`, which * is exactly the behaviour this refuses to copy. The file goes to review with * the tie named, because the person reading it can see the catalogue and this * function cannot. * * @since 1.0.0 * * @param string $stem Filename with the extension removed. * @param array $candidates Candidate rows, each optionally with `identifiers`. * @return array|null A match result, or null when no identifier is involved. */ function wpbr_match_by_identifier($stem, $candidates) { $hits = array(); foreach ((array) $candidates as $c) { if (empty($c['identifiers'])) continue; foreach ((array) $c['identifiers'] as $identifier) { if (!wpbr_identifier_names_file($stem, $identifier)) continue; $hits[(int) $c['id']] = array('candidate' => $c, 'identifier' => $identifier); break; } } if (!$hits) return null; if (count($hits) > 1) { return array( 'post_id' => null, 'post_title' => null, 'score' => null, 'rename_to' => null, 'kind' => WPBR_MATCH_AMBIGUOUS, 'identifier' => '', 'rival_ids' => array_keys($hits), ); } $hit = reset($hits); $best = $hit['candidate']; return array( 'post_id' => $best['id'], 'post_title' => $best['title'], // NOT 1.0, AND NOT 1.0 ON PURPOSE. See the block comment above. 'score' => null, 'rename_to' => wpbr_suggested_filename( $best['id'], $stem, isset($best['old_filename']) ? $best['old_filename'] : '' ), 'kind' => WPBR_MATCH_EXACT, 'identifier' => $hit['identifier'], 'rival_ids' => array(), ); } /** * What a match result means for the row it is about to be written onto. * * ONE PLACE, BECAUSE THERE WERE TWO AND THEY WERE BOTH `$match['score'] >= * $threshold`. That test reads as "is this confident enough", and it quietly * assumed every match has a score - so the moment one deliberately does not, it * answers "no" and sends a certain match to manual review. Both call sites * would have had to learn the same three kinds, separately, and one of them * would eventually not. * * @since 1.0.0 * * @param array $result A result from `wpbr_match_file()`. * @return array `update` columns to write, `settled` whether it needs no review. */ function wpbr_match_row_update($result) { $kind = isset($result['kind']) ? $result['kind'] : WPBR_MATCH_SIMILAR; if ($kind === WPBR_MATCH_AMBIGUOUS) { // NOTHING IS MATCHED AND THE REASON IS RECORDED. Leaving the row simply // unmatched would be indistinguishable from "no candidate looked // anything like this", and the two want completely different fixes: // one is a naming problem, the other is two products sharing a SKU. return array( 'settled' => false, 'update' => array( 'matched_post_id' => null, 'matched_post_title' => null, 'confidence_score' => null, 'match_kind' => WPBR_MATCH_AMBIGUOUS, 'rename_to' => null, ), ); } $settled = $kind === WPBR_MATCH_EXACT ? (bool) $result['post_id'] : ($result['post_id'] && $result['score'] >= wpbr_confidence_threshold()); return array( 'settled' => (bool) $settled, 'update' => array( 'matched_post_id' => $result['post_id'], 'matched_post_title' => $result['post_title'], 'confidence_score' => $result['score'], 'match_kind' => $result['post_id'] ? $kind : null, 'rename_to' => $result['rename_to'], ), ); } /** * The note that explains an ambiguous refusal to whoever has to resolve it. * * @since 1.0.0 * * @param array $result A result whose kind is `ambiguous`. * @return string */ function wpbr_ambiguous_match_note($result) { $ids = isset($result['rival_ids']) ? array_map('intval', (array) $result['rival_ids']) : array(); sort($ids); return sprintf( /* translators: %s is a comma-separated list of numeric post IDs. */ __('Not matched: the identifier in this filename names more than one candidate (IDs %s). Two records sharing an identifier is a catalog problem, so nothing was chosen — pick the right one, or fix the duplicate.', 'wpbr-bulk-replace'), implode(', ', $ids) ); } function wpbr_match_file($filename, $candidates, $post_type) { $name_no_ext = preg_replace('/\.[a-z0-9]+$/i', '', $filename); // IDENTIFIERS FIRST, AND THEY END IT. An exact hit outranks every // similarity score there could be, so there is no ordering to get wrong // later and no threshold that can demote it. A refusal on ambiguity ends // it too: falling through to scoring after finding two candidates with the // same SKU would answer a question the catalogue has made unanswerable. $by_identifier = wpbr_match_by_identifier($name_no_ext, $candidates); if ($by_identifier !== null) return $by_identifier; // Vocabulary comes from the configuration facade, not from a table Free // owns: Free ships none, and an add-on supplies it through the // wpbr_matching_config filter. The expansion itself stays here because it // is deterministic text mechanics, not intelligence. $config = wpbr_matching_config($post_type); $aliases = isset($config['aliases']) ? (array) $config['aliases'] : array(); $name_expanded = wpbr_expand_with_aliases($name_no_ext, $aliases); $best = null; $best_score = 0; foreach ($candidates as $c) { // A configured variant group can rule this pairing out entirely: // textually similar, but demonstrably different things. Abstains // unless both sides declare a variant of the same group. if (!wpbr_variants_allow_match($name_no_ext, $c['title'], $post_type)) continue; $score = wpbr_similarity($name_no_ext, $c['title']); if ($name_expanded !== $name_no_ext) { $score = max($score, wpbr_similarity($name_expanded, $c['title'])); } if ($c['old_filename']) { $old_no_ext = preg_replace('/\.[a-z0-9]+$/i', '', $c['old_filename']); $score = max($score, wpbr_similarity($name_no_ext, $old_no_ext)); if ($name_expanded !== $name_no_ext) { $score = max($score, wpbr_similarity($name_expanded, $old_no_ext)); } } if ($score > $best_score) { $best_score = $score; $best = $c; } } if (!$best) { return array( 'post_id' => null, 'post_title' => null, 'score' => 0, 'rename_to' => null, 'kind' => WPBR_MATCH_SIMILAR, 'identifier' => '', 'rival_ids' => array(), ); } return array( 'post_id' => $best['id'], 'post_title' => $best['title'], 'score' => $best_score, 'rename_to' => wpbr_suggested_filename($best['id'], $filename, $best['old_filename']), 'kind' => WPBR_MATCH_SIMILAR, 'identifier' => '', 'rival_ids' => array(), ); } /** * Work out what a file should be called, according to the configured policy. * * 'keep' - reuse the name already on the post, so the public URL doesn't * change. Safest, but inherits whatever the old name was. * 'template' - derive a consistent name from the post. Changes the URL. */ function wpbr_rename_policy() { $val = function_exists('wpbr_get_setting') ? wpbr_get_setting('rename_policy', 'keep') : 'keep'; return in_array($val, array('keep', 'template'), true) ? $val : 'keep'; } function wpbr_default_rename_template() { $val = function_exists('wpbr_get_setting') ? wpbr_get_setting('rename_template', WPBR_DEFAULT_RENAME_TEMPLATE) : WPBR_DEFAULT_RENAME_TEMPLATE; return $val ? $val : WPBR_DEFAULT_RENAME_TEMPLATE; } function wpbr_suggested_filename($post_id, $original_filename, $old_filename = '') { $ext = strtolower(pathinfo($original_filename, PATHINFO_EXTENSION)); if (wpbr_rename_policy() === 'template') { $name = wpbr_apply_rename_template(wpbr_default_rename_template(), $post_id, $original_filename, $ext); if ($name) return $name; } if ($old_filename) return sanitize_file_name($old_filename); // No existing name to keep, so fall back to the template either way. return wpbr_apply_rename_template(wpbr_default_rename_template(), $post_id, $original_filename, $ext); } /** * True when $token appears in uppercase in $filename - the practical marker of * an abbreviation rather than an ordinary word. */ function wpbr_token_is_shorthand($token, $filename) { if ($token === '') return false; $pattern = '/(?