Repository: spatie/opening-hours (php)

Issue:
[Feature request] createFromStructuredData method
Hello,

Is there any plan to construct `OpeningHours` object **from** https://schema.org/OpeningHoursSpecification ?
If not, will you accept pull-request ?

I need to store opening-hours data in database then I chose to store as a native `OpeningHoursSpecification` JSON object, but I need to parse it for my business logic.

Maybe you'll have better options.


Pull request description:
Support OpeningHoursSpecification structured data for creating OpeningHours
- Adds `OpeningHoursSpecificationParser` final class with its unit-test 
- Adds `OpeningHours::createFromStructuredData` method.

Fixes #231

Patch:
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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Spatie\OpeningHours\Exceptions;
+
+class InvalidOpeningHoursSpecification extends \InvalidArgumentException
+{
+}
diff --git a/src/OpeningHours.php b/src/OpeningHours.php
index fc18d87..9c3aa6c 100644
--- a/src/OpeningHours.php
+++ b/src/OpeningHours.php
@@ -123,6 +123,16 @@ class OpeningHours
         return new static($data, $timezone, $outputTimezone);
     }
 
+    public static function createFromStructuredData(
+        array|string $structuredData,
+        string|DateTimeZone|null $timezone = null,
+        string|DateTimeZone|null $outputTimezone = null,
+    ): self {
+        $parser = new OpeningHoursSpecificationParser($structuredData);
+
+        return new static($parser->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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Spatie\OpeningHours;
+
+use Spatie\OpeningHours\Exceptions\InvalidOpeningHoursSpecification;
+
+final class OpeningHoursSpecificationParser
+{
+    private array $openingHours = [];
+
+    public function __construct(array|string|null $openingHoursSpecification)
+    {
+        if (is_string($openingHoursSpecification)) {
+            try {
+                $openingHoursSpecification = \json_decode(
+                    $openingHoursSpecification,
+                    true,
+                    flags: JSON_THROW_ON_ERROR
+                );
+            } catch (\JsonException $e) {
+                throw new InvalidOpeningHoursSpecification(
+                    'Invalid https://schema.org/OpeningHoursSpecification JSON',
+                    previous: $e
+                );
+            }
+        }
+
+        if (! is_array($openingHoursSpecification) || empty($openingHoursSpecification)) {
+            throw new InvalidOpeningHoursSpecification(
+                'Invalid https://schema.org/OpeningHoursSpecification structured data'
+            );
+        }
+
+        foreach ($openingHoursSpecification as $openingHoursSpecificationItem) {
+            if (isset($openingHoursSpecificationItem['dayOfWeek'])) {
+                /*
+                 * Regular opening hours
+                 */
+                $dayOfWeek = $openingHoursSpecificationItem['dayOfWeek'];
+                if (is_array($dayOfWeek)) {
+                    // Multiple days of week for same specification
+                    foreach ($dayOfWeek as $dayOfWeekItem) {
+                        $this->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;
+    }
+}


Test patch:
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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Spatie\OpeningHours\Test;
+
+use PHPUnit\Framework\TestCase;
+use Spatie\OpeningHours\OpeningHours;
+
+class OpeningHoursSpecificationParserTest extends TestCase
+{
+    public function testCreateFromStructuredData(): void
+    {
+        $openingHoursSpecs = <<<'JSON'
+[
+    {
+        "@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": [
+            "https://schema.org/Monday",
+            "https://schema.org/Tuesday",
+            "https://schema.org/Wednesday",
+            "https://schema.org/Thursday",
+            "https://schema.org/Friday"
+        ]
+    },
+    {
+        "@type": "OpeningHoursSpecification",
+        "opens": "08:00:00",
+        "closes": "12:00:00",
+        "dayOfWeek": "https://schema.org/Saturday"
+    },
+    {
+        "@type": "OpeningHoursSpecification",
+        "opens": "00:00",
+        "closes": "00:00",
+        "dayOfWeek": [
+            "Sunday"
+        ]
+    },
+    {
+        "@type": "OpeningHoursSpecification",
+        "opens": "00:00",
+        "closes": "00:00",
+        "validFrom": "2023-12-25",
+        "validThrough": "2023-12-25"
+    },
+    {
+        "@type": "OpeningHoursSpecification",
+        "opens": "09:00",
+        "closes": "18:00",
+        "validFrom": "2023-12-24",
+        "validThrough": "2023-12-24"
+    }
+]
+JSON;
+
+        $openingHours = OpeningHours::createFromStructuredData(json_decode($openingHoursSpecs, true));
+        $this->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'
+        );
+    }
+}