feat: [#18] parser et générateur VTODO (iCalendar RFC 5545, RRULE, CATEGORIES, RELATED-TO)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user