$post_type, 'field' => $meta_key); } function wpbr_render_new_batch_page() { if (!current_user_can('manage_options')) return; // Creating a batch is wpbr_handle_new_batch_post(), on `load-{page}`. It must not live here: // by the time this function runs the admin header has been printed and a redirect is impossible. // The first-run screen offers to start a batch already pointed at a field it // found. Carried as hidden fields rather than kept in the session, so the // form still says everything it is about to do. // // THE ARGUMENTS ARE NAMESPACED BECAUSE `post_type` BELONGS TO CORE. wp-admin/admin.php reads it while it resolves an admin screen, and // when it names a registered post type core looks the plugin page up under `admin.php?post_type=...` instead of `admin.php`, finds no // page there and ends the request with "Cannot load wpbr-new-batch." before this function runs. The first-run screen's "Start a // batch" links carried exactly that and were a dead end on a real site for every post type that exists (reproduced on WordPress 7). // The old names are still read, for a link made before the change whose post type core does not know. $preset = wpbr_requested_preset(); $preset_type = $preset['post_type']; $preset_key = $preset['field']; if ($preset_type !== '' && !post_type_exists($preset_type)) { $preset_type = ''; $preset_key = ''; } echo "
"; wpbr_admin_header_print(__('Add New Batch', 'wpbr-bulk-replace')); echo "

"; wpbr_help_link_echo('core.batches.create', __('Starting a batch', 'wpbr-bulk-replace')); echo "

"; echo "
"; wp_nonce_field('wpbr_create_batch'); echo ""; if ($preset_type !== '') { echo ""; echo ""; echo "

" . esc_html(sprintf( /* translators: 1: a post type name, e.g. "Program", 2: a meta field name. */ __('This batch will start out matching %1$s on the field %2$s. You can change both at the Match step.', 'wpbr-bulk-replace'), wpbr_post_type_label_title($preset_type), $preset_key )) . "

"; } /* translators: %s is today's date, used as a default batch name. */ $default_label = sprintf(__('New batch %s', 'wpbr-bulk-replace'), date_i18n('Y-m-d')); echo "


"; echo "

"; echo "

"; echo "
"; echo "

" . esc_html__('You will upload files and set up matching on the next screen.', 'wpbr-bulk-replace') . "

"; echo "
"; } function wpbr_render_batches_page() { if (!current_user_can('manage_options')) return; global $wpdb; if (wpbr_posted_form() === 'delete_batch') { wpbr_handle_delete_batch(); } elseif (isset($_GET['wpbr_delete'])) { $bid = intval($_GET['wpbr_delete']); if (!isset($_GET['wpbr_del_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['wpbr_del_nonce'])), 'wpbr_delete_batch_'.$bid)) { wp_die(esc_html__('Invalid or missing security token.', 'wpbr-bulk-replace')); } wpbr_render_delete_batch_confirm($bid); return; } // 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. $batches = $wpdb->get_results( "SELECT b.*, COUNT(i.id) AS item_count, SUM(CASE WHEN i.status IN ('matched','confirmed_new_attach') THEN 1 ELSE 0 END) AS to_do, SUM(CASE WHEN i.status IN ('replaced','verified','cleaned_up') THEN 1 ELSE 0 END) AS done, SUM(CASE WHEN i.status = 'needs_review' THEN 1 ELSE 0 END) AS needs_review, SUM(CASE WHEN i.status = 'needs_attention' THEN 1 ELSE 0 END) AS needs_attention, SUM(CASE WHEN i.status = 'skip' THEN 1 ELSE 0 END) AS skipped, SUM(CASE WHEN i.status = 'combined' THEN 1 ELSE 0 END) AS combined, SUM(CASE WHEN i.status = 'new_page_needed' THEN 1 ELSE 0 END) AS new_page FROM {$wpdb->prefix}wpbr_batches b LEFT JOIN {$wpdb->prefix}wpbr_batch_items i ON i.batch_id = b.id GROUP BY b.id ORDER BY b.id DESC" ); echo "
"; wpbr_admin_header_print(__('Batches', 'wpbr-bulk-replace')); echo "

"; wpbr_help_link_echo('core.batches.overview', __('What a batch is', 'wpbr-bulk-replace')); echo "

"; if (!$batches) { wpbr_render_first_run(); echo "
"; return; } echo "

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

"; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; $not_set = __('(not set)', 'wpbr-bulk-replace'); foreach ($batches as $b) { $detail_url = admin_url('admin.php?page=wpbr-batch-detail&batch_id='.$b->id); echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; $done = (int) $b->done; $todo = (int) $b->to_do; $review = (int) $b->needs_review; $total = $done + $todo; echo ""; echo ""; echo ""; $del_url = wp_nonce_url( admin_url('admin.php?page=wpbr-batches&wpbr_delete='.$b->id), 'wpbr_delete_batch_'.$b->id, 'wpbr_del_nonce' ); echo ""; echo ""; } echo "
" . esc_html__('ID', 'wpbr-bulk-replace') . "" . esc_html__('Label', 'wpbr-bulk-replace') . "" . esc_html__('Post type', 'wpbr-bulk-replace') . "" . esc_html__('Meta field', 'wpbr-bulk-replace') . "" . esc_html__('Files', 'wpbr-bulk-replace') . "" . esc_html__('Progress', 'wpbr-bulk-replace') . "" . esc_html__('Created', 'wpbr-bulk-replace') . "" . esc_html__('By', 'wpbr-bulk-replace') . "" . esc_html__('Actions', 'wpbr-bulk-replace') . "
".esc_html(number_format_i18n($b->id))."".esc_html($b->label)."".esc_html($b->post_type ? wpbr_post_type_display($b->post_type) : $not_set)."".esc_html($b->meta_key_name ? $b->meta_key_name : $not_set)."".esc_html(number_format_i18n($b->item_count)).""; if ($total > 0) { $pct = (int) round(($done / $total) * 100); // The bar is a picture of the sentence beneath it, so it is hidden // rather than announced twice. echo "
"; echo "
"; echo ""; echo esc_html(sprintf( /* translators: 1: number of files processed, 2: total number of files. */ __('%1$s of %2$s done', 'wpbr-bulk-replace'), number_format_i18n($done), number_format_i18n($total) )); if ($review) { echo " · " . esc_html(sprintf( /* translators: %s is a number of files. */ _n('%s needs review', '%s need review', $review, 'wpbr-bulk-replace'), number_format_i18n($review) )); } $aside = array(); if ((int) $b->skipped) { /* translators: %s is a number of files. */ $aside[] = sprintf(_n('%s skipped', '%s skipped', (int) $b->skipped, 'wpbr-bulk-replace'), number_format_i18n($b->skipped)); } if ((int) $b->combined) { /* translators: %s is a number of files merged into another file. */ $aside[] = sprintf(_n('%s merged', '%s merged', (int) $b->combined, 'wpbr-bulk-replace'), number_format_i18n($b->combined)); } if ((int) $b->new_page) { /* translators: %s is a number of files with no post to attach to yet. */ $aside[] = sprintf(_n('%s needs a page', '%s need a page', (int) $b->new_page, 'wpbr-bulk-replace'), number_format_i18n($b->new_page)); } if ($aside) echo "
".esc_html(implode(', ', $aside)); echo "
"; } elseif ($review) { echo "" . esc_html(sprintf( /* translators: %s is a number of files. */ _n('%s needs review', '%s need review', $review, 'wpbr-bulk-replace'), number_format_i18n($review) )) . ""; } else { echo "" . esc_html__('Not started', 'wpbr-bulk-replace') . ""; } if ((int) $b->needs_attention) { // A file that could not be put back after a failed replacement. Stated plainly and in words, not colour alone. echo "
" . esc_html(sprintf( /* translators: %s is a number of files. */ _n('%s file needs attention', '%s files need attention', (int) $b->needs_attention, 'wpbr-bulk-replace'), number_format_i18n((int) $b->needs_attention) )) . ""; } echo "
" . esc_html(mysql2date(get_option('date_format', 'M j, Y'), $b->created_at)) . "
" . esc_html(mysql2date(get_option('time_format', 'g:i a'), $b->created_at)) . "
"; if (!empty($b->actor) && wpbr_actor_label($b->actor) !== '') { echo "created_by))."\">".esc_html(wpbr_actor_label($b->actor)).""; } elseif ($b->created_by) { echo "created_by))."\">".esc_html(wpbr_user_label($b->created_by)).""; } else { echo "—" . esc_html__('Unknown', 'wpbr-bulk-replace') . ""; } echo ""; // Both controls name the batch: in a list of twelve, "Open" and // "Delete this batch" twelve times over identify nothing. echo "label )) . "\">" . esc_html__('Open', 'wpbr-bulk-replace') . " "; echo "label )) . "\">"; echo "
"; echo ""; } /** * What a site with no batches sees. * * An empty table teaches nothing, and neither does an empty state that only * describes the plugin. The question a new user actually has is "will this work * on my site", and that question has an answer that can be looked up rather than * guessed at - so this screen looks, and says what it found. * * Three outcomes, and the second two are the point: * * - Fields found: they are listed, with how many posts have one and an example * of what is in it, each starting a batch already pointed at that field. * - Nothing found, but the site has content: say so plainly, and say what the * plugin needs, so the reader can decide whether it is for them without * uploading two hundred files first. * - No content at all: a different problem, and saying "no file fields found" * to somebody with an empty site would be a misleading answer to a question * they did not ask. */ function wpbr_render_first_run() { $scan = wpbr_scan_site_for_file_fields(); // `wpbr-first-run` is how the review ask's card knows this is onboarding, // and stays out of it (assets/wpbr-advocacy.js). echo "
"; echo ""; echo "

" . esc_html__('No batches yet', 'wpbr-bulk-replace') . "

"; echo "

" . esc_html__('A batch is one set of files you want matched to existing content and swapped in. Start one, upload your files, and the wizard takes it from there.', 'wpbr-bulk-replace') . "

"; if (!empty($scan['fields'])) { wpbr_render_first_run_fields($scan); } elseif ($scan['posts'] > 0) { wpbr_render_first_run_nothing_found($scan); } else { wpbr_render_first_run_empty_site(); } echo "

" . esc_html__('Add New Batch', 'wpbr-bulk-replace') . " "; echo "" . esc_html__('Read how it works first', 'wpbr-bulk-replace') . "

"; echo "
"; } /** The good case: fields that hold files, and how much is behind each one. */ function wpbr_render_first_run_fields($scan) { echo "

" . esc_html__('What is on your site', 'wpbr-bulk-replace') . "

"; // One sentence per count. Splicing a second number into a sentence whose // plural form is chosen by the first gives "across 1 post types" on a site // with one post type, and worse in languages with more than two forms. echo "

" . esc_html(sprintf( /* translators: %s is a number of meta fields. */ _n( '%s field looks like it holds files. Start a batch from it, or add one yourself.', '%s fields look like they hold files. Start a batch from one, or add one yourself.', count($scan['fields']), 'wpbr-bulk-replace' ), number_format_i18n(count($scan['fields'])) )) . "

"; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; foreach ($scan['fields'] as $field) { $start = add_query_arg( array( 'page' => 'wpbr-new-batch', 'wpbr_post_type' => $field['post_type'], 'wpbr_meta_key' => $field['meta_key'], ), admin_url('admin.php') ); echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; } echo "
" . esc_html__('Content', 'wpbr-bulk-replace') . "" . esc_html__('Field', 'wpbr-bulk-replace') . "" . esc_html__('Has a file', 'wpbr-bulk-replace') . "" . esc_html__('For example', 'wpbr-bulk-replace') . "" . esc_html__('Start a batch', 'wpbr-bulk-replace') . "
" . esc_html(wpbr_post_type_label_title($field['post_type'])) . "" . esc_html($field['meta_key']) . "" . esc_html(sprintf( /* translators: %s is a number of posts. */ _n('%s post', '%s posts', $field['posts'], 'wpbr-bulk-replace'), number_format_i18n($field['posts']) )) . "" . ($field['sample'] !== '' ? "" . esc_html($field['sample']) . "" : "—") . "" . esc_html__('Start a batch', 'wpbr-bulk-replace') . "
"; echo "

" . esc_html__('This is a guess from what the values look like, not a promise. You can pick any field at the Match step, including one that is not listed here.', 'wpbr-bulk-replace') . "

"; wpbr_render_first_run_sample_note($scan); } /** * Say so when the counts are a sample rather than a survey. * * On a large site the scan looks at the most recent posts rather than all of * them, because the alternative is a screen that does not open. A count that is * quietly of something narrower than the reader assumes is worse than no count, * so the narrowing is stated wherever the numbers are shown. */ function wpbr_render_first_run_sample_note($scan) { if (empty($scan['sampled'])) return; echo "

" . esc_html(sprintf( /* translators: 1: a number of posts looked at, 2: the total number of published posts. */ __('Counted across the %1$s most recently published posts, of %2$s on the site. Looking at all of them would mean reading every custom field on the site each time this screen opens.', 'wpbr-bulk-replace'), number_format_i18n($scan['limit']), number_format_i18n($scan['posts']) )) . "

"; } /** * The site has content, and none of it has a file in a meta field. * * The most useful thing this screen can do is say so, because the alternative is * the reader finding out at the Match step after uploading their files. */ function wpbr_render_first_run_nothing_found($scan) { echo "
"; echo "

" . esc_html__('Nothing on this site looks like a file field yet.', 'wpbr-bulk-replace') . " "; $looked_at = empty($scan['sampled']) ? (int) $scan['posts'] : (int) $scan['limit']; echo esc_html(sprintf( /* translators: %s is a number of published posts. */ _n( 'That is after looking at %s published post.', 'That is after looking at %s published posts.', $looked_at, 'wpbr-bulk-replace' ), number_format_i18n($looked_at) )) . " "; echo esc_html(sprintf( /* translators: %s is a number of post types. */ _n( 'Your site has %s post type.', 'Your site has %s post types.', $scan['types'], 'wpbr-bulk-replace' ), number_format_i18n($scan['types']) )) . "

"; echo "

" . esc_html__('This plugin replaces files that your posts point at through a custom field holding a URL or a path. If your files are attached through the Media Library alone, or stored as an attachment ID rather than a URL, there is nothing here for it to match against.', 'wpbr-bulk-replace') . "

"; echo "

" . esc_html__('You can still start a batch and choose any field by hand at the Match step — this is a guess, not a verdict.', 'wpbr-bulk-replace') . "

"; wpbr_render_first_run_sample_note($scan); echo "
"; } /** No published content at all, which is a different problem to describe. */ function wpbr_render_first_run_empty_site() { echo "
"; echo "

" . esc_html__('There is no published content to match files against yet. Publish the posts or pages the files belong to first, then come back — matching works by comparing filenames to the titles of published content.', 'wpbr-bulk-replace') . "

"; echo "
"; } function wpbr_handle_create_batch() { check_admin_referer('wpbr_create_batch'); if (!current_user_can('manage_options')) wp_die(esc_html__('You are not allowed to do that.', 'wpbr-bulk-replace'), '', array('response' => 403)); $label = isset($_POST['batch_label']) ? sanitize_text_field(wp_unslash($_POST['batch_label'])) : ''; // A preset from the first-run screen, validated again here: the form is the // only thing that carried it, and a form field is the user's to edit. $preset_type = isset($_POST['preset_post_type']) ? sanitize_key(wp_unslash($_POST['preset_post_type'])) : ''; $preset_key = isset($_POST['preset_meta_key']) ? sanitize_text_field(wp_unslash($_POST['preset_meta_key'])) : ''; return wpbr_create_batch($label, $preset_type, $preset_key, get_current_user_id()); } function wpbr_handle_scan_folder($batch_id) { check_admin_referer('wpbr_scan_into_batch'); if (!current_user_can('manage_options')) wp_die(esc_html__('You are not allowed to do that.', 'wpbr-bulk-replace'), '', array('response' => 403)); global $wpdb; $items_table = wpbr_table_items(); $staging_dir = wpbr_staging_dir(); $files = array_merge( glob($staging_dir . '/*.pdf'), glob($staging_dir . '/*.doc'), glob($staging_dir . '/*.docx') ); if (!$files) $files = array(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; this read decides what the next write does, so it is never cached (docs/PLUGIN-CHECK.md). $already_registered = $wpdb->get_col("SELECT staged_path FROM {$wpdb->prefix}wpbr_batch_items"); $already_registered = array_flip($already_registered); $added = 0; foreach ($files as $filepath) { if (isset($already_registered[$filepath])) continue; $filename = basename($filepath); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin-owned table with no WordPress API; an insert, so there is no cached read to invalidate. $wpdb->insert($items_table, array( 'batch_id' => $batch_id, 'staged_filename' => $filename, 'staged_path' => $filepath, 'status' => 'staged', )); $added++; } echo "

" . esc_html(sprintf( /* translators: %s is a number of files. */ _n('Picked up %s new file from the staging folder. Files already registered to a batch were skipped.', 'Picked up %s new files from the staging folder. Files already registered to a batch were skipped.', $added, 'wpbr-bulk-replace'), number_format_i18n($added) )) . "

"; } function wpbr_render_delete_batch_confirm($batch_id) { global $wpdb; $batch = wpbr_get_batch($batch_id); if (!$batch) { echo "

" . esc_html__('Delete batch', 'wpbr-bulk-replace') . "

" . esc_html__('That batch no longer exists.', 'wpbr-bulk-replace') . "

"; return; } // 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. $counts = $wpdb->get_row($wpdb->prepare( "SELECT COUNT(*) AS total, SUM(CASE WHEN status IN ('replaced','verified','cleaned_up') THEN 1 ELSE 0 END) AS processed, SUM(CASE WHEN backup_path IS NOT NULL AND backup_path != '' THEN 1 ELSE 0 END) AS with_backup FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d", $batch_id )); echo "
"; wpbr_admin_header_print(sprintf( /* translators: %s is a batch label. */ __('Delete “%s”?', 'wpbr-bulk-replace'), $batch->label )); echo "

" . wp_kses_post(sprintf( /* translators: %s is a number of file records. */ _n('This removes the batch and its %s file record from the tool. It does not undo anything already done to your site — replaced files stay replaced and created attachments stay in place.', 'This removes the batch and its %s file records from the tool. It does not undo anything already done to your site — replaced files stay replaced and created attachments stay in place.', (int) $counts->total, 'wpbr-bulk-replace'), number_format_i18n($counts->total) )) . "

"; if ((int) $counts->processed > 0) { echo "
"; echo "

" . wp_kses_post(sprintf( /* translators: %s is a number of files. */ _n('%s file in this batch has already been processed. Once the batch is gone you lose the ability to roll it back from here, even if its backup remains on disk. If you might want to undo it, do that first.', '%s files in this batch have already been processed. Once the batch is gone you lose the ability to roll them back from here, even if their backups remain on disk. If you might want to undo any of them, do that first.', (int) $counts->processed, 'wpbr-bulk-replace'), number_format_i18n($counts->processed) )) . "

"; echo "
"; } echo "
"; wp_nonce_field('wpbr_delete_batch_confirm'); echo ""; echo ""; echo "

" . esc_html__('Also delete from the server?', 'wpbr-bulk-replace') . "

"; echo "


"; echo "" . esc_html__('These are the working copies in the staging folder. Safe to remove — anything already published to the site is a separate file.', 'wpbr-bulk-replace') . "

"; echo "


"; echo "" . esc_html__('Leave this unticked to keep the old versions on disk. They are the only copies of what was there before.', 'wpbr-bulk-replace') . "

"; echo "


"; echo "" . esc_html__('Unticked keeps the record of what was done, which is usually worth having.', 'wpbr-bulk-replace') . "

"; echo "

"; // No confirm() here: this screen is the confirmation. A second dialog asking // the same question in English only was noise on top of an untranslatable // string. echo " "; echo "" . esc_html__('Cancel', 'wpbr-bulk-replace') . ""; echo "

"; echo "
"; } function wpbr_handle_delete_batch() { check_admin_referer('wpbr_delete_batch_confirm'); if (!current_user_can('manage_options')) wp_die(esc_html__('You are not allowed to do that.', 'wpbr-bulk-replace'), '', array('response' => 403)); global $wpdb; $batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0; if (!$batch_id) return; $items_table = wpbr_table_items(); $items = wpbr_get_batch_items($batch_id); $staged_removed = 0; $backups_removed = 0; $backups_kept = 0; foreach ($items as $item) { if (!empty($_POST['drop_staged']) && $item->staged_path && file_exists($item->staged_path) && wpbr_path_is_within(wpbr_staging_dir(), $item->staged_path)) { wp_delete_file(realpath($item->staged_path)); $staged_removed++; } if (!empty($_POST['drop_backups']) && $item->backup_path && file_exists($item->backup_path)) { $removed = wpbr_delete_backup($item->backup_path); if ($removed['deleted']) { $backups_removed++; } elseif ($removed['reason'] === 'protected') { $backups_kept++; } } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->delete($items_table, array('batch_id' => $batch_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->delete(wpbr_table_import_log(), array('batch_id' => $batch_id)); if (!empty($_POST['drop_log'])) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->delete(wpbr_table_log(), array('batch_id' => $batch_id)); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->delete(wpbr_table_batches(), array('id' => $batch_id)); // Assembled from whole sentences rather than sentence fragments, so each // one can be translated as a sentence. $parts = array(__('Batch deleted.', 'wpbr-bulk-replace')); if ($staged_removed) { /* translators: %s is a number of files. */ $parts[] = sprintf(_n('%s staged file removed.', '%s staged files removed.', $staged_removed, 'wpbr-bulk-replace'), number_format_i18n($staged_removed)); } if ($backups_removed) { /* translators: %s is a number of files. */ $parts[] = sprintf(_n('%s backup file removed.', '%s backup files removed.', $backups_removed, 'wpbr-bulk-replace'), number_format_i18n($backups_removed)); } if ($backups_kept) { /* translators: %s is a number of files. */ $parts[] = sprintf(_n('%s backup was kept: it is the only verified copy of a file that could not be put back.', '%s backups were kept: each is the only verified copy of a file that could not be put back.', $backups_kept, 'wpbr-bulk-replace'), number_format_i18n($backups_kept)); } if (!empty($_POST['drop_backups']) && !$backups_removed && !$backups_kept) { $parts[] = __('No backup files were found to remove.', 'wpbr-bulk-replace'); } echo "

".esc_html(implode(' ', $parts))."

"; }