Ver código fonte

Release v1.4.4 — resolve every in-scope finding from revise-1.4.3

Adds docs/revise-1.4.3.md (fresh-eye review of 1.4.3) alongside the
fixes for the issues it raised.

High:
- normalise_csv_date no longer shifts timezone-less CSV dates by the WP
  offset. The previous strtotime + wp_date pipeline interpreted naive
  input as UTC (because WP sets PHP timezone to UTC) and then formatted
  in WP-local, producing a 2-hour forward shift on Prague installs. Now
  uses pure-regex reformat for ISO-like inputs (no shift) and
  DateTimeImmutable(raw, wp_timezone)->setTimezone(wp_timezone) for the
  fallback. strtotime is gone from includes/ entirely — the recurring
  three-release timezone-bug pattern is structurally eliminated.

Medium:
- HPOS pre-flight check: get_orders_for_printing bails with a clear
  admin notice when {$wpdb->prefix}wc_orders is missing, parallel to
  the existing wc_order_product_lookup check.
- bulk_set_print_status now passes an explanatory note to update_status
  ("Marked Prepare to Printing by bulk action." / "Marked In Printing
  by bulk action."), matching the import paths.

Low:
- Custom_Columns_Manager uses the WC_Order object directly when WC
  hands one to the column-content callback (HPOS path, no wc_get_order
  call) and memoises by ID under legacy CPT (per-request $order_cache).
- Import_Manager::emit_import_summary gates the "%d orders updated
  successfully" notice on $updated > 0, matching notify_moved_count.
- run_import switched from call_user_func to direct callable invocation.
- before_woocommerce_init HPOS-compat callback converted from anonymous
  to named (studiou_wc_ord_print_statuses_declare_hpos_compat) so it
  can be remove_action-ed by other plugins / test harnesses.

Docs:
- README "Changelog" extends with the 1.4.4 entry; pointer to
  docs/revise-1.4.3.md added under Documentation.
- CLAUDE.md picks up the 1.4.4 code changes.
- docs/revise-1.4.3.md is the fresh-eye review of 1.4.3; every finding
  carries an inline ✅ resolved / ⏸ deferred status marker, plus a
  confirmation footnote that strtotime is now grep-clean.
- docs/translations.txt: header bumped to 1.4.4; three new EN→CS
  entries (two bulk-action notes + HPOS-required notice).
- languages/studiou-wc-ord-print-statuses-cs_CZ.po: Project-Id-Version
  bumped to 1.4.4; same three new translation entries.

Deferred (carried over): listener save() re-entrancy (documented), bulk
vs listener double-stamp (intentional), csv_escape on negative numbers
(OWASP trade-off), "—" literal column edge case, \r-only line endings,
HPOS search-filter live verification, Network: header, uninstall.php,
custom-order-number plugin integration, register_post_status under
HPOS, block-based order edit screen.

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 meses atrás
pai
commit
ae7e82fc06

+ 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.3
+- Current Version: 1.4.4
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
 - Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
 
@@ -24,11 +24,24 @@ User-facing and historical documentation lives at the project root and under `do
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0. All in-scope findings are resolved in 1.4.1; the doc carries per-item status annotations.
 - [`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.
 
 When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 ## Recent Changes
 
+### Version 1.4.4 (2026-05-12)
+
+Bug-fix release. Notable code changes resolving findings in `docs/revise-1.4.3.md`:
+
+- **`Studiou_DB_Manager::normalise_csv_date()`** rewritten — drops `strtotime` entirely. Fast path: regex reformat for ISO-like inputs (no timezone shift). Fallback: `DateTimeImmutable($raw, wp_timezone())->setTimezone(wp_timezone())`. Eliminates the WP-UTC-default + wp_date timezone double-handling that caused a Prague-shifted 14:30 → 16:30 drift in 1.4.2/1.4.3. **Zero `strtotime` calls in `includes/` now** — the recurring bug pattern is structurally eliminated.
+- **HPOS table pre-check.** `get_orders_for_printing()` now bails with an admin notice when `{$wpdb->prefix}wc_orders` is missing (HPOS disabled), parallel to the existing check on `wc_order_product_lookup`.
+- **Bulk-action order notes.** `bulk_set_print_status` passes "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." as the `update_status` note. Audit trail now consistent across bulk and import paths.
+- **Column rendering perf.** `Custom_Columns_Manager` uses the WC_Order object directly under HPOS (no `wc_get_order` call) and memoises by ID per request under legacy CPT. Saves ~150 lookups per 50-orders page.
+- **Import summary suppression.** `Import_Manager::emit_import_summary()` skips the "%d orders updated successfully" notice when no rows changed.
+- **`call_user_func` → direct callable** in `run_import`.
+- **`before_woocommerce_init` callback** converted from anonymous to named (`studiou_wc_ord_print_statuses_declare_hpos_compat`) so it can be `remove_action`-ed.
+
 ### Version 1.4.3 (2026-05-12)
 
 Bug-fix release resolving the fresh-eye findings in `docs/revise-1.4.2.md`. Notable code changes:

+ 15 - 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.3
+- **Current version:** 1.4.4
 - **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
 - **License:** GPL v2 or later
 
@@ -196,6 +196,7 @@ Reference documents live under [`docs/`](docs/):
 - [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0 (all in-scope findings addressed in 1.4.1).
 - [`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).
 
 Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
 
@@ -217,6 +218,19 @@ Project-internal guidance for AI-assisted development is documented in [`CLAUDE.
 
 ## Changelog
 
+### 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):
+
+- **Fixed (high):** `Studiou_DB_Manager::normalise_csv_date()` was shifting timezone-less CSV dates by the WP timezone offset. WordPress sets the PHP timezone to UTC very early, so `strtotime("2026-05-12 14:30")` returned a UTC timestamp; `wp_date()` then formatted it in WP-local, producing a Prague-shifted "2026-05-12 16:30:00". Now uses pure-regex reformat for ISO-like inputs (no shift) and `DateTimeImmutable(..., wp_timezone())->setTimezone(wp_timezone())` for the fallback path (handles explicit-timezone inputs correctly). **No remaining `strtotime` calls in `includes/`** — the recurring timezone-bug pattern is now structurally eliminated.
+- **Fixed:** `Studiou_DB_Manager::get_orders_for_printing()` now pre-checks the HPOS `wc_orders` table parallel to the existing `wc_order_product_lookup` check. If HPOS is disabled the export bails with a clear admin notice ("Export requires WooCommerce HPOS … Enable High-Performance Order Storage …") instead of failing with a cryptic SQL error.
+- **Fixed:** `bulk_set_print_status` now passes an explanatory note to `update_status` — "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." — matching the audit-trail pattern the import paths use.
+- **Changed:** `Custom_Columns_Manager` uses the `WC_Order` object directly when WC hands one to the column-content callback (HPOS path); under legacy CPT it memoises by ID per request so the same order isn't re-resolved across the three custom columns. Avoids 100–150 `wc_get_order` calls per 50-orders-per-page list.
+- **Changed:** `Import_Manager::emit_import_summary()` suppresses the "0 orders updated successfully" notice when no rows changed — the per-error notice stands on its own. Matches the `notify_moved_count` pattern.
+- **Changed:** `call_user_func($cfg['handler'], $csv_data)` → direct callable invocation. Tighter and slightly faster.
+- **Changed:** The `before_woocommerce_init` HPOS-compat declaration is now a named function (`studiou_wc_ord_print_statuses_declare_hpos_compat`). Can be `remove_action`-ed from outside.
+- **Doc:** [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) updated with per-item status annotations.
+
 ### 1.4.3 — 2026-05-12
 
 Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md):

+ 267 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.3.md

@@ -0,0 +1,267 @@
+# Plugin Revision — v1.4.3 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.4 (2026-05-12):** every in-scope finding below has been resolved in plugin version **1.4.4**. Each finding carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Independent read-only analytical pass over the v1.4.3 codebase. Re-read every PHP file from scratch and walked through user scenarios with deliberate scepticism toward fixes I made in the 1.4.2 / 1.4.3 cycles. The intent of this pass was specifically to look for *regressions introduced by recent fixes* rather than longer-standing issues.
+
+**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 `studiou-wc-ord-print-statuses.php`, the markdown docs, and the refreshed `.po` translation source.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High — newly discovered, introduced by recent fix-ups
+
+### 2.1 `Studiou_DB_Manager::normalise_csv_date()` shifts timezone-less CSV dates by the WP timezone offset — ✅ resolved in 1.4.4
+
+Resolution: `strtotime` removed entirely. Fast path uses pure-regex reformat for ISO-like inputs (no timezone shift). Fallback uses `DateTimeImmutable($raw, wp_timezone())->setTimezone(wp_timezone())` — interprets timezone-less input as WP local, honours explicit timezones, throws on garbage. The structural fix recommended in the review's §9 (zero `strtotime` calls in `includes/`) is in effect.
+
+`class-db-manager.php:426-437`:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') {
+        return '';
+    }
+    $timestamp = strtotime($raw);
+    if (!$timestamp) {
+        UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+        return '';
+    }
+    return wp_date('Y-m-d H:i:s', $timestamp);
+}
+```
+
+This was changed from `date(...)` to `wp_date(...)` in 1.4.2 with the stated intent of "matching `current_time('mysql')` elsewhere". **The change introduced a timezone shift bug.** Trace:
+
+WordPress calls `date_default_timezone_set('UTC')` very early in its boot. So `strtotime($raw)` for a timezone-less input string interprets it as UTC. Then `wp_date($fmt, $timestamp)` treats its `$timestamp` argument as a UTC value and formats it in the WP site timezone.
+
+Concrete walkthrough with WP site timezone `Europe/Prague` (UTC+2 in summer):
+
+1. CSV `externalorderdate` cell: `"2026-05-12 14:30"` — the print shop wrote this intending Prague-local 14:30.
+2. `strtotime("2026-05-12 14:30")` in WP's UTC-default PHP context → timestamp T (representing 14:30 UTC).
+3. `wp_date('Y-m-d H:i:s', T)` → formatted in Prague timezone → `"2026-05-12 16:30:00"`.
+4. Stored to meta. Operator opening the order edit screen sees **16:30**. CSV said 14:30. **2-hour drift.**
+
+The same direction-of-drift bug as 1.4.0 §2.2 / 1.4.1 §2.1, just in a different function. The previous fix-pass made `Order_Fields_Manager::mysql_to_datetime_local` and `datetime_local_to_mysql` use pure string reformatting (no `strtotime`/`date`/`wp_date`) precisely to avoid this. `Custom_Columns_Manager::format_meta_date` was rebuilt with `DateTimeImmutable(..., wp_timezone())` for the same reason. **`normalise_csv_date` was missed.**
+
+**Suggested fix** — fast-path the common "no explicit timezone" pattern with pure string reformat, fall back to `DateTimeImmutable` with `wp_timezone()` for everything else:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') {
+        return '';
+    }
+    // Fast path: ISO-ish date with no explicit timezone — treat as WP local
+    // time, no shift.
+    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;
+    }
+    // Fallback: parse in WP timezone (no shift on naive input), convert
+    // to WP timezone (handles explicit-TZ input by shifting to local).
+    try {
+        $dt = (new DateTimeImmutable($raw, wp_timezone()))->setTimezone(wp_timezone());
+        return $dt->format('Y-m-d H:i:s');
+    } catch (Exception $e) {
+        UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+        return '';
+    }
+}
+```
+
+This restores the documented invariant: imported `external_ref_ord_date` reads back at the value the print shop sent.
+
+The drift was hidden in 1.4.2 because `Custom_Columns_Manager::format_meta_date` had its own *opposite* drift bug — the two cancelled out in the columns view. Now that the columns view is correct (1.4.2), the import-side drift is the visible asymmetry.
+
+---
+
+## 3. Medium — pre-existing or longstanding
+
+### 3.1 Plugin assumes HPOS without enforcing it — ✅ resolved in 1.4.4
+
+Resolution: `get_orders_for_printing()` now runs `table_exists("{$wpdb->prefix}wc_orders")` parallel to the existing `wc_order_product_lookup` check. If HPOS is disabled, the export bails with an admin notice ("Export requires WooCommerce HPOS … Enable High-Performance Order Storage …") instead of failing with a cryptic SQL error.
+
+`class-db-manager.php:84` references `{$wpdb->prefix}wc_orders` (the HPOS custom table). The bootstrap declares HPOS compatibility:
+
+```php
+FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+```
+
+The third argument is `$compatible=true`, meaning "this plugin works correctly with HPOS". It does *not* mean "this plugin requires HPOS". If a site has HPOS turned off (legacy CPT storage only), the `wc_orders` table either doesn't exist or is unsynced. The export query then either:
+
+- Errors with "Table 'wp_wc_orders' doesn't exist" — caught by the `$results === false` branch, logged but user-visible as "no exportable items".
+- Returns rows from an incomplete or stale lookup — the export is incorrect but emits no warning.
+
+The plugin has a precedent for surfacing this kind of dependency: `table_exists($wpdb->prefix . 'wc_order_product_lookup')` already runs at the top of the same method (1.4.2 §3.7). A parallel check for `wc_orders` would close the gap, or the plugin could refuse to load entirely under legacy CPT and surface an admin notice à la `woocommerce_missing_notice`.
+
+The Search Manager has fallback (`woocommerce_shop_order_search_fields` for legacy CPT alongside the HPOS filter), but the export path doesn't. Asymmetric.
+
+### 3.2 `bulk_set_print_status` doesn't pass a note to `update_status`, while the import handlers do — ✅ resolved in 1.4.4
+
+Resolution: `bulk_set_print_status` now passes "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." to `update_status`. The audit trail in the order activity feed is now consistent across bulk-action and import paths.
+
+`class-db-manager.php:227`:
+
+```php
+$order->update_status($status_slug);
+```
+
+…versus `class-db-manager.php:280-283`:
+
+```php
+$order->update_status(
+    'in-print',
+    __('Marked In Printing by InPrint Protocol import.', 'studiou-wc-ord-print-statuses')
+);
+```
+
+Bulk-action-triggered transitions get WC's default "Status changed from X to Y" note. Import-triggered transitions get the explanatory `"Marked … by … import."` note. **Asymmetric audit trail.** The 1.4.3 release added explanatory notes to the import paths; the bulk paths were missed.
+
+Pass an explanatory note from `bulk_set_print_status` too — e.g., `"Marked … by bulk action."`.
+
+### 3.3 `normalise_csv_date` fallback (after fix) — `strtotime()` is permissive — ✅ resolved in 1.4.4
+
+Resolution: subsumed by §2.1's fix. `DateTimeImmutable` throws on inputs `strtotime` would silently misinterpret (`'foo'`, `'1'` etc.), so the catch-Exception branch logs and returns empty rather than persisting a meaningless timestamp.
+
+Independent of §2.1, `strtotime` returns a valid timestamp for many ambiguous inputs:
+
+- `strtotime('1')` → current year, January 1st.
+- `strtotime('next thursday')` → next Thursday.
+- `strtotime('foo bar')` → false (the only one that errors).
+
+If a print shop's CSV has a malformed date that happens to parse, the import silently stores a meaningless date. The current `if (!$timestamp)` guard only catches the `false` return value, not the "parsed but absurd" cases.
+
+The fix-suggestion in §2.1 — going through `DateTimeImmutable($raw, wp_timezone())` — is stricter; it throws on unparseable input rather than guessing.
+
+---
+
+## 4. Low — style and cosmetic
+
+### 4.1 Re-entrancy of `$order->save()` inside `handle_status_transitions` — ⏸ left as-is (forward-looking comment kept in code)
+
+The behaviour is correct under WC 10.7.0's caching semantics; the docblock in `class-order-status-manager.php:83-93` already explains the contract. Refactoring would require a more invasive design change to avoid the listener saving entirely.
+
+`class-order-status-manager.php:107-109`. The listener fires from within `woocommerce_order_status_changed`, which is itself inside `WC_Order::update_status()`. The listener calls `$order->save()` to persist the meta change. The parent path (bulk action, import handler, or manual edit) then calls its own `$order->save()` afterwards.
+
+Today this works because WC's data store returns the same object instance from `wc_get_order($id)` while the request is hot — both saves operate on the same object, the second save is a no-op or just re-saves the same state. If WC ever changes the cache semantics (e.g., to return fresh objects after a save), the parent and listener would operate on different instances and the meta change could be clobbered by the parent's save.
+
+Brittle but not broken. Worth a forward-looking comment.
+
+### 4.2 `bulk_set_print_status` and the listener both stamp the same meta key — ⏸ left as-is (intentional, documented)
+
+Acknowledged in `class-order-status-manager.php:71-77`'s docblock as intentional. The redundancy costs one extra `save()` per bulk-changed order, but the behaviour is correct. The 1.4.0 review §2.3 and the 1.4.1 review covered this.
+
+### 4.3 `Custom_Columns_Manager` calls `wc_get_order` per cell — ✅ resolved in 1.4.4
+
+Resolution: under HPOS the column-content callback receives the `WC_Order` object directly — `wc_get_order` is no longer called. Under legacy CPT a per-request `$order_cache` indexed by ID memoises the lookup so the three custom columns share one resolved instance per row.
+
+`class-custom-columns-manager.php:31`. On a 50-orders-per-page list with three custom columns, that's 150 calls per page load. WC's own object cache mitigates the cost, but a per-page-load static cache keyed by ID inside the manager would be tighter.
+
+### 4.4 `emit_import_summary` always shows "%d order(s) updated successfully" even when the count is 0 — ✅ resolved in 1.4.4
+
+Resolution: gated on `$updated > 0`, matching the pattern `Bulk_Actions_Manager::notify_moved_count` introduced in 1.4.3.
+
+`class-import-manager.php:175-186`. If 100% of rows errored, the operator sees "0 orders updated successfully" + the more useful error notice. The `notify_moved_count` helper in `Bulk_Actions_Manager` (1.4.3) already does the suppression-on-zero pattern; the import summary could adopt it.
+
+### 4.5 `add_action('before_woocommerce_init', function () {...})` uses an anonymous callback — ✅ resolved in 1.4.4
+
+Resolution: converted to a named function `studiou_wc_ord_print_statuses_declare_hpos_compat`. Other plugins / test harnesses can now `remove_action('before_woocommerce_init', 'studiou_wc_ord_print_statuses_declare_hpos_compat')` if they need to override.
+
+`studiou-wc-ord-print-statuses.php:26-30`. Anonymous functions cannot be unhooked from outside the file. If another plugin or test setup ever wants to disable the HPOS compatibility declaration, they can't. Cosmetic — converting to a named function is one line.
+
+### 4.6 `Studiou_DB_Manager::csv_escape` would prefix legitimate negative quantities with `'` — ⏸ left as-is
+
+Acknowledged trade-off — OWASP's recommendation to defuse formula injection is stronger than the cosmetic concern of a leading single quote on negative numbers (which Excel hides anyway). If negative quantities ever become a real workflow signal, csv_escape can be adjusted to whitelist numeric leading-minus.
+
+`class-db-manager.php:164-176`. A `qty` of `"-1"` (which shouldn't occur but might in refund-handling scenarios) starts with `-`, so `csv_escape` adds a leading single quote: `"'-1"`. Excel/LibreOffice display strips the quote so the user sees `-1` — no UX impact — but the underlying CSV bytes look unusual to programmatic consumers. The OWASP recommendation is consistent with this trade-off (defending against formula injection is more important than clean negative-number display), but worth noting.
+
+### 4.7 `call_user_func($cfg['handler'], $csv_data)` in `Import_Manager::run_import` — ✅ resolved in 1.4.4
+
+Resolution: replaced with `$handler = $cfg['handler']; $result = $handler($csv_data);` — direct callable invocation. Slightly faster, less verbose.
+
+`class-import-manager.php:129`. PHP 7+ supports invoking array callables directly: `$cfg['handler']($csv_data)`. The `call_user_func` form is slightly slower and slightly more verbose. Style only.
+
+### 4.8 `add_custom_shop_order_column_content`'s `external_order_number` cell relies on `$value !== ''` rather than `$value !== '' && $value !== '—'` — ⏸ left as-is (vanishingly rare edge case)
+
+If anyone ever stores the literal string `"—"` to `external_ref_ord_no` (operator typing it in for an unknown number), the column displays the literal `"—"`. Confusing but a vanishingly rare edge case.
+
+### 4.9 `parse_csv` doesn't handle `\r`-only line endings — ⏸ left as-is (legacy macOS, extremely rare)
+
+`class-db-manager.php:351-384`. PHP's `auto_detect_line_endings` was deprecated in 8.1 and removed in 9.0. macOS-Classic CSVs (`\r`-only) wouldn't tokenise correctly. Extremely unlikely in practice — modern macOS uses `\n`, Excel-Mac uses `\r\n`. Not worth fixing.
+
+### 4.10 `set_orders_to_prepare_printing` and `set_orders_to_in_printing` are thin wrappers — ⏸ left as-is (stable public API surface)
+
+`class-db-manager.php:197-209` — two two-line methods that delegate to `bulk_set_print_status`. The public API would be cleaner as a single `bulk_set_status($order_ids, $slug)` that the bulk action handler calls with the right slug. Refactor-only; behaviour unchanged.
+
+---
+
+## 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.
+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 | Status of in-scope findings | Carried-over deferrals |
+|--------|------------------------------|-------------------------|
+| `revise-1.3.2.md` | All resolved in 1.4.0 | 3 (`Network:` header, uninstall, cosmetic README). |
+| `revise-1.4.0.md` | All resolved in 1.4.1 | 1 (HPOS filter verification). |
+| `revise-1.4.1.md` | All resolved in 1.4.2 | 2 (custom-order-number plugins, register_post_status). |
+| `revise-1.4.2.md` | All resolved in 1.4.3 | 1 (Excel CS-locale CSV separator). |
+
+**One newly introduced regression** identified in this pass: §2.1 above, the side-effect of the 1.4.2 `date()` → `wp_date()` change in `normalise_csv_date`. Worth noting as a process observation: the same class of bug has now appeared in three separate functions across three releases (1.4.0 `Order_Fields_Manager`, 1.4.1 `Custom_Columns_Manager`, 1.4.2 `normalise_csv_date`). A future fix should audit *every* call to `strtotime` + `date_i18n`/`wp_date`/`date` in the codebase for the same pattern.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.4**:
+
+1. **§2.1 `normalise_csv_date` shifts timezone-less CSV dates by the WP offset.** ✅ resolved.
+2. **§3.1 No hard guard against HPOS-disabled installs.** ✅ resolved.
+3. **§3.2 `bulk_set_print_status` doesn't pass an explanatory note.** ✅ resolved.
+4. **§3.3 `normalise_csv_date` fallback accepts permissive `strtotime` interpretations.** ✅ resolved (subsumed by §2.1).
+5. **§4.1 Listener `save()` re-entrancy.** ⏸ left as-is (docblock kept).
+6. **§4.3 Per-cell `wc_get_order` in column rendering.** ✅ resolved.
+7. **§4.4 Zero-count import "success" notice.** ✅ resolved.
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling of the export query on large order volumes.
+- Behavioural verification on the WC 10.x block-based order edit screen.
+- Audit of the refreshed `.po` against the running plugin (the file was rewritten in this cycle; cross-verifying every `msgstr` against the source `msgid` is out of scope for this review).
+- Multisite, uninstall, custom-order-number plugin integration (carried-over deferrals).
+
+---
+
+## 9. Process observation
+
+The two prior fresh-eye reviews (`revise-1.4.1`, `revise-1.4.2`) each surfaced a single new high-severity bug that had survived earlier passes. This review continues the pattern with §2.1 — and the cause is a regression *I introduced* in the 1.4.2 fix for a different timezone bug. The same code shape (`strtotime` + format-in-WP-timezone) appears in three different functions across three releases, fixed each time only in the function specifically called out by the review.
+
+The reliable fix for this class of bug is to **drop `strtotime` for the "stored as WP-local MySQL datetime ↔ display in WP-local" pipeline entirely**. Either pure string reformatting (as `Order_Fields_Manager` now does) or `DateTimeImmutable(..., wp_timezone())` (as `Custom_Columns_Manager` now does) sidesteps the WP UTC-default trap. A grep for `strtotime` across `includes/` would catch any future regression.
+
+Today the only remaining `strtotime` call is in `normalise_csv_date`. After §2.1 is fixed, there should be none.
+
+> **Confirmation in 1.4.4:** a grep for `strtotime` across `includes/` returns zero hits in executable code. Only the docblock comments in `class-custom-columns-manager.php`, `class-db-manager.php`, and `class-order-fields-manager.php` mention it — explaining why each function avoids the pattern.
+
+---
+
+*End of review.*

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

@@ -1,5 +1,5 @@
 Translations: all user-facing strings (English → Czech).
-Last reviewed: v1.4.3 (2026-05-12).
+Last reviewed: v1.4.4 (2026-05-12).
 
 Note: After editing the .po file, regenerate the .mo binary with one of:
   wp i18n make-mo languages/
@@ -74,3 +74,10 @@ Only .csv files are accepted.
 # New in 1.4.3 — order notes passed to update_status from import paths
 Marked In Printing by InPrint Protocol import.                                              Označeno jako "V Tisku" importem protokolu V Tisku.
 Marked completed by Delivered Protocol import.                                              Označeno jako "Dokončeno" importem protokolu Tisk Dodán.
+
+# New in 1.4.4 — order notes passed to update_status from bulk-action paths
+Marked Prepare to Printing by bulk action.                                                  Označeno jako "Příprava Tisku" hromadnou akcí.
+Marked In Printing by bulk action.                                                          Označeno jako "V Tisku" hromadnou akcí.
+
+# 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.

+ 21 - 3
studiou-wc-ord-print-statuses/includes/class-custom-columns-manager.php

@@ -4,6 +4,14 @@
 defined('ABSPATH') || exit;
 
 class Custom_Columns_Manager {
+    /**
+     * Per-request cache of resolved WC_Order objects keyed by order ID.
+     * Used only on the legacy CPT path where WP passes us the order ID;
+     * under HPOS WC already hands us the WC_Order instance directly.
+     * @var array<int,WC_Order|false>
+     */
+    private $order_cache = array();
+
     public function __construct() {
         add_filter('manage_woocommerce_page_wc-orders_columns', array($this, 'add_custom_shop_order_column'));
         add_action('manage_woocommerce_page_wc-orders_custom_column', array($this, 'add_custom_shop_order_column_content'), 10, 2);
@@ -24,11 +32,21 @@ class Custom_Columns_Manager {
 
     /**
      * Render cell content. Under HPOS WC passes the WC_Order object as the
-     * second argument; under legacy CPT it passes the post ID. wc_get_order()
-     * normalises both forms, so we accept either.
+     * second argument; under legacy CPT it passes the post ID. Use the
+     * object directly when WC hands us one (skipping the wc_get_order
+     * lookup entirely), and memoise by ID for the legacy path so we don't
+     * re-resolve the same order for each of the three custom columns.
      */
     public function add_custom_shop_order_column_content($column, $order_or_id) {
-        $order = wc_get_order($order_or_id);
+        if ($order_or_id instanceof WC_Abstract_Order) {
+            $order = $order_or_id;
+        } else {
+            $id = (int) $order_or_id;
+            if (!array_key_exists($id, $this->order_cache)) {
+                $this->order_cache[$id] = wc_get_order($id);
+            }
+            $order = $this->order_cache[$id];
+        }
         if (!$order) {
             echo '—';
             return;

+ 46 - 9
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -49,6 +49,16 @@ class Studiou_DB_Manager {
             return array();
         }
 
+        $orders_table = $wpdb->prefix . 'wc_orders';
+        if (!self::table_exists($orders_table)) {
+            UtilsLog::log("get_orders_for_printing: required HPOS table {$orders_table} is missing. Is WooCommerce HPOS enabled?");
+            UtilsLog::message(
+                __('Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support.', 'studiou-wc-ord-print-statuses'),
+                'error'
+            );
+            return array();
+        }
+
         $lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
         if (!self::table_exists($lookup_table)) {
             UtilsLog::log("get_orders_for_printing: required table {$lookup_table} is missing. Is WC analytics enabled?");
@@ -212,6 +222,14 @@ class Studiou_DB_Manager {
         $updated_count = 0;
         $skipped       = 0;
 
+        // Explanatory note for the order activity feed. Mirrors the
+        // "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');
+
         foreach ((array) $order_ids as $order_id) {
             $order_id = (int) $order_id;
             $order    = wc_get_order($order_id);
@@ -224,7 +242,7 @@ class Studiou_DB_Manager {
                 $skipped++;
                 continue;
             }
-            $order->update_status($status_slug);
+            $order->update_status($status_slug, $note);
             $order->update_meta_data($meta_key, current_time('mysql'));
             $order->save();
             $updated_count++;
@@ -417,22 +435,41 @@ class Studiou_DB_Manager {
      * Returns '' (and logs) on parse failure so the UI does not silently
      * carry unparseable raw values through the round-trip.
      *
-     * Uses wp_date() so the resulting string is consistent with
-     * current_time('mysql') — both reflect WP local time, not server PHP
-     * timezone. Previously this used date(), which interpreted the timestamp
-     * in server timezone and produced an asymmetry between import-side
-     * external_ref_ord_date and every other meta key.
+     * Avoids strtotime() entirely. WordPress sets the PHP timezone to UTC
+     * very early in its boot (date_default_timezone_set('UTC')), so
+     * strtotime() interprets timezone-less inputs as UTC. Combining that
+     * with wp_date() — which expects a UTC timestamp and formats in WP
+     * timezone — shifted "2026-05-12 14:30" forward by the WP offset.
+     *
+     * Two paths:
+     * - Fast: ISO-like "YYYY-MM-DD[T ]HH:MM[:SS]" with no explicit timezone
+     *   → pure string reformat, no shift. This is the common shape of
+     *   what the print shop sends.
+     * - Fallback: DateTimeImmutable($raw, wp_timezone()) — interprets
+     *   timezone-less input as WP local; explicit-timezone input is honoured
+     *   by the constructor (second arg ignored in that case) and then
+     *   converted back to WP timezone via setTimezone() so the output is
+     *   always WP-local.
      */
     private static function normalise_csv_date($raw) {
         $raw = sanitize_text_field((string) $raw);
         if ($raw === '') {
             return '';
         }
-        $timestamp = strtotime($raw);
-        if (!$timestamp) {
+        // Fast path — common ISO-ish shape with no explicit timezone.
+        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;
+        }
+        // Fallback — let DateTimeImmutable handle locale dates and explicit
+        // timezones. Strict: throws ValueError on garbage (e.g. "foo").
+        try {
+            $dt = new DateTimeImmutable($raw, wp_timezone());
+            $dt = $dt->setTimezone(wp_timezone());
+            return $dt->format('Y-m-d H:i:s');
+        } catch (Exception $e) {
             UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
             return '';
         }
-        return wp_date('Y-m-d H:i:s', $timestamp);
     }
 }

+ 18 - 12
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -126,7 +126,8 @@ class Import_Manager {
             return $this->redirect_back();
         }
 
-        $result = call_user_func($cfg['handler'], $csv_data);
+        $handler = $cfg['handler'];
+        $result  = $handler($csv_data);
         $this->emit_import_summary($result);
         return $this->redirect_back();
     }
@@ -172,18 +173,23 @@ class Import_Manager {
         $updated = isset($result['updated_count']) ? (int) $result['updated_count'] : 0;
         $errors  = isset($result['errors']) && is_array($result['errors']) ? $result['errors'] : array();
 
-        UtilsLog::message(
-            sprintf(
-                _n(
-                    '%d order updated successfully.',
-                    '%d orders updated successfully.',
-                    $updated,
-                    'studiou-wc-ord-print-statuses'
+        // Skip the "0 orders updated" notice when nothing actually changed —
+        // the per-error notice below is more informative on its own and
+        // matches the pattern Bulk_Actions_Manager::notify_moved_count uses.
+        if ($updated > 0) {
+            UtilsLog::message(
+                sprintf(
+                    _n(
+                        '%d order updated successfully.',
+                        '%d orders updated successfully.',
+                        $updated,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $updated
                 ),
-                $updated
-            ),
-            $updated > 0 ? 'success' : 'info'
-        );
+                'success'
+            );
+        }
 
         if (!empty($errors)) {
             // Dedupe repeated error strings (e.g., "Invalid row format in

+ 12 - 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.3\n"
+"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.4.4\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"
@@ -174,6 +174,9 @@ msgstr "CSV export nebylo možné odeslat, protože výstup již začal. Zkontro
 msgid "Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support."
 msgstr "Export vyžaduje tabulku WooCommerce analytics (wc_order_product_lookup). Aktivujte WooCommerce Analytics nebo kontaktujte podporu."
 
+msgid "Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support."
+msgstr "Export vyžaduje WooCommerce HPOS (vlastní tabulky objednávek). Aktivujte High-Performance Order Storage v WooCommerce → Nastavení → Pokročilé → Funkce, nebo kontaktujte podporu."
+
 # --- Order notes passed to update_status from import paths (new in 1.4.3) ---
 
 msgid "Marked In Printing by InPrint Protocol import."
@@ -181,3 +184,11 @@ msgstr "Označeno jako \"V Tisku\" importem protokolu V Tisku."
 
 msgid "Marked completed by Delivered Protocol import."
 msgstr "Označeno jako \"Dokončeno\" importem protokolu Tisk Dodán."
+
+# --- Order notes passed to update_status from bulk-action paths (new in 1.4.4) ---
+
+msgid "Marked Prepare to Printing by bulk action."
+msgstr "Označeno jako \"Příprava Tisku\" hromadnou akcí."
+
+msgid "Marked In Printing by bulk action."
+msgstr "Označeno jako \"V Tisku\" hromadnou akcí."

+ 9 - 6
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.3
+ * Version: 1.4.4
  * Requires at least: 5.8
  * Tested up to: 6.9.4
  * Requires PHP: 7.2
@@ -19,15 +19,18 @@
 
 defined('ABSPATH') || exit;
 
-define('STUDIOU_WC_OPS_VERSION', '1.4.3');
+define('STUDIOU_WC_OPS_VERSION', '1.4.4');
 define('STUDIOU_WC_OPS_FILE', __FILE__);
 
-// Declare HPOS (High-Performance Order Storage) compatibility
-add_action('before_woocommerce_init', function () {
+// Declare HPOS (High-Performance Order Storage) compatibility.
+// Named function so other plugins or test harnesses can remove_action it
+// if they need to override the declaration.
+function studiou_wc_ord_print_statuses_declare_hpos_compat() {
     if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
-        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', STUDIOU_WC_OPS_FILE, true);
     }
-});
+}
+add_action('before_woocommerce_init', 'studiou_wc_ord_print_statuses_declare_hpos_compat');
 
 // load logging feature
 require_once plugin_dir_path(__FILE__) . 'includes/utils-log.php';