Kaynağa Gözat

Release v1.4.5 — resolve in-scope findings from revise-1.4.4

Adds docs/revise-1.4.4.md (fresh-eye review of 1.4.4) alongside the
defensive fixes for the mediums and lows it raised. No critical/high
findings in this round — the plugin is in a steady state.

Medium:
- normalise_csv_date fast path now validates ranges via checkdate() +
  hour/minute/second bounds. "2026-13-45T25:99" no longer matches and
  falls through to the DateTimeImmutable fallback, which throws.
- emit_import_summary surfaces an info notice ("The CSV file contained
  no actionable rows (empty order_no column).") when both $updated and
  $errors are zero. Closes the silent-import path where dedup-by-
  order_no filters out every row.
- csv_escape calls ltrim before the first-char check. Cells like
  " =CMD" with leading whitespace are no longer slipping past the
  formula-injection defence.

Low:
- parse_csv rejects CSVs whose normalised headers collide (e.g.,
  "Order No" and "order_no" both → "order_no"). Logs the headers
  and returns empty; the downstream import then emits the existing
  "could not be parsed" notice.
- bulk_set_print_status note dispatch lifted from binary ternary to
  an explicit array(slug => note) map with isset fallback. Future
  contributors adding a third status no longer risk inheriting the
  wrong note via an else-implicit branch.

Deferred (documented, no code change):
- §3.4 collation mismatch on terms.slug = postmeta.meta_value — added
  to README "Known limitations". Adding an explicit COLLATE clause
  risks breaking installs whose actual collation differs from any
  hardcoded value.
- §4.3 main plugin instance lifecycle — architectural curiosity,
  no functional impact.
- §4.4 woocommerce_missing_notice mid-request re-fire — edge case.

Docs:
- README "Changelog" extends with the 1.4.5 entry; "Known limitations"
  gains the collation note; pointer to docs/revise-1.4.4.md added.
- CLAUDE.md picks up the 1.4.5 changes.
- docs/revise-1.4.4.md is the fresh-eye review of 1.4.4; every finding
  carries an inline ✅ resolved / ⏸ deferred status marker.
- docs/translations.txt: header bumped to 1.4.5; one new EN→CS entry
  for the "no actionable rows" notice.
- languages/studiou-wc-ord-print-statuses-cs_CZ.po: Project-Id-Version
  bumped to 1.4.5; same new string added with Czech translation.

NOTE: .mo not rebuilt — run `wp i18n make-mo languages/` or compile
via Poedit before the new Czech wording appears on the site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 ay önce
ebeveyn
işleme
54a62e7c7d

+ 14 - 1
studiou-wc-ord-print-statuses/CLAUDE.md

@@ -8,7 +8,7 @@ This is a WordPress plugin that extends WooCommerce functionality to manage prin
 
 **Plugin Details:**
 - Text Domain: `studiou-wc-ord-print-statuses`
-- Current Version: 1.4.4
+- Current Version: 1.4.5
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
@@ -25,11 +25,24 @@ User-facing and historical documentation lives at the project root and under `do
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Fresh-eye analytical review of v1.4.1. All in-scope findings are resolved in 1.4.2; per-item status annotations inside.
 - [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2. All in-scope findings are resolved in 1.4.3; per-item status annotations inside.
 - [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) — Fresh-eye analytical review of v1.4.3. All in-scope findings are resolved in 1.4.4; per-item status annotations inside.
+- [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) — Fresh-eye analytical review of v1.4.4. Mediums and lows are addressed in 1.4.5; per-item status annotations inside.
 
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 ## Recent Changes
 
+### Version 1.4.5 (2026-05-12)
+
+Defensive-hardening release resolving the findings in `docs/revise-1.4.4.md`. No critical or high findings; this round closes mediums and lows.
+
+- **`normalise_csv_date()` fast path** now runs `checkdate()` + hour/minute/second bounds before accepting the regex match. Bogus inputs like `"2026-13-45T25:99"` no longer slip through to be stored verbatim.
+- **`emit_import_summary()`** adds an "info: no actionable rows" notice when both `$updated` and `$errors` are zero. Closes the silent-import path when every CSV row had an empty `order_no` after dedup.
+- **`csv_escape()`** trims leading whitespace before the first-char check. `" =CMD"`-style bypasses are defused.
+- **`parse_csv()`** rejects CSVs with normalised header collisions (e.g., `"Order No"` and `"order_no"` both → `order_no`). Avoids silent `array_combine` overwrite.
+- **`bulk_set_print_status` note dispatch** lifted to an explicit `array(slug => note)` map. Future-contributor trap closed.
+
+Deferred (low-impact): main plugin instance lifecycle, `woocommerce_missing_notice` mid-request edge case, theoretical MySQL collation mismatch on the slug join (now mentioned in README "Known limitations").
+
 ### Version 1.4.4 (2026-05-12)
 
 Bug-fix release. Notable code changes resolving findings in `docs/revise-1.4.3.md`:

+ 16 - 1
studiou-wc-ord-print-statuses/README.md

@@ -3,7 +3,7 @@
 WordPress plugin that extends WooCommerce with a dedicated workflow for managing print orders sent to third-party print providers. It adds custom order statuses, tracking fields, CSV export, protocol-based CSV imports, and search/filter capabilities tailored to a print fulfillment pipeline.
 
 - **Plugin slug / text domain:** `studiou-wc-ord-print-statuses`
-- **Current version:** 1.4.4
+- **Current version:** 1.4.5
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 
@@ -197,6 +197,7 @@ Reference documents live under [`docs/`](docs/):
 - [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Analytical review of v1.4.1 (all in-scope findings addressed in 1.4.2).
 - [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2 (all in-scope findings addressed in 1.4.3).
 - [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) — Fresh-eye analytical review of v1.4.3 (all in-scope findings addressed in 1.4.4).
+- [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) — Fresh-eye analytical review of v1.4.4 (mediums + lows addressed in 1.4.5; carried-over deferrals documented).
 
 Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
 
@@ -215,9 +216,23 @@ Project-internal guidance for AI-assisted development is documented in [`CLAUDE.
 - **MySQL `group_concat_max_len`** is raised to 64 KB via `SET SESSION` per export — connection-scoped, harmless but worth knowing if you maintain custom queries on the same connection.
 - **Excel CS-locale CSV separator** — the export uses `,` as the field separator. Czech Excel defaults to `;` as the list separator and may render the file as one column. Workaround: open with the Text Import wizard or set Windows regional list-separator to `,`. Other consumers (LibreOffice Calc, Numbers, programmatic CSV parsers) handle the comma natively.
 - **Trashed/draft products in historical orders** still appear in the export. Order line items reference product IDs; the export joins by ID without filtering on `post_status`. The intended behaviour: show what was sold, even if the product has since been removed from the catalogue.
+- **MySQL collation mismatch on the variation-attribute slug join** (`terms.slug = postmeta.meta_value`) is theoretically possible on databases that have been migrated across MySQL versions with mixed per-table collations. On standard `utf8mb4_unicode_ci` installs the join works fine. If `ERROR 1267 (Illegal mix of collations)` ever surfaces on the bulk export, the fix is to add an explicit `COLLATE` clause to the join condition.
 
 ## Changelog
 
+### 1.4.5 — 2026-05-12
+
+Defensive-hardening release addressing the medium and low findings in [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md):
+
+- **Fixed:** `Studiou_DB_Manager::normalise_csv_date()` fast path now validates date and time ranges with `checkdate()` + hour/minute/second bounds. Structurally-valid but semantically-bogus inputs like `"2026-13-45T25:99"` fall through to the `DateTimeImmutable` fallback, which throws → caught → empty string returned. Mirrors the validation pattern already in `Order_Fields_Manager::datetime_local_to_mysql`.
+- **Fixed:** `Import_Manager::emit_import_summary()` now surfaces an info notice ("The CSV file contained no actionable rows (empty order_no column).") when both `$updated` and `$errors` are zero — closes the silent-import case where every CSV row had an empty `order_no` column.
+- **Fixed:** `Studiou_DB_Manager::csv_escape()` now `ltrim`s before checking the first character. Cells like `" =CMD"` — which some spreadsheet apps strip and evaluate — are no longer slipping past the formula-injection defence.
+- **Fixed:** `Studiou_DB_Manager::parse_csv()` rejects CSVs whose normalised headers collide (e.g., two columns both ending up as `order_no` after whitespace/case normalisation). Previously `array_combine` silently overwrote, losing one column's data. Now logs and returns empty so the downstream import bails with the existing "could not be parsed" notice.
+- **Changed:** `bulk_set_print_status` order-note selection lifted from a binary ternary to an explicit `array(slug => note)` map. Adding a third status in the future no longer risks inheriting the wrong note via the else branch.
+- **Doc:** [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) updated with per-item status annotations. README "Known limitations" gains an entry for the theoretical MySQL collation mismatch on the slug join (§3.4 of the review).
+
+> **Deferred** (low-impact, carried over): main plugin instance lifecycle (architectural, no functional impact), `woocommerce_missing_notice` mid-request re-fire (edge case), collation mismatch (theoretical, no production occurrence).
+
 ### 1.4.4 — 2026-05-12
 
 Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md):

+ 260 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.4.md

@@ -0,0 +1,260 @@
+# Plugin Revision — v1.4.4 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.5 (2026-05-12):** all four mediums and items §4.1, §4.2 are resolved in plugin version **1.4.5**. §4.3 and §4.4 are documented and deferred (architectural / edge case). §3.4 is documented in README "Known limitations" without a code change. Each finding carries an inline ✅ resolved / ⏸ deferred status marker.
+
+**Scope:** Independent read-only analytical pass over the v1.4.4 codebase. Read with deliberate scepticism toward the changes introduced in 1.4.4 itself, looking for regressions and gaps the previous reviews missed.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap, the markdown docs, and the `.po` translation source.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact.
+
+> **Headline:** no critical findings in this round. The plugin is in a steady state. Four medium-severity items and four low-severity items remain — most are defensive improvements rather than active defects. Two of the mediums (§3.1, §3.3) are *gaps* in fixes I introduced in 1.4.4 itself.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High
+
+*(None.)*
+
+---
+
+## 3. Medium — gaps and asymmetries
+
+### 3.1 `normalise_csv_date` fast-path lacks range validation — asymmetric with `datetime_local_to_mysql` — ✅ resolved in 1.4.5
+
+Resolution: the fast path now runs `checkdate($month, $day, $year)` plus explicit hour ≤ 23 / minute ≤ 59 / second ≤ 59 bounds. Inputs that match the regex structurally but fall outside the valid ranges (e.g., `"2026-13-45T25:99"`) drop through to the `DateTimeImmutable` fallback, which throws and routes to the catch → empty string + logged.
+
+`class-db-manager.php:459-462`:
+
+```php
+if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+    $second = isset($m[6]) ? $m[6] : '00';
+    return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . $second;
+}
+```
+
+The regex matches structurally — `"2026-13-45T25:99"` passes — but doesn't validate that the date is real or that the time components are in range. The fast-path returns `"2026-13-45 25:99:00"`, which is then stored to postmeta as-is.
+
+Compare with `Order_Fields_Manager::datetime_local_to_mysql` (`class-order-fields-manager.php:118-131`), which I extended in 1.4.3 to add `checkdate()` plus explicit hour/minute/second bounds. **The same defence belongs on the import side**, because:
+
+- A CSV from the print shop's system could realistically include malformed dates (data-entry typo, locale mismatch, broken upstream tool).
+- The bogus stored value round-trips through display: `Order_Fields_Manager::mysql_to_datetime_local` matches the regex but produces nonsense (`2026-13-45T25:99`), which the browser renders as an empty input. The operator can't tell anything happened until they save the order — at which point `datetime_local_to_mysql` finally rejects it.
+
+**Suggested fix** — apply the same validation in the fast path:
+
+```php
+if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+    list(, $y, $mo, $d, $h, $mi) = $m;
+    $s = isset($m[6]) ? $m[6] : '00';
+    if (!checkdate((int) $mo, (int) $d, (int) $y) || (int) $h > 23 || (int) $mi > 59 || (int) $s > 59) {
+        // Fall through to the DateTimeImmutable path, which will throw on
+        // unparseable values and reach our catch.
+        return self::normalise_csv_date_fallback($raw);
+    }
+    return $y . '-' . $mo . '-' . $d . ' ' . $h . ':' . $mi . ':' . $s;
+}
+```
+
+…or simpler: drop the fast path entirely and always use `DateTimeImmutable($raw, wp_timezone())`. The fast path was an optimisation; the fallback handles the common case correctly too.
+
+### 3.2 `emit_import_summary` is silent when 0 successes and 0 errors — ✅ resolved in 1.4.5
+
+Resolution: added an `elseif (empty($errors))` branch that emits an info notice ("The CSV file contained no actionable rows (empty order_no column).") so the operator gets feedback even when dedup-by-order_no filters out every row.
+
+`class-import-manager.php:172-209`. The 1.4.4 change gated the "%d orders updated successfully" notice on `$updated > 0`. That's correct *when* there are errors to surface, but if both counters are zero the operator sees nothing.
+
+How can both be zero? `run_import` already bails earlier when `$csv_data` is empty (line 121). But `dedupe_by_order_no` (line 413) further filters out rows whose `order_no` is empty after `trim()`. A CSV with header + N rows where every row has an empty `order_no` column produces `count($csv_data) === N` but yields zero rows after dedup. No order_no → no `wc_get_order` lookup → no error entry → zero on both sides.
+
+The operator gets no feedback that the import "succeeded" (it ran without error) but did nothing.
+
+**Suggested fix** — add an else branch:
+
+```php
+} elseif (empty($errors)) {
+    UtilsLog::message(
+        __('The CSV file contained no actionable rows (empty order_no column).', 'studiou-wc-ord-print-statuses'),
+        'info'
+    );
+}
+```
+
+### 3.3 `csv_escape` checks only the literal first character — leading-whitespace bypass — ✅ resolved in 1.4.5
+
+Resolution: `csv_escape` now calls `ltrim` and checks the first non-whitespace character. Cells like `" =CMD"` are now prefixed with `'`.
+
+`class-db-manager.php:174-186`:
+
+```php
+$first = $value[0];
+if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
+    return "'" . $value;
+}
+```
+
+Cells beginning with a space followed by a danger character (e.g., `" =CMD"`) slip past — `$value[0]` is the space. Some spreadsheet apps trim leading whitespace before evaluating, others don't; behaviour varies by version and config.
+
+**Vector likelihood:** low in this codebase — the danger-prone fields are `prod_name`, `prod_var`, `prod_cat`, `email`. Product / category names are admin-controlled. `email` is customer-controlled but WC's email validation rejects strings starting with space-equals.
+
+Still, the OWASP recommendation is to check the value after `ltrim`, not just `$value[0]`. One-line fix:
+
+```php
+$trimmed = ltrim($value);
+if ($trimmed !== '' && in_array($trimmed[0], array('=', '+', '-', '@', "\t", "\r"), true)) {
+    return "'" . $value;
+}
+```
+
+### 3.4 Possible collation mismatch on `terms.slug = postmeta.meta_value` — ⏸ documented in README "Known limitations"
+
+Resolution path: the issue is theoretical and hasn't been observed in production. Adding an explicit `COLLATE` clause to the join condition risks breaking installs whose actual collation differs from whatever we'd hardcode. The pragmatic choice is to document the failure mode (which manifests as `ERROR 1267 (Illegal mix of collations)` from the bulk export) so future operators can add the fix if it surfaces. README "Known limitations" now mentions this.
+
+`class-db-manager.php:104-105`:
+
+```sql
+LEFT JOIN `{$wpdb->terms}` `product_variations_name`
+    ON `product_variations_name`.`slug` = `product_variations_attr`.`meta_value`
+```
+
+`terms.slug` is `VARCHAR(200)`; `postmeta.meta_value` is `LONGTEXT`. Both columns *should* use `utf8mb4_unicode_ci` on a default WP install, but installs that have been around long enough to have been migrated across MySQL versions (or had per-table collation changes applied) can end up with mismatched collations.
+
+When MySQL encounters a join condition between columns with incompatible collations, it can either:
+- Throw `ERROR 1267 (HY000): Illegal mix of collations` and abort the query, or
+- Implicitly coerce via temporary string conversion, which can silently change case sensitivity / accent handling for that join.
+
+The export query has no `COLLATE` clause, so it inherits the DB-default collation. Most production sites won't be affected — Studiou's install presumably wasn't, given that earlier 1.3.2 / 1.4.0 / 1.4.1 reviews didn't surface this. Worth noting as an unbounded edge case rather than a confirmed bug.
+
+**Mitigation** (only if it becomes a problem): add `COLLATE utf8mb4_unicode_ci` to the JOIN condition.
+
+---
+
+## 4. Low
+
+### 4.1 `bulk_set_print_status` note selection is a binary ternary — implicit else — ✅ resolved in 1.4.5
+
+Resolution: lifted to an explicit `array('to-print' => …, 'in-print' => …)` map with `isset` fallback to empty string. Future contributors adding a third slug get a no-op note rather than the wrong note.
+
+`class-db-manager.php:229-231`:
+
+```php
+$note = ($status_slug === 'to-print')
+    ? __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses')
+    : __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses');
+```
+
+The function is only ever called with `'to-print'` or `'in-print'`, so the else branch is correct today. If a future contributor extends the helper to a third status, the else-implicit selection of "In Printing" would silently produce a misleading note. Defensive option: a small dispatch map.
+
+### 4.2 `parse_csv` doesn't detect duplicate headers — ✅ resolved in 1.4.5
+
+Resolution: after `array_map(normalise_header, $headers)`, `parse_csv` compares `count($headers)` against `count(array_unique($headers))` and bails (with an `UtilsLog::log` entry naming the headers) on mismatch. The downstream import then emits the existing "could not be parsed" notice.
+
+`class-db-manager.php:391-397`:
+
+```php
+$headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
+while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
+    if (count($data) !== count($headers)) {
+        continue;
+    }
+    $csv_data[] = array_combine($headers, $data);
+}
+```
+
+If the CSV has two columns sharing a normalised name (e.g., `Order No` and `order_no` both normalising to `order_no`), `array_combine` silently overwrites — the second column's data wins for every row, the first column's data is lost. The user gets no warning.
+
+Realistic? Unlikely with a well-formed protocol CSV. Defensive — add `count($headers) === count(array_unique($headers))` check and abort with a clear notice if not.
+
+### 4.3 Main plugin instance is short-lived; managers persist via the WP filter array — ⏸ left as-is (architectural)
+
+Resolution path: refactoring would require either making the main instance a singleton (with `getInstance()`) or moving manager initialisation to a static method. Both are larger refactors than the symptom warrants — there is no functional impact, only a documentation concern. Worth a CLAUDE.md note if the lifecycle pattern surprises a future contributor.
+
+`studiou-wc-ord-print-statuses.php:47-50`:
+
+```php
+public static function initPlugin() {
+    $class = __CLASS__;
+    new $class;
+}
+```
+
+The new instance has no global / static reference. After `__construct()` calls `init()` and `init()` returns, the local `$class` variable goes out of scope and PHP's reference counter for the main instance hits zero. The instance is GC'd.
+
+The managers stored in `$this->order_status_manager` etc. would normally be GC'd along with the main instance — *except* each manager's constructor registers WP hooks with `array($this, '...')`, which adds references to the manager instances inside the global `$wp_filter` array. Those references keep the managers alive for the rest of the request.
+
+So the private `$order_status_manager` / `$order_fields_manager` / etc. fields on `Studiou_WC_Ord_Print_Statuses` are effectively *decorative* — they only matter during the few microseconds of `initialize_managers()`. After `init()` returns they're inaccessible.
+
+**Implications:** none in normal flow. But code that tries to look up "the plugin's order status manager" via `Studiou_WC_Ord_Print_Statuses` (e.g., a third-party hook integrating with this plugin) will find no instance. The pattern is unusual and worth documenting.
+
+### 4.4 `woocommerce_missing_notice` registered conditionally but never unregistered — ⏸ left as-is (edge case)
+
+`studiou-wc-ord-print-statuses.php:54-60`. If WC is not yet active when our `init()` runs, the notice hook is registered. If WC becomes active later in the same request (extremely unlikely — WC would have to be activated mid-request by another plugin's hook), the notice would still display once on the next admin page load. Edge case.
+
+---
+
+## 5. Verification gaps (carried over from earlier reviews)
+
+1. **HPOS search filter name** `woocommerce_order_table_search_query_meta_keys` against WC 10.7.0 source. The legacy filter `woocommerce_shop_order_search_fields` may not fire under HPOS at all — making it not a fallback. Smoke test by adding a temporary `error_log` inside the callback and observing whether the HPOS path actually fires.
+2. **`woocommerce_process_shop_order_meta`** behaviour under the WC 10.7.0 block-based order edit screen.
+3. **Object-cache scenarios for `UtilsLog::message`** (Redis/Memcached).
+
+---
+
+## 6. Status of prior reviews
+
+| Review | Resolved | Carried-over deferrals |
+|--------|----------|-------------------------|
+| `revise-1.3.2.md` | 1.4.0 | 3 (`Network:` header, uninstall, README phrasing). |
+| `revise-1.4.0.md` | 1.4.1 | 1 (HPOS filter verification). |
+| `revise-1.4.1.md` | 1.4.2 | 2 (custom-order-number plugins, register_post_status). |
+| `revise-1.4.2.md` | 1.4.3 | 1 (Excel CS-locale CSV separator). |
+| `revise-1.4.3.md` | 1.4.4 | (listener save() re-entrancy, bulk/listener double-stamp — both intentional, documented; csv_escape on negatives — OWASP trade-off; "—" literal column, `\r`-only line endings, `set_orders_to_*` API surface — vanishingly rare). |
+
+**No new regressions introduced by 1.4.4** in the strict sense. §3.1 and §3.3 are *under-applied defences* — patterns that should have extended to the import path / been stricter in csv_escape — rather than active bugs.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.5**:
+
+1. **§3.1 `normalise_csv_date` fast-path missing range validation.** ✅ resolved.
+2. **§3.2 Silent import when all rows have empty `order_no`.** ✅ resolved.
+3. **§3.3 `csv_escape` leading-whitespace bypass.** ✅ resolved.
+4. **§3.4 Collation mismatch on slug join.** ⏸ documented in README "Known limitations".
+5. **§4.1 Binary note selection in `bulk_set_print_status`.** ✅ resolved.
+6. **§4.2 Duplicate-header detection in `parse_csv`.** ✅ resolved.
+7. **§4.3 Main plugin instance lifecycle.** ⏸ left as-is (architectural, no functional impact).
+8. **§4.4 `woocommerce_missing_notice` re-fire.** ⏸ left as-is (edge case).
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling under realistic order volumes.
+- Behavioural verification on the WC 10.x block-based order edit screen.
+- Multisite, uninstall, custom-order-number plugin integration (carried-over deferrals).
+- Verifying every `msgstr` in the refreshed `.po` against the running plugin.
+
+---
+
+## 9. Process observation
+
+The "single new HIGH per fresh-eye pass" pattern from 1.4.0 → 1.4.3 has tailed off. This pass surfaces only mediums and lows. Either:
+
+- The plugin has genuinely reached a stable state (likely), or
+- I'm developing scope blindness on this codebase after seven reviews and four fix-up releases (possible — fresh-eye reviews work best with rotating reviewers).
+
+If a second reviewer is available, this is a good moment for an outside pass. Otherwise, the diminishing-returns curve suggests the next iteration of fixes should focus on the carried-over verification gaps (live testing against WC 10.7.0) rather than another static review cycle.
+
+---
+
+*End of review.*

+ 4 - 1
studiou-wc-ord-print-statuses/docs/translations.txt

@@ -1,5 +1,5 @@
 Translations: all user-facing strings (English → Czech).
-Last reviewed: v1.4.4 (2026-05-12).
+Last reviewed: v1.4.5 (2026-05-12).
 
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
@@ -81,3 +81,6 @@ Marked In Printing by bulk action.
 
 # New in 1.4.4 — HPOS table-missing admin notice
 Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support.  Export vyžaduje WooCommerce HPOS (vlastní tabulky objednávek). Aktivujte High-Performance Order Storage v WooCommerce → Nastavení → Pokročilé → Funkce, nebo kontaktujte podporu.
+
+# New in 1.4.5 — import-summary info notice when dedup filters out every row
+The CSV file contained no actionable rows (empty order_no column).                          CSV soubor neobsahoval žádné použitelné řádky (sloupec order_no je prázdný).

+ 39 - 6
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -170,6 +170,10 @@ class Studiou_DB_Manager {
      * prefixing values that begin with =, +, -, @, tab, or CR with a single
      * quote. OWASP-recommended pattern. The leading quote is hidden by
      * Excel when displayed but disables formula evaluation.
+     *
+     * Checks the first non-whitespace character (not just $value[0]) so
+     * cells like " =CMD" — with leading whitespace some spreadsheet apps
+     * strip before evaluating — are also defused.
      */
     private static function csv_escape($value) {
         if (!is_string($value)) {
@@ -178,7 +182,11 @@ class Studiou_DB_Manager {
         if ($value === '') {
             return $value;
         }
-        $first = $value[0];
+        $trimmed = ltrim($value);
+        if ($trimmed === '') {
+            return $value;
+        }
+        $first = $trimmed[0];
         if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
             return "'" . $value;
         }
@@ -226,9 +234,15 @@ class Studiou_DB_Manager {
         // "Marked … by … Protocol import." pattern used in the import
         // handlers so the audit trail consistently records *how* a
         // transition was triggered.
-        $note = ($status_slug === 'to-print')
-            ? __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses')
-            : __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses');
+        //
+        // Explicit map keyed by slug so a future contributor adding a
+        // third status doesn't accidentally inherit the wrong note via
+        // an else-implicit ternary.
+        $notes = array(
+            'to-print' => __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
+            'in-print' => __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
+        );
+        $note = isset($notes[$status_slug]) ? $notes[$status_slug] : '';
 
         foreach ((array) $order_ids as $order_id) {
             $order_id = (int) $order_id;
@@ -389,6 +403,14 @@ class Studiou_DB_Manager {
                 return $csv_data;
             }
             $headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
+            // Reject CSVs whose headers collide after normalisation (e.g.,
+            // "Order No" and "order_no" both → "order_no"). array_combine
+            // would silently let the later column's data overwrite the
+            // earlier — losing one column's values entirely.
+            if (count($headers) !== count(array_unique($headers))) {
+                UtilsLog::log('parse_csv: duplicate column headers after normalisation: ' . implode(', ', $headers));
+                return $csv_data;
+            }
             while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
                 if (count($data) !== count($headers)) {
                     continue;
@@ -457,9 +479,20 @@ class Studiou_DB_Manager {
             return '';
         }
         // Fast path — common ISO-ish shape with no explicit timezone.
+        // checkdate() + h/m/s bounds reject structurally-valid but
+        // semantically-out-of-range inputs (e.g., "2026-13-45T25:99")
+        // so they fall through to the DateTimeImmutable path, which
+        // will throw and route us to the catch.
         if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
-            $second = isset($m[6]) ? $m[6] : '00';
-            return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . $second;
+            $year   = (int) $m[1];
+            $month  = (int) $m[2];
+            $day    = (int) $m[3];
+            $hour   = (int) $m[4];
+            $minute = (int) $m[5];
+            $second = isset($m[6]) ? (int) $m[6] : 0;
+            if (checkdate($month, $day, $year) && $hour <= 23 && $minute <= 59 && $second <= 59) {
+                return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . sprintf('%02d', $second);
+            }
         }
         // Fallback — let DateTimeImmutable handle locale dates and explicit
         // timezones. Strict: throws ValueError on garbage (e.g. "foo").

+ 9 - 0
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -189,6 +189,15 @@ class Import_Manager {
                 ),
                 'success'
             );
+        } elseif (empty($errors)) {
+            // Both counters zero: the CSV parsed and had rows, but
+            // dedupe_by_order_no filtered everything out — every row had
+            // an empty order_no column. Surface this so the operator
+            // isn't left wondering whether the import ran.
+            UtilsLog::message(
+                __('The CSV file contained no actionable rows (empty order_no column).', 'studiou-wc-ord-print-statuses'),
+                'info'
+            );
         }
 
         if (!empty($errors)) {

+ 6 - 1
studiou-wc-ord-print-statuses/languages/studiou-wc-ord-print-statuses-cs_CZ.po

@@ -2,7 +2,7 @@
 # This file is distributed under the GPL2 license.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.4.4\n"
+"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.4.5\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses\n"
 "POT-Creation-Date: 2026-05-12 00:00:00+00:00\n"
 "PO-Revision-Date: 2026-05-12 00:00:00+00:00\n"
@@ -192,3 +192,8 @@ msgstr "Označeno jako \"Příprava Tisku\" hromadnou akcí."
 
 msgid "Marked In Printing by bulk action."
 msgstr "Označeno jako \"V Tisku\" hromadnou akcí."
+
+# --- Import-summary info notice (new in 1.4.5) ---
+
+msgid "The CSV file contained no actionable rows (empty order_no column)."
+msgstr "CSV soubor neobsahoval žádné použitelné řádky (sloupec order_no je prázdný)."

+ 2 - 2
studiou-wc-ord-print-statuses/studiou-wc-ord-print-statuses.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Order Print Statuses
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses
  * Description: Adds custom order statuses (wc-to-print, wc-in-print), bulk actions, and status change timestamps to WooCommerce for print orders to third party providers
- * Version: 1.4.4
+ * Version: 1.4.5
  * Requires at least: 5.8
  * Tested up to: 6.9.4
  * Requires PHP: 7.2
@@ -19,7 +19,7 @@
 
 defined('ABSPATH') || exit;
 
-define('STUDIOU_WC_OPS_VERSION', '1.4.4');
+define('STUDIOU_WC_OPS_VERSION', '1.4.5');
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
 // Declare HPOS (High-Performance Order Storage) compatibility.