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