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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user