Разработка плагина для интеграции Яндекс-Календаря с 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}
В теге
мы указываем временной интервал, события которого мы хотим получить.
В ответ приходит описание события/ий в соответствии с спецификацией. Осталось найти рабочую библиотеку, которая поддерживает 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) : JPanel() {
init {
layout = FlowLayout(FlowLayout.LEFT).apply {
add(list)
}
add(list)
}
}
Класс JBList<*> — это реализация обычного JList, а EventDataDto это наш класс, который содержит информацию о нашем календарном событии.
Создадим свой собственный лист, унаследовав его от JBList<*>.
@Service(Service.Level.PROJECT)
class CalendarList : JBList() {
init {
model = service()
cellRenderer = service()
}
}
И здесь мы сталкиваемся с концепцией сервисов, которую стоит немного объяснить.
По факту это некий 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 {
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().state
return state.login?.trim()
}
private fun getPassword(): String? {
val state = service().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) : 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 """
""".trim()
}
}
Логин и пароль мы получаем из StateService сервиса, который использует механизм state.
Чтобы хранить состояние вашего плагина, нужно реализовать типизированный интерфейс PersistentStateComponent<*>, где типом является dto объект, который и будет описывать
хранимое состояние (На практике, как мне показалось, работает это не очень надёжно, т.е. если вы изменили состояние и мгновенно завершили работу, то не факт что оно сохранится.
Я с этим сталкивался достаточно часто). Сам сервис выглядит очень просто:
@State(
name = "dev.calendar.state.ConfigState",
storages = [Storage("calendar.xml")]
)
@Service
class StateService: PersistentStateComponent {
private var state = ConfigStateDto()
override fun getState() = state
override fun loadState(loadedState: ConfigStateDto) {
service().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? {
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").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().get().date
endDate = event.getEndDate().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() {
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
Затем переопределить метод override fun appFrameCreated (commandLineArgs: MutableList), который будет выполняться каждый раз, когда наша IDE запускается.
class AppActivationListener : AppLifecycleListener {
override fun appFrameCreated(commandLineArgs: MutableList) {
service().startNotification(
service().state.notificationFrequencyTime
)
service().startCalendarSynchronization(
service().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().updateCalendar()
},
0,
synchronizationFrequencyTime,
TimeUnit.MINUTES
)
}
fun startNotification(notificationFrequencyTime: Long) {
notificationFeature?.cancel(true)
notificationFeature = AppExecutorUtil.getAppScheduledExecutorService().scheduleWithFixedDelay(
{
service().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.
Главными здесь являются методы apply () и createComponent (). Метод apply () вызывается при нажатии одноименной кнопки в настройках, а метод createComponent ()
возвращает UI панель с настройками. В простой реализации это класс получился таким:
class SettingsConfigurable : SearchableConfigurable {
override fun createComponent(): JComponent = service()
override fun isModified(): Boolean {
return true
}
override fun apply() {
val settingsPanel = service()
val state = service().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