Последние 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

Комментарии (0)