Lines
100.00%
63 / 63
Functions and Methods
100.00%
6 / 6
Classes and Traits
100.00%
1 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| BankDate | 100.00% | 63 / 63 | 100.00% | 6 / 6 | 38 | 100.00% | 1 / 1 | |||
| bratislava | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| utc | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| parseFirst | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 3 | |||||
| parseEnglish | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 3 | |||||
| parse | 100.00% | 27 / 27 | 100.00% | 1 / 1 | 16 | |||||
| englishMonth | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 14 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Tomaj\BankMailsParser\Parser; | |
| 6 | ||
| 7 | use DateTimeImmutable; | |
| 8 | use DateTimeZone; | |
| 9 | ||
| 10 | /** | |
| 11 | * Parses bank email dates without inheriting date.timezone and without rolling invalid dates forward. | |
| 12 | * | |
| 13 | * @internal Not part of the supported public API. Call it from a parser, and expect the formats to change. | |
| 14 | */ | |
| 15 | final class BankDate | |
| 16 | { | |
| 17 | public const string ZONE = 'Europe/Bratislava'; | |
| 18 | ||
| 19 | public static function bratislava(): DateTimeZone | |
| 20 | { | |
| 21 | return new DateTimeZone(self::ZONE); | |
| 22 | } | |
| 23 | ||
| 24 | public static function utc(): DateTimeZone | |
| 25 | { | |
| 26 | return new DateTimeZone('UTC'); | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * @param list<string> $formats | |
| 31 | */ | |
| 32 | public static function parseFirst(string $value, array $formats, DateTimeZone $zone): ?DateTimeImmutable | |
| 33 | { | |
| 34 | foreach ($formats as $format) { | |
| 35 | $parsed = self::parse($value, $format, $zone); | |
| 36 | if ($parsed !== null) { | |
| 37 | return $parsed; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | return null; | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * English month names, the way 4.x `strtotime()` read `16 January 2015 12:51`. | |
| 46 | * A numeric date stays on {@see parse()} so `31.2.2015` does not roll forward. | |
| 47 | */ | |
| 48 | public static function parseEnglish(string $value, DateTimeZone $zone): ?DateTimeImmutable | |
| 49 | { | |
| 50 | if (preg_match('/^(\d{1,2})[ \t]+([A-Za-z]+)[ \t]+(\d{4})(?:[ \t]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/', trim($value), $match) !== 1) { | |
| 51 | return null; | |
| 52 | } | |
| 53 | ||
| 54 | $month = self::englishMonth($match[2]); | |
| 55 | if ($month === null) { | |
| 56 | return null; | |
| 57 | } | |
| 58 | ||
| 59 | $clock = sprintf( | |
| 60 | '%04d-%02d-%02d %02d:%02d:%02d', | |
| 61 | (int) $match[3], | |
| 62 | $month, | |
| 63 | (int) $match[1], | |
| 64 | (int) ($match[4] ?? 0), | |
| 65 | (int) ($match[5] ?? 0), | |
| 66 | (int) ($match[6] ?? 0), | |
| 67 | ); | |
| 68 | ||
| 69 | return self::parse($clock, '!Y-m-d H:i:s', $zone); | |
| 70 | } | |
| 71 | ||
| 72 | public static function parse(string $value, string $format, DateTimeZone $zone): ?DateTimeImmutable | |
| 73 | { | |
| 74 | $value = trim($value); | |
| 75 | $nextDay = false; | |
| 76 | if ((str_contains($format, 'G:i') || str_contains($format, 'H:i')) | |
| 77 | && preg_match('/24:00(?::00)?$/', $value) === 1 | |
| 78 | ) { | |
| 79 | $rewritten = preg_replace('/24:00(:00)?$/', '00:00$1', $value, 1); | |
| 80 | if (is_string($rewritten)) { | |
| 81 | $value = $rewritten; | |
| 82 | $nextDay = true; | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | // Parse the wall clock in UTC first. Europe/Bratislava createFromFormat rolls a | |
| 87 | // spring-forward gap (29.3.2015 02:30 → 03:30) without setting getLastErrors(). | |
| 88 | $wall = DateTimeImmutable::createFromFormat($format, $value, self::utc()); | |
| 89 | if ($wall === false) { | |
| 90 | return null; | |
| 91 | } | |
| 92 | ||
| 93 | $errors = DateTimeImmutable::getLastErrors(); | |
| 94 | if (is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) { | |
| 95 | return null; | |
| 96 | } | |
| 97 | ||
| 98 | $year = (int) $wall->format('Y'); | |
| 99 | if ($year < 1000 || $year > 9999) { | |
| 100 | return null; | |
| 101 | } | |
| 102 | ||
| 103 | $clock = $wall->format('Y-m-d H:i:s'); | |
| 104 | $parsed = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $clock, $zone); | |
| 105 | if ($parsed === false || $parsed->format('Y-m-d H:i:s') !== $clock) { | |
| 106 | return null; | |
| 107 | } | |
| 108 | ||
| 109 | if ($nextDay) { | |
| 110 | $parsed = $parsed->modify('+1 day'); | |
| 111 | $year = (int) $parsed->format('Y'); | |
| 112 | if ($year < 1000 || $year > 9999) { | |
| 113 | return null; | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | return $parsed; | |
| 118 | } | |
| 119 | ||
| 120 | private static function englishMonth(string $name): ?int | |
| 121 | { | |
| 122 | return match (strtolower($name)) { | |
| 123 | 'january', 'jan' => 1, | |
| 124 | 'february', 'feb' => 2, | |
| 125 | 'march', 'mar' => 3, | |
| 126 | 'april', 'apr' => 4, | |
| 127 | 'may' => 5, | |
| 128 | 'june', 'jun' => 6, | |
| 129 | 'july', 'jul' => 7, | |
| 130 | 'august', 'aug' => 8, | |
| 131 | 'september', 'sep', 'sept' => 9, | |
| 132 | 'october', 'oct' => 10, | |
| 133 | 'november', 'nov' => 11, | |
| 134 | 'december', 'dec' => 12, | |
| 135 | default => null, | |
| 136 | }; | |
| 137 | } | |
| 138 | } |