array( // A CALLABLE, NOT A TRANSLATED STRING, AND THIS IS NOT STYLE. // See `wpbr_reference_provider_label()`: this registry is first // touched at `plugins_loaded`, and translating there makes // WordPress 6.7+ log "called incorrectly" on every request. 'label' => 'wpbr_meta_reference_label', 'read' => 'wpbr_meta_reference_read', 'write' => 'wpbr_meta_reference_write', 'clear' => 'wpbr_meta_reference_clear', 'choices' => 'wpbr_meta_reference_choices', ), ); } return $providers; } /** * Register a reference provider. * * REFUSES RATHER THAN OVERWRITES, and refuses to replace the built-in one. A * provider that silently took over post meta would change how every existing * batch on the site reads and writes its files, which is not a thing an add-on * should be able to do by loading. * * Callbacks receive ($post_id, $location) and, for a write, a third $value. * A read returns the value or '' ; a write and a clear return a boolean. * * `owns` receives ($location) and `choices` receives ($post_type); both are * optional. See `wpbr_reference_choices()` for the shape a choice takes. * * @since 1.0.0 * * @param string $id Machine id, unique on the site. * @param array $provider read/write/clear callbacks, plus a label, and * optionally `owns` and `choices`. * @return bool True if registered. */ function wpbr_register_reference_provider($id, array $provider) { $id = sanitize_key($id); if ($id === '' || $id === WPBR_REFERENCE_PROVIDER_META) return false; foreach (array('read', 'write', 'clear') as $required) { if (empty($provider[$required]) || !is_callable($provider[$required])) return false; } $providers = &wpbr_reference_providers(); if (isset($providers[$id])) return false; $providers[$id] = array( 'label' => isset($provider['label']) ? $provider['label'] : $id, 'read' => $provider['read'], 'write' => $provider['write'], 'clear' => $provider['clear'], // ALSO OPTIONAL, AND IT IS WHAT MAKES A PROVIDER USABLE RATHER THAN // MERELY PRESENT. Without it an operator has to know a location's // identifier before they can configure it, which for a plain meta key // is reasonable and for a generated one is not. `choices` is asked what // could be configured on a post type; it is never asked to create // anything, and a provider that cannot enumerate simply omits it. 'choices' => isset($provider['choices']) && is_callable($provider['choices']) ? $provider['choices'] : null, // OPTIONAL, AND THE REASON IT EXISTS IS A DEFECT IT PREVENTS. Without // it the built-in provider owns every location by default, including // one that plainly belongs to somebody else - so a batch naming an // add-on's location, on a site where the add-on is inactive or // unlicensed, had `update_post_meta()` write that name as an ordinary // meta row, return true, and leave the real reference untouched. A // provider that declares `owns` keeps its locations out of the // built-in provider's hands whether or not it is in a position to act // on them; refusing the write is then ITS decision to make, and a // refusal is reported rather than silent. 'owns' => isset($provider['owns']) && is_callable($provider['owns']) ? $provider['owns'] : null, // OPTIONAL, AND ONLY EVER A PERFORMANCE ANSWER. A provider that stores // attachment IDs has to turn each one into a URL, and doing that a post // at a time is a query a post. `prime` is handed every post a run is // about, once, so it can warm whatever it is about to read. It may // change nothing a caller can observe except how long the work takes - // see `wpbr_reference_prime()`. 'prime' => isset($provider['prime']) && is_callable($provider['prime']) ? $provider['prime'] : null, ); return true; } /** * What to call a provider where a person can see it. * * WHY A LABEL IS A CALLABLE HERE. Providers are registered on * `wpbr_extensions_init`, which fires from `plugins_loaded` - and a translation * call that early is exactly what WordPress 6.7 added * `_load_textdomain_just_in_time` to complain about. Translating the built-in * provider's name where it was registered put a "called incorrectly" notice in * the log of every request on every site, which is both noise in somebody * else's error log and the kind of thing a wp.org review reads as carelessness. * IT WAS FOUND BY RUNNING THE PLUGIN ON A REAL SITE rather than by * reading, because the stubbed suite has no textdomain machinery to complain. * * So a provider hands over something that CAN produce the label, and it is * asked at the moment the label is drawn - well after `init`. A plain string is * still accepted, because an add-on that does not need translating should not * have to write a function to say so. * * @since 1.0.0 * * @param string $id A registered provider id. * @return string */ function wpbr_reference_provider_label($id) { $providers = wpbr_reference_providers(); if (!isset($providers[$id])) return (string) $id; $label = $providers[$id]['label']; return is_callable($label) ? (string) call_user_func($label) : (string) $label; } /** * Which provider a batch's references are read and written through. * * DEFAULTS TO POST META AND FALLS BACK TO IT. An unknown id is not an error a * replacement run should discover halfway through: it resolves to the built-in * provider, which is the behaviour the site had before any add-on was involved. * * @since 1.0.0 * * @param object|null $batch The batch row. * @return string A registered provider id. */ function wpbr_reference_provider_id($batch = null, $location = '') { $providers = wpbr_reference_providers(); // WHICH PROVIDER OWNS THIS LOCATION, ASKED PER LOCATION AND NOT PER BATCH. // A batch's locations are ALTERNATIVES - "the file is in whichever of these // has a value" - and there is nothing saying they all live in the same // store. A site part-way through moving a field into an add-on has exactly // that shape: older posts hold the file in a plain meta key, newer ones in // the add-on's, and one batch covers both. Resolving per batch answered // with the built-in provider for all of them and lost every reference the // add-on held. $default = WPBR_REFERENCE_PROVIDER_META; if ($location !== '') { foreach ($providers as $id => $provider) { if ($id === WPBR_REFERENCE_PROVIDER_META || empty($provider['owns'])) continue; if (call_user_func($provider['owns'], $location)) { $default = $id; break; } } } /** * Filter which provider handles one file-reference location. * * @since 1.0.0 * * @param string $id Provider id. The owner declared for this * location, or post meta. * @param object|null $batch The batch row. * @param string $location The location being read or written. */ $id = (string) apply_filters('wpbr_reference_provider', $default, $batch, $location); return isset($providers[$id]) ? $id : WPBR_REFERENCE_PROVIDER_META; } /** * The provider array for one location. * * @param object|null $batch The batch row. * @param string $location Location identifier. * @return array */ function wpbr_reference_provider_for($batch, $location) { $providers = wpbr_reference_providers(); return $providers[wpbr_reference_provider_id($batch, $location)]; } /** * A context object for a caller that has the locations but not the batch row. * * ONE CALLER NEEDS THIS AND IT IS WORTH NAMING RATHER THAN HIDING. * `wpbr_run_matching_core()` is handed `$meta_keys` as a parameter and never * loads the batch, so it has the locations and no row to read them off. Building * a context from them keeps it on the same code path as everything else instead * of giving the matching screen its own way of reading a reference. * * WHAT IT COSTS, SAID OUT LOUD: an add-on's `wpbr_reference_provider` filter * receives this object rather than the real batch, so it can see the locations * and not which batch they came from. That is enough to choose a provider by the * shape of a location, which is what a provider actually needs, and it is not * enough to vary by batch. A caller that needs the latter should pass the row. * * @since 1.0.0 * * @param string[] $locations Location identifiers, in order. * @return object A batch-shaped context. */ function wpbr_reference_context($locations) { return (object) array('meta_key_name' => wpbr_join_meta_keys((array) $locations)); } /** * Question 1: which locations on a post could hold this batch's file. * * IN CONFIGURED ORDER, because the order is load-bearing - it is what decides * where a first-time write goes and which value wins when two locations are * filled. * * @since 1.0.0 * * @param object|null $batch The batch row. * @return string[] Location identifiers, in order. */ function wpbr_reference_locations($batch = null) { $raw = ($batch && isset($batch->meta_key_name)) ? $batch->meta_key_name : ''; $locations = wpbr_split_meta_keys($raw); /** * Filter the locations a batch's file reference may live in. * * @since 1.0.0 * * @param string[] $locations In configured order. * @param object|null $batch The batch row. */ return (array) apply_filters('wpbr_reference_locations', $locations, $batch); } /** * Before questions 2 and 3, over a whole set of posts at once. * * THIS IS A MEASUREMENT, NOT A DESIGN PREFERENCE. Candidate discovery reads one * reference per post, and for a provider that stores attachment IDs - a * WooCommerce product image, an ACF image field - turning that ID into a URL is * a query. Counted on a real WordPress: `get_posts()` over twenty products cost * ONE query, because WordPress primes the post and meta caches for the set; the * twenty attachments behind their images cost twenty more, because nothing had * primed those. A five-thousand-product shop would pay five thousand. * * SO THE SEAM GAINED A PLACE TO ANSWER IT RATHER THAN THE PROVIDER REACHING * AROUND. A provider could have primed inside its own `read`, but `read` is * called with one post and does not know there are four thousand nine hundred * and ninety-nine more coming. This is the only function that does. * * IT IS ALLOWED TO DO NOTHING. `prime` is optional, every provider without one * is skipped, and the built-in provider has none because `get_posts()` has * already primed post meta by the time this runs. Nothing a caller can observe * may change except how long the work takes - a provider that used this to * alter a value would be changing what a batch reads depending on whether * somebody remembered to call this first. * * @since 1.0.0 * * @param string[] $locations Location identifiers the run will read. * @param int[] $post_ids Every post the run is about. * @return void */ function wpbr_reference_prime($locations, $post_ids) { $locations = array_values(array_filter((array) $locations, 'strlen')); $post_ids = array_values(array_unique(array_filter(array_map('intval', (array) $post_ids)))); if (!$locations || !$post_ids) return; $providers = wpbr_reference_providers(); // GROUPED BY PROVIDER, so one that owns three of a batch's locations is // asked once with all three rather than three times. $by_provider = array(); foreach ($locations as $location) { $by_provider[wpbr_reference_provider_id(wpbr_reference_context($locations), $location)][] = $location; } foreach ($by_provider as $id => $owned) { if (empty($providers[$id]['prime'])) continue; call_user_func($providers[$id]['prime'], $post_ids, $owned); } } /** * Which post types a batch may be configured against. * * PUBLIC IS THE RIGHT DEFAULT AND IT IS NOT THE WHOLE ANSWER. The Match step * has always offered `get_post_types(array('public' => true))`, which is correct * for content a visitor can reach — and silently wrong for a store. WooCommerce * registers `product_variation` with `'public' => false`, so a variation that * holds its own image or its own downloadable file could be read and written * perfectly by a provider and **configured by nobody**: it never appeared in the * dropdown. * * THAT IS THE SAME DEFECT `wpbr_reference_choices()` EXISTS TO PREVENT, one * level up. Question 0 made a location configurable; this makes the post type * the location lives on selectable. A capability nothing can reach is not a * capability. * * A PROVIDER ADDS A TYPE, IT DOES NOT REPLACE THE LIST. The filter receives the * public types and returns them plus whatever it owns, so Free with no add-on * offers exactly what it always did. * * @since 1.0.0 * * @return WP_Post_Type[] Keyed by name. */ function wpbr_configurable_post_types() { $types = get_post_types(array('public' => true), 'objects'); /** * Filter the post types a batch may be configured against. * * For a provider holding references on a post type WordPress does not * consider public — a WooCommerce product variation, for instance — which * would otherwise be unreachable from the configuration screen. * * @since 1.0.0 * * @param WP_Post_Type[] $types Keyed by post type name. Public types. */ $types = (array) apply_filters('wpbr_configurable_post_types', $types); // A NAME THAT IS NOT REGISTERED IS DROPPED rather than trusted. Everything // downstream — the label, the query, the choices — assumes a real type. $clean = array(); foreach ($types as $name => $type) { $object = is_object($type) ? $type : get_post_type_object((string) $name); if ($object && !empty($object->name)) { $clean[$object->name] = $object; } } return $clean; } /** * Question 0: what could be configured as a location on this post type. * * THE QUESTION THE OTHER FIVE ASSUMED SOMEBODY HAD ALREADY ANSWERED. Questions * 1 to 5 all start from a batch that already names its locations. Naming them * is fine when a location is a meta key somebody typed into a theme years ago, * and hopeless when it is a generated identifier: an add-on could read and * write a reference perfectly and still be unusable, because configuring it * meant copying a key out of another plugin's editor. The engine can do it and * nobody can reach it is exactly the failure an earlier release shipped, and * the rule that came out of it. * * ENUMERATING IS NOT CREATING, and the distinction is the same one the rest of * this file draws. A provider lists locations that already exist for this post * type. It is never asked to add a field, a row or a structure, and nothing * here offers a way to. * * A CHOICE IS DELIBERATELY NOT A FIELD. It is a value a person can read and an * identifier WPBR can store, and the two are separate because they are for * different readers. There is no group, no type, no cardinality and no * validation rule in this shape, because the moment there is, this becomes a * form builder for somebody else's model. * * value A complete location, ready to store. * template A location with one `{n}` in it, for a reference that lives at * a position inside a repeating structure. WPBR substitutes a * number the operator gives; it never invents one, and a * position that does not exist is the provider's to refuse. * index_label What to call that number where a person can see it. * label What a person reads. * detail Optional second line - where it came from, what it holds. * provider Filled in here, not by the provider, so a provider cannot * attribute its choices to somebody else. * * Exactly one of `value` and `template` is set; anything else is dropped rather * than guessed at. * * @since 1.0.0 * * @param string $post_type Post type being configured, or '' for any. * @return array[] Choices, built-in provider first. */ function wpbr_reference_choices($post_type = '') { $post_type = (string) $post_type; $choices = array(); foreach (wpbr_reference_providers() as $id => $provider) { if (empty($provider['choices'])) continue; foreach ((array) call_user_func($provider['choices'], $post_type) as $choice) { $normalised = wpbr_reference_normalise_choice($choice, $id); if ($normalised) $choices[] = $normalised; } } /** * Filter what may be configured as a file-reference location. * * @since 1.0.0 * * @param array[] $choices Normalised choices. * @param string $post_type Post type being configured, or ''. */ return (array) apply_filters('wpbr_reference_choices', $choices, $post_type); } /** * Put one choice into the documented shape, or refuse it. * * REFUSED RATHER THAN REPAIRED. A choice with both a value and a template, or * a template with no `{n}` in it, describes a location WPBR cannot build - and * a half-built location is a meta key written onto a post under a name nobody * chose. There is no repair that is better than leaving it out of the list. * * @since 1.0.0 * * @param mixed $choice What a provider returned. * @param string $provider_id The provider it came from. * @return array|null */ function wpbr_reference_normalise_choice($choice, $provider_id) { if (is_string($choice)) $choice = array('value' => $choice); if (!is_array($choice)) return null; $value = isset($choice['value']) ? (string) $choice['value'] : ''; $template = isset($choice['template']) ? (string) $choice['template'] : ''; if (($value === '') === ($template === '')) return null; if ($template !== '' && strpos($template, '{n}') === false) return null; $shown = $value !== '' ? $value : $template; return array( 'value' => $value, 'template' => $template, 'index_label' => $template === '' ? '' : (isset($choice['index_label']) && $choice['index_label'] !== '' ? (string) $choice['index_label'] : __('Row', 'wpbr-bulk-replace')), 'label' => isset($choice['label']) && $choice['label'] !== '' ? (string) $choice['label'] : $shown, 'detail' => isset($choice['detail']) ? (string) $choice['detail'] : '', 'provider' => (string) $provider_id, ); } /** * What to call a location that is already configured. * * WHY THIS IS NOT JUST A LOOKUP. A saved batch holds identifiers, not names, and * the screen that shows them has to turn them back into something a person * recognises - including the ones that came from a template, where the number in * the middle is the only thing telling two of them apart. An identifier that * matches nothing is shown as itself, which is exactly right for a plain meta * key and is also the honest answer for a field whose add-on is no longer here. * * @since 1.0.0 * * @param string $location The stored location. * @param string $post_type Post type being configured, or ''. * @param array $choices Pre-fetched choices, to avoid re-asking per location. * @return string */ function wpbr_reference_choice_label($location, $post_type = '', $choices = null) { $location = (string) $location; if ($location === '') return ''; if ($choices === null) $choices = wpbr_reference_choices($post_type); foreach ((array) $choices as $choice) { if ($choice['value'] !== '' && $choice['value'] === $location) { return $choice['label']; } if ($choice['template'] === '') continue; $pattern = '/^' . str_replace('\{n\}', '(\d+)', preg_quote($choice['template'], '/')) . '$/'; if (preg_match($pattern, $location, $m)) { /* translators: 1: field name, 2: what the position is called, e.g. Row, 3: the position. */ return sprintf(__('%1$s (%2$s %3$s)', 'wpbr-bulk-replace'), $choice['label'], $choice['index_label'], (int) $m[1]); } } return $location; } /** * Read one location. Provider-dispatched. * * @since 1.0.0 * * @param object|null $batch The batch row. * @param int $post_id Post holding the reference. * @param string $location Location identifier. * @return string The stored value, or ''. */ function wpbr_reference_read($batch, $post_id, $location) { $provider = wpbr_reference_provider_for($batch, $location); return call_user_func($provider['read'], (int) $post_id, $location); } /** * Question 2: which location holds the live file, and what it is. * * THE ONE PRIMITIVE THE ENGINE ACTUALLY REPEATED. Four call sites carried this * loop verbatim and a fifth carried it with the value captured too: * * foreach ($meta_keys as $mk) { * if (get_post_meta($post_id, $mk, true)) { $target = $mk; break; } * } * if (!$target) $target = $meta_keys[0]; * * THE FALLBACK IS THE HALF THAT MATTERS AND IS EASY TO GET WRONG. A batch may * list several locations and the live file is found by taking the first WITH A * VALUE, which is not necessarily the first configured. Writing to the first * configured regardless is harmless on a true first-time attach - nothing has a * value, by definition - but wrong when unsharing: there the post does have a * file, and putting its dedicated copy in a different location leaves the page * still reading the shared one while the run reports success. * * `found` IS NOT `location !== ''`. On a post with nothing anywhere, `location` * is still the first configured one - that is where a write goes - while `found` * is false and `value` is ''. Callers deciding "does this post already have a * file" must read `found`. * * @since 1.0.0 * * @param object|null $batch The batch row. * @param int $post_id Post holding the reference. * @return array { * @type string $location Where to read or write. '' only if nothing is configured. * @type string $value The live value, or ''. * @type bool $found Whether any configured location held a value. * } */ function wpbr_reference_current($batch, $post_id) { $locations = wpbr_reference_locations($batch); $post_id = (int) $post_id; foreach ($locations as $location) { $value = wpbr_reference_read($batch, $post_id, $location); if ($value) { return array('location' => $location, 'value' => $value, 'found' => true); } } return array( 'location' => isset($locations[0]) ? $locations[0] : '', 'value' => '', 'found' => false, ); } /** * Question 3: write a new value to a location. * * @since 1.0.0 * * @param object|null $batch The batch row. * @param int $post_id Post holding the reference. * @param string $location Location identifier. * @param string $value The new reference. * @return bool */ function wpbr_reference_update($batch, $post_id, $location, $value) { if ($location === '' || !$post_id) return false; $provider = wpbr_reference_provider_for($batch, $location); return (bool) call_user_func($provider['write'], (int) $post_id, $location, $value); } /** * Question 5, first half: put a previous value back. * * THE RESTORE LOCATION IS RE-DERIVED RATHER THAN REMEMBERED, and that is the * pre-existing behaviour this function preserves exactly. Rollback asks the same * question a write asks - which location holds a value, else the first * configured - and writes there. It does not store the location it wrote to and * read it back, and changing that here would change what an undo does on a post * somebody has edited in between. * * @since 1.0.0 * * @param object|null $batch The batch row. * @param int $post_id Post holding the reference. * @param string $previous The value recorded before the replacement. * @return string The location written to, or '' if nothing was. */ function wpbr_reference_restore($batch, $post_id, $previous) { $current = wpbr_reference_current($batch, $post_id); $location = $current['location']; if ($location === '' || !$post_id) return ''; wpbr_reference_update($batch, $post_id, $location, $previous); return $location; } /** * Question 5, second half: undo a first-time attach by clearing the location. * * THE FIRST CONFIGURED LOCATION, NOT THE RESOLVED ONE. This path runs when there * was no previous value to put back, which means the plugin wrote the only value * the post has - and the pre-existing code cleared `$meta_keys[0]` here rather * than re-deriving. Preserved as it was. * * @since 1.0.0 * * @param object|null $batch The batch row. * @param int $post_id Post holding the reference. * @return bool */ function wpbr_reference_forget($batch, $post_id) { $locations = wpbr_reference_locations($batch); $location = isset($locations[0]) ? $locations[0] : ''; if ($location === '' || !$post_id) return false; $provider = wpbr_reference_provider_for($batch, $location); return (bool) call_user_func($provider['clear'], (int) $post_id, $location); } /** * Every configured location and what it holds. FOR DIAGNOSTICS ONLY. * * The self-test dump prints each location whether or not it has a value, which * is the one caller that wants more than `wpbr_reference_current()`. * * @since 1.0.0 * * @param object|null $batch The batch row. * @param int $post_id Post holding the reference. * @return array location => value. */ function wpbr_reference_map($batch, $post_id) { $map = array(); foreach (wpbr_reference_locations($batch) as $location) { $map[$location] = wpbr_reference_read($batch, $post_id, $location); } return $map; } // --------------------------------------------------------------------------- // The built-in provider: raw post meta, exactly as the engine used it. // --------------------------------------------------------------------------- /** * Asked for at draw time, never at registration. See * `wpbr_reference_provider_label()` for why that distinction is load-bearing. * * @since 1.0.0 * @return string */ function wpbr_meta_reference_label() { return __('Post meta', 'wpbr-bulk-replace'); } /** * @since 1.0.0 * @param int $post_id Post id. * @param string $location Meta key. * @return string */ function wpbr_meta_reference_read($post_id, $location) { if (!$post_id || $location === '') return ''; return get_post_meta($post_id, $location, true); } /** * @since 1.0.0 * @param int $post_id Post id. * @param string $location Meta key. * @param string $value New value. * @return bool */ function wpbr_meta_reference_write($post_id, $location, $value) { if (!$post_id || $location === '') return false; return (bool) update_post_meta($post_id, $location, $value); } /** * @since 1.0.0 * @param int $post_id Post id. * @param string $location Meta key. * @return bool */ function wpbr_meta_reference_clear($post_id, $location) { if (!$post_id || $location === '') return false; return (bool) delete_post_meta($post_id, $location); } /** * Which meta keys this post type already uses. * * THE SUGGESTIONS THE MATCHING SCREEN ALREADY OFFERED, moved behind the seam so * that every provider answers the same question the same way and the screen has * one list to render rather than a built-in case and an add-on case. The keys * are unchanged: what a post of this type actually carries, private ones * excluded, which is the list that was there before. * * A KEY IS ITS OWN NAME HERE, and that is not a gap. A meta key is what the * person who created it typed; inventing a prettier label for it would hide the * only string that identifies it. * * @since 1.0.0 * * @param string $post_type Post type being configured. * @return array[] */ function wpbr_meta_reference_choices($post_type) { if ($post_type === '' || !function_exists('wpbr_get_meta_key_suggestions')) return array(); $choices = array(); foreach ((array) wpbr_get_meta_key_suggestions($post_type) as $key) { $choices[] = array('value' => $key, 'label' => $key); } return $choices; }