-
-
Notifications
You must be signed in to change notification settings - Fork 157
Expand file tree
/
Copy pathLink.php
More file actions
247 lines (203 loc) · 8.88 KB
/
Copy pathLink.php
File metadata and controls
247 lines (203 loc) · 8.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
<?php
declare(strict_types=1);
namespace Spatie\CalendarLinks;
use Spatie\CalendarLinks\Exceptions\InvalidLink;
use Spatie\CalendarLinks\Generators\Google;
use Spatie\CalendarLinks\Generators\Ics;
use Spatie\CalendarLinks\Generators\WebOffice;
use Spatie\CalendarLinks\Generators\WebOutlook;
use Spatie\CalendarLinks\Generators\Yahoo;
/**
* @api
* @psalm-import-type IcsOptions from \Spatie\CalendarLinks\Generators\Ics
* @psalm-import-type GoogleUrlParameters from \Spatie\CalendarLinks\Generators\Google
* @psalm-import-type YahooUrlParameters from \Spatie\CalendarLinks\Generators\Yahoo
* @psalm-import-type OutlookUrlParameters from \Spatie\CalendarLinks\Generators\BaseOutlook
* @psalm-import-type IcsPresentationOptions from \Spatie\CalendarLinks\Generators\Ics
* @psalm-type LinkGuest = array{email: string, optional: bool}
*/
class Link
{
public readonly string $title;
public readonly \DateTimeImmutable $from;
public readonly \DateTimeImmutable $to;
/** The timezone the event starts in. */
public readonly \DateTimeZone $fromTimezone;
/** The timezone the event ends in, which is only distinct from $fromTimezone for something like a flight. */
public readonly \DateTimeZone $toTimezone;
public readonly bool $allDay;
public string $description = '';
public string $address = '';
/** @psalm-var list<LinkGuest> */
public array $guests = [];
final public function __construct(string $title, \DateTimeInterface $from, \DateTimeInterface $to, bool $allDay = false)
{
$this->title = $title;
$this->allDay = $allDay;
// Recorded before the normalisation below folds $to into $from's zone and the identity is lost.
$this->fromTimezone = $from->getTimezone();
$this->toTimezone = $to->getTimezone();
$this->from = \DateTimeImmutable::createFromInterface($from);
$immutableTo = \DateTimeImmutable::createFromInterface($to);
// Ensures timezones match.
if ($this->fromTimezone->getName() !== $this->toTimezone->getName()) {
$immutableTo = $allDay
? self::reinterpretIn($immutableTo, $this->fromTimezone)
: $immutableTo->setTimezone($this->fromTimezone);
}
// Ensures from date is earlier than to date.
if ($this->from > $immutableTo) {
throw InvalidLink::negativeDateRange($this->from, $immutableTo);
}
// All-day events: convert inclusive end date to exclusive end date,
// as calendar services expect the end date to be the day after the last event day.
$this->to = $allDay ? $immutableTo->modify('+1 day') : $immutableTo;
}
/**
* An all-day event has no clock time, so each endpoint is a calendar date rather than an instant.
* Reading the date the caller wrote in another zone keeps that date, where converting the instant
* would move it to whichever date the same moment falls on in the start's zone, gaining or losing
* a day. The format holds no offset, so the zone passed alongside it is the one that applies.
*/
private static function reinterpretIn(\DateTimeImmutable $date, \DateTimeZone $timezone): \DateTimeImmutable
{
return new \DateTimeImmutable($date->format('Y-m-d H:i:s'), $timezone);
}
/**
* @throws \Spatie\CalendarLinks\Exceptions\InvalidLink When date range is invalid.
*/
public static function create(string $title, \DateTimeInterface $from, \DateTimeInterface $to): static
{
return new static($title, $from, $to);
}
/**
* @param positive-int $numberOfDays
* @throws \Spatie\CalendarLinks\Exceptions\InvalidLink When date range is invalid.
*/
public static function createAllDay(string $title, \DateTimeInterface $from, int $numberOfDays = 1): static
{
$lastDay = $numberOfDays - 1;
$to = \DateTimeImmutable::createFromInterface($from)->modify("+$lastDay days");
return new static($title, $from, $to, true);
}
/** Set the description of the Event. */
public function description(string $description): static
{
$this->description = $description;
return $this;
}
/** Set the address of the Event. */
public function address(string $address): static
{
$this->address = $address;
return $this;
}
/**
* Add a guest (attendee) to the Event.
*
* An address already on the list is ignored, so the first spelling and role of a guest are the
* ones that are kept.
*
* @param string $email A plain email address. Display names are not supported, because Yahoo cannot represent them.
* @param bool $optional Whether attendance is optional. Yahoo has no optional role, so optional guests are invited as required there.
* @throws \Spatie\CalendarLinks\Exceptions\InvalidLink When the email address is invalid.
*/
public function guest(string $email, bool $optional = false): static
{
$email = self::validateGuestEmail($email);
if (! $this->hasGuest($email)) {
$this->guests[] = ['email' => $email, 'optional' => $optional];
}
return $this;
}
/**
* Add several guests (attendees) to the Event at once. Duplicates are ignored, both within the
* batch and against guests already added.
*
* @param list<string> $emails Plain email addresses.
* @param bool $optional Whether attendance is optional for all of them.
* @throws \Spatie\CalendarLinks\Exceptions\InvalidLink When any of the email addresses is invalid.
*/
public function guests(array $emails, bool $optional = false): static
{
// Validate every address up front, so a rejected one leaves the guest list untouched.
foreach ($emails as $email) {
self::validateGuestEmail($email);
}
foreach ($emails as $email) {
$this->guest($email, $optional);
}
return $this;
}
/**
* The whole address is compared case-insensitively. A local part is technically case-sensitive
* per RFC 5321, but no calendar service treats it as such, so two spellings of one address would
* invite the same person twice. FILTER_VALIDATE_EMAIL has already rejected anything non-ASCII,
* so a byte-wise fold is enough.
*/
private function hasGuest(string $email): bool
{
foreach ($this->guests as $guest) {
if (strcasecmp($guest['email'], $email) === 0) {
return true;
}
}
return false;
}
/**
* A quoted local part (`"foo,bar"@example.com`) passes FILTER_VALIDATE_EMAIL, but none of the
* services can carry one: Google and Yahoo both use a comma as their address separator, and no
* calendar accepts a quoted address anyway. Rejecting it keeps every generator in agreement.
*
* @throws \Spatie\CalendarLinks\Exceptions\InvalidLink When the email address is invalid.
*/
private static function validateGuestEmail(string $email): string
{
if (! filter_var($email, FILTER_VALIDATE_EMAIL) || str_starts_with($email, '"')) {
throw InvalidLink::invalidGuestEmail($email);
}
return $email;
}
/**
* Whether the event genuinely starts and ends in different timezones, so a generator that can
* express both should. An all-day event has no clock time to place in a zone, so it never does.
*/
public function hasDistinctTimezones(): bool
{
return ! $this->allDay && $this->fromTimezone->getName() !== $this->toTimezone->getName();
}
public function formatWith(Generator $generator): string
{
return $generator->generate($this);
}
/** @psalm-param GoogleUrlParameters $urlParameters */
public function google(array $urlParameters = []): string
{
return $this->formatWith(new Google($urlParameters));
}
/**
* @psalm-param IcsOptions $options ICS specific properties and components
* @psalm-param IcsPresentationOptions $presentationOptions
* @return string
* @throws \Spatie\CalendarLinks\Exceptions\InvalidLink When an option value cannot be written to the calendar.
*/
public function ics(array $options = [], array $presentationOptions = []): string
{
return $this->formatWith(new Ics($options, $presentationOptions));
}
/** @psalm-param YahooUrlParameters $urlParameters */
public function yahoo(array $urlParameters = []): string
{
return $this->formatWith(new Yahoo($urlParameters));
}
/** @psalm-param OutlookUrlParameters $urlParameters */
public function webOutlook(array $urlParameters = []): string
{
return $this->formatWith(new WebOutlook($urlParameters));
}
/** @psalm-param OutlookUrlParameters $urlParameters */
public function webOffice(array $urlParameters = []): string
{
return $this->formatWith(new WebOffice($urlParameters));
}
}