Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

Commit 22c9dd3

Browse files
authored
[jtx] Add builder for start date, due date and duration (#399)
1 parent 5d5e88c commit 22c9dd3

6 files changed

Lines changed: 446 additions & 33 deletions

File tree

lib/src/main/kotlin/at/bitfire/synctools/icalendar/Ical4jHelpers.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import net.fortuna.ical4j.model.component.CalendarComponent
1919
import net.fortuna.ical4j.model.component.VEvent
2020
import net.fortuna.ical4j.model.property.DtEnd
2121
import net.fortuna.ical4j.model.property.DtStart
22+
import net.fortuna.ical4j.model.property.Due
2223
import net.fortuna.ical4j.model.property.RecurrenceId
2324
import net.fortuna.ical4j.model.property.Sequence
2425
import net.fortuna.ical4j.model.property.Uid
@@ -57,6 +58,10 @@ fun <T: Temporal> CalendarComponent.dtEnd(): DtEnd<T>? {
5758
return getProperty<DtEnd<T>>(Property.DTEND).getOrNull()
5859
}
5960

61+
fun <T: Temporal> CalendarComponent.due(): Due<T>? {
62+
return getProperty<Due<T>>(Property.DUE).getOrNull()
63+
}
64+
6065
fun <T: Temporal> VEvent.requireDtStart(): DtStart<T> =
6166
getProperty<DtStart<T>>(Property.DTSTART).getOrNull() ?: throw InvalidICalendarException("Missing DTSTART in VEVENT")
6267

lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxItemBuilder.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import at.bitfire.synctools.mapping.jtx.builder.DescriptionBuilder
1414
import at.bitfire.synctools.mapping.jtx.builder.JtxEntityBuilder
1515
import at.bitfire.synctools.mapping.jtx.builder.RecurrenceFieldsBuilder
1616
import at.bitfire.synctools.mapping.jtx.builder.SyncPropertiesBuilder
17+
import at.bitfire.synctools.mapping.jtx.builder.TimeFieldsBuilder
1718
import at.bitfire.synctools.storage.jtx.JtxItemAndExceptions
1819
import net.fortuna.ical4j.model.component.CalendarComponent
1920
import net.fortuna.ical4j.model.component.VJournal
@@ -35,7 +36,8 @@ class JtxItemBuilder(
3536
SyncPropertiesBuilder(fileName, eTag, scheduleTag, flags),
3637

3738
DescriptionBuilder(),
38-
RecurrenceFieldsBuilder()
39+
RecurrenceFieldsBuilder(),
40+
TimeFieldsBuilder()
3941
)
4042

4143
fun build(component: AssociatedComponents<CalendarComponent>): JtxItemAndExceptions {

lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/builder/RecurrenceFieldsBuilder.kt

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,16 @@ import android.content.Entity
1010
import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate
1111
import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDates
1212
import at.bitfire.synctools.icalendar.recurrenceId
13+
import at.bitfire.synctools.mapping.jtx.builder.TimeZoneIdMapper.toTimeZoneId
1314
import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp
1415
import at.techbee.jtx.JtxContract
1516
import net.fortuna.ical4j.model.Property
1617
import net.fortuna.ical4j.model.component.CalendarComponent
1718
import net.fortuna.ical4j.model.property.DateListProperty
1819
import net.fortuna.ical4j.model.property.RRule
19-
import java.time.Instant
20-
import java.time.LocalDate
21-
import java.time.LocalDateTime
22-
import java.time.ZoneOffset
23-
import java.time.ZonedDateTime
24-
import java.time.temporal.Temporal
25-
import java.util.logging.Logger
2620

2721
class RecurrenceFieldsBuilder : JtxEntityBuilder {
2822

29-
private val logger
30-
get() = Logger.getLogger(javaClass.name)
31-
3223
override fun build(from: CalendarComponent, main: CalendarComponent, to: Entity) {
3324
if (from === main) {
3425
buildMainItem(main, to)
@@ -109,28 +100,7 @@ class RecurrenceFieldsBuilder : JtxEntityBuilder {
109100
}
110101

111102
to.entityValues.put(JtxContract.JtxICalObject.RECURID, recurrenceId.value)
112-
val timeZoneId = recurrenceId.normalizedDate().getTimeZoneId()
103+
val timeZoneId = recurrenceId.normalizedDate().toTimeZoneId()
113104
to.entityValues.put(JtxContract.JtxICalObject.RECURID_TIMEZONE, timeZoneId)
114105
}
115-
116-
private fun Temporal.getTimeZoneId(): String? = when (this) {
117-
is ZonedDateTime -> {
118-
this.zone.id
119-
}
120-
is Instant -> {
121-
ZoneOffset.UTC.id
122-
}
123-
is LocalDateTime -> {
124-
// Timezone unknown => floating time
125-
null
126-
}
127-
is LocalDate -> {
128-
// Without time, it is considered all-day
129-
JtxContract.JtxICalObject.TZ_ALLDAY
130-
}
131-
else -> {
132-
logger.warning("Ignoring unsupported temporal type: ${this::class}")
133-
null
134-
}
135-
}
136106
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.jtx.builder
8+
9+
import android.content.Entity
10+
import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate
11+
import at.bitfire.synctools.icalendar.dtStart
12+
import at.bitfire.synctools.icalendar.due
13+
import at.bitfire.synctools.mapping.jtx.builder.TimeZoneIdMapper.toTimeZoneId
14+
import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp
15+
import at.techbee.jtx.JtxContract
16+
import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY
17+
import net.fortuna.ical4j.model.Property
18+
import net.fortuna.ical4j.model.component.CalendarComponent
19+
import net.fortuna.ical4j.model.component.VJournal
20+
import net.fortuna.ical4j.model.component.VToDo
21+
import java.time.temporal.Temporal
22+
import java.util.logging.Logger
23+
24+
/**
25+
* Handles the iCalendar properties: DTSTART, DTEND, DUE, DURATION
26+
*/
27+
class TimeFieldsBuilder : JtxEntityBuilder {
28+
private val logger
29+
get() = Logger.getLogger(javaClass.name)
30+
31+
override fun build(from: CalendarComponent, main: CalendarComponent, to: Entity) {
32+
if (from is VJournal) {
33+
buildJournal(from, to)
34+
} else if (from is VToDo) {
35+
buildTask(from, to)
36+
}
37+
}
38+
39+
private fun buildJournal(from: VJournal, to: Entity) {
40+
buildStartDate(from, to)
41+
42+
ignoreDtEnd(from, to)
43+
ignoreDue(from, to)
44+
ignoreDuration(from, to)
45+
}
46+
47+
private fun buildTask(from: VToDo, to: Entity) {
48+
val startTimeZoneId = buildStartDate(from, to)
49+
val dueTimeZoneId = buildDueDate(from, to)
50+
cleanUpTimeZones(startTimeZoneId, dueTimeZoneId, to)
51+
52+
warnIfDueDateAfterStartDate(from)
53+
54+
buildDuration(from, to)
55+
56+
ignoreDtEnd(from, to)
57+
}
58+
59+
private fun buildStartDate(from: CalendarComponent, to: Entity): String? {
60+
val start = from.dtStart<Temporal>()?.normalizedDate()
61+
val timeZoneId = start?.toTimeZoneId()
62+
if (start != null) {
63+
to.entityValues.put(JtxContract.JtxICalObject.DTSTART, start.toTimestamp())
64+
to.entityValues.put(JtxContract.JtxICalObject.DTSTART_TIMEZONE, timeZoneId)
65+
} else {
66+
to.entityValues.putNull(JtxContract.JtxICalObject.DTSTART)
67+
to.entityValues.putNull(JtxContract.JtxICalObject.DTSTART_TIMEZONE)
68+
}
69+
70+
return timeZoneId
71+
}
72+
73+
private fun buildDueDate(from: CalendarComponent, to: Entity): String? {
74+
val due = from.due<Temporal>()?.normalizedDate()
75+
val timeZoneId = due?.toTimeZoneId()
76+
if (due != null) {
77+
to.entityValues.put(JtxContract.JtxICalObject.DUE, due.toTimestamp())
78+
to.entityValues.put(JtxContract.JtxICalObject.DUE_TIMEZONE, timeZoneId)
79+
} else {
80+
to.entityValues.putNull(JtxContract.JtxICalObject.DUE)
81+
to.entityValues.putNull(JtxContract.JtxICalObject.DUE_TIMEZONE)
82+
}
83+
84+
return timeZoneId
85+
}
86+
87+
private fun buildDuration(task: VToDo, to: Entity) {
88+
val durationValue = task.duration?.value
89+
90+
if (durationValue != null && task.dtStart<Temporal>()?.date == null) {
91+
logger.warning("Found DURATION without DTSTART in VTODO; ignoring DURATION")
92+
to.entityValues.putNull(JtxContract.JtxICalObject.DURATION)
93+
} else if (durationValue != null && task.due<Temporal>()?.date != null) {
94+
logger.warning("Found DURATION and DUE in VTODO; ignoring DURATION")
95+
to.entityValues.putNull(JtxContract.JtxICalObject.DURATION)
96+
} else if (durationValue != null) {
97+
to.entityValues.put(JtxContract.JtxICalObject.DURATION, durationValue)
98+
} else {
99+
to.entityValues.putNull(JtxContract.JtxICalObject.DURATION)
100+
}
101+
}
102+
103+
private fun cleanUpTimeZones(startTimeZoneId: String?, dueTimeZoneId: String?, to: Entity) {
104+
if (startTimeZoneId == TZ_ALLDAY && dueTimeZoneId != null && dueTimeZoneId != TZ_ALLDAY) {
105+
logger.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME")
106+
to.entityValues.put(JtxContract.JtxICalObject.DTSTART_TIMEZONE, dueTimeZoneId)
107+
} else if (dueTimeZoneId == TZ_ALLDAY && startTimeZoneId != null && startTimeZoneId != TZ_ALLDAY) {
108+
logger.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME")
109+
to.entityValues.put(JtxContract.JtxICalObject.DUE_TIMEZONE, startTimeZoneId)
110+
}
111+
}
112+
113+
private fun warnIfDueDateAfterStartDate(task: VToDo) {
114+
val start = task.dtStart<Temporal>()?.normalizedDate()?.toTimestamp()
115+
val due = task.due<Temporal>()?.normalizedDate()?.toTimestamp()
116+
117+
// Previously DUE was dropped. Now reduced to a warning.
118+
// See also: https://github.com/bitfireAT/ical4android/issues/70
119+
if (start != null && due != null && due < start) {
120+
logger.warning("Found invalid DUE < DTSTART")
121+
}
122+
}
123+
124+
private fun ignoreDtEnd(from: CalendarComponent, to: Entity) {
125+
if (from.hasProperty(Property.DTEND)) {
126+
logger.warning("DTEND must not be used with VJOURNAL, ignoring property.")
127+
}
128+
129+
to.entityValues.putNull(JtxContract.JtxICalObject.DTEND)
130+
to.entityValues.putNull(JtxContract.JtxICalObject.DTEND_TIMEZONE)
131+
}
132+
133+
private fun ignoreDue(from: CalendarComponent, to: Entity) {
134+
if (from.hasProperty(Property.DUE)) {
135+
logger.warning("DUE must not be used with VJOURNAL, ignoring property.")
136+
}
137+
138+
to.entityValues.putNull(JtxContract.JtxICalObject.DUE)
139+
to.entityValues.putNull(JtxContract.JtxICalObject.DUE_TIMEZONE)
140+
}
141+
142+
private fun ignoreDuration(from: CalendarComponent, to: Entity) {
143+
if (from.hasProperty(Property.DURATION)) {
144+
logger.warning("DURATION must not be used with VJOURNAL, ignoring property.")
145+
}
146+
147+
to.entityValues.putNull(JtxContract.JtxICalObject.DURATION)
148+
}
149+
}
150+
151+
private fun CalendarComponent.hasProperty(name: String) = getProperty<Property>(name).isPresent
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.jtx.builder
8+
9+
import at.techbee.jtx.JtxContract
10+
import java.time.Instant
11+
import java.time.LocalDate
12+
import java.time.LocalDateTime
13+
import java.time.ZoneOffset
14+
import java.time.ZonedDateTime
15+
import java.time.temporal.Temporal
16+
import java.util.logging.Logger
17+
18+
object TimeZoneIdMapper {
19+
private val logger
20+
get() = Logger.getLogger(javaClass.name)
21+
22+
fun Temporal.toTimeZoneId(): String? {
23+
return when (this) {
24+
is ZonedDateTime -> {
25+
this.zone.id
26+
}
27+
is Instant -> {
28+
ZoneOffset.UTC.id
29+
}
30+
is LocalDateTime -> {
31+
// Timezone unknown => floating time
32+
null
33+
}
34+
is LocalDate -> {
35+
// Without time, it is considered all-day
36+
JtxContract.JtxICalObject.TZ_ALLDAY
37+
}
38+
else -> {
39+
logger.warning("Ignoring unsupported temporal type: ${this::class}")
40+
null
41+
}
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)