Skip to content

Commit

Permalink
Merge pull request #232 from ambroisemaupate/master
Browse files Browse the repository at this point in the history
Support OpeningHoursSpecification structured data for creating OpeningHours
  • Loading branch information
kylekatarnls committed Nov 25, 2023
2 parents 8ef0d60 + aa8f3b9 commit 580d0c2
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 0 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/Exceptions/InvalidOpeningHoursSpecification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Spatie\OpeningHours\Exceptions;

class InvalidOpeningHoursSpecification extends \InvalidArgumentException
{
}
10 changes: 10 additions & 0 deletions src/OpeningHours.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ public static function create(
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
Expand Down
176 changes: 176 additions & 0 deletions src/OpeningHoursSpecificationParser.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
103 changes: 103 additions & 0 deletions tests/OpeningHoursSpecificationParserTest.php
Original file line number Diff line number Diff line change
@@ -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'
);
}
}

0 comments on commit 580d0c2

Please sign in to comment.