feat(export): write task lists out as iCalendar

Step 3 of docs/STORAGE-AND-SYNC.md. Now that our own provider holds the data in
the app's private storage, a Local-mode user's tasks exist in exactly one place
and uninstalling deletes them — on Play, where most people will never have a sync
engine, that is the majority case. So export is a v1 feature, not a nicety.

One .ics per list, because a list is a CalDAV collection and that is the unit
other clients understand; folding everything into one file would flatten the
lists away, and list membership is not recoverable from a VTODO afterwards.
ExportWriter can put them in a folder (ACTION_OPEN_DOCUMENT_TREE) or a single zip
(ACTION_CREATE_DOCUMENT). No storage permission either way — SAF hands us a Uri
the user picked.

Two things needed care:

Export reads the tasks table, not the instances view the rest of the app reads
from. In the instances view a recurring task appears once per occurrence with its
times resolved and no rule attached, so exporting from there would write the same
task fifty times and lose the RRULE that generated them.

And local tasks have no UID. The dmfs provider only lets a sync adapter assign
one, so in Local mode every task arrives with _uid null — and a VTODO without a
UID is both invalid and un-mergeable, meaning a re-imported backup would
duplicate every task rather than match it. ICalendarWriter synthesises one from
the row id, stable across exports and tagged so it is recognisable as synthetic.

Times go out in UTC rather than with a TZID. Emitting TZID obliges us to emit a
matching VTIMEZONE with its transition rules, and a TZID referencing an absent
definition is what actually breaks importers. All-day values keep VALUE=DATE, the
only form that survives a timezone change intact.

The writer is pure Kotlin with no Android in it and is covered by 40 tests —
line folding counted in octets and never splitting a UTF-8 sequence, TEXT
escaping, forward references from a subtask to a parent later in the file, and
CRLF endings. An export is only as good as its ability to be read back, and
nothing about a malformed .ics is obvious until someone needs the backup.

The SAF plumbing is marked in the storage doc as floret-kit material. Kept
app-local for now on the kit's own stated principle of not extracting before a
second consumer exists; the seam is in place, so moving it is a file move.

Backend only — no UI yet; that comes with the frontend pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 21:27:36 +02:00
parent f978c3727c
commit c5041d3f29
11 changed files with 889 additions and 0 deletions

View File

@@ -163,6 +163,7 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)

View File

@@ -0,0 +1,109 @@
package de.jeanlucmakiola.agendula.data.export
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import javax.inject.Singleton
/** Where an export ended up, for the UI to report. */
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
/** The export could not be written. Carries a cause worth showing a user. */
class ExportFailedException(message: String, cause: Throwable? = null) : IOException(message, cause)
/**
* Writes [ExportDocument]s to a user-chosen location through the Storage Access
* Framework.
*
* No storage permission anywhere: SAF hands us a `Uri` the user picked
* themselves, which is both the modern approach and the only one that still works
* on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for
* [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the
* result here.
*
* Marked in `docs/STORAGE-AND-SYNC.md` as floret-kit material — the plumbing is
* not task-domain and Calendula will want the same thing. Kept app-local for now
* on the kit's own stated principle of not extracting until a second consumer
* actually exists; the seam is here, so moving it later is a file move.
*/
@Singleton
class ExportWriter @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Writes every document into [treeUri], a directory the user picked.
*
* Overwrites same-named files rather than letting SAF append " (1)" — an
* export is a snapshot, and silently accumulating `Groceries-3 (4).ics` makes
* the folder useless as a backup.
*/
suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: throw ExportFailedException("Cannot open the chosen folder")
if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable")
documents.forEach { document ->
tree.findFile(document.fileName)?.delete()
val file = tree.createFile(MIME_ICALENDAR, document.fileName)
?: throw ExportFailedException("Cannot create ${document.fileName}")
write(file.uri, document.content)
}
ExportResult(documents.size, documents.map { it.fileName })
}
/**
* Writes every document into a single zip at [target].
*
* The one-file form, for sharing or for a backup the user filed somewhere
* themselves — one attachment rather than one per list.
*/
suspend fun writeZip(target: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) {
runCatching {
context.contentResolver.openOutputStream(target, "wt")?.use { raw ->
ZipOutputStream(raw.buffered()).use { zip ->
documents.forEach { document ->
zip.putNextEntry(ZipEntry(document.fileName))
zip.write(document.content)
zip.closeEntry()
}
}
} ?: throw ExportFailedException("Cannot write to the chosen file")
}.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName })
}
private fun write(target: Uri, bytes: ByteArray) {
runCatching {
// "wt" truncates. Without it a shorter export leaves the tail of the
// previous, longer one behind and produces a corrupt file.
context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
?: throw ExportFailedException("Cannot write to the chosen file")
}.getOrElse { throw asExportFailure(it) }
}
private fun asExportFailure(cause: Throwable): Throwable = when (cause) {
is ExportFailedException -> cause
// A SAF grant can be revoked between the picker and the write (the volume
// was unmounted, the provider's process died, the user cleared the grant).
is SecurityException -> ExportFailedException("Lost access to the chosen location", cause)
is IOException -> ExportFailedException(cause.message ?: "Could not write the export", cause)
else -> cause
}
private companion object {
const val MIME_ICALENDAR = "text/calendar"
}
}

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.agendula.data.export
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import de.jeanlucmakiola.agendula.domain.export.ExportList
import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Turns the user's task lists into `.ics` documents.
*
* Export is a v1 feature rather than a nicety because of where the data now
* lives: our own provider is inside the app's private storage, so in Local mode a
* user's tasks exist in exactly one place and uninstalling deletes them. On Play,
* where most people will never have a sync engine, that is the majority case.
*
* **One document per list**, because a list is a CalDAV collection and that is the
* unit every other client understands. Bundling everything into a single file
* would flatten the lists away, and list membership is not recoverable from a
* VTODO afterwards.
*/
@Singleton
class TaskExporter @Inject constructor(
private val dataSource: TasksDataSource,
@IoDispatcher private val io: CoroutineDispatcher,
) {
/**
* Serialises [listIds] — every visible list when null.
*
* A list with no tasks still produces a document. An empty `.ics` is a real
* answer ("this list is empty"), whereas a missing file is indistinguishable
* from the export having gone wrong.
*/
suspend fun export(listIds: Set<Long>? = null): List<ExportDocument> = withContext(io) {
dataSource.taskLists()
.filter { listIds == null || it.id in listIds }
.map { list ->
val document = ExportList(
listId = list.id,
name = list.name,
accountName = list.accountName,
tasks = dataSource.exportTasks(list.id),
)
ExportDocument(
fileName = fileNameFor(list.name, list.id),
content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8),
)
}
}
companion object {
/**
* A file name derived from the list name, safe on every filesystem the
* user might pick through SAF (including FAT32 on an SD card).
*
* The list id is appended rather than trusted to be redundant: two lists on
* different accounts may share a name, and two exports landing on the same
* file would silently lose one of them.
*/
fun fileNameFor(listName: String, listId: Long): String {
val safe = listName
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
.joinToString("")
.trim('-')
.take(60)
.ifBlank { "list" }
return "$safe-$listId.ics"
}
}
}

View File

@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
@@ -84,6 +85,26 @@ class AndroidTasksDataSource @Inject constructor(
} ?: emptyList()
}
override fun exportTasks(listId: Long): List<ExportTask> {
// projection = null for the same reason queryInstances uses it: the tasks
// table's shape varies across provider versions, and the by-name mapper
// reads what's there.
val uri = TasksContract.tasksUri(authority())
return resolver.query(
uri,
null,
// _deleted marks a row awaiting a sync round-trip. It's gone as far as
// the user is concerned, so exporting it would resurrect deleted tasks
// in the backup.
"${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(listId.toString()),
null,
)?.use { c ->
val reader = CursorColumnReader(c)
buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) }
} ?: emptyList()
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm): Long {

View File

@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant
@@ -52,6 +53,42 @@ object TaskMapper {
)
}
/**
* Maps a row of the **`tasks` table** — a master task, not an occurrence.
*
* Export reads there rather than from `instances` on purpose: in the instances
* view a recurring task appears once per occurrence with its times already
* resolved and no rule attached, so exporting from it would write the same
* task many times over and drop the RRULE that produced them. Here each task
* appears exactly once, carrying the rule itself.
*/
fun exportTask(r: ColumnReader): ExportTask {
fun instant(name: String): Instant? =
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
return ExportTask(
taskId = r.getLong(Tasks.ID) ?: 0L,
uid = r.getString(Tasks.UID),
title = r.getString(Tasks.TITLE).orEmpty(),
description = r.getString(Tasks.DESCRIPTION),
location = r.getString(Tasks.LOCATION),
url = r.getString(Tasks.URL),
priority = priorityFromICal(r.getInt(Tasks.PRIORITY)),
status = statusFromInt(r.getInt(Tasks.STATUS)),
percentComplete = r.getInt(Tasks.PERCENT_COMPLETE),
// The task's own columns, not the instance view's resolved ones.
start = instant(Tasks.DTSTART),
due = instant(Tasks.DUE),
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
completedAt = instant(Tasks.COMPLETED),
created = instant(Tasks.CREATED),
lastModified = instant(Tasks.LAST_MODIFIED),
rrule = r.getString(Tasks.RRULE),
rdate = r.getString(Tasks.RDATE),
parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 },
)
}
fun taskList(r: ColumnReader): TaskList = TaskList(
id = r.getLong(Lists.ID) ?: 0L,
name = r.getString(Lists.NAME).orEmpty(),

View File

@@ -42,6 +42,13 @@ interface TasksDataSource {
/** Every task's reminder lead, by task id. One query, for the scheduler. */
fun alarms(): Map<Long, Int>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
* not occurrences — see [TaskMapper.exportTask] for why that distinction
* matters. Excludes rows the provider has flagged deleted-but-unsynced.
*/
fun exportTasks(listId: Long): List<de.jeanlucmakiola.agendula.domain.export.ExportTask>
fun setCompleted(taskId: Long, completed: Boolean)
fun deleteTask(taskId: Long)
fun createLocalList(name: String, color: Int): Long

View File

@@ -0,0 +1,66 @@
package de.jeanlucmakiola.agendula.domain.export
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* One task as it goes out to iCalendar — a **master** task, not an occurrence.
*
* Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read
* from the `instances` view, where a recurring task appears once per occurrence
* with resolved times and no rule; exporting from it would write the same task
* fifty times and lose the RRULE that generated them. Export reads the `tasks`
* table instead, and needs two fields the UI never asks for ([uid], [rrule]).
*/
data class ExportTask(
/** `tasks._id` — the fallback identity when [uid] is absent. */
val taskId: Long,
/**
* The iCalendar UID, or `null` for a task created on this device and never
* synced. The dmfs provider only lets a *sync adapter* assign one, so in Local
* mode this is null for everything — see [ICalendarWriter.uidFor], which
* synthesises a stable substitute rather than emitting a VTODO with no UID.
*/
val uid: String?,
val title: String,
val description: String?,
val location: String?,
val url: String?,
val priority: Priority,
val status: TaskStatus,
val percentComplete: Int?,
val start: Instant?,
val due: Instant?,
val isAllDay: Boolean,
val completedAt: Instant?,
val created: Instant?,
val lastModified: Instant?,
/** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */
val rrule: String?,
/** Raw `RDATE` value as stored. Null when absent. */
val rdate: String?,
/** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */
val parentId: Long?,
)
/** A task list and everything in it, ready to become one `.ics` document. */
data class ExportList(
val listId: Long,
val name: String,
val accountName: String,
val tasks: List<ExportTask>,
)
/** A single file the export produced: [fileName] and its finished bytes. */
data class ExportDocument(
val fileName: String,
val content: ByteArray,
) {
// ByteArray gets identity equals/hashCode, which makes this data class lie.
override fun equals(other: Any?): Boolean =
this === other ||
(other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content))
override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode()
}

View File

@@ -0,0 +1,193 @@
package de.jeanlucmakiola.agendula.domain.export
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.calendarDate
import de.jeanlucmakiola.agendula.domain.toICal
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Instant
/**
* Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components.
*
* Pure Kotlin and deliberately free of any Android type, so the format — the part
* that decides whether an exported backup can actually be read again — is
* unit-testable on the JVM. Serialising tasks is task-domain and stays here; the
* SAF/file plumbing that carries the bytes out is not, and lives in the data
* layer (and is the piece `docs/STORAGE-AND-SYNC.md` marks as a floret-kit
* candidate).
*
* **Times are always written in UTC.** Emitting a local `TZID` would oblige us to
* also emit a matching `VTIMEZONE` component with its full transition rules, and
* a `TZID` referencing an absent definition is what actually breaks importers. UTC
* is unambiguous and universally accepted, so the exported instant is exact even
* though the original wall-clock zone is not carried. All-day values keep their
* `VALUE=DATE` form and stay date-only, which is the only representation that
* survives a timezone change intact.
*/
object ICalendarWriter {
private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN"
/** RFC 5545 caps a content line at 75 octets, excluding the CRLF. */
private const val MAX_LINE_OCTETS = 75
private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd")
private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
/** Serialises [list] to a complete `.ics` document. */
fun write(list: ExportList): String = buildString {
line("BEGIN:VCALENDAR")
line("VERSION:2.0")
line("PRODID:$PRODUCT_ID")
line("CALSCALE:GREGORIAN")
// Non-standard but near-universally understood, and the only way the list's
// name survives into a calendar app. Importers that don't know it skip it.
property("X-WR-CALNAME", list.name)
// Parents must be addressable by UID, and a subtask may appear before its
// parent in the list, so resolve every id up front.
val uidsById = list.tasks.associate { it.taskId to uidFor(it) }
list.tasks.forEach { task -> writeTask(task, uidsById) }
line("END:VCALENDAR")
}
/**
* The UID to write for [task].
*
* Local tasks have none: the dmfs provider only permits a sync adapter to
* assign `_uid`, so in Local mode every task arrives here with `uid == null`.
* A VTODO without a UID is invalid and, worse, un-mergeable — re-importing a
* backup would duplicate every task instead of matching it. So we synthesise
* one from the row id, which is stable for as long as the row is, and tag it
* with our own domain so a synthesised UID is recognisable as such.
*/
fun uidFor(task: ExportTask): String =
task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de"
private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map<Long, String>) {
line("BEGIN:VTODO")
property("UID", uidFor(task))
// DTSTAMP is mandatory. It means "when this representation was written",
// which for an export is now — not the task's own timestamps.
property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis())))
property("SUMMARY", task.title)
task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) }
task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) }
// URL is a URI, not TEXT: it must not be escaped like one.
task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) }
task.start?.let { dateProperty("DTSTART", it, task.isAllDay) }
task.due?.let { dateProperty("DUE", it, task.isAllDay) }
task.created?.let { rawProperty("CREATED", formatUtc(it)) }
task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) }
// COMPLETED is defined as UTC date-time even for an all-day task.
task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) }
rawProperty("STATUS", task.status.toICalName())
if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString())
task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) }
// Passed through as stored. The provider keeps these in iCalendar form
// already, and re-deriving them would risk changing what the user's
// recurrence actually means.
task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) }
task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) }
// Only emit the link when the parent is in this same document; a
// RELATED-TO pointing outside the file would dangle on import.
task.parentId?.let { uidsById[it] }?.let {
property("RELATED-TO;RELTYPE=PARENT", it)
}
line("END:VTODO")
}
private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) {
if (allDay) {
// Read in UTC, matching the storage convention (see AllDayTime): an
// all-day value *is* UTC midnight of the intended calendar date.
rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE))
} else {
rawProperty(name, formatUtc(instant))
}
}
private fun formatUtc(instant: Instant): String =
java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
.atZone(ZoneOffset.UTC)
.format(DATE_TIME_UTC)
/** A property whose value is TEXT, and so must be escaped. */
private fun StringBuilder.property(name: String, value: String) =
line("$name:${escapeText(value)}")
/** A property whose value is already in its final form (dates, numbers, URIs, rules). */
private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value")
private fun StringBuilder.line(content: String) {
append(fold(content))
append(CRLF)
}
/**
* Escapes a TEXT value per RFC 5545 §3.3.11. Backslash first, or it would
* double the backslashes introduced by the later replacements.
*/
internal fun escapeText(value: String): String = value
.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
.replace("\r", "\\n")
/**
* Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with
* CRLF + a single space.
*
* Counted in **octets, not characters** — the limit is defined that way, and an
* emoji in a task title is four of them. Splits are kept on character
* boundaries so folding can never cut a UTF-8 sequence in half and corrupt the
* text; an importer unfolds by removing CRLF + leading whitespace, recovering
* the original exactly.
*/
internal fun fold(content: String): String {
if (content.utf8Size() <= MAX_LINE_OCTETS) return content
val out = StringBuilder()
var octets = 0
// First line takes the full budget; every continuation loses one octet to
// the leading space.
var budget = MAX_LINE_OCTETS
var index = 0
while (index < content.length) {
val codePoint = content.codePointAt(index)
val charCount = Character.charCount(codePoint)
val size = String(Character.toChars(codePoint)).utf8Size()
if (octets + size > budget) {
out.append(CRLF).append(' ')
octets = 0
budget = MAX_LINE_OCTETS - 1
}
out.append(content, index, index + charCount)
octets += size
index += charCount
}
return out.toString()
}
private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size
private fun TaskStatus.toICalName(): String = when (this) {
TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION"
TaskStatus.IN_PROCESS -> "IN-PROCESS"
TaskStatus.COMPLETED -> "COMPLETED"
TaskStatus.CANCELLED -> "CANCELLED"
}
private const val CRLF = "\r\n"
}

View File

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.agendula.data.export
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The export file name. The user picks the folder, so whatever comes out of here
* is what they will be looking at in a file manager a year from now.
*/
class TaskExporterTest {
private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id)
@Test
fun `keeps a plain name readable`() {
assertThat(name("Groceries")).isEqualTo("Groceries-3.ics")
}
@Test
fun `replaces characters a filesystem would reject`() {
// SAF can land on FAT32 (an SD card), where these are simply illegal.
val result = name("Work / Home: notes?")
assertThat(result).doesNotContain("/")
assertThat(result).doesNotContain(":")
assertThat(result).doesNotContain("?")
assertThat(result).endsWith("-3.ics")
}
@Test
fun `keeps the id so same-named lists cannot collide`() {
// Two accounts may each have a list called "Personal"; without the id one
// export would silently overwrite the other.
assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2))
}
@Test
fun `falls back when the name has nothing usable in it`() {
assertThat(name("///")).isEqualTo("list-3.ics")
assertThat(name("")).isEqualTo("list-3.ics")
}
@Test
fun `does not leave dangling separators`() {
assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics")
}
@Test
fun `caps the length`() {
// Many filesystems stop at 255 bytes for a name; a pathological list title
// should not be the thing that fails an export.
assertThat(name("x".repeat(500)).length).isAtMost(80)
}
@Test
fun `keeps non-latin names instead of blanking them`() {
// isLetterOrDigit is Unicode-aware, so these survive rather than collapsing
// to the "list" fallback.
assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics")
assertThat(name("買い物")).isEqualTo("買い物-3.ics")
}
}

View File

@@ -0,0 +1,313 @@
package de.jeanlucmakiola.agendula.domain.export
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Instant
/**
* The export format. Worth testing closely: an export is only as good as its
* ability to be read back, and nothing about a malformed `.ics` is obvious until
* someone actually needs the backup.
*/
class ICalendarWriterTest {
private fun task(
taskId: Long = 1L,
uid: String? = null,
title: String = "Buy oat milk",
description: String? = null,
location: String? = null,
url: String? = null,
priority: Priority = Priority.NONE,
status: TaskStatus = TaskStatus.NEEDS_ACTION,
percentComplete: Int? = null,
start: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
completedAt: Instant? = null,
created: Instant? = null,
lastModified: Instant? = null,
rrule: String? = null,
rdate: String? = null,
parentId: Long? = null,
) = ExportTask(
taskId, uid, title, description, location, url, priority, status, percentComplete,
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId,
)
private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String =
ICalendarWriter.write(ExportList(1L, name, "local", tasks.toList()))
/** Unfolds the way an importer does, so assertions can read logical lines. */
private fun String.unfolded(): String = replace("\r\n ", "")
private fun linesOf(ics: String): List<String> = ics.unfolded().split("\r\n").filter { it.isNotEmpty() }
@Nested
inner class Structure {
@Test
fun `wraps the todos in a calendar`() {
val lines = linesOf(write(task()))
assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR")
assertThat(lines.last()).isEqualTo("END:VCALENDAR")
assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO")
}
@Test
fun `uses CRLF line endings`() {
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
// by a strict importer while looking perfectly fine in an editor.
val ics = write(task())
assertThat(ics).contains("\r\n")
assertThat(ics.replace("\r\n", "")).doesNotContain("\n")
}
@Test
fun `carries the list name`() {
assertThat(linesOf(write(task(), name = "Shopping"))).contains("X-WR-CALNAME:Shopping")
}
@Test
fun `every todo has a UID and a DTSTAMP`() {
// Both are mandatory; a VTODO missing either is invalid.
val lines = linesOf(write(task(), task(taskId = 2)))
assertThat(lines.count { it.startsWith("UID:") }).isEqualTo(2)
assertThat(lines.count { it.startsWith("DTSTAMP:") }).isEqualTo(2)
}
}
@Nested
inner class Identity {
@Test
fun `prefers the synced UID`() {
assertThat(linesOf(write(task(uid = "abc-123@example.org"))))
.contains("UID:abc-123@example.org")
}
@Test
fun `synthesises a stable UID for a local task`() {
// Local tasks never get a UID from the provider (only a sync adapter may
// assign one), and re-importing UID-less todos would duplicate rather
// than match them.
val first = ICalendarWriter.uidFor(task(taskId = 42))
val second = ICalendarWriter.uidFor(task(taskId = 42))
assertThat(first).isEqualTo(second)
assertThat(first).contains("42")
}
@Test
fun `distinct tasks get distinct UIDs`() {
assertThat(ICalendarWriter.uidFor(task(taskId = 1)))
.isNotEqualTo(ICalendarWriter.uidFor(task(taskId = 2)))
}
@Test
fun `blank stored UID falls back to the synthesised one`() {
assertThat(ICalendarWriter.uidFor(task(taskId = 7, uid = " "))).contains("7")
}
}
@Nested
inner class Dates {
private val noon = Instant.fromEpochMilliseconds(1_754_136_000_000L) // 2025-08-02T12:00:00Z
@Test
fun `timed values are written in UTC`() {
assertThat(linesOf(write(task(due = noon)))).contains("DUE:20250802T120000Z")
}
@Test
fun `all-day values are date-only`() {
// A DATE-TIME here would drift by a day for anyone east or west of UTC —
// the exact bug the AllDayTime convention exists to prevent.
val due = allDayInstantOf(LocalDate.of(2026, 8, 2))
val lines = linesOf(write(task(due = due, isAllDay = true)))
assertThat(lines).contains("DUE;VALUE=DATE:20260802")
}
@Test
fun `completion is always a UTC date-time even when all-day`() {
val lines = linesOf(
write(task(isAllDay = true, status = TaskStatus.COMPLETED, completedAt = noon)),
)
assertThat(lines).contains("COMPLETED:20250802T120000Z")
}
@Test
fun `absent dates emit no property at all`() {
val lines = linesOf(write(task()))
assertThat(lines.none { it.startsWith("DUE") }).isTrue()
assertThat(lines.none { it.startsWith("DTSTART") }).isTrue()
}
}
@Nested
inner class Fields {
@Test
fun `maps every status to its iCalendar name`() {
fun statusLine(status: TaskStatus) =
linesOf(write(task(status = status))).first { it.startsWith("STATUS:") }
assertThat(statusLine(TaskStatus.NEEDS_ACTION)).isEqualTo("STATUS:NEEDS-ACTION")
assertThat(statusLine(TaskStatus.IN_PROCESS)).isEqualTo("STATUS:IN-PROCESS")
assertThat(statusLine(TaskStatus.COMPLETED)).isEqualTo("STATUS:COMPLETED")
assertThat(statusLine(TaskStatus.CANCELLED)).isEqualTo("STATUS:CANCELLED")
}
@Test
fun `omits priority when there is none`() {
// PRIORITY:0 means "undefined" but reads as a real value to some
// importers; leaving it out is unambiguous.
assertThat(linesOf(write(task())).none { it.startsWith("PRIORITY") }).isTrue()
assertThat(linesOf(write(task(priority = Priority.HIGH)))).contains("PRIORITY:1")
}
@Test
fun `clamps percent complete into range`() {
assertThat(linesOf(write(task(percentComplete = 140)))).contains("PERCENT-COMPLETE:100")
assertThat(linesOf(write(task(percentComplete = -5)))).contains("PERCENT-COMPLETE:0")
}
@Test
fun `passes recurrence through unchanged`() {
val lines = linesOf(write(task(rrule = "FREQ=WEEKLY;BYDAY=MO,WE")))
assertThat(lines).contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE")
}
@Test
fun `does not escape a URL`() {
// URL is a URI, not TEXT. Escaping its commas would corrupt the address.
val lines = linesOf(write(task(url = "https://example.org/a,b;c")))
assertThat(lines).contains("URL:https://example.org/a,b;c")
}
@Test
fun `skips blank optional fields`() {
val lines = linesOf(write(task(description = " ", location = "", url = "")))
assertThat(lines.none { it.startsWith("DESCRIPTION") }).isTrue()
assertThat(lines.none { it.startsWith("LOCATION") }).isTrue()
assertThat(lines.none { it.startsWith("URL") }).isTrue()
}
}
@Nested
inner class Subtasks {
@Test
fun `links a child to its parent by UID`() {
val parent = task(taskId = 1, uid = "parent@example.org")
val child = task(taskId = 2, parentId = 1)
assertThat(linesOf(write(parent, child)))
.contains("RELATED-TO;RELTYPE=PARENT:parent@example.org")
}
@Test
fun `resolves a parent that appears after the child`() {
// Nothing guarantees provider order, and a forward reference must still
// resolve or half the hierarchy silently disappears.
val child = task(taskId = 2, parentId = 1)
val parent = task(taskId = 1, uid = "parent@example.org")
assertThat(linesOf(write(child, parent)))
.contains("RELATED-TO;RELTYPE=PARENT:parent@example.org")
}
@Test
fun `drops a link to a parent outside this list`() {
// A RELATED-TO pointing at a UID not in the file would dangle on import.
val orphan = task(taskId = 2, parentId = 999)
assertThat(linesOf(write(orphan)).none { it.startsWith("RELATED-TO") }).isTrue()
}
}
@Nested
inner class Escaping {
@Test
fun `escapes the special characters`() {
assertThat(ICalendarWriter.escapeText("a;b,c")).isEqualTo("a\\;b\\,c")
assertThat(ICalendarWriter.escapeText("line\nbreak")).isEqualTo("line\\nbreak")
assertThat(ICalendarWriter.escapeText("CRLF\r\nhere")).isEqualTo("CRLF\\nhere")
}
@Test
fun `escapes backslashes first`() {
// Doing it later would re-escape the backslashes the other rules add,
// turning "a;b" into "a\\;b".
assertThat(ICalendarWriter.escapeText("back\\slash")).isEqualTo("back\\\\slash")
assertThat(ICalendarWriter.escapeText("a\\;b")).isEqualTo("a\\\\\\;b")
}
@Test
fun `a multiline description stays one logical line`() {
val ics = write(task(description = "first\nsecond"))
assertThat(ics.unfolded()).contains("DESCRIPTION:first\\nsecond")
}
}
@Nested
inner class Folding {
@Test
fun `short lines are untouched`() {
assertThat(ICalendarWriter.fold("SUMMARY:short")).isEqualTo("SUMMARY:short")
}
@Test
fun `long lines are folded to 75 octets`() {
val folded = ICalendarWriter.fold("SUMMARY:" + "a".repeat(200))
folded.split("\r\n").forEachIndexed { index, segment ->
val octets = segment.toByteArray(Charsets.UTF_8).size
assertThat(octets).isAtMost(if (index == 0) 75 else 76) // 75 + the leading space
}
}
@Test
fun `folding round-trips`() {
val original = "DESCRIPTION:" + "long text ".repeat(40)
assertThat(ICalendarWriter.fold(original).replace("\r\n ", "")).isEqualTo(original)
}
@Test
fun `never splits a multi-byte character`() {
// The limit is in octets but an emoji is four of them; splitting mid
// sequence would emit invalid UTF-8 and mangle the title.
val emoji = "SUMMARY:" + "🌼".repeat(40)
val folded = ICalendarWriter.fold(emoji)
assertThat(folded.replace("\r\n ", "")).isEqualTo(emoji)
folded.split("\r\n").forEach { segment ->
// A broken surrogate pair round-trips through UTF-8 as U+FFFD.
assertThat(segment.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8))
.isEqualTo(segment)
}
}
@Test
fun `a long title survives the full write`() {
val title = "Remember to ".repeat(20)
assertThat(write(task(title = title)).unfolded()).contains("SUMMARY:$title")
}
}
@Nested
inner class EmptyList {
@Test
fun `still produces a valid calendar`() {
// An empty list is a real answer; a missing file is indistinguishable
// from a failed export.
val lines = linesOf(ICalendarWriter.write(ExportList(1L, "Empty", "local", emptyList())))
assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR")
assertThat(lines.last()).isEqualTo("END:VCALENDAR")
assertThat(lines.none { it == "BEGIN:VTODO" }).isTrue()
}
}
}