Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
23.21% |
13 / 56 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
| UserTimeCorrection | |
23.21% |
13 / 56 |
|
60.00% |
6 / 10 |
437.46 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getCorrectionType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getTimeOffset | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getTimeOffsetInterval | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| getTimeZone | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isValid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parse | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
210 | |||
| formatTimezoneOffset | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| toString | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
42 | |||
| __toString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\User; |
| 8 | |
| 9 | use DateInterval; |
| 10 | use DateTime; |
| 11 | use DateTimeZone; |
| 12 | use Exception; |
| 13 | use MediaWiki\Utils\MWTimestamp; |
| 14 | use Stringable; |
| 15 | use Wikimedia\RequestTimeout\TimeoutException; |
| 16 | |
| 17 | /** |
| 18 | * Utility class to parse the TimeCorrection string value. |
| 19 | * |
| 20 | * These values are used to specify the time offset for a user and are stored in |
| 21 | * the database as a user preference and returned by the preferences APIs |
| 22 | * |
| 23 | * The class will correct invalid input and adjusts timezone offsets to applicable dates, |
| 24 | * taking into account DST etc. |
| 25 | * |
| 26 | * @since 1.37 |
| 27 | * @ingroup User |
| 28 | * @author Derk-Jan Hartman <hartman.wiki@gmail.com> |
| 29 | */ |
| 30 | class UserTimeCorrection implements Stringable { |
| 31 | |
| 32 | /** |
| 33 | * @var string (default) Time correction based on the MediaWiki's system offset from UTC. |
| 34 | * The System offset can be configured with wgLocalTimezone and/or wgLocalTZoffset |
| 35 | */ |
| 36 | public const SYSTEM = 'System'; |
| 37 | |
| 38 | /** @var string Time correction based on a user defined offset from UTC */ |
| 39 | public const OFFSET = 'Offset'; |
| 40 | |
| 41 | /** @var string Time correction based on a user defined timezone */ |
| 42 | public const ZONEINFO = 'ZoneInfo'; |
| 43 | |
| 44 | /** @var DateTime */ |
| 45 | private $date; |
| 46 | |
| 47 | /** @var bool */ |
| 48 | private $valid; |
| 49 | |
| 50 | /** @var string */ |
| 51 | private $correctionType; |
| 52 | |
| 53 | /** @var int Offset in minutes */ |
| 54 | private $offset; |
| 55 | |
| 56 | /** @var DateTimeZone|null */ |
| 57 | private $timeZone; |
| 58 | |
| 59 | /** |
| 60 | * @param string $timeCorrection Original time correction string |
| 61 | * @param DateTime|null $relativeToDate The date used to calculate the time zone offset of. |
| 62 | * This defaults to the current date and time. |
| 63 | * @param int $systemOffset Offset for self::SYSTEM in minutes |
| 64 | */ |
| 65 | public function __construct( |
| 66 | string $timeCorrection, |
| 67 | ?DateTime $relativeToDate = null, |
| 68 | int $systemOffset = 0 |
| 69 | ) { |
| 70 | $this->date = $relativeToDate ?? new DateTime( '@' . MWTimestamp::time() ); |
| 71 | $this->valid = false; |
| 72 | $this->parse( $timeCorrection, $systemOffset ); |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * Get time offset for a user |
| 77 | * |
| 78 | * @return string Offset that was applied to the user |
| 79 | */ |
| 80 | public function getCorrectionType(): string { |
| 81 | return $this->correctionType; |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Get corresponding time offset for this correction |
| 86 | * Note: When correcting dates/times, apply only the offset OR the time zone, not both. |
| 87 | * @return int Offset in minutes |
| 88 | */ |
| 89 | public function getTimeOffset(): int { |
| 90 | return $this->offset; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Get corresponding time offset for this correction |
| 95 | * Note: When correcting dates/times, apply only the offset OR the time zone, not both. |
| 96 | * @return DateInterval Offset in minutes as a DateInterval |
| 97 | */ |
| 98 | public function getTimeOffsetInterval(): DateInterval { |
| 99 | $offset = abs( $this->offset ); |
| 100 | $interval = new DateInterval( "PT{$offset}M" ); |
| 101 | if ( $this->offset < 1 ) { |
| 102 | $interval->invert = 1; |
| 103 | } |
| 104 | return $interval; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * The time zone if known |
| 109 | * Note: When correcting dates/times, apply only the offset OR the time zone, not both. |
| 110 | * @return DateTimeZone|null |
| 111 | */ |
| 112 | public function getTimeZone(): ?DateTimeZone { |
| 113 | return $this->timeZone; |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Was the original correction specification valid |
| 118 | */ |
| 119 | public function isValid(): bool { |
| 120 | return $this->valid; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Parse the timecorrection string as stored in the database for a user |
| 125 | * or as entered into the Preferences form field |
| 126 | * |
| 127 | * There can be two forms of these strings: |
| 128 | * 1. A pipe separated tuple of a maximum of 3 fields |
| 129 | * - Field 1 is the type of offset definition |
| 130 | * - Field 2 is the offset in minutes from UTC (ignored for System type) |
| 131 | * FIXME Since it's ignored, remove the offset from System everywhere. |
| 132 | * - Field 3 is a timezone identifier from the tz database (only required for ZoneInfo type) |
| 133 | * - The offset for a ZoneInfo type is unreliable because of DST. |
| 134 | * After retrieving it from the database, it should be recalculated based on the TZ identifier. |
| 135 | * Examples: |
| 136 | * - System |
| 137 | * - System|60 |
| 138 | * - Offset|60 |
| 139 | * - ZoneInfo|60|Europe/Amsterdam |
| 140 | * |
| 141 | * 2. The following form provides an offset in hours and minutes |
| 142 | * This currently should only be used by the preferences input field, |
| 143 | * but historically they were present in the database. |
| 144 | * TODO: write a maintenance script to migrate these old db values |
| 145 | * Examples: |
| 146 | * - 16:00 |
| 147 | * - 10 |
| 148 | * |
| 149 | * @param string $timeCorrection |
| 150 | * @param int $systemOffset |
| 151 | */ |
| 152 | private function parse( string $timeCorrection, int $systemOffset ) { |
| 153 | $data = explode( '|', $timeCorrection, 3 ); |
| 154 | |
| 155 | // First handle the case of an actual timezone being specified. |
| 156 | if ( $data[0] === self::ZONEINFO ) { |
| 157 | try { |
| 158 | $this->correctionType = self::ZONEINFO; |
| 159 | $this->timeZone = new DateTimeZone( $data[2] ); |
| 160 | $this->offset = (int)floor( $this->timeZone->getOffset( $this->date ) / 60 ); |
| 161 | $this->valid = true; |
| 162 | return; |
| 163 | } catch ( TimeoutException $e ) { |
| 164 | throw $e; |
| 165 | } catch ( Exception ) { |
| 166 | // Not a valid/known timezone. |
| 167 | // Fall back to any specified offset |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // If $timeCorrection is in fact a pipe-separated value, check the |
| 172 | // first value. |
| 173 | switch ( $data[0] ) { |
| 174 | case self::OFFSET: |
| 175 | case self::ZONEINFO: |
| 176 | $this->correctionType = self::OFFSET; |
| 177 | // First value is Offset, so use the specified offset |
| 178 | $this->offset = (int)( $data[1] ?? 0 ); |
| 179 | // If this is ZoneInfo, then we didn't recognize the TimeZone |
| 180 | $this->valid = isset( $data[1] ) && $data[0] === self::OFFSET; |
| 181 | break; |
| 182 | case self::SYSTEM: |
| 183 | $this->correctionType = self::SYSTEM; |
| 184 | $this->offset = $systemOffset; |
| 185 | $this->valid = true; |
| 186 | break; |
| 187 | default: |
| 188 | // $timeCorrection actually isn't a pipe separated value, but instead |
| 189 | // a colon separated value. This is only used by the HTMLTimezoneField userinput |
| 190 | // but can also still be present in the Db. (but shouldn't be) |
| 191 | $this->correctionType = self::OFFSET; |
| 192 | $data = explode( ':', $timeCorrection, 2 ); |
| 193 | if ( count( $data ) >= 2 ) { |
| 194 | // Combination hours and minutes. |
| 195 | $this->offset = abs( (int)$data[0] ) * 60 + (int)$data[1]; |
| 196 | if ( (int)$data[0] < 0 ) { |
| 197 | $this->offset *= -1; |
| 198 | } |
| 199 | $this->valid = true; |
| 200 | } elseif ( preg_match( '/^[+-]?\d+$/', $data[0] ) ) { |
| 201 | // Just hours. |
| 202 | $this->offset = (int)$data[0] * 60; |
| 203 | $this->valid = true; |
| 204 | } else { |
| 205 | // We really don't know this. Fallback to System |
| 206 | $this->correctionType = self::SYSTEM; |
| 207 | $this->offset = $systemOffset; |
| 208 | return; |
| 209 | } |
| 210 | break; |
| 211 | } |
| 212 | |
| 213 | // Max is +14:00 and min is -12:00, see: |
| 214 | // https://en.wikipedia.org/wiki/Timezone |
| 215 | if ( $this->offset < -12 * 60 || $this->offset > 14 * 60 ) { |
| 216 | $this->valid = false; |
| 217 | } |
| 218 | // 14:00 |
| 219 | $this->offset = min( $this->offset, 14 * 60 ); |
| 220 | // -12:00 |
| 221 | $this->offset = max( $this->offset, -12 * 60 ); |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Converts a timezone offset in minutes (e.g., "120") to an hh:mm string like "+02:00". |
| 226 | * @param int $offset |
| 227 | * @return string |
| 228 | */ |
| 229 | public static function formatTimezoneOffset( int $offset ): string { |
| 230 | $hours = $offset > 0 ? floor( $offset / 60 ) : ceil( $offset / 60 ); |
| 231 | return sprintf( '%+03d:%02d', $hours, abs( $offset ) % 60 ); |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * Note: The string value of this object might not be equal to the original value |
| 236 | * @return string a timecorrection string representing this value |
| 237 | */ |
| 238 | public function toString(): string { |
| 239 | switch ( $this->correctionType ) { |
| 240 | case self::ZONEINFO: |
| 241 | if ( $this->timeZone ) { |
| 242 | return "ZoneInfo|{$this->offset}|{$this->timeZone->getName()}"; |
| 243 | } |
| 244 | // If not, fallback: |
| 245 | case self::SYSTEM: |
| 246 | case self::OFFSET: |
| 247 | default: |
| 248 | return "{$this->correctionType}|{$this->offset}"; |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | public function __toString() { |
| 253 | return $this->toString(); |
| 254 | } |
| 255 | } |