diff --git a/src/ValueParsers/DateFormatParser.php b/src/ValueParsers/DateFormatParser.php new file mode 100644 index 0000000..d70c73c --- /dev/null +++ b/src/ValueParsers/DateFormatParser.php @@ -0,0 +1,264 @@ +defaultOption( self::OPT_DATE_FORMAT, 'j F Y' ); + // FIXME: Should not be an option. Options should be trivial, never arrays or objects! + $this->defaultOption( self::OPT_DIGIT_TRANSFORM_TABLE, null ); + $this->defaultOption( self::OPT_MONTH_NAMES, null ); + } + + /** + * @see StringValueParser::stringParse + * + * @param string $value + * + * @throws ParseException + * @return TimeValue + */ + protected function stringParse( $value ) { + $pattern = $this->parseDateFormat( $this->getDateFormat() ); + + if ( @preg_match( '<^\p{Z}*' . $pattern . '$>iu', $value, $matches ) + && isset( $matches['year'] ) + ) { + $precision = TimeValue::PRECISION_YEAR; + $time = array( $this->parseFormattedNumber( $matches['year'] ), 0, 0, 0, 0, 0 ); + + if ( isset( $matches['month'] ) ) { + $precision = TimeValue::PRECISION_MONTH; + $time[1] = $this->findMonthMatch( $matches ); + } + + if ( isset( $matches['day'] ) ) { + $precision = TimeValue::PRECISION_DAY; + $time[2] = $this->parseFormattedNumber( $matches['day'] ); + } + + if ( isset( $matches['hour'] ) ) { + $precision = TimeValue::PRECISION_HOUR; + $time[3] = $this->parseFormattedNumber( $matches['hour'] ); + } + + if ( isset( $matches['minute'] ) ) { + $precision = TimeValue::PRECISION_MINUTE; + $time[4] = $this->parseFormattedNumber( $matches['minute'] ); + } + + if ( isset( $matches['second'] ) ) { + $precision = TimeValue::PRECISION_SECOND; + $time[5] = $this->parseFormattedNumber( $matches['second'] ); + } + + $timestamp = vsprintf( '+%04s-%02s-%02sT%02s:%02s:%02sZ', $time ); + return new TimeValue( $timestamp, 0, 0, 0, $precision, TimeValue::CALENDAR_GREGORIAN ); + } + + throw new ParseException( "Failed to parse $value (" + . $this->parseFormattedNumber( $value ) . ')', $value ); + } + + /** + * @see Language::sprintfDate + * + * @param string $format A date format, as described in Language::sprintfDate. + * + * @return string Regular expression + */ + private function parseDateFormat( $format ) { + $length = strlen( $format ); + $numberPattern = '[' . $this->getNumberCharacters() . ']'; + $pattern = ''; + + for ( $p = 0; $p < $length; $p++ ) { + $code = $format[$p]; + + if ( $code === 'x' && $p < $length - 1 ) { + $code .= $format[++$p]; + } + + if ( preg_match( '<^x[ijkmot]$>', $code ) && $p < $length - 1 ) { + $code .= $format[++$p]; + } + + switch ( $code ) { + case 'Y': + $pattern .= '(?P' . $numberPattern . '+)\p{Z}*'; + break; + case 'F': + case 'm': + case 'M': + case 'n': + case 'xg': + $pattern .= '(?P' . $numberPattern . '{1,2}' + . $this->getMonthNamesPattern() + . ')\p{P}*\p{Z}*'; + break; + case 'd': + case 'j': + $pattern .= '(?P' . $numberPattern . '{1,2})\p{P}*\p{Z}*'; + break; + case 'G': + case 'H': + $pattern .= '(?P' . $numberPattern . '{1,2})\p{Z}*'; + break; + case 'i': + $pattern .= '(?P' . $numberPattern . '{1,2})\p{Z}*'; + break; + case 's': + $pattern .= '(?P' . $numberPattern . '{1,2})\p{Z}*'; + break; + case '\\': + if ( $p < $length - 1 ) { + $pattern .= preg_quote( $format[++$p] ); + } else { + $pattern .= '\\'; + } + break; + case '"': + $endQuote = strpos( $format, '"', $p + 1 ); + if ( $endQuote !== false ) { + $pattern .= preg_quote( substr( $format, $p + 1, $endQuote - $p - 1 ) ); + $p = $endQuote; + } else { + $pattern .= '"'; + } + break; + case 'xn': + case 'xN': + // We can ignore raw and raw toggle when parsing, because we always accept + // canonical digits. + break; + default: + if ( preg_match( '<^\p{P}+$>u', $format[$p] ) ) { + $pattern .= '\p{P}*'; + } elseif ( preg_match( '<^\p{Z}+$>u', $format[$p] ) ) { + $pattern .= '\p{Z}*'; + } else { + $pattern .= preg_quote( $format[$p] ); + } + } + } + + return $pattern; + } + + /** + * @return string + */ + private function getMonthNamesPattern() { + $pattern = ''; + + foreach ( $this->getMonthNames() as $i => $monthNames ) { + $pattern .= '|(?P' + . implode( '|', array_map( 'preg_quote', (array)$monthNames ) ) + . ')'; + } + + return $pattern; + } + + /** + * @param string[] $matches + * + * @return int + */ + private function findMonthMatch( $matches ) { + for ( $i = 1; $i <= 12; $i++ ) { + if ( !empty( $matches['month' . $i] ) ) { + return $i; + } + } + + return $this->parseFormattedNumber( $matches['month'] ); + } + + /** + * @param string $number + * + * @return string + */ + private function parseFormattedNumber( $number ) { + $transformTable = $this->getDigitTransformTable(); + + if ( is_array( $transformTable ) ) { + // Eliminate empty array values (bug T66347). + $transformTable = array_filter( $transformTable ); + $number = strtr( $number, array_flip( $transformTable ) ); + } + + return $number; + } + + /** + * @return string + */ + private function getNumberCharacters() { + $numberCharacters = '\d'; + + $transformTable = $this->getDigitTransformTable(); + if ( is_array( $transformTable ) ) { + $numberCharacters .= preg_quote( implode( '', $transformTable ) ); + } + + return $numberCharacters; + } + + /** + * @return string + */ + private function getDateFormat() { + return $this->getOption( self::OPT_DATE_FORMAT ); + } + + /** + * @return string[]|null + */ + private function getDigitTransformTable() { + return $this->getOption( self::OPT_DIGIT_TRANSFORM_TABLE ); + } + + /** + * @return array[] + */ + private function getMonthNames() { + return $this->getOption( self::OPT_MONTH_NAMES ) ?: array(); + } + +} diff --git a/tests/ValueParsers/DateFormatParserTest.php b/tests/ValueParsers/DateFormatParserTest.php new file mode 100644 index 0000000..5be0de3 --- /dev/null +++ b/tests/ValueParsers/DateFormatParserTest.php @@ -0,0 +1,167 @@ + array( 'Sep', 'September' ) ); + + $valid = array( + 'Default options' => array( + '1 9 2014', + 'd M Y', null, null, + '+2014-09-01T00:00:00Z' + ), + 'Transform map' => array( + 'Z g Zo15', + 'd M Y', array( '0' => 'o', 2 => 'Z', 9 => 'g' ), null, + '+2015-09-02T00:00:00Z' + ), + 'Default month map' => array( + '1. September 2014', + 'd. M Y', null, $monthNames, + '+2014-09-01T00:00:00Z' + ), + 'Simple month map' => array( + '1 September 2014', + 'd M Y', null, array( 9 => 'September' ), + '+2014-09-01T00:00:00Z' + ), + 'Escapes' => array( + '1s 9s 2014', + 'd\s M\s Y', null, null, + '+2014-09-01T00:00:00Z' + ), + 'Quotes' => array( + '1th 9th 2014', + 'd"th" M"th" Y', null, null, + '+2014-09-01T00:00:00Z' + ), + 'Raw modifiers' => array( + '2014 9 1', + 'Y xNmxN xnd', null, null, + '+2014-09-01T00:00:00Z' + ), + 'Whitespace is optional' => array( + '1September2014', + 'd M Y', null, $monthNames, + '+2014-09-01T00:00:00Z' + ), + 'Delimiters are optional' => array( + '1 9 2014', + 'd. M. Y', null, null, + '+2014-09-01T00:00:00Z' + ), + 'Delimiters are ignored' => array( + '1. 9. 2014', + 'd M Y', null, null, + '+2014-09-01T00:00:00Z' + ), + 'Year precision' => array( + '2014', + 'Y', null, null, + '+2014-00-00T00:00:00Z', TimeValue::PRECISION_YEAR + ), + 'Month precision' => array( + '9 2014', + 'M Y', null, null, + '+2014-09-00T00:00:00Z', TimeValue::PRECISION_MONTH + ), + 'Minute precision' => array( + '1 9 2014 15:30', + 'd M Y H:i', null, null, + '+2014-09-01T15:30:00Z', TimeValue::PRECISION_MINUTE + ), + 'Second precision' => array( + '1 9 2014 15:30:59', + 'd M Y H:i:s', null, null, + '+2014-09-01T15:30:59Z', TimeValue::PRECISION_SECOND + ), + ); + + $cases = array(); + + foreach ( $valid as $key => $args ) { + $dateString = $args[0]; + $dateFormat = $args[1]; + $digitTransformTable = $args[2]; + $monthNames = $args[3]; + $timestamp = $args[4]; + $precision = isset( $args[5] ) ? $args[5] : TimeValue::PRECISION_DAY; + $calendarModel = isset( $args[6] ) ? $args[6] : TimeValue::CALENDAR_GREGORIAN; + + $cases[$key] = array( + $dateString, + new TimeValue( $timestamp, 0, 0, 0, $precision, $calendarModel ), + new DateFormatParser( new ParserOptions( array( + DateFormatParser::OPT_DATE_FORMAT => $dateFormat, + DateFormatParser::OPT_DIGIT_TRANSFORM_TABLE => $digitTransformTable, + DateFormatParser::OPT_MONTH_NAMES => $monthNames, + ) ) ) + ); + } + + return $cases; + } + + /** + * @see StringValueParserTest::invalidInputProvider + */ + public function invalidInputProvider() { + $invalid = array( + '', + ); + + $cases = parent::invalidInputProvider(); + + foreach ( $invalid as $value ) { + $cases[] = array( $value ); + } + + return $cases; + } + + public function testInvalidDateFormatOption() { + $parser = new DateFormatParser( new ParserOptions( array( + DateFormatParser::OPT_DATE_FORMAT => 'YY', + ) ) ); + $this->setExpectedException( 'ValueParsers\ParseException' ); + $parser->parse( '' ); + } + +}