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 | * @license GPL-2.0-or-later |
| 6 | * @file |
| 7 | * @ingroup Parser |
| 8 | */ |
| 9 | |
| 10 | namespace MediaWiki\Parser; |
| 11 | |
| 12 | use MediaWiki\Html\Html; |
| 13 | use MediaWiki\Language\Language; |
| 14 | use 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 | */ |
| 27 | class 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 */ |
| 308 | class_alias( DateFormatter::class, 'DateFormatter' ); |