get_var($wpdb->prepare('SHOW TABLES LIKE %s', $batches_table)); if ($found !== $batches_table) { return; // Fresh install: nothing to adopt. } update_option('wpbr_adopted_notice', array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; an activation-time look at what already exists, which must be current. 'batches' => (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}wpbr_batches"), 'items' => (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}wpbr_batch_items"), )); } add_action('admin_init', 'wpbr_dismiss_adoption_notice'); function wpbr_dismiss_adoption_notice() { if (empty($_GET['wpbr_dismiss_adopted'])) return; if (!current_user_can('manage_options')) return; if (!check_admin_referer('wpbr_dismiss_adopted')) return; delete_option('wpbr_adopted_notice'); wp_safe_redirect(remove_query_arg(array('wpbr_dismiss_adopted', '_wpnonce'))); exit; } /** * Catch installs updated in place rather than reactivated. * * Priority 1 so it precedes wpbr_maybe_upgrade_db(), which would otherwise * create an empty set of wpbr_ tables alongside the legacy data and leave the * site looking as though every batch had vanished. */ add_action('admin_init', 'wpbr_maybe_migrate_legacy', 1); function wpbr_maybe_migrate_legacy() { if (get_option('wpbr_schema_version') === WPBR_SCHEMA_VERSION) return; $result = wpbr_run_migration(); if ($result['status'] === 'migrated') { delete_option('wpbr_migration_failure'); update_option('wpbr_migration_notice', $result); } elseif ($result['status'] !== 'skipped') { // incomplete, failed, rolled_back and critical each mean something // different to whoever has to act on them, so the status travels with // the message rather than being flattened to "failed". update_option('wpbr_migration_failure', array( 'status' => $result['status'], 'detail' => $result['detail'], )); } } add_action('admin_init', 'wpbr_dismiss_migration_notice'); function wpbr_dismiss_migration_notice() { if (empty($_GET['wpbr_dismiss_migration'])) return; if (!current_user_can('manage_options')) return; if (!check_admin_referer('wpbr_dismiss_migration')) return; delete_option('wpbr_migration_notice'); wp_safe_redirect(remove_query_arg(array('wpbr_dismiss_migration', '_wpnonce'))); exit; } add_action('admin_init', 'wpbr_maybe_upgrade_db'); function wpbr_maybe_upgrade_db() { if (get_option('wpbr_db_version') !== WPBR_DB_VERSION) { wpbr_create_tables(); wpbr_ensure_folders(); } // Directory hardening must not depend on the schema version changing - // an install updating from a build that predates wpbr_protect_dir() would // otherwise leave its backups folder web-readable indefinitely. if (get_option('wpbr_dirs_protected') !== WPBR_PROTECT_VERSION) { wpbr_protect_dir(wpbr_staging_dir()); wpbr_protect_dir(wpbr_backups_dir()); update_option('wpbr_dirs_protected', WPBR_PROTECT_VERSION); } } /** * Whether the admin screen being drawn is one of this plugin's. * * Asked of WordPress's own `$plugin_page`, which admin.php sets from the page * the request named, rather than by reading the address a second time: the * question is "which screen is this", and core has already answered it. Every * screen here has a slug beginning "wpbr-". */ function wpbr_is_plugin_screen() { global $plugin_page; return is_string($plugin_page) && strpos($plugin_page, 'wpbr-') === 0; } add_action('admin_footer', 'wpbr_notice_behaviour'); function wpbr_notice_behaviour() { if (!wpbr_is_plugin_screen()) return; wpbr_inline_open('style'); ?> .wpbr-notice-fading { transition: opacity .6s ease; } .wrap .notice.is-dismissible { padding-right: 38px; } (function(){ var AUTO_HIDE_MS = 10000; function enhance(notice) { if (notice.dataset.wpbrEnhanced) return; if (notice.classList.contains('wpbr-no-dismiss')) return; notice.dataset.wpbrEnhanced = '1'; if (!notice.classList.contains('is-dismissible')) { notice.classList.add('is-dismissible'); var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'notice-dismiss'; btn.innerHTML = ''; btn.addEventListener('click', function(){ notice.parentNode && notice.parentNode.removeChild(notice); }); notice.appendChild(btn); } if (notice.classList.contains('notice-error') || notice.classList.contains('wpbr-persist')) return; setTimeout(function(){ notice.classList.add('wpbr-notice-fading'); notice.style.opacity = '0'; setTimeout(function(){ notice.parentNode && notice.parentNode.removeChild(notice); }, 650); }, AUTO_HIDE_MS); } document.querySelectorAll('.wrap .notice, .wrap .updated, .wrap .error').forEach(enhance); // Search box above any meta-field checkbox list document.addEventListener('input', function(e){ if (!e.target.classList.contains('wpbr-field-search')) return; var box = e.target; var list = box.nextElementSibling; if (!list) return; var q = (box.value || '').trim().toLowerCase(); var shown = 0, total = 0; list.querySelectorAll('.wpbr-field-opt').forEach(function(opt){ total++; var hit = (q === '') || (opt.dataset.field || '').indexOf(q) !== -1; // never hide a field that's currently ticked var cb = opt.querySelector('input[type=checkbox]'); if (cb && cb.checked) hit = true; opt.style.display = hit ? '' : 'none'; if (hit) shown++; }); box.title = (q === '') ? '' : (shown + ' of ' + total + ' fields'); }); })();

' . wp_kses_post(sprintf( /* translators: 1: "Staging" or "Backups", 2: a PHP constant name, 3: the value it was set to. */ __('%1$s folder constant ignored. %2$s is set to %3$s, which is not an absolute path, so the folder configured in Settings is being used instead.', 'wpbr-bulk-replace'), esc_html($label), esc_html($status['constant']), esc_html($status['raw']) )) . '

'; } elseif ($status['problem'] === 'not_writable') { // Two whole sentences per folder rather than one with a spliced // verb: "staged"/"backed up" cannot be swapped into a translated // sentence and still agree with the rest of it. $unwritable = ($which === 'staging') ? sprintf( /* translators: 1: "Staging", 2: a PHP constant name, 3: a filesystem path. */ __('%1$s folder is not writable. %2$s points at %3$s, which cannot be written to. Nothing can be staged until that is fixed.', 'wpbr-bulk-replace'), esc_html($label), esc_html($status['constant']), esc_html($status['path']) ) : sprintf( /* translators: 1: "Backups", 2: a PHP constant name, 3: a filesystem path. */ __('%1$s folder is not writable. %2$s points at %3$s, which cannot be written to. Nothing can be backed up until that is fixed.', 'wpbr-bulk-replace'), esc_html($label), esc_html($status['constant']), esc_html($status['path']) ); echo '

' . wp_kses_post($unwritable) . '

'; } } // A configured folder that failed validation is silently ignored - say so, // because new backups would be going somewhere the admin did not choose. $opts = get_option('wpbr_settings', array()); foreach (array('staging', 'backups') as $which) { $key = $which . '_dir'; $label = wpbr_dir_display_name($which); if (empty($opts[$key])) continue; if (wpbr_dir_is_pinned($which)) continue; // the constant wins; the field is moot if (wpbr_validate_custom_dir($opts[$key]) === '') { echo '

' . wp_kses_post(sprintf( /* translators: 1: "Staging" or "Backups", 2: the configured path, 3: a PHP constant name. */ __('%1$s folder setting ignored. %2$s is outside wp-content, so the default is being used instead. To put it outside wp-content deliberately, set %3$s in wp-config.php; otherwise update it under Bulk Replace → Settings.', 'wpbr-bulk-replace'), esc_html($label), esc_html($opts[$key]), 'WPBR_' . esc_html(strtoupper($which)) . '_DIR' )) . '

'; } } // An add-on that was refused because it needs a newer contract than this // release honours. Refusing is right (see includes/extensions.php); refusing // silently is not - the customer installed something they paid for, nothing // happened, and "update the free plugin" is not a fix they would guess at. $refused = wpbr_refused_features(); if ($refused) { $names = array(); foreach ($refused as $feature) { $names[] = $feature['title']; } echo '

' . wp_kses_post(sprintf( /* translators: 1: a comma-separated list of add-on names, 2: the version of the extension contract this release honours. */ _n( 'An add-on needs a newer version of WPBR Bulk Replace. %1$s was written against a later extension API than this release provides (this one supports version %2$s), so it has not been loaded. Updating WPBR Bulk Replace should fix it. Nothing else is affected.', 'Some add-ons need a newer version of WPBR Bulk Replace. %1$s were written against a later extension API than this release provides (this one supports version %2$s), so they have not been loaded. Updating WPBR Bulk Replace should fix it. Nothing else is affected.', count($refused), 'wpbr-bulk-replace' ), esc_html(implode(', ', $names)), esc_html(number_format_i18n(WPBR_EXTENSION_API)) )) . '

'; } $failure = get_option('wpbr_migration_failure'); if (is_array($failure) && !empty($failure['status'])) { $status = $failure['status']; if ($status === 'incomplete') { $headline = __('WPBR Bulk Replace has not finished migrating its settings.', 'wpbr-bulk-replace'); $schema = __('Your batches and history were moved successfully. The settings that could not be copied are still stored under their original names, and this retries automatically on the next page load.', 'wpbr-bulk-replace'); $class = 'notice-warning'; } elseif ($status === 'rolled_back') { $headline = __('WPBR Bulk Replace could not verify its data migration, so it undid it.', 'wpbr-bulk-replace'); $schema = __('The database was put back exactly as it was, so nothing is lost and the previous plugin version can still read it.', 'wpbr-bulk-replace'); $class = 'notice-error'; } elseif ($status === 'critical') { $headline = __('WPBR Bulk Replace is in an unverified state and needs manual attention.', 'wpbr-bulk-replace'); // The detail carried with this status already lists the exact // rename pairs. It used to point at docs/MIGRATION.md, which is // excluded from the release zip - so the most important message in // the plugin named a file the admin does not have. $schema = __('The tables were renamed but could not be verified, and undoing the rename also failed. Do not process any batches. The tables that need renaming back are listed above.', 'wpbr-bulk-replace'); $class = 'notice-error'; } else { // failed $headline = __('WPBR Bulk Replace could not migrate its data.', 'wpbr-bulk-replace'); $schema = __('Nothing was changed and your existing data is intact.', 'wpbr-bulk-replace'); $class = 'notice-error'; } echo '

' . esc_html($headline) . ' ' . esc_html($failure['detail']) . ' ' . wp_kses_post($schema) . '

'; } $migrated = get_option('wpbr_migration_notice'); if (is_array($migrated)) { $dismiss = wp_nonce_url(add_query_arg('wpbr_dismiss_migration', '1'), 'wpbr_dismiss_migration'); echo '

' . wp_kses_post(sprintf( /* translators: 1: number of database tables, 2: number of rows, 3: number of settings. */ __('Data migrated to WPBR Bulk Replace. %1$s tables carrying %2$s rows, and %3$s settings, were renamed from their legacy names. Row counts were verified after the move.', 'wpbr-bulk-replace'), esc_html(number_format_i18n($migrated['tables'])), esc_html(number_format_i18n($migrated['rows'])), esc_html(number_format_i18n($migrated['options'])) )) . ' ' . esc_html__('Dismiss', 'wpbr-bulk-replace') . '

'; } $adopted = get_option('wpbr_adopted_notice'); if (is_array($adopted)) { $dismiss = wp_nonce_url(add_query_arg('wpbr_dismiss_adopted', '1'), 'wpbr_dismiss_adopted'); echo '

' . wp_kses_post(sprintf( /* translators: 1: number of batches, 2: number of file records. */ __('Existing data adopted. WPBR Bulk Replace picked up %1$s batches and %2$s file records from your previous Bulk Attachment Manager install, including history, backups and settings. Nothing was re-created or lost.', 'wpbr-bulk-replace'), esc_html(number_format_i18n($adopted['batches'])), esc_html(number_format_i18n($adopted['items'])) )) . ' ' . esc_html__('Dismiss', 'wpbr-bulk-replace') . '

'; } $exposure = wpbr_check_backups_exposure(); $status = !empty($exposure['status']) ? $exposure['status'] : 'unknown'; if ($status === 'exposed') { echo '

' . esc_html__('Backup files are readable over the web.', 'wpbr-bulk-replace') . ' ' . esc_html($exposure['detail']) . ' ' . wp_kses_post(sprintf( /* translators: %s is a folder name. */ __('Backups are copies of live documents, so anyone who guesses a filename can download them. The plugin writes .htaccess and web.config rules, but those are ignored by nginx — ask whoever runs the server to deny access to %s, or move the folder outside uploads using the Backups folder setting.', 'wpbr-bulk-replace'), esc_html(basename(wpbr_backups_dir())) )) . '

'; } elseif ($status === 'unknown') { // Silence here would read as a pass, which is the failure mode the // check exists to prevent. $advice = (isset($exposure['reason']) && $exposure['reason'] === 'unmappable') // There is no URL to try, so "open it in a private window" is no // help. Only whoever configured the server can answer this. ? sprintf( /* translators: %s is an absolute filesystem path. */ __('Ask whoever runs the server whether %s falls under a document root or an alias. If it does, it needs denying; if it does not, nothing further is needed.', 'wpbr-bulk-replace'), esc_html(wpbr_backups_dir()) ) : sprintf( /* translators: %s is a folder name. */ __('This often means loopback requests are blocked, or the site sits behind HTTP auth or a firewall — common on staging. Check by hand: open a file from %s in a private window. If it downloads, the folder needs denying in the server config.', 'wpbr-bulk-replace'), esc_html(basename(wpbr_backups_dir())) ); echo '

' . esc_html__('Could not verify that backups are private.', 'wpbr-bulk-replace') . ' ' . esc_html($exposure['detail']) . ' ' . wp_kses_post($advice) . '

'; } } add_action('admin_menu', 'wpbr_admin_menu'); function wpbr_admin_menu() { // The parent points at Batches - that is the list you want on landing, // not the form for starting a new one. add_menu_page( __('WPBR Bulk Replace', 'wpbr-bulk-replace'), __('Bulk Replace', 'wpbr-bulk-replace'), 'manage_options', 'wpbr-batches', 'wpbr_render_batches_page', 'dashicons-media-document', 58 ); $batches_hook = add_submenu_page('wpbr-batches', __('Batches', 'wpbr-bulk-replace'), __('Batches', 'wpbr-bulk-replace'), 'manage_options', 'wpbr-batches', 'wpbr_render_batches_page'); $new_batch_hook = add_submenu_page('wpbr-batches', __('Add New Batch', 'wpbr-bulk-replace'), __('Add New Batch', 'wpbr-bulk-replace'), 'manage_options', 'wpbr-new-batch', 'wpbr_render_new_batch_page'); // '' rather than null for the parent, which is what registers a page that is // reachable but not listed in the sidebar. Both produce the identical hook // name, because add_submenu_page() runs the parent through plugin_basename() // and wp_normalize_path() turns either into ''. The difference is that null // reaches strpos() and str_replace() as null, which PHP 8.1 deprecates - two // notices in the error log on every single admin page load. $detail_hook = add_submenu_page('', __('Batch Detail', 'wpbr-bulk-replace'), __('Batch Detail', 'wpbr-bulk-replace'), 'manage_options', 'wpbr-batch-detail', 'wpbr_render_batch_detail_page'); add_submenu_page('wpbr-batches', __('Activity Log', 'wpbr-bulk-replace'), __('Activity Log', 'wpbr-bulk-replace'), 'manage_options', 'wpbr-log', 'wpbr_render_log_page'); add_submenu_page('wpbr-batches', __('Settings', 'wpbr-bulk-replace'), __('Settings', 'wpbr-bulk-replace'), 'manage_options', 'wpbr-settings', 'wpbr_render_settings_page'); add_submenu_page('wpbr-batches', __('Help', 'wpbr-bulk-replace'), __('Help', 'wpbr-bulk-replace'), 'manage_options', 'wpbr-help', 'wpbr_render_help_page'); // Before WordPress prints the admin header, so Start Batch can redirect (see the function). add_action('load-' . $new_batch_hook, 'wpbr_handle_new_batch_post'); add_action('load-' . $new_batch_hook, 'wpbr_add_batches_help_tab'); add_action('load-' . $detail_hook, 'wpbr_set_detail_page_title'); add_action('load-' . $detail_hook, 'wpbr_add_detail_help_tab'); } /** * Give the Batch Detail screen the title WordPress cannot work out for it. * * The screen is registered with an empty parent so it is reachable but not listed. WordPress then cannot place it in the menu, * get_admin_page_title() finds nothing, and the global $title stays null: admin-header.php runs strip_tags() on it (a deprecation on * PHP 8.1+, once per page load in the debug log) and the browser tab has no page name. Set before the header prints, like the form * handler above it. tests/integration/HiddenScreenTitleTest.php. */ function wpbr_set_detail_page_title() { global $title; if (empty($title)) { // admin-header.php reads this global directly (strip_tags($title)); a hidden screen has none to read, so it is set here and only here. $title = __('Batch Detail', 'wpbr-bulk-replace'); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- admin-header.php reads this global directly and a hidden screen has none, so it is set here and only here. } } function wpbr_add_batches_help_tab() { $screen = get_current_screen(); if (!$screen) return; $screen->add_help_tab(array( 'id' => 'wpbr-batches-overview', 'title' => __('Overview', 'wpbr-bulk-replace'), 'content' => '

' . wp_kses_post(__('Give the batch a label and click “Start Batch” — you land in that batch’s wizard on the Upload step. See the Help page in the left menu for the full walkthrough.', 'wpbr-bulk-replace')) . '

', )); } function wpbr_add_detail_help_tab() { $screen = get_current_screen(); if (!$screen) return; $screen->add_help_tab(array( 'id' => 'wpbr-detail-steps', 'title' => __('The four steps', 'wpbr-bulk-replace'), 'content' => '

' . wp_kses_post(__('Upload — drag files in, or pick up files placed in the staging folder by FTP.', 'wpbr-bulk-replace')) . '

' . '

' . wp_kses_post(__('Match — choose the post type and the meta field holding the file URL, then Run Matching. Safe to re-run: rows you set by hand are protected.', 'wpbr-bulk-replace')) . '

' . '

' . wp_kses_post(__('Review & Resolve — correct matches, filter by confidence, status or the matched post’s own fields, and send a CSV to a reviewer if you need a second opinion.', 'wpbr-bulk-replace')) . '

' . '

' . wp_kses_post(__('Backup & Replace — tick the rows you want and process them. Do one first to check it, then the rest. Progress shows in the toolbar and rows update as they finish. Everything processed can be rolled back from the same screen.', 'wpbr-bulk-replace')) . '

' . '

' . esc_html__('Verifying and deleting backups happen on that same screen once rows are processed.', 'wpbr-bulk-replace') . '

', )); $screen->add_help_tab(array( 'id' => 'wpbr-detail-gotchas', 'title' => __('Things to watch', 'wpbr-bulk-replace'), 'content' => '

' . wp_kses_post(__('Conflicts block replacing. Two files matched to the same post would overwrite each other, so the Backup & Replace step stops until you fix them.', 'wpbr-bulk-replace')) . '

' . '

' . wp_kses_post(__('“No existing file” needs confirming. When a post has nothing in the configured field, replacing becomes creating — a separate approval per row, or in bulk.', 'wpbr-bulk-replace')) . '

' . '

' . wp_kses_post(__('Imports can be undone. Every reviewer CSV import is listed with an Undo button, so a bad import is reversible later, not just immediately.', 'wpbr-bulk-replace')) . '

' . '

' . wp_kses_post(__('Backups are kept until you say otherwise. Nothing is deleted on a timer; cleanup is always a deliberate click — but note that deleting a backup also removes the ability to roll that file back.', 'wpbr-bulk-replace')) . '

', )); }