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/Exceptions/InvalidOpeningHoursSpecification.php b/src/Exceptions/InvalidOpeningHoursSpecification.php new file mode 100644 index 0000000..950b2d5 --- /dev/null +++ b/src/Exceptions/InvalidOpeningHoursSpecification.php @@ -0,0 +1,9 @@ +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..4d33246 --- /dev/null +++ b/src/OpeningHoursSpecificationParser.php @@ -0,0 +1,176 @@ +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..3a1dbc4 --- /dev/null +++ b/tests/OpeningHoursSpecificationParserTest.php @@ -0,0 +1,103 @@ +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' + ); + } +}