Completed
Pull Request — master (#140)
by
unknown
03:06
created

TimeValueCalculator::getDaysInMonth()   A

Complexity

Conditions 5
Paths 9

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 5.3906

Importance

Changes 0
Metric Value
dl 0
loc 6
ccs 3
cts 4
cp 0.75
rs 9.6111
c 0
b 0
f 0
cc 5
nc 9
nop 3
crap 5.3906
1
<?php
2
3
namespace DataValues;
4
5
use InvalidArgumentException;
6
7
/**
8
 * Logical and mathematical helper functions for calculations with and conversions from TimeValue
9
 * objects.
10
 *
11
 * @since 0.6
12
 *
13
 * @license GPL-2.0+
14
 * @author Thiemo Kreuz
15
 */
16
class TimeValueCalculator {
17
18
	/**
19
	 * Average length of a year in the Gregorian calendar in seconds, calculated via
20
	 * 365 + 1 / 4 - 1 / 100 + 1 / 400 = 365.2425 days.
21
	 */
22
	const SECONDS_PER_GREGORIAN_YEAR = 31556952;
23
24
	/**
25
	 * Maximum length for a timestamp.
26
	 */
27
	private $MAX_LENGTH_TIMESTAMP = 33;
28
29
	/**
30
	 * Lowest positive timestamp.
31
	 */
32
	private $TIMESTAMP_ZERO = '+0000000000000000-01-01T00:00:00Z';
33
34
	/**
35
	 * Highest positive timestamp strictly earlier than the lowest positive timestamp with
36
	 * a length of $MAX_LENGTH_TIMESTAMP + 1.
37
	 */
38
	private $HIGHEST_TIMESTAMP = '+9999999999999999-12-31T23:59:59Z';
39
40
	/**
41
	 * This returns a Unix timestamp from a TimeValue similar to PHP's mk_time() (or strtotime()),
42
	 * but with no range limitations. Data type is float because PHP's 32 bit integer would
43
	 * clip in the year 2038.
44
	 *
45
	 * @param TimeValue $timeValue
46
	 *
47
	 * @return float seconds since 1970-01-01T00:00:00Z
48
	 */
49 754
	public function getTimestamp( TimeValue $timeValue ) {
50 754
		return $this->getSecondsSinceUnixEpoch( $timeValue->getTime(), $timeValue->getTimezone() );
51
	}
52
53
	/**
54
	 * Returns the lowest possible Unix timestamp from a TimeValue considering its precision
55
	 * and its before value. Data type is float because PHP's 32 bit integer would clip in the
56
	 * year 2038.
57
	 *
58
	 * @param TimeValue $timeValue
59
	 *
60
	 * @return float seconds since 1970-01-01T00:00:00Z
61
	 */
62 1085
	public function getLowerTimestamp( TimeValue $timeValue ) {
63 1085
		$precision = $timeValue->getPrecision();
64 1085
		$timestamp = $timeValue->getTime();
65 1085
		if ( $timestamp[0] === '+' || $precision >= TimeValue::PRECISION_YEAR ) {
66 773
			$timestamp = $this->timestampAbsFloor( $timestamp, $precision );
67
		} else {
68
			// digits corresponding to [0, PRECISION_YEAR] must be maximized
69
			// digits corresponding to [PRECISION_MONTH, PRECISION_SECOND] must be minimized
70 324
			$subTimestampLeft = $this->timestampAbsCeiling( $timestamp, $precision );
71 324
			$subTimestampLeft = substr(
72 324
				$subTimestampLeft,
73 324
				0,
74 324
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR )
75
			);
76 324
			$subTimestampRight = $this->timestampAbsFloor( $timestamp, $precision );
77 324
			$subTimestampRight = substr(
78 324
				$subTimestampRight,
79 324
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR )
80
			);
81 324
			$timestamp = $subTimestampLeft . $subTimestampRight;
82
		}
83 1085
		$unixTimestamp = $this->getSecondsSinceUnixEpoch( $timestamp, $timeValue->getTimezone() );
84 1085
		$unixTimestamp -= $timeValue->getBefore() * $this->getSecondsForPrecision( $precision );
85 1085
		return $unixTimestamp;
86
	}
87
88
	/**
89
	 * Returns the highest possible Unix timestamp from a TimeValue considering its precision
90
	 * and its after value. Data type is float because PHP's 32 bit integer would clip in the
91
	 * year 2038.
92
	 *
93
	 * @param TimeValue $timeValue
94
	 *
95
	 * @return float seconds since 1970-01-01T00:00:00Z
96
	 */
97 1085
	public function getHigherTimestamp( TimeValue $timeValue ) {
98 1085
		$precision = $timeValue->getPrecision();
99 1085
		$timestamp = $timeValue->getTime();
100 1085
		if ( $timestamp[0] === '+' || $precision >= TimeValue::PRECISION_YEAR ) {
101 773
			$timestamp = $this->timestampAbsCeiling( $timestamp, $precision );
102
		} else {
103
			// digits corresponding to [0, PRECISION_YEAR] must be minimized
104
			// digits corresponding to [PRECISION_MONTH, PRECISION_SECOND] must be maximized
105 324
			$subTimestampLeft = $this->timestampAbsFloor( $timestamp, $precision );
106 324
			$subTimestampLeft = substr(
107 324
				$subTimestampLeft,
108 324
				0,
109 324
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR )
110
			);
111 324
			$subTimestampRight = $this->timestampAbsCeiling( $timestamp, $precision );
112 324
			$subTimestampRight = substr(
113 324
				$subTimestampRight,
114 324
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR )
115
			);
116 324
			$timestamp = $subTimestampLeft . $subTimestampRight;
117
		}
118 1085
		$unixTimestamp = $this->getSecondsSinceUnixEpoch( $timestamp, $timeValue->getTimezone() );
119 1085
		$unixTimestamp += $timeValue->getAfter() * $this->getSecondsForPrecision( $precision );
120 1085
		return $unixTimestamp;
121
	}
122
123
	/**
124
	 * @param string $time an ISO 8601 date and time
125
	 * @param int $timezone offset from UTC in minutes
126
	 *
127
	 * @throws InvalidArgumentException
128
	 * @return float seconds since 1970-01-01T00:00:00Z
129
	 */
130 2204
	private function getSecondsSinceUnixEpoch( $time, $timezone = 0 ) {
131
		// Validation is done in TimeValue. As long if we found enough numbers we are fine.
132 2204
		if ( !preg_match( '/([-+]?\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)/', $time, $matches )
133
		) {
134
			throw new InvalidArgumentException( "Failed to parse time value $time." );
135
		}
136 2204
		list( , $fullYear, $month, $day, $hour, $minute, $second ) = $matches;
137
138
		// We use mktime only for the month, day and time calculation. Set the year to the smallest
139
		// possible in the 1970-2038 range to be safe, even if it's 1901-2038 since PHP 5.1.0.
140 2204
		$year = $this->isLeapYear( $fullYear ) ? 1972 : 1970;
141
142 2204
		$defaultTimezone = date_default_timezone_get();
143 2204
		date_default_timezone_set( 'UTC' );
144
		// With day/month set to 0 mktime would calculate the last day of the previous month/year.
145
		// In the context of this calculation we must assume 0 means "start of the month/year".
146 2204
		$timestamp = mktime( $hour, $minute, $second, max( 1, $month ), max( 1, $day ), $year );
147 2204
		date_default_timezone_set( $defaultTimezone );
148
149 2204
		if ( $timestamp === false ) {
150
			throw new InvalidArgumentException( "Failed to get epoche from time value $time." );
151
		}
152
153 2204
		$missingYears = ( $fullYear < 0 ? $fullYear + 1 : $fullYear ) - $year;
154 2204
		$missingLeapDays = $this->getNumberOfLeapYears( $fullYear )
155 2204
			- $this->getNumberOfLeapYears( $year );
156
157 2204
		return $timestamp + ( $missingYears * 365 + $missingLeapDays ) * 86400 - $timezone * 60;
158
	}
159
160
	/**
161
	 * @param string $calendar only TimeValue::CALENDAR_GREGORIAN is supported
162
	 * @param float $month
163
	 * @param float $year
164
	 *
165
	 * @throws InvalidArgumentException if $calendar is not supported
166
	 * @return int
167
	 */
168 97
	public function getDaysInMonth( $calendar, $month, $year ) {
169 97
		if ( $calendar !== TimeValue::CALENDAR_GREGORIAN ) {
170
			throw new InvalidArgumentException( "Only Gregorian calendar is supported." );
171
		}
172 97
		return $month == 2 ? ( $this->isLeapYear( $year ) ? 29 : 28 ) : ( ( $month - 1 ) % 7 % 2 ? 30 : 31 );
173
	}
174
175
	/**
176
	 * @param float $year
177
	 *
178
	 * @return bool if the year is a leap year in the Gregorian calendar
179
	 */
180 2238
	public function isLeapYear( $year ) {
181 2238
		$year = $year < 0 ? ceil( $year ) + 1 : floor( $year );
182 2238
		$isMultipleOf4 = fmod( $year, 4 ) === 0.0;
183 2238
		$isMultipleOf100 = fmod( $year, 100 ) === 0.0;
184 2238
		$isMultipleOf400 = fmod( $year, 400 ) === 0.0;
185 2238
		return $isMultipleOf4 && !$isMultipleOf100 || $isMultipleOf400;
186
	}
187
188
	/**
189
	 * @param float $year
190
	 *
191
	 * @return float The number of leap years since year 1. To be more precise: The number of
192
	 * leap days in the range between 31 December of year 1 and 31 December of the given year.
193
	 */
194 2238
	public function getNumberOfLeapYears( $year ) {
195 2238
		$year = $year < 0 ? ceil( $year ) + 1 : floor( $year );
196 2238
		return floor( $year / 4 ) - floor( $year / 100 ) + floor( $year / 400 );
197
	}
198
199
	/**
200
	 * @param int $precision One of the TimeValue::PRECISION_... constants
201
	 *
202
	 * @throws InvalidArgumentException
203
	 * @return float number of seconds in one unit of the given precision
204
	 */
205 2178
	public function getSecondsForPrecision( $precision ) {
206 2178
		if ( $precision <= TimeValue::PRECISION_YEAR ) {
207 1445
			return self::SECONDS_PER_GREGORIAN_YEAR * pow(
208 1445
					10,
209 1445
					TimeValue::PRECISION_YEAR - $precision
210
				);
211
		}
212
213
		switch ( $precision ) {
214 781
			case TimeValue::PRECISION_SECOND:
215 151
				return 1;
216 678
			case TimeValue::PRECISION_MINUTE:
217 193
				return 60;
218 533
			case TimeValue::PRECISION_HOUR:
219 193
				return 3600;
220 388
			case TimeValue::PRECISION_DAY:
221 241
				return 86400;
222 195
			case TimeValue::PRECISION_MONTH:
223 195
				return self::SECONDS_PER_GREGORIAN_YEAR / 12;
224
		}
225
226
		throw new InvalidArgumentException( "Unable to get seconds for precision $precision." );
227
	}
228
229
	/**
230
	 * @param $timestamp
231
	 * @param $precision
232
	 *
233
	 * @return string
234
	 */
235 1409
	private function timestampAbsFloor( $timestamp, $precision ) {
236
		// The year is padded with zeros to have 16 digits
237 1409
		$timestamp = substr_replace(
238 1409
			$timestamp,
239 1409
			str_repeat( '0', $this->MAX_LENGTH_TIMESTAMP - strlen( $timestamp ) ),
240 1409
			1,
241
			0
242
		);
243 1409
		$numCharsToModify = $this->charsAffectedByPrecision( $precision );
244 1409
		$timestamp = substr( $timestamp, 0, -$numCharsToModify ) .
245 1409
			substr( $this->TIMESTAMP_ZERO, -$numCharsToModify );
246 1409
		return $timestamp;
247
	}
248
249
	/**
250
	 * @param $timestamp
251
	 * @param $precision
252
	 *
253
	 * @return string
254
	 */
255 1409
	private function timestampAbsCeiling( $timestamp, $precision ) {
256
		// The year is padded with zeros to have 16 digits
257 1409
		$timestamp = substr_replace(
258 1409
			$timestamp,
259 1409
			str_repeat( '0', $this->MAX_LENGTH_TIMESTAMP - strlen( $timestamp ) ),
260 1409
			1,
261
			0
262
		);
263 1409
		$numCharsToModify = $this->charsAffectedByPrecision( $precision );
264 1409
		$timestampCeiling = substr( $timestamp, 0, -$numCharsToModify ) .
265 1409
			substr( $this->HIGHEST_TIMESTAMP, -$numCharsToModify );
266 1409
		if ( $precision === TimeValue::PRECISION_MONTH ) {
267
			// The highest day (28-31) depends on the month and the year
268 97
			$month = (float)substr(
269 97
				$timestamp,
270 97
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR ),
271 97
				2
272
			);
273 97
			$year = (float)substr(
274 97
				$timestamp,
275 97
				1,
276 97
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR ) - 1
277
			);
278 97
			$daysInMonth = $this->getDaysInMonth( TimeValue::CALENDAR_GREGORIAN, $month, $year );
279 97
			$timestampCeiling = substr( $timestamp, 0, -$numCharsToModify ) .
280 97
				$daysInMonth .
281 97
				substr( $this->HIGHEST_TIMESTAMP, -$numCharsToModify + 2 );
282
		}
283 1409
		return $timestampCeiling;
284
	}
285
286
	/**
287
	 * @param int $precision
288
	 *
289
	 * @return int lowest number of characters in an ISO 8601 timestamp string
290
	 * that are irrelevant given $precision
291
	 */
292 2170
	private function charsAffectedByPrecision( $precision ) {
293 2170
		$numCharsAffected = 1;
294
		switch ( $precision ) {
295 2170
			case TimeValue::PRECISION_MINUTE:
296 192
				$numCharsAffected = 3;
297 192
				break;
298 2074
			case TimeValue::PRECISION_HOUR:
299 192
				$numCharsAffected = 6;
300 192
				break;
301 1930
			case TimeValue::PRECISION_DAY:
302 240
				$numCharsAffected = 9;
303 240
				break;
304 1738
			case TimeValue::PRECISION_MONTH:
305 194
				$numCharsAffected = 12;
306 194
				break;
307 1665
			case TimeValue::PRECISION_YEAR:
308 891
				$numCharsAffected = 15;
309 891
				break;
310 1446
			case TimeValue::PRECISION_YEAR10:
311 192
				$numCharsAffected = 17;
312 192
				break;
313 1302
			case TimeValue::PRECISION_YEAR100:
314 192
				$numCharsAffected = 18;
315 192
				break;
316 1158
			case TimeValue::PRECISION_YEAR1K:
317 192
				$numCharsAffected = 19;
318 192
				break;
319 1014
			case TimeValue::PRECISION_YEAR10K:
320 192
				$numCharsAffected = 20;
321 192
				break;
322 870
			case TimeValue::PRECISION_YEAR100K:
323 192
				$numCharsAffected = 21;
324 192
				break;
325 726
			case TimeValue::PRECISION_YEAR1M:
326 192
				$numCharsAffected = 22;
327 192
				break;
328 582
			case TimeValue::PRECISION_YEAR10M:
329 192
				$numCharsAffected = 23;
330 192
				break;
331 438
			case TimeValue::PRECISION_YEAR100M:
332 192
				$numCharsAffected = 24;
333 192
				break;
334 294
			case TimeValue::PRECISION_YEAR1G:
335 144
				$numCharsAffected = 25;
336 144
				break;
337
		}
338 2170
		return $numCharsAffected;
339
	}
340
341
}
342