diff --git a/app/src/main/java/com/planify/mobile/data/caldav/VTodoGenerator.kt b/app/src/main/java/com/planify/mobile/data/caldav/VTodoGenerator.kt new file mode 100644 index 0000000..8c9bcc6 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/VTodoGenerator.kt @@ -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 + } +} diff --git a/app/src/main/java/com/planify/mobile/data/caldav/VTodoParser.kt b/app/src/main/java/com/planify/mobile/data/caldav/VTodoParser.kt new file mode 100644 index 0000000..5d8d6f8 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/caldav/VTodoParser.kt @@ -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 { + val result = mutableListOf() + 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, name: String): List? { + 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): Map { + val result = mutableMapOf() + 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 + } +}