Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.91% |
90 / 99 |
|
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 | use MediaWiki\Html\Html; |
25 | use 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 | */ |
38 | class 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 | } |