История создания двух ссылок для добавления мероприятия в календарь

Ну казалось бы: две маленькие простые странные ссылки «Добавить в календарь» среди кучи интерактивных элементов на странице. При этом я получил ответы на вопросы типа «почему формат календарей устроен именно так?».

С чего всё началось

На самом-то деле, конечно, с глупости. На каком-то «сером» сайте-помойке, типа «скачать субтитры из видео ютуба», где тонна всякой хреновой рекламы и друных схем обмана наивных посетителей. Попал я на такой сайт с айфона.

Встретил забавную штуку: через какое-то время взаимодействия с сайтом (успел вставить скопированную ссылку на видос), появилось уведомление о предложении добавить чего-то там фид-ленту в мой календарь.

Из любопытства и исследовательского взгляда — согласился. И эта херня подписала меня на ical-feed какого-то «новостного агентства», засрав мне весь календарь, блин. Создав отдельный типа событий в календаре и при этом подтягивая данные из своей ленты из внешнего источника. Забавная же хрень. Я тоже такую захотел.

Понравилось. Как идея того, что можно сделать подписку на ленту мероприятий. Как раз то, что нужно мне в проекте.

Контекст истории в проекте начинается там, где есть вот такой дизайн статус-шапки с информацией о событии:

Дизайн статус-шапки с информацией о мероприятии
Даты проведения и длительность, место проведения и/или формат (очное или онлайн) ну и кнопка-действие. В первом экране, отвечает сразу на все базовые вопросы

Аккуратно свёрстано и работает. Во время вёрстки придумал, что для очного формата элемент с адресом будет кликабельным и открывать Яндекс.Карты с прокинутым в поиск, адресом. Урлы Яндекс.Карты умеют получать адрес и искать просто из ссылки с параметром ?text=:

<!-- 
    Параметр `z=16` — это масштаб карты (zoom), 
    z=20, например покажет отдельное здание 
-->

<a href="https://maps.yandex.ru/?text=Санкт-Петербург, Тверская 10&z=16" 
   target="_blank" 
   title="Подскажем адрес на Яндекс.Картах">
  Санкт-Петербург, ...
</a>

Включил это для тач-устройств в первую очередь: опыт дружелюбнее. В большинстве случаев после тапа, сразу откроется приложение Яндекс.Карт на телефоне. Если приложения на устройстве нет, то откроется браузер с сайтом карт сразу с адресом.

Захотелось также сделать что-то приятно-незаметное (пока не кликнешь, не знаешь, что о тебе позаботились и сценарий продумали уже) и для элемента с датой. Самый очевидный вариант — добавлялка напоминания в календарь.

А как у других

Afisha.ru, russpass.ru, KudaGo и Яндекс Афиша

На самых популярных порталах добавление в календарь не встречается в интерфейсе вовсе. Скорее там везде один паттерн: призыв к добавлению будет после действия — записи или покупки.

Также, как и с билетами на самолёт, например. В письме с маршрутной квитанцией обязательно будут ссылки, типа «Добавить в Apple Wallet» и добавлялки в календари.

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

  1. Google Calendar,
  2. iCal (Apple),
  3. Outlook Calendar,
  4. Есть ещё частные случаи, где компания активно пушит свои продукты: Календарь Mail.ru, Яндекс Календарь и проч.

TimePad

Подглядел в TimePad. Если смотреть их стандартную «Афишу» — клик по блоку с датой скроллит к этажу выбора билетов. А вот идея с оформлением даты, как календарика — понравилась. Кнопок-действий для добавления в календарь на странице отдельного события нет (апрель, 2024). Но есть в письмах после регистрации или оплаты:

123
Ссылки в письме TimePad. С отдельной явной сноской о необходимости авторизации в Google-календаре.

А если смотреть на страницу отдельного организатора мероприятий, то в блоке даты есть ссылка «Добавить в календарь» с выпадайкой и выбором вариантов. Любопытно, что такого нет на других отдельных страницах.

Отдельная страница организатора TimePad и контрол на стрнице в сайтбаре с выпадайкой

Как хочу сделать я 

Технические вводные у задачи: WordPress, PHP, свой шаблон и полный контроль над кодом, часть данных из админки доставать надо через поля Advanced Custom Fields (ACF).

  • Я выбираю только Google Calendar и (Календаря iCloud) iCal (Apple) варианты из мысли о том, что уж они всегда в телефоне и под рукой.
  • Придумал, что спрячу ссылки на добавление в dropdown по клику на дату: что элемент интерактивный и кликабельный, покажу ховер-эффектом.
  • Чего ещё хочу: чтобы иконки календарей в списке показывали актуальную дату события в стилистике самой иконки. Как фавикон у гугла или иконка на рабочем столе в айфоне. Контекст события и всех данных о нём у меня есть в шаблоне, почему бы и не сверстать.
  • Ещё хочу отдельную RSS-ленту для ical-календаря, чтобы на неё можно было подписаться и видеть весь актуальный список мероприятий.

Google Calendar

У продукта Google Calendar квадратная по пропорциям иконка и внутри текстом указывается актуальный день месяца. В favicon calendar.google.com — тоже подставляется актуальный день, например, такое:

<link 
  rel="icon" 
  id="favicon" 
  type="image/x-icon" 
  href="https://calendar.google.com/googlecalendar/images/favicons_2020q4/calendar_27.ico"
>

Где «27» в имени файла calendar_27.ico — день месяца. Ещё очень интересна структура УРЛа. Что ж там такое ещё есть в favicons_2020q4.

Как добавить событие Google Calendar в календарь

Чтобы добавить какое-то событие в календарь — была (!) ссылка http://www.google.com/calendar/render? с обширным списком параметров для события. Почему-то из официальной документации хоть какая-то полезная информация была удалена и все всегда во всех вопросах ссылаются на вебархивную ссылку из 2012-го (!): Share your events with an individual, a group, or the whole world.

Интернет очень странная штука, конечно. На этой же архивной странице есть генератор ссылок. Работает и спустя 12 лет.

А современные официальные ссылки документации и раздела помощи гугла показывают что-то бесполезное.

Параметры описаны на странице Instructions for making Google Calendar event reminder buttons, тоже вебархивная ссылка. Там параметры обозначают термином «CGI parameters».

Базовый набор:

# тут всегда только TEMPLATE, так сказано в доке 
action=TEMPLATE
# название события
text="Публикация поста и ссылках на календари"
# местоположение
location="https://sglazov.ru"
# описание, можно простые HTML-теги
details="Я публикую пост, это торжественно!"
# дата, в Ymd\THis + `Z` после времени
dates=20240517T190000Z/20240517T200000Z

И всё это можно превратить в набор параметров для ссылки, правда ссылка-пример из этой самой [документации] — не работает, почему-то в параметрах запроса есть лишние символы точки с запятой: text;=, &details;= ну и т. п. А конструктор кнопок — работает, но выбор года там только до 2020.

Ну и актуальный УРЛ для добавления мероприятий и формирования полной ссылки — https://calendar.google.com/calendar/r/eventedit, параметры передаются гетом.

Беру и делаю

SVG-иконку для фона взял случайную, сам в фигме удалил фон. При вёрстке поставил блок с текстом по центру размеров иконки и подогнал визуально размер шрифта, чтобы две цифры даты не поломали габариты блока. Ещё какое-то время понадобилось уделить тому, чтобы выяснить, параметр &ctz=Europe/Moscow для часового пояса обязателен.

<?php
// переменные $dateStart, $dateEnd, $speakerName, $title, $formatOffline, $city объявлены выше, там ничего интересного

// форматирует даты
$calendarDataStart = date('Ymd\THis', strtotime( $dateStart )); 
$calendarDataEnd = date('Ymd\THis', strtotime( $dateEnd ));

$calendarDetails = "Преподаватель: {$speakerName}. Описание и программа: https://site.tld/events/" . get_the_id() . "/";

$calendarUrl  = 'https://calendar.google.com/calendar/r/eventedit';
$calendarUrl .= '?text=' . htmlspecialchars( $title ); // название мероприятия
$calendarUrl .= '&ctz=Europe/Moscow'; // часовой пояс обязательный
$calendarUrl .= '&dates=' . $calendarDataStart . '/' . $calendarDataEnd;
$calendarUrl .= '&details=' . htmlspecialchars($calendarDetails);

// если событие в оффлайне и в админке указан город 
if ( $formatOffline && $city ) 
{
  $calendarAddress = $city;
  
  // добавляем параметр `location` с адресом к урлу календаря
  $calendarUrl .= '&location=' . htmlspecialchars($calendarAddress);
}
?>

<li class="dropdown-menu__item">
  <a class="add-to-calendar-link"
     href="<?= $calendarUrl; ?>"
     title="Календарь откроется в новом окне"
     target="_blank">
    <div class="add-to-calendar-link__symbol">
      <svg role="img"
           width="22"
           height="22">
        <use xlink:href="#i_google-calendar-blank"></use>
      </svg><!-- /.w-icon__symbol -->
      <div class="add-to-calendar-link__day">
        <?= date('j', strtotime( $dateStart ) ); ?><!-- день месяца в стилизованной иконке календаря -->
      </div><!-- /.add-to-calendar-link__day -->
    </div><!-- /.add-to-calendar-link__symbol -->
    <div class="add-to-calendar-link__text">
      Google Календарь
    </div><!-- /.add-to-calendar-link__text -->
  </a><!-- /.add-to-calendar-link -->
</li><!-- /.dropdown-menu__item -->

И всё прекрасно заработало.

Ссылка для добавления в iCal — календарь Apple iCloud

Стоит упомянуть, что «i» в названии формата iCalendar, созданного в 1998/2009, не имеет отношение к манере Apple называть свои продукты. В случае с iCalendar — это Internet Calendaring and Scheduling Core Object Specification (iCalendar).

Формат представляет собой набор параметров вида ключ-значение. В этой реализации все параметры орут и написаны прописными. В 199* так было модно? Файл на десятка два событий, визуально превращается в мешанину из капса, цифр и неожиданных двоеточий.

Пример структуры для отдельного события:

BEGIN:VCALENDAR
PRODID:-//Timepad Ltd.//NONSGML Timepad//RU
VERSION:2.0
METHOD:PUBLISH

BEGIN:VEVENT
UID:3414132849538
DTSTART:20240517T190000
DTEND:20240517T200000
DTSTAMP:20240415T000102
SUMMARY:SPB Frontend Митап #36
DESCRIPTION:[...] традиционная встреча веб-разработчиков в Санкт-Петербурге [...]
LOCATION:Санкт-Петербург, Цветочная улица, дом 19
END:VEVENT

END:VCALENDAR

Формат состоит из компонент-секций BEGIN:-END:. Версия является обязательной и всегда указывается вторая. Сомневаюсь, что она будет когда-то меняться, но поскольку есть совсем старинная первая, то указывается вторая.

Вместо TITLE использовали вариант SUMMARY, ну ок. METHOD:PUBLISH — это про событие, которое явно случится.

Отдельных VEVENT внутри *.ics может быть произвольное количество, не только одно событие.

Как всё работает и как должно работать у меня, тезисно:

  1. Магией создаётся/генерируется налету файл id.ics, где id — уникальный ID мероприятия, ну можно префикс ещё добавить;
  2. К этому «файлу» можно обратиться по уникальной ссылке;
  3. В момент обращения — скачивается *.ics;
  4. Файл при открытии запускает календарь, например, на айфоне;
  5. Или пользователь его открывает сам после скачивания (нууу, такоооое, но именно так оно задумано и работает).

Ещё я хотел отдельную ленту всех мероприятий в формате iCal. Придумал, что можно сделать кастомную RSS-ленту в WordPress, которая может принимать на вход один ID параметром, типа ?event=<?= get_the_id(); ?> и формировать файл для отдельного мероприятия. Ну и без параметра урл вида /feed/ical/ будет отдавать всегда актуальный общий список мероприятий в формате iCalendar. Два в одном типа.

Кастомную RSS-ленту можно собрать через WordPress-хук add_feed(). Писал про этот хук на Хабре в 2019-м: Кастомная авторассылка в MailChimp из RSS-ленты.

Скачать файл сразу поможет и сам браузер, но лучше ему помочь и указать заголовок Content-Disposition: attachment; при ответе.

Создание RSS-ленты

Файл feed-ical.php:

// Добавляет новую ленту `/feed/ical`
add_action('init', 'iCalendarFeed');
function iCalendarFeed()
{
  add_feed('ical', 'iCalendarFeedTemplate');
}

// Шаблон ленты
function iCalendarFeedTemplate()
{
  get_template_part('feed', 'ical');
}

Подключить в файле функций темы functions.php:

// лента для Apple Календаря
require get_template_directory() . '/core/custom/feed-ical.php';

Ну, у меня оно так разложено по своей структуре и собирается через require под полным моим контролем.

Кнопка-действие

<li class="dropdown-menu__item">
  <a class="add-to-calendar-link" 
     href="<?= get_feed_link('ical'); ?>?event=<?= get_the_id(); ?>"
     title="Файл события скачается"
     target="_blank"
     download="event-<?= get_the_id(); ?>.ics">
    <div class="add-to-calendar-link__symbol">
      <div class="apple-calendar">
        <div class="apple-calendar__week">
          <?= date_i18n('D', strtotime( $dateStart ) ); ?>
        </div><!-- /.apple-calendar__week -->
        <div class="apple-calendar__date">
          <?= date('j', strtotime( $dateStart ) ); ?>
        </div><!-- /.apple-calendar__date -->
      </div><!-- /.apple-calendar -->
    </div><!-- /.add-to-calendar-link__symbol -->
    <div class="add-to-calendar-link__text">
      iCal (Календарь Apple)
    </div><!-- /.add-to-google-calendar__text -->
  </a><!-- /.add-to-calendar-link -->
</li><!-- /.dropdown-menu__item -->

WordPress-функция date_i18n() позволяет из коробки работать с локализацией дат без магии. Ну и формат D выведет сокращённый двухбуквенный вариант дня недели. Как в иконке календаря на айфоне. А на макос иконка позывает месяц + день.

Шаблон iCal-ленты

<?php
// имя файла
$file = urlencode( "event-" . date('Y-m-d') . ".ics" );

// это — календарь, в UTF-8
header('Content-type: text/calendar; charset=utf-8');
// и файл надо скачать
header("Content-Description: File Transfer");
// имя этому файлу объявлено
header("Content-Disposition: attachment; filename={$file}");
// помогает всегда отдавать только свежее, игнорируя кэш
header("Expires: 0");


// если `event=ID`, то карточку собираем для него 
if ( isset($_GET['event']) && $_GET['event'] ) 
{
  $getEvent = $_GET['event'];
}

$args = array(
  'p' => $getEvent,        // ID мероприятия из запроса
  'post_type' => 'events', // мероприятия
  'posts_per_page' => -1,  // все
);
$getEvents = new WP_Query($args);

// в стандартном цикле WP перебираем всё, что нужно
if ( $getEvents->have_posts() ) : 
?>
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//RU
NAME:БРЕНД
X-WR-CALDESC:Краткое описание календаря
TZID:Europe/Moscow
<?php
// в цикле собирает отдельный компонент `BEGIN:VEVENT-END:VEVENT`
while ( $getEvents->have_posts() ) :
  // ... тут из acf/wp достаются нужные поля
  ob_start(); ?>
  BEGIN:VEVENT
  SUMMARY:<?= $title; ?>
  UID:<?= uniqid(); ?>
  DTSTAMP:<?= $timestamp; ?>
  DTSTART:<?= $dateStart; ?>
  DTEND:<?= $dateEnd; ?>
  LOCATION:<?= escapeString($location); ?>
  DESCRIPTION:<?= escapeString($description); ?>
  END:VEVENT
<?php endwhile; ?>
END:VCALENDAR
<?php
  ob_end_clean();
  return ob_get_contents();
  endif;
?>

Стандарты и спецификации

В 1996-м году написали спеку vCalendar, где «v» — virtual. vCalendar — формат данных для представления и обмена информацией календаря. В той же спеке было и первое упоминание формата vCard — формата данных для карточки контакта. vCard частично работает до сих и формат 1996 поддерживается в рамках обратной совместимости.

Формат vCalendar позже превратился в iCalendar — vCalendar: The Basis for Cross-Platform Scheduling. В 1998 году появилась спека RFC 2445.

В 2009-м, через два года после выхода первого iPhone, первую спеку прокачали для применения уже в разросшемся вебе — RFC 5545, она и является актуальной. Ещё её правили в 2016-м и в целом, спека — живая.

В 2014 году сделали мапинг формата iCalendar в json-формат в спеке RFC 7265 — jCal. Перед ним в период популярности XML была спека RFC 6321 — xCal, но формат уж слишком многословный получился.

Ещё всякие ссылки про календари и спецификации