PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.15
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.15
1.10.19 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 All 163 releases
woocommerce-pos / includes / Services / Receipt_Date_Formatter.php

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

518 lines 16.8 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 $timezone = $timezone ? $timezone : self::get_default_timezone();
97 $locale = $locale ? $locale : self::get_default_locale();
98 $date = ( new DateTimeImmutable( '@' . $timestamp ) )->setTimezone( $timezone );
99
100 // The WordPress time_format setting controls the clock convention
101 // (12 vs 24-hour); the locale keeps controlling localized date text.
102 $time_format = self::get_time_format_option();
103 $hour_token = self::get_icu_hour_token( $time_format );
104 $fallback_time = '' !== $time_format ? $time_format : 'g:i A';
105
106 return array(
107 'datetime' => self::format_style( $date, $timezone, $locale, self::INTL_MEDIUM, self::INTL_SHORT, 'M j, Y ' . $fallback_time, $hour_token ),
108 'date' => self::format_style( $date, $timezone, $locale, self::INTL_MEDIUM, self::INTL_NONE, 'M j, Y' ),
109 'time' => self::format_style( $date, $timezone, $locale, self::INTL_NONE, self::INTL_SHORT, $fallback_time, $hour_token ),
110 'datetime_short' => self::format_style( $date, $timezone, $locale, self::INTL_SHORT, self::INTL_SHORT, 'n/j/y ' . $fallback_time, $hour_token ),
111 'datetime_long' => self::format_style( $date, $timezone, $locale, self::INTL_LONG, self::INTL_SHORT, 'F j, Y ' . $fallback_time, $hour_token ),
112 'datetime_full' => self::format_style( $date, $timezone, $locale, self::INTL_FULL, self::INTL_SHORT, 'l, F j, Y ' . $fallback_time, $hour_token ),
113 'date_short' => self::format_style( $date, $timezone, $locale, self::INTL_SHORT, self::INTL_NONE, 'n/j/y' ),
114 'date_long' => self::format_style( $date, $timezone, $locale, self::INTL_LONG, self::INTL_NONE, 'F j, Y' ),
115 'date_full' => self::format_style( $date, $timezone, $locale, self::INTL_FULL, self::INTL_NONE, 'l, F j, Y' ),
116 'date_ymd' => self::format_pattern( $date, $timezone, $locale, 'yyyy-MM-dd', 'Y-m-d' ),
117 'date_dmy' => self::format_pattern( $date, $timezone, $locale, 'dd/MM/yyyy', 'd/m/Y' ),
118 'date_mdy' => self::format_pattern( $date, $timezone, $locale, 'MM/dd/yyyy', 'm/d/Y' ),
119 'weekday_short' => self::format_pattern( $date, $timezone, $locale, 'EEE', 'D' ),
120 'weekday_long' => self::format_pattern( $date, $timezone, $locale, 'EEEE', 'l' ),
121 'day' => self::format_pattern( $date, $timezone, $locale, 'dd', 'd' ),
122 'month' => self::format_pattern( $date, $timezone, $locale, 'MM', 'm' ),
123 'month_short' => self::format_pattern( $date, $timezone, $locale, 'MMM', 'M' ),
124 'month_long' => self::format_pattern( $date, $timezone, $locale, 'MMMM', 'F' ),
125 'year' => self::format_pattern( $date, $timezone, $locale, 'yyyy', 'Y' ),
126 );
127 }
128
129 /**
130 * Build an empty date structure.
131 *
132 * @return array<string, string>
133 */
134 public static function empty(): array {
135 return array_fill_keys( self::DATE_FIELDS, '' );
136 }
137
138 /**
139 * Format using Intl styles with a sane fallback.
140 *
141 * @param DateTimeInterface $date Date to format.
142 * @param DateTimeZone $timezone Date timezone.
143 * @param string $locale Locale code.
144 * @param int $date_style Intl date style.
145 * @param int $time_style Intl time style.
146 * @param string $fallback_pattern wp_date()/DateTime fallback pattern.
147 * @param string|null $hour_token ICU hour token enforcing the configured clock convention, or null to keep the locale default.
148 *
149 * @return string
150 */
151 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 {
152 return self::run_intl_with_fallback(
153 $date,
154 $timezone,
155 $locale,
156 $fallback_pattern,
157 static function ( string $timezone_name ) use ( $locale, $date_style, $time_style, $hour_token ) {
158 return self::build_clock_aware_formatter( $locale, $date_style, $time_style, $timezone_name, $hour_token );
159 }
160 );
161 }
162
163 /**
164 * Build an Intl formatter with the requested clock convention.
165 *
166 * @param string $locale Locale code.
167 * @param int $date_style Intl date style.
168 * @param int $time_style Intl time style.
169 * @param string $timezone_name Intl timezone name.
170 * @param string|null $hour_token ICU hour token, or null to keep the locale default.
171 *
172 * @return IntlDateFormatter
173 */
174 private static function build_clock_aware_formatter( string $locale, int $date_style, int $time_style, string $timezone_name, ?string $hour_token ): IntlDateFormatter {
175 if ( null === $hour_token || self::INTL_NONE === $time_style ) {
176 return new IntlDateFormatter( $locale, $date_style, $time_style, $timezone_name );
177 }
178
179 $use_24_hour = 'H' === $hour_token[0];
180
181 // Request the hour cycle via the Unicode locale extension first so
182 // CLDR controls day-period placement (before vs after the hour and
183 // time-first patterns). The pattern probe detects unsupported cycles.
184 $hour_cycle_locale = $locale . ( false === strpos( $locale, '-u-' ) ? '-u-hc-' : '-hc-' ) . ( $use_24_hour ? 'h23' : 'h12' );
185 $formatter = new IntlDateFormatter( $hour_cycle_locale, $date_style, $time_style, $timezone_name );
186 $pattern = (string) $formatter->getPattern();
187
188 if ( ! self::pattern_matches_clock( $pattern, $use_24_hour ) ) {
189 $formatter = new IntlDateFormatter( $locale, $date_style, $time_style, $timezone_name );
190 $pattern = (string) $formatter->getPattern();
191 }
192
193 // Normalize the hour symbols to the configured padding and,
194 // on the no-extension fallback, rewrite the clock convention.
195 // Never set an empty pattern (PCRE failure) — blank output
196 // is worse than the locale-default clock.
197 $adjusted = self::apply_clock_convention( $pattern, $hour_token );
198 if ( '' !== $adjusted && $adjusted !== $pattern ) {
199 $formatter->setPattern( $adjusted );
200 }
201
202 return $formatter;
203 }
204
205 /**
206 * Format using an Intl pattern with a sane fallback.
207 *
208 * @param DateTimeInterface $date Date to format.
209 * @param DateTimeZone $timezone Date timezone.
210 * @param string $locale Locale code.
211 * @param string $pattern Intl pattern.
212 * @param string $fallback_pattern wp_date()/DateTime fallback pattern.
213 *
214 * @return string
215 */
216 private static function format_pattern( DateTimeInterface $date, DateTimeZone $timezone, string $locale, string $pattern, string $fallback_pattern ): string {
217 return self::run_intl_with_fallback(
218 $date,
219 $timezone,
220 $locale,
221 $fallback_pattern,
222 static function ( string $timezone_name ) use ( $locale, $pattern ) {
223 return new IntlDateFormatter( $locale, self::INTL_NONE, self::INTL_NONE, $timezone_name, null, $pattern );
224 }
225 );
226 }
227
228 /**
229 * Run an IntlDateFormatter with fixed-offset timezone normalization and fallback.
230 *
231 * @param DateTimeInterface $date Date to format.
232 * @param DateTimeZone $timezone Date timezone.
233 * @param string $locale Locale code.
234 * @param string $fallback_pattern wp_date()/DateTime fallback pattern.
235 * @param callable $make_formatter Callback receiving timezone name, returns IntlDateFormatter.
236 *
237 * @return string
238 */
239 private static function run_intl_with_fallback( DateTimeInterface $date, DateTimeZone $timezone, string $locale, string $fallback_pattern, callable $make_formatter ): string {
240 $timezone_name = $timezone->getName();
241 if ( self::is_fixed_offset_timezone_name( $timezone_name ) ) {
242 // ICU rejects raw offset names like "+02:00" but accepts the
243 // equivalent "GMT+02:00" spelling, so fixed-offset sites can use
244 // the same Intl path as IANA timezones.
245 $timezone_name = 'GMT' . $timezone_name;
246 }
247
248 if ( static::intl_available() ) {
249 try {
250 $formatter = $make_formatter( $timezone_name );
251 $formatted = $formatter->format( $date );
252 if ( false !== $formatted ) {
253 return (string) $formatted;
254 }
255 } catch ( \Throwable $error ) {
256 return self::format_fallback( $date, $fallback_pattern, $locale );
257 }
258 }
259
260 return self::format_fallback( $date, $fallback_pattern, $locale );
261 }
262
263 /**
264 * Whether the Intl extension can be used. Overridable for testing.
265 *
266 * @return bool
267 */
268 protected static function intl_available(): bool {
269 return class_exists( IntlDateFormatter::class );
270 }
271
272 /**
273 * Read the WordPress time_format option.
274 *
275 * @return string Configured format, or empty string when unavailable/blank.
276 */
277 private static function get_time_format_option(): string {
278 if ( ! function_exists( 'get_option' ) ) {
279 return '';
280 }
281
282 $time_format = get_option( 'time_format', '' );
283 if ( ! is_string( $time_format ) || '' === trim( $time_format ) ) {
284 return '';
285 }
286
287 return $time_format;
288 }
289
290 /**
291 * Map the configured WordPress time format to an ICU hour token.
292 *
293 * The token carries both the clock convention and the zero-padding intent:
294 * G → H, H → HH (24-hour), g → h, h → hh (12-hour).
295 *
296 * @param string $time_format WordPress time format string.
297 *
298 * @return string|null ICU hour token, or null when no hour token is present.
299 */
300 private static function get_icu_hour_token( string $time_format ): ?string {
301 // Ignore backslash-escaped literal characters.
302 $unescaped = (string) preg_replace( '/\\\\./', '', $time_format );
303
304 if ( false !== strpos( $unescaped, 'H' ) ) {
305 return 'HH';
306 }
307 if ( false !== strpos( $unescaped, 'G' ) ) {
308 return 'H';
309 }
310 if ( false !== strpos( $unescaped, 'h' ) ) {
311 return 'hh';
312 }
313 if ( false !== strpos( $unescaped, 'g' ) ) {
314 return 'h';
315 }
316
317 return null;
318 }
319
320 /**
321 * Check whether a pattern's hour symbols already match a clock convention.
322 *
323 * Used to detect ICU builds that ignore the hours locale keyword.
324 *
325 * @param string $pattern ICU date/time pattern.
326 * @param bool $use_24_hour Whether the 24-hour convention is requested.
327 *
328 * @return bool
329 */
330 private static function pattern_matches_clock( string $pattern, bool $use_24_hour ): bool {
331 $in_quote = false;
332 $length = strlen( $pattern );
333
334 for ( $i = 0; $i < $length; $i++ ) {
335 $char = $pattern[ $i ];
336
337 if ( "'" === $char ) {
338 if ( $i + 1 < $length && "'" === $pattern[ $i + 1 ] ) {
339 ++$i;
340 continue;
341 }
342 $in_quote = ! $in_quote;
343 continue;
344 }
345
346 if ( $in_quote ) {
347 continue;
348 }
349
350 if ( $use_24_hour && ( 'H' === $char || 'k' === $char ) ) {
351 return true;
352 }
353 if ( ! $use_24_hour && ( 'h' === $char || 'K' === $char ) ) {
354 return true;
355 }
356 }
357
358 return false;
359 }
360
361 /**
362 * Rewrite an ICU pattern so its clock convention matches the hour token.
363 *
364 * Hour symbols (h, H, k, K) outside quoted literals are replaced with the
365 * token. For 24-hour tokens, day-period markers (a, b, B) are removed; for
366 * 12-hour tokens, a day-period marker is appended when missing. The
367 * time_format option contributes only the clock convention and padding —
368 * the locale owns separators, ordering, and day-period placement.
369 *
370 * Byte-wise iteration is safe on UTF-8 patterns: no ASCII byte can occur
371 * inside a multibyte sequence.
372 *
373 * @param string $pattern ICU date/time pattern.
374 * @param string $hour_token ICU hour token: H, HH, h, or hh.
375 *
376 * @return string
377 */
378 private static function apply_clock_convention( string $pattern, string $hour_token ): string {
379 $use_24_hour = 'H' === $hour_token[0];
380 $result = '';
381 $in_quote = false;
382 $has_day_period = false;
383 $has_hour = false;
384 $time_end = 0;
385 $length = strlen( $pattern );
386
387 for ( $i = 0; $i < $length; $i++ ) {
388 $char = $pattern[ $i ];
389
390 if ( "'" === $char ) {
391 $result .= $char;
392 // A doubled quote is an escaped literal quote, not a toggle.
393 if ( $i + 1 < $length && "'" === $pattern[ $i + 1 ] ) {
394 $result .= "'";
395 ++$i;
396 continue;
397 }
398 $in_quote = ! $in_quote;
399 continue;
400 }
401
402 if ( $in_quote ) {
403 $result .= $char;
404 continue;
405 }
406
407 if ( 'h' === $char || 'H' === $char || 'k' === $char || 'K' === $char ) {
408 while ( $i + 1 < $length && $pattern[ $i + 1 ] === $char ) {
409 ++$i;
410 }
411 $result .= $hour_token;
412 $has_hour = true;
413 $time_end = strlen( $result );
414 continue;
415 }
416
417 if ( 'a' === $char || 'b' === $char || 'B' === $char ) {
418 if ( $use_24_hour ) {
419 $result .= "\x01";
420 continue;
421 }
422 $has_day_period = true;
423 }
424
425 $result .= $char;
426
427 // Track the end of the time cluster (minutes/seconds and their
428 // separator) so a missing day-period marker can be inserted after
429 // the time rather than after a trailing date in time-first patterns.
430 if ( $has_hour && ( 'm' === $char || 's' === $char || ':' === $char || '.' === $char ) ) {
431 $time_end = strlen( $result );
432 }
433 }
434
435 if ( $use_24_hour ) {
436 // Strip removed day-period markers with their surrounding spacing
437 // (including NBSP/NNBSP used by newer CLDR data).
438 $result = (string) preg_replace( '/[\s\x{00A0}\x{202F}]*\x01+[\s\x{00A0}\x{202F}]*/u', ' ', $result );
439 $result = (string) preg_replace( '/[\s\x{00A0}\x{202F}]{2,}/u', ' ', $result );
440
441 return trim( $result );
442 }
443
444 if ( $has_hour && ! $has_day_period ) {
445 if ( $time_end >= strlen( $result ) ) {
446 return rtrim( $result ) . ' a';
447 }
448
449 return substr( $result, 0, $time_end ) . ' a' . substr( $result, $time_end );
450 }
451
452 return $result;
453 }
454
455 /**
456 * Check whether a timezone name is a fixed UTC offset like +00:00.
457 *
458 * @param string $timezone_name Timezone name.
459 *
460 * @return bool
461 */
462 private static function is_fixed_offset_timezone_name( string $timezone_name ): bool {
463 return 1 === preg_match( '/^[+-]\d{2}:\d{2}$/', $timezone_name );
464 }
465
466 /**
467 * Format a date when Intl is unavailable.
468 *
469 * @param DateTimeInterface $date Date to format.
470 * @param string $pattern wp_date()/DateTime pattern.
471 * @param string $locale Locale code.
472 *
473 * @return string
474 */
475 private static function format_fallback( DateTimeInterface $date, string $pattern, string $locale ): string {
476 if ( function_exists( 'wp_date' ) ) {
477 $current_locale = function_exists( 'get_locale' ) ? (string) get_locale() : '';
478 if ( '' !== $locale && $locale !== $current_locale && function_exists( 'switch_to_locale' ) && switch_to_locale( $locale ) ) {
479 try {
480 return wp_date( $pattern, $date->getTimestamp(), $date->getTimezone() );
481 } finally {
482 restore_previous_locale();
483 }
484 }
485
486 return wp_date( $pattern, $date->getTimestamp(), $date->getTimezone() );
487 }
488
489 return $date->format( $pattern );
490 }
491
492 /**
493 * Resolve default locale.
494 *
495 * @return string
496 */
497 private static function get_default_locale(): string {
498 if ( function_exists( 'get_locale' ) ) {
499 return (string) get_locale();
500 }
501
502 return 'en_US';
503 }
504
505 /**
506 * Resolve default timezone.
507 *
508 * @return DateTimeZone
509 */
510 private static function get_default_timezone(): DateTimeZone {
511 if ( function_exists( 'wp_timezone' ) ) {
512 return wp_timezone();
513 }
514
515 return new DateTimeZone( date_default_timezone_get() );
516 }
517 }
518