Разработка плагина для интеграции Яндекс-Календаря с IntelliJ IDEA

от автора

Последние 10 лет я занимаюсь java разработкой и на протяжении всего этого времени Intellij Idea является неотъемлемой частью моей(да и многих других джавистов) работы.
К сожалению, некоторых вещей, которые были бы удобны лично мне, в ней нет, но к счастью есть возможность расширять IDE с помощью плагинов. На моём ноутбуке установлен linux и нет какой-то удобной нотификации событий из корпоративного календаря, а IDE практически всегда открыта на главном мониторе. По этой причине(а ещё из-за внезапно появившегося окна свободного времени и простого интереса) я решил, почему бы не интегрировать календарь прямо в IDE, чтобы получать нотификации и точно не пропустить ничего важного?
Об этом и пойдёт речь в статье. Сразу скажу, что я не обладаю каким-то богатыми знаниями в этой области и всё нижеизложенное является исключительно моим личным опытом.

На работе в качестве корпоративной почты используется решение от yandex(я не являюсь их сотрудником), соответственно и интегрироваться предстоит именно с yandex calendar, но поскольку он поддерживает протокол CALDAV(RFC4791), то интегрироваться можно с любым другим решением, поддерживающим данный протокол(google, outlook, mail.ru).
Почитав [документацию](https://yandex.ru/support/yandex-360/business/admin/ru/security-service-applications, https://360.yandex.ru/blog/articles/kak-sinhronizirovat-yandeks-kalendar-s-kalendaryom-na-android) и немного поэкспериментировав с API, был найден рабочий вариант как получать события из календаря.
Для этого необходимо зайти в свой аккаунт -> Безопасность -> Пароли приложений и создать пароль для календаря.
Далее используя полученный пароль, можно выполнить HTTP запрос:
GET https://caldav.yandex.ru/calendars/${email}/events-default
Authorization: Basic ${token}

где token — это "{email}:{password}" в base64 и получить список ссылок на все события из вашего календаря вида:

/calendars/{email}/events-default/{uniqString}yandex.ru.ics
/calendars/{email}/events-default/{uniqString}yandex.ru.ics
/calendars/{email}/events-default/{uniqString}yandex.ru.ics
….

Отлично, мы научились вызывать API яндекса. Но это не совсем то что нас интересует. Чтобы получить события какого-то одного конкретного дня, согласно RFC4791 7.8.1
необходимо выполнить следующий HTTP запрос:

REPORT https://caldav.yandex.ru/calendars/{email}/events-default/
Authorization: Basic {secret}

    <?xml version="1.0" encoding="utf-8" ?>     <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">           <D:prop>             <D:getetag/>               <C:calendar-data>               <C:comp name="VCALENDAR">               <C:prop name="VERSION"/>                 <C:comp name="VEVENT">                 <C:prop name="SUMMARY"/>                 <C:prop name="UID"/>                 <C:prop name="DTSTART"/>                 <C:prop name="DTEND"/>                 <C:prop name="DURATION"/>                 <C:prop name="RRULE"/>                 <C:prop name="RDATE"/>                 <C:prop name="EXRULE"/>                 <C:prop name="EXDATE"/>                 <C:prop name="RECURRENCE-ID"/>               </C:comp>               <C:comp name="VTIMEZONE"/>               </C:comp>             </C:calendar-data>           </D:prop>           <C:filter>             <C:comp-filter name="VCALENDAR">               <C:comp-filter name="VEVENT">                 <C:time-range start="20060104T000000Z" end="20060105T000000Z"/>               </C:comp-filter>             </C:comp-filter>           </C:filter>         </C:calendar-query>     

В теге <C:time-range start="20060104T000000Z" end="20060105T000000Z"/> мы указываем временной интервал, события которого мы хотим получить.
В ответ приходит описание события/ий в соответствии с спецификацией. Осталось найти рабочую библиотеку, которая поддерживает RFC4791, чтобы не парсить ответ в ручную. Достаточно быстро можно наткнуться на что-то вроде библиотеки ical4j, которая умеет работать с caldav. Теперь у нас есть всё, чтобы начать писать наш плагин. В интернете достаточно информации, о том, как начать, поэтому заострять внимание на создании проекта для разработки плагина я не буду, просто оставлю ссылку на официальную документацию.

Итак, для начала, определимся, какой UI интерфейс мы бы хотели. Для меня было бы удобно иметь список событий, отсортированных по времени в правой боковой панели(там где у нас окно с gradle, maven и т.д.).

Чтобы сделать такую панель, необходимо имплементировать интерфейс com.intellij.openapi.wm.ToolWindowFactory и реализовать метод createToolWindowContent(project: Project, toolWindow: ToolWindow).
Так же добавить описание в resources/META-INF/plugin.xml или зарегистрировать согласно документации. Простейшая реализация выглядит примерно так:

     class CalendarToolFactory : ToolWindowFactory {              override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {             val contentFactory = ContentFactory.getInstance()                          val content = contentFactory.createContent(                 JPanel(),                 "",                 false             )             toolWindow.contentManager.addContent(content)         }          } 

Теперь необходимо создать саму панель(JPanel), которая будет содержать список с нашими событиями. Панель будет содержать лишь layout, который будет ответственен за размещение списка, и собственно сам элемент списка. Выглядит это примерно так:

     class CalendarPanel(private val list: JBList<EventDataDto>) : JPanel() {              init {             layout = FlowLayout(FlowLayout.LEFT).apply {                 add(list)             }             add(list)         }          } 

Класс JBList<*> — это реализация обычного JList, а EventDataDto это наш класс, который содержит информацию о нашем календарном событии.
Создадим свой собственный лист, унаследовав его от JBList<*>.

     @Service(Service.Level.PROJECT)         class CalendarList : JBList<EventDataDto>() {             init {                 model = service<CalendarListModel>()                 cellRenderer = service<CalendarListCellRenderer>()             }         }     

И здесь мы сталкиваемся с концепцией сервисов, которую стоит немного объяснить.
По факту это некий DI в IntellijIdea sdk, с помощью которого мы можем заинжектить необходимый нам bean сервис в другой сервис.
Сервисы могут быть 2 типов/скоупов: Project и Application. Разница между ними в том, что Application scope — это классический синглтон на всё приложение, в то время как Project scope —
это синглтон в рамках проекта(ну или окна IDE). У Application сервиса конструктор не должен принимать никаких аргументов, а у Project, он может принимать объект
типа Project, что часто весьма полезно, например, чтобы получить экземпляры других сервисов с этим же скоупом.

Так же в конструкторе CalendarList мы видим пример внедрения сервисов CalendarListModel и CalendarListCellRenderer, которые являются Application синглтонами, и ответственны за содержание и отображение нашего списка. Синглтонами они являются по причине того, что независимо от открытого окна IDE, мы хотим видеть одну и ту же актуальную информацию(Тут хочу обратить внимание, что сам CalendarList сделать синглтоном нельзя, т.к. он просто не будет отображаться на UI при открытии нескольких проектов — видимо такое ограничение).

Далее реализуем http client, для получения наших событий:

    @Service     class YandexRestClient {         private val client = HttpClientBuilder.create().build()              fun getTodayEvents(): Set<EventDataDto> {             val email = getLogin()             val password = getPassword()                  if (email.isNullOrBlank() || password.isNullOrBlank()) {                 return emptySet()             }                  val secret = Base64.getEncoder().encodeToString(                 "$email:$password".toByteArray(Charset.forName("UTF-8"))             )                  val url = "https://caldav.yandex.ru/calendars/$email/events-default"             val request = HttpReport(                 url,                 CaldavRequestTemplate.template(),                 mapOf(Pair(HttpHeaders.AUTHORIZATION, "Basic ${secret}"))             )                  val content = client.execute(request, BasicResponseHandler())                  return CaldavParser.toEvents(content) ?: emptySet()         }              private fun getLogin(): String? {             val state = service<StateService>().state             return state.login?.trim()         }              private fun getPassword(): String? {             val state = service<StateService>().state                  return PasswordSafe.instance.getPassword(                 CredentialAttributes(ConfigStateDto::class.java.name, state.login, ConfigStateDto::class.java)             )?.trim()         }          } 

Наш клиент так же является синглтоном и использует apache http client, который по умолчанию уже есть в idea sdk. Единственный нюанс здесь заключается в том, что необходимо реализовать метод REPORT, т.к. он отсутствует, но делается это очень просто:

    class HttpReport(url: String, body: String, headers: Map<String, String>) : HttpPost() {         init {             this.uri = URI(url)             headers.forEach {                 this.addHeader(it.key, it.value)             }             this.entity = StringEntity(body)         }              override fun getMethod() = "REPORT"          } 

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

     object CaldavRequestTemplate {         private val PATTERN = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")         private val zoneId = ZoneId.of("UTC")              fun template(): String {             val now = LocalDate.now()             val startDay = now.atTime(LocalTime.MIN).atZone(zoneId) // начало дня             val endDay = now.atTime(LocalTime.MAX).atZone(zoneId)   // конец дня                          return """                 <?xml version="1.0" encoding="utf-8" ?>                 <C:calendar-query xmlns:D="DAV:"                     xmlns:C="urn:ietf:params:xml:ns:caldav">                     <D:prop>                         <D:getetag/>                         <C:calendar-data>                             <C:comp name="VCALENDAR">                                 <C:prop name="VERSION"/>                                 <C:comp name="VEVENT">                                     <C:prop name="SUMMARY"/>                                     <C:prop name="DTSTART"/>                                     <C:prop name="DTEND"/>                                 </C:comp>                                 <C:comp name="VTIMEZONE"/>                             </C:comp>                         </C:calendar-data>                     </D:prop>                     <C:filter>                         <C:comp-filter name="VCALENDAR">                             <C:comp-filter name="VEVENT">                                 <C:time-range start="${startDay.format(PATTERN)}"                                     end="${endDay.format(PATTERN)}"/>                             </C:comp-filter>                         </C:comp-filter>                     </C:filter>                 </C:calendar-query>             """.trim()         }     } 

Логин и пароль мы получаем из StateService сервиса, который использует механизм state.
Чтобы хранить состояние вашего плагина, нужно реализовать типизированный интерфейс PersistentStateComponent<*>, где типом является dto объект, который и будет описывать
хранимое состояние(На практике, как мне показалось, работает это не очень надёжно, т.е. если вы изменили состояние и мгновенно завершили работу, то не факт что оно сохранится.
Я с этим сталкивался достаточно часто). Сам сервис выглядит очень просто:

    @State(          name = "dev.calendar.state.ConfigState",          storages = [Storage("calendar.xml")]     )     @Service     class StateService: PersistentStateComponent<ConfigStateDto> {              private var state = ConfigStateDto()              override fun getState() = state              override fun loadState(loadedState: ConfigStateDto) {             service<SettingsPanel>().applyState(loadedState)  // просто применяем сохранённое состояние для всяких checkBox и textField для панели с настройками             this.state = loadedState         }      }          class ConfigStateDto {         var login: String? = null         var enabled: Boolean = true         var notificationTime: Int = 0         var synchronizationFrequencyTime: Long = 5     }  

Для хранения sensitive data — в нашем случае поле password, idea предоставляет следующее решение.

Для получения данных о событиях напишем собственный класс CaldavParser. Нам необходимо сначала извлечь тело ответа из xml, а уже после этого использовать ical4j.
Полностью выкладывать код я не буду, т.к. он достаточно объёмный, а задача не самая сложная. У меня получилось что-то вроде:

    object CaldavParser {       // other methods...              fun toEvents(content: String): Set<EventDataDto>? {         if(content.isBlank()) {             return emptySet()         }         val value: MultistatusDto? = mapper.readValue(content, MultistatusDto::class.java)          val now = LocalDateTime.now().toLocalTime()         return value?.response             ?.asSequence()             ?.map { it.propstat?.prop?.calendarData?.text }             ?.filterNotNull()             ?.map(this::toCalendar)             ?.flatMap { it.getComponents<VEvent>("VEVENT").asSequence() }             ?.map(this::toEventDataDto)             ?.filter { it.endDate?.toLocalTime()?.isAfter(now) ?: false }             ?.toSet()     }      private fun toCalendar(it: String): Calendar {         return CalendarBuilder().build(StringReader(it.dropWhitespaces()))     }      private fun toEventDataDto(event: VEvent): EventDataDto {         return EventDataDto().apply {             startDate = event.getDateTimeStart<ZonedDateTime>().get().date             endDate = event.getEndDate<ZonedDateTime>().get().date             name = event.summary.get().value             conference = getConferenceLink(event)             conferenceType = getConferenceType(event)             description = event.description.getOrNull()?.value             uid = event.uid.getOrNull()?.value         }     } 

На данный момент наш плагин уже имеет панель для отображения событий календаря, и может эти самые события получать. Теперь попробуем соединить это воедино.
Ранее уже упоминалось, что за отображение ответственен класс CalendarListModel. Чтобы его написать, нам надо унаследовать его от стандартного DefaultListModel
и просто добавить логики, которая будет отвечать за добавление/обновление/удаление. Логика в нём достаточно простая, мы храним список отсортированных по дате событий
и переопределяем существующие(ну или пишем свои собственные методы добавления/удаления), поэтому расписывать подробно всё не имеет особого смысла.

    @Service     class CalendarListModel : DefaultListModel<EventDataDto>() {          override fun addElement(element: EventDataDto?) {           // logic           super.addElement(element)         }                  override fun removeElement(obj: Any?): Boolean {             // logic             return super.removeElement(obj)         }     } 

Теперь осталось периодически опрашивать календарь и отображать события. Задача достаточно простая, но как это сделать в intellij idea правильно, я не знаю (мой последний вопрос в саппорт остался без ответа, да и зайти туда стало возможно только из под впн). Поэтому ничего лучше, чем использовать Listeners я не придумал.
Idea предоставляет достаточно гибкий механизм событий — возможность создавать собственные события или привязываться к уже существующим.

Для собственного listener’а создадим новый класс CalendarActivationListener и заимплементим интерфейс AppLifecycleListener.

Далее нам необходимо зарегистрировать наш класс в resources/META-INF/plugin.xml

<applicationListeners>     <listener class="dev.calendar.listener.app.CalendarActivationListener" topic="com.intellij.com.intellij.ide.AppLifecycleListener"/> </applicationListeners> 

Затем переопределить метод override fun appFrameCreated(commandLineArgs: MutableList) , который будет выполняться каждый раз, когда наша IDE запускается.

    class AppActivationListener : AppLifecycleListener {      override fun appFrameCreated(commandLineArgs: MutableList<String>) {         service<SchedulerService>().startNotification(             service<StateService>().state.notificationFrequencyTime         )         service<SchedulerService>().startCalendarSynchronization(             service<StateService>().state.synchronizationFrequencyTime         )     }  } 

SchedulerService реализует выполнение повторяющейся логики, которую можно конфигурировать через настройки, т.е. менять частоту опроса API и частоту проверка случившихся событий.

@Service class SchedulerService {     private var calendarSyncFuture: ScheduledFuture<*>? = null     private var notificationFeature: ScheduledFuture<*>? = null      fun startCalendarSynchronization(synchronizationFrequencyTime: Long) {         calendarSyncFuture?.cancel(true)         calendarSyncFuture = AppExecutorUtil.getAppScheduledExecutorService().scheduleWithFixedDelay(             {                 service<CalendarService>().updateCalendar()             },             0,             synchronizationFrequencyTime,             TimeUnit.MINUTES         )     }      fun startNotification(notificationFrequencyTime: Long) {         notificationFeature?.cancel(true)         notificationFeature = AppExecutorUtil.getAppScheduledExecutorService().scheduleWithFixedDelay(             {                 service<NotificationService>().run()             },             5,             notificationFrequencyTime,             TimeUnit.SECONDS         )     } }  

В NotificationService реализована проверка и отображение события, которое наступило(или наступит). Чтобы отобразить событие, используется обычная JPanel, на которой мы выводим всю информацию. Есть пару моментов, которые бы я упомянул. Первое — создавать окно надо в другом потоке, к примеру с помощью SwingUtilities.invokeLater { }. Второе — чтобы получить кликабельную ссылку, нужно использовать класс com.intellij.ui.components.labels.LinkLabel, т.к. у простого JLabel такой возможности не даёт.

Для кастомизации отображения событий в списке на UI, можно сделать наследника от DefaultListCellRenderer и переопределить соответствующий метод:

    override fun getListCellRendererComponent(         list: JList<*>?,         value: Any?,         index: Int,         isSelected: Boolean,         cellHasFocus: Boolean     ): Component {          return (super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JLabel).apply {             // здесь можно кастомизировать элемент списка, например установить icon или background для этой ячейки, или вообще реализовать полностью новый JLabel              return this         }     } 

Сам класс необходимо добавить к нашему CalendarList в качестве cellRenderer(мы это уже сделали).

В завершении можно прикрутить UI для конфигурации нашего плагина — чтобы иметь возможность из настроек задавать логин/пароль, а так же различные другие переменные.
Чтобы это сделать, нужно имплементировать интерфейс com.intellij.openapi.options.SearchableConfigurable и зарегистрировать его в resources/META-INF/plugin.xml.

    <applicationConfigurable displayName="YCalendar" instance="dev.calendar.configurable.SettingsConfigurable"/> 

Главными здесь являются методы apply() и createComponent(). Метод apply() вызывается при нажатии одноименной кнопки в настройках, а метод createComponent()
возвращает UI панель с настройками. В простой реализации это класс получился таким:

    class SettingsConfigurable : SearchableConfigurable {              override fun createComponent(): JComponent = service<SettingsPanel>()              override fun isModified(): Boolean {             return true         }              override fun apply() {             val settingsPanel = service<SettingsPanel>()             val state = service<StateService>().state             settingsPanel.apply(state)         }              override fun getDisplayName(): String = "Yandex Calendar"              override fun getId(): String = "dev.calendar.configurable.SettingsConfigurable"          } 

SettingsPanel является наследником JPanel, и представляет собой обычный компонент с layout и различными UI компонентами, логика там достаточна простая.

Для меня самым сложным оказалось красиво расставить все компоненты с помощью GridBagLayout. Как работать с этим layout в интернете написано достаточно много,
и я точно не лучший кандидат, чтобы об этом что-то писать.

Результат работы выглядит примерно так:

Последнее что осталось сделать, это собрать и установить наш плагин. Чтобы собрать плагин, достаточно выполнить команду:
gradle buildPlugin

Теперь готовая к установке версия находится в ./build/distributions/. Во вкладке Plugins можно выбрать Install Plugin From Disk, указать собранный файл и перезапустить IDE.

В целом мне кажется я описал все ключевые моменты, с которыми столкнулся при написании данного плагина.

Если у вас есть какие-то вопросы или вы видите, что вышеизложенный материал содержит какие-то не точности — you are welcome.

Ссылка на github: https://github.com/epm-dev-priporov/YCalendar


ссылка на оригинал статьи https://habr.com/ru/articles/875464/