Как синхронизировать Google Events с Laravel

В этой статье мы будем привязывать Google Events к Google Календарям. Давайте рассмотрим, какие параметры нужно отправлять и что они означают.

Оглавление

В предыдущей статье "Как синхронизировать Google Календарь с Laravel" мы познакомились с общими принципами синхронизации ресурсов от Google и написали код, синхронизирующий календари для аккаунта Google.

В этой статье мы будем привязывать события к календарям. Давайте рассмотрим, какие параметры нужно отправлять и что они означают.

При синхронизации событий важно понимать, что нужно делать после получения данных: создавать, обновлять или удалять событие, чтобы данные оставались актуальными и валидными.

Концепция синхронизации

Google Event — это объект или ресурс, связанный с определённой датой или периодом времени. Часто он имеет дополнительные параметры, такие как местоположение, описание, часовой пояс, статус, вложения и т. д.

Помните, когда вы заходите в свой почтовый клиент и вас спрашивают, приедете ли вы?

Этот атрибут хранит ваш ответ, но не имеет никакого отношения к статусу синхронизации, поскольку при синхронизации мы будем обращать внимание на множество факторов.

Типы событий

Существует только 2 типа событий: одиночное и повторяющиеся события.

Одиночные события привязаны к одной дате или периоду времени, в отличие от повторяющихся событий, которые происходят несколько раз по расписанию (праздники, собрания, дни рождения) и имеют правило повторения (RFC 5545).

Мы будем работать с API, возвращающим все события вместе, и не будем обращать на это внимание при сохранении событий.

Параметр singleEvents отвечает за расширение событий. Отличительной чертой повторяющегося события является параметр recurrence, а всех дочерних событий — параметр recurringEventId.

Схема базы данных для Google Events

Мы не будем вносить все существующие поля, а только имеющие значение на данный момент. Структура таблицы базы данных будет выглядеть следующим образом:

Schema::create('calendar_events', function (Blueprint $table) {
$table->id();
$table->string('calendar_id');
$table->string('summary')->nullable();
$table->string('provider_id');
$table->string('provider_type');
$table->longText('description')->nullable();
$table->boolean('is_all_day')->default(false);
$table->timestamp('start_at')->nullable();
$table->timestamp('end_at')->nullable();
$table->timestamps();

$table->foreign('calendar_id')->references('provider_id')->on('calendars')->onDelete('CASCADE');
});

Синхронизация событий Google Events

Следуя интерфейсу ProviderInterface, мы определили функцию synchronize, создающую объект синхронизации ресурсов GoogleSynchronizer.

public function synchronize(string $resource, Account $account);

Этот объект помог в предыдущей статье выполнить синхронизацию календаря. Добавим реализацию работы по синхронизации событий для календаря.

Для синхронизации нам нужен идентификатор календаря. Вы можете использовать специальное значение calendarId — primary, это ссылка на основной календарь пользователя, используемый по умолчанию.

public function synchronizeEvents(Account $account, array $options = [])
{
$token = $account->getToken();
$accountId = $account->getId();
$calendarId = $options['calendarId'] ?? 'primary';
$pageToken = $options['pageToken'] ?? null;
$syncToken = $options['syncToken'] ?? null;

$now = now();

$query = Arr::only($options, ['timeMin', 'timeMax', 'maxResults']);
$query = array_merge($query, [
'maxResults' => 25,
'timeMin' => $now->copy()->startOfMonth()->toRfc3339String(),
'timeMax' => $now->copy()->addMonth()->toRfc3339String()
]);

/** @var CalendarRepository $calendarRepository */
$calendarRepository = $this->repository(CalendarRepository::class);

if ($token->isExpired()) {
return false;
}

if (isset($syncToken)) {
$query = [
'syncToken' => $syncToken,
];
}

/** @var EventRepository $eventRepository */
$eventRepository = $this->repository(EventRepository::class);

$eventIds = $eventRepository
->setColumns(['provider_id'])
->getByAttributes([
'calendar_id' => $calendarId,
'provider_type' => $this->provider->getProviderName()
])
->pluck('provider_id');

$url = "/calendar/{$this->provider->getVersion()}/calendars/${calendarId}/events";

do {
if (isset($pageToken) && empty($syncToken)) {
$query = [
'pageToken' => $pageToken
];
}

Log::debug('Synchronize Events', [
'query' => $query
]);

$body = $this->call('GET', $url, [
'headers' => ['Authorization' => 'Bearer ' . $token->getAccessToken()],
'query' => $query
]);

$items = $body['items'];

$pageToken = $body['nextPageToken'] ?? null;

// Skip loop
if (count($items) === 0) {
break;
}

$itemIterator = new \ArrayIterator($items);

while ($itemIterator->valid()) {
$event = $itemIterator->current();

$this->synchronizeEvent($event, $calendarId, $eventIds);

$itemIterator->next();
}

} while (is_null($pageToken) === false);

$syncToken = $body['nextSyncToken'];
$now = now();

$calendarRepository->updateByAttributes(
['provider_id' => $calendarId, 'account_id' => $accountId],
[
'sync_token' => Crypt::encryptString($syncToken),
'last_sync_at' => $now,
'updated_at' => $now
]
);
}

Эта функция получает токен доступа от аккаунта синхронизации и формирует запрос для запроса ресурсов из Google API. Конечная точка для получения данных выглядит следующим образом:

GET /calendars/v3/calendars/example@gmail.com/events?maxResults=25&timeMin=2023-01-01T00:00:00+00:00&timeMax=2023-02-02T20:54:27+00:00"

Для получения второй страницы данных запрос будет выглядеть следующим образом:

GET /calendars/v3/calendars/example@gmail.com/events?pageToken=CiAKGjBpNDd2Nmp2Zml2cXRwYjBpOXA"

Получив страницу ресурса, мы обрабатываем каждое событие отдельно с помощью функции synchronizeEvent. В результате у нас есть 3 сценария для каждого события.

Удаление событий

Если статус события cancelled, мы должны удалить его, если оно существует в нашей базе данных.

if ($event['status'] === 'cancelled') {

if ($eventIds->contains($eventId)) {
$eventRepository->deleteWhere([
'calendar_id' => $calendarId,
'provider_id' => $eventId,
'provider_type' => $this->provider->getProviderName(),
]);
}

return;
}

Обновление событий

Перед выполнением API-запроса мы получили список существующих ID, связанных с данным календарём в учётной записи. Мы должны проверить, присутствует ли Event ID в базе данных, и обновить его, поскольку Event ID является уникальным полем.

if ($eventIds->contains($eventId)) {

$eventRepository->updateByAttributes(
[
'calendar_id' => $calendarId,
'provider_id' => $eventId,
'provider_type' => $this->provider->getProviderName()
],
[
'summary' => $event['summary'],
'is_all_day' => $isAllDay,
'description' => $event['description'] ?? null,
'start_at' => $eventStart,
'end_at' => $eventEnd,
'updated_at' => new \DateTime(),
]
);
}

Создание события

Если это событие не найдено в базе данных, нам нужно его создать.

$eventRepository->insert([
'calendar_id' => $calendarId,
'provider_id' => $eventId,
'provider_type' => $this->provider->getProviderName(),
'summary' => $event['summary'],
'description' => $event['description'] ?? null,
'start_at' => $eventStart,
'end_at' => $eventEnd,
'is_all_day' => $isAllDay,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime(),
]);

Обратите внимание, что события приходят с указанным часовым поясом, но перед сохранением мы конвертируем их в UTC. Кроме того, события на весь день используют поля start.date и end.date для указания времени их проведения, а временные события — поля start.dateTime и end.dateTime. Для этого мы воспользуемся функцией преобразования даты.

protected function parseDateTime($eventDateTime): Carbon
{
if (isset($eventDateTime)) {
$eventDateTime = $eventDateTime['dateTime'] ?? $eventDateTime['date'];
}

return Carbon::parse($eventDateTime)->setTimezone('UTC');
}

Когда синхронизация завершена, мы сохраняем маркер синхронизации (syncToken) в записи календаря для дальнейшего использования и оптимизации.

Команда синхронизации событий календаря

Для проверки результата синхронизации мы воспользуемся командой в Laravel. Назовём команду synchronize:events.

Команда извлечёт из базы данных все календари выбранной учётной записи и синхронизирует их события.

public function handle()
{
$accountId = $this->argument('accountId');

$accountModel = app(AccountRepository::class)->find($accountId);

throw_if(empty($accountModel), ModelNotFoundException::class);

/** @var GoogleProvider $provider */
$provider = app(CalendarManager::class)->driver('google');

$calendars = app(CalendarRepository::class)->getByAttributes([
'account_id' => $accountId
]);

$account = tap(new Account(), function ($account) use ($accountModel) {

$token = Crypt::decrypt($accountModel->token);
$syncToken = '';

if (isset($accountModel->sync_token)) {
$syncToken = Crypt::decryptString($accountModel->sync_token);
}

$account
->setId($accountModel->id)
->setProviderId($accountModel->provider_id)
->setUserId($accountModel->user_id)
->setName($accountModel->name)
->setEmail($accountModel->email)
->setPicture($accountModel->picture)
->setSyncToken($syncToken)
->setToken(TokenFactory::create($token));
});

foreach ($calendars as $calendar) {
$options = ['calendarId' => $calendar->provider_id];

if (isset($calendar->sync_token)) {
$options['syncToken'] = Crypt::decryptString($calendar->sync_token);
}

$provider->synchronize('Event', $account, $options);
}
}

Заключение

Мы рассмотрели событие как ресурс и настроили синхронизацию календарей и их событий. Затем мы рассмотрели обновление токенов доступа к аккаунтам google, для автоматизации всего процесса. Полный код из статьи можно найти в коммите.

Дополнительные материалы

Предыдущая Статья

Как синхронизировать Google Календарь с Laravel

Следующая Статья

Простая целостность данных с валидацией массивов в Laravel