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