Mark ParserOptions suppressSectionEditLinks as safe to cache
[mediawiki.git] / includes / parser / DateFormatter.php
blobf46143ea82214216d8c2c3993c2b92f2119631d8
1 <?php
2 /**
3 * Date formatter
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.
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.
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
20 * @file
21 * @ingroup Parser
24 use MediaWiki\Html\Html;
25 use MediaWiki\MediaWikiServices;
27 /**
28 * Date formatter. Recognises dates and formats them according to a specified preference.
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.
36 * @ingroup Parser
38 class DateFormatter {
39 /** @var string[] Date format regexes indexed the class constants */
40 private $regexes;
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.
48 private const RULES = [
49 self::ALL => [
50 self::MD => self::MD,
51 self::DM => self::DM,
53 self::NONE => [
54 self::ISO => self::ISO,
56 self::MDY => [
57 self::DM => self::MD,
59 self::DMY => [
60 self::MD => self::DM,
64 /**
65 * @var array<string,int> Month numbers by lowercase name
67 private $xMonths = [];
69 /**
70 * @var array<int,string> Month names by number
72 private $monthNames = [];
74 /**
75 * @var int[] A map of descriptive preference text to internal format ID
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,
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',
96 /** Used as a preference ID for rules that apply regardless of preference */
97 private const ALL = -1;
99 /** No preference: the date may be left in the same format as the input */
100 private const NONE = 0;
102 /** e.g. January 15, 2001 */
103 private const MDY = 1;
105 /** e.g. 15 January 2001 */
106 private const DMY = 2;
108 /** e.g. 2001 January 15 */
109 private const YMD = 3;
111 /** e.g. 2001-01-15 */
112 private const ISO = 4;
114 /** e.g. 2001, 15 January */
115 private const YDM = 5;
117 /** e.g. 15 January */
118 private const DM = 6;
120 /** e.g. January 15 */
121 private const MD = 7;
124 * @param Language $lang In which language to format the date
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;
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})';
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",
157 * Get a DateFormatter object
159 * @deprecated since 1.33 use MediaWikiServices::getDateFormatterFactory()
161 * @param Language|null $lang In which language to format the date
162 * Defaults to the site content language
163 * @return DateFormatter
165 public static function getInstance( Language $lang = null ) {
166 $lang ??= MediaWikiServices::getInstance()->getContentLanguage();
167 return MediaWikiServices::getInstance()->getDateFormatterFactory()->get( $lang );
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.
177 * @return string
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;
195 $format = self::TARGET_FORMATS[$target];
196 $regex = $this->regexes[$source];
198 $text = preg_replace_callback( $regex,
199 function ( $match ) use ( $format ) {
200 $text = '';
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'] );
206 if ( !isset( $match['year'] ) && isset( $match['isoYear'] ) ) {
207 $match['year'] = $this->makeNormalYear( $match['isoYear'] );
210 if ( !isset( $match['isoMonth'] ) ) {
211 $m = $this->makeIsoMonth( $match['monthName'] );
212 if ( $m === null ) {
213 // Fail
214 return $match[0];
216 $match['isoMonth'] = $m;
219 if ( !isset( $match['isoDay'] ) ) {
220 $match['isoDay'] = sprintf( '%02d', $match['day'] );
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'];
243 break;
244 case 'F': // long month
245 $m = intval( $match['isoMonth'] );
246 if ( $m > 12 || $m < 1 ) {
247 // Fail
248 return $match[0];
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;
261 $isoBits = [];
262 if ( isset( $match['isoYear'] ) ) {
263 $isoBits[] = $match['isoYear'];
265 $isoBits[] = $match['isoMonth'];
266 $isoBits[] = $match['isoDay'];
267 $isoDate = implode( '-', $isoBits );
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
275 return $text;
279 * @param string $monthName
280 * @return string|null 2-digit month number, e.g. "02", or null if the input was invalid
282 private function makeIsoMonth( $monthName ) {
283 $number = $this->xMonths[mb_strtolower( $monthName )] ?? null;
284 return $number !== null ? sprintf( '%02d', $number ) : null;
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
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 );
301 return $text;
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.
310 private function makeNormalYear( $iso ) {
311 if ( $iso <= 0 ) {
312 $text = ( intval( substr( $iso, 1 ) ) + 1 ) . ' BC';
313 } else {
314 $text = intval( $iso );
316 return $text;