'width_min', 'width_max_mm' => 'width_max', 'drop_min_mm' => 'drop_min', 'drop_max_mm' => 'drop_max', 'price_pence' => 'price', 'option_code' => 'option_code', ]; } /** * Sanitize submitted staged rows (WordPress.org review, 2026-11-05). * * The approve step re-submits the previewed rows as JSON in a hidden * field, so every field must be sanitized here before it is stored — * json_decode() alone does not sanitize anything. Rules, most * restrictive per field type: * - Only the allow-listed keys survive: row, width_min, width_max, * drop_min, drop_max, price, option_code, fabric_group. Any other * key (including injected keys) is dropped with the row. * - Integer fields (row, width_min, width_max, drop_min, drop_max, * price) must match /^-?\d+$/ and are cast to int. Non-integer * values (floats, strings, arrays, objects) make the row malformed. * Negative values are kept as-is so the validator can report them * (MIN_GT_MAX / NEGATIVE_PRICE / NEGATIVE_DIMENSION) instead of * being silently clamped. * - option_code is a lowercase snake_case key: sanitize_key(). * - fabric_group is free text: sanitize_text_field(). * - A row that is not an array, or that fails any of the above, is * counted in 'dropped' and NOT included in 'rows'. The caller must * refuse approval when dropped > 0 — malformed rows are never * silently dropped and never promoted. * * Pure function except for the WP sanitizers, which are guarded with * function_exists() fallbacks so it stays testable outside WordPress. * * @return array {rows: array, dropped: int} */ function blinds_import_admin_sanitize_staged_rows($staged): array { $rows = []; $dropped = 0; if (!is_array($staged)) { return ['rows' => [], 'dropped' => 0]; } $sanitizeKey = function ($value): string { if (function_exists('sanitize_key')) { return sanitize_key((string) $value); } return strtolower((string) preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $value)); }; $sanitizeText = function ($value): string { if (function_exists('sanitize_text_field')) { return sanitize_text_field((string) $value); } return trim(strip_tags((string) $value)); }; $allowedKeys = ['row', 'width_min', 'width_max', 'drop_min', 'drop_max', 'price', 'option_code', 'fabric_group']; foreach ($staged as $r) { if (!is_array($r)) { $dropped++; continue; } $malformed = false; // Any key outside the allow-list (e.g. an injected field) makes // the whole row malformed: it is refused, not silently stripped. foreach (array_keys($r) as $k) { if (!in_array($k, $allowedKeys, true)) { $malformed = true; break; } } $clean = []; // Row number: integer, required (the parser always sets it). $rowNo = $r['row'] ?? null; if (is_int($rowNo)) { $clean['row'] = $rowNo; } elseif (is_string($rowNo) && preg_match('/^\d+$/', trim($rowNo))) { $clean['row'] = (int) trim($rowNo); } else { $malformed = true; } // Integer fields: integer-like strings or ints only. foreach (['width_min', 'width_max', 'drop_min', 'drop_max', 'price'] as $f) { $v = $r[$f] ?? null; if (is_int($v)) { $clean[$f] = $v; } elseif (is_string($v) && preg_match('/^-?\d+$/', trim($v))) { $clean[$f] = (int) trim($v); } else { $malformed = true; } } // option_code: optional key, sanitized as a key when present. if (isset($r['option_code']) && $r['option_code'] !== '') { if (!is_string($r['option_code']) && !is_int($r['option_code'])) { $malformed = true; } else { $clean['option_code'] = $sanitizeKey($r['option_code']); } } // fabric_group: optional free text, sanitized as text when present. if (isset($r['fabric_group']) && $r['fabric_group'] !== '') { if (!is_string($r['fabric_group']) && !is_int($r['fabric_group'])) { $malformed = true; } else { $clean['fabric_group'] = $sanitizeText($r['fabric_group']); } } if ($malformed) { $dropped++; continue; } $rows[] = $clean; } return ['rows' => $rows, 'dropped' => $dropped]; } function blinds_import_admin_render_page(): void { if (!function_exists('current_user_can') || !current_user_can('manage_options')) { if (function_exists('wp_die')) { wp_die('You do not have permission to access this page.'); } echo '

You do not have permission to access this page.

'; return; } if (!class_exists('Blinds_Csv_Importer') || !function_exists('blinds_price_book_create')) { echo '

Rollora — Price Books: Controlled Import

'; echo '

Import is unavailable: the CSV importer or price-book module is not loaded. Check the plugin loader order (price books before import admin).

'; return; } echo '

Blinds Price Books — Controlled Import

'; echo '

SYNTHETIC defaults. CSV flattened band rows only. ' . 'Nothing is promoted automatically: a book is created and activated only when you click ' . '"Approve and activate this price book" on an error-free preview, and that click is recorded as the approval.

'; // Existing books, newest last in storage; show newest first here. $books = function_exists('blinds_price_book_list') ? blinds_price_book_list() : []; $active = function_exists('blinds_price_book_get_active') ? blinds_price_book_get_active() : null; /* ---- Rollback (handled before rendering, so the display below is fresh) ---- * WordPress.org review, 2026-11-05: blinds_price_book_rollback() * existed but no admin UI exposed it. A superseded book can now be * reactivated from the history table below. Capability is already * checked at the top of this page (manage_options); the action is * nonce-checked here. Only 'superseded' books can be rolled back — * the function itself enforces that — and the current active book * becomes superseded. Rollback is core safety, not premium-gated. */ if (!empty($_POST['blinds_price_books_rollback']) && isset($_POST['blinds_price_books_rollback_nonce']) && function_exists('wp_verify_nonce') && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['blinds_price_books_rollback_nonce'])), 'blinds_price_books_rollback')) { $rollbackId = absint($_POST['blinds_price_books_rollback']); $rollbackUser = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0; if (function_exists('blinds_price_book_rollback') && blinds_price_book_rollback($rollbackId, $rollbackUser)) { echo '

Rolled back: price book #' . esc_html((string) $rollbackId) . ' is now the active price book. The previous active book is now superseded.

'; $books = function_exists('blinds_price_book_list') ? blinds_price_book_list() : []; $active = function_exists('blinds_price_book_get_active') ? blinds_price_book_get_active() : null; } else { echo '

Rollback failed: only a superseded price book can be rolled back (book #' . esc_html((string) $rollbackId) . ' was not superseded, or does not exist). Nothing was changed.

'; } } /* ---- Step (c): approve (handled before rendering the forms) ---- */ if (!empty($_POST['blinds_price_books_approve']) && isset($_POST['blinds_price_books_approve_nonce']) && function_exists('wp_verify_nonce') && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['blinds_price_books_approve_nonce'])), 'blinds_price_books_approve')) { $stagedJson = isset($_POST['blinds_staged_rows']) ? wp_unslash($_POST['blinds_staged_rows']) : ''; $staged = json_decode((string) $stagedJson, true); $source = isset($_POST['blinds_source_filename']) ? sanitize_text_field(wp_unslash($_POST['blinds_source_filename'])) : ''; $dataLabel = isset($_POST['blinds_data_label']) ? sanitize_text_field(wp_unslash($_POST['blinds_data_label'])) : 'SYNTHETIC'; if (!is_array($staged)) { echo '

Approval failed: the submitted staged data could not be read. Nothing was created or activated.

'; } else { // Sanitize every submitted field first: json_decode() does // not sanitize. Malformed rows are refused, never silently // dropped and never promoted (review fix, 2026-11-05). $sanitized = blinds_import_admin_sanitize_staged_rows($staged); $validRows = $sanitized['rows']; if ($sanitized['dropped'] > 0) { echo '

Approval refused: ' . esc_html((string) $sanitized['dropped']) . ' submitted row(s) were malformed (non-integer measurements/prices, unexpected fields, or non-text option codes) and were not sanitized. Nothing was created or activated. Preview the CSV again.

'; } else { // Re-validate on approval: never trust the preview submission. // Re-run the full row checks (numeric, min<=max, negative, // known option, overlap/duplicate/gap) on the submitted rows, // and block promotion while any row is unmapped to a band. $configForApproval = function_exists('blinds_proto_load_config') ? blinds_proto_load_config() : []; $knownForApproval = array_column($configForApproval['options'] ?? [], 'code'); $errors = blinds_price_book_validate_rows($validRows, $knownForApproval); $unmappedForApproval = function_exists('blinds_price_book_unmapped_rows') ? blinds_price_book_unmapped_rows($validRows, $configForApproval) : []; if ($errors !== [] || $unmappedForApproval !== []) { echo '

Approval refused: the submitted staged set did not pass validation' . ' (' . esc_html((string) count($errors)) . ' errors, ' . esc_html((string) count($unmappedForApproval)) . ' unmapped rows). Nothing was created or activated.

'; } else { $userId = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0; $book = blinds_price_book_create($validRows, [], $source, $userId, $dataLabel); if (is_wp_error($book)) { echo '

Approval failed: ' . esc_html((string) $book->get_error_message()) . ' Nothing was activated.

'; } elseif (!blinds_price_book_activate((int) $book['id'], $userId)) { echo '

Book #' . esc_html((string) $book['id']) . ' was created as a draft but activation failed. No book is active from this approval.

'; } else { echo '

Approved and activated: price book #' . esc_html((string) $book['id']) . ' (' . esc_html((string) count($validRows)) . ' rows, source: ' . esc_html((string) $source) . ') is now the active price book. The previous active book, if any, is now superseded.

'; $active = blinds_price_book_get_active(); } } } } } /* ---- Active price book (rendered AFTER the approve/rollback * handlers above, so it names the book that is active now) ---- * 1.0.1 fix: this line was previously rendered before the approve * handler ran, so on the approval response it still named the * previous active book while the notice and history table below * already showed the newly activated one. $active is re-fetched * by the handlers above after any activation or rollback. */ if ($active !== null) { echo '

Active price book

Book #' . esc_html((string) $active['id'] ?? '') . ' — source: ' . esc_html((string) $active['source_filename'] ?? '') . ' — rows: ' . esc_html((string) count($active['rows'] ?? [])) . ' — label: ' . esc_html((string) ($active['data_label'] ?? 'SYNTHETIC')) . ' — activated by user ' . esc_html((string) $active['activated_by'] ?? '') . ' at ' . esc_html((string) $active['activated_at'] ?? '') . '

'; } else { echo '

Active price book

None active yet.

'; } /* ---- Step (a): upload ---- */ echo '

Step 1 — Upload CSV

'; echo '
'; wp_nonce_field('blinds_price_books_upload', 'blinds_price_books_upload_nonce'); echo ' '; echo '
'; /* ---- Step (b): preview ---- */ if (!empty($_POST['blinds_price_books_upload']) && isset($_FILES['blinds_csv']['tmp_name']) && isset($_POST['blinds_price_books_upload_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['blinds_price_books_upload_nonce'])), 'blinds_price_books_upload')) { if (!empty($_FILES['blinds_csv']['error']) || !is_uploaded_file(wp_unslash($_FILES['blinds_csv']['tmp_name']))) { echo '

Import failed: no valid uploaded file.

'; } else { $csv = file_get_contents(wp_unslash($_FILES['blinds_csv']['tmp_name'])); $sourceName = isset($_FILES['blinds_csv']['name']) ? sanitize_file_name(wp_unslash($_FILES['blinds_csv']['name'])) : ''; $config = function_exists('blinds_proto_load_config') ? blinds_proto_load_config() : []; $known = array_column($config['options'] ?? [], 'code'); $parsed = (new Blinds_Csv_Importer())->parse((string) $csv, blinds_import_admin_mapping(), $known); $rows = $parsed['rows']; $errors = $parsed['errors']; echo '

Step 2 — Preview

'; echo '

Source: ' . esc_html((string) $sourceName) . ' — staged rows: ' . esc_html((string) count($rows)) . ' — errors: ' . esc_html((string) count($errors)) . '

'; if ($errors !== []) { echo '

Row-level errors

' . esc_html((string) print_r($errors, true)) . '
'; } echo '

Staged rows

' . esc_html((string) print_r($rows, true)) . '
'; $activeRows = ($active !== null && isset($active['rows']) && is_array($active['rows'])) ? $active['rows'] : []; $diff = blinds_price_book_diff_with_options($activeRows, $rows); echo '

Diff vs active price book

'; echo '

Added: ' . esc_html((string) count($diff['added'])) . ' — Changed (price, option_code, or fabric_group): ' . esc_html((string) count($diff['changed'])) . ' — Removed: ' . esc_html((string) count($diff['removed'])) . '

'; echo '
' . esc_html((string) print_r($diff, true)) . '
'; if (!blinds_price_book_validate_staged($rows, $errors)) { // No approve button is rendered on this path, by design. echo '

Cannot promote with errors. ' . 'Fix the CSV and preview again. Rejected rows are shown above — none are silently dropped, ' . 'and no price book has been created or activated from this upload.

'; } else { $unmapped = function_exists('blinds_price_book_unmapped_rows') ? blinds_price_book_unmapped_rows($rows, $config) : []; if ($unmapped !== []) { echo '

Cannot promote: ' . esc_html((string) count($unmapped)) . ' row(s) do not match a configured width/drop band. ' . 'Band rows must match the configured bands exactly, or they cannot become matrix prices. ' . 'Fix the CSV (or the band configuration) and preview again. No price book has been created or activated.

'; echo '
' . esc_html((string) print_r($unmapped, true)) . '
'; } else { echo '

Step 3 — Approve

'; echo '

Review the staged rows and diff above. Approving creates a draft price book and immediately ' . 'activates it; the previous active book becomes superseded. This action is recorded with your user id.

'; echo '
'; wp_nonce_field('blinds_price_books_approve', 'blinds_price_books_approve_nonce'); echo ''; echo ''; echo ''; echo '
'; } } } } /* ---- Price-book history and rollback (review fix, 2026-11-05) ---- * The book list was previously fetched but never rendered. It is now * shown in full, newest first, with a rollback action for superseded * books. Re-fetch here so the table reflects any approval or * rollback handled above on this same request. */ $books = function_exists('blinds_price_book_list') ? blinds_price_book_list() : []; $active = function_exists('blinds_price_book_get_active') ? blinds_price_book_get_active() : null; echo '

Price-book history

'; if ($books === []) { echo '

No price books yet. Upload a CSV above to create the first one.

'; } else { echo '

Exactly one book is active at a time. Activating a book supersedes the previous one. Rolling back reactivates a superseded book; the current active book becomes superseded.

'; echo ''; foreach (array_reverse($books) as $b) { if (!is_array($b)) { continue; } $bookId = (int) ($b['id'] ?? 0); $status = (string) ($b['status'] ?? ''); echo '' . '' . '' . '' . '' . '' . ''; } echo '
BookStatusSourceRowsLabelCreatedActivatedAction
#' . esc_html((string) $bookId) . '' . esc_html($status) . '' . esc_html((string) ($b['source_filename'] ?? '')) . '' . esc_html((string) count($b['rows'] ?? [])) . '' . esc_html((string) ($b['data_label'] ?? 'SYNTHETIC')) . '' . esc_html((string) ($b['created_at'] ?? '')) . '' . esc_html((string) (($b['activated_by'] ?? '') . ' / ' . ($b['activated_at'] ?? ''))) . ''; if ($status === 'superseded') { echo '
'; wp_nonce_field('blinds_price_books_rollback', 'blinds_price_books_rollback_nonce'); echo '
'; } elseif ($status === 'active') { echo 'Active'; } else { echo '—'; } echo '
'; } echo '
'; }