Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.00% |
90 / 100 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
DateFormatter | |
90.91% |
90 / 99 |
|
50.00% |
3 / 6 |
33.82 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
2 | |||
getInstance | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
reformat | |
96.83% |
61 / 63 |
|
0.00% |
0 / 1 |
24 | |||
makeIsoMonth | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
makeIsoYear | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
makeNormalYear | |
100.00% |
4 / 4 |
|
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 | |
24 | namespace MediaWiki\Parser; |
25 | |
26 | use MediaWiki\Html\Html; |
27 | use MediaWiki\Language\Language; |
28 | use 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 | */ |
41 | class 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 */ |
324 | class_alias( DateFormatter::class, 'DateFormatter' ); |