'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 'Import is unavailable: the CSV importer or price-book module is not loaded. Check the plugin loader order (price books before import admin).
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.
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.
Approval failed: the submitted staged data could not be read. Nothing was created or activated.
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.
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.
Approval failed: ' . esc_html((string) $book->get_error_message()) . ' Nothing was activated.
Book #' . esc_html((string) $book['id']) . ' was created as a draft but activation failed. No book is active from this approval.
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.
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 'None active yet.
'; } /* ---- Step (a): upload ---- */ echo 'Import failed: no valid uploaded file.
Source: ' . esc_html((string) $sourceName) . ' — staged rows: ' . esc_html((string) count($rows)) . ' — errors: ' . esc_html((string) count($errors)) . '
'; if ($errors !== []) { echo '' . esc_html((string) print_r($errors, true)) . ''; } echo '
' . 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 '
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.
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.
' . esc_html((string) print_r($unmapped, true)) . ''; } else { 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 ''; } } } } /* ---- 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 '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 '| Book | Status | Source | Rows | Label | Created | Activated | Action |
|---|---|---|---|---|---|---|---|
| #' . 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 ''; } elseif ($status === 'active') { echo 'Active'; } else { echo '—'; } echo ' |