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

TimeValueCalculator::getLowerTimestamp()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3.0052

Importance

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