feat: [#18] parser et générateur VTODO (iCalendar RFC 5545, RRULE, CATEGORIES, RELATED-TO)

This commit is contained in:
2026-06-06 06:28:38 +02:00
parent 254efff4b3
commit ab1e59b237
2 changed files with 210 additions and 0 deletions
@@ -0,0 +1,82 @@
package com.planify.mobile.data.caldav
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.util.RRuleBuilder
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object VTodoGenerator {
private val dtFormat = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
private val dateFormat = DateTimeFormatter.ofPattern("yyyyMMdd")
fun generate(task: Task): String {
val now = LocalDateTime.now().format(dtFormat)
val uid = task.calendarEventUid ?: task.id
return buildString {
appendLine("BEGIN:VCALENDAR")
appendLine("VERSION:2.0")
appendLine("PRODID:-//Planify Mobile//Android//EN")
appendLine("BEGIN:VTODO")
appendLine("UID:$uid")
appendLine("DTSTAMP:$now")
appendLine("CREATED:${formatDateTime(task.addedAt, now)}")
appendLine("LAST-MODIFIED:${formatDateTime(task.updatedAt, now)}")
appendLine("SUMMARY:${task.content.encodeIcal()}")
if (task.description.isNotBlank()) {
appendLine("DESCRIPTION:${task.description.encodeIcal()}")
}
appendLine("STATUS:${if (task.checked) "COMPLETED" else "NEEDS-ACTION"}")
if (task.checked && task.completedAt != null) {
appendLine("COMPLETED:${formatDateCompact(task.completedAt)}")
}
val icalPriority = appPriorityToIcal(task.priority)
if (icalPriority > 0) appendLine("PRIORITY:$icalPriority")
task.dueDate?.date?.let { due ->
appendLine("DUE;VALUE=DATE:${due.replace("-", "")}")
}
task.dueDate?.let { dd ->
RRuleBuilder.build(dd)?.let { appendLine(it) }
}
if (task.labels.isNotEmpty()) {
appendLine("CATEGORIES:${task.labels.joinToString(",") { it.encodeIcal() }}")
}
task.parentId?.let { appendLine("RELATED-TO;RELTYPE=PARENT:$it") }
appendLine("END:VTODO")
append("END:VCALENDAR")
}
}
private fun String.encodeIcal(): String =
replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
private fun formatDateTime(iso: String?, fallback: String): String {
if (iso.isNullOrBlank()) return fallback
return runCatching {
val ldt = if (iso.contains("T")) LocalDateTime.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
else LocalDateTime.parse("${iso}T00:00:00", DateTimeFormatter.ISO_LOCAL_DATE_TIME)
ldt.format(dtFormat)
}.getOrDefault(fallback)
}
private fun formatDateCompact(iso: String): String = runCatching {
"${iso.replace("-", "").take(8)}T000000Z"
}.getOrDefault(iso)
private fun appPriorityToIcal(p: Int): Int = when (p) {
1 -> 1
2 -> 5
3 -> 9
else -> 0
}
}
@@ -0,0 +1,128 @@
package com.planify.mobile.data.caldav
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.util.RRuleBuilder
object VTodoParser {
fun parse(ical: String, projectId: String, icalUrl: String? = null): Task? {
val lines = unfold(ical)
val vtodo = extractComponent(lines, "VTODO") ?: return null
val props = parseProperties(vtodo)
val uid = props["UID"] ?: return null
val summary = props["SUMMARY"]?.decodeIcal() ?: return null
val description = props["DESCRIPTION"]?.decodeIcal() ?: ""
val status = props["STATUS"]
val checked = status == "COMPLETED"
val priority = props["PRIORITY"]?.toIntOrNull()?.let { icalPriorityToApp(it) } ?: 4
val due = props["DUE"]?.let { parseDate(it) }
val dtstart = props["DTSTART"]?.let { parseDate(it) }
val rrule = props["RRULE"]
val dueDate: DueDate? = if (due != null) {
val recDueDate = rrule?.let { RRuleBuilder.parse(it) }
DueDate(
date = due,
isRecurring = recDueDate != null,
recurrencyType = recDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE,
recurrencyInterval = recDueDate?.recurrencyInterval ?: 1,
recurrencyWeekDays = recDueDate?.recurrencyWeekDays ?: emptyList(),
recurrencyCount = recDueDate?.recurrencyCount,
recurrencyEnd = recDueDate?.recurrencyEnd,
)
} else null
val categories = props["CATEGORIES"]?.split(",")?.map { it.trim() } ?: emptyList()
val parentRelated = props.entries
.firstOrNull { it.key.startsWith("RELATED-TO") && it.value.isNotBlank() }
?.value
val completedAt = props["COMPLETED"]?.let { parseDate(it) }
val createdAt = props["CREATED"]?.let { parseDateTime(it) } ?: ""
val lastModified = props["LAST-MODIFIED"]?.let { parseDateTime(it) } ?: ""
return Task(
id = uid,
content = summary,
description = description,
projectId = projectId,
parentId = parentRelated,
priority = priority,
checked = checked,
dueDate = dueDate,
labels = categories,
addedAt = createdAt,
updatedAt = lastModified,
completedAt = completedAt,
calendarEventUid = uid,
icalUrl = icalUrl,
)
}
private fun unfold(ical: String): List<String> {
val result = mutableListOf<String>()
val sb = StringBuilder()
for (line in ical.lines()) {
if (line.startsWith(" ") || line.startsWith("\t")) {
sb.append(line.substring(1))
} else {
if (sb.isNotEmpty()) result.add(sb.toString())
sb.clear()
sb.append(line)
}
}
if (sb.isNotEmpty()) result.add(sb.toString())
return result
}
private fun extractComponent(lines: List<String>, name: String): List<String>? {
val start = lines.indexOfFirst { it == "BEGIN:$name" }
val end = lines.indexOfFirst { it == "END:$name" }
if (start < 0 || end < 0) return null
return lines.subList(start + 1, end)
}
private fun parseProperties(lines: List<String>): Map<String, String> {
val result = mutableMapOf<String, String>()
for (line in lines) {
val colonIdx = line.indexOf(':')
if (colonIdx < 0) continue
val rawKey = line.substring(0, colonIdx).uppercase()
val value = line.substring(colonIdx + 1)
// Key may have parameters like DUE;TZID=UTC — use base key
val key = rawKey.substringBefore(';')
result[key] = value
}
return result
}
private fun String.decodeIcal(): String =
replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
private fun parseDate(value: String): String? {
val v = value.substringAfterLast(':') // strip TZID param if any
return when {
v.length >= 8 -> "${v.take(4)}-${v.substring(4, 6)}-${v.substring(6, 8)}"
else -> null
}
}
private fun parseDateTime(value: String): String? {
val v = value.substringAfterLast(':')
if (v.length < 15) return null
return "${v.take(4)}-${v.substring(4, 6)}-${v.substring(6, 8)}T${v.substring(9, 11)}:${v.substring(11, 13)}:${v.substring(13, 15)}"
}
private fun icalPriorityToApp(p: Int): Int = when {
p == 1 -> 1
p in 2..4 -> 2
p in 5..6 -> 3
else -> 4
}
}