feat: Реализация базового CalDav без to-do через REST API обёртку

feat: Информативное логгирование и ответы сервера
This commit is contained in:
2025-07-18 02:40:06 +03:00
parent f043d7355f
commit 3f7e6d7774
10 changed files with 1097 additions and 0 deletions

188
API_DOCS.md Normal file
View File

@@ -0,0 +1,188 @@
## Calendar API Documentation
### Аутентификация
Все эндпоинты требуют Basic авторизации. Используйте ваши учетные данные от CalDAV сервера.
```bash
# Заголовки для всех запросов
Authorization: Basic base64(username:password)
CAL_ID: your_calendar_id
```
### Обязательные заголовки
- `Authorization`: Basic авторизация с креденшлами CalDAV
- `CAL_ID`: Идентификатор календаря (например: "personal", "work", "family")
### Эндпоинты
#### 1. Получить все события
```bash
GET /calendar/events
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
CAL_ID: personal
# Ответ:
{
"success": true,
"data": [
{
"uid": "12345-67890-abcdef",
"summary": "Важная встреча",
"description": "Обсуждение проекта",
"startDateTime": "2025-07-20T10:00:00",
"endDateTime": "2025-07-20T11:30:00",
"location": "Конференц-зал"
}
]
}
```
#### 2. Создать новое событие
```bash
POST /calendar/events
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
CAL_ID: personal
Content-Type: application/json
{
"summary": "Новая встреча",
"description": "Описание встречи",
"startDateTime": "2025-07-20T14:00:00",
"endDateTime": "2025-07-20T15:00:00",
"location": "Офис"
}
# Ответ (201 Created):
{
"success": true,
"data": {
"uid": "generated-uid-12345",
"summary": "Новая встреча",
"description": "Описание встречи",
"startDateTime": "2025-07-20T14:00:00",
"endDateTime": "2025-07-20T15:00:00",
"location": "Офис"
}
}
```
#### 3. Получить событие по UID
```bash
GET /calendar/events/{uid}
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
CAL_ID: personal
# Ответ:
{
"success": true,
"data": {
"uid": "12345-67890-abcdef",
"summary": "Важная встреча",
"description": "Обсуждение проекта",
"startDateTime": "2025-07-20T10:00:00",
"endDateTime": "2025-07-20T11:30:00",
"location": "Конференц-зал"
}
}
```
#### 4. Обновить событие
```bash
PUT /calendar/events/{uid}
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
CAL_ID: personal
Content-Type: application/json
{
"summary": "Обновленная встреча",
"description": "Новое описание",
"startDateTime": "2025-07-20T15:00:00",
"endDateTime": "2025-07-20T16:00:00",
"location": "Новый офис"
}
# Ответ:
{
"success": true,
"data": {
"uid": "12345-67890-abcdef",
"summary": "Обновленная встреча",
"description": "Новое описание",
"startDateTime": "2025-07-20T15:00:00",
"endDateTime": "2025-07-20T16:00:00",
"location": "Новый офис"
}
}
```
#### 5. Удалить событие
```bash
DELETE /calendar/events/{uid}
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
CAL_ID: personal
# Ответ:
{
"success": true,
"message": "Событие успешно удалено"
}
```
### Примеры с curl
```bash
# Получить все события из календаря "work"
curl -X GET "http://localhost:8080/calendar/events" \
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
-H "CAL_ID: work"
# Создать событие в календаре "personal"
curl -X POST "http://localhost:8080/calendar/events" \
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
-H "CAL_ID: personal" \
-H "Content-Type: application/json" \
-d '{
"summary": "Тестовая встреча",
"description": "Описание тестовой встречи",
"startDateTime": "2025-07-20T10:00:00",
"endDateTime": "2025-07-20T11:00:00",
"location": "Онлайн"
}'
# Обновить событие в календаре "family"
curl -X PUT "http://localhost:8080/calendar/events/your-event-uid" \
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
-H "CAL_ID: family" \
-H "Content-Type: application/json" \
-d '{
"summary": "Семейный обед",
"startDateTime": "2025-07-20T14:00:00",
"endDateTime": "2025-07-20T15:00:00"
}'
# Удалить событие из календаря "personal"
curl -X DELETE "http://localhost:8080/calendar/events/your-event-uid" \
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
-H "CAL_ID: personal"
```
### Ошибки
При отсутствии заголовка `CAL_ID`:
```json
{
"success": false,
"message": "Заголовок CAL_ID обязателен"
}
```
### Форматы дат
Все даты должны быть в формате ISO LocalDateTime: `YYYY-MM-DDTHH:mm:ss`
Например: `2025-07-20T14:30:00`
### Поддерживаемые календари
API поддерживает работу с любыми календарями, доступными пользователю в CalDAV:
- `personal` - личный календарь
- `work` - рабочий календарь
- `family` - семейный календарь
- Любые другие календари, созданные пользователем

45
README.md Normal file
View File

@@ -0,0 +1,45 @@
# webdav-service
This project was created using the [Ktor Project Generator](https://start.ktor.io).
Here are some useful links to get you started:
- [Ktor Documentation](https://ktor.io/docs/home.html)
- [Ktor GitHub page](https://github.com/ktorio/ktor)
- The [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). You'll need
to [request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up) to join.
## Features
Here's a list of features included in this project:
| Name | Description |
|------------------------------------------------------------------------|------------------------------------------------------------------------------------|
| [Routing](https://start.ktor.io/p/routing) | Provides a structured routing DSL |
| [Authentication](https://start.ktor.io/p/auth) | Provides extension point for handling the Authorization header |
| [Authentication Basic](https://start.ktor.io/p/auth-basic) | Handles 'Basic' username / password authentication scheme |
| [Call Logging](https://start.ktor.io/p/call-logging) | Logs client requests |
| [Content Negotiation](https://start.ktor.io/p/content-negotiation) | Provides automatic content conversion according to Content-Type and Accept headers |
| [kotlinx.serialization](https://start.ktor.io/p/kotlinx-serialization) | Handles JSON serialization using kotlinx.serialization library |
## Building & Running
To build or run the project, use one of the following tasks:
| Task | Description |
|-------------------------------|----------------------------------------------------------------------|
| `./gradlew test` | Run the tests |
| `./gradlew build` | Build everything |
| `buildFatJar` | Build an executable JAR of the server with all dependencies included |
| `buildImage` | Build the docker image to use with the fat JAR |
| `publishImageToLocalRegistry` | Publish the docker image locally |
| `run` | Run the server |
| `runDocker` | Run using the local docker image |
If the server starts successfully, you'll see the following output:
```
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
```

40
build.gradle.kts Normal file
View File

@@ -0,0 +1,40 @@
val kotlin_version: String by project
val logback_version: String by project
plugins {
kotlin("jvm") version "2.1.10"
id("io.ktor.plugin") version "3.2.2"
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
}
group = "com.nano"
version = "0.0.1"
application {
mainClass = "com.nano.ApplicationKt"
}
repositories {
maven("https://jitpack.io")
mavenCentral()
}
allprojects {
repositories {
}
}
dependencies {
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-call-logging")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-netty")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("com.github.bitfireAT:dav4jvm:2.2.1")
testImplementation("io.ktor:ktor-server-test-host")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}

View File

@@ -0,0 +1,17 @@
package com.nano
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureSecurity()
configureMonitoring()
configureSerialization()
configureRouting()
}

View File

@@ -0,0 +1,18 @@
package com.nano
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.slf4j.event.*
fun Application.configureMonitoring() {
install(CallLogging) {
level = Level.INFO
filter { call -> call.request.path().startsWith("/") }
}
}

347
src/main/kotlin/Routing.kt Normal file
View File

@@ -0,0 +1,347 @@
package com.nano
import com.nano.Service.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import org.slf4j.event.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Serializable
data class CreateEventRequest(
val summary: String,
val description: String? = null,
val startDateTime: String, // ISO format: "2025-07-20T10:00:00"
val endDateTime: String,
val location: String? = null
)
@Serializable
data class EventResponse(
val uid: String,
val summary: String,
val description: String? = null,
val startDateTime: String,
val endDateTime: String,
val location: String? = null
)
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val message: String? = null
)
fun Application.configureRouting() {
routing {
authenticate("calendar-auth") {
route("/calendar") {
route("/events") {
// Получение всех событий
get {
try {
val principal = call.principal<UserIdPrincipal>()!!
val username = principal.name
// Получаем пароль из заголовка Authorization для создания DAV подключения
val authHeader = call.request.headers["Authorization"]
val password = authHeader?.let { header ->
val encoded = header.substringAfter("Basic ")
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
decoded.substringAfter(":")
} ?: ""
// Получаем календарь ID из заголовка CAL_ID
val calendarId = call.request.headers["CAL_ID"] ?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
)
val dav = Dav(username, password, calendarId)
val eventManager = dav.getEventManager()
eventManager.getAllEvents().fold(
onSuccess = { events ->
val eventResponses = events.map { event ->
EventResponse(
uid = event.uid,
summary = event.summary,
description = event.description,
startDateTime = event.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
endDateTime = event.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
location = event.location
)
}
call.respond(ApiResponse(success = true, data = eventResponses))
},
onFailure = { error ->
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<String>(success = false, message = error.message)
)
}
)
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<String>(success = false, message = "Неожиданная ошибка: ${e.message}")
)
}
}
// Создание нового события
post {
try {
val principal = call.principal<UserIdPrincipal>()!!
val username = principal.name
val authHeader = call.request.headers["Authorization"]
val password = authHeader?.let { header ->
val encoded = header.substringAfter("Basic ")
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
decoded.substringAfter(":")
} ?: ""
// Получаем календарь ID из заголовка CAL_ID
val calendarId = call.request.headers["CAL_ID"] ?: return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
)
val request = call.receive<CreateEventRequest>()
val event = CalendarEvent(
summary = request.summary,
description = request.description,
startDateTime = LocalDateTime.parse(request.startDateTime),
endDateTime = LocalDateTime.parse(request.endDateTime),
location = request.location
)
val dav = Dav(username, password, calendarId)
val eventManager = dav.getEventManager()
eventManager.addEvent(event).fold(
onSuccess = { createdEvent ->
val response = EventResponse(
uid = createdEvent.uid,
summary = createdEvent.summary,
description = createdEvent.description,
startDateTime = createdEvent.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
endDateTime = createdEvent.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
location = createdEvent.location
)
call.respond(HttpStatusCode.Created, ApiResponse(success = true, data = response))
},
onFailure = { error ->
val statusCode = when {
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
error.message?.contains("не найден") == true -> HttpStatusCode.NotFound
error.message?.contains("Конфликт") == true -> HttpStatusCode.Conflict
else -> HttpStatusCode.BadRequest
}
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
}
)
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<String>(success = false, message = "Ошибка создания события: ${e.message}")
)
}
}
// Получение события по UID
get("/{uid}") {
try {
val principal = call.principal<UserIdPrincipal>()!!
val username = principal.name
val uid = call.parameters["uid"] ?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "UID события не указан")
)
val authHeader = call.request.headers["Authorization"]
val password = authHeader?.let { header ->
val encoded = header.substringAfter("Basic ")
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
decoded.substringAfter(":")
} ?: ""
// Получаем календарь ID из заголовка CAL_ID
val calendarId = call.request.headers["CAL_ID"] ?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
)
val dav = Dav(username, password, calendarId)
val eventManager = dav.getEventManager()
eventManager.getEvent(uid).fold(
onSuccess = { event ->
if (event != null) {
val response = EventResponse(
uid = event.uid,
summary = event.summary,
description = event.description,
startDateTime = event.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
endDateTime = event.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
location = event.location
)
call.respond(ApiResponse(success = true, data = response))
} else {
call.respond(
HttpStatusCode.NotFound,
ApiResponse<String>(success = false, message = "Событие не найдено")
)
}
},
onFailure = { error ->
val statusCode = when {
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
error.message?.contains("не найдено") == true -> HttpStatusCode.NotFound
else -> HttpStatusCode.InternalServerError
}
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
}
)
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<String>(success = false, message = "Ошибка получения события: ${e.message}")
)
}
}
// Обновление события
put("/{uid}") {
try {
val principal = call.principal<UserIdPrincipal>()!!
val username = principal.name
val uid = call.parameters["uid"] ?: return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "UID события не указан")
)
val authHeader = call.request.headers["Authorization"]
val password = authHeader?.let { header ->
val encoded = header.substringAfter("Basic ")
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
decoded.substringAfter(":")
} ?: ""
// Получаем календарь ID из заголовка CAL_ID
val calendarId = call.request.headers["CAL_ID"] ?: return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
)
val request = call.receive<CreateEventRequest>()
val event = CalendarEvent(
uid = uid,
summary = request.summary,
description = request.description,
startDateTime = LocalDateTime.parse(request.startDateTime),
endDateTime = LocalDateTime.parse(request.endDateTime),
location = request.location
)
val dav = Dav(username, password, calendarId)
val eventManager = dav.getEventManager()
eventManager.updateEvent(event).fold(
onSuccess = { updatedEvent ->
val response = EventResponse(
uid = updatedEvent.uid,
summary = updatedEvent.summary,
description = updatedEvent.description,
startDateTime = updatedEvent.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
endDateTime = updatedEvent.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
location = updatedEvent.location
)
call.respond(ApiResponse(success = true, data = response))
},
onFailure = { error ->
val statusCode = when {
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
error.message?.contains("не найдено") == true -> HttpStatusCode.NotFound
error.message?.contains("Конфликт версий") == true -> HttpStatusCode.PreconditionFailed
else -> HttpStatusCode.BadRequest
}
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
}
)
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<String>(success = false, message = "Ошибка обновления события: ${e.message}")
)
}
}
// Удаление события
delete("/{uid}") {
try {
val principal = call.principal<UserIdPrincipal>()!!
val username = principal.name
val uid = call.parameters["uid"] ?: return@delete call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "UID события не указан")
)
val authHeader = call.request.headers["Authorization"]
val password = authHeader?.let { header ->
val encoded = header.substringAfter("Basic ")
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
decoded.substringAfter(":")
} ?: ""
// Получаем календарь ID из заголовка CAL_ID
val calendarId = call.request.headers["CAL_ID"] ?: return@delete call.respond(
HttpStatusCode.BadRequest,
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
)
val dav = Dav(username, password, calendarId)
val eventManager = dav.getEventManager()
eventManager.deleteEvent(uid).fold(
onSuccess = {
call.respond(ApiResponse<String>(success = true, message = "Событие успешно удалено"))
},
onFailure = { error ->
val statusCode = when {
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
error.message?.contains("не найдено") == true -> HttpStatusCode.NotFound
else -> HttpStatusCode.BadRequest
}
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
}
)
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<String>(success = false, message = "Ошибка удаления события: ${e.message}")
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,31 @@
package com.nano
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.slf4j.event.*
data class CalendarCredentials(
val username: String,
val password: String
)
fun Application.configureSecurity() {
authentication {
basic(name = "calendar-auth") {
realm = "Calendar Access"
validate { credentials ->
if (credentials.name.isNotEmpty() && credentials.password.isNotEmpty()) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
package com.nano
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.slf4j.event.*
fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
routing {
get("/json/kotlinx-serialization") {
call.respond(mapOf("hello" to "world"))
}
}
}

View File

@@ -0,0 +1,368 @@
package com.nano.Service
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.CalendarData
import io.ktor.http.Url
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory.getLogger
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.math.log
enum class DAVHOST(val url: String) {
BAIKAL("https://baikal.illegalfiles.icu/dav.php"),
}
data class CalendarEvent(
val uid: String = UUID.randomUUID().toString(),
val summary: String,
val description: String? = null,
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val location: String? = null
)
class Dav(
val username: String,
val password: String,
val calendarName: String,
server: DAVHOST = DAVHOST.BAIKAL,
) {
val baseUrl = Url(server.url)
val authHandler = BasicDigestAuthHandler(
domain = null, // Убираем ограничение по домену
username = username,
password = password,
)
val client = OkHttpClient.Builder()
.followRedirects(false)
.authenticator(authHandler)
.addNetworkInterceptor(authHandler)
.build()
val calDav = DavCalendar(
client,
location = baseUrl.toString().toHttpUrlOrNull()?.newBuilder()
?.addPathSegment("calendars")
?.build() ?: throw IllegalArgumentException("Invalid URL: $baseUrl"),
)
/**
* Получает менеджер для работы с событиями календаря
*/
fun getEventManager(): CalendarEventManager {
return CalendarEventManager(this)
}
}
/**
* Менеджер для работы с событиями календаря.
* Реализует основные CRUD операции с использованием библиотеки dav4jvm
*/
class CalendarEventManager(private val dav: Dav) {
private val logger = getLogger("Dav")
private fun createICalendarEvent(event: CalendarEvent): String {
val dtFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")
val now = LocalDateTime.now().format(dtFormatter)
return """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//WebDAV Service//CalDAV Client//EN
BEGIN:VEVENT
UID:${event.uid}
DTSTAMP:${now}Z
DTSTART:${event.startDateTime.format(dtFormatter)}Z
DTEND:${event.endDateTime.format(dtFormatter)}Z
SUMMARY:${event.summary}
${if (!event.description.isNullOrEmpty()) "DESCRIPTION:${event.description}" else ""}
${if (!event.location.isNullOrEmpty()) "LOCATION:${event.location}" else ""}
END:VEVENT
END:VCALENDAR""".trimIndent()
}
/**
* Добавляет новое событие в календарь
*/
fun addEvent(event: CalendarEvent): Result<CalendarEvent> {
return try {
val iCalData = createICalendarEvent(event)
val eventUrl = dav.calDav.location.newBuilder()
.addPathSegment(dav.username)
.addPathSegment(dav.calendarName)
.addPathSegment("${event.uid}.ics")
.build()
val davResource = DavResource(dav.client, eventUrl)
val requestBody = iCalData.toRequestBody("text/calendar; charset=utf-8".toMediaType())
var responseCode: Int? = null
var responseMessage: String? = null
davResource.put(requestBody) { response ->
responseCode = response.code
responseMessage = response.message
}
when {
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
responseCode in 200..299 -> Result.success(event)
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для записи в календарь '${dav.calendarName}'"))
responseCode == 404 -> Result.failure(Exception("Календарь '${dav.calendarName}' не найден"))
responseCode == 409 -> Result.failure(Exception("Конфликт: событие с UID '${event.uid}' уже существует"))
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
}
} catch (e: Exception) {
Result.failure(Exception("Ошибка при добавлении события: ${e.message}", e))
}
}
/**
* Получает событие по UID
*/
fun getEvent(uid: String): Result<CalendarEvent?> {
return try {
val eventUrl = dav.calDav.location.newBuilder()
.addPathSegment(dav.username)
.addPathSegment(dav.calendarName)
.addPathSegment("$uid.ics")
.build()
val davResource = DavResource(dav.client, eventUrl)
var eventData: String? = null
var responseCode: Int? = null
var responseMessage: String? = null
davResource.get("text/calendar", null) { response ->
responseCode = response.code
responseMessage = response.message
if (response.isSuccessful) {
eventData = response.body?.string()
}
}
when {
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
responseCode == 200 -> {
val event = eventData?.let { parseICalendarEvent(it, uid) }
Result.success(event)
}
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для чтения календаря '${dav.calendarName}'"))
responseCode == 404 -> Result.failure(Exception("Событие с UID '$uid' не найдено в календаре '${dav.calendarName}'"))
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
}
} catch (e: Exception) {
Result.failure(Exception("Ошибка при получении события: ${e.message}", e))
}
}
/**
* Обновляет существующее событие
*/
fun updateEvent(event: CalendarEvent): Result<CalendarEvent> {
return try {
val iCalData = createICalendarEvent(event)
val eventUrl = dav.calDav.location.newBuilder()
.addPathSegment(dav.username)
.addPathSegment(dav.calendarName)
.addPathSegment("${event.uid}.ics")
.build()
val davResource = DavResource(dav.client, eventUrl)
val requestBody = iCalData.toRequestBody("text/calendar; charset=utf-8".toMediaType())
var responseCode: Int? = null
var responseMessage: String? = null
davResource.put(requestBody) { response ->
responseCode = response.code
responseMessage = response.message
}
when {
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
responseCode in 200..299 -> Result.success(event)
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для изменения календаря '${dav.calendarName}'"))
responseCode == 404 -> Result.failure(Exception("Событие с UID '${event.uid}' не найдено в календаре '${dav.calendarName}'"))
responseCode == 412 -> Result.failure(Exception("Конфликт версий: событие было изменено другим пользователем"))
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
}
} catch (e: Exception) {
Result.failure(Exception("Ошибка при обновлении события: ${e.message}", e))
}
}
/**
* Удаляет событие по UID
*/
fun deleteEvent(uid: String): Result<Unit> {
return try {
val eventUrl = dav.calDav.location.newBuilder()
.addPathSegment(dav.username)
.addPathSegment(dav.calendarName)
.addPathSegment("$uid.ics")
.build()
val davResource = DavResource(dav.client, eventUrl)
var responseCode: Int? = null
var responseMessage: String? = null
davResource.delete { response ->
responseCode = response.code
responseMessage = response.message
}
when {
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
responseCode in 200..299 -> Result.success(Unit)
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для удаления из календаря '${dav.calendarName}'"))
responseCode == 404 -> Result.failure(Exception("Событие с UID '$uid' не найдено в календаре '${dav.calendarName}'"))
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
}
} catch (e: Exception) {
Result.failure(Exception("Ошибка при удалении события: ${e.message}", e))
}
}
/**
* Получает список всех событий из календаря
*/
fun getAllEvents(): Result<List<CalendarEvent>> {
return try {
val calendarUrl = dav.calDav.location.newBuilder()
.addPathSegment(dav.username)
.addPathSegment(dav.calendarName)
.build()
val calendar = DavCalendar(dav.client, calendarUrl)
val events = mutableListOf<CalendarEvent>()
var hasError = false
var errorMessage = ""
// Простое получение всех ресурсов календаря
calendar.propfind(1, CalendarData.NAME) { response, relation ->
try {
if (relation == at.bitfire.dav4jvm.Response.HrefRelation.MEMBER) {
response[CalendarData::class.java]?.let { calData ->
val iCalContent = calData.iCalendar ?: return@propfind
val uid = extractUidFromICal(iCalContent)
uid?.let {
parseICalendarEvent(iCalContent, it)?.let { event ->
events.add(event)
}
}
}
}
} catch (e: Exception) {
hasError = true
errorMessage = "Ошибка при обработке события: ${e.message}"
}
}
when {
hasError -> Result.failure(Exception(errorMessage))
events.isEmpty() -> Result.success(emptyList())
else -> Result.success(events)
}
} catch (e: Exception) {
when {
e.message?.contains("401") == true -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
e.message?.contains("403") == true -> Result.failure(Exception("Доступ запрещен: недостаточно прав для чтения календаря '${dav.calendarName}'"))
e.message?.contains("404") == true -> Result.failure(Exception("Календарь '${dav.calendarName}' не найден"))
else -> Result.failure(Exception("Ошибка при получении списка событий: ${e.message}", e))
}
}
}
/**
* Получает события за определенный период
*/
fun getEventsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): List<CalendarEvent> {
return getAllEvents().getOrDefault(emptyList()).filter { event ->
event.startDateTime.isAfter(startDate.minusDays(1)) &&
event.endDateTime.isBefore(endDate.plusDays(1))
}
}
private fun parseICalendarEvent(iCalData: String, uid: String): CalendarEvent? {
return try {
val lines = iCalData.lines()
var summary = ""
var description: String? = null
var location: String? = null
var startDateTime: LocalDateTime? = null
var endDateTime: LocalDateTime? = null
val dtFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
val dtFormatterLocal = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")
for (line in lines) {
when {
line.startsWith("SUMMARY:") -> summary = line.substringAfter("SUMMARY:")
line.startsWith("DESCRIPTION:") -> description = line.substringAfter("DESCRIPTION:")
line.startsWith("LOCATION:") -> location = line.substringAfter("LOCATION:")
line.startsWith("DTSTART:") -> {
val dtStart = line.substringAfter("DTSTART:")
startDateTime = try {
if (dtStart.endsWith("Z")) {
LocalDateTime.parse(dtStart, dtFormatter)
} else {
LocalDateTime.parse(dtStart, dtFormatterLocal)
}
} catch (_: Exception) {
null
}
}
line.startsWith("DTEND:") -> {
val dtEnd = line.substringAfter("DTEND:")
endDateTime = try {
if (dtEnd.endsWith("Z")) {
LocalDateTime.parse(dtEnd, dtFormatter)
} else {
LocalDateTime.parse(dtEnd, dtFormatterLocal)
}
} catch (_: Exception) {
null
}
}
}
}
if (startDateTime != null && endDateTime != null) {
CalendarEvent(
uid = uid,
summary = summary,
description = description,
startDateTime = startDateTime,
endDateTime = endDateTime,
location = location
)
} else null
} catch (_: Exception) {
logger.info("Ошибка при парсинге события")
null
}
}
private fun extractUidFromICal(iCalData: String): String? {
return iCalData.lines().find { it.startsWith("UID:") }?.substringAfter("UID:")
}
}

View File

@@ -0,0 +1,21 @@
package com.nano
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.Test
import kotlin.test.assertEquals
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
client.get("/").apply {
assertEquals(HttpStatusCode.OK, status)
}
}
}