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