PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Services / Receipt_Date_Formatter.php

Receipt_Date_Formatter.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Services/Receipt_Date_Formatter.php

576 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Receipt date formatter.
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use DateTimeImmutable;
11 use DateTimeInterface;
12 use DateTimeZone;
13 use IntlDateFormatter;
14 use WC_DateTime;
15
16 /**
17 * Receipt_Date_Formatter class.
18 */
19 class Receipt_Date_Formatter {
20 /**
21 * Canonical list of date field keys returned by every formatting method.
22 */
23 private const DATE_FIELDS = array(
24 'datetime',
25 'date',
26 'time',
27 'datetime_short',
28 'datetime_long',
29 'datetime_full',
30 'date_short',
31 'date_long',
32 'date_full',
33 'date_ymd',
34 'date_dmy',
35 'date_mdy',
36 'weekday_short',
37 'weekday_long',
38 'day',
39 'month',
40 'month_short',
41 'month_long',
42 'year',
43 );
44
45 /**
46 * Intl full style fallback value.
47 */
48 private const INTL_FULL = 0;
49
50 /**
51 * Intl long style fallback value.
52 */
53 private const INTL_LONG = 1;
54
55 /**
56 * Intl medium style fallback value.
57 */
58 private const INTL_MEDIUM = 2;
59
60 /**
61 * Intl short style fallback value.
62 */
63 private const INTL_SHORT = 3;
64
65 /**
66 * Intl none style fallback value.
67 */
68 private const INTL_NONE = -1;
69
70 /**
71 * Build all practical display formats for a WooCommerce date.
72 *
73 * @param WC_DateTime|null $date WooCommerce date.
74 * @param string|null $locale Optional locale override.
75 *
76 * @return array<string, string>
77 */
78 public static function from_wc_datetime( ?WC_DateTime $date, ?string $locale = null ): array {
79 if ( ! $date ) {
80 return self::empty();
81 }
82
83 return self::from_timestamp( $date->getTimestamp(), $date->getTimezone(), $locale );
84 }
85
86 /**
87 * Build all practical display formats for a timestamp.
88 *
89 * @param int $timestamp Unix timestamp.
90 * @param DateTimeZone|null $timezone Optional timezone override.
91 * @param string|null $locale Optional locale override.
92 *
93 * @return array<string, string>
94 */
95 public static function from_timestamp( int $timestamp, ?DateTimeZone $timezone = null, ?string $locale = null ): array {
96 list( $date, $timezone, $locale, $hour_token, $fallback_time ) = self::resolve_context( $timestamp, $timezone, $locale );
97
98 return array(
99 'datetime' => self::format_style( $date, $timezone, $locale, self::INTL_MEDIUM, self::INTL_SHORT, 'M j, Y ' . $fallback_time, $hour_token ),
100 'date' => self::format_style( $date, $timezone, $locale, self::INTL_MEDIUM, self::INTL_NONE, 'M j, Y' ),
101 'time' => self::format_style( $date, $timezone, $locale, self::INTL_NONE, self::INTL_SHORT, $fallback_time, $hour_token ),
102 'datetime_short' => self::format_style( $date, $timezone, $locale, self::INTL_SHORT, self::INTL_SHORT, 'n/j/y ' . $fallback_time, $hour_token ),
103 'datetime_long' => self::format_style( $date, $timezone, $locale, self::INTL_LONG, self::INTL_SHORT, 'F j, Y ' . $fallback_time, $hour_token ),
104 'datetime_full' => self::format_style( $date, $timezone, $locale, self::INTL_FULL, self::INTL_SHORT, 'l, F j, Y ' . $fallback_time, $hour_token ),
105 'date_short' => self::format_style( $date, $timezone, $locale, self::INTL_SHORT, self::INTL_NONE, 'n/j/y' ),
106 'date_long' => self::format_style( $date, $timezone, $locale, self::INTL_LONG, self::INTL_NONE, 'F j, Y' ),
107 'date_full' => self::format_style( $date, $timezone, $locale, self::INTL_FULL, self::INTL_NONE, 'l, F j, Y' ),
108 'date_ymd' => self::format_pattern( $date, $timezone, $locale, 'yyyy-MM-dd', 'Y-m-d' ),
109 'date_dmy' => self::format_pattern( $date, $timezone, $locale, 'dd/MM/yyyy', 'd/m/Y' ),
110 'date_mdy' => self::format_pattern( $date, $timezone, $locale, 'MM/dd/yyyy', 'm/d/Y' ),
111 'weekday_short' => self::format_pattern( $date, $timezone, $locale, 'EEE', 'D' ),
112 'weekday_long' => self::format_pattern( $date, $timezone, $locale, 'EEEE', 'l' ),
113 'day' => self::format_pattern( $date, $timezone, $locale, 'dd', 'd' ),
114 'month' => self::format_pattern( $date, $timezone, $locale, 'MM', 'm' ),
115 'month_short' => self::format_pattern( $date, $timezone, $locale, 'MMM', 'M' ),
116 'month_long' => self::format_pattern( $date, $timezone, $locale, 'MMMM', 'F' ),
117 'year' => self::format_pattern( $date, $timezone, $locale, 'yyyy', 'Y' ),
118 );
119 }
120
121 /**
122 * Format the time of day on its own.
123 *
124 * Returns exactly the value `from_timestamp()` puts in its `time` key, so
125 * every clock rendered on a receipt — order timestamps, opening hours —
126 * comes from one convention source.
127 *
128 * @param int $timestamp Unix timestamp.
129 * @param DateTimeZone|null $timezone Optional timezone override.
130 * @param string|null $locale Optional locale override.
131 *
132 * @return string
133 */
134 public static function time( int $timestamp, ?DateTimeZone $timezone = null, ?string $locale = null ): string {
135 list( $date, $timezone, $locale, $hour_token, $fallback_time ) = self::resolve_context( $timestamp, $timezone, $locale );
136
137 return self::format_style( $date, $timezone, $locale, self::INTL_NONE, self::INTL_SHORT, $fallback_time, $hour_token );
138 }
139
140 /**
141 * Format the short weekday name on its own.
142 *
143 * Returns exactly the value `from_timestamp()` puts in its `weekday_short`
144 * key, so day labels match wherever a receipt names a weekday.
145 *
146 * @param int $timestamp Unix timestamp.
147 * @param DateTimeZone|null $timezone Optional timezone override.
148 * @param string|null $locale Optional locale override.
149 *
150 * @return string
151 */
152 public static function weekday_short( int $timestamp, ?DateTimeZone $timezone = null, ?string $locale = null ): string {
153 list( $date, $timezone, $locale ) = self::resolve_context( $timestamp, $timezone, $locale );
154
155 return self::format_pattern( $date, $timezone, $locale, 'EEE', 'D' );
156 }
157
158 /**
159 * Build an empty date structure.
160 *
161 * @return array<string, string>
162 */
163 public static function empty(): array {
164 return array_fill_keys( self::DATE_FIELDS, '' );
165 }
166
167 /**
168 * Resolve the shared inputs every formatting method needs.
169 *
170 * The WordPress time_format setting controls the clock convention (12 vs
171 * 24-hour); the locale keeps controlling localized date text.
172 *
173 * @param int $timestamp Unix timestamp.
174 * @param DateTimeZone|null $timezone Optional timezone override.
175 * @param string|null $locale Optional locale override.
176 *
177 * @return array{0: DateTimeImmutable, 1: DateTimeZone, 2: string, 3: string|null, 4: string}
178 */
179 private static function resolve_context( int $timestamp, ?DateTimeZone $timezone, ?string $locale ): array {
180 $timezone = $timezone ? $timezone : self::get_default_timezone();
181 $locale = $locale ? $locale : self::get_default_locale();
182 $date = ( new DateTimeImmutable( '@' . $timestamp ) )->setTimezone( $timezone );
183
184 $time_format = self::get_time_format_option();
185
186 return array(
187 $date,
188 $timezone,
189 $locale,
190 self::get_icu_hour_token( $time_format ),
191 '' !== $time_format ? $time_format : 'g:i A',
192 );
193 }
194
195 /**
196 * Format using Intl styles with a sane fallback.
197 *
198 * @param DateTimeInterface $date Date to format.
199 * @param DateTimeZone $timezone Date timezone.
200 * @param string $locale Locale code.
201 * @param int $date_style Intl date style.
202 * @param int $time_style Intl time style.
203 * @param string $fallback_pattern wp_date()/DateTime fallback pattern.
204 * @param string|null $hour_token ICU hour token enforcing the configured clock convention, or null to keep the locale default.
205 *
206 * @return string
207 */
208 private static function format_style( DateTimeInterface $date, DateTimeZone $timezone, string $locale, int $date_style, int $time_style, string $fallback_pattern, ?string $hour_token = null ): string {
209 return self::run_intl_with_fallback(
210 $date,
211 $timezone,
212 $locale,
213 $fallback_pattern,
214 static function ( string $timezone_name ) use ( $locale, $date_style, $time_style, $hour_token ) {
215 return self::build_clock_aware_formatter( $locale, $date_style, $time_style, $timezone_name, $hour_token );
216 }
217 );
218 }
219
220 /**
221 * Build an Intl formatter with the requested clock convention.
222 *
223 * @param string $locale Locale code.
224 * @param int $date_style Intl date style.
225 * @param int $time_style Intl time style.
226 * @param string $timezone_name Intl timezone name.
227 * @param string|null $hour_token ICU hour token, or null to keep the locale default.
228 *
229 * @return IntlDateFormatter
230 */
231 private static function build_clock_aware_formatter( string $locale, int $date_style, int $time_style, string $timezone_name, ?string $hour_token ): IntlDateFormatter {
232 if ( null === $hour_token || self::INTL_NONE === $time_style ) {
233 return new IntlDateFormatter( $locale, $date_style, $time_style, $timezone_name );
234 }
235
236 $use_24_hour = 'H' === $hour_token[0];
237
238 // Request the hour cycle via the Unicode locale extension first so
239 // CLDR controls day-period placement (before vs after the hour and
240 // time-first patterns). The pattern probe detects unsupported cycles.
241 $hour_cycle_locale = $locale . ( false === strpos( $locale, '-u-' ) ? '-u-hc-' : '-hc-' ) . ( $use_24_hour ? 'h23' : 'h12' );
242 $formatter = new IntlDateFormatter( $hour_cycle_locale, $date_style, $time_style, $timezone_name );
243 $pattern = (string) $formatter->getPattern();
244
245 if ( ! self::pattern_matches_clock( $pattern, $use_24_hour ) ) {
246 $formatter = new IntlDateFormatter( $locale, $date_style, $time_style, $timezone_name );
247 $pattern = (string) $formatter->getPattern();
248 }
249
250 // Normalize the hour symbols to the configured padding and,
251 // on the no-extension fallback, rewrite the clock convention.
252 // Never set an empty pattern (PCRE failure) — blank output
253 // is worse than the locale-default clock.
254 $adjusted = self::apply_clock_convention( $pattern, $hour_token );
255 if ( '' !== $adjusted && $adjusted !== $pattern ) {
256 $formatter->setPattern( $adjusted );
257 }
258
259 return $formatter;
260 }
261
262 /**
263 * Format using an Intl pattern with a sane fallback.
264 *
265 * @param DateTimeInterface $date Date to format.
266 * @param DateTimeZone $timezone Date timezone.
267 * @param string $locale Locale code.
268 * @param string $pattern Intl pattern.
269 * @param string $fallback_pattern wp_date()/DateTime fallback pattern.
270 *
271 * @return string
272 */
273 private static function format_pattern( DateTimeInterface $date, DateTimeZone $timezone, string $locale, string $pattern, string $fallback_pattern ): string {
274 return self::run_intl_with_fallback(
275 $date,
276 $timezone,
277 $locale,
278 $fallback_pattern,
279 static function ( string $timezone_name ) use ( $locale, $pattern ) {
280 return new IntlDateFormatter( $locale, self::INTL_NONE, self::INTL_NONE, $timezone_name, null, $pattern );
281 }
282 );
283 }
284
285 /**
286 * Run an IntlDateFormatter with fixed-offset timezone normalization and fallback.
287 *
288 * @param DateTimeInterface $date Date to format.
289 * @param DateTimeZone $timezone Date timezone.
290 * @param string $locale Locale code.
291 * @param string $fallback_pattern wp_date()/DateTime fallback pattern.
292 * @param callable $make_formatter Callback receiving timezone name, returns IntlDateFormatter.
293 *
294 * @return string
295 */
296 private static function run_intl_with_fallback( DateTimeInterface $date, DateTimeZone $timezone, string $locale, string $fallback_pattern, callable $make_formatter ): string {
297 $timezone_name = $timezone->getName();
298 if ( self::is_fixed_offset_timezone_name( $timezone_name ) ) {
299 // ICU rejects raw offset names like "+02:00" but accepts the
300 // equivalent "GMT+02:00" spelling, so fixed-offset sites can use
301 // the same Intl path as IANA timezones.
302 $timezone_name = 'GMT' . $timezone_name;
303 }
304
305 if ( static::intl_available() ) {
306 try {
307 $formatter = $make_formatter( $timezone_name );
308 $formatted = $formatter->format( $date );
309 if ( false !== $formatted ) {
310 return (string) $formatted;
311 }
312 } catch ( \Throwable $error ) {
313 return self::format_fallback( $date, $fallback_pattern, $locale );
314 }
315 }
316
317 return self::format_fallback( $date, $fallback_pattern, $locale );
318 }
319
320 /**
321 * Whether the Intl extension can be used. Overridable for testing.
322 *
323 * @return bool
324 */
325 protected static function intl_available(): bool {
326 return class_exists( IntlDateFormatter::class );
327 }
328
329 /**
330 * Read the WordPress time_format option.
331 *
332 * @return string Configured format, or empty string when unavailable/blank.
333 */
334 private static function get_time_format_option(): string {
335 if ( ! function_exists( 'get_option' ) ) {
336 return '';
337 }
338
339 $time_format = get_option( 'time_format', '' );
340 if ( ! is_string( $time_format ) || '' === trim( $time_format ) ) {
341 return '';
342 }
343
344 return $time_format;
345 }
346
347 /**
348 * Map the configured WordPress time format to an ICU hour token.
349 *
350 * The token carries both the clock convention and the zero-padding intent:
351 * G → H, H → HH (24-hour), g → h, h → hh (12-hour).
352 *
353 * @param string $time_format WordPress time format string.
354 *
355 * @return string|null ICU hour token, or null when no hour token is present.
356 */
357 private static function get_icu_hour_token( string $time_format ): ?string {
358 // Ignore backslash-escaped literal characters.
359 $unescaped = (string) preg_replace( '/\\\\./', '', $time_format );
360
361 if ( false !== strpos( $unescaped, 'H' ) ) {
362 return 'HH';
363 }
364 if ( false !== strpos( $unescaped, 'G' ) ) {
365 return 'H';
366 }
367 if ( false !== strpos( $unescaped, 'h' ) ) {
368 return 'hh';
369 }
370 if ( false !== strpos( $unescaped, 'g' ) ) {
371 return 'h';
372 }
373
374 return null;
375 }
376
377 /**
378 * Check whether a pattern's hour symbols already match a clock convention.
379 *
380 * Used to detect ICU builds that ignore the hours locale keyword.
381 *
382 * @param string $pattern ICU date/time pattern.
383 * @param bool $use_24_hour Whether the 24-hour convention is requested.
384 *
385 * @return bool
386 */
387 private static function pattern_matches_clock( string $pattern, bool $use_24_hour ): bool {
388 $in_quote = false;
389 $length = strlen( $pattern );
390
391 for ( $i = 0; $i < $length; $i++ ) {
392 $char = $pattern[ $i ];
393
394 if ( "'" === $char ) {
395 if ( $i + 1 < $length && "'" === $pattern[ $i + 1 ] ) {
396 ++$i;
397 continue;
398 }
399 $in_quote = ! $in_quote;
400 continue;
401 }
402
403 if ( $in_quote ) {
404 continue;
405 }
406
407 if ( $use_24_hour && ( 'H' === $char || 'k' === $char ) ) {
408 return true;
409 }
410 if ( ! $use_24_hour && ( 'h' === $char || 'K' === $char ) ) {
411 return true;
412 }
413 }
414
415 return false;
416 }
417
418 /**
419 * Rewrite an ICU pattern so its clock convention matches the hour token.
420 *
421 * Hour symbols (h, H, k, K) outside quoted literals are replaced with the
422 * token. For 24-hour tokens, day-period markers (a, b, B) are removed; for
423 * 12-hour tokens, a day-period marker is appended when missing. The
424 * time_format option contributes only the clock convention and padding —
425 * the locale owns separators, ordering, and day-period placement.
426 *
427 * Byte-wise iteration is safe on UTF-8 patterns: no ASCII byte can occur
428 * inside a multibyte sequence.
429 *
430 * @param string $pattern ICU date/time pattern.
431 * @param string $hour_token ICU hour token: H, HH, h, or hh.
432 *
433 * @return string
434 */
435 private static function apply_clock_convention( string $pattern, string $hour_token ): string {
436 $use_24_hour = 'H' === $hour_token[0];
437 $result = '';
438 $in_quote = false;
439 $has_day_period = false;
440 $has_hour = false;
441 $time_end = 0;
442 $length = strlen( $pattern );
443
444 for ( $i = 0; $i < $length; $i++ ) {
445 $char = $pattern[ $i ];
446
447 if ( "'" === $char ) {
448 $result .= $char;
449 // A doubled quote is an escaped literal quote, not a toggle.
450 if ( $i + 1 < $length && "'" === $pattern[ $i + 1 ] ) {
451 $result .= "'";
452 ++$i;
453 continue;
454 }
455 $in_quote = ! $in_quote;
456 continue;
457 }
458
459 if ( $in_quote ) {
460 $result .= $char;
461 continue;
462 }
463
464 if ( 'h' === $char || 'H' === $char || 'k' === $char || 'K' === $char ) {
465 while ( $i + 1 < $length && $pattern[ $i + 1 ] === $char ) {
466 ++$i;
467 }
468 $result .= $hour_token;
469 $has_hour = true;
470 $time_end = strlen( $result );
471 continue;
472 }
473
474 if ( 'a' === $char || 'b' === $char || 'B' === $char ) {
475 if ( $use_24_hour ) {
476 $result .= "\x01";
477 continue;
478 }
479 $has_day_period = true;
480 }
481
482 $result .= $char;
483
484 // Track the end of the time cluster (minutes/seconds and their
485 // separator) so a missing day-period marker can be inserted after
486 // the time rather than after a trailing date in time-first patterns.
487 if ( $has_hour && ( 'm' === $char || 's' === $char || ':' === $char || '.' === $char ) ) {
488 $time_end = strlen( $result );
489 }
490 }
491
492 if ( $use_24_hour ) {
493 // Strip removed day-period markers with their surrounding spacing
494 // (including NBSP/NNBSP used by newer CLDR data).
495 $result = (string) preg_replace( "/[\s\x{00A0}\x{202F}]*\x01+[\s\x{00A0}\x{202F}]*(?!')(?=[\p{Pe}\p{Pf}\p{Po}])/u", '', $result );
496 $result = (string) preg_replace( '/[\s\x{00A0}\x{202F}]*\x01+[\s\x{00A0}\x{202F}]*/u', ' ', $result );
497 $result = (string) preg_replace( '/[\s\x{00A0}\x{202F}]{2,}/u', ' ', $result );
498
499 return trim( $result );
500 }
501
502 if ( $has_hour && ! $has_day_period ) {
503 if ( $time_end >= strlen( $result ) ) {
504 return rtrim( $result ) . ' a';
505 }
506
507 return substr( $result, 0, $time_end ) . ' a' . substr( $result, $time_end );
508 }
509
510 return $result;
511 }
512
513 /**
514 * Check whether a timezone name is a fixed UTC offset like +00:00.
515 *
516 * @param string $timezone_name Timezone name.
517 *
518 * @return bool
519 */
520 private static function is_fixed_offset_timezone_name( string $timezone_name ): bool {
521 return 1 === preg_match( '/^[+-]\d{2}:\d{2}$/', $timezone_name );
522 }
523
524 /**
525 * Format a date when Intl is unavailable.
526 *
527 * @param DateTimeInterface $date Date to format.
528 * @param string $pattern wp_date()/DateTime pattern.
529 * @param string $locale Locale code.
530 *
531 * @return string
532 */
533 private static function format_fallback( DateTimeInterface $date, string $pattern, string $locale ): string {
534 if ( function_exists( 'wp_date' ) ) {
535 $current_locale = function_exists( 'get_locale' ) ? (string) get_locale() : '';
536 if ( '' !== $locale && $locale !== $current_locale && function_exists( 'switch_to_locale' ) && switch_to_locale( $locale ) ) {
537 try {
538 return wp_date( $pattern, $date->getTimestamp(), $date->getTimezone() );
539 } finally {
540 restore_previous_locale();
541 }
542 }
543
544 return wp_date( $pattern, $date->getTimestamp(), $date->getTimezone() );
545 }
546
547 return $date->format( $pattern );
548 }
549
550 /**
551 * Resolve default locale.
552 *
553 * @return string
554 */
555 private static function get_default_locale(): string {
556 if ( function_exists( 'get_locale' ) ) {
557 return (string) get_locale();
558 }
559
560 return 'en_US';
561 }
562
563 /**
564 * Resolve default timezone.
565 *
566 * @return DateTimeZone
567 */
568 private static function get_default_timezone(): DateTimeZone {
569 if ( function_exists( 'wp_timezone' ) ) {
570 return wp_timezone();
571 }
572
573 return new DateTimeZone( date_default_timezone_get() );
574 }
575 }
576