Commit f2a7e34c authored by Ricki Hirner's avatar Ricki Hirner 🐑

Minify VTIMEZONEs when generating iCalendars

parent ea3b1e95
Pipeline #148426604 passed with stages
in 5 minutes and 56 seconds
......@@ -244,10 +244,15 @@ class Event: ICalendar() {
exception.dtEnd?.timeZone?.let(usedTimeZones::add)
}
// determine first dtStart (there may be exceptions with an earlier DTSTART that the main event)
val dtStarts = mutableListOf(dtStart.date)
dtStarts.addAll(exceptions.mapNotNull { it.dtStart?.date })
val earliest = dtStarts.sorted().firstOrNull()
// add VTIMEZONE components
usedTimeZones.forEach {
val tz = it.vTimeZone
// TODO dtStart?.let { minifyVTimeZone(tz, it.date) }
var tz = it.vTimeZone
if (earliest != null)
tz = minifyVTimeZone(tz, earliest)
ical.components += tz
}
......
......@@ -17,6 +17,8 @@ import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.component.*
import net.fortuna.ical4j.model.parameter.Related
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.TzUrl
import net.fortuna.ical4j.validate.ValidationException
import java.io.Reader
......@@ -105,57 +107,91 @@ open class ICalendar {
// time zone helpers
/**
* Minifies a VTIMEZONE so that only components after [start] are kept.
* Doesn't return the smallest possible VTIMEZONE at the moment, but
* reduces its size significantly.
* Minifies a VTIMEZONE so that only these observances are kept:
*
* @param tz Time zone definition to minify. Attention: the observances of this object
* will be modified!
* @param start Start date for components
* - the last STANDARD observance matching [start], and
* - the last DAYLIGHT observance matching [start], and
* - observances beginning after [start]
*
* Additionally, TZURL properties is filtered.
*
* @param tz time zone definition to minify
* @param start start date for components (usually DTSTART)
* @return minified time zone definition
*/
fun minifyVTimeZone(tz: VTimeZone, start: Date) {
// find latest matching STANDARD/DAYLIGHT component,
// keep components at/after "start"
val iter = tz.observances.iterator()
fun minifyVTimeZone(tz: VTimeZone, start: Date): VTimeZone {
val keep = mutableSetOf<Observance>()
// find latest matching STANDARD/DAYLIGHT observances
var latestDaylight: Pair<Date, Observance>? = null
var latestStandard: Pair<Date, Observance>? = null
val keep = mutableSetOf<Observance>()
while (iter.hasNext()) {
val entry = iter.next() as Observance
val latest = entry.getLatestOnset(start)
if (latest == null /* observance begins after "start" */ ||
latest >= start /* observance has onsets at/after "start" */ ) {
keep += entry
continue
for (observance in tz.observances) {
val latest = observance.getLatestOnset(start)
if (latest == null) // observance begins after "start", keep in any case
keep += observance
else
when (observance) {
is Standard ->
if (latestStandard == null || latest > latestStandard.first)
latestStandard = Pair(latest, observance)
is Daylight ->
if (latestDaylight == null || latest > latestDaylight.first)
latestDaylight = Pair(latest, observance)
}
}
// keep latest STANDARD observance
latestStandard?.second?.let { keep += it }
// Check latest DAYLIGHT for whether it can apply in the future. Otherwise, DST is not
// used in this time zone anymore and the DAYLIGHT component can be dropped completely.
latestDaylight?.second?.let { daylight ->
// check whether start time is in DST
if (latestStandard != null) {
val latestStandardOnset = latestStandard.second.getLatestOnset(start)
val latestDaylightOnset = daylight.getLatestOnset(start)
if (latestStandardOnset != null && latestDaylightOnset != null && latestDaylightOnset > latestStandardOnset) {
// we're currently in DST
keep += daylight
return@let
}
}
when (entry) {
is Standard -> {
if (latestStandard == null || latest.after(latestStandard.first))
latestStandard = Pair(latest, entry)
// check RRULEs
for (rRule in daylight.getProperties<RRule>(Property.RRULE)) {
val nextDstOnset = rRule.recur.getNextDate(daylight.startDate.date, start)
if (nextDstOnset != null) {
// there will be a DST onset in the future -> keep DAYLIGHT
keep += daylight
return@let
}
is Daylight -> {
if (latestDaylight == null || latest.after(latestDaylight.first))
latestDaylight = Pair(latest, entry)
}
// no RRULE, check whether there's an RDATE in the future
for (rDate in daylight.getProperties<RDate>(Property.RDATE)) {
if (rDate.dates.any { it >= start }) {
// RDATE in the future
keep += daylight
return@let
}
}
}
latestStandard?.second?.let { keep += it }
latestDaylight?.second?.let { keep += it }
// actually remove all observances that shall not be kept
val iter2 = tz.observances.iterator()
while (iter2.hasNext()) {
val entry = iter2.next() as Observance
// remove all observances that shall not be kept
val newTz = tz.copy() as VTimeZone
val iterator = newTz.observances.iterator() as MutableIterator<Observance>
while (iterator.hasNext()) {
val entry = iterator.next()
if (!keep.contains(entry))
iter2.remove()
iterator.remove()
}
// remove TZURL
tz.properties.filterIsInstance<TzUrl>().forEach {
tz.properties.remove(it)
newTz.properties.filterIsInstance<TzUrl>().forEach {
newTz.properties.remove(it)
}
return newTz
}
/**
......
......@@ -209,7 +209,19 @@ class Task: ICalendar() {
if (alarms.isNotEmpty())
vTodo.alarms.addAll(alarms)
ical.components.addAll(usedTimeZones.map { it.vTimeZone })
// determine earliest referenced date
val earliest = arrayOf(
dtStart?.date,
due?.date,
completedAt?.date
).filterNotNull().sorted().firstOrNull()
// add VTIMEZONE components
usedTimeZones.forEach {
var tz = it.vTimeZone
if (earliest != null)
tz = minifyVTimeZone(tz, earliest)
ical.components += tz
}
softValidate(ical)
CalendarOutputter(false).output(ical, os)
......
......@@ -8,6 +8,7 @@
package at.bitfire.ical4android
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateTime
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.parameter.Related
......@@ -21,6 +22,60 @@ import java.time.Duration
class ICalendarTest {
@Test
fun testMinifyVTimezone() {
// Austria (Europa/Vienna) uses DST regularly
val tzVienna = DateUtils.tzRegistry.getTimeZone("Europe/Vienna").vTimeZone
// Pakistan (Asia/Karachi) used DST only in 2002, 2008 and 2009; no known future occurrences
val tzKarachi = DateUtils.tzRegistry.getTimeZone("Asia/Karachi").vTimeZone
// Somalia (Africa/Mogadishu) has never used DST
val tzMogadishu = DateUtils.tzRegistry.getTimeZone("Africa/Mogadishu").vTimeZone
// test: remove obsolete observances when DST is used
assertEquals(6, tzVienna.observances.size)
// By default, the earliest observance is in 1893. We can drop that for events in 2020.
assertEquals(DateTime("18930401T000000"), tzVienna.observances.sortedBy { it.startDate.date }.first().startDate.date)
ICalendar.minifyVTimeZone(tzVienna, Date("20200101")).let { minified ->
assertEquals(2, minified.observances.size)
// now earliest observance for DAYLIGHT/STANDARD is 1981/1996
assertEquals(DateTime("19810329T020000"), minified.observances[0].startDate.date)
assertEquals(DateTime("19961027T030000"), minified.observances[1].startDate.date)
}
// test: remove obsolete observances when DST is not used
ICalendar.minifyVTimeZone(tzMogadishu, Date("19611001")).let { minified ->
assertEquals(1, minified.observances.size)
}
// test: keep future observances
ICalendar.minifyVTimeZone(tzVienna, Date("19751001")).let { minified ->
assertEquals(4, minified.observances.size)
assertEquals(DateTime("19160430T230000"), minified.observances[2].startDate.date)
assertEquals(DateTime("19161001T010000"), minified.observances[3].startDate.date)
}
ICalendar.minifyVTimeZone(tzKarachi, Date("19611001")).let { minified ->
assertEquals(4, minified.observances.size)
}
ICalendar.minifyVTimeZone(tzKarachi, Date("19751001")).let { minified ->
assertEquals(3, minified.observances.size)
}
ICalendar.minifyVTimeZone(tzMogadishu, Date("19311001")).let { minified ->
assertEquals(3, minified.observances.size)
}
// test: keep DST when there are no obsolete observances, but start time is in DST
ICalendar.minifyVTimeZone(tzKarachi, Date("20091031")).let { minified ->
assertEquals(2, minified.observances.size)
}
// test: remove obsolete observances (including DST) when DST is not used anymore
ICalendar.minifyVTimeZone(tzKarachi, Date("201001001")).let { minified ->
assertEquals(1, minified.observances.size)
}
}
@Test
fun testTimezoneDefToTzId() {
// test valid definition
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment