window.wpbrSaveFilters = function(key, state){ try { window.localStorage.setItem('wpbr_filters_' + key, JSON.stringify(state)); } catch (e) { /* private mode or storage full - filters just won't persist */ } }; window.wpbrLoadFilters = function(key){ try { var raw = window.localStorage.getItem('wpbr_filters_' + key); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } }; window.wpbrClearFilters = function(key){ try { window.localStorage.removeItem('wpbr_filters_' + key); } catch (e) {} }; __('Upload', 'wpbr-bulk-replace'), 'match' => __('Match', 'wpbr-bulk-replace'), 'review' => __('Review & Resolve', 'wpbr-bulk-replace'), 'replace' => __('Backup & Replace', 'wpbr-bulk-replace'), ); } /** One step's title, for prose that names a step the reader has to click. */ function wpbr_wizard_step_title($step) { $steps = wpbr_wizard_steps(); return isset($steps[$step]) ? $steps[$step] : $step; } /** * Prints one filter chip. * * The chips are toggles, so they are real buttons carrying aria-pressed: as * plain spans they could not be reached or operated from the keyboard at all, * and a screen reader had no way to tell an engaged filter from an idle one. * The count is decorative next to the label the button already announces. * * $attrs are the data- attributes the filtering script reads; they are stable * selector keys, never translated. * * It prints rather than returns, so every dynamic part is escaped at the * point it is written out and not somewhere a caller has to trust. */ function wpbr_filter_chip($attrs, $count, $label, $dot_style) { echo ''; } /** * Prints the sprintf-alike the inline scripts format their translated templates with. * * It understands both %s and the numbered %1$s form, because a translator may * need to reorder placeholders that PHP wrote in one order, and collapses %% * to a literal percent sign so the strings follow the same convention as the * ones PHP formats. A fixed JavaScript literal: nothing is interpolated. */ function wpbr_js_fmt_helper() { echo 'function fmt(tpl){ var a = arguments; return String(tpl).replace(/%%|%(\d+)\$s|%s/g, function(m, n){ return m === "%%" ? "%" : a[n ? Number(n) : 1]; }); }'; } function wpbr_step_url($batch_id, $step) { return admin_url('admin.php?page=wpbr-batch-detail&batch_id='.$batch_id.'&step='.$step); } /** * The inline-error styling, emitted at most once per request. * * Both the Match step and the Review step show inline validation, and they * render in separate requests. A rule defined inside one step's style block * is simply absent on the other, which is how an error message ends up * rendering as unstyled body text exactly when it matters. */ function wpbr_print_inline_error_style() { static $printed = false; if ($printed) return; $printed = true; wpbr_inline_open('style'); echo ".wpbr-inline-error{display:inline-block;margin-left:8px;color:#b32d2e;font-weight:600;}"; echo "p.wpbr-inline-error{display:block;margin-left:0;}"; wpbr_inline_close(); } function wpbr_render_batch_detail_page() { if (!current_user_can('manage_options')) return; global $wpdb; $batch_id = isset($_GET['batch_id']) ? intval($_GET['batch_id']) : 0; if (!$batch_id) { echo "

" . wp_kses_post(sprintf( /* translators: %s is a link to the batch list, with its own translated label. */ __('No batch selected. %s.', 'wpbr-bulk-replace'), '' . esc_html__('Back to batches', 'wpbr-bulk-replace') . '' )) . "

"; return; } if (isset($_GET['wpbr_export']) && $_GET['wpbr_export'] === 'csv') { if (!isset($_GET['wpbr_export_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['wpbr_export_nonce'])), 'wpbr_export_csv_'.$batch_id)) { wp_die(esc_html__('Invalid or missing security token.', 'wpbr-bulk-replace')); } wpbr_export_batch_csv($batch_id); exit; } if (isset($_GET['wpbr_undo_import']) && isset($_GET['wpbr_undo_nonce'])) { $import_uuid_to_undo = sanitize_text_field(wp_unslash($_GET['wpbr_undo_import'])); if (wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['wpbr_undo_nonce'])), 'wpbr_undo_import_'.$import_uuid_to_undo)) { wpbr_handle_undo_import($batch_id, $import_uuid_to_undo); } else { wp_die(esc_html__('Invalid or missing security token.', 'wpbr-bulk-replace')); } } // Each handler verifies its own nonce before it reads the request. $form = wpbr_posted_form(); if ($form === 'scan_into_batch') { wpbr_handle_scan_folder($batch_id); } if ($form === 'run_matching') { wpbr_handle_run_matching($batch_id); } if ($form === 'save_review') { wpbr_handle_save_review($batch_id); } if ($form === 'import_csv') { wpbr_handle_import_csv($batch_id); } $batch = wpbr_get_batch($batch_id); if (!$batch) { echo "

" . esc_html__('Batch not found.', '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. $items = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d ORDER BY FIELD(status, 'staged') DESC, status, id DESC", $batch_id)); $candidates = ($batch->post_type && $batch->meta_key_name) ? wpbr_get_post_candidates($batch->post_type, $batch->meta_key_name) : array(); $steps = wpbr_wizard_steps(); $step_keys = array_keys($steps); $needs_review_count = 0; $eligible_count = 0; $replaced_count = 0; $combined_away_count = 0; foreach ($items as $i) { if ($i->status === 'combined') { $combined_away_count++; continue; } $unresolved_new_attach = ($i->status === 'matched' && wpbr_item_needs_new_attach($i, $batch)); if ($i->status === 'needs_review' || $unresolved_new_attach) $needs_review_count++; if (in_array($i->status, array('matched','confirmed_new_attach'), true) && !$unresolved_new_attach) $eligible_count++; if (in_array($i->status, array('replaced','verified'), true)) $replaced_count++; } $visible_count = count($items) - $combined_away_count; if (isset($_GET['step'])) { $current_step = sanitize_key(wp_unslash($_GET['step'])); if (!array_key_exists($current_step, $steps)) $current_step = 'upload'; } else { if (empty($items)) $current_step = 'upload'; // A batch with files that have never been matched is at Match, whatever it is already pointed at: starting from the first-run // screen presets the post type and field, and without this a batch reopened after its upload opened on Backup & Replace with // nothing matched (reproduced on a real site). elseif (array_filter($items, function ($item) { return $item->status === 'staged'; })) $current_step = 'match'; elseif (!$batch->post_type || !$batch->meta_key_name) $current_step = 'match'; elseif ($needs_review_count > 0) $current_step = 'review'; elseif ($eligible_count > 0) $current_step = 'replace'; else $current_step = 'replace'; } $current_index = array_search($current_step, $step_keys, true); $prev_step = $current_index > 0 ? $step_keys[$current_index - 1] : null; $next_step = $current_index < count($step_keys) - 1 ? $step_keys[$current_index + 1] : null; wpbr_print_filter_storage_js(); $not_set = __('(not set)', 'wpbr-bulk-replace'); $meta_keys_shown = $batch->meta_key_name ? wpbr_split_meta_keys($batch->meta_key_name) : array( $not_set ); // The meta line, as chips. The same sentences as before, in the same order; only the separators ("|") became chip edges. $chips = array(); $chips[] = esc_html__('Post type:', 'wpbr-bulk-replace') . " " . esc_html($batch->post_type ? $batch->post_type : $not_set) . ""; $chips[] = esc_html__('Meta field(s):', 'wpbr-bulk-replace') . " " . esc_html(implode(' → ', $meta_keys_shown)) . ""; $files_chip = esc_html(sprintf( /* translators: %s is a number of files. */ _n('%s file total', '%s files total', $visible_count, 'wpbr-bulk-replace'), number_format_i18n($visible_count) )); if ($combined_away_count > 0) { $files_chip .= " (" . esc_html(sprintf( /* translators: %s is a number of files. */ _n('%s combined into a new file, hidden from view', '%s combined into new files, hidden from view', $combined_away_count, 'wpbr-bulk-replace'), number_format_i18n($combined_away_count) )) . ")"; } $chips[] = $files_chip; if ($batch->created_by) { $started = esc_html(sprintf( /* translators: %s is a user's display name. */ __('Started by %s', 'wpbr-bulk-replace'), (!empty($batch->actor) && wpbr_actor_label($batch->actor) !== '') ? wpbr_actor_label($batch->actor) : wpbr_user_label($batch->created_by) )); if ($batch->created_at) { $started .= " " . esc_html(sprintf( /* translators: %s is a date and time. */ __('on %s', 'wpbr-bulk-replace'), mysql2date(wpbr_datetime_format(), $batch->created_at) )); } $chips[] = $started; } echo "
"; wpbr_admin_header_print( sprintf( /* translators: %s is the name the reader gave this batch. */ __('Batch: %s', 'wpbr-bulk-replace'), $batch->label ), sprintf( /* translators: %d is a batch number. */ __('Batch %d', 'wpbr-bulk-replace'), (int) $batch_id ), $chips ); wpbr_inline_open('style'); echo ".wpbr-steps{display:flex;gap:6px;margin:16px 0;flex-wrap:wrap;}"; echo ".wpbr-step, .wpbr-step:visited{padding:8px 16px;background:#f0f0f1;border:1px solid #c3c4c7;border-radius:4px;text-decoration:none;color:#1d2327;font-weight:500;}"; echo ".wpbr-step:hover{background:#e0e0e0;color:#1d2327;}"; echo ".wpbr-step-active, .wpbr-step-active:visited, .wpbr-step-active:hover, .wpbr-step-active:focus{background:#2271b1;border-color:#2271b1;color:#fff;}"; echo ".wpbr-step-badge{display:inline-block;background:#d63638;color:#fff;border-radius:10px;padding:0 7px;font-size:11px;margin-left:6px;}"; echo ".wpbr-panel{background:#fff;border:1px solid #c3c4c7;padding:20px;margin-bottom:16px;}"; echo ".wpbr-nav-buttons{margin-top:16px;}"; wpbr_inline_close(); echo ""; echo "
"; if ($current_step === 'upload') { wpbr_render_step_upload($batch, $batch_id, $items); } elseif ($current_step === 'match') { wpbr_render_step_match($batch, $batch_id, $candidates); } elseif ($current_step === 'review') { wpbr_render_step_review($batch, $batch_id, $items, $candidates); } elseif ($current_step === 'replace') { wpbr_render_step_replace($batch_id, $eligible_count, $items, $batch); } echo "
"; echo "
"; if ($prev_step) { echo "← ".esc_html(sprintf( /* translators: %s is the title of the previous wizard step, e.g. "Match". */ __('Back: %s', 'wpbr-bulk-replace'), $steps[$prev_step] ))." "; } if ($next_step) { echo "".esc_html(sprintf( /* translators: %s is the title of the next wizard step, e.g. "Review & Resolve". */ __('Continue: %s', 'wpbr-bulk-replace'), $steps[$next_step] ))." →"; } echo "
"; echo "
"; } function wpbr_render_step_upload($batch, $batch_id, $items) { echo "

Step 1: Upload

"; wpbr_help_link_echo('core.batches.stage-files', __('How staging works', 'wpbr-bulk-replace')); echo "

" . esc_html__('Drag files here, or click to browse. Uploads start automatically, one at a time in the background.', 'wpbr-bulk-replace') . "

"; echo "
"; echo ""; echo "

" . esc_html__('Drop PDF, DOC, or DOCX files here, or click to select', 'wpbr-bulk-replace') . "

"; echo "
"; echo ""; echo " "; // Scrollable, so it needs to be reachable and scrollable from the keyboard. echo "
";
    // Where a failed upload is said, and kept. The page used to reload the moment the last file finished, which threw the log (and
    // any error in it) away; now the reload waits for a person when something failed. Always rendered, empty until needed.
    echo "
"; wpbr_inline_open('script'); echo "var wpbrUploadL10n = " . wp_json_encode(wpbr_upload_js_l10n(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";"; echo "(function(){"; echo "var L = wpbrUploadL10n;"; wpbr_js_fmt_helper(); echo "var uploadNonce = '".esc_js(wp_create_nonce('wpbr_upload_file'))."';"; echo "var batchId = ".intval($batch_id).";"; echo "var input = document.getElementById('wpbr_file_input');"; echo "var dropzone = document.getElementById('wpbr_dropzone');"; echo "var bar = document.getElementById('wpbr_upload_progress');"; echo "var label = document.getElementById('wpbr_upload_label');"; echo "var log = document.getElementById('wpbr_upload_log');"; echo "var uploading = false;"; echo "var alertBox = document.getElementById('wpbr_upload_alert');"; echo "function showFailures(failed, total){"; echo "alertBox.textContent = '';"; echo "alertBox.className = 'wpbr-inline-error';"; echo "var head = document.createElement('strong');"; echo "head.textContent = total === 1 ? L.failedOne : fmt(L.failedSome, failed.length, total);"; echo "alertBox.appendChild(head);"; echo "var list = document.createElement('ul'); list.style.cssText = 'list-style:disc;margin:6px 0 10px 20px;font-weight:400;';"; echo "failed.forEach(function(f){ var li = document.createElement('li'); li.textContent = fmt(L.uploadFailed, f.name, f.reason); list.appendChild(li); });"; echo "alertBox.appendChild(list);"; echo "var go = document.createElement('button'); go.type = 'button'; go.id = 'wpbr_upload_continue'; go.className = 'button button-primary'; go.textContent = L.continueLabel;"; echo "go.addEventListener('click', function(){ window.location.reload(); });"; echo "alertBox.appendChild(go);"; echo "}"; echo "function uploadOneXHR(file, bytesBefore, totalBytes, onProgress){"; echo "return new Promise(function(resolve){"; echo "var xhr = new XMLHttpRequest();"; echo "xhr.open('POST', ajaxurl, true);"; echo "xhr.upload.addEventListener('progress', function(e){"; echo "if (e.lengthComputable) { onProgress(bytesBefore + e.loaded, totalBytes); }"; echo "});"; echo "xhr.onload = function(){"; echo "var resp = null;"; echo "try { resp = JSON.parse(xhr.responseText); } catch(err) { resp = {success:false, data:L.badResponse}; }"; echo "resolve(resp);"; echo "};"; echo "xhr.onerror = function(){ resolve({success:false, data:L.networkError}); };"; echo "var fd = new FormData();"; echo "fd.append('action', 'wpbr_upload_file');"; echo "fd.append('nonce', uploadNonce);"; echo "fd.append('batch_id', batchId);"; echo "fd.append('file', file);"; echo "xhr.send(fd);"; echo "});"; echo "}"; echo "function updateBar(bytesDone, totalBytes){"; echo "var pct = totalBytes > 0 ? Math.round((bytesDone/totalBytes)*100) : 100;"; echo "bar.value = pct;"; echo "label.textContent = fmt(L.progress, pct, formatBytes(bytesDone), formatBytes(totalBytes));"; echo "}"; echo "function formatBytes(b){"; echo "if (b > 1048576) return fmt(L.mb, (b/1048576).toFixed(1));"; echo "if (b > 1024) return fmt(L.kb, (b/1024).toFixed(0));"; echo "return fmt(L.bytes, b);"; echo "}"; echo "async function uploadAll(files){"; echo "if (!files || !files.length) return;"; echo "uploading = true;"; echo "bar.style.display = 'inline-block'; log.style.display = 'block'; log.textContent = '';"; echo "var totalBytes = 0;"; echo "for (var i = 0; i < files.length; i++) totalBytes += files[i].size;"; echo "var bytesBefore = 0;"; echo "var failed = [];"; echo "alertBox.textContent = ''; alertBox.className = '';"; echo "for (var i = 0; i < files.length; i++) {"; echo "var file = files[i];"; echo "var resp = await uploadOneXHR(file, bytesBefore, totalBytes, updateBar);"; echo "if (resp.success) {"; echo "if (resp.data && resp.data.notice) { log.textContent += resp.data.notice + '\\n'; }"; echo "else { log.textContent += fmt(L.uploaded, file.name) + '\\n'; }"; echo "}"; echo "else { log.textContent += fmt(L.uploadFailed, file.name, JSON.stringify(resp.data)) + '\\n'; failed.push({ name: file.name, reason: (typeof resp.data === 'string') ? resp.data : JSON.stringify(resp.data) }); }"; echo "log.scrollTop = log.scrollHeight;"; echo "bytesBefore += file.size;"; echo "updateBar(bytesBefore, totalBytes);"; echo "}"; echo "uploading = false;"; // Every file went up: as before, say so and reload to show them. Something failed: stay, keep the message, let the person continue. echo "if (failed.length) { showFailures(failed, files.length); return; }"; echo "log.textContent += L.allComplete + '\\n';"; echo "window.location.reload();"; echo "}"; echo "dropzone.addEventListener('click', function(){ if (!uploading) input.click(); });"; echo "input.addEventListener('change', function(){ uploadAll(input.files); });"; echo "['dragenter','dragover'].forEach(function(evt){"; echo "dropzone.addEventListener(evt, function(e){ e.preventDefault(); e.stopPropagation(); dropzone.style.background = '#eaf2fa'; dropzone.style.borderColor = '#2271b1'; });"; echo "});"; echo "['dragleave','drop'].forEach(function(evt){"; echo "dropzone.addEventListener(evt, function(e){ e.preventDefault(); e.stopPropagation(); dropzone.style.background = '#fafafa'; dropzone.style.borderColor = '#c3c4c7'; });"; echo "});"; echo "dropzone.addEventListener('drop', function(e){"; echo "if (uploading) return;"; echo "var files = e.dataTransfer.files;"; echo "uploadAll(files);"; echo "});"; echo "})();"; wpbr_inline_close(); echo "

" . sprintf( /* translators: %s is a filesystem path, already wrapped in tags. */ esc_html__('Alternatively, files can be dropped straight into %s over FTP or a mapped drive.', 'wpbr-bulk-replace'), "".esc_html(wpbr_staging_dir())."" ) . "

"; echo "
"; wp_nonce_field('wpbr_scan_into_batch'); echo ""; echo ""; echo "
"; if ($items) { echo "

" . esc_html(sprintf( /* translators: %s is a number of files. */ _n('Files in this batch (%s)', 'Files in this batch (%s)', count($items), 'wpbr-bulk-replace'), number_format_i18n(count($items)) )) . "

"; echo ""; } else { echo "

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

"; } } // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Shared step-renderer signature; the four steps take the same arguments whether or not each uses them. function wpbr_render_step_match($batch, $batch_id, $candidates) { echo "

" . esc_html__('Step 2: Matching Configuration', 'wpbr-bulk-replace') . "

"; wpbr_help_link_echo('core.matching.overview', __('How matching works', 'wpbr-bulk-replace')); echo "

" . esc_html__('Choose what these files should be matched against.', 'wpbr-bulk-replace') . "

"; echo "
"; echo "

" . wp_kses_post(__('Safe to re-run. Run Matching again any time you change abbreviations or the fields above.', 'wpbr-bulk-replace')) . "

"; echo "

" . wp_kses_post(__('Left alone: rows you set by hand, plus anything marked Skip, New Page Needed, Confirmed (New Attachment), combined, replaced or verified.
', 'wpbr-bulk-replace')); echo wp_kses_post(__('Re-matched: rows still carrying an automatic match — Matched and Needs Review.', 'wpbr-bulk-replace')) . "

"; echo "
"; // Public types, plus any a provider can act on — see wpbr_configurable_post_types(). $post_types = wpbr_configurable_post_types(); // WHAT CAN BE CONFIGURED, ASKED OF THE SEAM RATHER THAN OF POST META. This // was `wpbr_get_meta_key_suggestions()` called directly, which is why an // add-on holding a reference somewhere else could be read and written // perfectly and still not be configurable: the only list this screen knew // how to draw was meta keys. `wpbr_reference_choices()` asks every // registered provider, the built-in one included, so post meta keeps // behaving exactly as it did and anything else appears beside it. $location_choices = array(); foreach ($post_types as $pt) { $location_choices[$pt->name] = wpbr_reference_choices($pt->name); } // Resolved here rather than read off the registry: a provider's label is // produced at draw time so that registering one does not translate at // `plugins_loaded`. See `wpbr_reference_provider_label()`. $provider_labels = array(); foreach (array_keys(wpbr_reference_providers()) as $provider_id) { $provider_labels[$provider_id] = wpbr_reference_provider_label($provider_id); } // If a submission was rejected, keep what was chosen rather than resetting // the form to defaults - otherwise the post type silently reverts and it // looks as though the selection never registered. $selected_post_type = $batch->post_type; $existing_keys = wpbr_split_meta_keys($batch->meta_key_name); $pending_text = ''; // What the handler was just sent, held for this redraw (see wpbr_match_submission()). $submission = wpbr_match_submission(); if ($submission !== null) { if (!empty($submission['post_type'])) { $selected_post_type = $submission['post_type']; } // What was submitted: the chips and anything typed beside them. Accepted, // it is the list the batch now holds. Rejected, the chips stay as they // were and the text stays in the box, so a person corrects what they // typed instead of typing it again. $submitted_keys = wpbr_combine_meta_keys($submission['chips'], $submission['pending']); if ('' === wpbr_meta_keys_problem($submitted_keys)) { $existing_keys = $submitted_keys; } else { if (!empty($submission['chips'])) { $existing_keys = wpbr_split_meta_keys($submission['chips']); } if (!empty($submission['pending'])) { $pending_text = $submission['pending']; } } } // A SAVED BATCH HOLDS IDENTIFIERS, AND A PERSON NEEDS NAMES. Resolved here // rather than in the browser so that a location whose add-on has gone away // still renders as itself instead of as an empty chip. $selected_choices = isset($location_choices[$selected_post_type]) ? $location_choices[$selected_post_type] : array(); $existing_chips = array(); foreach ($existing_keys as $existing_key) { $existing_chips[] = array( 'key' => $existing_key, 'label' => wpbr_reference_choice_label($existing_key, $selected_post_type, $selected_choices), ); } echo "
"; wp_nonce_field('wpbr_run_matching'); echo ""; echo ""; echo ""; echo ""; echo ""; echo "
"; // The chips are an ordered list, and the order is meaningful - it is the // priority chain. Marked up as one so it reads as a list, with its own // accessible name. echo "
    "; echo "

    "; // THE PICKER AND THE TEXT BOX ARE BOTH HERE ON PURPOSE. Everything the site // can enumerate is in the select, which is what makes a generated identifier // configurable at all. The text box stays because enumeration is never // complete: a meta key a theme writes only on some posts does not appear in // the suggestions, and typing it has always worked. Removing it to tidy the // screen would take away the only way to configure that field. echo "
    "; echo ""; echo " "; // Shown only for a location that lives at a position inside a repeating // structure, because asking every field which row it is in would be a // question most of them have no answer to. echo ""; echo ""; echo ""; echo " "; echo ""; echo "

    "; echo "
    "; // A field name is an identifier, not prose. No spelling suggestions, which // arrive as the same input event a pick from the list does and would turn a // half-typed name into a chip, and no capital letter put on the front of it. echo ""; echo ""; // Inline, owned by the field, and announced - in place of an alert() that // arrived with no context and left focus wherever it found it. echo "

    "; echo "

    " . wp_kses_post(__('Each field becomes an entry above. Add one or more, in priority order — the first one with a value on a given post wins. Use the arrow buttons to reorder, or drag. The list and the suggestions both come from the post type selected above; a meta field that is not listed can still be typed in.', 'wpbr-bulk-replace')) . "

    "; echo "
    "; echo "

    "; echo "
    "; wpbr_inline_open('style'); echo ".wpbr-chip{display:flex;align-items:center;gap:6px;background:#2271b1;color:#fff;padding:4px 10px;border-radius:14px;font-size:13px;cursor:grab;}"; echo ".wpbr-chip.dragging{opacity:0.4;}"; echo ".wpbr-chip button{background:transparent;border:0;color:#fff;cursor:pointer;padding:0 2px;font-size:13px;line-height:1;}"; echo ".wpbr-chip button:focus{outline:2px solid #fff;outline-offset:1px;}"; wpbr_inline_close(); wpbr_print_inline_error_style(); wpbr_inline_open('script'); echo "var wpbrLocationChoices = ".wp_json_encode($location_choices, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT).";"; echo "var wpbrProviderLabels = ".wp_json_encode($provider_labels, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT).";"; echo "var wpbrExistingChips = ".wp_json_encode($existing_chips, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT).";"; echo "var wpbrMetaL10n = " . wp_json_encode(wpbr_meta_js_l10n(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";"; // A number, not text, so it travels beside the translated strings rather than in them. echo "var wpbrMetaMaxLength = " . (int) wpbr_meta_keys_max_length() . ";"; echo "(function(){"; echo "var L = wpbrMetaL10n;"; echo "var list = document.getElementById('wpbr_meta_chip_list');"; echo "var hidden = document.getElementById('wpbr_meta_keys_hidden');"; echo "var addInput = document.getElementById('wpbr_meta_key_add');"; echo "var datalist = document.getElementById('wpbr_meta_key_list');"; echo "var errBox = document.getElementById('wpbr_meta_error');"; echo "var live = document.getElementById('wpbr_meta_live');"; echo "var postTypeSelect = document.getElementById('wpbr_post_type');"; echo "var picker = document.getElementById('wpbr_location_picker');"; echo "var pickerSelect = document.getElementById('wpbr_location_select');"; echo "var pickerDetail = document.getElementById('wpbr_location_detail');"; echo "var indexWrap = document.getElementById('wpbr_location_index_wrap');"; echo "var indexLabel = document.getElementById('wpbr_location_index_label');"; echo "var indexInput = document.getElementById('wpbr_location_index');"; echo "var pickerAdd = document.getElementById('wpbr_location_add');"; wpbr_js_fmt_helper(); echo "function announce(msg){ live.textContent = ''; window.setTimeout(function(){ live.textContent = msg; }, 50); }"; echo "function showError(msg, target){"; echo "target = target || addInput;"; echo "errBox.textContent = msg; errBox.style.display = 'block';"; echo "target.setAttribute('aria-invalid', 'true');"; echo "target.focus();"; echo "}"; echo "function clearError(){"; echo "errBox.textContent = ''; errBox.style.display = 'none';"; echo "addInput.removeAttribute('aria-invalid');"; echo "indexInput.removeAttribute('aria-invalid');"; echo "}"; echo "function syncHidden(){"; echo "var keys = Array.prototype.map.call(list.children, function(chip){ return chip.dataset.key; });"; echo "hidden.value = keys.join(',');"; echo "}"; // NAMED BY WHAT IS ON SCREEN. These used to read the identifier, which for a // meta key is the same string and for a generated one is an announcement of // "Remove field_68c1f0a9b2e41". echo "function chipName(chip){ return chip.dataset.label || chip.dataset.key; }"; echo "function relabel(){"; echo "Array.prototype.slice.call(list.children).forEach(function(chip){"; echo "var name = chipName(chip);"; echo "chip.querySelector('.wpbr-chip-remove').setAttribute('aria-label', fmt(L.remove, name));"; echo "chip.querySelector('.wpbr-chip-up').setAttribute('aria-label', fmt(L.moveUp, name));"; echo "chip.querySelector('.wpbr-chip-down').setAttribute('aria-label', fmt(L.moveDown, name));"; echo "});"; echo "}"; echo "function moveChip(chip, delta){"; echo "var sibling = delta < 0 ? chip.previousElementSibling : chip.nextElementSibling;"; echo "if (!sibling) { announce(delta < 0 ? L.atTop : L.atBottom); return; }"; echo "if (delta < 0) { list.insertBefore(chip, sibling); } else { list.insertBefore(sibling, chip); }"; echo "syncHidden();"; echo "var pos = Array.prototype.indexOf.call(list.children, chip) + 1;"; echo "announce(fmt(L.moved, chipName(chip), pos, list.children.length));"; echo "}"; echo "function addChip(key, label){"; echo "key = key.trim();"; echo "if (!key) return;"; echo "label = (label || key).trim() || key;"; echo "var existing = Array.prototype.map.call(list.children, function(c){ return c.dataset.key; });"; echo "if (existing.indexOf(key) !== -1) return;"; echo "var chip = document.createElement('li');"; echo "chip.className = 'wpbr-chip';"; echo "chip.draggable = true;"; echo "chip.dataset.key = key;"; echo "chip.dataset.label = label;"; // The identifier is on the chip as a title, which is where a diagnostic // belongs: reachable when somebody is working out why a field is not // matching, and not the thing they read while configuring a batch. echo "chip.title = key;"; echo "var span = document.createElement('span'); span.textContent = label;"; // Buttons, not spans. A span with a click handler is invisible to the // keyboard and unnamed to a screen reader, so the only way to remove a // field was to reach for a mouse. echo "var up = document.createElement('button'); up.type='button'; up.className='wpbr-chip-up'; up.innerHTML='\\u2039';"; echo "var dn = document.createElement('button'); dn.type='button'; dn.className='wpbr-chip-down'; dn.innerHTML='\\u203A';"; echo "var remove = document.createElement('button'); remove.type='button'; remove.className='wpbr-chip-remove'; remove.innerHTML='\\u00D7';"; echo "up.addEventListener('click', function(){ moveChip(chip, -1); up.focus(); });"; echo "dn.addEventListener('click', function(){ moveChip(chip, 1); dn.focus(); });"; echo "remove.addEventListener('click', function(){"; echo "var name = chipName(chip);"; echo "chip.remove(); syncHidden(); relabel();"; echo "announce(fmt(L.removed, name));"; echo "addInput.focus();"; echo "});"; echo "chip.appendChild(span); chip.appendChild(up); chip.appendChild(dn); chip.appendChild(remove);"; echo "chip.addEventListener('dragstart', function(e){ chip.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });"; echo "chip.addEventListener('dragend', function(){ chip.classList.remove('dragging'); syncHidden(); });"; echo "list.appendChild(chip);"; echo "syncHidden(); relabel();"; echo "clearError();"; echo "announce(fmt(L.added, label, list.children.length, list.children.length));"; echo "}"; // Text typed but not committed with Enter would otherwise be silently // discarded on submit, posting an empty field list. Enter, a pick from the // list and Run Matching all go through the one function, so the keyboard and // the mouse cannot disagree about what was typed: it is split on commas the // way the server splits the saved list, each name is trimmed, and a name // already in the list is not added again. echo "function commitPending(){"; echo "var typed = addInput.value;"; echo "addInput.value = '';"; echo "typed.split(',').forEach(function(name){ addChip(name); });"; echo "}"; echo "var matchForm = addInput.closest('form');"; echo "if (matchForm) {"; echo "matchForm.addEventListener('submit', function(e){"; echo "commitPending();"; echo "syncHidden();"; echo "if (!hidden.value.trim()) {"; echo "e.preventDefault();"; echo "showError(L.needOne);"; echo "} else if (hidden.value.length > wpbrMetaMaxLength) {"; echo "e.preventDefault();"; echo "showError(fmt(L.tooLong, wpbrMetaMaxLength));"; echo "}"; echo "});"; echo "}"; echo "list.addEventListener('dragover', function(e){"; echo "e.preventDefault();"; echo "var dragging = list.querySelector('.dragging');"; echo "if (!dragging) return;"; echo "var after = Array.prototype.find.call(list.children, function(c){ return c !== dragging && e.clientX < c.getBoundingClientRect().left + c.getBoundingClientRect().width/2; });"; echo "if (after) { list.insertBefore(dragging, after); } else { list.appendChild(dragging); }"; echo "});"; echo "addInput.addEventListener('keydown', function(e){"; echo "if (e.key === 'Enter') { e.preventDefault(); commitPending(); }"; echo "});"; // PICKING FROM THE LIST IS A CHOICE, NOT TYPING. A datalist pick arrives as an // input event of type insertReplacementText (a browser that gives no type at // all is taken to have picked when the value is exactly one of the offered // names), and it becomes a chip at once - the same thing Enter and Add field // do. Typing and pasting still wait for Enter or Run Matching. echo "addInput.addEventListener('input', function(e){"; echo "clearError();"; echo "var offered = Array.prototype.map.call(datalist.children, function(o){ return o.value; });"; echo "var picked = e.inputType === 'insertReplacementText' || (typeof e.inputType === 'undefined' && offered.indexOf(addInput.value.trim()) !== -1);"; echo "if (picked) { commitPending(); }"; echo "});"; // --- the picker --------------------------------------------------------- // Built from whatever the seam could enumerate for the selected post type, // grouped by the provider that offered it so that "Post meta" and an // add-on's fields are told apart without either being privileged. echo "function choicesFor(postType){ return wpbrLocationChoices[postType] || []; }"; echo "function currentChoice(){"; echo "var choices = choicesFor(postTypeSelect.value);"; echo "var i = parseInt(pickerSelect.value, 10);"; echo "return (isNaN(i) || !choices[i]) ? null : choices[i];"; echo "}"; echo "function syncPicker(){"; echo "var c = currentChoice();"; echo "if (!c) { indexWrap.style.display = 'none'; pickerDetail.textContent = ''; return; }"; // A position is asked for only where one exists to ask about. Everything // else would be a question with no answer. echo "if (c.template) { indexWrap.style.display = ''; indexLabel.textContent = c.index_label; }"; echo "else { indexWrap.style.display = 'none'; }"; echo "var id = c.value || c.template;"; echo "pickerDetail.textContent = c.detail ? (c.detail + ' \u2014 ' + id) : id;"; echo "}"; echo "function buildPicker(){"; echo "var choices = choicesFor(postTypeSelect.value);"; echo "pickerSelect.innerHTML = '';"; echo "datalist.innerHTML = '';"; echo "var groups = {};"; echo "choices.forEach(function(c, i){"; // The text box has always offered meta keys and still does; a provider's // locations belong in the list, not in a free-text box that would accept a // half-typed identifier. echo "if (c.provider === " . wp_json_encode(WPBR_REFERENCE_PROVIDER_META) . " && c.value) {"; echo "var d = document.createElement('option'); d.value = c.value; datalist.appendChild(d);"; echo "}"; echo "if (!groups[c.provider]) {"; echo "var g = document.createElement('optgroup');"; echo "g.label = wpbrProviderLabels[c.provider] || c.provider;"; echo "groups[c.provider] = g; pickerSelect.appendChild(g);"; echo "}"; echo "var o = document.createElement('option'); o.value = String(i); o.textContent = c.label;"; echo "groups[c.provider].appendChild(o);"; echo "});"; // Hidden rather than shown empty: a select with nothing in it is a control // that looks broken, and the text box below it still works. echo "picker.style.display = choices.length ? '' : 'none';"; echo "syncPicker();"; echo "}"; echo "pickerSelect.addEventListener('change', syncPicker);"; echo "pickerAdd.addEventListener('click', function(){"; echo "var c = currentChoice();"; echo "if (!c) return;"; echo "var key = c.value, label = c.label;"; echo "if (c.template) {"; echo "var n = parseInt(indexInput.value, 10);"; echo "if (!n || n < 1) { showError(fmt(L.needIndex, c.index_label), indexInput); return; }"; echo "key = c.template.replace('{n}', String(n));"; echo "label = fmt(L.indexed, c.label, c.index_label, n);"; echo "}"; echo "addChip(key, label);"; echo "});"; echo "indexInput.addEventListener('input', clearError);"; // Enter in this box is "add the field", as it is in the name box. Left alone // it is implicit form submission: Run Matching, from a half-filled picker. echo "indexInput.addEventListener('keydown', function(e){"; echo "if (e.key === 'Enter') { e.preventDefault(); pickerAdd.click(); }"; echo "});"; echo "postTypeSelect.addEventListener('change', buildPicker);"; echo "buildPicker();"; echo "wpbrExistingChips.forEach(function(c){ addChip(c.key, c.label); });"; echo "live.textContent = '';"; echo "})();"; wpbr_inline_close(); } function wpbr_filename_safe($str) { $str = (string) $str; $str = str_replace(array('.', "'", '(', ')', ','), '', $str); $str = str_replace('&', 'and', $str); $str = preg_replace('/\s+/', '-', trim($str)); $str = preg_replace('/-+/', '-', $str); return sanitize_file_name($str); } function wpbr_apply_rename_template($template, $post_id, $original_filename, $ext) { $name_no_ext = preg_replace('/\.[a-z0-9]+$/i', '', $original_filename); $result = $template; $result = str_replace('{title}', $post_id ? wpbr_filename_safe(wpbr_clean_title(get_the_title($post_id))) : '', $result); $result = str_replace('{id}', $post_id ? intval($post_id) : '', $result); $result = str_replace('{original}', wpbr_filename_safe($name_no_ext), $result); $result = str_replace('{ext}', $ext ? $ext : 'pdf', $result); // current_time(), not date(): a filename dated "today" should mean today in // the timezone the site is configured for, not the server's. $result = str_replace('{date}', current_time('Y-m-d'), $result); $result = str_replace('{date:Ymd}', current_time('Ymd'), $result); $result = str_replace('{date:Y}', current_time('Y'), $result); if ($post_id && preg_match_all('/\{meta:([a-zA-Z0-9_-]+)\}/', $result, $matches)) { foreach ($matches[1] as $i => $meta_key) { $val = get_post_meta($post_id, $meta_key, true); $result = str_replace($matches[0][$i], wpbr_filename_safe($val), $result); } } return sanitize_file_name($result); } /** * Display labels for the stored item statuses. * * The keys are persisted and queried on, so they are never translated; only * what the reader sees is. */ function wpbr_status_labels() { return array( 'staged' => __('Staged', 'wpbr-bulk-replace'), 'matched' => __('Matched', 'wpbr-bulk-replace'), 'needs_review' => __('Needs Review', 'wpbr-bulk-replace'), 'confirmed_new_attach' => __('Confirmed (New Attachment)', 'wpbr-bulk-replace'), 'skip' => __('Skip', 'wpbr-bulk-replace'), 'new_page_needed' => __('New Page Needed', 'wpbr-bulk-replace'), 'replaced' => __('Replaced', 'wpbr-bulk-replace'), 'verified' => __('Verified', 'wpbr-bulk-replace'), 'cleaned_up' => __('Cleaned Up', 'wpbr-bulk-replace'), 'combined' => __('Combined', 'wpbr-bulk-replace'), 'needs_attention' => __('Needs attention', 'wpbr-bulk-replace'), ); } function wpbr_selectable_statuses() { return array('matched', 'needs_review', 'skip', 'new_page_needed'); } /** * The status a review-form submission may leave a row in. * * The form offers wpbr_selectable_statuses(), plus - for a row already past * review, such as one that has been replaced - that row's own status, so it * round-trips unchanged. Anything else in the request is not something the form * could have produced: a row cannot be declared "replaced" or "verified" by * posting the word, since those are set only by the run that did the work. * The row keeps the status it has. * * @param string $submitted The status the form sent, already sanitized. * @param string|null $current The row's stored status, or null for an unknown row. */ function wpbr_review_status($submitted, $current) { // A file waiting for attention leaves that state only through Retry restore or its own recovery action. No form // field, however it was posted, moves it: that would hide the damage and release the batch. if ($current === 'needs_attention') return $current; if (in_array($submitted, wpbr_selectable_statuses(), true)) return $submitted; if ($current !== null && $submitted === $current) return $submitted; return $current !== null ? $current : 'needs_review'; } function wpbr_item_needs_new_attach($item, $batch) { if (!$item->matched_post_id || !$batch || !$batch->meta_key_name) return false; // `found` rather than a truthy location: on a post with nothing anywhere the // resolved location is still the first configured one, because that is where // a write would go. return !wpbr_reference_current($batch, $item->matched_post_id)['found']; } function wpbr_match_kind_of($item) { return isset($item->match_kind) && $item->match_kind !== null ? (string) $item->match_kind : ''; } function wpbr_confidence_info($item) { if (!$item->matched_post_id) { // AN AMBIGUOUS REFUSAL IS NOT THE SAME AS NOTHING RESEMBLING THIS FILE, // and the reader fixes them in completely different places, so the // screen says which one happened. if (wpbr_match_kind_of($item) === WPBR_MATCH_AMBIGUOUS) { return array('color' => '#d63638', 'label' => __('Ambiguous identifier', 'wpbr-bulk-replace')); } return array('color' => '#c3c4c7', 'label' => __('No match', 'wpbr-bulk-replace')); } // AN EXACT IDENTIFIER IS NOT A CONFIDENCE. Rendering it as "High confidence // (100%)" invites the reader to wonder what the missing 0% was, and puts it // on the same scale as a coincidence of spelling. if (wpbr_match_kind_of($item) === WPBR_MATCH_EXACT) { return array('color' => '#00a32a', 'label' => __('Exact identifier', 'wpbr-bulk-replace')); } if (wpbr_match_kind_of($item) === WPBR_MATCH_REMEMBERED) { return array('color' => '#00a32a', 'label' => __('From a previous decision', 'wpbr-bulk-replace')); } if ($item->confidence_score === null || $item->confidence_score === '') { return array('color' => '#00a32a', 'label' => __('Set manually', 'wpbr-bulk-replace')); } $score = floatval($item->confidence_score); if ($score >= wpbr_high_threshold()) return array('color' => '#00a32a', 'label' => __('High confidence', 'wpbr-bulk-replace')); if ($score >= wpbr_confidence_threshold()) return array('color' => '#dba617', 'label' => __('Medium confidence', 'wpbr-bulk-replace')); return array('color' => '#d63638', 'label' => __('Low confidence', 'wpbr-bulk-replace')); } /** * The short, visible form of a row's score: "Exact", "High 91%", "Medium 72%", "Low 51%", "No match". * * @param object $item The row's item. * @param array $conf wpbr_confidence_info($item). * @return string */ function wpbr_score_short_label($item, $conf) { $tier = wpbr_confidence_tier_key($item); $names = array( 'exact' => __('Exact', 'wpbr-bulk-replace'), 'high' => __('High', 'wpbr-bulk-replace'), 'medium' => __('Medium', 'wpbr-bulk-replace'), 'low' => __('Low', 'wpbr-bulk-replace'), 'none' => $conf['label'], ); $name = isset($names[$tier]) ? $names[$tier] : $conf['label']; if (in_array($tier, array('high', 'medium', 'low'), true) && $item->confidence_score !== null && $item->confidence_score !== '') { return $name . ' ' . round($item->confidence_score * 100) . '%'; } return $name; } function wpbr_confidence_tier_key($item) { if (!$item->matched_post_id) return 'none'; if (wpbr_match_kind_of($item) === WPBR_MATCH_EXACT) return 'exact'; if ($item->confidence_score === null || $item->confidence_score === '') return 'high'; $score = floatval($item->confidence_score); if ($score >= wpbr_high_threshold()) return 'high'; if ($score >= wpbr_confidence_threshold()) return 'medium'; return 'low'; } /** * The attributes that disable a control on a row waiting for attention. Printed rather than returned: it is output. */ function wpbr_echo_held_attributes($held, $reason) { if (!$held) return; echo ' disabled aria-disabled="true" title="' . esc_attr($reason) . '"'; } /** * An accessible name for a control in a review row: what it is, and for which file. * * The visible column heading is not associated with a field in a table cell, so a screen reader would announce "edit text" with * no hint which row it belongs to. * * @param string $what The control's role in words ("Rename to", "Status", "Notes", "Select"). * @param object $item The row's item. * @return string Plain text; callers escape it. */ function wpbr_row_control_label($what, $item) { return sprintf( /* translators: 1: what a field in the table is ("Rename to", "Status", "Notes"), 2: the staged file's name. */ __('%1$s: %2$s', 'wpbr-bulk-replace'), $what, $item->staged_filename ); } function wpbr_render_review_row($item, $candidates, $batch, $conflict_ids = array()) { /* translators: %s is the singular label of the post type being matched, e.g. "product". */ $matched_col = sprintf(__('Matched %s', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type)); $ext = strtolower(pathinfo($item->staged_filename, PATHINFO_EXTENSION)); $needs_new_attach = wpbr_item_needs_new_attach($item, $batch); $confirmed_new = ($item->status === 'confirmed_new_attach'); $tier = wpbr_confidence_tier_key($item); $matched_permalink = ''; if ($item->matched_post_id) { foreach ($candidates as $c) { if ($c['id'] == $item->matched_post_id) { $matched_permalink = $c['permalink']; break; } } } $has_conflict = isset($conflict_ids[$item->id]); $conflict_group = $has_conflict ? $conflict_ids[$item->id]['group'] : ''; // A file waiting for attention is resolved from the panel above the table, not here. Its controls are shown but disabled, // with the reason on them, so nothing invites an edit that would be ignored. $held = ($item->status === 'needs_attention'); $held_reason = __('Deal with this file in the panel at the top of the page first.', 'wpbr-bulk-replace'); echo "status)."\" data-post-id=\"".esc_attr($item->matched_post_id)."\" data-item-id=\"".esc_attr($item->id)."\">"; echo "id)."\" data-ext=\"".esc_attr($ext)."\" aria-label=\"".esc_attr(wpbr_row_control_label(__('Select', 'wpbr-bulk-replace'), $item))."\""; wpbr_echo_held_attributes($held, $held_reason); echo ">"; echo ""; echo "".esc_html($item->staged_filename)." "; echo "id))."\" target=\"_blank\" class=\"wpbr-preview-icon\" title=\"Preview file\" aria-label=\"Preview file\">"; if ($has_conflict) { $ci = $conflict_ids[$item->id]; echo "
    "; echo " "; echo esc_html(sprintf( /* translators: 1: a post title, 2: how many other files are matched to it. */ _n('Also matched to “%1$s” by %2$s other file.', 'Also matched to “%1$s” by %2$s other files.', (int) $ci['count'], 'wpbr-bulk-replace'), $ci['post_title'], number_format_i18n($ci['count']) )) . " "; echo "" . esc_html( _n('Show it', 'Show them', $ci['count'], 'wpbr-bulk-replace') ) . ""; echo "
    "; } echo ""; echo ""; $current_title = $item->matched_post_title ? $item->matched_post_title : ''; if (!$current_title && $item->matched_post_id) { $live_title = wpbr_clean_title(get_the_title($item->matched_post_id)); $current_title = $live_title ? $live_title : ''; } $orphan_id = (!$current_title && $item->matched_post_id) ? $item->matched_post_id : 0; echo "
    "; echo "
    "; echo "staged_filename)."\" style=\"width:100%;box-sizing:border-box;\""; wpbr_echo_held_attributes($held, $held_reason); echo ">"; echo "id)."]\" class=\"wpbr-combo-hidden\" value=\"".esc_attr($item->matched_post_id)."\">"; echo "
    "; echo "
    "; $view_style = $matched_permalink ? '' : 'display:none;'; $open_label = sprintf( /* translators: %s is the singular label of a post type, e.g. "product". */ __('Open this %s in a new tab', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type) ); echo ""; echo "
    "; if ($orphan_id) { echo "
    "; echo esc_html(sprintf( /* translators: 1: the singular label of a post type, e.g. "product", 2: a numeric post ID. */ __('Matched to %1$s ID %2$s, but that %1$s has no title — it may be unpublished or deleted. Pick another one above to replace it.', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type), $orphan_id )); echo "
    "; } echo ""; echo ""; $conf = wpbr_confidence_info($item); // Colour alone must not carry the meaning: the same fact is text, in the // accessible name and in a screen-reader span. if ($item->confidence_score !== null && $item->confidence_score !== '') { $conf_text = sprintf( /* translators: 1: a confidence label such as "High confidence", 2: a percentage. */ __('%1$s (%2$s)', 'wpbr-bulk-replace'), $conf['label'], round($item->confidence_score * 100) . '%' ); } else { // NO PERCENTAGE RATHER THAN "n/a". A scoreless match has a reason - // exact, remembered, or a person - and the label already says which; // pairing it with "(n/a)" only suggests a number went missing. $conf_text = $conf['label']; } echo ""; echo "".esc_html($conf_text).""; // The same fact as visible text: colour is never the only signal. aria-hidden because the line above already says it. echo "".esc_html(wpbr_score_short_label($item, $conf)).""; echo ""; echo ""; $rename_style = ($needs_new_attach && !$confirmed_new) ? 'display:none;' : ''; $rename_value = $item->rename_to; $rename_suggested = false; if (!$rename_value && $item->matched_post_id) { $existing = ''; $live = wpbr_reference_current($batch, $item->matched_post_id); if ($live['found']) { $existing = basename(wp_parse_url($live['value'], PHP_URL_PATH)); } $rename_value = wpbr_suggested_filename($item->matched_post_id, $item->staged_filename, $existing); $rename_suggested = ($rename_value !== ''); } echo "id)."]\" class=\"wpbr-rename-input\" aria-label=\"".esc_attr(wpbr_row_control_label(__('Rename to', 'wpbr-bulk-replace'), $item))."\" value=\"".esc_attr($rename_value)."\" style=\"width:100%;box-sizing:border-box;".esc_attr($rename_style)."\""; wpbr_echo_held_attributes($held, $held_reason); echo ">"; if ($held) { echo "
    " . esc_html($held_reason) . "
    "; } elseif ($rename_suggested) { echo "
    " . esc_html(sprintf( /* translators: %s is the singular label of a post type, e.g. "product". */ __('Suggested from the %s title — edit if needed, then Save Changes.', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type) )) . "
    "; } if ($needs_new_attach && !$confirmed_new) { echo "
    "; echo esc_html__('No existing file', 'wpbr-bulk-replace') . " — "; echo "
    "; } echo ""; if ($item->status === 'needs_attention') { // Nothing to choose: the recovery panel above the table is the only way out, and a dropdown would suggest otherwise. echo "" . esc_html__('Needs attention', 'wpbr-bulk-replace') . ""; echo "" . esc_html((string) $item->notes) . ""; echo ""; return; } echo ""; echo "id)."]\" aria-label=\"".esc_attr(wpbr_row_control_label(__('Notes', 'wpbr-bulk-replace'), $item))."\" value=\"".esc_attr($item->notes)."\" style=\"width:100%;box-sizing:border-box;\">"; echo ""; } function wpbr_render_step_review($batch, $batch_id, $items, $candidates) { global $wpdb; echo "

    " . esc_html__('Step 3: Review & Resolve', 'wpbr-bulk-replace') . "

    "; wpbr_help_link_echo('core.batches.review-resolve', __('Working through the matches', 'wpbr-bulk-replace')); wpbr_inline_open('style'); echo ".wpbr-confidence-dot{display:inline-block;width:12px;height:12px;border-radius:50%;}"; echo ".wpbr-count-dot{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:50%;font-size:11px;font-weight:600;padding:0 4px;box-sizing:border-box;}"; echo ".wpbr-preview-icon{color:#787c82;text-decoration:none;vertical-align:middle;display:inline-flex;}"; echo ".wpbr-preview-icon:hover{color:#2271b1;}"; echo ".wpbr-preview-icon .dashicons{font-size:16px;width:16px;height:16px;}"; echo ".wpbr-view-post-icon{color:#787c82;text-decoration:none;flex-shrink:0;display:inline-flex;align-items:center;}"; echo ".wpbr-view-post-icon:hover{color:#2271b1;}"; echo ".wpbr-view-post-icon .dashicons{font-size:16px;width:16px;height:16px;}"; echo "table.wpbr-responsive-table th.wpbr-check-col, table.wpbr-responsive-table td.wpbr-check-col{width:32px !important;text-align:left !important;padding:8px !important;box-sizing:border-box !important;}"; echo ".wpbr-filter-chip{display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid #dcdcde;border-radius:20px;padding:4px 12px 4px 8px;font-size:12px;font-family:inherit;line-height:1.6;margin:0;color:#3c434a;cursor:pointer;user-select:none;}"; echo ".wpbr-filter-chip:focus-visible{outline:2px solid #2271b1;outline-offset:1px;}"; echo ".wpbr-filter-chip.active{background:#2271b1;border-color:#2271b1;color:#fff;}"; echo ".wpbr-filter-chip.active .wpbr-confidence-dot, .wpbr-filter-chip.active .wpbr-count-dot{box-shadow:0 0 0 2px #fff;}"; echo "tr[data-conflict='1'] > td{background:#fcf0f1 !important;}"; // The conflict notice: room reserved on the right for WordPress's dismiss button (an inline padding used to remove it, so the button sat on the text), // long file names break instead of pushing the page sideways, and the groups stack on a narrow screen. echo ".wpbr-conflict-notice{padding:12px 48px 12px 16px;margin:0 0 16px;position:relative;overflow-wrap:anywhere;}"; echo ".wpbr-conflict-notice p{margin:0 0 6px;}"; echo ".wpbr-conflict-notice__title{display:block;margin:0 0 4px;}"; echo ".wpbr-conflict-notice ul{margin:8px 0 0 18px;list-style:disc;}"; echo ".wpbr-conflict-notice li{margin:0 0 6px;min-width:0;}"; echo ".wpbr-conflict-notice code{display:inline-block;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;vertical-align:baseline;}"; echo ".wpbr-conflict-notice .notice-dismiss{top:4px;right:4px;}"; echo "@media screen and (max-width: 782px){.wpbr-conflict-notice{padding:12px 52px 12px 12px;}.wpbr-conflict-notice .notice-dismiss{top:2px;right:2px;}}"; echo "tr.wpbr-conflict-lit > td{background:#fde2e4 !important;box-shadow:inset 0 0 0 2px #d63638;}"; echo "@keyframes wpbrFlash{0%,100%{background:#fde2e4;}50%{background:#f7a8ad;}}"; echo "tr.wpbr-conflict-flash > td{animation:wpbrFlash .45s ease-in-out 3;}"; echo ".wpbr-panel-box{background:#f6f7f7;border:1px solid #dcdcde;border-radius:4px;padding:16px;margin-bottom:16px;}"; echo ".wpbr-panel-box h3{margin:0 0 4px;font-size:14px;}"; echo ".wpbr-panel-box .description{margin:6px 0 0;}"; echo ".wpbr-reviewer-row{display:flex;gap:12px;flex-wrap:wrap;align-items:center;}"; echo ".wpbr-legend-chip{display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid #dcdcde;border-radius:20px;padding:4px 12px 4px 8px;font-size:12px;color:#3c434a;}"; echo "#wpbr_sticky_toolbar{position:sticky;top:32px;background:#fff;z-index:100;padding:10px 0;border-bottom:1px solid #dcdcde;margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;}"; echo "@media screen and (max-width: 782px) {"; echo "#wpbr_sticky_toolbar{top:46px;}"; echo ".wpbr-responsive-table thead{display:none;}"; echo ".wpbr-responsive-table tr{display:block;margin-bottom:16px;border:1px solid #dcdcde;padding:8px;}"; echo ".wpbr-responsive-table td{display:block;text-align:left !important;padding:6px 4px;border:none;width:auto !important;}"; // Reads data-label, not data-colname. data-colname is a stable English key // that JavaScript selects cells by; this pseudo-element renders its text to // the reader on a narrow screen. One attribute cannot be both a selector // key and a translated string, so they are two attributes. echo ".wpbr-responsive-table td[data-label]:not([data-label='']):before{content:attr(data-label);display:block;font-weight:600;font-size:11px;color:#787c82;text-transform:uppercase;margin-bottom:2px;}"; echo "}"; wpbr_inline_close(); $collisions = wpbr_detect_collisions($batch_id, $items, $batch); $conflict_ids = array(); foreach ($collisions['blocking'] as $gi => $c) { $group_key = 'g' . $gi; foreach ($c['items'] as $ci) { $conflict_ids[$ci->id] = array( 'group' => $group_key, 'count' => count($c['items']) - 1, 'post_title' => $c['items'][0]->matched_post_title, ); } } if ($collisions['blocking']) { // wpbr-persist: this notice stays until a person closes it (the page's notice script otherwise fades warnings after ten seconds). Closing it // never hides the conflicts themselves: every conflicting row keeps its marker and the Conflicts filter below. echo "
    "; $blocking_count = count($collisions['blocking']); echo "

    " . esc_html(sprintf( /* translators: %s is a number of conflicts. */ _n('%s conflict needs resolving.', '%s conflicts need resolving.', $blocking_count, 'wpbr-bulk-replace'), number_format_i18n($blocking_count) )) . " "; echo wp_kses_post(sprintf( /* translators: 1: the singular label of a post type, e.g. "product", 2: the name of the final wizard step, e.g. "Backup & Replace". */ __('More than one file is matched to the same %1$s, so they would overwrite each other. %2$s is blocked until these are fixed. Use the Conflicts filter below to work through them.', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type), esc_html(wpbr_wizard_step_title('replace')) )) . " "; wpbr_help_link_echo('error.collision-detected', __('Why this blocks the run', 'wpbr-bulk-replace')); echo "

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

    " . esc_html__('Reviewer round-trip', 'wpbr-bulk-replace') . "

    "; echo "

    " . wp_kses_post(__('Send the file to whoever knows this content best, then import their filled-in copy — matches rows by staged filename and updates the match, status, and notes.', 'wpbr-bulk-replace')) . "

    "; echo "
    "; $icon_style = 'font-size:16px;width:16px;height:16px;line-height:16px;'; echo ""; echo ""; echo "
    "; // 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. $import_history = $wpdb->get_results($wpdb->prepare( "SELECT import_uuid, source_filename, created_at, created_by, COUNT(*) AS row_count FROM {$wpdb->prefix}wpbr_import_log WHERE batch_id = %d GROUP BY import_uuid, source_filename, created_at, created_by ORDER BY created_at DESC", $batch_id )); if ($import_history) { echo "
    "; echo "

    " . esc_html__('Import history', 'wpbr-bulk-replace') . " "; echo "— " . esc_html__('undo restores every row that import changed, back to how it was before it ran. Rows combined, replaced, or verified since then are left alone.', 'wpbr-bulk-replace') . " "; wpbr_help_link_echo('core.review.import', __('The reviewer round trip', 'wpbr-bulk-replace')); echo "

    "; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; $unknown = __('(unknown)', 'wpbr-bulk-replace'); foreach ($import_history as $h) { $undo_url = wp_nonce_url( admin_url('admin.php?page=wpbr-batch-detail&batch_id='.$batch_id.'&step=review&wpbr_undo_import='.$h->import_uuid), 'wpbr_undo_import_'.$h->import_uuid, 'wpbr_undo_nonce' ); $user = get_userdata($h->created_by); echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; } echo "
    " . esc_html__('When', 'wpbr-bulk-replace') . "" . esc_html__('File', 'wpbr-bulk-replace') . "" . esc_html__('Rows changed', 'wpbr-bulk-replace') . "" . esc_html__('By', 'wpbr-bulk-replace') . "" . esc_html__('Actions', 'wpbr-bulk-replace') . "
    ".esc_html(mysql2date(wpbr_datetime_format(), $h->created_at))."".esc_html($h->source_filename ? $h->source_filename : $unknown)."".esc_html(number_format_i18n($h->row_count))."".esc_html($user ? $user->display_name : $unknown)."" . esc_html__('Undo', 'wpbr-bulk-replace') . "
    "; echo "
    "; } echo "
    "; echo "
    "; echo "
    "; echo "

    " . esc_html__('Before you send this to a reviewer', 'wpbr-bulk-replace') . "

    "; echo "

    " . esc_html__('What the reviewer should edit', 'wpbr-bulk-replace') . "

    "; echo "
      "; echo "
    • " . wp_kses_post(__('staged_filename — do not edit or reorder this column, it\'s how their answers get matched back up.', 'wpbr-bulk-replace')) . "
    • "; echo "
    • " . wp_kses_post(__('matched_post_id — the correct numeric ID if the automatic match is wrong. Leave blank if none applies.', 'wpbr-bulk-replace')) . "
    • "; echo "
    • " . wp_kses_post(__('status — one of the values listed below.', 'wpbr-bulk-replace')) . "
    • "; echo "
    • " . wp_kses_post(__('notes — any comments back to you.', 'wpbr-bulk-replace')) . "
    • "; echo "
    "; echo "

    " . esc_html__('Valid status values', 'wpbr-bulk-replace') . "

    "; echo "
      "; $status_labels_all = wpbr_status_labels(); foreach (wpbr_selectable_statuses() as $slug) { echo "
    • ".esc_html($slug)." — ".esc_html($status_labels_all[$slug])."
    • "; } echo "
    "; // The suggested email is prose the site owner sends to a colleague, so it // is translated. The column names inside it are the CSV's own headers and // stay in English: renaming them would break the import. $email_body = __('Hi,', 'wpbr-bulk-replace') . "\n\n"; $email_body .= sprintf( /* translators: 1: a number of files, 2: the batch name. */ _n('Attached is a spreadsheet of %1$s file ("%2$s") that needs your review. Please only edit these columns:', 'Attached is a spreadsheet of %1$s files ("%2$s") that need your review. Please only edit these columns:', count($items), 'wpbr-bulk-replace'), number_format_i18n(count($items)), $batch->label ) . "\n\n"; $email_body .= '- ' . __('matched_post_id: the correct numeric ID if the automatic match is wrong. Leave blank if none applies.', 'wpbr-bulk-replace') . "\n"; $email_body .= '- ' . sprintf( /* translators: %s is a comma-separated list of status values such as "matched, needs_review". */ __('status: one of %s', 'wpbr-bulk-replace'), implode(', ', wpbr_selectable_statuses()) ) . "\n"; $email_body .= '- ' . __('notes: any comments back to me', 'wpbr-bulk-replace') . "\n\n"; $email_body .= __('Please do not edit the staged_filename column or rename the file itself.', 'wpbr-bulk-replace') . "\n\n"; $email_body .= __('Status meanings:', 'wpbr-bulk-replace') . "\n"; foreach (wpbr_selectable_statuses() as $slug) { $email_body .= '- ' . $slug . ': ' . $status_labels_all[$slug] . "\n"; } $email_body .= "\n" . __('When you are done, just save and send the file back to me in the same format (CSV or Excel both work).', 'wpbr-bulk-replace'); $email_body .= "\n\n" . __('Thanks!', 'wpbr-bulk-replace'); echo "

    " . esc_html__('Suggested email', 'wpbr-bulk-replace') . "

    "; echo ""; echo "

    "; // Always rendered and empty until the copy happens: a status that is unhidden as it speaks is announced less reliably. echo "

    "; echo "
    "; echo ""; echo ""; echo " " . esc_html__('Download CSV', 'wpbr-bulk-replace') . ""; echo "
    "; echo "
    "; echo "
    "; wpbr_inline_open('script'); echo "(function(){"; echo "var openBtn = document.getElementById('wpbr_open_download_modal');"; echo "var modal = document.getElementById('wpbr_download_modal');"; echo "var cancelBtn = document.getElementById('wpbr_modal_cancel');"; echo "function closeModal(){ window.wpbrDialog.hide(modal); }"; echo "openBtn.addEventListener('click', function(){ window.wpbrDialog.show(modal, { opener: openBtn, initial: cancelBtn, cancel: closeModal }); });"; echo "cancelBtn.addEventListener('click', closeModal);"; echo "var downloadLink = document.getElementById('wpbr_modal_download');"; echo "downloadLink.addEventListener('click', function(){ setTimeout(closeModal, 150); });"; echo "modal.addEventListener('click', function(e){ if (e.target === modal) { closeModal(); } });"; echo "var copyBtn = document.getElementById('wpbr_copy_email');"; echo "var copyConfirm = document.getElementById('wpbr_copy_confirm');"; echo "var emailTextarea = document.getElementById('wpbr_email_template');"; echo "copyBtn.addEventListener('click', function(){"; echo "emailTextarea.select();"; echo "if (navigator.clipboard) { navigator.clipboard.writeText(emailTextarea.value); }"; echo "else { document.execCommand('copy'); }"; echo "copyConfirm.textContent = copyConfirm.getAttribute('data-text');"; echo "setTimeout(function(){ copyConfirm.textContent = ''; }, 2000);"; echo "});"; echo "})();"; wpbr_inline_close(); echo "
    "; echo "
    "; echo "

    " . esc_html__('Import reviewer\'s file', 'wpbr-bulk-replace') . "

    "; echo "

    " . wp_kses_post(__('Make sure the file still has these columns: staged_filename, matched_post_id, status, notes. Accepted formats: .csv, .xlsx (first sheet only).', 'wpbr-bulk-replace')) . "

    "; echo "
    "; wp_nonce_field('wpbr_import_csv'); echo ""; echo "
    "; echo ""; echo "

    " . esc_html__('Drop the file here, or click to select', 'wpbr-bulk-replace') . "

    "; echo "
    "; echo ""; echo "

    "; echo "
    "; echo ""; echo ""; echo "
    "; echo "
    "; echo "
    "; echo "
    "; wpbr_inline_open('script'); echo "(function(){"; echo "var openBtn = document.getElementById('wpbr_open_import_modal');"; echo "var modal = document.getElementById('wpbr_import_modal');"; echo "var cancelBtn = document.getElementById('wpbr_import_cancel');"; echo "var dropzone = document.getElementById('wpbr_import_dropzone');"; echo "var input = document.getElementById('wpbr_import_file_input');"; echo "var filenameLabel = document.getElementById('wpbr_import_filename');"; echo "var submitBtn = document.getElementById('wpbr_import_submit');"; echo "function setFile(files){"; echo "if (!files || !files.length) return;"; echo "input.files = files;"; echo "filenameLabel.textContent = 'Selected: ' + files[0].name;"; echo "filenameLabel.style.display = 'block';"; echo "submitBtn.disabled = false;"; echo "}"; echo "function closeImport(){"; echo "input.value = '';"; echo "filenameLabel.style.display = 'none';"; echo "submitBtn.disabled = true;"; echo "window.wpbrDialog.hide(modal);"; echo "}"; echo "openBtn.addEventListener('click', function(){ window.wpbrDialog.show(modal, { opener: openBtn, initial: cancelBtn, cancel: closeImport }); });"; echo "cancelBtn.addEventListener('click', closeImport);"; echo "modal.addEventListener('click', function(e){ if (e.target === modal) { closeImport(); } });"; echo "dropzone.addEventListener('click', function(){ input.click(); });"; echo "dropzone.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });"; echo "input.addEventListener('change', function(){ setFile(input.files); });"; echo "['dragenter','dragover'].forEach(function(evt){"; echo "dropzone.addEventListener(evt, function(e){ e.preventDefault(); e.stopPropagation(); dropzone.style.background = '#eaf2fa'; dropzone.style.borderColor = '#2271b1'; });"; echo "});"; echo "['dragleave','drop'].forEach(function(evt){"; echo "dropzone.addEventListener(evt, function(e){ e.preventDefault(); e.stopPropagation(); dropzone.style.background = '#fafafa'; dropzone.style.borderColor = '#c3c4c7'; });"; echo "});"; echo "dropzone.addEventListener('drop', function(e){ setFile(e.dataTransfer.files); });"; echo "})();"; wpbr_inline_close(); wpbr_dialog_support_print(); $active_items = array(); foreach ($items as $item) { if ($item->status === 'combined') continue; $active_items[] = $item; } // `exact` IS A TIER AND IT HAS TO BE DECLARED HERE, which the identifier // work did not do. `wpbr_confidence_tier_key()` started returning it, this // array did not have the key, and every exact match raised an undefined-key // warning and was counted in no chip at all — so the filter row undercounted // the batch and offered no way to see the matches it was most sure of. Found // by driving the screen in a browser; no unit test renders this row. $tier_counts = array('exact' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'none' => 0); $no_file_count = 0; foreach ($active_items as $ai) { if ($ai->status === 'skip') continue; $tier_counts[wpbr_confidence_tier_key($ai)]++; if (wpbr_item_needs_new_attach($ai, $batch) && $ai->status !== 'confirmed_new_attach') $no_file_count++; } echo "
    "; echo "" . esc_html__('Filter:', 'wpbr-bulk-replace') . ""; // FIRST, AND IN THE BRAND BLUE RATHER THAN THE CONFIDENCE GREEN, because it // is not the top of the confidence scale — it is off it. wpbr_filter_chip(array('data-filter-tier' => 'exact'), $tier_counts['exact'], __('Exact', 'wpbr-bulk-replace'), 'background:#006DF5;color:#fff;'); wpbr_filter_chip(array('data-filter-tier' => 'high'), $tier_counts['high'], __('High', 'wpbr-bulk-replace'), 'background:#00a32a;color:#fff;'); wpbr_filter_chip(array('data-filter-tier' => 'medium'), $tier_counts['medium'], __('Medium', 'wpbr-bulk-replace'), 'background:#dba617;color:#1d2327;'); wpbr_filter_chip(array('data-filter-tier' => 'low'), $tier_counts['low'], __('Low', 'wpbr-bulk-replace'), 'background:#d63638;color:#fff;'); wpbr_filter_chip(array('data-filter-tier' => 'none'), $tier_counts['none'], __('No match', 'wpbr-bulk-replace'), 'background:#c3c4c7;color:#1d2327;'); wpbr_filter_chip(array('data-filter-attach' => '1'), $no_file_count, __('No existing file', 'wpbr-bulk-replace'), 'background:#b32d2e;color:#fff;'); if ($conflict_ids) { wpbr_filter_chip(array('data-filter-conflict' => '1'), count($conflict_ids), __('Conflicts', 'wpbr-bulk-replace'), 'background:#b32d2e;color:#fff;'); } echo ""; echo "
    "; $status_counts = array(); foreach ($active_items as $ai) { if (!isset($status_counts[$ai->status])) $status_counts[$ai->status] = 0; $status_counts[$ai->status]++; } if (count($status_counts) > 1) { echo "
    "; echo "" . esc_html__('Status:', 'wpbr-bulk-replace') . ""; foreach (wpbr_status_labels() as $slug => $label) { if (empty($status_counts[$slug])) continue; wpbr_filter_chip(array('data-filter-status' => $slug), $status_counts[$slug], $label, 'background:#50575e;color:#fff;'); } echo "
    "; } echo "
    "; wp_nonce_field('wpbr_save_review'); echo ""; echo ""; echo ""; echo "
    "; echo "
    "; echo "" . esc_html(sprintf( /* translators: %s is a number of selected rows. */ __('%s selected', 'wpbr-bulk-replace'), number_format_i18n(0) )) . ""; if (wpbr_combine_enabled()) { echo ""; echo ""; // Inline, adjacent to the control, and announced: replaces an alert() // that a screen-reader user met with no context and no way back. echo ""; // The visible reason Combine is unavailable while it is disabled: same rule as the button, said where it can be read. echo ""; } echo ""; echo ""; echo ""; echo "
    "; echo "
    "; echo ""; echo ""; echo ""; echo ""; echo "
    "; echo "
    "; echo "
    "; echo "
    ', 'wpbr-bulk-replace')); echo ""; echo ""; $tag_label = wpbr_post_type_label($batch->post_type); $tags = array( /* translators: %s is the singular label of a post type, e.g. "product". */ '{title}' => sprintf(__('matched %s title', 'wpbr-bulk-replace'), $tag_label), /* translators: %s is the singular label of a post type, e.g. "product". */ '{id}' => sprintf(__('matched %s ID', 'wpbr-bulk-replace'), $tag_label), '{original}' => __('staged filename without extension', 'wpbr-bulk-replace'), '{ext}' => __('file extension', 'wpbr-bulk-replace'), '{date}' => __('today, Y-m-d', 'wpbr-bulk-replace'), '{date:Ymd}' => '', '{date:Y}' => '', /* translators: %s is the singular label of a post type, e.g. "product". */ '{meta:field_name}' => sprintf(__('any meta field on the matched %s', 'wpbr-bulk-replace'), $tag_label), ); echo "

    " . esc_html__('Tags:', 'wpbr-bulk-replace') . " "; $first_tag = true; foreach ($tags as $tag => $explanation) { if (!$first_tag) echo ', '; $first_tag = false; echo "".esc_html($tag).""; if ($explanation) echo " (".esc_html($explanation).")"; } echo ". "; echo esc_html__('Example:', 'wpbr-bulk-replace') . " {title}-{date:Y}.{ext}. "; echo esc_html__('Only fills in rows that currently have a match selected; anything without one is left alone.', 'wpbr-bulk-replace') . "

    "; echo "
    "; $meta_field_choices = ($batch->post_type) ? wpbr_get_meta_key_suggestions($batch->post_type, 200) : array(); sort($meta_field_choices); echo "
    "; echo "

    " . esc_html(sprintf( /* translators: %s is the singular label of a post type, e.g. "product". */ __('Filter by the matched %s’s own fields', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type) )) . "

    "; echo "

    " . esc_html__('Tick a field to load its values, then pick a value to narrow the table. Fields with unusable values (URLs, long text, lists) are skipped automatically.', 'wpbr-bulk-replace') . "

    "; echo ""; echo ""; echo "
    "; foreach ($meta_field_choices as $mf) { echo ""; } echo "
    "; echo "
    "; echo "
    "; wpbr_print_review_js_l10n(); wpbr_inline_open('script'); echo "(function(){"; echo "var L = window.wpbrReviewL10n;"; echo "function bindPanel(btnId, panelId, openLabel, closedLabel){"; echo "var btn = document.getElementById(btnId), panel = document.getElementById(panelId);"; echo "if (!btn || !panel) return;"; echo "btn.addEventListener('click', function(){"; echo "var isHidden = panel.style.display === 'none';"; echo "panel.style.display = isHidden ? 'block' : 'none';"; echo "btn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');"; echo "btn.textContent = isHidden ? openLabel : closedLabel;"; echo "});"; echo "}"; echo "bindPanel('wpbr_toggle_rename', 'wpbr_rename_panel', L.hideRename, L.showRename);"; echo "bindPanel('wpbr_toggle_custom_filter', 'wpbr_custom_filter_panel', L.hideFilter, L.showFilter);"; echo "})();"; wpbr_inline_close(); wpbr_print_review_js_l10n(); wpbr_inline_open('script'); echo "window.wpbrCustomFilter = { maps: {}, selected: {} };"; echo "(function(){"; echo "var L = window.wpbrReviewL10n;"; echo "var nonce = '".esc_js(wp_create_nonce('wpbr_meta_field_values'))."';"; echo "var batchId = ".intval($batch_id).";"; echo "var cfRestoreNonce = nonce, cfRestoreBatch = batchId;"; echo "var selectors = document.getElementById('wpbr_cf_selectors');"; echo "document.addEventListener('change', function(e){"; echo "if (!e.target.classList.contains('wpbr-cf-field')) return;"; echo "var field = e.target.value;"; echo "var existing = document.getElementById('wpbr_cf_wrap_' + field.replace(/[^a-zA-Z0-9_-]/g, ''));"; echo "if (!e.target.checked) {"; echo "if (existing) existing.remove();"; echo "delete window.wpbrCustomFilter.selected[field];"; echo "if (window.wpbrRefreshFilters) window.wpbrRefreshFilters();"; echo "return;"; echo "}"; echo "if (window.wpbrRefreshFilters) window.wpbrRefreshFilters();"; echo "var safeId = field.replace(/[^a-zA-Z0-9_-]/g, '');"; echo "var wrap = document.createElement('div');"; echo "wrap.id = 'wpbr_cf_wrap_' + safeId;"; echo "wrap.style.fontSize = '12px';"; echo "window.wpbrCfWrap(wrap, field, L.loading);"; echo "selectors.appendChild(wrap);"; echo "var body = 'action=wpbr_meta_field_values&nonce=' + encodeURIComponent(nonce) + '&batch_id=' + batchId + '&field=' + encodeURIComponent(field);"; echo "fetch(ajaxurl, {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})"; echo ".then(function(r){ return r.json(); })"; echo ".then(function(resp){"; echo "if (!resp.success || !resp.data.values.length) {"; echo "window.wpbrCfWrap(wrap, field, L.noValues);"; echo "return;"; echo "}"; echo "window.wpbrCustomFilter.maps[field] = resp.data.map;"; echo "var sel = document.createElement('select');"; echo "sel.dataset.cfField = field;"; echo "sel.className = 'wpbr-cf-value';"; echo "var optAny = document.createElement('option'); optAny.value = ''; optAny.textContent = L.anyValue;"; echo "sel.appendChild(optAny);"; echo "resp.data.values.forEach(function(v){"; echo "var o = document.createElement('option'); o.value = v.value; o.textContent = v.label; sel.appendChild(o);"; echo "});"; echo "sel.setAttribute('aria-label', window.wpbrFmt(L.filterByField, field));"; echo "window.wpbrCfWrap(wrap, field, '');"; echo "wrap.appendChild(sel);"; echo "});"; echo "});"; echo "document.addEventListener('change', function(e){"; echo "if (!e.target.classList.contains('wpbr-cf-value')) return;"; echo "var field = e.target.dataset.cfField;"; echo "if (e.target.value === '') { delete window.wpbrCustomFilter.selected[field]; }"; echo "else { window.wpbrCustomFilter.selected[field] = e.target.value; }"; echo "if (window.wpbrRefreshFilters) window.wpbrRefreshFilters();"; echo "});"; echo "window.wpbrRestoreCustomFilter = function(fields, values){"; echo "var panel = document.getElementById('wpbr_custom_filter_panel');"; echo "var toggle = document.getElementById('wpbr_toggle_custom_filter');"; echo "if (panel) { panel.style.display = 'block'; }"; echo "if (toggle) { toggle.textContent = L.hideFilter; toggle.setAttribute('aria-expanded', 'true'); }"; echo "fields.forEach(function(field){"; echo "var cb = document.querySelector(\".wpbr-cf-field[value='\" + field + \"']\");"; echo "if (!cb) return;"; echo "cb.checked = true;"; echo "var safeId = field.replace(/[^a-zA-Z0-9_-]/g, '');"; echo "var wrap = document.createElement('div');"; echo "wrap.id = 'wpbr_cf_wrap_' + safeId; wrap.style.fontSize = '12px';"; echo "window.wpbrCfWrap(wrap, field, L.loading);"; echo "document.getElementById('wpbr_cf_selectors').appendChild(wrap);"; echo "var body = 'action=wpbr_meta_field_values&nonce=' + encodeURIComponent(cfRestoreNonce) + '&batch_id=' + cfRestoreBatch + '&field=' + encodeURIComponent(field);"; echo "fetch(ajaxurl,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body})"; echo ".then(function(r){ return r.json(); }).then(function(resp){"; echo "if (!resp.success || !resp.data.values.length) { window.wpbrCfWrap(wrap, field, L.noValues); return; }"; echo "window.wpbrCustomFilter.maps[field] = resp.data.map;"; echo "var sel = document.createElement('select'); sel.dataset.cfField = field; sel.className = 'wpbr-cf-value';"; echo "sel.setAttribute('aria-label', window.wpbrFmt(L.filterByField, field));"; echo "var o0 = document.createElement('option'); o0.value=''; o0.textContent=L.anyValue; sel.appendChild(o0);"; echo "resp.data.values.forEach(function(v){ var o=document.createElement('option'); o.value=v.value; o.textContent=v.label; sel.appendChild(o); });"; echo "if (values[field]) { sel.value = values[field]; window.wpbrCustomFilter.selected[field] = values[field]; }"; echo "window.wpbrCfWrap(wrap, field, ''); wrap.appendChild(sel);"; echo "if (window.wpbrRefreshFilters) window.wpbrRefreshFilters();"; echo "});"; echo "});"; echo "};"; echo "})();"; wpbr_inline_close(); /* translators: %s is the singular label of the post type being matched, e.g. "product". */ $matched_col = sprintf(__('Matched %s', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type)); echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; foreach ($active_items as $item) { wpbr_render_review_row($item, $candidates, $batch, $conflict_ids); } echo "
    " . esc_html__('Staged filename', 'wpbr-bulk-replace') . "" . esc_html($matched_col) . "" . esc_html__('Score', 'wpbr-bulk-replace') . "" . esc_html__('Rename to', 'wpbr-bulk-replace') . "" . esc_html__('Status', 'wpbr-bulk-replace') . "" . esc_html__('Notes', 'wpbr-bulk-replace') . "
    "; echo ""; echo "

    "; wpbr_print_review_js_l10n(); wpbr_inline_open('script'); echo "(function(){"; echo "var L = window.wpbrReviewL10n;"; echo "var chips = document.querySelectorAll('.wpbr-filter-chip');"; echo "var clearBtn = document.getElementById('wpbr_clear_filters');"; echo "var currentPage = 1;"; echo "var pageSize = 50;"; echo "var pageSizeSelect = document.getElementById('wpbr_page_size');"; echo "if (pageSizeSelect) { pageSize = parseInt(pageSizeSelect.value, 10); }"; echo "function getActiveFilters(){"; echo "var activeTiers = [];"; echo "var activeStatuses = [];"; echo "var attachOnly = false;"; echo "var conflictOnly = false;"; echo "chips.forEach(function(chip){"; echo "if (!chip.classList.contains('active')) return;"; echo "if (chip.dataset.filterTier) { activeTiers.push(chip.dataset.filterTier); }"; echo "if (chip.dataset.filterStatus) { activeStatuses.push(chip.dataset.filterStatus); }"; echo "if (chip.dataset.filterAttach) { attachOnly = true; }"; echo "if (chip.dataset.filterConflict) { conflictOnly = true; }"; echo "});"; echo "var matchGroupActive = (activeTiers.length > 0 || attachOnly || conflictOnly);"; echo "var anyActive = (matchGroupActive || activeStatuses.length > 0);"; echo "return { activeTiers: activeTiers, activeStatuses: activeStatuses, attachOnly: attachOnly, conflictOnly: conflictOnly, matchGroupActive: matchGroupActive, anyActive: anyActive };"; echo "}"; echo "function rowMatchesFilters(row, f){"; echo "var skipActive = f.activeStatuses.indexOf('skip') !== -1;"; echo "if (row.dataset.status === 'skip' && !skipActive) return false;"; echo "var cf = window.wpbrCustomFilter;"; echo "if (cf) {"; echo "for (var field in cf.selected) {"; echo "if (!cf.selected.hasOwnProperty(field)) continue;"; echo "var map = cf.maps[field] || {};"; echo "if (map[row.dataset.postId] !== cf.selected[field]) return false;"; echo "}"; echo "}"; echo "if (!f.anyActive) return true;"; echo "if (f.matchGroupActive) {"; echo "var ok = (f.activeTiers.indexOf(row.dataset.tier) !== -1)"; echo " || (f.attachOnly && row.dataset.needsAttach === '1')"; echo " || (f.conflictOnly && row.dataset.conflict === '1');"; echo "if (!ok) return false;"; echo "}"; echo "if (f.activeStatuses.length > 0) {"; echo "if (f.activeStatuses.indexOf(row.dataset.status) === -1) return false;"; echo "}"; echo "return true;"; echo "}"; echo "function refreshActiveTable(f){"; echo "var table = document.getElementById('wpbr_active_table');"; echo "if (!table) return;"; echo "var allRows = Array.prototype.slice.call(table.querySelectorAll('tbody tr'));"; echo "var filtered = allRows.filter(function(row){ return rowMatchesFilters(row, f); });"; echo "var totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));"; echo "if (currentPage > totalPages) currentPage = totalPages;"; echo "if (currentPage < 1) currentPage = 1;"; echo "var start = (currentPage - 1) * pageSize;"; echo "var end = start + pageSize;"; echo "allRows.forEach(function(row){ row.style.display = 'none'; });"; echo "filtered.slice(start, end).forEach(function(row){ row.style.display = ''; });"; echo "var label = document.getElementById('wpbr_page_label');"; echo "if (label) { window.wpbrSetLive(label, window.wpbrFmt(L.pageOf, currentPage, totalPages, filtered.length)); }"; echo "var prevBtn = document.getElementById('wpbr_page_prev');"; echo "var nextBtn = document.getElementById('wpbr_page_next');"; echo "if (prevBtn) { prevBtn.disabled = (currentPage <= 1); }"; echo "if (nextBtn) { nextBtn.disabled = (currentPage >= totalPages); }"; echo "}"; echo "var storeKey = '".esc_js($batch_id)."_review';"; echo "function saveState(){"; echo "var chipState = [];"; echo "chips.forEach(function(ch){ if (ch.classList.contains('active')) chipState.push(ch.dataset.filterTier || ('flag:' + (ch.dataset.filterAttach ? 'attach' : 'conflict'))); });"; echo "var statusState = [];"; echo "chips.forEach(function(ch){ if (ch.classList.contains('active') && ch.dataset.filterStatus) statusState.push(ch.dataset.filterStatus); });"; echo "var fieldState = Array.prototype.slice.call(document.querySelectorAll('.wpbr-cf-field:checked')).map(function(cb){ return cb.value; });"; echo "if (window.wpbrSaveFilters) window.wpbrSaveFilters(storeKey, { chips: chipState, statuses: statusState, fields: fieldState, cf: window.wpbrCustomFilter ? window.wpbrCustomFilter.selected : {}, pageSize: pageSize });"; echo "}"; echo "function refreshAll(){"; echo "chips.forEach(function(c){ c.setAttribute('aria-pressed', c.classList.contains('active') ? 'true' : 'false'); });"; echo "var f = getActiveFilters();"; echo "clearBtn.style.display = f.anyActive ? 'inline-block' : 'none';"; echo "refreshActiveTable(f);"; echo "saveState();"; echo "}"; echo "window.wpbrRefreshFilters = function(){ currentPage = 1; refreshAll(); };"; echo "chips.forEach(function(chip){"; echo "chip.addEventListener('click', function(){"; echo "chip.classList.toggle('active');"; echo "currentPage = 1;"; echo "refreshAll();"; echo "});"; echo "});"; echo "clearBtn.addEventListener('click', function(){"; echo "chips.forEach(function(chip){ chip.classList.remove('active'); });"; echo "if (window.wpbrCustomFilter) { window.wpbrCustomFilter.selected = {}; }"; echo "document.querySelectorAll('.wpbr-cf-field').forEach(function(cb){ cb.checked = false; });"; echo "var sels = document.getElementById('wpbr_cf_selectors'); if (sels) sels.innerHTML = '';"; echo "if (window.wpbrClearFilters) window.wpbrClearFilters(storeKey);"; echo "currentPage = 1;"; echo "refreshAll();"; echo "});"; echo "var prevBtn = document.getElementById('wpbr_page_prev');"; echo "var nextBtn = document.getElementById('wpbr_page_next');"; echo "if (prevBtn) { prevBtn.addEventListener('click', function(){ currentPage--; refreshAll(); }); }"; echo "if (nextBtn) { nextBtn.addEventListener('click', function(){ currentPage++; refreshAll(); }); }"; echo "if (pageSizeSelect) {"; echo "pageSizeSelect.addEventListener('change', function(){"; echo "pageSize = parseInt(pageSizeSelect.value, 10);"; echo "currentPage = 1;"; echo "refreshAll();"; echo "});"; echo "}"; echo "var saved = window.wpbrLoadFilters ? window.wpbrLoadFilters(storeKey) : null;"; echo "if (saved) {"; echo "(saved.chips||[]).forEach(function(v){"; echo "var sel = v.indexOf('flag:') === 0"; echo " ? (v === 'flag:attach' ? '.wpbr-filter-chip[data-filter-attach]' : '.wpbr-filter-chip[data-filter-conflict]')"; echo " : \".wpbr-filter-chip[data-filter-tier='\" + v + \"']\";"; echo "var ch = document.querySelector(sel); if (ch) ch.classList.add('active');"; echo "});"; echo "(saved.statuses||[]).forEach(function(v){"; echo "var ch = document.querySelector(\".wpbr-filter-chip[data-filter-status='\" + v + \"']\"); if (ch) ch.classList.add('active');"; echo "});"; echo "if (saved.pageSize && pageSizeSelect) { pageSize = saved.pageSize; pageSizeSelect.value = String(saved.pageSize); }"; echo "var revFields = saved.fields || Object.keys(saved.cf || {});"; echo "if (revFields.length && window.wpbrRestoreCustomFilter) { window.wpbrRestoreCustomFilter(revFields, saved.cf || {}); }"; echo "}"; echo "refreshAll();"; echo "document.addEventListener('click', function(e){"; echo "var link = e.target.closest('.wpbr-show-conflict');"; echo "if (!link) return;"; echo "e.preventDefault();"; echo "var group = link.dataset.group;"; echo "var conflictChip = document.querySelector('.wpbr-filter-chip[data-filter-conflict]');"; echo "if (conflictChip && !conflictChip.classList.contains('active')) {"; echo "chips.forEach(function(c){ c.classList.remove('active'); });"; echo "conflictChip.classList.add('active');"; echo "}"; echo "pageSize = 99999;"; echo "if (pageSizeSelect) { pageSizeSelect.value = '99999'; }"; echo "currentPage = 1;"; echo "refreshAll();"; echo "document.querySelectorAll('tr.wpbr-conflict-lit').forEach(function(r){ r.classList.remove('wpbr-conflict-lit'); });"; echo "var groupRows = document.querySelectorAll(\"tr[data-conflict-group='\" + group + \"']\");"; echo "groupRows.forEach(function(r){"; echo "r.classList.add('wpbr-conflict-lit');"; echo "r.classList.remove('wpbr-conflict-flash');"; echo "void r.offsetWidth;"; echo "r.classList.add('wpbr-conflict-flash');"; echo "});"; echo "if (groupRows.length) { groupRows[0].scrollIntoView({behavior:'smooth', block:'center'}); }"; echo "});"; echo "})();"; wpbr_inline_close(); echo "
    "; // Strings the script shows the user, translated server-side and handed // over as data. Leaving English literals in the script would make this // dialog the one part of the screen that cannot be translated. echo "
    "; echo "
    "; echo "

    " . esc_html__('Combine into one PDF', 'wpbr-bulk-replace') . "

    "; echo "

    " . esc_html__('These will be merged in the order shown — first in the list becomes the first page. Use the Move up and Move down buttons to reorder, or drag the rows if you prefer.', 'wpbr-bulk-replace') . "

    "; echo "
      "; // Reordering is otherwise a silent visual change: the list rearranges and // nothing says so. Announced politely, so it does not interrupt a key // press already in progress. echo "

      "; echo "

      " . esc_html__('The originals stay in the batch marked as combined, so this can be undone by deleting the merged row and setting them back.', 'wpbr-bulk-replace') . "

      "; echo "
      "; echo ""; echo ""; echo "
      "; wpbr_inline_open('style'); echo "#wpbr_combine_list li{display:flex;align-items:center;gap:8px;background:#f6f7f7;border:1px solid #dcdcde;border-radius:4px;padding:8px 10px;margin-bottom:6px;cursor:grab;}"; echo "#wpbr_combine_list li.dragging{opacity:.45;}"; echo "#wpbr_combine_list .wpbr-pos{min-width:22px;height:22px;border-radius:50%;background:#2271b1;color:#fff;font-size:11px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;}"; echo "#wpbr_combine_list .wpbr-nm{flex:1;font-size:13px;word-break:break-all;}"; echo "#wpbr_combine_list button{padding:0 6px;line-height:1.6;}"; wpbr_inline_close(); wpbr_print_inline_error_style(); wpbr_inline_open('script'); echo "var wpbrCombineL10n = " . wp_json_encode(wpbr_combine_js_l10n(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";"; echo "(function(){"; echo "var L = wpbrCombineL10n;"; echo "var modal = document.getElementById('wpbr_combine_modal');"; echo "var panel = document.getElementById('wpbr_combine_panel');"; echo "var list = document.getElementById('wpbr_combine_list');"; echo "var live = document.getElementById('wpbr_combine_live');"; echo "var btn = document.getElementById('wpbr_btn_combine');"; echo "var errBox = document.getElementById('wpbr_combine_error');"; echo "var orderField = document.getElementById('wpbr_combine_order');"; echo "var form = document.getElementById('wpbr_review_form');"; echo "if (!btn || !modal) return;"; wpbr_js_fmt_helper(); echo "function announce(msg){ live.textContent = ''; window.setTimeout(function(){ live.textContent = msg; }, 50); }"; echo "function showError(msg){"; echo "if (!errBox) return;"; echo "errBox.textContent = msg; errBox.style.display = 'inline-block';"; echo "btn.focus();"; echo "}"; echo "function clearError(){ if (errBox) { errBox.textContent = ''; errBox.style.display = 'none'; } }"; // Position is shown as a numbered circle, which is decoration; the same // information has to reach a screen reader as text, and both have to be // rewritten after every move. echo "function renumber(){"; echo "var items = Array.prototype.slice.call(list.children);"; echo "items.forEach(function(li, i){"; echo "var name = li.dataset.name || '';"; echo "li.querySelector('.wpbr-pos').textContent = (i + 1);"; echo "li.querySelector('.wpbr-sr-pos').textContent = fmt(L.position, i + 1, items.length) + ' ';"; echo "li.querySelector('.wpbr-up').setAttribute('aria-label', fmt(L.moveUp, name));"; echo "li.querySelector('.wpbr-down').setAttribute('aria-label', fmt(L.moveDown, name));"; echo "});"; echo "}"; echo "function move(li, delta){"; echo "var sibling = delta < 0 ? li.previousElementSibling : li.nextElementSibling;"; echo "if (!sibling) { announce(delta < 0 ? L.atTop : L.atBottom); return; }"; echo "if (delta < 0) { list.insertBefore(li, sibling); } else { list.insertBefore(sibling, li); }"; echo "renumber();"; echo "var pos = Array.prototype.indexOf.call(list.children, li) + 1;"; echo "announce(fmt(L.moved, li.dataset.name || '', pos, list.children.length));"; echo "}"; echo "function build(){"; echo "list.innerHTML = '';"; echo "document.querySelectorAll('.wpbr-row-check:checked').forEach(function(cb){"; echo "var row = cb.closest('tr');"; echo "var cell = row ? row.querySelector('td[data-colname=\"Staged filename\"] span') : null;"; echo "var name = cell ? cell.textContent : '';"; echo "if (!name) { name = fmt(L.itemLabel, cb.value); }"; echo "var li = document.createElement('li');"; echo "li.draggable = true;"; echo "li.dataset.id = cb.value;"; echo "li.dataset.name = name;"; echo "var pos = document.createElement('span'); pos.className = 'wpbr-pos'; pos.setAttribute('aria-hidden', 'true');"; echo "var srPos = document.createElement('span'); srPos.className = 'wpbr-sr-pos screen-reader-text';"; echo "var nm = document.createElement('span'); nm.className = 'wpbr-nm'; nm.textContent = name;"; echo "var up = document.createElement('button'); up.type='button'; up.className='button button-small wpbr-up'; up.innerHTML='\\u2191';"; echo "var dn = document.createElement('button'); dn.type='button'; dn.className='button button-small wpbr-down'; dn.innerHTML='\\u2193';"; echo "up.addEventListener('click', function(){ move(li, -1); up.focus(); });"; echo "dn.addEventListener('click', function(){ move(li, 1); dn.focus(); });"; echo "li.appendChild(pos); li.appendChild(srPos); li.appendChild(nm); li.appendChild(up); li.appendChild(dn);"; echo "list.appendChild(li);"; echo "});"; echo "renumber();"; echo "}"; // Drag stays as an enhancement. Everything it does, the buttons already do. echo "var dragged = null;"; echo "list.addEventListener('dragstart', function(e){ dragged = e.target.closest('li'); if (dragged) dragged.classList.add('dragging'); });"; echo "list.addEventListener('dragend', function(){"; echo "if (dragged) { dragged.classList.remove('dragging'); renumber();"; echo "var pos = Array.prototype.indexOf.call(list.children, dragged) + 1;"; echo "announce(fmt(L.moved, dragged.dataset.name || '', pos, list.children.length)); }"; echo "dragged = null;"; echo "});"; echo "list.addEventListener('dragover', function(e){"; echo "e.preventDefault();"; echo "var over = e.target.closest('li');"; echo "if (!over || !dragged || over === dragged) return;"; echo "var rect = over.getBoundingClientRect();"; echo "var after = (e.clientY - rect.top) > (rect.height / 2);"; echo "list.insertBefore(dragged, after ? over.nextSibling : over);"; echo "});"; // Focus is returned to whatever opened the dialog. Without this, closing // drops the caret at the top of the document and a keyboard user has to // tab back through the whole table. echo "var returnFocusTo = null;"; echo "function openModal(){"; echo "returnFocusTo = document.activeElement;"; echo "build();"; echo "modal.style.display = 'block';"; echo "panel.focus();"; echo "}"; echo "function closeModal(){"; echo "modal.style.display = 'none';"; echo "if (returnFocusTo && returnFocusTo.focus) returnFocusTo.focus();"; echo "returnFocusTo = null;"; echo "}"; echo "btn.addEventListener('click', function(){"; echo "clearError();"; echo "var n = document.querySelectorAll('.wpbr-row-check:checked').length;"; echo "if (n < 2) { showError(L.needTwo); return; }"; echo "openModal();"; echo "});"; echo "document.getElementById('wpbr_combine_cancel').addEventListener('click', closeModal);"; echo "modal.addEventListener('click', function(e){ if (e.target === modal) closeModal(); });"; // Escape closes, and Tab is kept inside the dialog: a modal a keyboard // user can tab out of is not modal, and they end up operating a table // they cannot see behind the overlay. echo "modal.addEventListener('keydown', function(e){"; echo "if (e.key === 'Escape') { e.preventDefault(); closeModal(); return; }"; echo "if (e.key !== 'Tab') return;"; echo "var focusable = panel.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');"; echo "if (!focusable.length) return;"; echo "var first = focusable[0], last = focusable[focusable.length - 1];"; echo "if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) { e.preventDefault(); last.focus(); }"; echo "else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }"; echo "});"; echo "document.getElementById('wpbr_combine_go').addEventListener('click', function(){"; echo "var ids = Array.prototype.slice.call(list.children).map(function(li){ return li.dataset.id; });"; echo "orderField.value = ids.join(',');"; echo "var hidden = document.createElement('input');"; echo "hidden.type = 'hidden'; hidden.name = 'wpbr_sub'; hidden.value = 'combine_files';"; echo "form.appendChild(hidden);"; echo "form.submit();"; echo "});"; echo "})();"; wpbr_inline_close(); wpbr_print_review_js_l10n(); wpbr_inline_open('script'); echo "(function(){"; echo "var L = window.wpbrReviewL10n;"; echo "var checks = document.querySelectorAll('.wpbr-row-check');"; echo "var countLabel = document.getElementById('wpbr_selected_count');"; echo "var btnCombine = document.getElementById('wpbr_btn_combine');"; echo "var btnSkip = document.getElementById('wpbr_btn_skip');"; echo "var btnConfirmNew = document.getElementById('wpbr_btn_confirm_new');"; echo "var btnDelete = document.getElementById('wpbr_btn_delete');"; echo "var checkAll = document.getElementById('wpbr_check_all');"; echo "function refreshToolbar(){"; echo "var checked = Array.prototype.filter.call(checks, function(c){ return c.checked; });"; echo "var bulkActions = document.getElementById('wpbr_bulk_actions');"; echo "if (bulkActions) { bulkActions.style.display = checked.length > 0 ? 'flex' : 'none'; }"; echo "window.wpbrSetLive(countLabel, window.wpbrFmt(L.nSelected, checked.length));"; echo "if (btnCombine) {"; echo "var allPdf = checked.length > 0 && checked.every(function(c){ return c.dataset.ext === 'pdf'; });"; echo "btnCombine.disabled = !(checked.length >= 2 && allPdf);"; echo "var why = document.getElementById('wpbr_combine_reason');"; echo "var need = (btnCombine.disabled && checked.length > 0 && window.wpbrCombineL10n) ? window.wpbrCombineL10n.needTwo : '';"; echo "if (why && why.textContent !== need) { why.textContent = need; }"; echo "}"; echo "btnSkip.disabled = checked.length === 0;"; echo "if (btnConfirmNew) { btnConfirmNew.disabled = checked.length === 0; }"; echo "btnDelete.disabled = checked.length === 0;"; echo "}"; echo "checks.forEach(function(c){ c.addEventListener('change', refreshToolbar); });"; echo "if (checkAll) { checkAll.addEventListener('change', function(){"; echo "checks.forEach(function(c){"; echo "var row = c.closest('tr');"; echo "if (row && row.style.display === 'none') { c.checked = false; return; }"; echo "c.checked = checkAll.checked;"; echo "});"; echo "refreshToolbar();"; echo "}); }"; echo "refreshToolbar();"; echo "})();"; wpbr_inline_close(); wpbr_print_review_js_l10n(); wpbr_inline_open('script'); echo "var wpbrAllCandidates = ".wp_json_encode(array_map(function($c){ return array('id' => $c['id'], 'title' => $c['title'], 'permalink' => $c['permalink']); }, $candidates), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT).";"; echo "var wpbrRenameNonce = '".esc_js(wp_create_nonce('wpbr_compute_rename'))."';"; echo "var wpbrConfirmNewAttachNonce = '".esc_js(wp_create_nonce('wpbr_confirm_new_attach'))."';"; echo "var wpbrBatchIdForRename = ".intval($batch_id).";"; echo "(function(){"; echo "var L = window.wpbrReviewL10n;"; echo "function wpbrCloseAllLists(except){"; echo "document.querySelectorAll('.wpbr-combo-list').forEach(function(l){ if (l !== except) l.style.display = 'none'; });"; echo "}"; echo "function wpbrTriggerRenameCompute(hiddenInput){"; echo "var row = hiddenInput.closest('tr');"; echo "if (!row) return;"; echo "var renameInput = row.querySelector('.wpbr-rename-input');"; echo "if (!renameInput) return;"; echo "var combo = hiddenInput.closest('.wpbr-combobox');"; echo "var textInput = combo ? combo.querySelector('.wpbr-combo-input') : null;"; echo "var filename = textInput ? textInput.dataset.filename : '';"; echo "var postId = hiddenInput.value;"; echo "if (!postId) return;"; echo "var templateInput = document.getElementById('wpbr_rename_template');"; echo "var template = templateInput ? templateInput.value : '';"; echo "var body = 'action=wpbr_compute_rename' +"; echo "'&nonce=' + encodeURIComponent(wpbrRenameNonce) +"; echo "'&batch_id=' + encodeURIComponent(wpbrBatchIdForRename) +"; echo "'&post_id=' + encodeURIComponent(postId) +"; echo "'&original_filename=' + encodeURIComponent(filename) +"; echo "'&template=' + encodeURIComponent(template);"; echo "fetch(ajaxurl, {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})"; echo ".then(function(r){ return r.json(); })"; echo ".then(function(resp){"; echo "if (!resp.success) return;"; echo "if (resp.data.rename_to) { renameInput.value = resp.data.rename_to; }"; echo "var cell = renameInput.parentNode;"; echo "var oldBadge = cell.querySelector('.wpbr-new-attach-badge');"; echo "if (oldBadge) oldBadge.remove();"; echo "var oldHint = cell.querySelector('.wpbr-rename-hint');"; echo "if (oldHint) oldHint.remove();"; echo "if (resp.data.has_existing_file) {"; echo "renameInput.style.display = '';"; echo "if (row) { row.dataset.needsAttach = '0'; }"; echo "} else {"; echo "renameInput.style.display = 'none';"; echo "if (row) { row.dataset.needsAttach = '1'; }"; echo "var badge = document.createElement('div');"; echo "badge.className = 'wpbr-new-attach-badge';"; echo "badge.style.fontSize = '12px';"; echo "badge.style.color = '#b32d2e';"; echo "badge.textContent = L.noExistingFile + ' \u2014 ';"; echo "var btn2 = document.createElement('button');"; echo "btn2.type = 'button';"; echo "btn2.className = 'button button-small wpbr-confirm-new-attach';"; echo "btn2.dataset.itemId = row ? row.dataset.itemId : '';"; echo "btn2.textContent = L.confirmAttach;"; echo "badge.appendChild(btn2);"; echo "cell.appendChild(badge);"; echo "}"; echo "});"; echo "}"; echo "document.addEventListener('input', function(e){"; echo "if (!e.target.classList.contains('wpbr-combo-input')) return;"; echo "var input = e.target;"; echo "var combo = input.closest('.wpbr-combobox');"; echo "var list = combo.querySelector('.wpbr-combo-list');"; echo "var term = input.value.trim().toLowerCase();"; echo "list.innerHTML = '';"; echo "if (!term) { list.style.display = 'none'; return; }"; echo "var matches = wpbrAllCandidates.filter(function(c){ return c.title.toLowerCase().indexOf(term) !== -1; }).slice(0, 50);"; echo "if (!matches.length) {"; echo "var none = document.createElement('div');"; echo "none.textContent = L.noMatches;"; echo "none.style.padding = '6px 8px';"; echo "none.style.color = '#787c82';"; echo "list.appendChild(none);"; echo "list.style.display = 'block';"; echo "return;"; echo "}"; echo "matches.forEach(function(c){"; echo "var opt = document.createElement('div');"; echo "opt.textContent = c.title;"; echo "opt.dataset.id = c.id;"; echo "opt.dataset.title = c.title;"; echo "opt.dataset.permalink = c.permalink || '';"; echo "opt.style.padding = '6px 8px';"; echo "opt.style.cursor = 'pointer';"; echo "opt.addEventListener('mouseenter', function(){ opt.style.background = '#f0f0f1'; });"; echo "opt.addEventListener('mouseleave', function(){ opt.style.background = ''; });"; echo "list.appendChild(opt);"; echo "});"; echo "list.style.display = 'block';"; echo "wpbrCloseAllLists(list);"; echo "});"; echo "document.addEventListener('mousedown', function(e){"; echo "var picked = e.target.closest('.wpbr-combo-list > div[data-id]');"; echo "if (!picked) return;"; echo "var combo = picked.closest('.wpbr-combobox');"; echo "var textInput = combo.querySelector('.wpbr-combo-input');"; echo "var hiddenInput = combo.querySelector('.wpbr-combo-hidden');"; echo "var viewLink = combo.querySelector('.wpbr-view-post-icon');"; echo "textInput.value = picked.dataset.title;"; echo "hiddenInput.value = picked.dataset.id;"; echo "if (viewLink) {"; echo "if (picked.dataset.permalink) { viewLink.href = picked.dataset.permalink; viewLink.style.display = ''; }"; echo "else { viewLink.style.display = 'none'; }"; echo "}"; echo "picked.closest('.wpbr-combo-list').style.display = 'none';"; echo "var row = hiddenInput.closest('tr');"; echo "var statusSelect = row ? row.querySelector('.wpbr-status-select') : null;"; echo "if (statusSelect) { statusSelect.value = 'matched'; }"; echo "var dot = row ? row.querySelector('.wpbr-confidence-dot') : null;"; echo "if (dot) { dot.style.background = '#00a32a'; dot.title = L.setManually;"; // The dot is aria-hidden; its screen-reader companion carries the words. echo "var srDot = dot.nextElementSibling;"; echo "if (srDot && srDot.classList.contains('screen-reader-text')) { srDot.textContent = L.setManually; var vis = srDot.nextElementSibling; if (vis && vis.classList.contains('wpbr-score-label')) { vis.textContent = L.setManually; } } }"; echo "if (row) { row.dataset.tier = 'high'; }"; echo "var jsonField = document.getElementById('wpbr_manual_picks_json');"; echo "if (jsonField && row && row.dataset.itemId) {"; echo "var picks = [];"; echo "try { picks = jsonField.value ? JSON.parse(jsonField.value) : []; } catch(err) { picks = []; }"; echo "if (picks.indexOf(row.dataset.itemId) === -1) { picks.push(row.dataset.itemId); }"; echo "jsonField.value = JSON.stringify(picks);"; echo "}"; echo "wpbrTriggerRenameCompute(hiddenInput);"; echo "});"; echo "document.addEventListener('focusout', function(e){"; echo "if (!e.target.classList.contains('wpbr-combo-input')) return;"; echo "var combo = e.target.closest('.wpbr-combobox');"; echo "var list = combo.querySelector('.wpbr-combo-list');"; echo "var hiddenInput = combo.querySelector('.wpbr-combo-hidden');"; echo "setTimeout(function(){"; echo "list.style.display = 'none';"; echo "if (e.target.value.trim() === '' && hiddenInput.value !== '') {"; echo "hiddenInput.value = '';"; echo "var viewLink = combo.querySelector('.wpbr-view-post-icon');"; echo "if (viewLink) { viewLink.style.display = 'none'; }"; echo "}"; echo "}, 200);"; echo "});"; echo "document.addEventListener('click', function(e){"; echo "if (!e.target.closest('.wpbr-combobox')) { wpbrCloseAllLists(); }"; echo "});"; echo "document.addEventListener('click', function(e){"; echo "if (!e.target.classList.contains('wpbr-confirm-new-attach')) return;"; echo "var btn = e.target;"; echo "var itemId = btn.dataset.itemId;"; echo "btn.disabled = true;"; echo "var body = 'action=wpbr_confirm_new_attach&nonce=' + encodeURIComponent(wpbrConfirmNewAttachNonce) + '&item_id=' + encodeURIComponent(itemId);"; echo "fetch(ajaxurl, {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})"; echo ".then(function(r){ return r.json(); })"; echo ".then(function(resp){"; echo "if (!resp.success) { btn.disabled = false; return; }"; echo "var row = btn.closest('tr');"; echo "var statusSelect = row ? row.querySelector('.wpbr-status-select') : null;"; echo "if (statusSelect) { statusSelect.value = 'confirmed_new_attach'; }"; echo "var renameInput = row ? row.querySelector('.wpbr-rename-input') : null;"; echo "if (renameInput) { renameInput.style.display = ''; }"; echo "var hiddenInput = row ? row.querySelector('.wpbr-combo-hidden') : null;"; echo "if (hiddenInput && hiddenInput.value && renameInput && !renameInput.value) { wpbrTriggerRenameCompute(hiddenInput); }"; echo "var badge = btn.closest('.wpbr-new-attach-badge');"; echo "if (row) { row.dataset.needsAttach = '0'; }"; echo "if (badge) {"; echo "badge.style.color = '#2271b1';"; echo "badge.style.transition = 'opacity .5s ease';"; echo "badge.textContent = '\u2713 ' + L.confirmedAttach;"; echo "setTimeout(function(){ badge.style.opacity = '0'; }, 3000);"; echo "setTimeout(function(){ if (badge.parentNode) { badge.parentNode.removeChild(badge); } }, 3600);"; echo "}"; echo "});"; echo "});"; echo "})();"; wpbr_inline_close(); // Last thing on the step, after the work rather than in front of it. wpbr_render_pro_matching_cta(); } function wpbr_resolve_destination($item, $batch) { if (!$item->matched_post_id) return null; // Not memoised. This reads post meta, and a run rewrites post meta as it // goes: unsharing repoints a post, a rename repoints another. A cache keyed // on the row - which is what it was keyed on, since the row does not change // - kept answering with the destination the post had before it was // repointed. A retry inside the same request then saw the old shared path, // decided the file was still shared, and unshared a second time: another // attachment created, and previous_meta_value overwritten with the URL from // the first attempt, so the original was no longer recoverable. // // The saving was negligible - two calls per row, over meta WordPress has // already cached - and the same mistake has now been made three times in // this file (and once more in the sharing lookup). Reading the state // each time is the rule here. $meta_keys = wpbr_reference_locations($batch); foreach (wpbr_reference_map($batch, $item->matched_post_id) as $mk => $val) { if ($val) { $resolved = wpbr_url_to_upload_path($val); if ($resolved) { return array( 'path' => $resolved, // A host-managed reference is not stat-ed here: this runs for every row of a batch on every chunk, and a stat on a // stream wrapper is a network call. The engine looks properly, once, when the row is processed. 'mode' => (wpbr_ref_is_stream_uri($resolved) || file_exists($resolved)) ? 'overwrite' : 'repair', ); } } } $desired = $item->rename_to ? $item->rename_to : $item->staged_filename; $upload_dir = wp_upload_dir(); return array('path' => $upload_dir['path'] . '/' . sanitize_file_name($desired), 'mode' => 'new'); } /** * Which posts point at this file, through any of the batch's meta fields? * * Asked before overwriting anything, because a file referenced by a post the * reader did not select is not this batch's to change: overwriting it edits * that page too, and the reader is never told. Sharing inside the batch is a * different matter and is handled by unsharing - there, every affected post is * one the reader chose. * * The query narrows on meta_key, which is indexed, and then matches the * filename. The path comparison afterwards is what decides: two files in * different folders can share a basename, and one file can be stored as http, * https or protocol-relative by different plugins, so the URL text is not * comparable but the resolved path is. * * @param string[] $meta_keys The batch's configured meta fields. * @param string $path An absolute path inside the uploads directory. * @return array Post ID => the meta field it points through. */ function wpbr_posts_referencing_file($meta_keys, $path) { global $wpdb; if (!$path || !$meta_keys) return array(); // Deliberately not memoised. The answer depends on post meta, which a run // changes as it goes - unsharing repoints a post, a rename repoints // another - so a cache keyed on the question would hand back an answer // about a site that has since moved on. That is the mistake a static in // wpbr_destination_shared_with_another_post() once made, and it is // worth one query per row not to repeat it on the path that decides // whether to overwrite somebody's file. // One query per field rather than one IN (...) over all of them: the list // is as long as the batch has fields, so the statement would otherwise be // written differently for each length. Each is answered from postmeta's // meta_key index, which is how the single query was answered too. $like = '%' . $wpdb->esc_like('/' . basename($path)); $found = array(); foreach (array_unique(array_map('strval', $meta_keys)) as $meta_key) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core's postmeta has no API for the posts holding a file in any of these fields; deliberately not cached, because a run repoints posts as it goes and a stale answer decides whether to overwrite somebody's file. $rows = $wpdb->get_results($wpdb->prepare( "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value LIKE %s", $meta_key, $like )); foreach ((array) $rows as $row) { $resolved = wpbr_url_to_upload_path($row->meta_value); if ($resolved && $resolved === $path) { $found[(int) $row->post_id] = $row->meta_key; } } } return $found; } /** * Posts outside this batch that also point at this file. * * "Outside the batch" means having no row here at all. A post with a row is one * the reader selected, whatever state that row is in. * * @param string[] $meta_keys * @param string $path * @param int $mine The post this row is matched to, which is not a sharer. * @param object[] $all_items Every row in the batch. * @return array Post ID => the meta field it points through. */ function wpbr_outside_batch_sharers($meta_keys, $path, $mine, $all_items) { $sharers = wpbr_posts_referencing_file($meta_keys, $path); unset($sharers[(int) $mine]); if (!$sharers) return array(); foreach ((array) $all_items as $row) { if ($row->matched_post_id) { unset($sharers[(int) $row->matched_post_id]); } } return $sharers; } /** * Name the posts sharing a file, for a refusal the reader can act on. * * Bounded at three plus a count: the point is to make the situation * recognisable, and a run that refuses forty rows should not print forty * lists. * * @param array $sharers * @return string Plain text; the caller escapes it. */ function wpbr_describe_sharers($sharers) { $names = array(); $shown = array_slice($sharers, 0, 3, true); foreach ($shown as $post_id => $meta_key) { $names[] = sprintf( /* translators: 1: a post title, 2: its numeric ID, 3: a meta field name. */ __('"%1$s" (ID %2$s, field %3$s)', 'wpbr-bulk-replace'), wpbr_clean_title(get_the_title($post_id)), number_format_i18n($post_id), $meta_key ); } $list = implode('; ', $names); $extra = count($sharers) - count($shown); if ($extra > 0) { $list .= ' ' . sprintf( /* translators: %s is a number of further posts not listed. */ _n('and %s other', 'and %s others', $extra, 'wpbr-bulk-replace'), number_format_i18n($extra) ); } return $list; } /** * Destination paths in this batch that more than one post is aiming at. * * Separate from the question below because the answer depends only on the * batch, while the question is asked once per item. The caller processing a * chunk computes this once and passes it in; anything asking about a single * item can let the function below work it out. * * @param object $batch * @param object[] $all_items Every row in the batch. * @return array Destination path => true. */ function wpbr_shared_destination_paths($batch, $all_items) { $by_path = array(); foreach ($all_items as $other) { if (!in_array($other->status, array('matched', 'confirmed_new_attach'), true)) continue; $d = wpbr_resolve_destination($other, $batch); if (!$d) continue; $by_path[$d['path']][$other->matched_post_id] = true; } $shared = array(); foreach ($by_path as $path => $posts) { if (count($posts) > 1) $shared[$path] = true; } return $shared; } /** * Would replacing this item's destination also change another post's page? * * Note the scope: sharing is detected among the rows of this batch. A post that * references the same file but has no row here is not seen. * * The shared set is passed in rather than worked out here, because the caller * asks this once per item and the set costs a scan of the whole batch. It used * to be a function-level static, which made the answer for the first batch the * answer for every batch the same process went on to see - correct in * production only because each chunk is its own request, and wrong the moment * anything called it twice. * * @param object $item * @param object $batch * @param array $shared_paths From wpbr_shared_destination_paths(). * @return bool */ function wpbr_destination_shared_with_another_post($item, $batch, $shared_paths) { $mine = wpbr_resolve_destination($item, $batch); if (!$mine || !in_array($mine['mode'], array('overwrite','repair'), true)) return false; return isset($shared_paths[$mine['path']]); } function wpbr_detect_collisions($batch_id, $items, $batch) { if (!$batch || !$batch->post_type || !$batch->meta_key_name) { return array('blocking' => array(), 'auto' => array(), 'shared_paths' => array()); } $eligible = array(); foreach ($items as $item) { if (!in_array($item->status, array('matched', 'confirmed_new_attach'), true)) continue; $eligible[] = $item; } $by_post = array(); $by_dest = array(); foreach ($eligible as $item) { if ($item->matched_post_id) { $by_post[$item->matched_post_id][] = $item; } $dest = wpbr_resolve_destination($item, $batch); if ($dest) { $by_dest[$dest['path']][] = $item; } } $blocking = array(); $auto = array(); foreach ($by_post as $post_id => $group) { if (count($group) > 1) { $blocking[] = array( 'type' => 'same_post', 'key' => $post_id, // Plain text, not markup: both callers escape it, because a // post title is whatever an editor typed. 'label' => sprintf( /* translators: 1: the singular label of a post type, e.g. "product", 2: the title of that post, 3: its numeric ID. */ __('These files are all matched to the same %1$s: "%2$s" (ID %3$s)', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type), $group[0]->matched_post_title, $post_id ), 'items' => $group, ); } } foreach ($by_dest as $path => $group) { if (count($group) < 2) continue; $post_ids = array(); foreach ($group as $g) $post_ids[$g->matched_post_id] = true; if (count($post_ids) <= 1) continue; $auto[] = array( 'type' => 'shared_file', 'key' => $path, 'label' => sprintf( /* translators: 1: the plural label of a post type, e.g. "products", 2: a filename. */ __('These %1$s currently share one file on the site (%2$s). Each will be given its own new file named after itself instead of overwriting the shared one.', 'wpbr-bulk-replace'), wpbr_post_type_label_plural($batch->post_type), basename($path) ), 'items' => $group, ); } // The shared-destination set falls out of $by_dest, which this function has // already built. Returning it means the chunk processor resolves every // item's destination once per chunk rather than twice - it needs both // answers, and they come from the same pass. $shared_paths = array(); foreach ($by_dest as $path => $group) { $post_ids = array(); foreach ($group as $g) $post_ids[$g->matched_post_id] = true; if (count($post_ids) > 1) $shared_paths[$path] = true; } return array('blocking' => $blocking, 'auto' => $auto, 'shared_paths' => $shared_paths); } function wpbr_render_collision_report($collisions, $batch_id, $batch = null) { $post_type = $batch ? $batch->post_type : ''; $status_labels = wpbr_status_labels(); echo "
      "; echo "

      " . esc_html(sprintf( /* translators: %s is a number of conflicts. */ _n('%s conflict must be resolved before replacing files', '%s conflicts must be resolved before replacing files', count($collisions), 'wpbr-bulk-replace'), number_format_i18n(count($collisions)) )) . "

      "; echo "

      " . wp_kses_post(sprintf( /* translators: 1: the singular label of a post type, e.g. "product", 2: the title of the Review step. */ __('More than one file is matched to the same %1$s, so they would overwrite each other. Fix each group below on the %2$s step (reassign to the correct one, or set the ones you do not want to process to Skip).', 'wpbr-bulk-replace'), esc_html(wpbr_post_type_label($post_type)), esc_html(wpbr_wizard_step_title('review')) )) . "

      "; foreach ($collisions as $c) { echo "
      "; echo "

      ".esc_html($c['label'])."

      "; echo "
        "; foreach ($c['items'] as $item) { echo "
      • ".esc_html($item->staged_filename)." [".esc_html(isset($status_labels[$item->status]) ? $status_labels[$item->status] : $item->status)."]
      • "; } echo "
      "; echo "
      "; } echo "

      " . esc_html(sprintf( /* translators: %s is the title of the Review step, e.g. "Review & Resolve". */ __('Go to %s', 'wpbr-bulk-replace'), wpbr_wizard_step_title('review') )) . "

      "; echo "
      "; } /** * Strings the Replace step's script writes into the page. * * The script is echoed inline rather than enqueued, so wp.i18n is not * available to it: translations are resolved here and handed over as data. * * Counts only exist once the reader has made a selection, so anything counted * travels as a singular/plural pair and the script picks one. That covers * languages with two plural forms; languages with more fall back to the plural * form, which is why the pairs stay short and never carry the whole sentence. * * The four actions each carry their own complete title, progress and * completion sentences. They used to share one past-tense English verb that * the script turned into a gerund with verb.replace(/ed$/,'ing'), which * produces nothing usable outside English. */ /** * Strings the Review step's scripts write into the page. * * Emitted once as a global because that step renders several separate script * blocks that share this vocabulary; the alternative is the same strings * duplicated in each. */ /** * Small helpers both the Review and Replace steps' scripts use, emitted once. * * wpbrCfWrap builds a custom-filter field's box. It replaced six copies of * wrap.innerHTML = '' + field + '...', which put a meta key * straight into innerHTML: the keys come from whatever plugins wrote them, so * that was a markup-injection path as well as duplication. */ /** Strings the combine dialog's script writes into the page. */ function wpbr_combine_js_l10n() { return array( 'needTwo' => __('Select at least two PDFs to combine.', 'wpbr-bulk-replace'), /* translators: %s is a filename. */ 'moveUp' => __('Move %s up', 'wpbr-bulk-replace'), /* translators: %s is a filename. */ 'moveDown' => __('Move %s down', 'wpbr-bulk-replace'), /* translators: 1: position number, 2: total number of files. */ 'position' => __('Position %1$s of %2$s:', 'wpbr-bulk-replace'), /* translators: 1: filename, 2: new position, 3: total number of files. */ 'moved' => __('%1$s moved to position %2$s of %3$s', 'wpbr-bulk-replace'), 'atTop' => __('Already first.', 'wpbr-bulk-replace'), 'atBottom' => __('Already last.', 'wpbr-bulk-replace'), /* translators: %s is a row id, used when a filename cannot be read from the row. */ 'itemLabel' => __('item %s', 'wpbr-bulk-replace'), ); } /** Strings the upload step's script writes into the page. */ function wpbr_upload_js_l10n() { return array( 'badResponse' => __('Bad server response', 'wpbr-bulk-replace'), 'networkError' => __('Network error', 'wpbr-bulk-replace'), /* translators: 1: a percentage, 2: how much has been uploaded, 3: the total size. */ 'progress' => __('%1$s%% (%2$s of %3$s)', 'wpbr-bulk-replace'), /* translators: %s is a size in megabytes. */ 'mb' => __('%s MB', 'wpbr-bulk-replace'), /* translators: %s is a size in kilobytes. */ 'kb' => __('%s KB', 'wpbr-bulk-replace'), /* translators: %s is a size in bytes. */ 'bytes' => __('%s B', 'wpbr-bulk-replace'), /* translators: %s is a filename. */ 'uploaded' => __('%s — uploaded', 'wpbr-bulk-replace'), /* translators: 1: a filename, 2: what the server said went wrong. */ 'uploadFailed' => __('%1$s — ERROR: %2$s', 'wpbr-bulk-replace'), 'allComplete' => __('All uploads complete.', 'wpbr-bulk-replace'), 'failedOne' => __('The file could not be uploaded:', 'wpbr-bulk-replace'), /* translators: 1: how many files failed, 2: how many were being uploaded. */ 'failedSome' => __('%1$s of %2$s files could not be uploaded:', 'wpbr-bulk-replace'), 'continueLabel' => __('Continue', 'wpbr-bulk-replace'), ); } /** Strings the meta-field chip editor's script writes into the page. */ function wpbr_meta_js_l10n() { return array( 'needOne' => __('Add at least one field. Pick one from the list and press Add field, or type a meta field name and press Enter.', 'wpbr-bulk-replace'), /* translators: %s is a number of characters. */ 'tooLong' => __('Those field names are too long to save together. The names can be at most %s characters in all, commas included. Remove one, or use a shorter name.', 'wpbr-bulk-replace'), /* translators: %s is a meta field name. */ 'remove' => __('Remove %s', 'wpbr-bulk-replace'), /* translators: %s is a meta field name. */ 'moveUp' => __('Move %s earlier', 'wpbr-bulk-replace'), /* translators: %s is a meta field name. */ 'moveDown' => __('Move %s later', 'wpbr-bulk-replace'), /* translators: 1: field name, 2: position, 3: total. */ 'added' => __('%1$s added at position %2$s of %3$s', 'wpbr-bulk-replace'), /* translators: %s is a meta field name. */ 'removed' => __('%s removed', 'wpbr-bulk-replace'), /* translators: 1: field name, 2: new position, 3: total. */ 'moved' => __('%1$s moved to position %2$s of %3$s', 'wpbr-bulk-replace'), 'atTop' => __('Already first.', 'wpbr-bulk-replace'), 'atBottom' => __('Already last.', 'wpbr-bulk-replace'), // THE SAME MSGID AS `wpbr_reference_choice_label()` USES, deliberately. // A field added by the picker and the same field read back off a saved // batch must read identically, or reloading the screen looks like it // changed something. /* translators: 1: field name, 2: what the position is called, e.g. Row, 3: the position. */ 'indexed' => __('%1$s (%2$s %3$s)', 'wpbr-bulk-replace'), /* translators: %s is what the position is called, e.g. Row. */ 'needIndex' => __('Enter which %s this file is in. Counting starts at 1.', 'wpbr-bulk-replace'), ); } /** Strings the Review step's scripts write into the page. */ function wpbr_review_js_l10n() { return array( 'showRename' => __('Bulk Rename', 'wpbr-bulk-replace'), 'hideRename' => __('Hide Bulk Rename', 'wpbr-bulk-replace'), 'showFilter' => __('Custom Filter', 'wpbr-bulk-replace'), 'hideFilter' => __('Hide Custom Filter', 'wpbr-bulk-replace'), 'loading' => __('loading…', 'wpbr-bulk-replace'), 'noValues' => __('no usable values', 'wpbr-bulk-replace'), 'anyValue' => __('-- any --', 'wpbr-bulk-replace'), 'noMatches' => __('No matches', 'wpbr-bulk-replace'), 'noExistingFile' => __('No existing file', 'wpbr-bulk-replace'), 'confirmAttach' => __('Confirm new attachment', 'wpbr-bulk-replace'), 'confirmedAttach'=> __('Confirmed — will create new attachment', 'wpbr-bulk-replace'), 'setManually' => __('Set manually', 'wpbr-bulk-replace'), /* translators: 1: how many rows are visible, 2: how many there are in total. */ 'showingOf' => __('showing %1$s of %2$s', 'wpbr-bulk-replace'), /* translators: 1: the current page number, 2: how many pages there are, 3: how many rows match the current filters. */ 'pageOf' => __('Page %1$s of %2$s (%3$s rows)', 'wpbr-bulk-replace'), /* translators: %s is a meta field name. */ 'filterByField' => __('Filter by %s', 'wpbr-bulk-replace'), /* translators: %s is a number of selected rows. */ 'nSelected' => __('%s selected', 'wpbr-bulk-replace'), ); } function wpbr_print_js_helpers() { static $printed = false; if ($printed) return; $printed = true; wpbr_inline_open('script'); echo "(function(){"; wpbr_js_fmt_helper(); echo "window.wpbrFmt = fmt;"; // Live regions are rewritten with the same words on every refresh (a filter change that leaves the count as it was); // a screen reader can announce that again. Write only when the words are different. echo "window.wpbrSetLive = function(el, text){ if (el && el.textContent !== text) { el.textContent = text; } };"; echo "})();"; echo "window.wpbrCfWrap = function(wrap, field, note){"; echo "wrap.textContent = '';"; echo "var strong = document.createElement('strong'); strong.textContent = field;"; echo "wrap.appendChild(strong);"; echo "wrap.appendChild(document.createElement('br'));"; echo "if (note) { var em = document.createElement('em'); em.textContent = note; wrap.appendChild(em); }"; echo "};"; wpbr_inline_close(); } function wpbr_print_review_js_l10n() { static $printed = false; if ($printed) return; $printed = true; wpbr_print_js_helpers(); wpbr_inline_open('script'); echo "window.wpbrReviewL10n = " . wp_json_encode(wpbr_review_js_l10n(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";"; wpbr_inline_close(); } function wpbr_replace_js_l10n() { return array( 'noneSelected' => __('none selected', 'wpbr-bulk-replace'), 'selectFirst' => __('Select one or more rows to enable these actions.', 'wpbr-bulk-replace'), /* translators: 1: how many rows are visible, 2: how many there are in total. */ 'showingOf' => __('showing %1$s of %2$s', 'wpbr-bulk-replace'), /* translators: %s is a number of selected rows. */ 'nSelected' => __('%s selected', 'wpbr-bulk-replace'), 'showDetails' => __('Show details', 'wpbr-bulk-replace'), 'hideDetails' => __('Hide details', 'wpbr-bulk-replace'), 'leaveWarning' => __('A run is in progress. Files already done are saved, but the rest will stop.', 'wpbr-bulk-replace'), 'requestFailed'=> __('Request failed', 'wpbr-bulk-replace'), 'networkError' => __('Network error', 'wpbr-bulk-replace'), 'resultDone' => __('done', 'wpbr-bulk-replace'), 'resultFailed' => __('failed', 'wpbr-bulk-replace'), 'resultSkipped'=> __('skipped', 'wpbr-bulk-replace'), /* translators: 1: a filename, 2: what happened to that file. */ 'currentFile' => __('%1$s — %2$s', 'wpbr-bulk-replace'), /* translators: %s is a whole number of minutes. */ 'etaMinuteOne' => __(' — about %s minute left', 'wpbr-bulk-replace'), /* translators: %s is a whole number of minutes. */ 'etaMinutesMany'=> __(' — about %s minutes left', 'wpbr-bulk-replace'), /* translators: %s is a number of seconds. */ 'etaSecondOne' => __(' — about %s second left', 'wpbr-bulk-replace'), /* translators: %s is a number of seconds. */ 'etaSecondsMany'=> __(' — about %s seconds left', 'wpbr-bulk-replace'), /* translators: 1: how many rows will be skipped, 2: how many were selected. */ 'skipNoteOne' => __('%1$s of the %2$s selected will be left alone — it is not in a suitable state for this action.', 'wpbr-bulk-replace'), /* translators: 1: how many rows will be skipped, 2: how many were selected. */ 'skipNoteMany' => __('%1$s of the %2$s selected will be left alone — they are not in a suitable state for this action.', 'wpbr-bulk-replace'), 'buttons' => array( 'run' => array( 'label' => __('Backup & Replace', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'labelN' => __('Backup & Replace (%s)', 'wpbr-bulk-replace'), /* translators: 1: how many of the selection this action applies to, 2: how many were selected. */ 'hint' => __('%1$s of the %2$s selected have not been processed yet', 'wpbr-bulk-replace'), 'hintNone' => __('None of the selected rows are waiting to be processed', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'titleOne' => __('Backing up and replacing %s file', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'titleMany' => __('Backing up and replacing %s files', 'wpbr-bulk-replace'), /* translators: 1: how many are done, 2: how many in total. */ 'progress' => __('Replaced %1$s of %2$s', 'wpbr-bulk-replace'), /* translators: 1: how many were done, 2: how many were attempted. */ 'complete' => __('Replacing complete (%1$s of %2$s). Reloading…', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'confirmOne' => __('Back up and replace %s file?', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'confirmMany' => __('Back up and replace %s files?', 'wpbr-bulk-replace'), ), 'roll' => array( 'label' => __('Roll Back', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'labelN' => __('Roll Back (%s)', 'wpbr-bulk-replace'), /* translators: 1: how many of the selection this action applies to, 2: how many were selected. */ 'hint' => __('%1$s of the %2$s selected can be rolled back', 'wpbr-bulk-replace'), 'hintNone' => __('None of the selected rows can be rolled back', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'titleOne' => __('Rolling back %s file', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'titleMany' => __('Rolling back %s files', 'wpbr-bulk-replace'), /* translators: 1: how many are done, 2: how many in total. */ 'progress' => __('Rolled back %1$s of %2$s', 'wpbr-bulk-replace'), /* translators: 1: how many were done, 2: how many were attempted. */ 'complete' => __('Roll back complete (%1$s of %2$s). Reloading…', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'confirmOne' => __('Roll back %s file?', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'confirmMany' => __('Roll back %s files?', 'wpbr-bulk-replace'), 'confirmNote' => __('Previous files are restored from backup, and newly created attachments are deleted.', 'wpbr-bulk-replace'), ), 'verify' => array( 'label' => __('Mark Verified', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'labelN' => __('Mark Verified (%s)', 'wpbr-bulk-replace'), /* translators: 1: how many of the selection this action applies to, 2: how many were selected. */ 'hint' => __('%1$s of the %2$s selected can be marked verified', 'wpbr-bulk-replace'), 'hintNone' => __('None of the selected rows are processed and unverified', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'titleOne' => __('Marking %s file verified', 'wpbr-bulk-replace'), /* translators: %s is a number of files. */ 'titleMany' => __('Marking %s files verified', 'wpbr-bulk-replace'), /* translators: 1: how many are done, 2: how many in total. */ 'progress' => __('Marked %1$s of %2$s', 'wpbr-bulk-replace'), /* translators: 1: how many were done, 2: how many were attempted. */ 'complete' => __('Marking complete (%1$s of %2$s). Reloading…', 'wpbr-bulk-replace'), ), 'delete' => array( 'label' => __('Delete Backups', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'labelN' => __('Delete Backups (%s)', 'wpbr-bulk-replace'), /* translators: 1: how many of the selection this action applies to, 2: how many were selected. */ 'hint' => __('%1$s of the %2$s selected are verified and still have a backup', 'wpbr-bulk-replace'), 'hintNone' => __('Mark rows verified before deleting their backups', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'titleOne' => __('Deleting the backup for %s row', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'titleMany' => __('Deleting backups for %s rows', 'wpbr-bulk-replace'), /* translators: 1: how many are done, 2: how many in total. */ 'progress' => __('Cleaned up %1$s of %2$s', 'wpbr-bulk-replace'), /* translators: 1: how many were done, 2: how many were attempted. */ 'complete' => __('Cleanup complete (%1$s of %2$s). Reloading…', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'confirmOne' => __('Delete the backup for %s row?', 'wpbr-bulk-replace'), /* translators: %s is a number of rows. */ 'confirmMany' => __('Delete backups for %s rows?', 'wpbr-bulk-replace'), 'confirmNote' => __('This cannot be undone, and those rows can no longer be rolled back afterwards.', 'wpbr-bulk-replace'), ), ), 'customFilter' => array( 'show' => __('Custom Filter', 'wpbr-bulk-replace'), 'hide' => __('Hide Custom Filter', 'wpbr-bulk-replace'), 'loading' => __('loading…', 'wpbr-bulk-replace'), 'noValues'=> __('no usable values', 'wpbr-bulk-replace'), 'any' => __('-- any --', 'wpbr-bulk-replace'), /* translators: %s is a meta field name. */ 'filterBy'=> __('Filter by %s', 'wpbr-bulk-replace'), ), ); } function wpbr_render_step_replace($batch_id, $eligible_count, $items, $batch) { echo "

      " . esc_html__('Step 4: Backup & Replace', 'wpbr-bulk-replace') . "

      "; wpbr_help_link_echo('core.batches.run', __('What happens when you run this', 'wpbr-bulk-replace')); if (!$batch->post_type || !$batch->meta_key_name) { echo "

      " . esc_html__('Set the post type and meta field on the Match step first.', 'wpbr-bulk-replace') . "

      "; return; } $collisions = wpbr_detect_collisions($batch_id, $items, $batch); if ($collisions['blocking']) { wpbr_render_collision_report($collisions['blocking'], $batch_id, $batch); return; } $rows = array(); foreach ($items as $it) { if (in_array($it->status, array('matched','confirmed_new_attach','replaced','verified','cleaned_up'), true)) { $rows[] = $it; } } if (!$rows) { echo "

      " . esc_html(sprintf( /* translators: %s is the title of the Review step, e.g. "Review & Resolve". */ __('Nothing here yet. Resolve some matches on the %s step first.', 'wpbr-bulk-replace'), wpbr_wizard_step_title('review') )) . "

      "; return; } $n_pending_replace = 0; $n_pending_new = 0; $n_replaced = 0; $n_attached = 0; $n_verified = 0; foreach ($rows as $it) { $ready = in_array($it->status, array('matched','confirmed_new_attach'), true); $d = wpbr_resolve_destination($it, $batch); $is_overwrite = ($d && in_array($d['mode'], array('overwrite','repair'), true)); if ($ready) { if ($is_overwrite) $n_pending_replace++; else $n_pending_new++; } else { if ($it->created_attachment_id) $n_attached++; else $n_replaced++; if (in_array($it->status, array('verified','cleaned_up'), true)) $n_verified++; } } echo "

      " . wp_kses_post(__('Select rows and choose an action. Start with one or two to confirm the result on the live site before doing the rest — there is no penalty for running this several times. Everything processed keeps a backup until you delete it.', 'wpbr-bulk-replace')) . "

      "; if ($collisions['auto']) { echo "
      "; $auto_count = count($collisions['auto']); echo "

      " . esc_html(sprintf( /* translators: 1: a number of groups, 2: the plural label of a post type, e.g. "products". */ _n('%1$s set of %2$s shares a file.', '%1$s sets of %2$s share a file.', $auto_count, 'wpbr-bulk-replace'), number_format_i18n($auto_count), wpbr_post_type_label_plural($batch->post_type) )) . " " . esc_html__('Each will get its own new file named after itself rather than overwriting the shared one.', 'wpbr-bulk-replace') . " "; wpbr_help_link_echo('error.shared-file-outside-batch', __('What happens if a page outside this batch shares it', 'wpbr-bulk-replace')); echo "

      "; } wpbr_inline_open('style'); echo ".wpbr-rep-toolbar{display:flex;gap:8px;align-items:center;background:#fff;border:1px solid #dcdcde;padding:10px 12px;margin:0 0 12px;position:sticky;top:32px;z-index:20;flex-wrap:wrap;}"; echo ".wpbr-rep-count{font-size:13px;color:#50575e;}"; echo ".wpbr-action-pill{display:inline-block;padding:2px 9px;border-radius:12px;font-size:12px;font-weight:500;white-space:nowrap;}"; echo ".wpbr-pill-pending{background:transparent;border:1px dashed;}"; echo ".wpbr-pill-done{color:#fff;border:1px solid transparent;}"; echo ".wpbr-view-post-icon,.wpbr-preview-icon{color:#787c82;text-decoration:none;}"; echo ".wpbr-view-post-icon:hover,.wpbr-preview-icon:hover{color:#2271b1;}"; echo ".wpbr-view-post-icon .dashicons,.wpbr-preview-icon .dashicons{font-size:16px;width:16px;height:16px;vertical-align:text-bottom;}"; echo ".wpbr-filter-chip{display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid #dcdcde;border-radius:20px;padding:4px 12px 4px 8px;font-size:12px;font-family:inherit;line-height:1.6;margin:0;color:#3c434a;cursor:pointer;user-select:none;}"; echo ".wpbr-filter-chip:focus-visible{outline:2px solid #2271b1;outline-offset:1px;}"; echo ".wpbr-filter-chip.active{background:#2271b1;border-color:#2271b1;color:#fff;}"; echo ".wpbr-count-dot{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:50%;font-size:11px;font-weight:600;padding:0 4px;box-sizing:border-box;}"; echo ".wpbr-filter-chip.active .wpbr-count-dot{box-shadow:0 0 0 2px #fff;}"; echo "table.wpbr-responsive-table th.wpbr-check-col, table.wpbr-responsive-table td.wpbr-check-col{width:32px !important;text-align:left !important;padding:8px !important;box-sizing:border-box !important;}"; wpbr_inline_close(); // ---------- filters ---------- echo "
      "; echo "" . esc_html__('To do:', 'wpbr-bulk-replace') . ""; wpbr_filter_chip(array('data-stage-filter' => 'pending_overwrite'), $n_pending_replace, __('Replace file', 'wpbr-bulk-replace'), 'background:#0a4b78;color:#fff;'); wpbr_filter_chip(array('data-stage-filter' => 'pending_new'), $n_pending_new, __('New attachment', 'wpbr-bulk-replace'), 'background:#1d5b1f;color:#fff;'); echo ""; echo "Done:"; echo ""; wpbr_help_link_echo('core.rollback.overview', __('Rolling back and cleaning up', 'wpbr-bulk-replace')); echo ""; wpbr_filter_chip(array('data-stage-filter' => 'replaced'), $n_replaced, __('Replaced', 'wpbr-bulk-replace'), 'background:#0a4b78;color:#fff;'); wpbr_filter_chip(array('data-stage-filter' => 'attached'), $n_attached, __('Attached', 'wpbr-bulk-replace'), 'background:#1d5b1f;color:#fff;'); wpbr_filter_chip(array('data-stage-filter' => 'verified'), $n_verified, __('Verified', 'wpbr-bulk-replace'), 'background:#00a32a;color:#fff;'); echo "
      "; echo "
      "; echo "post_type)))."\" style=\"margin-left:8px;width:260px;\">"; echo ""; echo ""; echo ""; echo "
      "; $rep_fields = wpbr_get_meta_key_suggestions($batch->post_type, 200); sort($rep_fields); echo "
      "; echo "

      " . wp_kses_post(__('Narrow by the matched post’s own fields — for example process one category, or one supplier, at a time.', 'wpbr-bulk-replace')) . "

      "; echo ""; echo ""; echo "
      "; foreach ($rep_fields as $mf) { echo ""; } echo "
      "; echo "
      "; echo "
      "; // ---------- toolbar ---------- echo "
      "; echo "
      "; echo ""; echo ""; echo ""; echo ""; echo ""; echo "" . esc_html__('none selected', 'wpbr-bulk-replace') . ""; // Why the disabled buttons above are disabled: the tooltip text, visible. Filled by refreshButtons(). echo "
      "; echo "
      "; echo "
      "; // A run that cannot continue says so here, in words, where it can be seen and is announced. It is always rendered (empty // until needed): an alert that is created or unhidden at the moment it speaks is announced less reliably than one that // is already on the page. The details are still in the log below. echo "
      "; echo "
      "; echo "" . esc_html__('Working…', 'wpbr-bulk-replace') . ""; // The bar is drawn with two
      s rather than , so it has to say // what it is and where it has got to; the percentage caption inside repeats // aria-valuenow, so it is hidden from assistive tech rather than read twice. echo "
      "; echo "
      "; echo "0%"; echo "
      "; echo ""; echo ""; echo "
      "; // Deliberately not a live region: it changes once per row, and announcing // every filename would talk over the progress label that carries the count. // One line that ends in an ellipsis when the file name or the reason is long; wpbr_truncate_support_print() adds a "⋯" button when it is cut. echo "
      "; echo "
      ";
          echo "
      "; echo "
      "; // ---------- table ---------- echo ""; echo ""; /* translators: %s is the singular label of the post type being matched. */ $matched_col = sprintf(__('Matched %s', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type)); echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; $meta_keys = wpbr_split_meta_keys($batch->meta_key_name); foreach ($rows as $it) { $dest = wpbr_resolve_destination($it, $batch); $mode = $dest ? $dest['mode'] : 'new'; $is_ready = in_array($it->status, array('matched','confirmed_new_attach'), true); $is_verified = in_array($it->status, array('verified','cleaned_up'), true); $created = $it->created_attachment_id ? true : false; $has_backup = ($it->backup_path && file_exists($it->backup_path)); $can_rollback = (!$is_ready) && ($created || $has_backup); if ($is_ready) { $stage = ($mode === 'new') ? 'pending_new' : 'pending_overwrite'; } else { $stage = $created ? 'attached' : 'replaced'; } $hay = strtolower($it->staged_filename.' '.$it->matched_post_title); echo "matched_post_id)."\">"; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; echo ""; } echo "
      " . esc_html__('Staged file', 'wpbr-bulk-replace') . "" . esc_html($matched_col) . "" . esc_html__('Action', 'wpbr-bulk-replace') . "" . esc_html__('File', 'wpbr-bulk-replace') . "" . esc_html__('When', 'wpbr-bulk-replace') . "" . esc_html__('Backup', 'wpbr-bulk-replace') . "
      id)."\" aria-label=\"".esc_attr(wpbr_row_control_label(__('Select', 'wpbr-bulk-replace'), $it))."\">".esc_html($it->staged_filename); echo " id))."\" target=\"_blank\" class=\"wpbr-preview-icon\" title=\"Preview the staged file\">".esc_html($it->matched_post_title); if ($it->matched_post_id) { echo " matched_post_id))."\" target=\"_blank\" class=\"wpbr-view-post-icon\" title=\"".esc_attr__('Open this post in a new tab', 'wpbr-bulk-replace')."\">"; $live = wpbr_reference_current($batch, $it->matched_post_id)['value']; if ($live && !$is_ready) { // A REPLACED FILE KEEPS ITS URL, so this link is the one place a // reviewer is most likely to be shown the copy their browser kept. // It carries the file's version whatever the public setting says: // it is an administrator's own link, so there is nothing public // to change. The stored value is not touched. echo " true)))."\" target=\"_blank\" class=\"wpbr-preview-icon\" title=\"".esc_attr__('Open the file now live on this post', 'wpbr-bulk-replace')."\">"; } } echo ""; if ($is_ready) { if ($mode === 'overwrite') { $want = $it->rename_to ? sanitize_file_name($it->rename_to) : ''; if ($want && $dest && $want !== basename($dest['path'])) { $rename_note = sprintf( /* translators: %s is the singular label of a post type, e.g. "product". */ __('A different filename was entered, so a new file is written under that name and the %s is pointed at it. The old file is left in place.', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type) ); echo "" . esc_html__('Replace & rename', 'wpbr-bulk-replace') . ""; } else { echo "" . esc_html__('Replace file', 'wpbr-bulk-replace') . ""; } } elseif ($mode === 'repair') { $repair_note = sprintf( /* translators: %s is the singular label of a post type, e.g. "product". */ __('This %s links to this file but it is not on the server. Processing writes it there and makes sure it has a Media Library entry.', 'wpbr-bulk-replace'), wpbr_post_type_label($batch->post_type) ); echo "" . esc_html__('Add missing file', 'wpbr-bulk-replace') . ""; } else { echo "" . esc_html__('New attachment', 'wpbr-bulk-replace') . ""; } } elseif ($created) { echo "✓ " . esc_html__('Attached', 'wpbr-bulk-replace') . ""; } else { echo "✓ " . esc_html__('Replaced', 'wpbr-bulk-replace') . ""; } echo ""; if ($is_ready && $mode === 'overwrite' && $it->rename_to && $dest && sanitize_file_name($it->rename_to) !== basename($dest['path'])) { echo "".esc_html(basename($dest['path']))."
      "; echo esc_html(sanitize_file_name($it->rename_to)); } else { echo esc_html($dest ? basename($dest['path']) : __('(unknown)', 'wpbr-bulk-replace')); } echo "
      "; if ($is_ready) { echo "—"; } else { if ($is_verified) { echo "✓ " . esc_html__('Verified', 'wpbr-bulk-replace') . "
      "; } if ($it->processed_at) { echo "".esc_html(mysql2date(wpbr_datetime_format(), $it->processed_at)).""; } } echo "
      "; if ($is_ready) { echo "—"; } elseif ($created) { echo "" . esc_html__('Can undo', 'wpbr-bulk-replace') . ""; } elseif ($has_backup) { echo "id, 'backup'))."\" target=\"_blank\" title=\"".esc_attr__('Open the backed-up previous version', 'wpbr-bulk-replace')."\">" . esc_html__('Backup kept', 'wpbr-bulk-replace') . ""; } else { echo "" . esc_html__('Deleted — cannot undo', 'wpbr-bulk-replace') . ""; } echo "
      "; // ---------- behaviour ---------- $nonce = wp_create_nonce('wpbr_process_chunk_' . $batch_id); wpbr_print_js_helpers(); wpbr_truncate_support_print(); wpbr_inline_open('script'); echo "var wpbrReplaceL10n = " . wp_json_encode(wpbr_replace_js_l10n(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";"; echo "(function(){"; echo "var L = wpbrReplaceL10n;"; wpbr_js_fmt_helper(); echo "function pick(one, many, n){ return n === 1 ? one : many; }"; echo "var batchId = ".intval($batch_id).";"; echo "var nonce = '".esc_js($nonce)."';"; echo "var chunkSize = ".intval(wpbr_chunk_size()).";"; echo "var cfNonce = '".esc_js(wp_create_nonce('wpbr_meta_field_values'))."';"; echo "var storeKey = '".esc_js($batch_id)."_replace';"; echo "var table = document.getElementById('wpbr_rep_table');"; echo "var rows = Array.prototype.slice.call(table.querySelectorAll('tbody tr'));"; echo "var stageChips = document.querySelectorAll('.wpbr-filter-chip[data-stage-filter]');"; echo "var search = document.getElementById('wpbr_rep_search');"; echo "var clearBtn = document.getElementById('wpbr_rep_clear');"; echo "var shown = document.getElementById('wpbr_rep_shown');"; echo "var checkAll = document.getElementById('wpbr_check_all_rep');"; echo "var selCount = document.getElementById('wpbr_sel_count');"; echo "var bar = document.getElementById('wpbr_progress_bar');"; echo "var label = document.getElementById('wpbr_progress_label');"; echo "var actionsBar = document.getElementById('wpbr_toolbar_actions');"; echo "var progressBar = document.getElementById('wpbr_toolbar_progress');"; echo "var fill = document.getElementById('wpbr_run_fill');"; echo "var pct = document.getElementById('wpbr_run_pct');"; echo "var runTitle = document.getElementById('wpbr_run_title');"; echo "var current = document.getElementById('wpbr_run_current');"; echo "var logToggle = document.getElementById('wpbr_toggle_log');"; echo "var runBar = document.getElementById('wpbr_run_bar');"; echo "var runAlert = document.getElementById('wpbr_run_alert');"; echo "var log = document.getElementById('wpbr_progress_log');"; echo "var cf = { maps: {}, selected: {} };"; echo "var btnRun = document.getElementById('wpbr_run_selected');"; echo "var btnRoll = document.getElementById('wpbr_rollback_selected');"; echo "var btnVerify = document.getElementById('wpbr_mark_verified');"; echo "var btnDelete = document.getElementById('wpbr_delete_backups');"; echo "function checkedFields(){"; echo "return Array.prototype.slice.call(document.querySelectorAll('.wpbr-repcf-field:checked')).map(function(cb){ return cb.value; });"; echo "}"; echo "function saveFilterState(){"; echo "var stages = []; stageChips.forEach(function(ch){ if (ch.classList.contains('active')) stages.push(ch.dataset.stageFilter); });"; echo "if (window.wpbrSaveFilters) window.wpbrSaveFilters(storeKey, { stages: stages, search: (search.value||'').trim().toLowerCase(), fields: checkedFields(), cf: cf.selected });"; echo "}"; echo "function selectedRows(){"; echo "return rows.filter(function(r){ var cb=r.querySelector('.wpbr-rep-check'); return cb && cb.checked; });"; echo "}"; echo "function idsOf(list){ return list.map(function(r){ return r.querySelector('.wpbr-rep-check').value; }); }"; echo "function subsetFor(action){"; echo "return selectedRows().filter(function(r){"; echo "if (action === 'run') return r.dataset.ready === '1';"; echo "if (action === 'roll') return r.dataset.ready === '0' && r.dataset.rollback === '1';"; echo "if (action === 'verify') return r.dataset.ready === '0' && r.dataset.verified === '0';"; echo "if (action === 'delete') return r.dataset.verified === '1';"; echo "return false; });"; echo "}"; echo "function refreshButtons(){"; echo "var n = selectedRows().length;"; echo "var nRun = subsetFor('run').length;"; echo "var nRoll = subsetFor('roll').length;"; echo "var nVerify = subsetFor('verify').length;"; echo "var nDelete = subsetFor('delete').length;"; echo "window.wpbrSetLive(selCount, n === 0 ? L.noneSelected : fmt(L.nSelected, n));"; echo "btnRun.disabled = (nRun === 0);"; echo "btnRoll.disabled = (nRoll === 0);"; echo "btnVerify.disabled = (nVerify === 0);"; echo "btnDelete.disabled = (nDelete === 0);"; echo "function labelFor(btn, key, applicable){"; echo "var t = L.buttons[key];"; echo "btn.textContent = (applicable && applicable !== n) ? fmt(t.labelN, applicable) : t.label;"; echo "btn.title = applicable ? fmt(t.hint, applicable, n) : t.hintNone;"; echo "}"; echo "labelFor(btnRun, 'run', nRun);"; echo "labelFor(btnRoll, 'roll', nRoll);"; echo "labelFor(btnVerify, 'verify', nVerify);"; echo "labelFor(btnDelete, 'delete', nDelete);"; // The same rule as each button's disabled state, said in words. Rewritten only when the words change, so a // selection change that leaves the reasons as they were announces nothing. echo "var reasons = [];"; echo "if (n === 0) { reasons.push(L.selectFirst); } else {"; echo "[['run', btnRun], ['roll', btnRoll], ['verify', btnVerify], ['delete', btnDelete]].forEach(function(p){"; echo "if (p[1].disabled) { reasons.push(L.buttons[p[0]].label + ': ' + L.buttons[p[0]].hintNone); }"; echo "});"; echo "}"; echo "var why = document.getElementById('wpbr_toolbar_reason');"; echo "var text = reasons.join('\\n');"; echo "if (why && why.getAttribute('data-text') !== text) {"; echo "why.setAttribute('data-text', text);"; echo "while (why.firstChild) { why.removeChild(why.firstChild); }"; echo "reasons.forEach(function(t){ var d = document.createElement('div'); d.textContent = t; why.appendChild(d); });"; echo "}"; echo "}"; echo "function apply(){"; echo "stageChips.forEach(function(c){ c.setAttribute('aria-pressed', c.classList.contains('active') ? 'true' : 'false'); });"; echo "var stages = []; stageChips.forEach(function(ch){ if (ch.classList.contains('active')) stages.push(ch.dataset.stageFilter); });"; echo "var q = (search.value||'').trim().toLowerCase();"; echo "var visible = 0;"; echo "rows.forEach(function(r){"; echo "var okStage = true;"; echo "if (stages.length) {"; echo "var wantVerified = stages.indexOf('verified') !== -1;"; echo "var others = stages.filter(function(s){ return s !== 'verified'; });"; echo "var hitOther = others.length ? (others.indexOf(r.dataset.stage) !== -1) : false;"; echo "var hitVerified = wantVerified && r.dataset.verified === '1';"; echo "okStage = hitOther || hitVerified;"; echo "}"; echo "var okText = (q === '') || (r.dataset.search||'').indexOf(q) !== -1;"; echo "var okMeta = true;"; echo "for (var f in cf.selected) { if (!cf.selected.hasOwnProperty(f)) continue;"; echo "var map = cf.maps[f] || {};"; echo "if (map[r.dataset.postId] !== cf.selected[f]) { okMeta = false; break; } }"; echo "var ok = okStage && okText && okMeta;"; echo "r.style.display = ok ? '' : 'none';"; echo "if (!ok) { var cb = r.querySelector('.wpbr-rep-check'); if (cb) cb.checked = false; }"; echo "if (ok) visible++;"; echo "});"; echo "var active = (stages.length || q !== '' || Object.keys(cf.selected).length);"; echo "clearBtn.style.display = active ? 'inline-block' : 'none';"; echo "shown.textContent = active ? fmt(L.showingOf, visible, rows.length) : '';"; echo "if (checkAll) checkAll.checked = false;"; echo "refreshButtons();"; echo "saveFilterState();"; echo "}"; echo "rows.forEach(function(r){ var cb=r.querySelector('.wpbr-rep-check'); if (cb) cb.addEventListener('change', refreshButtons); });"; echo "if (checkAll) { checkAll.addEventListener('change', function(){"; echo "rows.forEach(function(r){ var cb=r.querySelector('.wpbr-rep-check'); if (!cb) return;"; echo "cb.checked = (r.style.display !== 'none') ? checkAll.checked : false; });"; echo "refreshButtons(); }); }"; echo "stageChips.forEach(function(ch){ ch.addEventListener('click', function(){ ch.classList.toggle('active'); apply(); }); });"; echo "search.addEventListener('input', apply);"; echo "clearBtn.addEventListener('click', function(){"; echo "stageChips.forEach(function(ch){ ch.classList.remove('active'); });"; echo "search.value=''; cf.selected = {};"; echo "document.querySelectorAll('.wpbr-repcf-field').forEach(function(cb){ cb.checked=false; });"; echo "document.getElementById('wpbr_repcf_selectors').innerHTML='';"; echo "if (window.wpbrClearFilters) window.wpbrClearFilters(storeKey);"; echo "apply(); });"; echo "var cfToggle = document.getElementById('wpbr_rep_custom_toggle');"; echo "var cfPanel = document.getElementById('wpbr_rep_custom_panel');"; echo "cfToggle.addEventListener('click', function(){"; echo "var h = cfPanel.style.display === 'none';"; echo "cfPanel.style.display = h ? 'block' : 'none';"; echo "cfToggle.setAttribute('aria-expanded', h ? 'true' : 'false');"; echo "cfToggle.textContent = h ? L.customFilter.hide : L.customFilter.show; });"; echo "function buildFieldSelect(field, preset){"; echo "var safe = 'wpbr_repcf_' + field.replace(/[^a-zA-Z0-9_-]/g,'');"; echo "var wrap = document.getElementById(safe);"; echo "if (!wrap) { wrap = document.createElement('div'); wrap.id = safe; wrap.style.fontSize='12px';"; echo "document.getElementById('wpbr_repcf_selectors').appendChild(wrap); }"; echo "window.wpbrCfWrap(wrap, field, L.customFilter.loading);"; echo "var body = 'action=wpbr_meta_field_values&nonce=' + encodeURIComponent(cfNonce) + '&batch_id=' + batchId + '&field=' + encodeURIComponent(field);"; echo "fetch(ajaxurl,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body})"; echo ".then(function(r){ return r.json(); }).then(function(resp){"; echo "if (!resp.success || !resp.data.values.length) { window.wpbrCfWrap(wrap, field, L.customFilter.noValues); return; }"; echo "cf.maps[field] = resp.data.map;"; echo "var sel = document.createElement('select'); sel.dataset.cfField = field; sel.className='wpbr-repcf-value';"; echo "sel.setAttribute('aria-label', fmt(L.customFilter.filterBy, field));"; echo "var o0 = document.createElement('option'); o0.value=''; o0.textContent=L.customFilter.any; sel.appendChild(o0);"; echo "resp.data.values.forEach(function(v){ var o=document.createElement('option'); o.value=v.value; o.textContent=v.label; sel.appendChild(o); });"; echo "if (preset) { sel.value = preset; cf.selected[field] = preset; }"; echo "window.wpbrCfWrap(wrap, field, ''); wrap.appendChild(sel);"; echo "apply();"; echo "});"; echo "}"; echo "document.addEventListener('change', function(e){"; echo "if (!e.target.classList.contains('wpbr-repcf-field')) return;"; echo "var field = e.target.value;"; echo "if (!e.target.checked) {"; echo "var w = document.getElementById('wpbr_repcf_' + field.replace(/[^a-zA-Z0-9_-]/g,''));"; echo "if (w) w.remove(); delete cf.selected[field]; saveFilterState(); apply(); return; }"; echo "buildFieldSelect(field, null); saveFilterState();"; echo "});"; echo "document.addEventListener('change', function(e){"; echo "if (!e.target.classList.contains('wpbr-repcf-value')) return;"; echo "var f = e.target.dataset.cfField;"; echo "if (e.target.value === '') delete cf.selected[f]; else cf.selected[f] = e.target.value;"; echo "apply(); });"; echo "function runQueue(action, ids, btn, key){"; echo "var T = L.buttons[key];"; echo "if (!ids.length) return;"; echo "var total = ids.length, doneCount = 0, isFirst = true, startedAt = Date.now();"; echo "[btnRun,btnRoll,btnVerify,btnDelete].forEach(function(b){ b.disabled = true; });"; echo "actionsBar.style.display='none'; progressBar.style.display='block';"; echo "fill.style.width='0%'; pct.textContent='0%';"; echo "if (runBar) runBar.setAttribute('aria-valuenow', '0');"; echo "runTitle.textContent = fmt(pick(T.titleOne, T.titleMany, total), total);"; echo "log.textContent=''; current.textContent=''; if (runAlert) { runAlert.textContent = ''; }"; echo "window.onbeforeunload = function(){ return L.leaveWarning; };"; echo "function step(){"; echo "if (!ids.length) {"; echo "window.onbeforeunload = null;"; echo "label.textContent = fmt(T.complete, doneCount, total);"; echo "setTimeout(function(){ location.reload(); }, 1200); return; }"; echo "var slice = ids.splice(0, chunkSize);"; echo "var body = 'action=' + action + '&batch_id=' + batchId + '&nonce=' + encodeURIComponent(nonce) + '&ids=' + slice.join(',') + (isFirst ? '&first=1' : '');"; echo "isFirst = false;"; echo "fetch(ajaxurl, {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})"; echo ".then(function(r){ return r.json(); }).then(function(resp){"; echo "if (!resp.success) { log.textContent += L.requestFailed + '\\n'; if (runAlert) { runAlert.textContent = L.requestFailed; } refreshButtons(); return; }"; echo "(resp.data.log||[]).forEach(function(line){ log.textContent += line + '\\n'; });"; echo "log.scrollTop = log.scrollHeight;"; echo "doneCount += (resp.data.processed||0);"; echo "(resp.data.results||[]).forEach(function(r){"; echo "var cb = table.querySelector('.wpbr-rep-check[value=\"' + r.id + '\"]');"; echo "if (!cb) return;"; echo "var row = cb.closest('tr');"; echo "cb.checked = false;"; echo "row.style.transition = 'background-color .4s ease';"; echo "row.style.backgroundColor = (r.result === 'success') ? '#e8f6e8' : (r.result === 'failed' ? '#fbeaea' : '#fcf3e3');"; echo "var cell = row.querySelector('td[data-colname=\"Action\"]');"; echo "if (cell) {"; echo "var mark = (r.result === 'success') ? ('\\u2713 ' + L.resultDone) : (r.result === 'failed' ? ('\\u2717 ' + L.resultFailed) : L.resultSkipped);"; echo "var bg = (r.result === 'success') ? '#1d5b1f' : (r.result === 'failed' ? '#8a1f1f' : '#8a6116');"; echo "cell.textContent = '';"; echo "var pill = document.createElement('span');"; echo "pill.className = 'wpbr-action-pill wpbr-pill-done';"; echo "pill.style.background = bg;"; echo "pill.textContent = mark;"; echo "cell.appendChild(pill);"; echo "}"; echo "current.textContent = fmt(L.currentFile, r.filename, r.message);"; echo "});"; echo "var pctVal = Math.round(((total - ids.length) / total) * 100);"; echo "fill.style.width = pctVal + '%'; pct.textContent = pctVal + '%';"; echo "if (runBar) runBar.setAttribute('aria-valuenow', String(pctVal));"; echo "var doneN = total - ids.length;"; echo "var elapsed = (Date.now() - startedAt) / 1000;"; echo "var rate = doneN > 0 ? elapsed / doneN : 0;"; echo "var leftSec = Math.round(rate * ids.length);"; echo "var eta = '';"; echo "if (doneN > 2 && leftSec > 5) {"; echo "if (leftSec > 90) { var mins = Math.ceil(leftSec/60); eta = fmt(pick(L.etaMinuteOne, L.etaMinutesMany, mins), mins); }"; echo "else { eta = fmt(pick(L.etaSecondOne, L.etaSecondsMany, leftSec), leftSec); }"; echo "}"; echo "label.textContent = fmt(T.progress, doneN, total) + eta;"; echo "step();"; echo "}).catch(function(){ log.textContent += L.networkError + '\\n'; if (runAlert) { runAlert.textContent = L.networkError; } refreshButtons(); });"; echo "}"; echo "step();"; echo "}"; echo "function skipNote(applicable){"; echo "var total = selectedRows().length;"; echo "if (applicable >= total) return '';"; echo "var left = total - applicable;"; echo "return '\\n\\n' + fmt(pick(L.skipNoteOne, L.skipNoteMany, left), left, total);"; echo "}"; echo "if (logToggle) { logToggle.addEventListener('click', function(){"; echo "var hidden = (log.style.display === 'none' || !log.style.display);"; echo "log.style.display = hidden ? 'block' : 'none';"; echo "logToggle.setAttribute('aria-expanded', hidden ? 'true' : 'false');"; echo "logToggle.textContent = hidden ? L.hideDetails : L.showDetails;"; echo "}); }"; echo "btnRun.addEventListener('click', function(){"; echo "var rowsFor = subsetFor('run'); var ids = idsOf(rowsFor);"; echo "if (!confirm(fmt(pick(L.buttons.run.confirmOne, L.buttons.run.confirmMany, ids.length), ids.length) + skipNote(ids.length))) return;"; echo "runQueue('wpbr_process_chunk', ids, btnRun, 'run'); });"; echo "btnRoll.addEventListener('click', function(){"; echo "var ids = idsOf(subsetFor('roll'));"; echo "if (!confirm(fmt(pick(L.buttons.roll.confirmOne, L.buttons.roll.confirmMany, ids.length), ids.length) + '\\n\\n' + L.buttons.roll.confirmNote)) return;"; echo "runQueue('wpbr_rollback_chunk', ids, btnRoll, 'roll'); });"; echo "btnVerify.addEventListener('click', function(){"; echo "runQueue('wpbr_mark_verified', idsOf(subsetFor('verify')), btnVerify, 'verify'); });"; echo "btnDelete.addEventListener('click', function(){"; echo "var ids = idsOf(subsetFor('delete'));"; echo "if (!confirm(fmt(pick(L.buttons.delete.confirmOne, L.buttons.delete.confirmMany, ids.length), ids.length) + '\\n\\n' + L.buttons.delete.confirmNote)) return;"; echo "runQueue('wpbr_delete_backups', ids, btnDelete, 'delete'); });"; echo "var saved = window.wpbrLoadFilters ? window.wpbrLoadFilters(storeKey) : null;"; echo "if (saved) {"; echo "(saved.stages||[]).forEach(function(v){ var ch=document.querySelector(\".wpbr-filter-chip[data-stage-filter='\" + v + \"']\"); if (ch) ch.classList.add('active'); });"; echo "if (saved.search) search.value = saved.search;"; echo "var fieldsToRestore = saved.fields || Object.keys(saved.cf || {});"; echo "if (fieldsToRestore.length) {"; echo "cfPanel.style.display='block'; cfToggle.textContent=L.customFilter.hide; cfToggle.setAttribute('aria-expanded', 'true');"; echo "fieldsToRestore.forEach(function(field){"; echo "var cb = document.querySelector(\".wpbr-repcf-field[value='\" + field + \"']\");"; echo "if (cb) { cb.checked = true; buildFieldSelect(field, (saved.cf||{})[field] || null); } });"; echo "} }"; echo "apply();"; echo "})();"; wpbr_inline_close(); } function wpbr_handle_run_matching($batch_id) { check_admin_referer('wpbr_run_matching'); 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; $post_type = isset($_POST['post_type']) ? sanitize_key(wp_unslash($_POST['post_type'])) : ''; // A FIELD NAME TYPED INTO THE BOX AND NEVER TURNED INTO A CHIP. The page // commits it when Run Matching is pressed, so this is normally empty; it is // here for the request that arrives without that script having run - blocked // by a content-security policy, deferred by an optimizer, or the browser // submitting from the keyboard before it loaded. Folded in after the chips, // trimmed and de-duplicated by the same split, so the keyboard, the mouse and // a script-less browser all submit the same list. $chips = isset($_POST['meta_keys']) ? sanitize_text_field(wp_unslash($_POST['meta_keys'])) : ''; $pending = isset($_POST['meta_key_pending']) ? sanitize_text_field(wp_unslash($_POST['meta_key_pending'])) : ''; $meta_keys = wpbr_combine_meta_keys($chips, $pending); // Held for the Match step, which redraws the form from it. wpbr_match_submission(array('post_type' => $post_type, 'chips' => $chips, 'pending' => $pending)); $problem = wpbr_meta_keys_problem($meta_keys); if ('empty' === $problem) { echo "

      " . wp_kses_post(__('At least one meta field is required. Type the field name into the box, or pick one from the list, then press Run Matching.', 'wpbr-bulk-replace')) . "

      "; return; } if ('too_long' === $problem) { /* translators: %d is a number of characters. */ echo "

      " . wp_kses_post(sprintf(__('Those field names are too long to save together. The names can be at most %d characters in all, commas included. Remove one, or use a shorter name, then press Run Matching.', 'wpbr-bulk-replace'), wpbr_meta_keys_max_length())) . "

      "; return; } wpbr_set_batch_matching_config($batch_id, $post_type, $meta_keys); wpbr_run_matching_core($batch_id, $post_type, $meta_keys); } function wpbr_rerun_matching_from_batch($batch_id) { global $wpdb; $batch = wpbr_get_batch($batch_id); if (!$batch || !$batch->post_type || !$batch->meta_key_name) { echo "

      " . wp_kses_post(__('This batch has no matching configuration yet — set the post type and meta field on the Match step first.', 'wpbr-bulk-replace')) . "

      "; return; } wpbr_run_matching_core($batch_id, $batch->post_type, wpbr_split_meta_keys($batch->meta_key_name)); } function wpbr_run_matching_core($batch_id, $post_type, $meta_keys) { $counts = wpbr_match_batch_items($batch_id, $post_type, $meta_keys); $matched_count = $counts['matched']; $review_count = $counts['review']; $learned_count = $counts['learned']; $protected_count = $counts['protected']; $msg = sprintf( /* translators: 1: number of files matched above the confidence threshold, 2: number needing review. */ __('Matching complete: %1$s matched above threshold, %2$s need review.', 'wpbr-bulk-replace'), number_format_i18n($matched_count), number_format_i18n($review_count) ); if ($learned_count > 0) { $msg .= ' ' . sprintf( /* translators: %s is a number of files. */ _n('%s matched from a remembered manual decision.', '%s matched from remembered manual decisions.', $learned_count, 'wpbr-bulk-replace'), number_format_i18n($learned_count) ); } if ($protected_count > 0) { $msg .= ' ' . sprintf( /* translators: %s is a number of rows. */ _n('%s row left untouched because you had already set it by hand.', '%s rows left untouched because you had already set them by hand.', $protected_count, 'wpbr-bulk-replace'), number_format_i18n($protected_count) ); } $msg .= ' ' . __('Skipped and already-replaced items were also left untouched.', 'wpbr-bulk-replace'); echo "

      ".esc_html($msg)."

      "; } function wpbr_export_batch_csv($batch_id) { global $wpdb; $batch = wpbr_get_batch($batch_id); $items = wpbr_get_batch_items($batch_id); $filename_base = $batch ? sanitize_file_name($batch->label) : ('batch-' . $batch_id); if (!$filename_base) $filename_base = 'batch-' . $batch_id; header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename='.$filename_base.'.csv'); $out = fopen('php://output', 'w'); fputcsv($out, array('staged_filename','matched_post_id','matched_post_title','confidence_score','match_kind','rename_to','status','notes'), ',', '"', WPBR_CSV_ESCAPE); foreach ($items as $i) { fputcsv($out, array_map('wpbr_csv_safe', array( $i->staged_filename, $i->matched_post_id, $i->matched_post_title, $i->confidence_score, (isset($i->match_kind) ? $i->match_kind : ''), $i->rename_to, $i->status, $i->notes, )), ',', '"', WPBR_CSV_ESCAPE); } fclose($out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes php://output, the response stream; WP_Filesystem works on files on disk and has no handle to hand back. } function wpbr_run_shell($cmd) { if (!function_exists('exec')) return null; $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); if (in_array('exec', $disabled, true)) return null; $output = array(); $return_var = null; @exec($cmd . ' 2>&1', $output, $return_var); return array('output' => implode("\n", $output), 'return_var' => $return_var); } /** * " did not produce an output file." * * The library name is a product name, so it is a placeholder rather than part * of the translated sentence. */ function wpbr_merge_no_output_error($library) { return sprintf( /* translators: %s is the name of a PDF library, e.g. "FPDI". */ __('%s did not produce an output file', 'wpbr-bulk-replace'), $library ); } /** " error: ". */ function wpbr_merge_backend_error($library, $message) { return sprintf( /* translators: 1: the name of a PDF library, e.g. "FPDI", 2: the error text that library reported. */ __('%1$s error: %2$s', 'wpbr-bulk-replace'), $library, $message ); } /** * The smallest PDF that is genuinely parseable, for asking a backend whether * it can read one at all. * * One empty 9x9 page with a correct cross-reference table - 325 bytes. It has * to be real, because the question being asked is whether ImageMagick's PDF * delegate runs and whether its security policy permits PDF, and neither can * be answered by looking at a version string. */ function wpbr_probe_pdf() { $objects = array( "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 9 9] >>\nendobj\n", ); $pdf = "%PDF-1.4\n"; $offsets = array(); foreach ($objects as $object) { $offsets[] = strlen($pdf); $pdf .= $object; } $xref = strlen($pdf); $pdf .= "xref\n0 4\n0000000000 65535 f \n"; foreach ($offsets as $offset) { $pdf .= sprintf("%010d 00000 n \n", $offset); } $pdf .= "trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n".$xref."\n%%EOF\n"; return $pdf; } /** * Can this server merge PDFs, by which method, and at what cost to the file? * * Every backend is asked before anything is merged, so the Settings screen can * say what will happen rather than the reader finding out from a failure. The * answers carry a `fidelity` because the methods are not interchangeable: four * of them combine the page objects, and one rebuilds the pages as images. * * @return array Keyed by method id, in the order they are tried. */ function wpbr_pdf_merge_backends() { $backends = array(); // --- FPDI, bundled with the plugin ------------------------------------ wpbr_load_vendor_autoload(); $backends['fpdi'] = array( 'label' => 'FPDI', 'fidelity' => 'structural', 'available' => class_exists('setasign\\Fpdi\\Fpdi'), 'detail' => class_exists('setasign\\Fpdi\\Fpdi') ? __('Bundled with the plugin, so it is always here. Keeps text, fonts and links. Cannot read PDFs that use compressed object streams, which most recent software produces - those need Ghostscript or pdftk.', 'wpbr-bulk-replace') : __('The bundled copy could not be loaded, which should not happen in a released build.', 'wpbr-bulk-replace'), 'remedy' => '', ); // --- libmergepdf, only if a site has installed it ---------------------- $backends['libmergepdf'] = array( 'label' => 'libmergepdf', 'fidelity' => 'structural', 'available' => class_exists('LibMergePDF\\Merger'), 'detail' => class_exists('LibMergePDF\\Merger') ? __('Found. Built on FPDI, so it reads the same PDFs FPDI can and fails on the same ones.', 'wpbr-bulk-replace') : __('Not installed. Optional - it uses the same parser as FPDI, so it does not read anything FPDI cannot.', 'wpbr-bulk-replace'), 'remedy' => '', ); // --- pdftk ------------------------------------------------------------- $pdftk = wpbr_run_shell('pdftk --version'); $backends['pdftk'] = array( 'label' => 'pdftk', 'fidelity' => 'structural', 'available' => ($pdftk !== null && $pdftk['return_var'] === 0), 'detail' => ($pdftk === null) ? __('Cannot be checked: PHP is not allowed to run external commands on this server, so neither pdftk nor Ghostscript can be used.', 'wpbr-bulk-replace') : (($pdftk['return_var'] === 0) ? __('Installed. Concatenates pages as they are, which is the most faithful of these methods, and reads recent PDFs.', 'wpbr-bulk-replace') : __('Not installed.', 'wpbr-bulk-replace')), 'remedy' => ($pdftk !== null && $pdftk['return_var'] !== 0) ? __('Ask your host to install pdftk (the pdftk-java package on most systems).', 'wpbr-bulk-replace') : '', ); // --- Ghostscript ------------------------------------------------------- $gs = wpbr_run_shell('gs --version'); $backends['ghostscript'] = array( 'label' => 'Ghostscript', 'fidelity' => 'structural', 'available' => ($gs !== null && $gs['return_var'] === 0), 'detail' => ($gs === null) ? __('Cannot be checked: PHP is not allowed to run external commands on this server.', 'wpbr-bulk-replace') : (($gs['return_var'] === 0) ? sprintf( /* translators: %s is a version number reported by Ghostscript. */ __('Installed, version %s. Reads recent PDFs and keeps text and fonts. It rewrites the file rather than copying pages, so some form fields and annotations may not survive.', 'wpbr-bulk-replace'), trim((string) $gs['output']) ) : __('Not installed.', 'wpbr-bulk-replace')), 'remedy' => ($gs !== null && $gs['return_var'] !== 0) ? __('Ask your host to install Ghostscript (the ghostscript package on most systems). This is the usual fix when recent PDFs will not combine.', 'wpbr-bulk-replace') : '', ); // --- Imagick, last, and asked properly --------------------------------- $backends['imagick'] = wpbr_imagick_pdf_status(); return $backends; } /** * Three separate questions about Imagick, which the class alone cannot answer. * * Whether the PHP extension is loaded, whether ImageMagick was built with a * PDF coder, and whether its security policy permits reading one, are * different things. Most distributions ship a policy.xml that denies PDF - * added after the 2016 ImageTragick reports and the Ghostscript sandbox * escapes that followed - so an extension that is present and a merge that * works are a long way apart. The only reliable answer is to hand it a PDF. * * @return array */ function wpbr_imagick_pdf_status() { $raster_note = __('Rebuilds every page as a 150 DPI image, so the result has no selectable or searchable text, no embedded fonts, and no links, form fields or bookmarks. Line art becomes pixels and the file is typically many times larger. It is tried last, and only when nothing else on this server can do the job.', 'wpbr-bulk-replace'); if (!class_exists('Imagick')) { return array( 'label' => 'Imagick', 'fidelity' => 'raster', 'available' => false, 'detail' => __('The PHP Imagick extension is not installed.', 'wpbr-bulk-replace'), 'remedy' => '', ); } try { $imagick = new Imagick(); $formats = $imagick->queryFormats('PDF'); if (!in_array('PDF', (array) $formats, true)) { return array( 'label' => 'Imagick', 'fidelity' => 'raster', 'available' => false, 'detail' => __('The PHP Imagick extension is installed, but the ImageMagick behind it was built without PDF support.', 'wpbr-bulk-replace'), 'remedy' => __('Nothing to change here. Install Ghostscript or pdftk instead - both keep the text, which this method would not.', 'wpbr-bulk-replace'), ); } $imagick->readImageBlob(wpbr_probe_pdf()); $imagick->clear(); } catch (Exception $e) { $message = $e->getMessage(); // ImageMagick says "not authorized" or "security policy" when // policy.xml denies the PDF coder, and complains about a delegate or a // failed command when Ghostscript is missing behind it. The two need // different things from the host, so they are reported differently. $policy = (stripos($message, 'security policy') !== false || stripos($message, 'not authorized') !== false); return array( 'label' => 'Imagick', 'fidelity' => 'raster', 'available' => false, 'detail' => $policy ? __('The PHP Imagick extension is installed and supports PDF, but this server\'s ImageMagick security policy blocks reading PDFs. That is a deliberate default on many hosts.', 'wpbr-bulk-replace') : sprintf( /* translators: %s is the error ImageMagick reported, quoted as the tool said it. */ __('The PHP Imagick extension is installed but could not read a PDF: %s', 'wpbr-bulk-replace'), $message ), 'remedy' => $policy ? __('It can be re-enabled by removing the PDF line from ImageMagick\'s policy.xml, but there is usually no need: installing Ghostscript or pdftk gives a better result, because this method cannot keep the text.', 'wpbr-bulk-replace') : __('Usually means Ghostscript is missing, which ImageMagick needs in order to read PDFs. Installing Ghostscript fixes this and also gives a better method than this one.', 'wpbr-bulk-replace'), ); } return array( 'label' => 'Imagick', 'fidelity' => 'raster', 'available' => true, 'detail' => $raster_note, 'remedy' => '', ); } /** * Is any method available that keeps the text? * * @return bool */ function wpbr_has_structural_pdf_merge() { foreach (wpbr_pdf_merge_backends() as $backend) { if ($backend['available'] && $backend['fidelity'] === 'structural') return true; } return false; } /** * Combine PDFs with the best method this server has. * * Order matters, and it used to be wrong. Imagick was tried first, so on any * server that had it every combine was rasterised - pages rebuilt as 150 DPI * images - even when FPDI, Ghostscript or pdftk were sitting right there and * would have kept the text. Measured on two one-page text PDFs: the Imagick * output had no extractable text, no embedded fonts, four image XObjects and * was 190,854 bytes; the Ghostscript output kept every word and was 2,908. * * So the four methods that combine the page objects come first, most faithful * first, and the one that rebuilds them as pictures comes last - where it is a * better answer than refusing, but only just, and the reader is told. */ function wpbr_merge_pdfs($input_paths, $output_path) { // Bundled, no external dependency, keeps everything - but its free parser // cannot read compressed object streams, which most recent software emits. $fpdi_result = wpbr_merge_pdfs_fpdi($input_paths, $output_path); if ($fpdi_result !== null && $fpdi_result['success']) return $fpdi_result; $vendor_result = wpbr_merge_pdfs_vendor_library($input_paths, $output_path); if ($vendor_result !== null && $vendor_result['success']) return $vendor_result; $escaped_inputs = implode(' ', array_map('escapeshellarg', $input_paths)); $escaped_output = escapeshellarg($output_path); // pdftk before Ghostscript: it concatenates the pages as they are, while // Ghostscript rewrites the file, which can drop some form fields and // annotations even though the text survives. $pdftk_cmd = "pdftk ".$escaped_inputs." cat output ".$escaped_output; $pdftk_result = wpbr_run_shell($pdftk_cmd); if ($pdftk_result !== null && $pdftk_result['return_var'] === 0 && file_exists($output_path)) { return array('success' => true, 'method' => 'pdftk', 'error' => null); } // -dSAFER because these are PDFs somebody else produced, and Ghostscript // without it will follow instructions inside them that reach the // filesystem. Versions from 9.50 assume it, older ones do not, and saying // so costs nothing. -dPDFSETTINGS=/prepress stops it downsampling images // that were fine as they were. $gs_cmd = "gs -q -dNOPAUSE -dBATCH -dSAFER -dPDFSETTINGS=/prepress -sDEVICE=pdfwrite -sOutputFile=".$escaped_output." ".$escaped_inputs; $gs_result = wpbr_run_shell($gs_cmd); if ($gs_result !== null && $gs_result['return_var'] === 0 && file_exists($output_path)) { return array('success' => true, 'method' => 'Ghostscript', 'error' => null); } // Last, because this one does not combine the pages - it photographs them. $imagick_result = wpbr_merge_pdfs_imagick($input_paths, $output_path); if ($imagick_result['success']) return $imagick_result; $not_installed = __('not installed', 'wpbr-bulk-replace'); $err = sprintf( /* translators: 1: what the FPDI attempt reported, 2: what the optional vendor library reported, 3: what the Imagick attempt reported. The library names are product names. */ __('No working PDF merge method found on this server. Tried, in order: FPDI (%1$s), libmergepdf (%2$s), pdftk, Ghostscript, and the PHP Imagick extension (%3$s). Installing Ghostscript is the usual fix, and it keeps the text. See Settings for what this server has.', 'wpbr-bulk-replace'), $fpdi_result !== null ? $fpdi_result['error'] : $not_installed, $vendor_result !== null ? $vendor_result['error'] : $not_installed, $imagick_result['error'] ); // The two shell backends print their own diagnostics; those are the tools' // own output, quoted rather than translated. if ($gs_result !== null) $err .= ' gs: '.$gs_result['output']; if ($pdftk_result !== null) $err .= ' pdftk: '.$pdftk_result['output']; return array('success' => false, 'method' => null, 'error' => trim($err)); } /** * Load the plugin's Composer-installed libraries (FPDF and FPDI), once. * * The release zip carries them in vendor/, installed by Composer from a committed * lock and copied in by the build, so nobody who installs the plugin needs * Composer (docs/THIRD-PARTY.md). A development checkout gets the same * vendor/autoload.php from `composer install`. Where it is missing the merge * backends that depend on it report "not installed" and the others are tried. */ function wpbr_load_vendor_autoload() { static $loaded = false; if ($loaded) return; $composer_autoload = WPBR_PLUGIN_DIR . 'vendor/autoload.php'; if (file_exists($composer_autoload)) { require_once $composer_autoload; } $loaded = true; } function wpbr_merge_pdfs_fpdi($input_paths, $output_path) { wpbr_load_vendor_autoload(); if (!class_exists('setasign\\Fpdi\\Fpdi')) return null; try { $fpdi_class = 'setasign\\Fpdi\\Fpdi'; $pdf = new $fpdi_class(); foreach ($input_paths as $path) { $page_count = $pdf->setSourceFile($path); for ($i = 1; $i <= $page_count; $i++) { $tpl_id = $pdf->importPage($i); $size = $pdf->getTemplateSize($tpl_id); $pdf->AddPage($size['orientation'], array($size['width'], $size['height'])); $pdf->useTemplate($tpl_id); } } $pdf->Output($output_path, 'F'); if (file_exists($output_path)) { return array('success' => true, 'method' => 'FPDI', 'error' => null); } return array('success' => false, 'method' => null, 'error' => wpbr_merge_no_output_error('FPDI')); } catch (Exception $e) { return array('success' => false, 'method' => null, 'error' => wpbr_merge_backend_error('FPDI', $e->getMessage())); } } /** * Combine by photographing every page. * * Imagick::readImage() on a PDF runs it through ImageMagick's delegate and * hands back a bitmap per page; setImageFormat('pdf') then wraps those bitmaps * in a PDF container. The pages are images from that point on, so nothing that * was not a picture survives: no selectable text, no fonts, no links, no form * fields, no bookmarks, and line art at whatever the resolution below happens * to be. This is the last method tried for that reason, and callers disclose * it when it is what ran. */ function wpbr_merge_pdfs_imagick($input_paths, $output_path) { if (!class_exists('Imagick')) { return array('success' => false, 'method' => null, 'error' => __('Imagick class not available', 'wpbr-bulk-replace')); } try { $combined = new Imagick(); foreach ($input_paths as $path) { $pages = new Imagick(); $pages->setResolution(150, 150); $pages->readImage($path); foreach ($pages as $page) { $combined->addImage($page->getImage()); } } $combined->setImageFormat('pdf'); $written = $combined->writeImages($output_path, true); $combined->clear(); if ($written && file_exists($output_path)) { return array('success' => true, 'method' => 'PHP Imagick', 'error' => null, 'lossy' => true); } return array('success' => false, 'method' => null, 'error' => wpbr_merge_no_output_error('Imagick')); } catch (Exception $e) { return array('success' => false, 'method' => null, 'error' => wpbr_merge_backend_error('Imagick', $e->getMessage())); } } function wpbr_merge_pdfs_vendor_library($input_paths, $output_path) { wpbr_load_vendor_autoload(); if (!class_exists('LibMergePDF\\Merger')) return null; try { $merger_class = 'LibMergePDF\\Merger'; $merger = new $merger_class(); foreach ($input_paths as $path) { $merger->addFile($path); } file_put_contents($output_path, $merger->merge()); if (file_exists($output_path)) { return array('success' => true, 'method' => 'libmergepdf', 'error' => null); } return array('success' => false, 'method' => null, 'error' => wpbr_merge_no_output_error('libmergepdf')); } catch (Exception $e) { return array('success' => false, 'method' => null, 'error' => wpbr_merge_backend_error('libmergepdf', $e->getMessage())); } } function wpbr_longest_common_prefix($strings) { if (empty($strings)) return ''; $prefix = $strings[0]; foreach ($strings as $s) { while ($prefix !== '' && strpos($s, $prefix) !== 0) { $prefix = substr($prefix, 0, -1); } if ($prefix === '') return ''; } return $prefix; } function wpbr_derive_combined_filename($original_filenames) { $names_no_ext = array_map(function($f) { return preg_replace('/\.[a-z0-9]+$/i', '', $f); }, $original_filenames); $prefix = rtrim(wpbr_longest_common_prefix($names_no_ext)); $delims = array(' ', '-', '_', '('); $last_delim = false; for ($i = strlen($prefix) - 1; $i >= 0; $i--) { if (in_array($prefix[$i], $delims, true)) { $last_delim = $i; break; } } if ($last_delim !== false) { $prefix = substr($prefix, 0, $last_delim); } $prefix = trim(rtrim($prefix, " -_(")); if (strlen($prefix) >= 8) { $base = $prefix; } else { $base = implode('-', array_map('wpbr_filename_safe', $names_no_ext)); $base = substr($base, 0, 120); } return wpbr_filename_safe($base) . '.pdf'; } /** * Read the page order supplied by the reorder step. * * Every id is checked against the rows actually selected, so a tampered POST * cannot pull in rows the user did not tick. * * @param string $raw Comma-separated item ids. * @param int[] $selected_ids The rows genuinely selected. * @return int[] */ function wpbr_parse_combine_order($raw, $selected_ids) { $requested_order = array(); if ($raw === '' || $raw === null) return $requested_order; $allowed = array_map('intval', (array) $selected_ids); foreach (explode(',', (string) $raw) as $oid) { $oid = (int) trim($oid); if ($oid && in_array($oid, $allowed, true) && !in_array($oid, $requested_order, true)) { $requested_order[] = $oid; } } return $requested_order; } /** * Put the rows into the requested page order. * * Without this the IN() query returns rows in whatever order the database * chooses, which is not a sensible page order for a merged document. Rows not * named in the order fall to the end rather than being dropped, so a partial * order can never silently lose a page. * * @param object[] $items * @param int[] $requested_order * @return object[] */ function wpbr_apply_combine_order($items, $requested_order) { if (!$requested_order) return $items; $by_id = array(); foreach ($items as $it) $by_id[(int) $it->id] = $it; $ordered = array(); foreach ($requested_order as $oid) { if (isset($by_id[$oid])) { $ordered[] = $by_id[$oid]; unset($by_id[$oid]); } } foreach ($by_id as $leftover) $ordered[] = $leftover; // anything not listed goes last return $ordered; } /** * The notice shown when no merge method worked. * * `$error` is what the backends reported, quoted. It is not ours, and it is not * safe: a library that is parsing a PDF quotes the part of the PDF it choked on, * so a crafted file can put markup into the message (FPDI's "Unexpected data in * xref table (%s)" is one). It is therefore escaped here, where it is printed, * and nowhere earlier - escaping at the throw would double-escape at every other * place the same text is shown. tests/integration/LibraryErrorTextTest.php puts * a real hostile PDF through it. * * @param string $error The merge attempt's own diagnostic text. */ function wpbr_render_combine_failed($error) { echo "

      " . esc_html(sprintf( /* translators: %s is the diagnostic text from the merge attempt. */ __('Combine failed: %s', 'wpbr-bulk-replace'), $error )) . " " . esc_html__('Tried the PHP Imagick extension, FPDI, a LibMergePDF-based vendor library, Ghostscript, and pdftk — none worked on this server. Ask your server admin which of these is available, or merge these files locally and re-upload the result as one file.', 'wpbr-bulk-replace') . "

      "; } function wpbr_handle_combine_files($batch_id, $selected_ids, $combine_order = '') { 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(); if (count($selected_ids) < 2) { echo "

      " . esc_html__('Select at least two files to combine.', 'wpbr-bulk-replace') . "

      "; return; } // An explicit order can be supplied by the reorder step. Without it the // IN() query returns rows in whatever order the database chooses, which // is not a sensible page order for a merged document. $requested_order = wpbr_parse_combine_order($combine_order, $selected_ids); $items = wpbr_get_batch_items_in($batch_id, $selected_ids); if (count($items) < 2) { echo "

      " . esc_html__('Could not find the selected rows.', 'wpbr-bulk-replace') . "

      "; return; } $items = wpbr_apply_combine_order($items, $requested_order); $paths = array(); $names = array(); foreach ($items as $item) { $ext = strtolower(pathinfo($item->staged_filename, PATHINFO_EXTENSION)); if ($ext !== 'pdf') { echo "

      " . esc_html(sprintf( /* translators: 1: a filename, 2: a file extension without the dot, e.g. "docx". */ __('Only PDF files can be combined — "%1$s" is a .%2$s file.', 'wpbr-bulk-replace'), $item->staged_filename, $ext )) . "

      "; return; } if (!file_exists($item->staged_path)) { echo "

      " . esc_html(sprintf( /* translators: %s is a filename. */ __('File missing on disk: %s', 'wpbr-bulk-replace'), $item->staged_filename )) . "

      "; return; } $paths[] = $item->staged_path; $names[] = $item->staged_filename; } $output_filename = wpbr_derive_combined_filename($names); $output_path = wpbr_staging_dir() . '/' . $output_filename; $merge_result = wpbr_merge_pdfs($paths, $output_path); if (!$merge_result['success']) { wpbr_render_combine_failed($merge_result['error']); return; } $batch = wpbr_get_batch($batch_id); $new_status = 'staged'; $match_data = array( 'matched_post_id' => null, 'matched_post_title' => null, 'confidence_score' => null, 'match_kind' => null, 'rename_to' => null, ); $combined_match = null; if ($batch && $batch->post_type && $batch->meta_key_name) { $meta_keys = wpbr_split_meta_keys($batch->meta_key_name); $candidates = wpbr_get_post_candidates($batch->post_type, $meta_keys); $combined_match = wpbr_match_file($output_filename, $candidates, $batch->post_type); $decision = wpbr_match_row_update($combined_match); $new_status = $decision['settled'] ? 'matched' : 'needs_review'; $match_data = $decision['update']; } // 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_merge(array( 'batch_id' => $batch_id, 'staged_filename' => $output_filename, 'staged_path' => $output_path, 'status' => $new_status, 'notes' => trim(sprintf( /* translators: 1: a list of filenames joined by " + ", 2: the name of the PDF library used, e.g. "Ghostscript". */ __('Combined from, in page order: %1$s (via %2$s)', 'wpbr-bulk-replace'), implode(' + ', $names), $merge_result['method'] ) . (empty($merge_result['lossy']) ? '' : ' | ' . __('This was the only method available on this server, and it rebuilds each page as an image: the combined file has no selectable text, no embedded fonts and no links. Installing Ghostscript would avoid that.', 'wpbr-bulk-replace'))), ), $match_data)); $new_id = $wpdb->insert_id; foreach ($items as $item) { // 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->update($items_table, array( 'status' => 'combined', 'notes' => trim(($item->notes ? $item->notes.' | ' : '').sprintf( /* translators: %s is a row's numeric ID. */ __('Combined into item #%s', 'wpbr-bulk-replace'), $new_id )), ), array('id' => $item->id)); } if ($new_status === 'matched') { $result_msg = ($match_data['match_kind'] === WPBR_MATCH_EXACT) ? sprintf( /* translators: %s is the title of the post it matched. */ __('matched to "%s" by an exact identifier in its name', 'wpbr-bulk-replace'), $match_data['matched_post_title'] ) : sprintf( /* translators: 1: the title of the post it matched, 2: a confidence score. */ __('matched to "%1$s" (score %2$s)', 'wpbr-bulk-replace'), $match_data['matched_post_title'], $match_data['confidence_score'] ); } elseif ($new_status === 'needs_review') { $result_msg = ($combined_match && isset($combined_match['kind']) && $combined_match['kind'] === WPBR_MATCH_AMBIGUOUS) ? __('needs manual review — the identifier in its name matches more than one candidate', 'wpbr-bulk-replace') : __('needs manual review — no confident match found', 'wpbr-bulk-replace'); } else { $result_msg = __('unmatched — set up matching on this batch first, then re-run it', 'wpbr-bulk-replace'); } echo "

      " . esc_html(sprintf( /* translators: 1: a number of files, 2: the new combined filename, 3: the PDF library used, e.g. "Ghostscript", 4: how the new row matched, e.g. "needs manual review". */ _n('Combined %1$s file into a new row (%2$s) via %3$s. It is %4$s at the top of the table below — open the file to verify it looks right before trusting the match.', 'Combined %1$s files into a new row (%2$s) via %3$s. It is %4$s at the top of the table below — open the file to verify it looks right before trusting the match.', count($items), 'wpbr-bulk-replace'), number_format_i18n(count($items)), $output_filename, $merge_result['method'], $result_msg )); if (!empty($merge_result['lossy'])) { echo "

      " . esc_html__('The combined file has no selectable text.', 'wpbr-bulk-replace') . " " . esc_html__('The only merge method available on this server rebuilds every page as an image, so the result cannot be searched, copied from or read by a screen reader, and it will be much larger than the files it came from. Installing Ghostscript on the server avoids all of that. Settings lists what this server has.', 'wpbr-bulk-replace'); } echo "

      "; } function wpbr_handle_confirm_new_attach_bulk($batch_id, $selected_ids) { 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; if (empty($selected_ids)) { echo "

      " . esc_html__('No rows selected.', 'wpbr-bulk-replace') . "

      "; return; } $ids = wpbr_clean_ids($selected_ids); if ($ids) { // 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->query($wpdb->prepare( "UPDATE {$wpdb->prefix}wpbr_batch_items SET status = 'confirmed_new_attach' WHERE batch_id = %d AND status <> 'needs_attention' AND id IN (" . implode(',', array_fill(0, count($ids), '%d')) . ')', array_merge(array($batch_id), $ids) )); } echo "

      " . esc_html(sprintf( /* translators: %s is a number of rows. */ _n('%s row confirmed for first-time attachment.', '%s rows confirmed for first-time attachment.', count($selected_ids), 'wpbr-bulk-replace'), number_format_i18n(count($selected_ids)) )) . "

      "; } function wpbr_handle_delete_items($batch_id, $selected_ids) { 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(); if (empty($selected_ids)) { echo "

      " . esc_html__('No rows selected.', 'wpbr-bulk-replace') . "

      "; return; } $items = wpbr_get_batch_items_in($batch_id, $selected_ids); $deleted = 0; $blocked = 0; $held = 0; foreach ($items as $item) { if ($item->status === 'needs_attention') { $held++; continue; } if (in_array($item->status, array('replaced', 'verified'), true)) { $blocked++; continue; } if ($item->staged_path && file_exists($item->staged_path)) { wp_delete_file($item->staged_path); } // 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('id' => $item->id, 'batch_id' => $batch_id)); $deleted++; } $msg = sprintf( /* translators: %s is a number of rows. */ _n('Deleted %s row.', 'Deleted %s rows.', $deleted, 'wpbr-bulk-replace'), number_format_i18n($deleted) ); if ($blocked) { // THE STEP IS NAMED BY THE FUNCTION THAT NAMES STEPS, not by these // sentences. They used to send the reader to "Verify & Cleanup", a // fifth step of a four-step wizard: verifying and deleting backups are // toolbar actions on the processed table inside Backup & Replace, and // have been since the wizard lost its fifth step. A screen name spelled // out in prose is a screen name that stops being true silently. $msg .= ' ' . sprintf( /* translators: 1: a number of rows, 2: the title of the Backup & Replace step. */ _n('%1$s row was skipped because it has already been replaced live — roll it back or clean it up on the %2$s step instead.', '%1$s rows were skipped because they have already been replaced live — roll them back or clean them up on the %2$s step instead.', $blocked, 'wpbr-bulk-replace'), number_format_i18n($blocked), wpbr_wizard_step_title('replace') ); } if ($held) { $msg .= ' ' . esc_html__('A file that needs attention was left in place: deal with it from the panel above the table first.', 'wpbr-bulk-replace'); } echo "

      ".esc_html($msg)."

      "; } function wpbr_handle_skip_items($batch_id, $selected_ids) { 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; if (empty($selected_ids)) { echo "

      " . esc_html__('No rows selected.', 'wpbr-bulk-replace') . "

      "; return; } $ids = wpbr_clean_ids($selected_ids); if ($ids) { // 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->query($wpdb->prepare( "UPDATE {$wpdb->prefix}wpbr_batch_items SET status = 'skip' WHERE batch_id = %d AND status <> 'needs_attention' AND id IN (" . implode(',', array_fill(0, count($ids), '%d')) . ')', array_merge(array($batch_id), $ids) )); } echo "

      " . esc_html(sprintf( /* translators: %s is a number of rows. "Skipped" is a status name. */ _n('%s row moved to Skipped.', '%s rows moved to Skipped.', count($selected_ids), 'wpbr-bulk-replace'), number_format_i18n(count($selected_ids)) )) . "

      "; } function wpbr_handle_save_review($batch_id) { check_admin_referer('wpbr_save_review'); 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; $wpbr_sub = isset($_POST['wpbr_sub']) ? sanitize_key(wp_unslash($_POST['wpbr_sub'])) : ''; $selected_ids = isset($_POST['item_ids']) ? array_map('intval', (array) $_POST['item_ids']) : array(); // Delete and Refresh Matching are asked about in the page (admin-dialog.php), and the dialog's Confirm is the only thing that // sends this field, naming the action it confirmed. It is checked here, not just in the browser: if the dialog's script // did not run (an error earlier on the page, a blocked or cached-stale file), the button is a plain submit button, and a // plain submit must not delete or re-match. $confirmed = isset($_POST['wpbr_confirmed']) && sanitize_key(wp_unslash($_POST['wpbr_confirmed'])) === $wpbr_sub; if (wpbr_combine_enabled() && $wpbr_sub === 'combine_files') { $combine_order = isset($_POST['combine_order']) ? sanitize_text_field(wp_unslash($_POST['combine_order'])) : ''; wpbr_handle_combine_files($batch_id, $selected_ids, $combine_order); return; } if ($wpbr_sub === 'confirm_new_attach_bulk') { wpbr_handle_confirm_new_attach_bulk($batch_id, $selected_ids); return; } if ($wpbr_sub === 'delete_selected') { if (!$confirmed) { echo "

      " . esc_html__('Nothing was deleted. Deleting files needs your confirmation, and the confirmation window did not open. Reload the page and try again.', 'wpbr-bulk-replace') . "

      "; return; } wpbr_handle_delete_items($batch_id, $selected_ids); return; } if ($wpbr_sub === 'skip_selected') { wpbr_handle_skip_items($batch_id, $selected_ids); return; } $items_table = wpbr_table_items(); $post_ids = isset($_POST['post_id']) ? array_map('sanitize_text_field', (array) wp_unslash($_POST['post_id'])) : array(); $expected = isset($_POST['expected_rows']) ? intval($_POST['expected_rows']) : 0; if ($expected > 0 && count($post_ids) < $expected) { $limit = (int) ini_get('max_input_vars'); echo "

      " . esc_html__('Nothing was saved.', 'wpbr-bulk-replace') . " " . esc_html(sprintf( /* translators: 1: how many rows arrived, 2: how many were expected. */ __('The browser sent data for only %1$s of %2$s rows, which means PHP truncated the submission before it arrived. Saving a partial form could have quietly overwritten rows with blank values, so the save was canceled.', 'wpbr-bulk-replace'), number_format_i18n(count($post_ids)), number_format_i18n($expected) )) . "

      "; $limit_note = $limit ? sprintf( /* translators: %s is the current max_input_vars value. */ __('(currently %s)', 'wpbr-bulk-replace'), number_format_i18n($limit) ) : ''; echo "

      " . wp_kses_post(sprintf( /* translators: 1: "(currently 1,000)", or empty when PHP does not report a limit, 2: an approximate number of form fields. max_input_vars and php.ini are literal names and stay untranslated. */ __('This is PHP’s max_input_vars limit %1$s being exceeded: this batch submits roughly %2$s fields. Ask your server admin to raise max_input_vars to 5000 — it is a one-line php.ini change and costs nothing.', 'wpbr-bulk-replace'), esc_html($limit_note), esc_html(number_format_i18n($expected * 4)) )) . "

      "; echo "

      " . wp_kses_post(__('Changing Rows per page won\'t help: rows hidden by paging and filtering are still part of the form and still get submitted.', 'wpbr-bulk-replace')) . "

      "; return; } $statuses = isset($_POST['status']) ? array_map('sanitize_text_field', (array) wp_unslash($_POST['status'])) : array(); $renames = isset($_POST['rename_to']) ? array_map('sanitize_text_field', (array) wp_unslash($_POST['rename_to'])) : array(); $notes = isset($_POST['notes']) ? array_map('sanitize_text_field', (array) wp_unslash($_POST['notes'])) : array(); $manual_picks = array(); if (!empty($_POST['manual_picks_json'])) { $decoded = json_decode(sanitize_text_field(wp_unslash($_POST['manual_picks_json'])), true); if (is_array($decoded)) { foreach ($decoded as $mid) { $manual_picks[intval($mid)] = '1'; } } } $apply_template = ($wpbr_sub === 'apply_template'); $template = isset($_POST['rename_template']) ? trim(sanitize_text_field(wp_unslash($_POST['rename_template']))) : ''; $batch_row = wpbr_get_batch($batch_id); $prior = 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). $rows = $wpdb->get_results($wpdb->prepare("SELECT id, staged_filename, matched_post_id, status FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d", $batch_id)); $staged_filenames = array(); $current_status = array(); foreach ($rows as $r) { $staged_filenames[$r->id] = $r->staged_filename; $prior[$r->id] = $r->matched_post_id; $current_status[$r->id] = $r->status; } foreach ($post_ids as $item_id => $post_id) { $item_id = intval($item_id); if (!isset($current_status[$item_id])) continue; // Not a row of this batch: nothing to save. wpbr_set_row_match($batch_id, $item_id, $post_id, array( 'status' => isset($statuses[$item_id]) ? $statuses[$item_id] : null, 'rename_to' => isset($renames[$item_id]) ? (string) $renames[$item_id] : '', 'rename_template' => $apply_template ? $template : '', 'notes' => isset($notes[$item_id]) ? $notes[$item_id] : '', 'manual_pick' => isset($manual_picks[$item_id]), ), array( 'row' => (object) array( 'id' => $item_id, 'staged_filename' => $staged_filenames[$item_id], 'matched_post_id' => $prior[$item_id], 'status' => $current_status[$item_id], ), 'batch' => $batch_row, )); } if ($apply_template && $template) { echo "

      " . esc_html__('Rename template applied to all matched rows and changes saved.', 'wpbr-bulk-replace') . "

      "; } else { echo "

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

      "; } if ($wpbr_sub === 'rerun_matching') { if ($confirmed) { wpbr_rerun_matching_from_batch($batch_id); } else { // The save above is what the Save Changes button does and is safe to keep; the re-match is what needs the question. echo "

      " . esc_html__('Your changes were saved, but matching was not re-run. Re-running needs your confirmation, and the confirmation window did not open. Reload the page and try again.', 'wpbr-bulk-replace') . "

      "; } } } function wpbr_col_letter_to_index($ref) { preg_match('/^([A-Z]+)/', $ref, $m); $letters = isset($m[1]) ? $m[1] : 'A'; $index = 0; for ($i = 0; $i < strlen($letters); $i++) { $index = $index * 26 + (ord($letters[$i]) - 64); } return $index - 1; } function wpbr_read_xlsx_rows($path) { if (!class_exists('ZipArchive')) return null; $zip = new ZipArchive(); if ($zip->open($path) !== true) return null; $shared_strings = array(); $ss_xml = $zip->getFromName('xl/sharedStrings.xml'); if ($ss_xml) { $ss = @simplexml_load_string($ss_xml, 'SimpleXMLElement', LIBXML_NONET); if ($ss) { foreach ($ss->si as $si) { if (isset($si->t)) { $shared_strings[] = (string) $si->t; } else { $text = ''; foreach ($si->r as $r) { $text .= (string) $r->t; } $shared_strings[] = $text; } } } } $sheet_name = null; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name)) { $sheet_name = $name; break; } } if (!$sheet_name) { $zip->close(); return null; } $sheet_xml = $zip->getFromName($sheet_name); $zip->close(); if (!$sheet_xml) return null; $sheet = @simplexml_load_string($sheet_xml, 'SimpleXMLElement', LIBXML_NONET); if (!$sheet) return null; $rows_raw = array(); foreach ($sheet->sheetData->row as $row) { $row_data = array(); foreach ($row->c as $c) { $ref = (string) $c['r']; $col_index = wpbr_col_letter_to_index($ref); $type = (string) $c['t']; if ($type === 's') { $idx = (int) $c->v; $value = isset($shared_strings[$idx]) ? $shared_strings[$idx] : ''; } elseif ($type === 'inlineStr') { $value = isset($c->is->t) ? (string) $c->is->t : ''; } else { $value = (string) $c->v; } $row_data[$col_index] = $value; } $rows_raw[] = $row_data; } if (empty($rows_raw)) return array(); $header_row = $rows_raw[0]; if (empty($header_row)) return array(); $max_col = max(array_keys($header_row)); $headers = array(); for ($i = 0; $i <= $max_col; $i++) { $headers[$i] = isset($header_row[$i]) ? trim($header_row[$i]) : ''; } $result = array(); for ($r = 1; $r < count($rows_raw); $r++) { $row_data = $rows_raw[$r]; $assoc = array(); foreach ($headers as $i => $h) { if ($h === '') continue; $assoc[$h] = isset($row_data[$i]) ? $row_data[$i] : ''; } if (!empty($assoc)) $result[] = $assoc; } return $result; } /** * The escape character for this plugin's CSV reading and writing. * * PHP defaults to a backslash, which no spreadsheet program uses and RFC 4180 * does not describe: a field ending in one swallows the quote that closes it, * and every column after that lands in the wrong place. Passing an empty * string turns that off and parses the way Excel and Google Sheets write. * * It also has to be passed explicitly. PHP 8.4 deprecates leaving it out - an * import on 8.4 fills the error log with notices - and the default changes in * PHP 9, which would change how files parse without a line of this plugin * being edited. */ if (!defined('WPBR_CSV_ESCAPE')) define('WPBR_CSV_ESCAPE', ''); function wpbr_read_csv_rows($path) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- A streaming read of an uploaded CSV: fgetcsv() needs a handle, and WP_Filesystem can only return the whole file as a string, which cannot be parsed row by row without holding every row of a large spreadsheet in memory. $handle = fopen($path, 'r'); if (!$handle) return array(); $header = fgetcsv($handle, 0, ',', '"', WPBR_CSV_ESCAPE); if (!$header) { fclose($handle); return array(); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the CSV handle opened above, which WP_Filesystem could not stream. $header = array_map('trim', $header); $width = count($header); $result = array(); // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- The idiomatic read loop: fgetcsv() returns false at EOF, so the assignment is the condition. while (($row = fgetcsv($handle, 0, ',', '"', WPBR_CSV_ESCAPE)) !== false) { // A blank line reads as a single null cell. Every editor leaves one at // the end of a file. if (array() === array_filter($row, function ($cell) { return null !== $cell && '' !== trim((string) $cell); })) { continue; } // Squared off against the header rather than handed to array_combine(), // which returns false on a length mismatch in PHP 7 and throws a // ValueError in PHP 8 - so a row with a trailing comma, an empty last // column or a half-deleted cell took the whole admin screen down // instead of producing a message about the file. Spreadsheets produce // these constantly. $row = array_slice(array_pad($row, $width, ''), 0, $width); $result[] = array_combine($header, $row); } fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the CSV handle opened above, which WP_Filesystem could not stream. return $result; } function wpbr_parse_import_file($tmp_path, $original_filename) { $ext = strtolower(pathinfo($original_filename, PATHINFO_EXTENSION)); if ($ext === 'xlsx') { $rows = wpbr_read_xlsx_rows($tmp_path); return $rows !== null ? $rows : array(); } return wpbr_read_csv_rows($tmp_path); } function wpbr_handle_import_csv($batch_id) { check_admin_referer('wpbr_import_csv'); 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; // One file, whole: present, with its name and path as strings (a "csv_file[]" // upload would make them arrays). if (!isset($_FILES['csv_file']['name'], $_FILES['csv_file']['tmp_name']) || !is_string($_FILES['csv_file']['name']) || !is_string($_FILES['csv_file']['tmp_name']) || $_FILES['csv_file']['tmp_name'] === '') return; $items_table = wpbr_table_items(); $import_log_table = wpbr_table_import_log(); // The uploaded name is whatever the browser sent. It only ever picks a // parser and gets recorded, and both the history table and the log escape // it on the way out, but it is cleaned once here so nothing downstream has // to remember that. $source_filename = sanitize_file_name(wp_unslash($_FILES['csv_file']['name'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- tmp_name is the path PHP itself chose for this upload, not text the browser sent; it is opened for reading and nothing else. $tmp_path = $_FILES['csv_file']['tmp_name']; $rows = wpbr_parse_import_file($tmp_path, $source_filename); if (empty($rows)) { echo "

      " . esc_html__('Could not read that file, or it had no data rows. Accepted formats: .csv, .xlsx (first sheet only).', 'wpbr-bulk-replace') . "

      "; return; } $import_uuid = wp_generate_uuid4(); $now = current_time('mysql'); $user_id = get_current_user_id(); // What the file is allowed to say. Both the on-screen instructions and the // suggested email already tell the reviewer exactly this set; until now // nothing checked that the file came back inside it. $batch = wpbr_get_batch($batch_id); $selectable = wpbr_selectable_statuses(); $updated = 0; $protected_skipped = 0; $rejected = 0; $unrecorded = 0; foreach ($rows as $data) { if (empty($data['staged_filename'])) continue; // 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). $existing = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d AND staged_filename = %s", $batch_id, trim($data['staged_filename']) )); if (!$existing) continue; if (in_array($existing->status, array('combined', 'replaced', 'verified', 'needs_attention'), true)) { $protected_skipped++; continue; } $update = array(); if (isset($data['matched_post_id']) && $data['matched_post_id'] !== '') { // A post that exists, of the type this batch is working on. Neither // was checked, so a typo'd or stale ID silently retargeted the file // at whatever happened to hold that number - or at nothing, leaving // the row pointed somewhere the Replace step cannot write. $candidate_id = intval($data['matched_post_id']); $candidate = $candidate_id ? get_post($candidate_id) : null; if (!$candidate || ($batch && $batch->post_type && $candidate->post_type !== $batch->post_type)) { $rejected++; } else { $update['matched_post_id'] = $candidate_id; $update['matched_post_title'] = wpbr_clean_title(get_the_title($candidate_id)); if (intval($existing->matched_post_id) !== $candidate_id) { /** Both columns, for the reason given in wpbr_handle_save_review(). */ $update['confidence_score'] = null; $update['match_kind'] = WPBR_MATCH_MANUAL; if ($batch && $batch->post_type) { /** This method documented in wpbr_handle_bulk_reassign(). */ do_action('wpbr_match_confirmed', $existing->staged_filename, $candidate_id, $batch->post_type); } } } } if (isset($data['status']) && $data['status'] !== '') { // Status drives which rows process, which can be rolled back and // which can have their backup deleted. A value outside the set the // reviewer was given leaves the row somewhere no query matches and // no screen offers an action for; "replaced" and "verified" are // records of something having happened on disk, and a file cannot // assert them into being. $candidate_status = sanitize_text_field($data['status']); if (in_array($candidate_status, $selectable, true)) { $update['status'] = $candidate_status; } else { $rejected++; } } if (isset($data['notes'])) $update['notes'] = sanitize_text_field($data['notes']); if ($update) { // The undo record goes in first and is checked, because it is the // only copy of what this row held. Written after the change, or // written and not checked, a failure here leaves a row altered by // somebody else's spreadsheet with no way back. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin-owned table with no WordPress API; an insert, so there is no cached read to invalidate. $recorded = $wpdb->insert($import_log_table, array( 'import_uuid' => $import_uuid, 'batch_id' => $batch_id, 'item_id' => $existing->id, 'source_filename' => $source_filename, 'prev_matched_post_id' => $existing->matched_post_id, 'prev_matched_post_title' => $existing->matched_post_title, 'prev_status' => $existing->status, 'prev_notes' => $existing->notes, 'created_at' => $now, 'created_by' => $user_id, )); if (false === $recorded) { $unrecorded++; continue; } // 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->update($items_table, $update, array('id' => $existing->id)); $updated++; } } $log_detail = sprintf( /* translators: %s is a number of rows. */ _n('Updated %s row', 'Updated %s rows', $updated, 'wpbr-bulk-replace'), number_format_i18n($updated) ); if ($protected_skipped) { $log_detail .= ', ' . sprintf( /* translators: %s is a number of rows that were deliberately left as they were. */ _n('left %s protected row alone', 'left %s protected rows alone', $protected_skipped, 'wpbr-bulk-replace'), number_format_i18n($protected_skipped) ); } if ($rejected) { $log_detail .= ', ' . sprintf( /* translators: %s is a number of values in the file that were not accepted. */ _n('ignored %s value the file was not allowed to set', 'ignored %s values the file was not allowed to set', $rejected, 'wpbr-bulk-replace'), number_format_i18n($rejected) ); } wpbr_log($batch_id, null, $source_filename, 'import', 'success', $log_detail); $msg = sprintf( /* translators: %s is a number of rows. */ _n('Imported changes for %s row.', 'Imported changes for %s rows.', $updated, 'wpbr-bulk-replace'), number_format_i18n($updated) ); if ($protected_skipped > 0) { $msg .= ' ' . sprintf( /* translators: %s is a number of rows. */ _n('Left %s row untouched because it has already been combined, replaced, or verified — a reviewer’s file cannot override work you have already done past that point.', 'Left %s rows untouched because they have already been combined, replaced, or verified — a reviewer’s file cannot override work you have already done past that point.', $protected_skipped, 'wpbr-bulk-replace'), number_format_i18n($protected_skipped) ); } if ($unrecorded > 0) { $msg .= ' ' . sprintf( /* translators: %s is a number of rows. */ _n('Left %s row unchanged because its undo record could not be saved — a change that cannot be undone is not one this plugin will make on a spreadsheet\'s word. Try the import again.', 'Left %s rows unchanged because their undo records could not be saved — a change that cannot be undone is not one this plugin will make on a spreadsheet\'s word. Try the import again.', $unrecorded, 'wpbr-bulk-replace'), number_format_i18n($unrecorded) ); } if ($rejected > 0) { $msg .= ' ' . sprintf( /* translators: %s is a number of values in the file. */ _n('Ignored %s value the file was not allowed to set — a status outside the listed set, or a match to a post that does not exist or is not the type this batch is processing. Everything else in the file was applied.', 'Ignored %s values the file was not allowed to set — statuses outside the listed set, or matches to posts that do not exist or are not the type this batch is processing. Everything else in the file was applied.', $rejected, 'wpbr-bulk-replace'), number_format_i18n($rejected) ); } if ($updated > 0) { $msg .= ' ' . __('You can undo this from the Import history table below if something looks wrong.', 'wpbr-bulk-replace'); } echo "

      ".wp_kses_post($msg)."

      "; } function wpbr_handle_undo_import($batch_id, $import_uuid) { 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(); $import_log_table = wpbr_table_import_log(); // 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). $log_rows = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}wpbr_import_log WHERE batch_id = %d AND import_uuid = %s", $batch_id, $import_uuid )); if (!$log_rows) { echo "

      " . wp_kses_post(__('Could not find that import to undo — it may have already been undone, or this is an old page load.', 'wpbr-bulk-replace')) . "

      "; return; } $restored = 0; foreach ($log_rows as $log) { // 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). $current = $wpdb->get_row($wpdb->prepare("SELECT status FROM {$wpdb->prefix}wpbr_batch_items WHERE id = %d", $log->item_id)); if ($current && in_array($current->status, array('combined', 'replaced', 'verified'), true)) { continue; } // 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->update($items_table, array( 'matched_post_id' => $log->prev_matched_post_id, 'matched_post_title' => $log->prev_matched_post_title, 'status' => $log->prev_status, 'notes' => $log->prev_notes, ), array('id' => $log->item_id)); $restored++; } // 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($import_log_table, array('import_uuid' => $import_uuid)); wpbr_log($batch_id, null, null, 'undo_import', 'success', sprintf( /* translators: %s is a number of rows. */ _n('Restored %s row', 'Restored %s rows', $restored, 'wpbr-bulk-replace'), number_format_i18n($restored) )); echo "

      " . esc_html(sprintf( /* translators: %s is a number of rows. */ _n('Undid the import — restored %s row to its state before it ran.', 'Undid the import — restored %s rows to their state before it ran.', $restored, 'wpbr-bulk-replace'), number_format_i18n($restored) )) . "

      "; } function wpbr_find_attachment_id_by_url($url) { global $wpdb; $path = wp_parse_url($url, PHP_URL_PATH); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core's attachment_url_to_postid() only matches URLs under this site's current uploads URL, and this finds a file by the end of its stored address whatever the host; it runs on the replace path, so it is not cached. $id = $wpdb->get_var($wpdb->prepare("SELECT ID FROM ".$wpdb->posts." WHERE post_type = 'attachment' AND guid LIKE %s", '%'.$wpdb->esc_like($path))); return $id ? intval($id) : null; }