From 4b6bcab906c40a50c5d659559841504770cb928a Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Thu, 23 Nov 2023 18:55:51 +0100 Subject: [PATCH 1/3] feat: Added `OpeningHoursSpecificationParser` with its unit-test to add `OpeningHours::createFromStructuredData` method. Fixes #231 --- .../InvalidOpeningHoursSpecification.php | 10 + src/OpeningHours.php | 10 + src/OpeningHoursSpecificationParser.php | 173 ++++++++++++++++++ tests/OpeningHoursSpecificationParserTest.php | 105 +++++++++++ 4 files changed, 298 insertions(+) create mode 100644 src/Exceptions/InvalidOpeningHoursSpecification.php create mode 100644 src/OpeningHoursSpecificationParser.php create mode 100644 tests/OpeningHoursSpecificationParserTest.php diff --git a/src/Exceptions/InvalidOpeningHoursSpecification.php b/src/Exceptions/InvalidOpeningHoursSpecification.php new file mode 100644 index 0000000..10c26f7 --- /dev/null +++ b/src/Exceptions/InvalidOpeningHoursSpecification.php @@ -0,0 +1,10 @@ +getOpeningHours(), $timezone, $outputTimezone); + } + /** * @param array $data hours definition array or sub-array * @param array $excludedKeys keys to ignore from parsing diff --git a/src/OpeningHoursSpecificationParser.php b/src/OpeningHoursSpecificationParser.php new file mode 100644 index 0000000..4ce16e3 --- /dev/null +++ b/src/OpeningHoursSpecificationParser.php @@ -0,0 +1,173 @@ +addDayOfWeekHours( + $dayOfWeekItem, + $openingHoursSpecificationItem['opens'] ?? null, + $openingHoursSpecificationItem['closes'] ?? null + ); + } + } elseif (is_string($dayOfWeek)) { + $this->addDayOfWeekHours( + $dayOfWeek, + $openingHoursSpecificationItem['opens'] ?? null, + $openingHoursSpecificationItem['closes'] ?? null + ); + } else { + throw new InvalidOpeningHoursSpecification('Invalid https://schema.org/OpeningHoursSpecification structured data'); + } + } elseif ( + isset($openingHoursSpecificationItem['validFrom']) && + isset($openingHoursSpecificationItem['validThrough']) + ) { + /* + * Exception opening hours + */ + $validFrom = $openingHoursSpecificationItem['validFrom']; + $validThrough = $openingHoursSpecificationItem['validThrough']; + $this->addExceptionsHours( + $validFrom, + $validThrough, + $openingHoursSpecificationItem['opens'] ?? null, + $openingHoursSpecificationItem['closes'] ?? null + ); + } else { + throw new InvalidOpeningHoursSpecification('Invalid https://schema.org/OpeningHoursSpecification structured data'); + } + } + } + + public function getOpeningHours(): array + { + return $this->openingHours; + } + + private function schemaOrgDayToString(string $schemaOrgDaySpec): string + { + // Support official and Google-flavored Day specifications + return match ($schemaOrgDaySpec) { + 'Monday', 'https://schema.org/Monday' => 'monday', + 'Tuesday', 'https://schema.org/Tuesday' => 'tuesday', + 'Wednesday', 'https://schema.org/Wednesday' => 'wednesday', + 'Thursday', 'https://schema.org/Thursday' => 'thursday', + 'Friday', 'https://schema.org/Friday' => 'friday', + 'Saturday', 'https://schema.org/Saturday' => 'saturday', + 'Sunday', 'https://schema.org/Sunday' => 'sunday', + default => throw new InvalidOpeningHoursSpecification('Invalid https://schema.org Day specification'), + }; + } + + private function addDayOfWeekHours( + string $dayOfWeek, + ?string $opens, + ?string $closes + ): void { + $dayOfWeek = self::schemaOrgDayToString($dayOfWeek); + + $hours = $this->formatHours($opens, $closes); + if (null === $hours) { + return; + } + $this->openingHours[$dayOfWeek][] = $hours; + } + + private function addExceptionsHours( + ?string $validFrom, + ?string $validThrough, + ?string $opens, + ?string $closes + ): void { + if (!is_string($validFrom) || !is_string($validThrough)) { + throw new InvalidOpeningHoursSpecification('Missing validFrom and validThrough dates'); + } + + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $validFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $validThrough)) { + throw new InvalidOpeningHoursSpecification('Invalid validFrom and validThrough dates'); + } + + if ($validFrom === $validThrough) { + $exceptionKey = $validFrom; + } else { + $exceptionKey = $validFrom . ' to ' . $validThrough; + } + + if (!isset($this->openingHours['exceptions'])) { + $this->openingHours['exceptions'] = []; + } + if (!isset($this->openingHours['exceptions'][$exceptionKey])) { + // Default to close all day + $this->openingHours['exceptions'][$exceptionKey] = []; + } + + $hours = $this->formatHours($opens, $closes); + if (null === $hours) { + return; + } + $this->openingHours['exceptions'][$exceptionKey][] = $hours; + } + + private function formatHours( + ?string $opens, + ?string $closes, + ): ?string { + if (!is_string($opens) || !is_string($closes)) { + throw new InvalidOpeningHoursSpecification('Missing opens and closes hours'); + } + + if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $opens) || !preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $closes)) { + throw new InvalidOpeningHoursSpecification('Invalid opens and closes hours'); + } + + // strip seconds part if present + $opens = preg_replace('/^(\d{2}:\d{2})(:\d{2})?$/', '$1', $opens); + $closes = preg_replace('/^(\d{2}:\d{2})(:\d{2})?$/', '$1', $closes); + + // Ignore 00:00-00:00 which means closed all day + if ($opens === '00:00' && $closes === '00:00') { + return null; + } + + return $opens . '-' . $closes; + } +} diff --git a/tests/OpeningHoursSpecificationParserTest.php b/tests/OpeningHoursSpecificationParserTest.php new file mode 100644 index 0000000..a5b3133 --- /dev/null +++ b/tests/OpeningHoursSpecificationParserTest.php @@ -0,0 +1,105 @@ +assertInstanceOf(OpeningHours::class, $openingHours); + + $this->assertCount(2, $openingHours->forDay('monday')); + $this->assertCount(2, $openingHours->forDay('tuesday')); + $this->assertCount(2, $openingHours->forDay('wednesday')); + $this->assertCount(2, $openingHours->forDay('thursday')); + $this->assertCount(2, $openingHours->forDay('friday')); + $this->assertCount(1, $openingHours->forDay('saturday')); + $this->assertCount(0, $openingHours->forDay('sunday')); + + $this->assertTrue($openingHours->isOpenAt(new \DateTime('2023-11-20 08:00'))); + $this->assertTrue($openingHours->isOpenAt(new \DateTime('2023-11-21 08:00'))); + $this->assertTrue($openingHours->isOpenAt(new \DateTime('2023-11-22 08:00'))); + $this->assertTrue($openingHours->isOpenAt(new \DateTime('2023-11-23 08:00'))); + $this->assertTrue($openingHours->isOpenAt(new \DateTime('2023-11-24 08:00'))); + $this->assertTrue($openingHours->isOpenAt(new \DateTime('2023-11-25 08:00'))); + $this->assertTrue($openingHours->isOpenAt(new \DateTime('2023-11-25 11:59'))); + $this->assertFalse($openingHours->isOpenAt(new \DateTime('2023-11-25 13:00'))); + $this->assertFalse($openingHours->isOpenAt(new \DateTime('2023-11-26 08:00'))); + + // Exception Closed on Christmas day + $this->assertTrue( + $openingHours->isClosedAt(new \DateTime('2023-12-25 08:00')), + 'Closed on 2023 Monday Christmas day' + ); + // Exception Opened on Christmas Eve + $this->assertTrue( + $openingHours->isOpenAt(new \DateTime('2023-12-24 10:00')), + 'Opened on 2023 Sunday before Christmas day' + ); + } +} From 4e59c42812b8f79215323e651df89cc3b3f9d0dc Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Thu, 23 Nov 2023 20:00:02 +0100 Subject: [PATCH 2/3] chore: StyleCI fixes --- .../InvalidOpeningHoursSpecification.php | 1 - src/OpeningHours.php | 4 +-- src/OpeningHoursSpecificationParser.php | 25 +++++++++++-------- tests/OpeningHoursSpecificationParserTest.php | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Exceptions/InvalidOpeningHoursSpecification.php b/src/Exceptions/InvalidOpeningHoursSpecification.php index 10c26f7..950b2d5 100644 --- a/src/Exceptions/InvalidOpeningHoursSpecification.php +++ b/src/Exceptions/InvalidOpeningHoursSpecification.php @@ -6,5 +6,4 @@ class InvalidOpeningHoursSpecification extends \InvalidArgumentException { - } diff --git a/src/OpeningHours.php b/src/OpeningHours.php index 17695e6..1b836b9 100644 --- a/src/OpeningHours.php +++ b/src/OpeningHours.php @@ -128,9 +128,9 @@ public static function createFromStructuredData( string|DateTimeZone|null $timezone = null, string|DateTimeZone|null $outputTimezone = null, ): self { - $parse = new OpeningHoursSpecificationParser($structuredData); + $parser = new OpeningHoursSpecificationParser($structuredData); - return new static($parse->getOpeningHours(), $timezone, $outputTimezone); + return new static($parser->getOpeningHours(), $timezone, $outputTimezone); } /** diff --git a/src/OpeningHoursSpecificationParser.php b/src/OpeningHoursSpecificationParser.php index 4ce16e3..4d33246 100644 --- a/src/OpeningHoursSpecificationParser.php +++ b/src/OpeningHoursSpecificationParser.php @@ -27,7 +27,7 @@ public function __construct(array|string|null $openingHoursSpecification) } } - if (!is_array($openingHoursSpecification) || empty($openingHoursSpecification)) { + if (! is_array($openingHoursSpecification) || empty($openingHoursSpecification)) { throw new InvalidOpeningHoursSpecification( 'Invalid https://schema.org/OpeningHoursSpecification structured data' ); @@ -51,8 +51,8 @@ public function __construct(array|string|null $openingHoursSpecification) } elseif (is_string($dayOfWeek)) { $this->addDayOfWeekHours( $dayOfWeek, - $openingHoursSpecificationItem['opens'] ?? null, - $openingHoursSpecificationItem['closes'] ?? null + $openingHoursSpecificationItem['opens'] ?? null, + $openingHoursSpecificationItem['closes'] ?? null ); } else { throw new InvalidOpeningHoursSpecification('Invalid https://schema.org/OpeningHoursSpecification structured data'); @@ -118,24 +118,24 @@ private function addExceptionsHours( ?string $opens, ?string $closes ): void { - if (!is_string($validFrom) || !is_string($validThrough)) { + if (! is_string($validFrom) || ! is_string($validThrough)) { throw new InvalidOpeningHoursSpecification('Missing validFrom and validThrough dates'); } - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $validFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $validThrough)) { + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $validFrom) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $validThrough)) { throw new InvalidOpeningHoursSpecification('Invalid validFrom and validThrough dates'); } if ($validFrom === $validThrough) { $exceptionKey = $validFrom; } else { - $exceptionKey = $validFrom . ' to ' . $validThrough; + $exceptionKey = $validFrom.' to '.$validThrough; } - if (!isset($this->openingHours['exceptions'])) { + if (! isset($this->openingHours['exceptions'])) { $this->openingHours['exceptions'] = []; } - if (!isset($this->openingHours['exceptions'][$exceptionKey])) { + if (! isset($this->openingHours['exceptions'][$exceptionKey])) { // Default to close all day $this->openingHours['exceptions'][$exceptionKey] = []; } @@ -151,11 +151,14 @@ private function formatHours( ?string $opens, ?string $closes, ): ?string { - if (!is_string($opens) || !is_string($closes)) { + if (! is_string($opens) || ! is_string($closes)) { throw new InvalidOpeningHoursSpecification('Missing opens and closes hours'); } - if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $opens) || !preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $closes)) { + if ( + ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $opens) || + ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $closes) + ) { throw new InvalidOpeningHoursSpecification('Invalid opens and closes hours'); } @@ -168,6 +171,6 @@ private function formatHours( return null; } - return $opens . '-' . $closes; + return $opens.'-'.$closes; } } diff --git a/tests/OpeningHoursSpecificationParserTest.php b/tests/OpeningHoursSpecificationParserTest.php index a5b3133..5915080 100644 --- a/tests/OpeningHoursSpecificationParserTest.php +++ b/tests/OpeningHoursSpecificationParserTest.php @@ -11,7 +11,7 @@ class OpeningHoursSpecificationParserTest extends TestCase { public function testCreateFromStructuredData(): void { - $openingHoursSpecs = << Date: Thu, 23 Nov 2023 20:07:31 +0100 Subject: [PATCH 3/3] doc: Added README entry, support JSON string input --- README.md | 42 +++++++++++++++++++ src/OpeningHours.php | 2 +- tests/OpeningHoursSpecificationParserTest.php | 4 +- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index baa101a..b729708 100644 --- a/README.md +++ b/README.md @@ -607,6 +607,48 @@ if ($date) { } ``` +#### `OpeningHours::createFromStructuredData(array|string $data, $timezone = null, $outputTimezone = null): Spatie\OpeningHours\OpeningHours` + +Static factory method to fill the set with a https://schema.org/OpeningHoursSpecification array or JSON string. + +`dayOfWeek` supports array of day names (Google-flavored) or array of day URLs (official schema.org specification). + +```php +$openingHours = OpeningHours::createFromStructuredData('[ + { + "@type": "OpeningHoursSpecification", + "opens": "08:00", + "closes": "12:00", + "dayOfWeek": [ + "https://schema.org/Monday", + "https://schema.org/Tuesday", + "https://schema.org/Wednesday", + "https://schema.org/Thursday", + "https://schema.org/Friday" + ] + }, + { + "@type": "OpeningHoursSpecification", + "opens": "14:00", + "closes": "18:00", + "dayOfWeek": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday" + ] + }, + { + "@type": "OpeningHoursSpecification", + "opens": "00:00", + "closes": "00:00", + "validFrom": "2023-12-25", + "validThrough": "2023-12-25" + } +]'); +``` + #### `OpeningHours::asStructuredData(strinf $format = 'H:i', string|DateTimeZone $timezone) : array` Returns a [OpeningHoursSpecification](https://schema.org/openingHoursSpecification) as an array. diff --git a/src/OpeningHours.php b/src/OpeningHours.php index 1b836b9..9c3aa6c 100644 --- a/src/OpeningHours.php +++ b/src/OpeningHours.php @@ -124,7 +124,7 @@ public static function create( } public static function createFromStructuredData( - array $structuredData, + array|string $structuredData, string|DateTimeZone|null $timezone = null, string|DateTimeZone|null $outputTimezone = null, ): self { diff --git a/tests/OpeningHoursSpecificationParserTest.php b/tests/OpeningHoursSpecificationParserTest.php index 5915080..3a1dbc4 100644 --- a/tests/OpeningHoursSpecificationParserTest.php +++ b/tests/OpeningHoursSpecificationParserTest.php @@ -41,9 +41,7 @@ public function testCreateFromStructuredData(): void "@type": "OpeningHoursSpecification", "opens": "08:00:00", "closes": "12:00:00", - "dayOfWeek": [ - "https://schema.org/Saturday" - ] + "dayOfWeek": "https://schema.org/Saturday" }, { "@type": "OpeningHoursSpecification",