Failed Conditions
Pull Request — master (#3876)
by Abdul Malik
22:45 queued 13:31
created

Date   F

Complexity

Total Complexity 80

Size/Duplication

Total Lines 529
Duplicated Lines 0 %

Test Coverage

Coverage 99.46%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 80
eloc 195
dl 0
loc 529
ccs 185
cts 186
cp 0.9946
rs 2
c 2
b 0
f 0

How to fix   Complexity   

Complex Class

Complex classes like Date often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Date, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace PhpOffice\PhpSpreadsheet\Shared;
4
5
use DateTime;
6
use DateTimeInterface;
7
use DateTimeZone;
8
use PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
9
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
10
use PhpOffice\PhpSpreadsheet\Cell\Cell;
11
use PhpOffice\PhpSpreadsheet\Exception;
12
use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException;
13
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDate;
14
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
15
16
class Date
17
{
18
    /** constants */
19
    public const CALENDAR_WINDOWS_1900 = 1900; //    Base date of 1st Jan 1900 = 1.0
20
    public const CALENDAR_MAC_1904 = 1904; //    Base date of 2nd Jan 1904 = 1.0
21
22
    /**
23
     * Names of the months of the year, indexed by shortname
24
     * Planned usage for locale settings.
25
     *
26
     * @var string[]
27
     */
28
    public static array $monthNames = [
29
        'Jan' => 'January',
30
        'Feb' => 'February',
31
        'Mar' => 'March',
32
        'Apr' => 'April',
33
        'May' => 'May',
34
        'Jun' => 'June',
35
        'Jul' => 'July',
36
        'Aug' => 'August',
37
        'Sep' => 'September',
38
        'Oct' => 'October',
39
        'Nov' => 'November',
40
        'Dec' => 'December',
41
    ];
42
43
    /**
44
     * @var string[]
45
     */
46
    public static array $numberSuffixes = [
47
        'st',
48
        'nd',
49
        'rd',
50
        'th',
51
    ];
52
53
    /**
54
     * Base calendar year to use for calculations
55
     * Value is either CALENDAR_WINDOWS_1900 (1900) or CALENDAR_MAC_1904 (1904).
56
     */
57
    protected static int $excelCalendar = self::CALENDAR_WINDOWS_1900;
58
59
    /**
60
     * Default timezone to use for DateTime objects.
61
     */
62
    protected static ?DateTimeZone $defaultTimeZone = null;
63
64
    /**
65
     * Set the Excel calendar (Windows 1900 or Mac 1904).
66
     *
67
     * @param int $baseYear Excel base date (1900 or 1904)
68
     *
69
     * @return bool Success or failure
70
     */
71 1872
    public static function setExcelCalendar(int $baseYear): bool
72
    {
73
        if (
74 1872
            ($baseYear == self::CALENDAR_WINDOWS_1900)
75 1872
            || ($baseYear == self::CALENDAR_MAC_1904)
76
        ) {
77 1872
            self::$excelCalendar = $baseYear;
78
79 1872
            return true;
80
        }
81
82 1
        return false;
83
    }
84
85
    /**
86
     * Return the Excel calendar (Windows 1900 or Mac 1904).
87
     *
88
     * @return int Excel base date (1900 or 1904)
89
     */
90 3668
    public static function getExcelCalendar(): int
91
    {
92 3668
        return self::$excelCalendar;
93
    }
94
95
    /**
96
     * Set the Default timezone to use for dates.
97
     *
98
     * @param null|DateTimeZone|string $timeZone The timezone to set for all Excel datetimestamp to PHP DateTime Object conversions
99
     *
100
     * @return bool Success or failure
101
     */
102 140
    public static function setDefaultTimezone($timeZone): bool
103
    {
104
        try {
105 140
            $timeZone = self::validateTimeZone($timeZone);
106 140
            self::$defaultTimeZone = $timeZone;
107 140
            $retval = true;
108 1
        } catch (PhpSpreadsheetException) {
109 1
            $retval = false;
110
        }
111
112 140
        return $retval;
113
    }
114
115
    /**
116
     * Return the Default timezone, or UTC if default not set.
117
     */
118 2308
    public static function getDefaultTimezone(): DateTimeZone
119
    {
120 2308
        return self::$defaultTimeZone ?? new DateTimeZone('UTC');
121
    }
122
123
    /**
124
     * Return the Default timezone, or local timezone if default is not set.
125
     */
126 865
    public static function getDefaultOrLocalTimezone(): DateTimeZone
127
    {
128 865
        return self::$defaultTimeZone ?? new DateTimeZone(date_default_timezone_get());
129
    }
130
131
    /**
132
     * Return the Default timezone even if null.
133
     */
134 140
    public static function getDefaultTimezoneOrNull(): ?DateTimeZone
135
    {
136 140
        return self::$defaultTimeZone;
137
    }
138
139
    /**
140
     * Validate a timezone.
141
     *
142
     * @param null|DateTimeZone|string $timeZone The timezone to validate, either as a timezone string or object
143
     *
144
     * @return ?DateTimeZone The timezone as a timezone object
145
     */
146 158
    private static function validateTimeZone($timeZone): ?DateTimeZone
147
    {
148 158
        if ($timeZone instanceof DateTimeZone || $timeZone === null) {
149 158
            return $timeZone;
150
        }
151 26
        if (in_array($timeZone, DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC))) {
152 25
            return new DateTimeZone($timeZone);
153
        }
154
155 1
        throw new PhpSpreadsheetException('Invalid timezone');
156
    }
157
158
    /**
159
     * @param mixed $value Converts a date/time in ISO-8601 standard format date string to an Excel
160
     *                         serialized timestamp.
161
     *                     See https://en.wikipedia.org/wiki/ISO_8601 for details of the ISO-8601 standard format.
162
     */
163 18
    public static function convertIsoDate(mixed $value): float|int
164
    {
165 18
        if (!is_string($value)) {
166 1
            throw new Exception('Non-string value supplied for Iso Date conversion');
167
        }
168
169 17
        $date = new DateTime($value);
170 17
        $dateErrors = DateTime::getLastErrors();
171
172 17
        if (is_array($dateErrors) && ($dateErrors['warning_count'] > 0 || $dateErrors['error_count'] > 0)) {
173 1
            throw new Exception("Invalid string $value supplied for datatype Date");
174
        }
175
176 16
        $newValue = SharedDate::PHPToExcel($date);
177 16
        if ($newValue === false) {
178
            throw new Exception("Invalid string $value supplied for datatype Date");
179
        }
180
181 16
        if (preg_match('/^\\s*\\d?\\d:\\d\\d(:\\d\\d([.]\\d+)?)?\\s*(am|pm)?\\s*$/i', $value) == 1) {
182 4
            $newValue = fmod($newValue, 1.0);
183
        }
184
185 16
        return $newValue;
186
    }
187
188
    /**
189
     * Convert a MS serialized datetime value from Excel to a PHP Date/Time object.
190
     *
191
     * @param float|int $excelTimestamp MS Excel serialized date/time value
192
     * @param null|DateTimeZone|string $timeZone The timezone to assume for the Excel timestamp,
193
     *                                           if you don't want to treat it as a UTC value
194
     *                                           Use the default (UTC) unless you absolutely need a conversion
195
     *
196
     * @return DateTime PHP date/time object
197
     */
198 2345
    public static function excelToDateTimeObject(float|int $excelTimestamp, null|DateTimeZone|string $timeZone = null): DateTime
199
    {
200 2345
        $timeZone = ($timeZone === null) ? self::getDefaultTimezone() : self::validateTimeZone($timeZone);
201 2345
        if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_EXCEL) {
202 2323
            if ($excelTimestamp < 1 && self::$excelCalendar === self::CALENDAR_WINDOWS_1900) {
203
                // Unix timestamp base date
204 188
                $baseDate = new DateTime('1970-01-01', $timeZone);
205
            } else {
206
                // MS Excel calendar base dates
207 2146
                if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) {
208
                    // Allow adjustment for 1900 Leap Year in MS Excel
209 2052
                    $baseDate = ($excelTimestamp < 60) ? new DateTime('1899-12-31', $timeZone) : new DateTime('1899-12-30', $timeZone);
210
                } else {
211 2323
                    $baseDate = new DateTime('1904-01-01', $timeZone);
212
                }
213
            }
214
        } else {
215 22
            $baseDate = new DateTime('1899-12-30', $timeZone);
216
        }
217
218 2345
        $days = floor($excelTimestamp);
219 2345
        $partDay = $excelTimestamp - $days;
220 2345
        $hms = 86400 * $partDay;
221 2345
        $microseconds = (int) round(fmod($hms, 1) * 1_000_000);
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_STRING, expecting ',' or ')' on line 221 at column 53
Loading history...
222 2345
        $hms = (int) floor($hms);
223 2345
        $hours = intdiv($hms, 3600);
224 2345
        $hms -= $hours * 3600;
225 2345
        $minutes = intdiv($hms, 60);
226 2345
        $seconds = $hms % 60;
227
228 2345
        if ($days >= 0) {
229 2341
            $days = '+' . $days;
230
        }
231 2345
        $interval = $days . ' days';
232
233 2345
        return $baseDate->modify($interval)
234 2345
            ->setTime($hours, $minutes, $seconds, $microseconds);
235
    }
236
237
    /**
238
     * Convert a MS serialized datetime value from Excel to a unix timestamp.
239
     * The use of Unix timestamps, and therefore this function, is discouraged.
240
     * They are not Y2038-safe on a 32-bit system, and have no timezone info.
241
     *
242
     * @param float|int $excelTimestamp MS Excel serialized date/time value
243
     * @param null|DateTimeZone|string $timeZone The timezone to assume for the Excel timestamp,
244
     *                                               if you don't want to treat it as a UTC value
245
     *                                               Use the default (UTC) unless you absolutely need a conversion
246
     *
247
     * @return int Unix timetamp for this date/time
248
     */
249 55
    public static function excelToTimestamp($excelTimestamp, $timeZone = null): int
250
    {
251 55
        $dto = self::excelToDateTimeObject($excelTimestamp, $timeZone);
252 55
        self::roundMicroseconds($dto);
253
254 55
        return (int) $dto->format('U');
255
    }
256
257
    /**
258
     * Convert a date from PHP to an MS Excel serialized date/time value.
259
     *
260
     * @param mixed $dateValue PHP DateTime object or a string - Unix timestamp is also permitted, but discouraged;
261
     *    not Y2038-safe on a 32-bit system, and no timezone info
262
     *
263
     * @return false|float Excel date/time value
264
     *                                  or boolean FALSE on failure
265
     */
266 292
    public static function PHPToExcel(mixed $dateValue)
267
    {
268 292
        if ((is_object($dateValue)) && ($dateValue instanceof DateTimeInterface)) {
269 287
            return self::dateTimeToExcel($dateValue);
270 11
        } elseif (is_numeric($dateValue)) {
271 10
            return self::timestampToExcel($dateValue);
272 7
        } elseif (is_string($dateValue)) {
273 1
            return self::stringToExcel($dateValue);
274
        }
275
276 6
        return false;
277
    }
278
279
    /**
280
     * Convert a PHP DateTime object to an MS Excel serialized date/time value.
281
     *
282
     * @param DateTimeInterface $dateValue PHP DateTime object
283
     *
284
     * @return float MS Excel serialized date/time value
285
     */
286 341
    public static function dateTimeToExcel(DateTimeInterface $dateValue): float
287
    {
288 341
        $seconds = (float) sprintf('%d.%06d', $dateValue->format('s'), $dateValue->format('u'));
289
290 341
        return self::formattedPHPToExcel(
291 341
            (int) $dateValue->format('Y'),
292 341
            (int) $dateValue->format('m'),
293 341
            (int) $dateValue->format('d'),
294 341
            (int) $dateValue->format('H'),
295 341
            (int) $dateValue->format('i'),
296 341
            $seconds
297 341
        );
298
    }
299
300
    /**
301
     * Convert a Unix timestamp to an MS Excel serialized date/time value.
302
     * The use of Unix timestamps, and therefore this function, is discouraged.
303
     * They are not Y2038-safe on a 32-bit system, and have no timezone info.
304
     *
305
     * @param float|int|string $unixTimestamp Unix Timestamp
306
     *
307
     * @return false|float MS Excel serialized date/time value
308
     */
309 28
    public static function timestampToExcel($unixTimestamp): bool|float
310
    {
311 28
        if (!is_numeric($unixTimestamp)) {
312 1
            return false;
313
        }
314
315 27
        return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp));
316
    }
317
318
    /**
319
     * formattedPHPToExcel.
320
     *
321
     * @return float Excel date/time value
322
     */
323 2738
    public static function formattedPHPToExcel(int $year, int $month, int $day, int $hours = 0, int $minutes = 0, float|int $seconds = 0): float
324
    {
325 2738
        if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) {
326
            //
327
            //    Fudge factor for the erroneous fact that the year 1900 is treated as a Leap Year in MS Excel
328
            //    This affects every date following 28th February 1900
329
            //
330 2671
            $excel1900isLeapYear = true;
331 2671
            if (($year == 1900) && ($month <= 2)) {
332 234
                $excel1900isLeapYear = false;
333
            }
334 2671
            $myexcelBaseDate = 2_415_020;
335
        } else {
336 67
            $myexcelBaseDate = 2_416_481;
337 67
            $excel1900isLeapYear = false;
338
        }
339
340
        //    Julian base date Adjustment
341 2738
        if ($month > 2) {
342 1764
            $month -= 3;
343
        } else {
344 1789
            $month += 9;
345 1789
            --$year;
346
        }
347
348
        //    Calculate the Julian Date, then subtract the Excel base date (JD 2415020 = 31-Dec-1899 Giving Excel Date of 0)
349 2738
        $century = (int) substr((string) $year, 0, 2);
350 2738
        $decade = (int) substr((string) $year, 2, 2);
351 2738
        $excelDate = floor((146097 * $century) / 4) + floor((1461 * $decade) / 4) + floor((153 * $month + 2) / 5) + $day + 1_721_119 - $myexcelBaseDate + $excel1900isLeapYear;
352
353 2738
        $excelTime = (($hours * 3600) + ($minutes * 60) + $seconds) / 86400;
354
355 2738
        return (float) $excelDate + $excelTime;
356
    }
357
358
    /**
359
     * Is a given cell a date/time?
360
     */
361 9
    public static function isDateTime(Cell $cell, mixed $value = null, bool $dateWithoutTimeOkay = true): bool
362
    {
363 9
        $result = false;
364 9
        $worksheet = $cell->getWorksheetOrNull();
365 9
        $spreadsheet = ($worksheet === null) ? null : $worksheet->getParent();
366 9
        if ($worksheet !== null && $spreadsheet !== null) {
367 9
            $index = $spreadsheet->getActiveSheetIndex();
368 9
            $selected = $worksheet->getSelectedCells();
369
370
            try {
371 9
                $result = is_numeric($value ?? $cell->getCalculatedValue())
372 9
                    && self::isDateTimeFormat(
373 9
                        $worksheet->getStyle(
374 9
                            $cell->getCoordinate()
375 9
                        )->getNumberFormat(),
376 9
                        $dateWithoutTimeOkay
377 9
                    );
378 1
            } catch (Exception) {
379
                // Result is already false, so no need to actually do anything here
380
            }
381 9
            $worksheet->setSelectedCells($selected);
382 9
            $spreadsheet->setActiveSheetIndex($index);
383
        }
384
385 9
        return $result;
386
    }
387
388
    /**
389
     * Is a given NumberFormat code a date/time format code?
390
     */
391 9
    public static function isDateTimeFormat(NumberFormat $excelFormatCode, bool $dateWithoutTimeOkay = true): bool
392
    {
393 9
        return self::isDateTimeFormatCode((string) $excelFormatCode->getFormatCode(), $dateWithoutTimeOkay);
394
    }
395
396
    private const POSSIBLE_DATETIME_FORMAT_CHARACTERS = 'eymdHs';
397
    private const POSSIBLE_TIME_FORMAT_CHARACTERS = 'Hs'; // note - no 'm' due to ambiguity
398
399
    /**
400
     * Is a given number format code a date/time?
401
     */
402 67
    public static function isDateTimeFormatCode(string $excelFormatCode, bool $dateWithoutTimeOkay = true): bool
403
    {
404 67
        if (strtolower($excelFormatCode) === strtolower(NumberFormat::FORMAT_GENERAL)) {
405
            //    "General" contains an epoch letter 'e', so we trap for it explicitly here (case-insensitive check)
406 17
            return false;
407
        }
408 55
        if (preg_match('/[0#]E[+-]0/i', $excelFormatCode)) {
409
            //    Scientific format
410 1
            return false;
411
        }
412
413
        // Switch on formatcode
414 55
        if (in_array($excelFormatCode, NumberFormat::DATE_TIME_OR_DATETIME_ARRAY, true)) {
415 26
            return $dateWithoutTimeOkay || in_array($excelFormatCode, NumberFormat::TIME_OR_DATETIME_ARRAY);
416
        }
417
418
        //    Typically number, currency or accounting (or occasionally fraction) formats
419 33
        if ((str_starts_with($excelFormatCode, '_')) || (str_starts_with($excelFormatCode, '0 '))) {
420 1
            return false;
421
        }
422
        // Some "special formats" provided in German Excel versions were detected as date time value,
423
        // so filter them out here - "\C\H\-00000" (Switzerland) and "\D-00000" (Germany).
424 32
        if (str_contains($excelFormatCode, '-00000')) {
425 2
            return false;
426
        }
427 30
        $possibleFormatCharacters = $dateWithoutTimeOkay ? self::POSSIBLE_DATETIME_FORMAT_CHARACTERS : self::POSSIBLE_TIME_FORMAT_CHARACTERS;
428
        // Try checking for any of the date formatting characters that don't appear within square braces
429 30
        if (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $excelFormatCode)) {
430
            //    We might also have a format mask containing quoted strings...
431
            //        we don't want to test for any of our characters within the quoted blocks
432 18
            if (str_contains($excelFormatCode, '"')) {
433 7
                $segMatcher = false;
434 7
                foreach (explode('"', $excelFormatCode) as $subVal) {
435
                    //    Only test in alternate array entries (the non-quoted blocks)
436 7
                    $segMatcher = $segMatcher === false;
437
                    if (
438 7
                        $segMatcher
439 7
                        && (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $subVal))
440
                    ) {
441 6
                        return true;
442
                    }
443
                }
444
445 1
                return false;
446
            }
447
448 16
            return true;
449
        }
450
451
        // No date...
452 17
        return false;
453
    }
454
455
    /**
456
     * Convert a date/time string to Excel time.
457
     *
458
     * @param string $dateValue Examples: '2009-12-31', '2009-12-31 15:59', '2009-12-31 15:59:10'
459
     *
460
     * @return false|float Excel date/time serial value
461
     */
462 177
    public static function stringToExcel(string $dateValue): bool|float
463
    {
464 177
        if (strlen($dateValue) < 2) {
465 26
            return false;
466
        }
467 152
        if (!preg_match('/^(\d{1,4}[ \.\/\-][A-Z]{3,9}([ \.\/\-]\d{1,4})?|[A-Z]{3,9}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?|\d{1,4}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2})?)?$/iu', $dateValue)) {
468 149
            return false;
469
        }
470
471 9
        $dateValueNew = DateTimeExcel\DateValue::fromString($dateValue);
472
473 9
        if (!is_float($dateValueNew)) {
474 2
            return false;
475
        }
476
477 8
        if (str_contains($dateValue, ':')) {
478 2
            $timeValue = DateTimeExcel\TimeValue::fromString($dateValue);
479 2
            if (!is_float($timeValue)) {
480 1
                return false;
481
            }
482 2
            $dateValueNew += $timeValue;
483
        }
484
485 8
        return $dateValueNew;
486
    }
487
488
    /**
489
     * Converts a month name (either a long or a short name) to a month number.
490
     *
491
     * @param string $monthName Month name or abbreviation
492
     *
493
     * @return int|string Month number (1 - 12), or the original string argument if it isn't a valid month name
494
     */
495 9
    public static function monthStringToNumber(string $monthName)
496
    {
497 9
        $monthIndex = 1;
498 9
        foreach (self::$monthNames as $shortMonthName => $longMonthName) {
499 9
            if (($monthName === $longMonthName) || ($monthName === $shortMonthName)) {
500 6
                return $monthIndex;
501
            }
502 9
            ++$monthIndex;
503
        }
504
505 3
        return $monthName;
506
    }
507
508
    /**
509
     * Strips an ordinal from a numeric value.
510
     *
511
     * @param string $day Day number with an ordinal
512
     *
513
     * @return int|string The integer value with any ordinal stripped, or the original string argument if it isn't a valid numeric
514
     */
515 18
    public static function dayStringToNumber(string $day)
516
    {
517 18
        $strippedDayValue = (str_replace(self::$numberSuffixes, '', $day));
518 18
        if (is_numeric($strippedDayValue)) {
519 15
            return (int) $strippedDayValue;
520
        }
521
522 3
        return $day;
523
    }
524
525 871
    public static function dateTimeFromTimestamp(string $date, ?DateTimeZone $timeZone = null): DateTime
526
    {
527 871
        $dtobj = DateTime::createFromFormat('U', $date) ?: new DateTime();
528 871
        $dtobj->setTimeZone($timeZone ?? self::getDefaultOrLocalTimezone());
529
530 871
        return $dtobj;
531
    }
532
533 10
    public static function formattedDateTimeFromTimestamp(string $date, string $format, ?DateTimeZone $timeZone = null): string
534
    {
535 10
        $dtobj = self::dateTimeFromTimestamp($date, $timeZone);
536
537 10
        return $dtobj->format($format);
538
    }
539
540 665
    public static function roundMicroseconds(DateTime $dti): void
541
    {
542 665
        $microseconds = (int) $dti->format('u');
543 665
        if ($microseconds >= 500000) {
544 12
            $dti->modify('+1 second');
545
        }
546
    }
547
}
548