Test Failed
Pull Request — master (#140)
by
unknown
07:46
created

TimeValueCalculator::timestampAbsCeiling()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
dl 0
loc 30
ccs 0
cts 0
cp 0
rs 9.44
c 0
b 0
f 0
cc 2
nc 2
nop 2
crap 6
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
34 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 34
	 *
45
	 * @param TimeValue $timeValue
46 34
	 *
47
	 * @return float seconds since 1970-01-01T00:00:00Z
48
	 */
49
	public function getTimestamp( TimeValue $timeValue ) {
50 34
		return $this->getSecondsSinceUnixEpoch( $timeValue->getTime(), $timeValue->getTimezone() );
51
	}
52
53
	/**
54 34
	 * 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 34
	 * year 2038.
57 34
	 *
58
	 * @param TimeValue $timeValue
59
	 *
60 34
	 * @return float seconds since 1970-01-01T00:00:00Z
61 34
	 */
62
	public function getLowerTimestamp( TimeValue $timeValue ) {
63 34
		$precision = $timeValue->getPrecision();
64
		$timestamp = $timeValue->getTime();
65
		if ( $timestamp[0] === '-' && $precision < TimeValue::PRECISION_YEAR ) {
66
			$timestamp = $this->timestampAbsCeiling( $timestamp, $precision );
67 34
		} else {
68 34
			$timestamp = $this->timestampAbsFloor( $timestamp, $precision );
69 34
		}
70
		$unixTimestamp = $this->getSecondsSinceUnixEpoch( $timestamp, $timeValue->getTimezone() );
71 34
		$unixTimestamp -= $timeValue->getBefore() * $this->getSecondsForPrecision( $precision );
72
		return $unixTimestamp;
73
	}
74
75
	/**
76
	 * Returns the highest possible Unix timestamp from a TimeValue considering its precision
77
	 * and its after value. Data type is float because PHP's 32 bit integer would clip in the
78
	 * year 2038.
79 68
	 *
80 68
	 * @param TimeValue $timeValue
81 68
	 *
82 68
	 * @return float seconds since 1970-01-01T00:00:00Z
83 68
	 */
84 68
	public function getHigherTimestamp( TimeValue $timeValue ) {
85
		$precision = $timeValue->getPrecision();
86
		$timestamp = $timeValue->getTime();
87
		if ( $timestamp[0] === '-' && $precision < TimeValue::PRECISION_YEAR ) {
88
			$timestamp = $this->timestampAbsFloor( $timestamp, $precision );
89
		} else {
90
			$timestamp = $this->timestampAbsCeiling( $timestamp, $precision );
91
		}
92
		$unixTimestamp = $this->getSecondsSinceUnixEpoch( $timestamp, $timeValue->getTimezone() );
93 68
		$unixTimestamp += $timeValue->getAfter() * $this->getSecondsForPrecision( $precision );
94 68
		return $unixTimestamp;
95 68
	}
96
97
	/**
98
	 * @param string $time an ISO 8601 date and time
99
	 * @param int $timezone offset from UTC in minutes
100
	 *
101
	 * @throws InvalidArgumentException
102
	 * @return float seconds since 1970-01-01T00:00:00Z
103
	 */
104 8
	private function getSecondsSinceUnixEpoch( $time, $timezone = 0 ) {
105 8
		// Validation is done in TimeValue. As long if we found enough numbers we are fine.
106 3
		if ( !preg_match( '/([-+]?\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)/', $time, $matches )
107 3
		) {
108 3
			throw new InvalidArgumentException( "Failed to parse time value $time." );
109
		}
110
		list( , $fullYear, $month, $day, $hour, $minute, $second ) = $matches;
111
112
		// We use mktime only for the month, day and time calculation. Set the year to the smallest
113 5
		// possible in the 1970-2038 range to be safe, even if it's 1901-2038 since PHP 5.1.0.
114 1
		$year = $this->isLeapYear( $fullYear ) ? 1972 : 1970;
115 4
116 1
		$defaultTimezone = date_default_timezone_get();
117 3
		date_default_timezone_set( 'UTC' );
118 1
		// With day/month set to 0 mktime would calculate the last day of the previous month/year.
119 2
		// In the context of this calculation we must assume 0 means "start of the month/year".
120 1
		$timestamp = mktime( $hour, $minute, $second, max( 1, $month ), max( 1, $day ), $year );
121 1
		date_default_timezone_set( $defaultTimezone );
122 1
123
		if ( $timestamp === false ) {
124
			throw new InvalidArgumentException( "Failed to get epoche from time value $time." );
125
		}
126
127
		$missingYears = ( $fullYear < 0 ? $fullYear + 1 : $fullYear ) - $year;
128
		$missingLeapDays = $this->getNumberOfLeapYears( $fullYear )
129
			- $this->getNumberOfLeapYears( $year );
130
131
		return $timestamp + ( $missingYears * 365 + $missingLeapDays ) * 86400 - $timezone * 60;
132
	}
133
134
	/**
135
	 * @param float $year
136
	 *
137
	 * @return bool if the year is a leap year in the Gregorian calendar
138
	 */
139
	public function isLeapYear( $year ) {
140
		$year = $year < 0 ? ceil( $year ) + 1 : floor( $year );
141
		$isMultipleOf4   = fmod( $year,   4 ) === 0.0;
142
		$isMultipleOf100 = fmod( $year, 100 ) === 0.0;
143
		$isMultipleOf400 = fmod( $year, 400 ) === 0.0;
144
		return $isMultipleOf4 && !$isMultipleOf100 || $isMultipleOf400;
145
	}
146
147
	/**
148
	 * @param float $year
149
	 *
150
	 * @return float The number of leap years since year 1. To be more precise: The number of
151
	 * leap days in the range between 31 December of year 1 and 31 December of the given year.
152
	 */
153
	public function getNumberOfLeapYears( $year ) {
154
		$year = $year < 0 ? ceil( $year ) + 1 : floor( $year );
155
		return floor( $year / 4 ) - floor( $year / 100 ) + floor( $year / 400 );
156
	}
157
158
	/**
159
	 * @param int $precision One of the TimeValue::PRECISION_... constants
160
	 *
161
	 * @throws InvalidArgumentException
162
	 * @return float number of seconds in one unit of the given precision
163
	 */
164
	public function getSecondsForPrecision( $precision ) {
165
		if ( $precision <= TimeValue::PRECISION_YEAR ) {
166
			return self::SECONDS_PER_GREGORIAN_YEAR * pow(
167
				10,
168
				TimeValue::PRECISION_YEAR - $precision
169
			);
170
		}
171
172
		switch ( $precision ) {
173
			case TimeValue::PRECISION_SECOND:
174
				return 1;
175
			case TimeValue::PRECISION_MINUTE:
176
				return 60;
177
			case TimeValue::PRECISION_HOUR:
178
				return 3600;
179
			case TimeValue::PRECISION_DAY:
180
				return 86400;
181
			case TimeValue::PRECISION_MONTH:
182
				return self::SECONDS_PER_GREGORIAN_YEAR / 12;
183
		}
184
185
		throw new InvalidArgumentException( "Unable to get seconds for precision $precision." );
186
	}
187
188
	/**
189
	 * @param $timestamp
190
	 * @param $precision
191
	 *
192
	 * @return string
193
	 */
194
	private function timestampAbsFloor( $timestamp, $precision ) {
195
		// The year is padded with zeros to have 16 digits
196
		$timestamp = substr_replace(
197
			$timestamp,
198
			str_repeat( '0', $this->MAX_LENGTH_TIMESTAMP - strlen( $timestamp ) ),
199
			1,
200
			0
201
		);
202
		$numCharsToModify = $this->charsAffectedByPrecision( $precision );
203
		$timestamp = substr( $timestamp, 0, -$numCharsToModify ) .
204
			substr( $this->TIMESTAMP_ZERO, -$numCharsToModify );
205
		return $timestamp;
206
	}
207
208
	/**
209
	 * @param $timestamp
210
	 * @param $precision
211
	 *
212
	 * @return string
213
	 */
214
	private function timestampAbsCeiling( $timestamp, $precision ) {
215
		// The year is padded with zeros to have 16 digits
216
		$timestamp = substr_replace(
217
			$timestamp,
218
			str_repeat( '0', $this->MAX_LENGTH_TIMESTAMP - strlen( $timestamp ) ),
219
			1,
220
			0
221
		);
222
		$numCharsToModify = $this->charsAffectedByPrecision( $precision );
223
		// WARNING: Day 31 will be applied to all months
224
		$timestampCeiling = substr( $timestamp, 0, -$numCharsToModify ) .
225
			substr( $this->HIGHEST_TIMESTAMP, -$numCharsToModify );
226
		if ( $precision === TimeValue::PRECISION_MONTH ) {
227
			$month = (int)substr(
228
				$timestamp,
229
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR ),
230
				2
231
			);
232
			$year = (int)substr(
233
				$timestamp,
234
				1,
235
				-$this->charsAffectedByPrecision( TimeValue::PRECISION_YEAR ) - 1
236
			);
237
			$daysInMonth = \cal_days_in_month( CAL_GREGORIAN, $month, $year );
238
			$timestampCeiling = substr( $timestamp, 0, -$numCharsToModify ) .
239
				$daysInMonth .
240
				substr( $this->HIGHEST_TIMESTAMP, -$numCharsToModify + 2 );
241
		}
242
		return $timestampCeiling;
243
	}
244
245
	/**
246
	 * @param $precision
247
	 *
248
	 * @return int
249
	 */
250
	private function charsAffectedByPrecision( $precision ) {
251
		$numCharsAffected = 1;
252
		switch ( $precision ) {
253
			case TimeValue::PRECISION_MINUTE:
254
				$numCharsAffected = 3;
255
				break;
256
			case TimeValue::PRECISION_HOUR:
257
				$numCharsAffected = 6;
258
				break;
259
			case TimeValue::PRECISION_DAY:
260
				$numCharsAffected = 9;
261
				break;
262
			case TimeValue::PRECISION_MONTH:
263
				$numCharsAffected = 12;
264
				break;
265
			case TimeValue::PRECISION_YEAR:
266
				$numCharsAffected = 15;
267
				break;
268
			case TimeValue::PRECISION_YEAR10:
269
				$numCharsAffected = 17;
270
				break;
271
			case TimeValue::PRECISION_YEAR100:
272
				$numCharsAffected = 18;
273
				break;
274
			case TimeValue::PRECISION_YEAR1K:
275
				$numCharsAffected = 19;
276
				break;
277
			case TimeValue::PRECISION_YEAR10K:
278
				$numCharsAffected = 20;
279
				break;
280
			case TimeValue::PRECISION_YEAR100K:
281
				$numCharsAffected = 21;
282
				break;
283
			case TimeValue::PRECISION_YEAR1M:
284
				$numCharsAffected = 22;
285
				break;
286
			case TimeValue::PRECISION_YEAR10M:
287
				$numCharsAffected = 23;
288
				break;
289
			case TimeValue::PRECISION_YEAR100M:
290
				$numCharsAffected = 24;
291
				break;
292
			case TimeValue::PRECISION_YEAR1G:
293
				$numCharsAffected = 25;
294
				break;
295
		}
296
		return $numCharsAffected;
297
	}
298
299
}
300