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