".esc_html($status['path']).""; echo "

" . wp_kses_post(sprintf( /* translators: %s is a PHP constant name such as WPBR_BACKUPS_DIR. */ __('Set by %s in wp-config.php, so this cannot be edited here.', 'wpbr-bulk-replace'), esc_html($status['constant']) )) . "

"; if ($status['problem'] === 'not_writable') { echo "

" . wp_kses_post(__('That folder is not writable. Until it is, nothing can be staged or backed up there.', 'wpbr-bulk-replace')) . "

"; } if ($stored !== '') { echo "

" . wp_kses_post(sprintf( /* translators: %s is a filesystem path the admin saved earlier. */ __('A saved setting of %s is being ignored. Removing the constant brings it back.', 'wpbr-bulk-replace'), esc_html($stored) )) . "

"; } } /** * "Go to Variant groups", for the notices that say the rules were not saved. * * The notices sit above the tabs and the box is on Matching, which is not necessarily the tab the person saved from. The link is to the * field's own id: the tab script opens the panel that holds it and focuses it, and without script every panel is already showing. */ function wpbr_variant_groups_link() { return '' . esc_html__('Go to Variant groups', 'wpbr-bulk-replace') . ''; } function wpbr_render_settings_page() { if (!current_user_can('manage_options')) return; global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; the screen shows live state, and a cached copy would lag the write the person just made. $post_types_used = $wpdb->get_col("SELECT DISTINCT post_type FROM {$wpdb->prefix}wpbr_batches WHERE post_type IS NOT NULL AND post_type != ''"); // No post type is special. Offer what the site actually has, starting // with the one most file-replacement work targets. if (empty($post_types_used)) $post_types_used = array('page'); $active_pt = isset($_GET['pt']) ? sanitize_key(wp_unslash($_GET['pt'])) : $post_types_used[0]; // What the save below has to say. It used to be echoed on the spot, ahead of the page; it is collected so it can be printed in the // notices container above the tabs. Every piece is escaped where it is built. $notices = ''; if (isset($_POST['wpbr_action']) && $_POST['wpbr_action'] === 'save_settings' && check_admin_referer('wpbr_settings')) { $opts = array( 'match_threshold' => isset($_POST['match_threshold']) ? floatval(wp_unslash($_POST['match_threshold'])) : 0.65, 'high_threshold' => isset($_POST['high_threshold']) ? floatval(wp_unslash($_POST['high_threshold'])) : 0.85, 'chunk_size' => isset($_POST['chunk_size']) ? max(1, intval(wp_unslash($_POST['chunk_size']))) : 5, // A pinned folder renders as text, not as an input, so the field // is absent from the POST. Keep what is stored rather than // clearing it - unsetting the constant must bring it back. 'staging_dir' => isset($_POST['staging_dir']) ? sanitize_text_field(wp_unslash($_POST['staging_dir'])) : wpbr_get_setting('staging_dir', ''), 'backups_dir' => isset($_POST['backups_dir']) ? sanitize_text_field(wp_unslash($_POST['backups_dir'])) : wpbr_get_setting('backups_dir', ''), 'enable_combine' => isset($_POST['enable_combine']) ? 1 : 0, 'cache_bust_links' => isset($_POST['cache_bust_links']) ? 1 : 0, 'rename_policy' => (isset($_POST['rename_policy']) && $_POST['rename_policy'] === 'template') ? 'template' : 'keep', 'rename_template' => isset($_POST['rename_template']) ? sanitize_text_field(wp_unslash($_POST['rename_template'])) : '', 'enable_diagnostics' => isset($_POST['enable_diagnostics']) ? 1 : 0, 'delete_data_on_uninstall' => isset($_POST['delete_data_on_uninstall']) ? 1 : 0, ); if (!$opts['rename_template']) $opts['rename_template'] = WPBR_DEFAULT_RENAME_TEMPLATE; if ($opts['match_threshold'] <= 0 || $opts['match_threshold'] >= 1) $opts['match_threshold'] = 0.65; if ($opts['high_threshold'] <= 0 || $opts['high_threshold'] >= 1) $opts['high_threshold'] = 0.85; if ($opts['high_threshold'] < $opts['match_threshold']) $opts['high_threshold'] = $opts['match_threshold']; update_option('wpbr_settings', $opts); if (isset($_POST['variant_groups'])) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON text, decoded below and rebuilt field by field by wpbr_normalize_variant_group(), which sanitizes every value; running a text sanitizer over the raw JSON first would only corrupt valid input. $raw = trim((string) wp_unslash($_POST['variant_groups'])); if ($raw === '') { update_option('wpbr_variant_groups', array()); } else { $decoded = json_decode($raw, true); // A LIST OF GROUPS, OR NOTHING. A single group pasted without its brackets decodes to an object whose // members are scalars; read as a list it would reject every "group" and save an empty set over the // rules that are in force. That is not what anybody meant, so it is refused like malformed JSON. $is_list = is_array($decoded) && (!$decoded || array_keys($decoded) === range(0, count($decoded) - 1)); if (!$is_list) { $notices .= "

" . wp_kses_post(__('Variant groups not saved. That is not valid JSON, so the previous rules are still in force.', 'wpbr-bulk-replace')) . ' ' . wpbr_variant_groups_link() . "

"; } else { $clean = array(); $rejected = 0; foreach ($decoded as $group) { $normalized = wpbr_normalize_variant_group($group); if ($normalized) { $clean[] = $normalized; } else { $rejected++; } } if ($decoded && !$clean) { // Every entry was refused: keep what is in force rather than replace it with nothing. $notices .= "

" . wp_kses_post(__('Variant groups not saved. None of the groups was valid, so the previous rules are still in force. A group needs an id and at least two variants, each with at least one token.', 'wpbr-bulk-replace')) . ' ' . wpbr_variant_groups_link() . "

"; $rejected = 0; } else { update_option('wpbr_variant_groups', $clean); } if ($rejected) { $notices .= "

" . esc_html(sprintf( /* translators: %s is a number of variant groups. */ _n('%s group was ignored: a group needs an id and at least two variants, each with at least one token.', '%s groups were ignored: a group needs an id and at least two variants, each with at least one token.', $rejected, 'wpbr-bulk-replace'), number_format_i18n($rejected) )) . ' ' . wpbr_variant_groups_link() . "

"; } } } } wpbr_ensure_folders(); $notices .= "

" . esc_html__('Settings saved.', 'wpbr-bulk-replace') . "

"; } if (isset($_POST['wpbr_action']) && $_POST['wpbr_action'] === 'index_replacements' && check_admin_referer('wpbr_index_replacements')) { $indexed = wpbr_asset_versions_backfill(15); // Two sentences, each with its own count, because one sentence cannot agree // with two numbers in every language. $notices .= "

" . esc_html( sprintf( /* translators: %s is a number of files. */ _n('%s file was given a version.', '%s files were given a version.', (int) $indexed['indexed'], 'wpbr-bulk-replace'), number_format_i18n($indexed['indexed']) ) . ' ' . sprintf( /* translators: %s is a number of earlier replacements. */ _n('%s earlier replacement was looked at.', '%s earlier replacements were looked at.', (int) $indexed['seen'], 'wpbr-bulk-replace'), number_format_i18n($indexed['seen']) ) ) . "

"; if ($indexed['remaining'] > 0) { $notices .= "

" . esc_html(sprintf( /* translators: %s is a number of replacement rows. */ _n('%s earlier replacement is still to be looked at. Run this again to continue.', '%s earlier replacements are still to be looked at. Run this again to continue.', (int) $indexed['remaining'], 'wpbr-bulk-replace'), number_format_i18n($indexed['remaining']) )) . "

"; } } // Each row is a function that prints it, collected here and called by tab further down, so a control's markup is the same wherever // its tab puts it. $rows = array(); $rows['match_threshold'] = function () { echo ""; echo ""; echo "

" . wp_kses_post(__('Scores at or above this are marked Matched; below it, Needs Review. Default 0.65.', 'wpbr-bulk-replace')) . "

"; echo ""; }; $rows['high_threshold'] = function () { echo ""; echo ""; echo "

" . esc_html__('Only affects where the confidence indicator switches from medium to high. Default 0.85.', 'wpbr-bulk-replace') . "

"; echo ""; }; $rows['chunk_size'] = function () { echo ""; echo ""; echo "

" . wp_kses_post(__('How many files Backup & Replace processes per request. Lower is safer on slow servers. Default 5.', 'wpbr-bulk-replace')) . "

"; echo ""; }; $rows['staging_dir'] = function () { echo ""; echo wpbr_dir_is_pinned('staging') ? esc_html__('Staging folder', 'wpbr-bulk-replace') : ""; echo ""; if (wpbr_dir_is_pinned('staging')) { wpbr_render_pinned_dir_field('staging'); } else { echo ""; echo "

" . wp_kses_post(__('Leave blank for the default shown above. Must be inside wp-content, and writable by both PHP and whatever account you upload with.', 'wpbr-bulk-replace')) . "

"; } echo ""; }; $rows['backups_dir'] = function () { echo ""; echo wpbr_dir_is_pinned('backups') ? esc_html__('Backups folder', 'wpbr-bulk-replace') : ""; echo ""; if (wpbr_dir_is_pinned('backups')) { wpbr_render_pinned_dir_field('backups'); } else { echo ""; echo "

" . wp_kses_post(__('Leave blank for the default shown above. Must be inside wp-content.', 'wpbr-bulk-replace')) . "

"; } echo ""; }; // The wp-config explanation, as a row of its own under the two folder fields. Collapsed: it is long, and needed once. $rows['backups_outside'] = function () { echo "
" . esc_html__('Put backups outside the web root (wp-config.php)', 'wpbr-bulk-replace') . ""; echo "
"; echo "

" . wp_kses_post(__('Backups are better off outside the web root than behind a deny rule. This field cannot put them there, on purpose — a path typed into the admin should not be able to write anywhere on the server. To use a folder outside wp-content, set it in wp-config.php instead:', 'wpbr-bulk-replace')) . "

"; echo "

define('WPBR_BACKUPS_DIR', '/path/outside/webroot/wpbr-backups');
"; echo "define('WPBR_STAGING_DIR', '/path/outside/webroot/wpbr-staging');

"; echo "

" . wp_kses_post(sprintf( /* translators: %s is a link to the "When the plugin is deleted" setting, which is on the Advanced tab. */ __('A folder set that way overrides the Staging folder and Backups folder settings and is shown here as read-only. It is not removed when the plugin is deleted — see %s.', 'wpbr-bulk-replace'), '' . esc_html__('When the plugin is deleted, on the Advanced tab', 'wpbr-bulk-replace') . '' )) . "

"; echo "
"; echo "
"; }; $rows['rename_policy'] = function () { echo "" . esc_html__('Filename when replacing', 'wpbr-bulk-replace') . ""; $policy = wpbr_rename_policy(); echo "
"; echo "" . esc_html__('Filename when replacing', 'wpbr-bulk-replace') . ""; echo ""; echo ""; echo "
"; echo "

" . wp_kses_post(__('Keep overwrites the existing file in place, so the public URL never changes — nothing that links to it breaks. Rename gives every file a consistent name derived from the post, which is what you want if the existing names are inconsistent; the URL changes, and the old file is left on the server so old links still resolve. Either way you can still override any individual row on the Review step.', 'wpbr-bulk-replace')) . "

"; echo ""; }; $rows['rename_template'] = function () { echo ""; echo ""; echo "

" . wp_kses_post(__('Used when renaming, and whenever a post has no file yet. Tags: {title}, {id}, {original}, {ext}, {date}, {date:Ymd}, {date:Y}, {meta:field_name}.', 'wpbr-bulk-replace')) . "

"; echo ""; }; $rows['cache_bust_links'] = function () { // THE EXPLANATION SITS DIRECTLY UNDER THE BOX and is written for the person // who replaced a PDF and still sees the old one, not for a developer. What it // says about coverage is what includes/attachment-links.php does and no more: // it must not read as "every link on the site". echo "" . esc_html__('Prevent browsers from showing old files', 'wpbr-bulk-replace') . ""; echo ""; echo ""; echo "\n\n"; }; $rows['enable_combine'] = function () { echo "" . esc_html__('Combine Files', 'wpbr-bulk-replace') . ""; echo ""; echo "

" . esc_html__('Merges several PDFs into one row. What the result is like depends entirely on what the server has, so it is listed below.', 'wpbr-bulk-replace') . "

"; wpbr_render_pdf_merge_report(); echo "

"; wpbr_help_link_echo('core.pdf-combine.overview', __('What a merged file is like on each of these', 'wpbr-bulk-replace')); echo "

"; echo ""; }; $rows['variant_groups'] = function () { $groups = wpbr_variant_groups(); echo ""; if ($groups) { echo "

" . esc_html(sprintf( /* translators: %s is a number of variant groups. */ _n('%s group in force:', '%s groups in force:', count($groups), 'wpbr-bulk-replace'), number_format_i18n(count($groups)) )) . "

"; } else { // Shipping empty is the decision, so the empty state states it rather // than reading like something failed to load. echo "

" . wp_kses_post(__('None configured, which is the default — nothing is set up for you. Matching is by text similarity alone until you add a group.', 'wpbr-bulk-replace')) . "

"; } echo ""; echo "
"; echo "

" . wp_kses_post(__('A variant group names alternatives that must never match each other, however similar the wording — desktop / mobile, english / spanish, mens / womens, manual / warranty. A file showing one variant is never matched to a post showing another.', 'wpbr-bulk-replace')) . "

"; echo "

" . esc_html__('The rule stays out of the way when it has no opinion: if either the filename or the title shows no variant at all, the group ignores that pairing. So switching a group on cannot break matching for files it says nothing about.', 'wpbr-bulk-replace') . "

"; echo "

" . esc_html__('Tokens are matched as whole words, longest first. Clear the box to remove every group.', 'wpbr-bulk-replace') . "

"; echo "
"; echo ""; }; $rows['delete_data'] = function () { echo "" . esc_html__('When the plugin is deleted', 'wpbr-bulk-replace') . ""; echo ""; echo "

" . esc_html__('Off by default. While it is off, deleting the plugin leaves every batch, every history entry, your settings and all backups untouched, and reinstalling picks them straight back up.', 'wpbr-bulk-replace') . "

"; echo "
"; echo "

" . esc_html__('Switching this on destroys two things permanently, and they cannot be recovered:', 'wpbr-bulk-replace') . "

"; echo ""; echo "

" . wp_kses_post(__('One exception. If a replacement fails and the original cannot be put back, that backup is the only good copy of the file. It is kept when the plugin is deleted, with a note beside it (PROTECTED-BACKUPS.txt) saying which file it belongs to, and the backups folder is left in place. Deleting data is allowed to be incomplete; it is never allowed to destroy the only original. Remove those files by hand once the live file is right.', 'wpbr-bulk-replace')) . "

"; echo "

" . esc_html__('Also removed: the plugin’s six database tables, its settings, and its batch and activity history.', 'wpbr-bulk-replace') . "

"; echo "

" . wp_kses_post(__('Your site’s own content is never touched, with this on or off. Media Library attachments, the replacement files now live on your pages, and the meta values on matched posts all stay exactly where they are — they belong to your site, not to this plugin.', 'wpbr-bulk-replace')) . "

"; $wpbr_external = wpbr_externally_pinned_dirs(); echo "

" . esc_html__('Externally pinned staging or backup directories are not deleted automatically during uninstall and must be removed manually if desired.', 'wpbr-bulk-replace'); if ($wpbr_external) { echo " " . esc_html__('On this site that means:', 'wpbr-bulk-replace'); } echo "

"; if ($wpbr_external) { echo ""; } echo "
"; echo ""; }; $rows['enable_diagnostics'] = function () { echo "" . esc_html__('Diagnostics', 'wpbr-bulk-replace') . ""; echo ""; echo "

" . esc_html__('These print raw batch rows, resolved server paths and which plugin files are loaded — useful when something has not behaved as expected. They are off by default because that is more detail than needs to be reachable day to day. Administrator access and a signed link are required either way.', 'wpbr-bulk-replace') . "

"; echo "

"; wpbr_help_link_echo('core.diagnostics.overview', __('Reading a support package before you share it', 'wpbr-bulk-replace')); echo "

"; echo ""; }; $tabs = wpbr_settings_tabs(); $active = wpbr_settings_active_tab($tabs); $badges = wpbr_settings_tab_badges($tabs); echo "
"; wpbr_admin_header_print(__('Settings', 'wpbr-bulk-replace')); echo "

"; wpbr_help_link_echo('core.settings.overview', __('What each setting changes', 'wpbr-bulk-replace')); echo "

"; // WordPress moves any non-inline notice to just after the header's `wp-header-end` marker when the page loads, so what lands here // ends up above the tabs either way. The action is the place for another plugin's result notice (Pro's, in time). echo "
"; echo wp_kses_post($notices); do_action('wpbr_settings_notices'); echo "
"; // THE FORM IS EMPTY ON PURPOSE. Every control on every tab names it with form="wpbr-settings-form", so one Save posts all of them // whichever tab is showing. The save replaces the whole option and treats a missing field as "reset it", so a control that was // left out of the page, or out of the form, would be reset without anyone touching it. Inactive tabs are hidden, never removed. echo "
"; wp_nonce_field('wpbr_settings'); echo ""; echo "
"; // The tabs are links, so they work without script and can be copied. The `#` part is for the no-script page, where every panel // is showing and the link scrolls to its own. echo "
"; // Before the panels are parsed, so the "every panel showing" fallback for a page without script never paints, and so the tabs // have their roving tabindex from the start. The rest is assets/wpbr-settings-tabs.js. wp_print_inline_script_tag("(function(){var w=document.getElementById('wpbr-settings');if(!w){return;}w.className+=' wpbr-js';var t=w.querySelectorAll('[role=\"tab\"]');for(var i=0;i"; foreach ($keys as $key) { $rows[$key](); } echo ""; }; foreach ($tabs as $slug => $tab) { echo "
"; switch ($slug) { case 'general': echo "

" . esc_html__('Replacing', 'wpbr-bulk-replace') . "

"; $table(array('chunk_size', 'rename_policy', 'rename_template')); echo "
"; echo "

" . esc_html__('Links to replaced files', 'wpbr-bulk-replace') . "

"; $table(array('cache_bust_links')); echo "
"; echo "

" . esc_html__('Combine Files', 'wpbr-bulk-replace') . "

"; $table(array('enable_combine')); echo "
"; break; case 'matching': echo "

" . esc_html__('Matching', 'wpbr-bulk-replace') . "

"; $table(array('match_threshold', 'high_threshold', 'variant_groups')); echo "
"; if (wpbr_slot_features('settings_matching')) { // Pro's own sections (suggested abbreviations, remembered matches, words to ignore, vocabulary packs). Nothing is // drawn unless something declared the slot, so a site without an add-on sees exactly what it saw before. echo "

" . esc_html__('Learned matching', 'wpbr-bulk-replace') . "

"; wpbr_render_slot('settings_matching'); echo "
"; } elseif (wpbr_settings_shows_pro_tab()) { echo "

" . esc_html__('Learned matching', 'wpbr-bulk-replace') . "

"; echo "

" . esc_html__('Remembered matches, abbreviations, words to ignore and vocabulary packs are part of Pro.', 'wpbr-bulk-replace') . "

"; echo "
"; } break; case 'backups': echo "

" . esc_html__('Folders', 'wpbr-bulk-replace') . "

"; $table(array('staging_dir', 'backups_dir', 'backups_outside')); echo "
"; wpbr_render_storage_panel(); break; case 'advanced': echo "

" . esc_html__('Diagnostics', 'wpbr-bulk-replace') . "

"; $table(array('enable_diagnostics')); if (wpbr_diagnostics_enabled()) { echo "
"; echo ""; echo ""; echo ""; echo ""; echo ""; echo "
"; echo "

" . esc_html__('Reports whether a file can be processed, how its URL resolves on disk, and whether every plugin file on the server is the current version.', 'wpbr-bulk-replace') . "

"; } echo "
"; // Its button posts its own form and acts when pressed, which the header says; the Save bar does not cover it. echo "
"; echo "

" . esc_html__('Maintenance', 'wpbr-bulk-replace'); wpbr_immediate_tag(); echo "

"; echo "
" . esc_html__('Earlier replacements', 'wpbr-bulk-replace') . ""; echo "

" . esc_html__('Files replaced before this plugin started keeping versions have no version to put on a link. This looks at the replacements already on record and gives each of those files one, dated to when it was replaced. It changes nothing on your pages or in the Media Library, and it is safe to run again.', 'wpbr-bulk-replace') . "

"; echo "
"; wp_nonce_field('wpbr_index_replacements'); echo ""; echo "

"; echo "
"; echo "
"; echo "
"; // Last, and alone in its panel: the one setting that destroys data, kept where it is not passed on the way to another. echo "

" . esc_html__('When the plugin is deleted', 'wpbr-bulk-replace') . "

"; $table(array('delete_data')); echo "
"; break; case 'pro': wpbr_render_pro_tab(); break; case 'license': wpbr_render_license_settings(); break; case 'ai': wpbr_render_slot('settings_ai'); break; case 'addons': wpbr_render_extension_settings(); break; } echo "
"; } // The one Save. Its button is the only submit button the settings form owns, so Enter in a text field presses it and nothing else. echo "
"; echo "

" . esc_html__('No unsaved changes', 'wpbr-bulk-replace') . "

"; echo ""; echo ""; echo "
"; echo "
"; wpbr_settings_enqueue_tabs_script(); } /** * Which tabs carry a "needs attention" badge, and how strongly. * * A badge is for a standing problem, never for something that happened (component spec 2). Free does not know what an add-on's problem * is, so it asks: the `wpbr_settings_tab_badges` filter takes `slug => 'error'|'warning'` and an add-on answers only for a tab it * fills. Anything else - an unknown tab, another level, a tab this screen does not draw - is dropped, so a careless filter cannot put * a badge on a tab that is not there. * * @param array $tabs The result of wpbr_settings_tabs(). * @return array slug => 'error'|'warning' */ function wpbr_settings_tab_badges(array $tabs) { /** * Filters the standing problems shown as a badge on a Settings tab. * * @since 1.2.0 * @param array $badges Map of tab slug => 'error' or 'warning'. Empty by default. */ $asked = apply_filters('wpbr_settings_tab_badges', array()); $badges = array(); if (!is_array($asked)) return $badges; foreach ($asked as $slug => $level) { if (!is_string($slug) || !isset($tabs[$slug]) || !is_string($level)) continue; if ($level === 'error' || $level === 'warning') $badges[$slug] = $level; } return $badges; } /** * The badge inside a tab: a glyph and, for a screen reader, the words. Never colour alone. * * @param string $level 'error', 'warning' or '' for none. * @return string HTML, escaped. */ function wpbr_settings_tab_badge_html($level) { if ($level === 'error') { $glyph = '✕'; $text = __('Needs attention', 'wpbr-bulk-replace'); } elseif ($level === 'warning') { $glyph = '!'; $text = __('Needs a decision', 'wpbr-bulk-replace'); } else { return ''; } return '' . esc_html($text) . ''; } /** * The Licenses tab: whatever fills the `settings_license` slot, in one panel. * * The slot is the only way anything gets here, so the tab exists only while something fills it (wpbr_settings_tabs()). The add-on * draws its controls under this panel's heading (its own sub-sections are h3). */ function wpbr_render_license_settings() { if (!wpbr_slot_features('settings_license')) return; echo "

" . esc_html__('License', 'wpbr-bulk-replace') . "

"; wpbr_render_slot('settings_license'); echo "
"; } /** * The tabs on the Settings screen, in the order they are drawn. * * Four are always there. The others appear only when something fills them, so a site with no add-on sees General, Matching, Backups and * Advanced and nothing that is not its own: `license` and `ai` when an add-on fills their slot, `addons` when anything fills the * older single `settings` slot or the newer `settings_addons` one. (Slot names are run through sanitize_key(), so they are written with * an underscore: a dot would be stripped and two slots could collide.) * * The label is what people read; the key is what is in the address, so `license` is "Licenses" on screen and `&tab=license` in a link. * * @return array `bar` is whether the Save bar belongs on that tab. */ function wpbr_settings_tabs() { $tabs = array( 'general' => array('label' => __('General', 'wpbr-bulk-replace'), 'bar' => true), 'matching' => array('label' => __('Matching', 'wpbr-bulk-replace'), 'bar' => true), 'backups' => array('label' => __('Backups', 'wpbr-bulk-replace'), 'bar' => true), 'ai' => array('label' => __('AI', 'wpbr-bulk-replace'), 'bar' => false), 'license' => array('label' => __('Licenses', 'wpbr-bulk-replace'), 'bar' => false), 'addons' => array('label' => __('Add-ons', 'wpbr-bulk-replace'), 'bar' => false), 'advanced' => array('label' => __('Advanced', 'wpbr-bulk-replace'), 'bar' => true), ); if (!wpbr_slot_features('settings_ai')) unset($tabs['ai']); if (!wpbr_slot_features('settings_license')) unset($tabs['license']); if (!wpbr_slot_features('settings') && !wpbr_slot_features('settings_addons')) unset($tabs['addons']); // Last, and only on the hosted edition of a site that has no Pro: see wpbr_settings_shows_pro_tab(). if (wpbr_settings_shows_pro_tab()) { $tabs['pro'] = array('label' => __('Pro', 'wpbr-bulk-replace'), 'bar' => false); } return $tabs; } /** * Is this the edition that is distributed through the WordPress.org plugin directory? * * That build's main file defines WPBRDIR_PLUGIN_FILE (bin/directory-edition.php); the hosted build never does. This is the one place the * Settings screen asks the question, so the answer cannot differ between its parts. */ function wpbr_is_directory_edition() { return defined('WPBRDIR_PLUGIN_FILE'); } /** * Does the Settings screen carry the quiet "Pro" tab, and the one sentence under Learned matching that goes with it? * * Only on the hosted edition, and only while no Pro is installed. The directory edition never shows either: it adds nothing to the * screen that points at a paid product, so the guideline against promotion inside the plugin is not in question, and docs/EXTENDING.md * ("Nothing at all... not an advertisement") hold there word for word. Where it is shown it is one panel, one link and * no control, so a site that bought Pro sees nothing of it: the feature Pro registers is what switches it off. */ function wpbr_settings_shows_pro_tab() { if (wpbr_is_directory_edition()) return false; return !wpbr_feature_enabled('learned_matching'); } /** Print the "Acts immediately" tag for a panel whose buttons post their own form, so the Save bar does not cover it. */ function wpbr_immediate_tag() { echo ' ' . esc_html__('Acts immediately', 'wpbr-bulk-replace') . ''; } /** * The Pro tab of a site without Pro: two sentences and a link. No list of add-ons, no prices, nothing that looks like a control. * (The Pro page itself is wpbr_pro_url(), the address the Review step's note already uses.) */ function wpbr_render_pro_tab() { echo "

" . esc_html__('Pro', 'wpbr-bulk-replace') . "

"; echo "

" . esc_html__('Pro adds learned matching and add-ons. Pro installs as a second plugin; you enter its license key on this screen afterwards.', 'wpbr-bulk-replace') . "

"; echo "

" . esc_html__('About Pro', 'wpbr-bulk-replace') . " " . esc_html__('(opens in a new tab)', 'wpbr-bulk-replace') . "

"; echo "
"; } /** * Which tab the page opens on: `?tab=` if it names a tab that is drawn, otherwise the first one. * * Anything else - empty, an array, upper case, a tab this site does not have - is General. The page is complete without script, so the * server decides; the script only follows. * * @param array $tabs The result of wpbr_settings_tabs(). * @return string */ function wpbr_settings_active_tab(array $tabs) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Chooses which tab to draw; changes nothing. $want = (isset($_GET['tab']) && is_string($_GET['tab'])) ? sanitize_key(wp_unslash($_GET['tab'])) : ''; return isset($tabs[$want]) ? $want : 'general'; } /** Load the tab script, with the few sentences it shows. */ function wpbr_settings_enqueue_tabs_script() { wp_enqueue_script('wpbr-settings-tabs', WPBR_PLUGIN_URL . 'assets/wpbr-settings-tabs.js', array(), WPBR_VERSION, true); wp_add_inline_script('wpbr-settings-tabs', sprintf('window.wpbrSettingsTabs=%s;', wp_json_encode(array( 'clean' => __('No unsaved changes', 'wpbr-bulk-replace'), /* translators: %s is a list of tab names, such as "General, Backups". */ 'dirty' => __('Unsaved changes in %s', 'wpbr-bulk-replace'), 'saving' => __('Saving…', 'wpbr-bulk-replace'), /* translators: %s is the name of a setting. */ 'fix' => __('Fix “%s” before saving.', 'wpbr-bulk-replace'), ), JSON_HEX_TAG)), 'before'); } /** * Where an add-on's own settings go. * * This exists so that no add-on ever has to patch this screen to have somewhere * to put a field. It is the 'settings' slot, and it behaves three ways on * purpose: * * - No add-on installed: nothing is printed. Not a heading, not an empty panel, * not a list of what the reader has not bought. * - Add-on installed and working: its fields render under this heading, inside * their own container. * - Add-on installed but unable to draw - a lapsed licence, a version pair it * will not serve: a placeholder that names it. That is the difference between * a customer filing "where did my settings go" and one who can see what * happened. */ function wpbr_render_extension_settings() { if (!wpbr_slot_features('settings') && !wpbr_slot_features('settings_addons')) return; echo "
"; echo "

" . esc_html__('Add-ons', 'wpbr-bulk-replace') . "

"; // The older single slot first, exactly as before: an add-on written against it still draws here. `settings_addons` is the same // place for one that wants only this tab. wpbr_render_slot('settings'); wpbr_render_slot('settings_addons'); echo "
"; } /** * What this server can actually do about merging PDFs. * * On the Settings screen rather than in an error after a failed merge, because * the methods are not interchangeable: four of them combine the page objects * and one rebuilds the pages as images, and which one runs decides whether the * result has selectable text. A reader who can see that before switching the * feature on can do something about it. */ function wpbr_render_pdf_merge_report() { $backends = wpbr_pdf_merge_backends(); $structural = wpbr_has_structural_pdf_merge(); $any = false; foreach ($backends as $backend) { if ($backend['available']) { $any = true; break; } } // A disclosure: the table is for the person deciding whether to switch Combine Files on. It opens by itself when there is something // they need to read first, that is, when no method keeps the text. echo "
" . esc_html__('PDF merge methods on this server', 'wpbr-bulk-replace') . ""; echo "
"; if (!$any) { echo "

" . esc_html__('None available.', 'wpbr-bulk-replace') . " " . esc_html__('Combining will refuse rather than produce anything. Everything below says what would fix it.', 'wpbr-bulk-replace') . "

"; } elseif (!$structural) { echo "

" . esc_html__('Only an image-based method is available.', 'wpbr-bulk-replace') . " " . esc_html__('Combining will work, but every page becomes a picture: no selectable text, no embedded fonts, no links, and a much larger file. Installing Ghostscript is the usual fix.', 'wpbr-bulk-replace') . "

"; } echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; foreach ($backends as $backend) { echo ""; echo ""; echo ""; echo ""; echo ""; } echo "
" . esc_html__('Method', 'wpbr-bulk-replace') . "" . esc_html__('On this server', 'wpbr-bulk-replace') . "" . esc_html__('Keeps text', 'wpbr-bulk-replace') . "" . esc_html__('Notes', 'wpbr-bulk-replace') . "
" . esc_html($backend['label']) . "" . ($backend['available'] ? '' . esc_html__('Available', 'wpbr-bulk-replace') . '' : '' . esc_html__('Unavailable', 'wpbr-bulk-replace') . '') . "" . ($backend['fidelity'] === 'structural' ? esc_html__('Yes', 'wpbr-bulk-replace') : '' . esc_html__('No', 'wpbr-bulk-replace') . '') . "" . esc_html($backend['detail']); if ($backend['remedy']) { echo "
" . esc_html($backend['remedy']) . ""; } echo "
"; echo "

" . esc_html__('They are tried in this order, and the first one that works is used.', 'wpbr-bulk-replace') . "

"; echo "
"; echo "
"; }