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