__('What a batch is, the four steps it moves through, and why the work is organized that way.', 'wpbr-bulk-replace'), 'core.batches.create' => __('Starting a batch, adding files, and what happens to files that share a name.', 'wpbr-bulk-replace'), 'core.batches.stage-files' => __('Uploading through the browser versus dropping files into the staging folder, and what file types are accepted.', 'wpbr-bulk-replace'), 'core.matching.overview' => __('How a file is matched to a post: what is compared, what the confidence score means, and what re-running matching does to decisions already made by hand.', 'wpbr-bulk-replace'), 'core.batches.review-resolve' => __('Working through the proposed matches: reassigning, skipping, confirming a new attachment, and sending the list to a reviewer.', 'wpbr-bulk-replace'), 'core.review.import' => __('Exporting the review list, what a reviewer fills in, what the importer refuses to overwrite, and how to undo an import.', 'wpbr-bulk-replace'), 'core.batches.run' => __('Running the replacement: what is backed up, what each result means, and what happens if the run is interrupted.', 'wpbr-bulk-replace'), 'core.replace.old-file-showing' => __('Why an old file can still appear after a successful replacement: what browser and page caches do, what the version marker on a link does and which links it reaches, how to check the replacement worked, and what to clear.', 'wpbr-bulk-replace'), 'core.rollback.overview' => __('Undoing a replacement, marking one verified, and what deleting backups costs.', 'wpbr-bulk-replace'), 'core.settings.overview' => __('What each setting changes, which of them a filter or a constant can override, and where staging and backups live.', 'wpbr-bulk-replace'), 'core.pdf-combine.overview' => __('What Combine does, why it is off by default, and what the merged file is like on each backend.', 'wpbr-bulk-replace'), 'core.diagnostics.overview' => __('What the diagnostics screens show, what is removed from a support package, and how to read one before sharing it.', 'wpbr-bulk-replace'), // ---- refusal states, linked from the message itself ---- 'error.shared-file-outside-batch' => __('Why a replacement is refused when the destination file is also used by a post outside the batch, and the three ways to resolve it.', 'wpbr-bulk-replace'), 'error.collision-detected' => __('Why two files matched to one post block the run, and how to resolve the conflict.', 'wpbr-bulk-replace'), ); // Pro, and anything else that ships screens of its own, adds its keys here. The result is still the single // registry: unregistered keys render no link, and the manifest below carries whatever is registered. return apply_filters('wpbr_help_keys', $keys); } /** * Is this a well-formed key under the grammar? * * Validated as STRUCTURE, not against the registry - `wpbr_help_known()` is the * registry question. Two separate questions, because "you typed a key that * cannot exist" and "you typed a key nobody has written an article for" are * different mistakes with different fixes. */ function wpbr_help_key_valid($key) { $key = (string) $key; if ($key === '' || strlen($key) > 120) { return false; } // Lower-case, locale-neutral: letters, digits, hyphens, dot-separated. $segment = '[a-z0-9]+(?:-[a-z0-9]+)*'; if (!preg_match('/^' . $segment . '(?:\.' . $segment . ')+$/', $key)) { return false; } $parts = explode('.', $key); if (!in_array($parts[0], wpbr_help_areas(), true)) { return false; } // DEPTH IS PER AREA. A `core` or `pro` key names a screen or topic and then // the specific thing, which is three segments. An `error` key is anchored to // a failure condition and has no screen, so it is two - and requiring three // everywhere would reject the shape refusal keys actually have. return $parts[0] === 'error' ? count($parts) === 2 : count($parts) === 3; } /** Does the registry claim this key? */ function wpbr_help_known($key) { $keys = wpbr_help_keys(); return isset($keys[(string) $key]); } /** * The resolver base. The official Help Centre unless something says otherwise. * * THREE OUTCOMES, AND ONLY ONE OF THEM IS A CONFIGURATION ERROR: * * - nothing overrides it: the official address, so help works out of the box; * - an explicit empty override: help links are OFF, deliberately, and every * call site renders nothing rather than a dead link; * - anything that is not an absolute https base: REFUSED, and treated as off. * * The third is refused rather than passed through because a filter is exactly * the kind of place a wrong value arrives from, and a plain-http or relative base * would send an operator somewhere unintended from inside their own admin. * * A trailing slash is stripped so call sites can join with '/go/' without each * of them having to think about it. */ function wpbr_help_base_url() { $base = trim((string) apply_filters('wpbr_help_base_url', WPBR_HELP_BASE_URL)); if ($base === '') { return ''; } if (!preg_match('#^https://[^\s/$.?\#].[^\s]*$#i', $base)) { return ''; } return rtrim($base, '/'); } /** * The URL for a stable key, or '' if there is nothing honest to link to. * * Returns '' when the key is malformed, when it is not registered, and when no * resolver base is configured. Callers treat '' as "render no link" - there is * no fallback URL, because a fallback is how you get a link that goes somewhere * wrong. */ function wpbr_help_url($key) { if (!wpbr_help_key_valid($key) || !wpbr_help_known($key)) { return ''; } $base = wpbr_help_base_url(); if ($base === '') { return ''; } return $base . '/go/' . rawurlencode((string) $key); } /** * A contextual help link, or '' - never a dead one. * * `$text` is the visible label and IS translated; `$key` is identity and is not. * That split is the whole contract, and it is why the label is a separate * argument rather than being looked up from the key. */ function wpbr_help_link($key, $text = '', $args = array()) { $url = wpbr_help_url($key); if ($url === '') { return ''; } $text = (string) $text; if ($text === '') { $text = __('Learn more', 'wpbr-bulk-replace'); } $class = isset($args['class']) ? (string) $args['class'] : 'wpbr-help-link'; // `noreferrer` IS A PRIVACY GUARANTEE, NOT A DUPLICATE OF `noopener`. Without // it the browser sends a `Referer` header when somebody clicks, and the page // they clicked from is a wp-admin URL: it carries the customer's site // hostname and whatever is in the query string - which screen, which batch. // Every contextual help click would put that in our access logs, for a // feature whose entire job is to open an article. `noopener` closes a // different hole (the opened tab reaching back through `window.opener`) and // does not suppress the header. Both are needed; neither is redundant. return sprintf( '%s %s', esc_attr($class), esc_url($url), esc_attr($key), esc_html($text), /* translators: appended for screen readers to every help link, which opens a new tab. */ esc_html__('(opens the help center in a new tab)', 'wpbr-bulk-replace')); } /** * What a help link may contain: the anchor wpbr_help_link() builds, its * screen-reader span, and nothing else. * * @return array wp_kses() allowed-HTML list. */ function wpbr_help_link_allowed_html() { return array( 'a' => array( 'class' => true, 'href' => true, 'target' => true, 'rel' => true, 'data-wpbr-help-key' => true, ), 'span' => array('class' => true), ); } /** * Echo helper, so templates read as markup rather than as string plumbing. * * Escaped where it is printed: wpbr_help_link() builds the anchor from esc_*() * parts, and this holds the result to that shape at the point of output as well, * so an `a` with an attribute it does not list, or any other tag, cannot get * through whatever is later added to the builder. */ function wpbr_help_link_echo($key, $text = '', $args = array()) { echo wp_kses(wpbr_help_link($key, $text, $args), wpbr_help_link_allowed_html()); } /** * The keys this release references, as data the Help Centre can import. * * DELIBERATELY THE REGISTRY RATHER THAN A GREP OF THE SOURCE: the registry is * the promise, and `tests/HelpContractTest.php` is what proves the source and * the registry agree in both directions. A manifest built by grepping would * agree with the source by construction and could never catch anything. * * The Help Center imports this manifest, so "does every key this release * ships have an article?" is a question one command can answer. */ function wpbr_help_manifest($version = '') { $keys = array(); foreach (wpbr_help_keys() as $key => $intent) { $keys[] = array('key' => $key, 'intent' => $intent); } return array( 'plugin' => 'wp-bulk-replace', // THE CALLER SUPPLIES THE VERSION. This function owns the registry and // has no business parsing a plugin header; a second reader of the header // is a second thing that can disagree with it, and the header is the one // place the version is declared. 'version' => (string) $version, 'keys' => $keys, ); }