Passed
Push — master ( 9d96ac...fde1f5 )
by adam
07:01
created

IsoTimestampParser::getCalendarModel()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5

Importance

Changes 0
Metric Value
dl 0
loc 19
ccs 10
cts 10
cp 1
rs 9.3222
c 0
b 0
f 0
cc 5
nc 6
nop 1
crap 5
1
<?php
2
3
namespace ValueParsers;
4
5
use DataValues\IllegalValueException;
6
use DataValues\TimeValue;
7
8
/**
9
 * ValueParser that parses various string representations of time values, in YMD ordered formats
10
 * resembling ISO 8601, e.g. +2013-01-01T00:00:00Z. While the parser tries to be relaxed, certain
11
 * aspects of the ISO norm are obligatory: The order must be YMD. All elements but the year must
12
 * have 2 digits. The separation characters must be dashes (in the date part), "T" and colons (in
13
 * the time part).
14
 *
15
 * The parser refuses to parse strings that can be parsed differently by other, locale-aware
16
 * parsers, e.g. 01-02-03 can be in YMD, DMY or MDY order depending on the language.
17
 *
18
 * @since 0.7 renamed from TimeParser to IsoTimestampParser.
19
 *
20
 * @license GPL-2.0+
21
 * @author Addshore
22
 * @author Thiemo Kreuz
23
 * @author Daniel Kinzler
24
 */
25
class IsoTimestampParser extends StringValueParser {
26
27
	const FORMAT_NAME = 'iso-timestamp';
28
29
	/**
30
	 * Option to override the precision auto-detection and set a specific precision. Should be an
31
	 * integer or string containing one of the TimeValue::PRECISION_... constants.
32
	 */
33
	const OPT_PRECISION = 'precision';
34
35
	/**
36
	 * Option to override the calendar model auto-detection and set a specific calendar model URI.
37
	 * Should be one of the TimeValue::CALENDAR_... constants.
38
	 */
39
	const OPT_CALENDAR = 'calendar';
40
41
	/**
42
	 * @deprecated since 0.7.1, use TimeValue::CALENDAR_GREGORIAN instead
43
	 */
44
	const CALENDAR_GREGORIAN = TimeValue::CALENDAR_GREGORIAN;
45
46
	/**
47
	 * @deprecated since 0.7.1, use TimeValue::CALENDAR_JULIAN instead
48
	 */
49
	const CALENDAR_JULIAN = TimeValue::CALENDAR_JULIAN;
50
51
	/**
52
	 * @var CalendarModelParser
53
	 */
54
	private $calendarModelParser;
55
56
	/**
57
	 * @param CalendarModelParser|null $calendarModelParser
58
	 * @param ParserOptions|null $options
59
	 */
60 40
	public function __construct(
61
		CalendarModelParser $calendarModelParser = null,
62
		ParserOptions $options = null
63
	) {
64 40
		parent::__construct( $options );
65
66 40
		$this->defaultOption( self::OPT_CALENDAR, null );
67 40
		$this->defaultOption( self::OPT_PRECISION, null );
68
69 40
		$this->calendarModelParser = $calendarModelParser ?: new CalendarModelParser( $this->options );
70 40
	}
71
72
	/**
73
	 * @param string $value
74
	 *
75
	 * @throws ParseException
76
	 * @return TimeValue
77
	 */
78 86
	protected function stringParse( $value ) {
79
		try {
80 86
			$timeParts = $this->splitTimeString( $value );
81 20
		} catch ( ParseException $ex ) {
82 20
			throw new ParseException( $ex->getMessage(), $value, self::FORMAT_NAME );
83
		}
84
85
		// Pad sign with 1 plus, year with 4 zeros and hour, minute and second with 2 zeros
86 66
		$timestamp = vsprintf( '%\'+1s%04s-%s-%sT%02s:%02s:%02sZ', $timeParts );
87 66
		$precision = $this->getPrecision( $timeParts );
88 64
		$calendarModel = $this->getCalendarModel( $timeParts );
89
90
		try {
91 63
			return new TimeValue( $timestamp, 0, 0, 0, $precision, $calendarModel );
92 2
		} catch ( IllegalValueException $ex ) {
93 2
			throw new ParseException( $ex->getMessage(), $value, self::FORMAT_NAME );
94
		}
95
	}
96
97
	/**
98
	 * @param string $value
99
	 *
100
	 * @throws ParseException
101
	 * @return string[] Array with index 0 => sign, 1 => year, 2 => month, 3 => day, 4 => hour,
102
	 * 5 => minute, 6 => second and 7 => calendar model.
103
	 */
104 86
	private function splitTimeString( $value ) {
105
		$pattern = '@^\s*'                                                // leading spaces
106
			. "([-+\xE2\x88\x92]?)\\s*"                                   // sign
107
			. '(\d{1,16})-(\d{2})-(\d{2})'                                // year-month-day
108
			. '(?:T(\d{2}):?(\d{2})(?::?(\d{2}))?)?'                      // hour:minute:second
109
			. 'Z?'                                                        // time zone
110 86
			. '\s*\(?\s*' . CalendarModelParser::MODEL_PATTERN . '\s*\)?' // calendar model
0 ignored issues
show
Deprecated Code introduced by
The constant ValueParsers\CalendarModelParser::MODEL_PATTERN has been deprecated with message: Do not use. Regex pattern constant matching the parable calendar models
should be used as an insensitive to match all cases TODO: How crucial is it that this regex is in sync with the list below?

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
111 86
			. '\s*$@iu';                                                  // trailing spaces
112
113 86
		if ( !preg_match( $pattern, $value, $matches ) ) {
114 7
			throw new ParseException( 'Malformed time' );
115
		}
116
117 79
		list( , $sign, $year, $month, $day, $hour, $minute, $second, $calendarModel ) = $matches;
118
119 79
		if ( $sign === ''
120 79
			&& strlen( $year ) < 3
121 79
			&& $year < 32
122 79
			&& $hour === ''
123
		) {
124 4
			throw new ParseException( 'Not enough information to decide if the format is YMD' );
125
		}
126
127 75
		if ( $month > 12 ) {
128 1
			throw new ParseException( 'Month out of range' );
129 74
		} elseif ( $day > 31 ) {
130 1
			throw new ParseException( 'Day out of range' );
131 73
		} elseif ( $hour > 23 ) {
132 1
			throw new ParseException( 'Hour out of range' );
133 72
		} elseif ( $minute > 59 ) {
134 1
			throw new ParseException( 'Minute out of range' );
135 71
		} elseif ( $second > 61 ) {
136 1
			throw new ParseException( 'Second out of range' );
137
		}
138
139 70
		if ( $month < 1 && $day > 0 ) {
140 1
			throw new ParseException( 'Can not have a day with no month' );
141
		}
142
143 69
		if ( $day < 1 && ( $hour > 0 || $minute > 0 || $second > 0 ) ) {
144 3
			throw new ParseException( 'Can not have hour, minute or second with no day' );
145
		}
146
147 66
		$sign = str_replace( "\xE2\x88\x92", '-', $sign );
148
149 66
		return array( $sign, $year, $month, $day, $hour, $minute, $second, $calendarModel );
150
	}
151
152
	/**
153
	 * @param string[] $timeParts Array with index 0 => sign, 1 => year, 2 => month, etc.
154
	 *
155
	 * @throws ParseException
156
	 * @return int One of the TimeValue::PRECISION_... constants.
157
	 */
158 66
	private function getPrecision( array $timeParts ) {
159 66
		if ( $timeParts[6] > 0 ) {
160 2
			$precision = TimeValue::PRECISION_SECOND;
161 64
		} elseif ( $timeParts[5] > 0 ) {
162 1
			$precision = TimeValue::PRECISION_MINUTE;
163 63
		} elseif ( $timeParts[4] > 0 ) {
164
			$precision = TimeValue::PRECISION_HOUR;
165 63
		} elseif ( $timeParts[3] > 0
166
			// Can not have a day with no month, fall back to year precision.
167 63
			&& $timeParts[2] > 0
168
		) {
169 42
			$precision = TimeValue::PRECISION_DAY;
170 21
		} elseif ( $timeParts[2] > 0 ) {
171 4
			$precision = TimeValue::PRECISION_MONTH;
172
		} else {
173 17
			$precision = $this->getPrecisionFromYear( $timeParts[1] );
174
		}
175
176 66
		$option = $this->getOption( self::OPT_PRECISION );
177
178 66
		if ( $option !== null ) {
179 13
			if ( !is_int( $option ) && !ctype_digit( $option ) ) {
180 2
				throw new ParseException( 'Precision must be an integer' );
181
			}
182
183 11
			$option = (int)$option;
184
185
			// It's impossible to increase the detected precision via option, e.g. from year to month if
186
			// no month is given. If a day is given it can be increased, relevant for midnight.
187 11
			if ( $option <= $precision || $precision >= TimeValue::PRECISION_DAY ) {
188 8
				return $option;
189
			}
190
		}
191
192 56
		return $precision;
193
	}
194
195
	/**
196
	 * @param string $unsignedYear
197
	 *
198
	 * @return int One of the TimeValue::PRECISION_... constants.
199
	 */
200 17
	private function getPrecisionFromYear( $unsignedYear ) {
201
		// default to year precision for range 4000 BC to 4000
202 17
		if ( $unsignedYear <= 4000 ) {
203 8
			return TimeValue::PRECISION_YEAR;
204
		}
205
206 9
		$rightZeros = strlen( $unsignedYear ) - strlen( rtrim( $unsignedYear, '0' ) );
207 9
		$precision = TimeValue::PRECISION_YEAR - $rightZeros;
208 9
		if ( $precision < TimeValue::PRECISION_YEAR1G ) {
209 2
			$precision = TimeValue::PRECISION_YEAR1G;
210
		}
211
212 9
		return $precision;
213
	}
214
215
	/**
216
	 * Determines the calendar model. The calendar model is determined as follows:
217
	 *
218
	 * - if $timeParts[7] is set, use $this->calendarModelParser to parse it into a URI.
219
	 * - otherwise, if $this->getOption( self::OPT_CALENDAR ) is not null, use
220
	 *   $this->calendarModelParser to parse it into a URI.
221
	 * - otherwise, use self::CALENDAR_JULIAN for dates before 1583, and self::CALENDAR_GREGORIAN
222
	 *   for later dates.
223
	 *
224
	 * @note Keep this in sync with HtmlTimeFormatter::getDefaultCalendar().
225
	 *
226
	 * @param string[] $timeParts as returned by splitTimeString()
227
	 *
228
	 * @return string URI
229
	 */
230 64
	private function getCalendarModel( array $timeParts ) {
231 64
		list( $sign, $unsignedYear, , , , , , $calendarModel ) = $timeParts;
232
233 64
		if ( !empty( $calendarModel ) ) {
234 9
			return $this->calendarModelParser->parse( $calendarModel );
235
		}
236
237 55
		$option = $this->getOption( self::OPT_CALENDAR );
238
239 55
		if ( $option !== null ) {
240 6
			return $this->calendarModelParser->parse( $option );
241
		}
242
243
		// The Gregorian calendar was introduced in October 1582,
244
		// so we'll default to Julian for all years before that.
245 49
		return $sign === '-' || $unsignedYear <= 1582
246 14
			? TimeValue::CALENDAR_JULIAN
247 49
			: TimeValue::CALENDAR_GREGORIAN;
248
	}
249
250
}
251