diff --git a/src/Recurr/Rule.php b/src/Recurr/Rule.php index b1d8840..418e368 100644 --- a/src/Recurr/Rule.php +++ b/src/Recurr/Rule.php @@ -253,6 +253,37 @@ public static function createFromArray( } /** + * Create a Rule object based on natural language text. + * + * @param string $text Natural language description like "every day for 3 times" + * @param \DateTime|\DateTimeImmutable|string|null $startDate Start date for the recurrence + * @param \DateTime|\DateTimeImmutable|string|null $endDate End date for the recurrence + * @param string|null $timezone Timezone identifier + * + * @return self + * + * @throws InvalidRRule If the text cannot be parsed + */ + public static function createFromText( + string $text, + \DateTime|\DateTimeImmutable|string|null $startDate = null, + \DateTime|\DateTimeImmutable|string|null $endDate = null, + ?string $timezone = null, + ): self { + $parser = new TextParser(); + $options = $parser->parseText($text); + + if ($options === null) { + throw new InvalidRRule('Unable to parse text: ' . $text); + } + + return new self($options, $startDate, $endDate, $timezone); + } + + /** + * Populate the object based on a RRULE string. + * + * @param string $rrule RRULE string * Populate the model from a RRULE string. * * @throws InvalidRRule diff --git a/src/Recurr/TextParser.php b/src/Recurr/TextParser.php new file mode 100644 index 0000000..838ffd8 --- /dev/null +++ b/src/Recurr/TextParser.php @@ -0,0 +1,360 @@ + + */ +class TextParser +{ + /** + * @var array + */ + private array $tokens = []; + + private string $text = ''; + + private ?string $symbol = null; + + /** + * @var array|null + */ + private ?array $value = null; + + private bool $done = true; + + /** + * @var array + */ + private array $options = []; + + public function __construct() + { + $this->initializeTokens(); + } + + /** + * Parse natural language text into Rule options array. + * + * @param string $text Natural language text describing recurrence + * + * @return array|null Options array suitable for Rule constructor + */ + public function parseText(string $text): ?array + { + $this->options = []; + $this->text = strtolower(trim($text)); + $this->done = false; + $this->symbol = null; + $this->value = null; + + if (!$this->nextSymbol()) { + return null; + } + + try { + $this->parseStatement(); + return $this->options; + } catch (\Exception $e) { + return null; + } + } + + /** + * Initialize the token patterns for text parsing. + */ + private function initializeTokens(): void + { + $this->tokens = [ + 'SKIP' => '/^[ \r\n\t]+|^\.$/i', + 'number' => '/^[1-9][0-9]*/', + 'every' => '/^every/i', + 'days' => '/^days?/i', + 'weekdays' => '/^weekdays?/i', + 'weeks' => '/^weeks?/i', + 'hours' => '/^hours?/i', + 'minutes' => '/^minutes?/i', + 'months' => '/^months?/i', + 'years' => '/^years?/i', + 'on' => '/^(on|in)/i', + 'at' => '/^(at)/i', + 'the' => '/^the/i', + 'first' => '/^first/i', + 'second' => '/^second/i', + 'third' => '/^third/i', + 'last' => '/^last/i', + 'for' => '/^for/i', + 'times' => '/^times?/i', + 'until' => '/^(un)?til/i', + 'monday' => '/^mo(n(day)?)?/i', + 'tuesday' => '/^tu(e(s(day)?)?)?/i', + 'wednesday' => '/^we(d(n(esday)?)?)?/i', + 'thursday' => '/^th(u(r(sday)?)?)?/i', + 'friday' => '/^fr(i(day)?)?/i', + 'saturday' => '/^sa(t(urday)?)?/i', + 'sunday' => '/^su(n(day)?)?/i', + 'january' => '/^jan(uary)?/i', + 'february' => '/^feb(ruary)?/i', + 'march' => '/^mar(ch)?/i', + 'april' => '/^apr(il)?/i', + 'may' => '/^may/i', + 'june' => '/^june?/i', + 'july' => '/^july?/i', + 'august' => '/^aug(ust)?/i', + 'september' => '/^sep(t(ember)?)?/i', + 'october' => '/^oct(ober)?/i', + 'november' => '/^nov(ember)?/i', + 'december' => '/^dec(ember)?/i', + 'comma' => '/^(,\s*|(and|or)\s*)+/i', + ]; + } + + /** + * Move to the next symbol in the text. + * + * @return bool True if a symbol was found, false if parsing is complete + */ + private function nextSymbol(): bool + { + $this->symbol = null; + $this->value = null; + $best = null; + $bestSymbol = null; + + do { + if ($this->done) { + return false; + } + + $best = null; + foreach ($this->tokens as $name => $regex) { + if (preg_match($regex, $this->text, $matches)) { + if ($best === null || strlen($matches[0]) > strlen($best[0])) { + $best = $matches; + $bestSymbol = $name; + } + } + } + + if ($best !== null) { + $this->text = substr($this->text, strlen($best[0])); + if ($this->text === '') { + $this->done = true; + } + } + + if ($best === null) { + $this->done = true; + $this->symbol = null; + $this->value = null; + return false; + } + } while ($bestSymbol === 'SKIP'); + + $this->symbol = $bestSymbol; + $this->value = $best; + return true; + } + + /** + * Accept a specific symbol if it matches the current symbol. + * + * @param string $name Symbol name to match + * + * @return array|false Matched symbol data or false if no match + */ + private function accept(string $name): array|false + { + if ($this->symbol === $name) { + $value = $this->value; + $this->nextSymbol(); + return $value ?? false; + } + return false; + } + + /** + * Expect a specific symbol and throw exception if not found. + * + * @param string $name Symbol name to expect + * + * @return bool Always returns true if symbol is found + * + * @throws InvalidArgument If expected symbol is not found + */ + private function expect(string $name): bool + { + if ($this->accept($name)) { + return true; + } + throw new InvalidArgument("Expected $name but found " . $this->symbol); + } + + /** + * Accept a number symbol. + * + * @return array|false Number data or false if current symbol is not a number + */ + private function acceptNumber(): array|false + { + return $this->accept('number'); + } + + /** + * Check if parsing is complete. + * + * @return bool True if parsing is done + */ + private function isDone(): bool + { + return $this->done && $this->symbol === null; + } + + /** + * Parse a complete recurrence statement. + * + * Handles patterns like "every [n] frequency [for count times]". + * + * @throws InvalidArgument If statement cannot be parsed + */ + private function parseStatement(): void + { + // every [n] + $this->expect('every'); + $n = $this->acceptNumber(); + if ($n) { + $this->options['INTERVAL'] = (string) intval($n[0]); + } + + if ($this->isDone()) { + throw new InvalidArgument('Unexpected end'); + } + + switch ($this->symbol) { + case 'days': + $this->options['FREQ'] = 'DAILY'; + $this->nextSymbol(); + break; + + case 'weekdays': + $this->options['FREQ'] = 'WEEKLY'; + $this->options['BYDAY'] = 'MO,TU,WE,TH,FR'; + $this->nextSymbol(); + break; + + case 'weeks': + $this->options['FREQ'] = 'WEEKLY'; + $this->nextSymbol(); + break; + + case 'hours': + $this->options['FREQ'] = 'HOURLY'; + $this->nextSymbol(); + break; + + case 'minutes': + $this->options['FREQ'] = 'MINUTELY'; + $this->nextSymbol(); + break; + + case 'months': + $this->options['FREQ'] = 'MONTHLY'; + $this->nextSymbol(); + break; + + case 'years': + $this->options['FREQ'] = 'YEARLY'; + $this->nextSymbol(); + break; + + case 'monday': + case 'tuesday': + case 'wednesday': + case 'thursday': + case 'friday': + case 'saturday': + case 'sunday': + $this->options['FREQ'] = 'WEEKLY'; + $dayMap = [ + 'monday' => 'MO', + 'tuesday' => 'TU', + 'wednesday' => 'WE', + 'thursday' => 'TH', + 'friday' => 'FR', + 'saturday' => 'SA', + 'sunday' => 'SU' + ]; + if ($this->symbol !== null && isset($dayMap[$this->symbol])) { + $this->options['BYDAY'] = $dayMap[$this->symbol]; + } + $this->nextSymbol(); + break; + + case 'january': + case 'february': + case 'march': + case 'april': + case 'may': + case 'june': + case 'july': + case 'august': + case 'september': + case 'october': + case 'november': + case 'december': + $this->options['FREQ'] = 'YEARLY'; + $monthMap = [ + 'january' => 1, 'february' => 2, 'march' => 3, 'april' => 4, + 'may' => 5, 'june' => 6, 'july' => 7, 'august' => 8, + 'september' => 9, 'october' => 10, 'november' => 11, 'december' => 12 + ]; + if ($this->symbol !== null && isset($monthMap[$this->symbol])) { + $this->options['BYMONTH'] = (string) $monthMap[$this->symbol]; + } + $this->nextSymbol(); + break; + + default: + throw new InvalidArgument('Unknown symbol: ' . $this->symbol); + } + + // Handle "for X times" or "until date" + if ($this->symbol === 'for') { + $this->nextSymbol(); + $count = $this->acceptNumber(); + if ($count) { + $this->options['COUNT'] = (string) intval($count[0]); + $this->accept('times'); // optional "times" after number + } + } elseif ($this->symbol === 'until') { + // Simple until handling - could be extended for date parsing + $this->nextSymbol(); + // For now, we'll skip complex date parsing + } + } +} diff --git a/tests/Recurr/RuleTest.php b/tests/Recurr/RuleTest.php index 67179a9..e2c1ce6 100644 --- a/tests/Recurr/RuleTest.php +++ b/tests/Recurr/RuleTest.php @@ -456,6 +456,118 @@ public function testRepeatsIndefinitely(string $string, bool $expected): void $this->assertSame($expected, $this->rule->loadFromString($string)->repeatsIndefinitely()); } + public function testCreateFromTextSimple(): void + { + $rule = Rule::createFromText('every day'); + $this->assertEquals('DAILY', $rule->getFreqAsText()); + } + + public function testCreateFromTextWithInterval(): void + { + $rule = Rule::createFromText('every 2 weeks'); + $this->assertEquals('WEEKLY', $rule->getFreqAsText()); + $this->assertEquals(2, $rule->getInterval()); + } + + public function testCreateFromTextWithCount(): void + { + $rule = Rule::createFromText('every day for 3 times'); + $this->assertEquals('DAILY', $rule->getFreqAsText()); + $this->assertEquals(3, $rule->getCount()); + } + + public function testCreateFromTextWithWeekday(): void + { + $rule = Rule::createFromText('every monday'); + $this->assertEquals('WEEKLY', $rule->getFreqAsText()); + $this->assertEquals(['MO'], $rule->getByDay()); + } + + public function testCreateFromTextWithMonth(): void + { + $rule = Rule::createFromText('every january'); + $this->assertEquals('YEARLY', $rule->getFreqAsText()); + $this->assertEquals([1], $rule->getByMonth()); + } + + public function testCreateFromTextWeekdays(): void + { + $rule = Rule::createFromText('every weekday'); + $this->assertEquals('WEEKLY', $rule->getFreqAsText()); + $this->assertEquals(['MO', 'TU', 'WE', 'TH', 'FR'], $rule->getByDay()); + } + + public function testCreateFromTextWithStartDate(): void + { + $startDate = new \DateTime('2025-01-01'); + $rule = Rule::createFromText('every day', $startDate); + $this->assertEquals('DAILY', $rule->getFreqAsText()); + $this->assertEquals($startDate, $rule->getStartDate()); + } + + public function testCreateFromTextWithEndDate(): void + { + $startDate = new \DateTime('2025-01-01'); + $endDate = new \DateTime('2025-12-31'); + $rule = Rule::createFromText('every day', $startDate, $endDate); + $this->assertEquals('DAILY', $rule->getFreqAsText()); + $this->assertEquals($endDate, $rule->getEndDate()); + } + + public function testCreateFromTextWithTimezone(): void + { + $rule = Rule::createFromText('every day', null, null, 'America/New_York'); + $this->assertEquals('DAILY', $rule->getFreqAsText()); + $this->assertEquals('America/New_York', $rule->getTimezone()); + } + + public function testCreateFromTextInvalid(): void + { + $this->expectException(InvalidRRule::class); + Rule::createFromText('this is not a valid recurrence text'); + } + + public function testCreateFromTextEmpty(): void + { + $this->expectException(InvalidRRule::class); + Rule::createFromText(''); + } + + #[DataProvider('validTextProvider')] + public function testCreateFromTextValid(string $text, string $expectedFreq, ?int $expectedInterval, ?int $expectedCount): void + { + $rule = Rule::createFromText($text); + $this->assertEquals($expectedFreq, $rule->getFreqAsText()); + + if ($expectedInterval !== null) { + $this->assertEquals($expectedInterval, $rule->getInterval()); + } + + if ($expectedCount !== null) { + $this->assertEquals($expectedCount, $rule->getCount()); + } + } + + public static function validTextProvider(): array + { + return [ + ['every day', 'DAILY', null, null], + ['every 3 days', 'DAILY', 3, null], + ['every week', 'WEEKLY', null, null], + ['every 2 weeks', 'WEEKLY', 2, null], + ['every month', 'MONTHLY', null, null], + ['every 6 months', 'MONTHLY', 6, null], + ['every year', 'YEARLY', null, null], + ['every 2 years', 'YEARLY', 2, null], + ['every hour', 'HOURLY', null, null], + ['every 4 hours', 'HOURLY', 4, null], + ['every minute', 'MINUTELY', null, null], + ['every 15 minutes', 'MINUTELY', 15, null], + ['every day for 5 times', 'DAILY', null, 5], + ['every 2 weeks for 10 times', 'WEEKLY', 2, 10], + ]; + } + /** * Taken from https://tools.ietf.org/html/rfc5545#section-3.8.5.3 */ diff --git a/tests/Recurr/TextParserTest.php b/tests/Recurr/TextParserTest.php new file mode 100644 index 0000000..a9ac753 --- /dev/null +++ b/tests/Recurr/TextParserTest.php @@ -0,0 +1,590 @@ +parser = new TextParser(); + } + + public function testParseTextSimpleDaily(): void + { + $result = $this->parser->parseText('every day'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('FREQ', $result); + $this->assertEquals('DAILY', $result['FREQ']); + } + + public function testParseTextSimpleWeekly(): void + { + $result = $this->parser->parseText('every week'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + } + + public function testParseTextSimpleMonthly(): void + { + $result = $this->parser->parseText('every month'); + + $this->assertIsArray($result); + $this->assertEquals('MONTHLY', $result['FREQ']); + } + + public function testParseTextSimpleYearly(): void + { + $result = $this->parser->parseText('every year'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + } + + public function testParseTextSimpleHourly(): void + { + $result = $this->parser->parseText('every hour'); + + $this->assertIsArray($result); + $this->assertEquals('HOURLY', $result['FREQ']); + } + + public function testParseTextSimpleMinutely(): void + { + $result = $this->parser->parseText('every minute'); + + $this->assertIsArray($result); + $this->assertEquals('MINUTELY', $result['FREQ']); + } + + public function testParseTextWithInterval(): void + { + $result = $this->parser->parseText('every 2 days'); + + $this->assertIsArray($result); + $this->assertEquals('DAILY', $result['FREQ']); + $this->assertArrayHasKey('INTERVAL', $result); + $this->assertEquals('2', $result['INTERVAL']); + } + + public function testParseTextWithLargeInterval(): void + { + $result = $this->parser->parseText('every 10 weeks'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('10', $result['INTERVAL']); + } + + public function testParseTextWithCount(): void + { + $result = $this->parser->parseText('every day for 3 times'); + + $this->assertIsArray($result); + $this->assertEquals('DAILY', $result['FREQ']); + $this->assertArrayHasKey('COUNT', $result); + $this->assertEquals('3', $result['COUNT']); + } + + public function testParseTextWithCountNoTimesKeyword(): void + { + $result = $this->parser->parseText('every day for 5'); + + $this->assertIsArray($result); + $this->assertEquals('DAILY', $result['FREQ']); + $this->assertEquals('5', $result['COUNT']); + } + + public function testParseTextWithIntervalAndCount(): void + { + $result = $this->parser->parseText('every 2 weeks for 10 times'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('2', $result['INTERVAL']); + $this->assertEquals('10', $result['COUNT']); + } + + public function testParseTextMonday(): void + { + $result = $this->parser->parseText('every monday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertArrayHasKey('BYDAY', $result); + $this->assertEquals('MO', $result['BYDAY']); + } + + public function testParseTextTuesday(): void + { + $result = $this->parser->parseText('every tuesday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('TU', $result['BYDAY']); + } + + public function testParseTextWednesday(): void + { + $result = $this->parser->parseText('every wednesday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('WE', $result['BYDAY']); + } + + public function testParseTextThursday(): void + { + $result = $this->parser->parseText('every thursday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('TH', $result['BYDAY']); + } + + public function testParseTextFriday(): void + { + $result = $this->parser->parseText('every friday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('FR', $result['BYDAY']); + } + + public function testParseTextSaturday(): void + { + $result = $this->parser->parseText('every saturday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('SA', $result['BYDAY']); + } + + public function testParseTextSunday(): void + { + $result = $this->parser->parseText('every sunday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertEquals('SU', $result['BYDAY']); + } + + public function testParseTextWeekdays(): void + { + $result = $this->parser->parseText('every weekday'); + + $this->assertIsArray($result); + $this->assertEquals('WEEKLY', $result['FREQ']); + $this->assertArrayHasKey('BYDAY', $result); + $this->assertEquals('MO,TU,WE,TH,FR', $result['BYDAY']); + } + + public function testParseTextJanuary(): void + { + $result = $this->parser->parseText('every january'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertArrayHasKey('BYMONTH', $result); + $this->assertEquals('1', $result['BYMONTH']); + } + + public function testParseTextFebruary(): void + { + $result = $this->parser->parseText('every february'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('2', $result['BYMONTH']); + } + + public function testParseTextMarch(): void + { + $result = $this->parser->parseText('every march'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('3', $result['BYMONTH']); + } + + public function testParseTextApril(): void + { + $result = $this->parser->parseText('every april'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('4', $result['BYMONTH']); + } + + public function testParseTextMay(): void + { + $result = $this->parser->parseText('every may'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('5', $result['BYMONTH']); + } + + public function testParseTextJune(): void + { + $result = $this->parser->parseText('every june'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('6', $result['BYMONTH']); + } + + public function testParseTextJuly(): void + { + $result = $this->parser->parseText('every july'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('7', $result['BYMONTH']); + } + + public function testParseTextAugust(): void + { + $result = $this->parser->parseText('every august'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('8', $result['BYMONTH']); + } + + public function testParseTextSeptember(): void + { + $result = $this->parser->parseText('every september'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('9', $result['BYMONTH']); + } + + public function testParseTextOctober(): void + { + $result = $this->parser->parseText('every october'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('10', $result['BYMONTH']); + } + + public function testParseTextNovember(): void + { + $result = $this->parser->parseText('every november'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('11', $result['BYMONTH']); + } + + public function testParseTextDecember(): void + { + $result = $this->parser->parseText('every december'); + + $this->assertIsArray($result); + $this->assertEquals('YEARLY', $result['FREQ']); + $this->assertEquals('12', $result['BYMONTH']); + } + + public function testParseTextCaseInsensitive(): void + { + $result1 = $this->parser->parseText('EVERY DAY'); + $result2 = $this->parser->parseText('Every Day'); + $result3 = $this->parser->parseText('every day'); + + $this->assertEquals($result1, $result2); + $this->assertEquals($result2, $result3); + } + + public function testParseTextWithExtraWhitespace(): void + { + $result = $this->parser->parseText(' every day '); + + $this->assertIsArray($result); + $this->assertEquals('DAILY', $result['FREQ']); + } + + public function testParseTextInvalidReturnsNull(): void + { + $result = $this->parser->parseText('this is not valid'); + + $this->assertNull($result); + } + + public function testParseTextEmptyReturnsNull(): void + { + $result = $this->parser->parseText(''); + + $this->assertNull($result); + } + + public function testParseTextOnlyWhitespaceReturnsNull(): void + { + $result = $this->parser->parseText(' '); + + $this->assertNull($result); + } + + public function testParseTextWithoutEveryReturnsNull(): void + { + $result = $this->parser->parseText('day'); + + $this->assertNull($result); + } + + public function testParseTextIncompleteReturnsNull(): void + { + $result = $this->parser->parseText('every'); + + $this->assertNull($result); + } + + #[DataProvider('validTextProvider')] + public function testParseTextValid(string $text, string $expectedFreq, ?string $expectedInterval, ?string $expectedCount, ?string $expectedByDay, ?string $expectedByMonth): void + { + $result = $this->parser->parseText($text); + + $this->assertIsArray($result); + $this->assertEquals($expectedFreq, $result['FREQ']); + + if ($expectedInterval !== null) { + $this->assertArrayHasKey('INTERVAL', $result); + $this->assertEquals($expectedInterval, $result['INTERVAL']); + } + + if ($expectedCount !== null) { + $this->assertArrayHasKey('COUNT', $result); + $this->assertEquals($expectedCount, $result['COUNT']); + } + + if ($expectedByDay !== null) { + $this->assertArrayHasKey('BYDAY', $result); + $this->assertEquals($expectedByDay, $result['BYDAY']); + } + + if ($expectedByMonth !== null) { + $this->assertArrayHasKey('BYMONTH', $result); + $this->assertEquals($expectedByMonth, $result['BYMONTH']); + } + } + + public static function validTextProvider(): array + { + return [ + ['every day', 'DAILY', null, null, null, null], + ['every 2 days', 'DAILY', '2', null, null, null], + ['every 5 days', 'DAILY', '5', null, null, null], + ['every week', 'WEEKLY', null, null, null, null], + ['every 3 weeks', 'WEEKLY', '3', null, null, null], + ['every month', 'MONTHLY', null, null, null, null], + ['every 6 months', 'MONTHLY', '6', null, null, null], + ['every year', 'YEARLY', null, null, null, null], + ['every 2 years', 'YEARLY', '2', null, null, null], + ['every hour', 'HOURLY', null, null, null, null], + ['every 4 hours', 'HOURLY', '4', null, null, null], + ['every minute', 'MINUTELY', null, null, null, null], + ['every 15 minutes', 'MINUTELY', '15', null, null, null], + ['every day for 5 times', 'DAILY', null, '5', null, null], + ['every 2 weeks for 10 times', 'WEEKLY', '2', '10', null, null], + ['every monday', 'WEEKLY', null, null, 'MO', null], + ['every tuesday', 'WEEKLY', null, null, 'TU', null], + ['every wednesday', 'WEEKLY', null, null, 'WE', null], + ['every thursday', 'WEEKLY', null, null, 'TH', null], + ['every friday', 'WEEKLY', null, null, 'FR', null], + ['every saturday', 'WEEKLY', null, null, 'SA', null], + ['every sunday', 'WEEKLY', null, null, 'SU', null], + ['every weekday', 'WEEKLY', null, null, 'MO,TU,WE,TH,FR', null], + ['every january', 'YEARLY', null, null, null, '1'], + ['every february', 'YEARLY', null, null, null, '2'], + ['every march', 'YEARLY', null, null, null, '3'], + ['every april', 'YEARLY', null, null, null, '4'], + ['every may', 'YEARLY', null, null, null, '5'], + ['every june', 'YEARLY', null, null, null, '6'], + ['every july', 'YEARLY', null, null, null, '7'], + ['every august', 'YEARLY', null, null, null, '8'], + ['every september', 'YEARLY', null, null, null, '9'], + ['every october', 'YEARLY', null, null, null, '10'], + ['every november', 'YEARLY', null, null, null, '11'], + ['every december', 'YEARLY', null, null, null, '12'], + ]; + } + + #[DataProvider('invalidTextProvider')] + public function testParseTextInvalid(string $text): void + { + $result = $this->parser->parseText($text); + + $this->assertNull($result); + } + + public static function invalidTextProvider(): array + { + return [ + [''], + [' '], + ['every'], + ['day'], + ['week'], + ['this is invalid'], + ['random text'], + ['every invalid'], + ['every 0 days'], + ['every -1 weeks'], + ['123 every day'], + ]; + } + + public function testParseTextShortDayNames(): void + { + $resultMo = $this->parser->parseText('every mo'); + $this->assertIsArray($resultMo); + $this->assertEquals('MO', $resultMo['BYDAY']); + + $resultTu = $this->parser->parseText('every tu'); + $this->assertIsArray($resultTu); + $this->assertEquals('TU', $resultTu['BYDAY']); + + $resultWe = $this->parser->parseText('every we'); + $this->assertIsArray($resultWe); + $this->assertEquals('WE', $resultWe['BYDAY']); + + $resultTh = $this->parser->parseText('every th'); + $this->assertIsArray($resultTh); + $this->assertEquals('TH', $resultTh['BYDAY']); + + $resultFr = $this->parser->parseText('every fr'); + $this->assertIsArray($resultFr); + $this->assertEquals('FR', $resultFr['BYDAY']); + + $resultSa = $this->parser->parseText('every sa'); + $this->assertIsArray($resultSa); + $this->assertEquals('SA', $resultSa['BYDAY']); + + $resultSu = $this->parser->parseText('every su'); + $this->assertIsArray($resultSu); + $this->assertEquals('SU', $resultSu['BYDAY']); + } + + public function testParseTextShortMonthNames(): void + { + $resultJan = $this->parser->parseText('every jan'); + $this->assertIsArray($resultJan); + $this->assertEquals('1', $resultJan['BYMONTH']); + + $resultFeb = $this->parser->parseText('every feb'); + $this->assertIsArray($resultFeb); + $this->assertEquals('2', $resultFeb['BYMONTH']); + + $resultMar = $this->parser->parseText('every mar'); + $this->assertIsArray($resultMar); + $this->assertEquals('3', $resultMar['BYMONTH']); + + $resultApr = $this->parser->parseText('every apr'); + $this->assertIsArray($resultApr); + $this->assertEquals('4', $resultApr['BYMONTH']); + + $resultJun = $this->parser->parseText('every jun'); + $this->assertIsArray($resultJun); + $this->assertEquals('6', $resultJun['BYMONTH']); + + $resultJul = $this->parser->parseText('every jul'); + $this->assertIsArray($resultJul); + $this->assertEquals('7', $resultJul['BYMONTH']); + + $resultAug = $this->parser->parseText('every aug'); + $this->assertIsArray($resultAug); + $this->assertEquals('8', $resultAug['BYMONTH']); + + $resultSep = $this->parser->parseText('every sep'); + $this->assertIsArray($resultSep); + $this->assertEquals('9', $resultSep['BYMONTH']); + + $resultOct = $this->parser->parseText('every oct'); + $this->assertIsArray($resultOct); + $this->assertEquals('10', $resultOct['BYMONTH']); + + $resultNov = $this->parser->parseText('every nov'); + $this->assertIsArray($resultNov); + $this->assertEquals('11', $resultNov['BYMONTH']); + + $resultDec = $this->parser->parseText('every dec'); + $this->assertIsArray($resultDec); + $this->assertEquals('12', $resultDec['BYMONTH']); + } + + public function testParseTextPluralForms(): void + { + $daysSingular = $this->parser->parseText('every day'); + $daysPlural = $this->parser->parseText('every 3 days'); + $this->assertIsArray($daysSingular); + $this->assertIsArray($daysPlural); + $this->assertEquals('DAILY', $daysSingular['FREQ']); + $this->assertEquals('DAILY', $daysPlural['FREQ']); + + $weeksSingular = $this->parser->parseText('every week'); + $weeksPlural = $this->parser->parseText('every 2 weeks'); + $this->assertIsArray($weeksSingular); + $this->assertIsArray($weeksPlural); + $this->assertEquals('WEEKLY', $weeksSingular['FREQ']); + $this->assertEquals('WEEKLY', $weeksPlural['FREQ']); + + $monthsSingular = $this->parser->parseText('every month'); + $monthsPlural = $this->parser->parseText('every 2 months'); + $this->assertIsArray($monthsSingular); + $this->assertIsArray($monthsPlural); + $this->assertEquals('MONTHLY', $monthsSingular['FREQ']); + $this->assertEquals('MONTHLY', $monthsPlural['FREQ']); + + $yearsSingular = $this->parser->parseText('every year'); + $yearsPlural = $this->parser->parseText('every 2 years'); + $this->assertIsArray($yearsSingular); + $this->assertIsArray($yearsPlural); + $this->assertEquals('YEARLY', $yearsSingular['FREQ']); + $this->assertEquals('YEARLY', $yearsPlural['FREQ']); + + $hoursSingular = $this->parser->parseText('every hour'); + $hoursPlural = $this->parser->parseText('every 2 hours'); + $this->assertIsArray($hoursSingular); + $this->assertIsArray($hoursPlural); + $this->assertEquals('HOURLY', $hoursSingular['FREQ']); + $this->assertEquals('HOURLY', $hoursPlural['FREQ']); + + $minutesSingular = $this->parser->parseText('every minute'); + $minutesPlural = $this->parser->parseText('every 2 minutes'); + $this->assertIsArray($minutesSingular); + $this->assertIsArray($minutesPlural); + $this->assertEquals('MINUTELY', $minutesSingular['FREQ']); + $this->assertEquals('MINUTELY', $minutesPlural['FREQ']); + } + + public function testParseTextTimesSingularAndPlural(): void + { + $singular = $this->parser->parseText('every day for 1 time'); + $this->assertIsArray($singular); + $this->assertEquals('1', $singular['COUNT']); + + $plural = $this->parser->parseText('every day for 5 times'); + $this->assertIsArray($plural); + $this->assertEquals('5', $plural['COUNT']); + } + + public function testParseTextWeekdaySingularAndPlural(): void + { + $singular = $this->parser->parseText('every weekday'); + $this->assertIsArray($singular); + $this->assertEquals('MO,TU,WE,TH,FR', $singular['BYDAY']); + + $plural = $this->parser->parseText('every weekdays'); + $this->assertIsArray($plural); + $this->assertEquals('MO,TU,WE,TH,FR', $plural['BYDAY']); + } +}