Handling period durations of days with `T` prefix
ArnyminerZ opened this issue · 12 comments
There has been detected some instances where the ICS file has an invalid duration, for example this trigger:
TRIGGER:-P2DT
Trying to parse it in Kotlin Playground shows that is not a valid duration, which is what ICSx5 complains about when trying to load those kind of URLs.
The Java Documentation is not very clear about the use of the T
prefix. However, this StackOverflow post doesn't mention D
as a valid unit for periods with the T
prefix, which this is the case. To be clearer:
TRIGGER:-PT2H <- VALID
TRIGGER:-PT2D <- INVALID
This must be fixed by ical4j, but a possible fix is to find matches of [-]?PT\d*D
, and replace them by the corresponding duration in hours, for example, so this conversion should be made:
TRIGGER:-PT2D
<>
TRIGGER:-PT48H
(For internal reference)
The ICS file where this popped up was: https://ics.ecal.com/ecal-sub/63b0e388811993000d5f2064/Collingwood%20Magpies%20Football%20Club.ics
Zammad ticket: #234
(Ok please forget that… guess my brain was somewhere else, have to check again.)
A fix can be to take the same approach as with fixInvalidUtcOffset
. This code fixes the offset in days, and converts it to hours (you can take a look at the Kotlin Playground):
import java.time.Duration;
val INVALID_DAY_PERIOD_REGEX = Regex("-?PT-?\\d+D", setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
fun fixStringFromReader(original: String): String {
// Convert the reader to a string
var str = original
val found = INVALID_DAY_PERIOD_REGEX.findAll(str)
for (match in found) {
val range = match.range
val start = range.first
val end = range.last
val numPos = str.indexOf("PT", start) + 2
val number = str.substring(numPos, end).toLongOrNull()
if (number != null) {
val newPiece = str.substring(start, numPos) + (number*24) + "H"
str = str.replaceRange(IntRange(start, end), newPiece)
}
}
return str
}
fun main() {
val original = "-PT2D"
println("Original: $original")
try {
Duration.parse(original)
println("Duration was ok")
} catch (_: Exception) {
println("Original duration is not valid")
}
val fixed = fixStringFromReader(original)
println("Fixed: $fixed")
try {
Duration.parse(fixed)
println("New Duration is ok")
} catch (_: Exception) {
println("Could not fix duration")
}
}
Then the proper function for ICalPreprocessor
:
/**
* Fixes durations with day offsets with the 'T' prefix.
* @param reader Reader that reads the potentially broken iCalendar
* @see <a href="https://github.com/bitfireAT/ical4android/issues/77">GitHub</a>
*/
fun fixInvalidDayOffset(reader: Reader): Reader {
fun fixStringFromReader(): String {
// Convert the reader to a string
var str = IOUtils.toString(reader)
val found = INVALID_DAY_PERIOD_REGEX.findAll(str)
// Find all matches for the expression
for (match in found) {
// Get the range of the match
val range = match.range
// Get the start position of the match
val start = range.first
// And the end position
val end = range.last
// Get the position of the number inside str (without the prefix)
val numPos = str.indexOf("PT", start) + 2
// And get the number, converting it to long
val number = str.substring(numPos, end).toLongOrNull()
// If the number has been converted to long correctly
if (number != null) {
// Build a new string with the prefix given, and the number converted to hours
val newPiece = str.substring(start, numPos) + (number*24) + "H"
// Replace the range found with the new piece
str = str.replaceRange(IntRange(start, end), newPiece)
}
}
return str
}
var result: String? = null
val resetSupported = try {
reader.reset()
true
} catch (e: IOException) {
false
}
if (resetSupported) {
// reset is supported, no need to copy the whole stream to another String (unless we have to fix the period)
val horizonFind = Scanner(reader)
.findWithinHorizon(INVALID_DAY_PERIOD_REGEX.toPattern(), 0)
if (horizonFind != null) {
reader.reset()
result = fixStringFromReader()
}
} else
// If reset is not supported, always try to fix the issue by copying the string
result = fixStringFromReader()
if (result != null)
return StringReader(result)
// not modified, return original iCalendar
reader.reset()
return reader
}
This does the trick, but also means more overload when parsing long icals... What do you think @rfc2822 ? Should we implement this?
Edit: I was thinking that maybe we could only target the tags that use Java Duration
s, otherwise unexpected replacements may be made, in the description, for example, or something.
Edit 2: If I'm not wrong, the only ical elements that make use of the Duration type are DURATION
and TRIGGER
My bad, this is something for ical4android. Maybe we should move this there?
@devvv4ever The invalid iCalendar seems to be generated by "E-DIARY 1.0". I couldn't find such a product… can you please ask the customer which software was used? So that it can be fixed at the source, regardless of whether/how we work around it.
@ArnyminerZ Thanks, I'll have a look at at it. It's however complicated code just to support an invalid iCalendar, I don't know whether it's worth to lug along the maintenance work for this special case…
Yup, it's quite a lot of code for such an specific case...
But looks fine, I think it's a good approach and we should do it. I'll review soon :)
I just noted that the defect line is
TRIGGER:-P2DT
but the fix is for
TRIGGER:-PT2D
isn't it?
So as I understand it, the fix wouldn't allow to process the original file.
I've fixed that, and improved the tests so we are sure that the strings are parseable to Duration
. See #80
I can confirm the issue is still there, but not sure why. I've tried copying the pre-processor code into a Kotlin snippet:
fun regexpForProblem() = Regex(
// Examples:
// TRIGGER:-P2DT
// TRIGGER:-PT2D
"^(DURATION|TRIGGER):-?P((T-?\\d+D)|(-?\\d+DT))\$",
setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)
)
fun fixString(original: String): String {
var s: String = original
// Find all matches for the expression
val found = regexpForProblem().find(s) ?: return s
for (match in found.groupValues) {
val fixed = match
.replace("PT", "P")
.replace("DT", "D")
s = s.replace(match, fixed)
}
return s
}
fun main() {
val cases = listOf("TRIGGER:-P2DT", "TRIGGER:-PT15M", "TRIGGER:-P5DT")
for (str in cases) {
val fixed = fixString(str).let { it.substring(it.indexOf(':') + 1) }
println(fixed)
println(java.time.Duration.parse(fixed))
}
}
The cases provided have been taken from the url shared by @devvv4ever (https://ics.ecal.com/ecal-sub/63b0e388811993000d5f2064/Collingwood%20Magpies%20Football%20Club.ics).
When running in ICSx⁵, the error is:
Couldn't validate calendar
at.bitfire.ical4android.InvalidCalendarException: Couldn't parse iCalendar
at at.bitfire.ical4android.ICalendar$Companion.fromReader(ICalendar.kt:91)
at at.bitfire.ical4android.Event$Companion.eventsFromReader(Event.kt:79)
at at.bitfire.icsdroid.ui.AddCalendarValidationFragment$ValidationModel$validate$downloader$1.onSuccess(AddCalendarValidationFragment.kt:126)
at at.bitfire.icsdroid.CalendarFetcher.fetchNetwork$icsx5_72_2_1_1_gplayDebug(CalendarFetcher.kt:157)
at at.bitfire.icsdroid.CalendarFetcher$fetchNetwork$1.invokeSuspend(Unknown Source:14)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
Caused by: net.fortuna.ical4j.data.ParserException: Error at line 63:Text cannot be parsed to a Duration
at net.fortuna.ical4j.data.CalendarParserImpl.parse(CalendarParserImpl.java:172)
at net.fortuna.ical4j.data.CalendarBuilder.build(CalendarBuilder.java:197)
at net.fortuna.ical4j.data.CalendarBuilder.build(CalendarBuilder.java:185)
at at.bitfire.ical4android.ICalendar$Companion.fromReader(ICalendar.kt:89)
at at.bitfire.ical4android.Event$Companion.eventsFromReader(Event.kt:79)
at at.bitfire.icsdroid.ui.AddCalendarValidationFragment$ValidationModel$validate$downloader$1.onSuccess(AddCalendarValidationFragment.kt:126)
at at.bitfire.icsdroid.CalendarFetcher.fetchNetwork$icsx5_72_2_1_1_gplayDebug(CalendarFetcher.kt:157)
at at.bitfire.icsdroid.CalendarFetcher$fetchNetwork$1.invokeSuspend(Unknown Source:14)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
Caused by: j$.time.format.DateTimeParseException: Text cannot be parsed to a Duration
at j$.time.Duration.parse(Duration.java:417)
at net.fortuna.ical4j.model.TemporalAmountAdapter.parse(TemporalAmountAdapter.java:145)
at net.fortuna.ical4j.model.TemporalAmountAdapter.parse(TemporalAmountAdapter.java:132)
at net.fortuna.ical4j.model.property.Trigger.setValue(Trigger.java:253)
at net.fortuna.ical4j.model.property.Trigger.<init>(Trigger.java:166)
at net.fortuna.ical4j.model.property.Trigger$Factory.createProperty(Trigger.java:301)
at net.fortuna.ical4j.model.property.Trigger$Factory.createProperty(Trigger.java:291)
at net.fortuna.ical4j.model.PropertyBuilder.build(PropertyBuilder.java:76)
at net.fortuna.ical4j.data.DefaultContentHandler.endProperty(DefaultContentHandler.java:159)
at net.fortuna.ical4j.data.CalendarParserImpl$PropertyParser.parse(CalendarParserImpl.java:309)
at net.fortuna.ical4j.data.CalendarParserImpl$PropertyParser.access$1100(CalendarParserImpl.java:241)
at net.fortuna.ical4j.data.CalendarParserImpl$PropertyListParser.parse(CalendarParserImpl.java:226)
at net.fortuna.ical4j.data.CalendarParserImpl$ComponentParser.parse(CalendarParserImpl.java:446)
at net.fortuna.ical4j.data.CalendarParserImpl$ComponentParser.access$900(CalendarParserImpl.java:422)
at net.fortuna.ical4j.data.CalendarParserImpl$PropertyListParser.parse(CalendarParserImpl.java:224)
at net.fortuna.ical4j.data.CalendarParserImpl$ComponentParser.parse(CalendarParserImpl.java:446)
at net.fortuna.ical4j.data.CalendarParserImpl$ComponentParser.access$900(CalendarParserImpl.java:422)
at net.fortuna.ical4j.data.CalendarParserImpl$PropertyListParser.parse(CalendarParserImpl.java:224)
at net.fortuna.ical4j.data.CalendarParserImpl.parseCalendar(CalendarParserImpl.java:128)
at net.fortuna.ical4j.data.CalendarParserImpl.parseCalendarList(CalendarParserImpl.java:194)
at net.fortuna.ical4j.data.CalendarParserImpl.parse(CalendarParserImpl.java:163)
at net.fortuna.ical4j.data.CalendarBuilder.build(CalendarBuilder.java:197)
at net.fortuna.ical4j.data.CalendarBuilder.build(CalendarBuilder.java:185)
at at.bitfire.ical4android.ICalendar$Companion.fromReader(ICalendar.kt:89)
at at.bitfire.ical4android.Event$Companion.eventsFromReader(Event.kt:79)
at at.bitfire.icsdroid.ui.AddCalendarValidationFragment$ValidationModel$validate$downloader$1.onSuccess(AddCalendarValidationFragment.kt:126)
at at.bitfire.icsdroid.CalendarFetcher.fetchNetwork$icsx5_72_2_1_1_gplayDebug(CalendarFetcher.kt:157)
at at.bitfire.icsdroid.CalendarFetcher$fetchNetwork$1.invokeSuspend(Unknown Source:14)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
Which shows the error at line 63, which is TRIGGER:-P5DT
, that is parsing correctly at the test snippet.
I've printed the reader that is being passed for parsing the calendar:
BEGIN:VCALENDAR
PRODID:-//E-DIARY//E-DIARY 1.0//EN
VERSION:2.0
METHOD:PUBLISH
X-WR-CALNAME:Collingwood Magpies Football Club
X-Built-On-Cache-Miss:true
BEGIN:VEVENT
LAST-MODIFIED:20230223T030355Z
DTSTAMP:20230223T030355Z
LOCATION:MCG
X-ECAL-SCHEDULE:508efe5dfb0615bb30000001
DTSTART:20230317T084000Z
DTEND:20230317T114000Z
SUMMARY:Round 1: Geelong Cats V Collingwood
TRANSP:TRANSPARENT
SEQUENCE:0
UID:63945a154c410f17fb7528a7
PRIORITY:5
X-MICROSOFT-CDO-IMPORTANCE:1
CLASS:PUBLIC
DESCRIPTION:Round 1 | Watch live on Kayo | Join the conversation @Collingwo
odFC #GoPies \n\nCan't get the game? Follow the action live at the Colling
wood Match Centre\n\nMatch Centre\nhttps://ecal.ai/f/jS6Wx/TS6N\n\nBuy Tic
kets\nhttps://ecal.ai/f/kcCl6/TS6N\n\nVisit our Superstore\nhttps://ecal.a
i/f/jS6Wy/TS6N\n\nDownload our Official Club App\nhttps://ecal.ai/f/jS6Wz/
TS6N\n\nFacebook\nhttps://ecal.ai/f/jS6WB/TS6N\n\nTwitter\nhttps://ecal.ai
/f/jS6WC/TS6N\n\nYouTube\nhttps://ecal.ai/f/jS6WD/TS6N\n\nInstagram\nhttps
://ecal.ai/f/jS6WF/TS6N\n\nManage my ECAL\nhttps://support.ecal.com
BEGIN:VALARM
TRIGGER:-P2D
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
BEGIN:VALARM
TRIGGER:-PT15M
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
END:VEVENT
BEGIN:VEVENT
LAST-MODIFIED:20230223T030355Z
DTSTAMP:20230223T030355Z
LOCATION:MCG
X-ECAL-SCHEDULE:508efe5dfb0615bb30000001
DTSTART:20230325T024500Z
DTEND:20230325T054500Z
SUMMARY:Round 2: Collingwood V Port Adelaide
TRANSP:TRANSPARENT
SEQUENCE:0
UID:63945a154c410f17fb7528a8
PRIORITY:5
X-MICROSOFT-CDO-IMPORTANCE:1
CLASS:PUBLIC
DESCRIPTION:Round 2 | Watch live on Kayo | Join the conversation @Collingwo
odFC #GoPies\n\nGet to the game! Purchase your tickets now!\n\nBuy Tickets
\nhttps://ecal.ai/f/kcCl7/TS6N\n\nJoin the Magpie Army\nhttps://ecal.ai/f/
jS6WG/TS6N\n\nVisit our Superstore\nhttps://ecal.ai/f/jS6WH/TS6N\n\nDownlo
ad our Official Club App\nhttps://ecal.ai/f/jS6WJ/TS6N\n\nFacebook\nhttps:
//ecal.ai/f/jS6WK/TS6N\n\nTwitter\nhttps://ecal.ai/f/jS6WL/TS6N\n\nYouTube
\nhttps://ecal.ai/f/jS6WM/TS6N\n\nInstagram\nhttps://ecal.ai/f/jS6WN/TS6N\
n\nManage my ECAL\nhttps://support.ecal.com
BEGIN:VALARM
TRIGGER:-P5DT
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
BEGIN:VALARM
TRIGGER:-P2D
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
END:VEVENT
Line 63 is: TRIGGER:-P5DT
, which is in fact a non-valid duration. Therefore ICalPreprocessor
is not working correctly.