diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt b/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt index 64661c3d..ae69374a 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt @@ -6,13 +6,51 @@ package at.bitfire.ical4android -import at.bitfire.ical4android.ICalendar.Companion.fromReader +import at.bitfire.ical4android.util.DateUtils import at.bitfire.synctools.exception.InvalidICalendarException +import at.bitfire.synctools.icalendar.Css3Color +import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate +import at.bitfire.synctools.icalendar.ICalendarParser +import at.bitfire.synctools.mapping.calendar.builder.AndroidTemporalMapper.toTimestamp import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.TemporalAdapter import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Comment +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.Created +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtStamp +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.PercentComplete +import net.fortuna.ical4j.model.property.Priority +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url import java.io.IOException import java.io.Reader -import java.util.LinkedList +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.Temporal import java.util.logging.Logger /** @@ -24,28 +62,26 @@ class TaskReader { get() = Logger.getLogger(TaskReader::class.java.name) /** - * Parses an iCalendar resource, applies [at.bitfire.synctools.icalendar.validation.ICalPreprocessor] to increase compatibility - * and extracts the VTODOs. + * Parses an iCalendar resource and extracts the VTODOs. * * @param reader where the iCalendar is taken from * - * @return array of filled [Task] data objects (may have size 0) + * @return list of filled [Task] data objects (may have size 0) * * @throws InvalidICalendarException when the iCalendar can't be parsed * @throws IOException on I/O errors */ fun readTasks(reader: Reader): List { - val ical = fromReader(reader) + val ical = ICalendarParser().parse(reader) val vToDos = ical.getComponents(Component.VTODO) - return vToDos.mapTo(LinkedList()) { this.fromVToDo(it) } + return vToDos.map { fromVToDo(it) } } private fun fromVToDo(todo: VToDo): Task { - TODO("ical4j 4.x") - /*val t = Task() + val t = Task() - if (todo.uid != null) - t.uid = todo.uid.value + if (todo.uid.isPresent) + t.uid = todo.uid.get().value else { logger.warning("Received VTODO without UID, generating new one") t.generateUID() @@ -54,11 +90,11 @@ class TaskReader { // sequence must only be null for locally created, not-yet-synchronized events t.sequence = 0 - for (prop in todo.properties) + for (prop in todo.propertyList.all) when (prop) { is Sequence -> t.sequence = prop.sequenceNo - is Created -> t.createdAt = prop.dateTime.time - is LastModified -> t.lastModified = prop.dateTime.time + is Created -> t.createdAt = prop.date.toTimestamp() + is LastModified -> t.lastModified = prop.date.toTimestamp() is Summary -> t.summary = prop.value is Location -> t.location = prop.value is Geo -> t.geoPosition = prop @@ -69,17 +105,15 @@ class TaskReader { is Priority -> t.priority = prop.level is Clazz -> t.classification = prop is Status -> t.status = prop - is Due -> { t.due = prop } + is Due<*> -> { t.due = prop } is Duration -> t.duration = prop - is DtStart -> { t.dtStart = prop } + is DtStart<*> -> { t.dtStart = prop } is Completed -> { t.completedAt = prop } is PercentComplete -> t.percentComplete = prop.percentage - is RRule -> t.rRule = prop - is RDate -> t.rDates += prop - is ExDate -> t.exDates += prop - is Categories -> - for (category in prop.categories) - t.categories += category + is RRule<*> -> t.rRule = prop + is RDate<*> -> t.rDates += prop + is ExDate<*> -> t.exDates += prop + is Categories -> t.categories.addAll(prop.categories.texts) is Comment -> t.comment = prop.value is RelatedTo -> t.relatedTo.add(prop) is Uid, is ProdId, is DtStamp -> { /* don't save these as unknown properties */ } @@ -89,20 +123,21 @@ class TaskReader { t.alarms.addAll(todo.alarms) // There seem to be many invalid tasks out there because of some defect clients, do some validation. - val dtStart = t.dtStart - val due = t.due + val startDate = t.dtStart?.normalizedDate() + val dueDate = t.due?.normalizedDate() - if (dtStart != null && due != null) { - if (DateUtils.isDate(dtStart) && DateUtils.isDateTime(due)) { + if (startDate != null && dueDate != null) { + if (startDate is LocalDate && DateUtils.isDateTime(dueDate)) { logger.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME") - t.dtStart = DtStart(DateTime(dtStart.value, due.timeZone)) - } else if (DateUtils.isDateTime(dtStart) && DateUtils.isDate(due)) { + t.dtStart = DtStart(startDate.toDateTime(dueDate)) + } else if (DateUtils.isDateTime(startDate) && dueDate is LocalDate) { logger.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME") - t.due = Due(DateTime(due.value, dtStart.timeZone)) + t.due = Due(dueDate.toDateTime(startDate)) } - - if (due.date <= dtStart.date) { + val newStartDate = t.dtStart!!.date + val newDueDate = t.due!!.date + if (TemporalAdapter.isAfter(newStartDate, newDueDate)) { logger.warning("Found invalid DUE <= DTSTART; dropping DTSTART") t.dtStart = null } @@ -113,7 +148,20 @@ class TaskReader { t.duration = null } - return t*/ + return t + } + + /** + * Converts this [LocalDate] to a date-time [Temporal] of the same type as `referenceDateTime`. + */ + private fun LocalDate.toDateTime(referenceDateTime: Temporal): Temporal { + return when (referenceDateTime) { + is LocalDateTime -> atStartOfDay() + is ZonedDateTime -> atStartOfDay(referenceDateTime.zone) + is OffsetDateTime -> OffsetDateTime.of(atStartOfDay(), referenceDateTime.offset) + is Instant -> atStartOfDay(ZoneOffset.UTC).toInstant() + else -> error("Unsupported Temporal type: ${this::class.qualifiedName}") + } } } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt b/lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt index cc6226e1..2951de43 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt @@ -6,9 +6,43 @@ package at.bitfire.ical4android +import at.bitfire.ical4android.ICalendar.Companion.softValidate +import at.bitfire.ical4android.ICalendar.Companion.withUserAgents +import at.bitfire.synctools.icalendar.Css3Color +import at.bitfire.synctools.icalendar.VTimeZoneMinifier +import at.bitfire.synctools.icalendar.plusAssign +import at.bitfire.synctools.mapping.calendar.builder.AndroidTemporalMapper.toTimestamp +import net.fortuna.ical4j.data.CalendarOutputter +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.TextList +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Comment +import net.fortuna.ical4j.model.property.Created +import net.fortuna.ical4j.model.property.DateProperty +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.PercentComplete +import net.fortuna.ical4j.model.property.Priority import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url +import net.fortuna.ical4j.model.property.immutable.ImmutableVersion import java.io.Writer +import java.net.URI +import java.net.URISyntaxException +import java.time.Instant +import java.util.logging.Level import java.util.logging.Logger +import kotlin.jvm.optionals.getOrNull /** * Writes a [Task] data class to a stream that contains an iCalendar @@ -23,6 +57,7 @@ class TaskWriter( private val logger get() = Logger.getLogger(TaskWriter::class.java.name) + private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } /** * Generates an iCalendar from the provided Task. @@ -31,85 +66,93 @@ class TaskWriter( * @param to stream that the iCalendar is written to */ fun write(task: Task, to: Writer): Unit = with(task) { - TODO() - /*val ical = Calendar() - ical.properties += Version.VERSION_2_0 - ical.properties += prodId.withUserAgents(userAgents) + val ical = Calendar() + ical += ImmutableVersion.VERSION_2_0 + ical += prodId.withUserAgents(userAgents) val vTodo = VToDo(true /* generates DTSTAMP */) - ical.components += vTodo - val props = vTodo.properties + ical += vTodo - uid?.let { props += Uid(uid) } + uid?.let { vTodo += Uid(uid) } sequence?.let { if (it != 0) - props += Sequence(it) + vTodo += Sequence(it) } - createdAt?.let { props += Created(DateTime(it)) } - lastModified?.let { props += LastModified(DateTime(it)) } + createdAt?.let { vTodo += Created(Instant.ofEpochMilli(it)) } + lastModified?.let { vTodo += LastModified(Instant.ofEpochMilli(it)) } - summary?.let { props += Summary(it) } - location?.let { props += Location(it) } - geoPosition?.let { props += it } - description?.let { props += Description(it) } - color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } + summary?.let { vTodo += Summary(it) } + location?.let { vTodo += Location(it) } + geoPosition?.let { vTodo += it } + description?.let { vTodo += Description(it) } + color?.let { vTodo += Color(null, Css3Color.nearestMatch(it).name) } url?.let { try { - props += Url(URI(it)) + vTodo += Url(URI(it)) } catch (e: URISyntaxException) { logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) } } - organizer?.let { props += it } + organizer?.let { vTodo += it } - if (priority != Priority.UNDEFINED.level) - props += Priority(priority) - classification?.let { props += it } - status?.let { props += it } + if (priority != Priority.VALUE_UNDEFINED) + vTodo += Priority(priority) + classification?.let { vTodo += it } + status?.let { vTodo += it } - rRule?.let { props += it } - rDates.forEach { props += it } - exDates.forEach { props += it } + rRule?.let { vTodo += it } + rDates.forEach { vTodo += it } + exDates.forEach { vTodo += it } if (categories.isNotEmpty()) - props += Categories(TextList(categories.toTypedArray())) - comment?.let { props += Comment(it) } - props.addAll(relatedTo) - props.addAll(unknownProperties) + vTodo += Categories(TextList(categories)) + comment?.let { vTodo += Comment(it) } + vTodo.addAll(relatedTo as Collection) + vTodo.addAll(unknownProperties) // remember used time zones - val usedTimeZones = HashSet() - due?.let { - props += it - it.timeZone?.let(usedTimeZones::add) + val usedTimeZones = mutableSetOf() + due?.let { due -> + vTodo += due + due.getTzidOrNull()?.let(usedTimeZones::add) } - duration?.let(props::add) - dtStart?.let { - props += it - it.timeZone?.let(usedTimeZones::add) + duration?.let { vTodo += it } + dtStart?.let { dtStart -> + vTodo += dtStart + dtStart.getTzidOrNull()?.let(usedTimeZones::add) } - completedAt?.let { - props += it - it.timeZone?.let(usedTimeZones::add) + completedAt?.let { completedAt -> + vTodo += completedAt + completedAt.getTzidOrNull()?.let(usedTimeZones::add) } - percentComplete?.let { props += PercentComplete(it) } + percentComplete?.let { vTodo += PercentComplete(it) } - if (alarms.isNotEmpty()) - vTodo.components.addAll(alarms) + for (alarm in alarms) { + vTodo.add(alarm) + } // determine earliest referenced date val earliest = arrayOf( dtStart?.date, due?.date, completedAt?.date - ).filterNotNull().minOrNull() + ).filterNotNull().minByOrNull { it.toTimestamp() } + + + val timeZoneMinifier = VTimeZoneMinifier() // add VTIMEZONE components - for (tz in usedTimeZones) - ical.components += minifyVTimeZone(tz.vTimeZone, earliest) + for (tz in usedTimeZones) { + val vTimeZone = tzRegistry.getTimeZone(tz).vTimeZone + val minifiedVTimeZone = timeZoneMinifier.minify(vTimeZone, earliest) + ical += minifiedVTimeZone + } softValidate(ical) - CalendarOutputter(false).output(ical, to)*/ + CalendarOutputter(false).output(ical, to) } + private fun DateProperty<*>.getTzidOrNull(): String? { + return getParameter(Parameter.TZID).getOrNull()?.value + } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt index 07e3a8b9..73ad6210 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt @@ -6,36 +6,38 @@ package at.bitfire.ical4android -import net.fortuna.ical4j.model.Date +import at.bitfire.dateTimeValue +import at.bitfire.dateValue import net.fortuna.ical4j.model.DateList -import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterList import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.property.Clazz import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule -import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.immutable.ImmutableClazz +import net.fortuna.ical4j.model.property.immutable.ImmutableStatus import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Ignore import org.junit.Test import java.io.InputStreamReader import java.io.StringReader import java.io.StringWriter import java.nio.charset.Charset import java.time.Duration +import java.time.temporal.Temporal + +import net.fortuna.ical4j.model.property.Duration as ICalDuration -@Ignore("ical4j 4.x") class TaskReaderTest { val testProdId = ProdId(javaClass.name) @@ -64,9 +66,8 @@ class TaskReaderTest { "END:VCALENDAR\r\n") assertEquals("DTSTART is DATE, but DUE is DATE-TIME", t.summary) // rewrite DTSTART to DATE-TIME, too - TODO("ical4j 4.x") - /*assertEquals(DtStart(DateTime("20200731T000000", tzVienna)), t.dtStart) - assertEquals(Due(DateTime("20200731T234600", tzVienna)), t.due)*/ + assertEquals(DtStart(dateTimeValue("20200731T000000", tzVienna)), t.dtStart) + assertEquals(Due(dateTimeValue("20200731T234600", tzVienna)), t.due) } @Test @@ -81,9 +82,8 @@ class TaskReaderTest { "END:VCALENDAR\r\n") assertEquals("DTSTART is DATE-TIME, but DUE is DATE", t.summary) // rewrite DTSTART to DATE-TIME, too - TODO("ical4j 4.x") - /*assertEquals(DtStart(DateTime("20200731T235510", tzVienna)), t.dtStart) - assertEquals(Due(DateTime("20200801T000000", tzVienna)), t.due)*/ + assertEquals(DtStart(dateTimeValue("20200731T235510", tzVienna)), t.dtStart) + assertEquals(Due(dateTimeValue("20200801T000000", tzVienna)), t.due) } @Test @@ -99,8 +99,7 @@ class TaskReaderTest { assertEquals("DUE before DTSTART", t.summary) // invalid tasks with DUE before DTSTART: DTSTART should be set to null assertNull(t.dtStart) - TODO("ical4j 4.x") - //assertEquals(Due(DateTime("20200731T123000", tzVienna)), t.due) + assertEquals(Due(dateTimeValue("20200731T123000", tzVienna)), t.due) } @Test @@ -131,19 +130,15 @@ class TaskReaderTest { } - init { - TODO("ical4j 4.x") - } - - /*@Test + @Test fun testSamples() { val t = regenerate(parseCalendarFile("rfc5545-sample1.ics")) assertEquals(2, t.sequence) assertEquals("uid4@example.com", t.uid) assertEquals("mailto:unclesam@example.com", t.organizer!!.value) - assertEquals(Due("19980415T000000"), t.due) + assertEquals(Due(dateTimeValue("19980415T000000")), t.due) assertFalse(t.isAllDay()) - assertEquals(Status.VTODO_NEEDS_ACTION, t.status) + assertEquals(ImmutableStatus.VTODO_NEEDS_ACTION, t.status) assertEquals("Submit Income Taxes", t.summary) } @@ -165,20 +160,20 @@ class TaskReaderTest { assertEquals("http://example.com/principals/jsmith", t.organizer!!.value) assertEquals("http://example.com/pub/calendars/jsmith/mytime.ics", t.url) assertEquals(1, t.priority) - assertEquals(Clazz.CONFIDENTIAL, t.classification) - assertEquals(Status.VTODO_IN_PROCESS, t.status) + assertEquals(ImmutableClazz.CONFIDENTIAL, t.classification) + assertEquals(ImmutableStatus.VTODO_IN_PROCESS, t.status) assertEquals(25, t.percentComplete) - assertEquals(DtStart(Date("20100101")), t.dtStart) - assertEquals(Due(Date("20101001")), t.due) + assertEquals(DtStart(dateValue("20100101")), t.dtStart) + assertEquals(Due(dateValue("20101001")), t.due) assertTrue(t.isAllDay()) - assertEquals(RRule("FREQ=YEARLY;INTERVAL=2"), t.rRule) + assertEquals(RRule("FREQ=YEARLY;INTERVAL=2"), t.rRule) assertEquals(2, t.exDates.size) - assertTrue(t.exDates.contains(ExDate(DateList("20120101", Value.DATE)))) - assertTrue(t.exDates.contains(ExDate(DateList("20140101,20180101", Value.DATE)))) + assertTrue(t.exDates.contains(ExDate(ParameterList(listOf(Value.DATE)), DateList(dateValue("20120101"))))) + assertTrue(t.exDates.contains(ExDate(ParameterList(listOf(Value.DATE)), DateList(dateValue("20140101"), dateValue("20180101"))))) assertEquals(2, t.rDates.size) - assertTrue(t.rDates.contains(RDate(DateList("20100310,20100315", Value.DATE)))) - assertTrue(t.rDates.contains(RDate(DateList("20100810", Value.DATE)))) + assertTrue(t.rDates.contains(RDate(ParameterList(listOf(Value.DATE)), DateList(dateValue("20100310"), dateValue("20100315"))))) + assertTrue(t.rDates.contains(RDate(ParameterList(listOf(Value.DATE)), DateList(dateValue("20100810"))))) assertEquals(828106200000L, t.createdAt) assertEquals(840288600000L, t.lastModified) @@ -187,23 +182,23 @@ class TaskReaderTest { val (sibling) = t.relatedTo assertEquals("most-fields2@example.com", sibling.value) - assertEquals(RelType.SIBLING, (sibling.getParameter(Parameter.RELTYPE) as RelType)) + assertEquals(RelType.SIBLING, sibling.getRequiredParameter(Parameter.RELTYPE)) val (unknown) = t.unknownProperties assertEquals("X-UNKNOWN-PROP", unknown.name) - assertEquals("xxx", unknown.getParameter("param1").value) + assertEquals("xxx", unknown.getRequiredParameter("param1").value) assertEquals("Unknown Value", unknown.value) // other file t = regenerate(parseCalendarFile("most-fields2.ics")) assertEquals("most-fields2@example.com", t.uid) - assertEquals(DtStart(DateTime("20100101T101010Z")), t.dtStart) + assertEquals(DtStart(dateTimeValue("20100101T101010Z")), t.dtStart) assertEquals( - net.fortuna.ical4j.model.property.Duration(Duration.ofSeconds(4 * 86400 + 3 * 3600 + 2 * 60 + 1) *//*Dur(4, 3, 2, 1)*//*), + ICalDuration(Duration.ofSeconds(4 * 86400 + 3 * 3600 + 2 * 60 + 1)), t.duration ) assertTrue(t.unknownProperties.isEmpty()) - }*/ + } /* helpers */ diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt index 50435080..893bffeb 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt @@ -6,19 +6,19 @@ package at.bitfire.ical4android +import at.bitfire.dateTimeValue +import at.bitfire.synctools.icalendar.plusAssign import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.property.Action import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.immutable.ImmutableAction import org.junit.Assert.assertTrue -import org.junit.Ignore import org.junit.Test import java.io.StringWriter import java.time.Duration -@Ignore("ical4j 4.x") class TaskWriterTest { val testProdId = ProdId(javaClass.name) @@ -31,11 +31,10 @@ class TaskWriterTest { fun testWrite() { val t = Task() t.uid = "SAMPLEUID" - TODO("ical4j 4.x") - //t.dtStart = DtStart("20190101T100000", tzBerlin) + t.dtStart = DtStart(dateTimeValue("20190101T100000", tzBerlin)) - val alarm = VAlarm(Duration.ofHours(-1) /*Dur(0, -1, 0, 0)*/) - //alarm.properties += Action.AUDIO + val alarm = VAlarm(Duration.ofHours(-1)) + alarm += ImmutableAction.AUDIO t.alarms += alarm val icalWriter = StringWriter()