Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
90 / 99
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
DateFormatter
90.91% covered (success)
90.91%
90 / 99
50.00% covered (danger)
50.00%
3 / 6
33.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 getInstance
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 reformat
96.83% covered (success)
96.83%
61 / 63
0.00% covered (danger)
0.00%
0 / 1
24
 makeIsoMonth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 makeIsoYear
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 makeNormalYear
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Date formatter
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Parser
22 */
23
24use MediaWiki\Html\Html;
25use MediaWiki\MediaWikiServices;
26
27/**
28 * Date formatter. Recognises dates and formats them according to a specified preference.
29 *
30 * This class was originally introduced to detect and transform dates in free text. It is now
31 * only used by the {{#dateformat}} parser function. This is a very rudimentary date formatter;
32 * Language::sprintfDate() has many more features and is the correct choice for most new code.
33 * The main advantage of this date formatter is that it is able to format incomplete dates with an
34 * unspecified year.
35 *
36 * @ingroup Parser
37 */
38class DateFormatter {
39    /** @var string[] Date format regexes indexed the class constants */
40    private $regexes;
41
42    /**
43     * @var int[][] Array of special rules. The first key is the preference ID
44     * (one of the class constants), the second key is the detected source
45     * format, and the value is the ID of the target format that will be used
46     * in that case.
47     */
48    private const RULES = [
49        self::ALL => [
50            self::MD => self::MD,
51            self::DM => self::DM,
52        ],
53        self::NONE => [
54            self::ISO => self::ISO,
55        ],
56        self::MDY => [
57            self::DM => self::MD,
58        ],
59        self::DMY => [
60            self::MD => self::DM,
61        ],
62    ];
63
64    /**
65     * @var array<string,int> Month numbers by lowercase name
66     */
67    private $xMonths = [];
68
69    /**
70     * @var array<int,string> Month names by number
71     */
72    private $monthNames = [];
73
74    /**
75     * @var int[] A map of descriptive preference text to internal format ID
76     */
77    private const PREFERENCE_IDS = [
78        'default' => self::NONE,
79        'dmy' => self::DMY,
80        'mdy' => self::MDY,
81        'ymd' => self::YMD,
82        'ISO 8601' => self::ISO,
83    ];
84
85    /** @var string[] Format strings similar to those used by date(), indexed by ID */
86    private const TARGET_FORMATS = [
87        self::MDY => 'F j, Y',
88        self::DMY => 'j F Y',
89        self::YMD => 'Y F j',
90        self::ISO => 'y-m-d',
91        self::YDM => 'Y, j F',
92        self::DM => 'j F',
93        self::MD => 'F j',
94    ];
95
96    /** Used as a preference ID for rules that apply regardless of preference */
97    private const ALL = -1;
98
99    /** No preference: the date may be left in the same format as the input */
100    private const NONE = 0;
101
102    /** e.g. January 15, 2001 */
103    private const MDY = 1;
104
105    /** e.g. 15 January 2001 */
106    private const DMY = 2;
107
108    /** e.g. 2001 January 15 */
109    private const YMD = 3;
110
111    /** e.g. 2001-01-15 */
112    private const ISO = 4;
113
114    /** e.g. 2001, 15 January */
115    private const YDM = 5;
116
117    /** e.g. 15 January */
118    private const DM = 6;
119
120    /** e.g. January 15 */
121    private const MD = 7;
122
123    /**
124     * @param Language $lang In which language to format the date
125     */
126    public function __construct( Language $lang ) {
127        $monthRegexParts = [];
128        for ( $i = 1; $i <= 12; $i++ ) {
129            $monthName = $lang->getMonthName( $i );
130            $monthAbbrev = $lang->getMonthAbbreviation( $i );
131            $this->monthNames[$i] = $monthName;
132            $monthRegexParts[] = preg_quote( $monthName, '/' );
133            $monthRegexParts[] = preg_quote( $monthAbbrev, '/' );
134            $this->xMonths[mb_strtolower( $monthName )] = $i;
135            $this->xMonths[mb_strtolower( $monthAbbrev )] = $i;
136        }
137
138        // Partial regular expressions
139        $monthNames = implode( '|', $monthRegexParts );
140        $dm = "(?<day>\d{1,2})[ _](?<monthName>{$monthNames})";
141        $md = "(?<monthName>{$monthNames})[ _](?<day>\d{1,2})";
142        $y = '(?<year>\d{1,4}([ _]BC|))';
143        $iso = '(?<isoYear>-?\d{4})-(?<isoMonth>\d{2})-(?<isoDay>\d{2})';
144
145        $this->regexes = [
146            self::DMY => "/^{$dm}(?: *, *| +){$y}$/iu",
147            self::YDM => "/^{$y}(?: *, *| +){$dm}$/iu",
148            self::MDY => "/^{$md}(?: *, *| +){$y}$/iu",
149            self::YMD => "/^{$y}(?: *, *| +){$md}$/iu",
150            self::DM => "/^{$dm}$/iu",
151            self::MD => "/^{$md}$/iu",
152            self::ISO => "/^{$iso}$/iu",
153        ];
154    }
155
156    /**
157     * Get a DateFormatter object
158     *
159     * @deprecated since 1.33 use MediaWikiServices::getDateFormatterFactory()
160     *
161     * @param Language|null $lang In which language to format the date
162     *     Defaults to the site content language
163     * @return DateFormatter
164     */
165    public static function getInstance( Language $lang = null ) {
166        $lang ??= MediaWikiServices::getInstance()->getContentLanguage();
167        return MediaWikiServices::getInstance()->getDateFormatterFactory()->get( $lang );
168    }
169
170    /**
171     * @param string $preference User preference, must be one of "default",
172     *   "dmy", "mdy", "ymd" or "ISO 8601".
173     * @param string $text Text to reformat
174     * @param array $options Ignored. Since 1.33, 'match-whole' is implied, and
175     *  'linked' has been removed.
176     *
177     * @return string
178     */
179    public function reformat( $preference, $text, $options = [] ) {
180        $userFormatId = self::PREFERENCE_IDS[$preference] ?? self::NONE;
181        foreach ( self::TARGET_FORMATS as $source => $_ ) {
182            if ( isset( self::RULES[$userFormatId][$source] ) ) {
183                # Specific rules
184                $target = self::RULES[$userFormatId][$source];
185            } elseif ( isset( self::RULES[self::ALL][$source] ) ) {
186                # General rules
187                $target = self::RULES[self::ALL][$source];
188            } elseif ( $userFormatId ) {
189                # User preference
190                $target = $userFormatId;
191            } else {
192                # Default
193                $target = $source;
194            }
195            $format = self::TARGET_FORMATS[$target];
196            $regex = $this->regexes[$source];
197
198            $text = preg_replace_callback( $regex,
199                function ( $match ) use ( $format ) {
200                    $text = '';
201
202                    // Pre-generate y/Y stuff because we need the year for the <span> title.
203                    if ( !isset( $match['isoYear'] ) && isset( $match['year'] ) ) {
204                        $match['isoYear'] = $this->makeIsoYear( $match['year'] );
205                    }
206                    if ( !isset( $match['year'] ) && isset( $match['isoYear'] ) ) {
207                        $match['year'] = $this->makeNormalYear( $match['isoYear'] );
208                    }
209
210                    if ( !isset( $match['isoMonth'] ) ) {
211                        $m = $this->makeIsoMonth( $match['monthName'] );
212                        if ( $m === null ) {
213                            // Fail
214                            return $match[0];
215                        }
216                        $match['isoMonth'] = $m;
217                    }
218
219                    if ( !isset( $match['isoDay'] ) ) {
220                        $match['isoDay'] = sprintf( '%02d', $match['day'] );
221                    }
222
223                    $formatLength = strlen( $format );
224                    for ( $p = 0; $p < $formatLength; $p++ ) {
225                        $char = $format[$p];
226                        switch ( $char ) {
227                            case 'd': // ISO day of month
228                                $text .= $match['isoDay'];
229                                break;
230                            case 'm': // ISO month
231                                $text .= $match['isoMonth'];
232                                break;
233                            case 'y': // ISO year
234                                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
235                                $text .= $match['isoYear'];
236                                break;
237                            case 'j': // ordinary day of month
238                                if ( !isset( $match['day'] ) ) {
239                                    $text .= intval( $match['isoDay'] );
240                                } else {
241                                    $text .= $match['day'];
242                                }
243                                break;
244                            case 'F': // long month
245                                $m = intval( $match['isoMonth'] );
246                                if ( $m > 12 || $m < 1 ) {
247                                    // Fail
248                                    return $match[0];
249                                }
250                                $text .= $this->monthNames[$m];
251                                break;
252                            case 'Y': // ordinary (optional BC) year
253                                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
254                                $text .= $match['year'];
255                                break;
256                            default:
257                                $text .= $char;
258                        }
259                    }
260
261                    $isoBits = [];
262                    if ( isset( $match['isoYear'] ) ) {
263                        $isoBits[] = $match['isoYear'];
264                    }
265                    $isoBits[] = $match['isoMonth'];
266                    $isoBits[] = $match['isoDay'];
267                    $isoDate = implode( '-', $isoBits );
268
269                    // Output is not strictly HTML (it's wikitext), but <span> is allowed.
270                    return Html::rawElement( 'span',
271                        [ 'class' => 'mw-formatted-date', 'title' => $isoDate ], $text );
272                }, $text
273            );
274        }
275        return $text;
276    }
277
278    /**
279     * @param string $monthName
280     * @return string|null 2-digit month number, e.g. "02", or null if the input was invalid
281     */
282    private function makeIsoMonth( $monthName ) {
283        $number = $this->xMonths[mb_strtolower( $monthName )] ?? null;
284        return $number !== null ? sprintf( '%02d', $number ) : null;
285    }
286
287    /**
288     * Make an ISO year from a year name, for instance: '-1199' from '1200 BC'
289     * @param string $year Year name
290     * @return string ISO year name
291     */
292    private function makeIsoYear( $year ) {
293        // Assumes the year is in a nice format, as enforced by the regex
294        if ( substr( $year, -2 ) == 'BC' ) {
295            $num = intval( substr( $year, 0, -3 ) ) - 1;
296            // PHP bug note: sprintf( "%04d", -1 ) fails poorly
297            $text = sprintf( '-%04d', $num );
298        } else {
299            $text = sprintf( '%04d', $year );
300        }
301        return $text;
302    }
303
304    /**
305     * Make a year from an ISO year, for instance: '400 BC' from '-0399'.
306     * @param string $iso ISO year
307     * @return int|string int representing year number in case of AD dates, or string containing
308     *   year number and 'BC' at the end otherwise.
309     */
310    private function makeNormalYear( $iso ) {
311        if ( $iso <= 0 ) {
312            $text = ( intval( substr( $iso, 1 ) ) + 1 ) . ' BC';
313        } else {
314            $text = intval( $iso );
315        }
316        return $text;
317    }
318}