瀏覽代碼

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 月之前
父節點
當前提交
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:**
 **Plugin Details:**
 - Text Domain: `studiou-wc-ord-print-statuses`
 - 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+
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 - 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.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.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.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.
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 
 ## Recent Changes
 ## 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)
 ### Version 1.4.4 (2026-05-12)
 
 
 Bug-fix release. Notable code changes resolving findings in `docs/revise-1.4.3.md`:
 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.
 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`
 - **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)
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 - **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.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.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.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).
 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.
 - **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.
 - **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.
 - **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
 ## 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
 ### 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):
 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).
 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:
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
   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
 # 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.
 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
      * prefixing values that begin with =, +, -, @, tab, or CR with a single
      * quote. OWASP-recommended pattern. The leading quote is hidden by
      * quote. OWASP-recommended pattern. The leading quote is hidden by
      * Excel when displayed but disables formula evaluation.
      * 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) {
     private static function csv_escape($value) {
         if (!is_string($value)) {
         if (!is_string($value)) {
@@ -178,7 +182,11 @@ class Studiou_DB_Manager {
         if ($value === '') {
         if ($value === '') {
             return $value;
             return $value;
         }
         }
-        $first = $value[0];
+        $trimmed = ltrim($value);
+        if ($trimmed === '') {
+            return $value;
+        }
+        $first = $trimmed[0];
         if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
         if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
             return "'" . $value;
             return "'" . $value;
         }
         }
@@ -226,9 +234,15 @@ class Studiou_DB_Manager {
         // "Marked … by … Protocol import." pattern used in the import
         // "Marked … by … Protocol import." pattern used in the import
         // handlers so the audit trail consistently records *how* a
         // handlers so the audit trail consistently records *how* a
         // transition was triggered.
         // 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) {
         foreach ((array) $order_ids as $order_id) {
             $order_id = (int) $order_id;
             $order_id = (int) $order_id;
@@ -389,6 +403,14 @@ class Studiou_DB_Manager {
                 return $csv_data;
                 return $csv_data;
             }
             }
             $headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
             $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) {
             while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
                 if (count($data) !== count($headers)) {
                 if (count($data) !== count($headers)) {
                     continue;
                     continue;
@@ -457,9 +479,20 @@ class Studiou_DB_Manager {
             return '';
             return '';
         }
         }
         // Fast path — common ISO-ish shape with no explicit timezone.
         // 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)) {
         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
         // Fallback — let DateTimeImmutable handle locale dates and explicit
         // timezones. Strict: throws ValueError on garbage (e.g. "foo").
         // 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'
                 '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)) {
         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.
 # This file is distributed under the GPL2 license.
 msgid ""
 msgid ""
 msgstr ""
 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"
 "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"
 "POT-Creation-Date: 2026-05-12 00:00:00+00:00\n"
 "PO-Revision-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."
 msgid "Marked In Printing by bulk action."
 msgstr "Označeno jako \"V Tisku\" hromadnou akcí."
 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 Name: QDR - Studiou WC Order Print Statuses
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-ord-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
  * 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
  * Requires at least: 5.8
  * Tested up to: 6.9.4
  * Tested up to: 6.9.4
  * Requires PHP: 7.2
  * Requires PHP: 7.2
@@ -19,7 +19,7 @@
 
 
 defined('ABSPATH') || exit;
 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__);
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
 
 // Declare HPOS (High-Performance Order Storage) compatibility.
 // Declare HPOS (High-Performance Order Storage) compatibility.