Transforming iCal Calendars with Java
Pages: 1, 2, 3
Reading your calendar from disk
If you are comfortable reading files from disk, you can skip this section.
The class ICalendarToString is fairly lightweight. Here's
what it looks like to an outsider.
package ical;
public class ICalendarToString {
public static String getCalendarAsString(String calendarName)
throws CalendarNotFoundException {}
}
It has a single static method, getCalendarAsString, that
takes the name of the calendar as a String and returns the
contents of the file in a String. You invoke it like
this:
ICalendarToString.getCalendarAsString("example");
In the code listing below you can see the details. The body of the
getICalendarAsString() method shows that three private
methods are called. You can put everything into a single method, but that
makes your code harder to understand. Note that when you read the body of
this method, you get a high level view of what this class does. It locates
the calendar file using the name it received, sets up a file reader that
is used to read the contents of the file, and returns a representation of
that calendar as a String. The method also throws a
CalendarNotFoundException. It is important to use exceptions
that help the developer know what has gone wrong in using your class.
package ical;
import java.io.FileNotFoundException;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
public class ICalendarToString {
private static final String CALENDAR_HOME = "Calendar/";
private static File inputFile;
private static FileReader calendarReader;
public static String getCalendarAsString(String calendarName)
throws CalendarNotFoundException {
findCalendarFile(calendarName);
setUpFileReader();
return representCalendarAsString();
}
private static void findCalendarFile(String calendarName)
throws CalendarNotFoundException {
inputFile = new File(CALENDAR_HOME + calendarName + ".ics");
if (!inputFile.exists()) throw new CalendarNotFoundException(
"There is no file with the name " + inputFile.getAbsolutePath());
}
private static void setUpFileReader() throws CalendarNotFoundException {
try {
calendarReader = new FileReader(inputFile);
} catch (FileNotFoundException e) {
throw new CalendarNotFoundException(e.getMessage(), e.getCause());
}
}
private static String representCalendarAsString()
throws CalendarNotFoundException {
try {
char[] contentCharacterArray = new char[(int) inputFile.length()];
calendarReader.read(contentCharacterArray);
calendarReader.close();
return new String(contentCharacterArray);
} catch (IOException e) {
throw new CalendarNotFoundException(e.getMessage(), e.getCause());
}
}
}
The awkward part of the code is found in the
representCalendarAsString() method. You create a character
array with enough slots to read in the contents of the file. You then use
the calendarReader to read the file into the array,
remembering to close the reader, and finally construct a
String from this array.
The findCalendarFile method tries to find the calendar
file in the directory that holds your calendars (you need to alter this to
match your setup) and appends ".ics" to the end of the name entered. It
may not be clear whether the user is to enter the calendar name as
"example" or "example.ics".
If I were writing this application for distribution I would probably
choose a strategy that took the complete file name as input and didn't add
the suffix. In any case, you can see that the
CalendarNotFoundException is thrown with a message that
details the presumed location of the file. A developer that gets an
exception, that "example.ics.ics" was not found, can quickly see that the
calendar should be passed in as "example". In the other two methods, the
exceptions are constructed to pass on the message and cause from the
exception that triggered the condition. This uses another new facility
from Java 1.4, chained exceptions. Here's the
CalendarNotFoundException class.
package ical;
public class CalendarNotFoundException extends Exception {
public CalendarNotFoundException(String description) {
super(description);
}
public CalendarNotFoundException(String description, Throwable cause) {
super(description, cause);
}
}
Next stop is manipulating the calendar using regular expressions.
Much ToDo about Nothing
The idea is to use regular expressions to identify a todo item from our calendar and then replace the todo so that it can be recognized as an event. We could look at this all at once and use this as the search string.
BEGIN:VTODO(.*?)SUMMARY:(.*?)DTSTART(.*?)\n(.*?)END:VTODO
You could then use this as the replacement string.
BEGIN:VEVENT$1SUMMARY:TODO $2DTSTART;VALUE=DATE:20030520\nDURATION:P1D\n$4END:VEVENT
If you are comfortable with these type of expressions, then the code presented in this section will point you at how to accomplish this using the Java APIs. Our actual code may appear overly verbose to those already comfortable with regexen. The JavaDocs for these two classes contain a lot of information, but if you are going to end up doing a lot of regex work, you should pick up a copy of Jeffrey Friedl's book, Mastering Regular Expressions, 2nd Edition.
Consider this first pass at the code.
public static String convert( String contents){
Matcher todoMatcher = getTodoMatcher(contents);
return todoMatcher.replaceAll(setTodoRegEx());
}
private static Matcher getTodoMatcher(String contents){
Pattern todoPattern = Pattern.compile(getTodoRegEx(),Pattern.DOTALL);
return todoPattern.matcher(contents);
}
private static String getTodoRegEx(){
return "BEGIN:VTODO(.*?)END:VTODO";
}
private static String setTodoRegEx(){
return "BEGIN:VEVENT$1END:VEVENT";
}
The two classes used for regular expressions are in the
java.util package. You create the patterns to match on using
the Pattern class and do most of the matching and replacement
work with the Matcher class. In the
getTodoMatcher() method you create an instance of
Matcher using the compile static method from the
Pattern class. Then in the convert() method this
matcher is used to replace all of the found matches with the replacement
expression.
The expression in the getTodoRegEx() method will match a
string that begins with BEGIN:VTODO and ends with
END:VTODO. In between the dot will match almost any character
and the star means match as many of those characters as you
can. Ordinarily this would only apply to characters on the same line so
this pattern would look for a string with that pattern that is contained
in a single line. To enable the search to take place in multiple lines we
have set the Pattern,DOTALL flag in the second parameter of
the call to thecompile() method. This flag specifies that the
dot should match any character including new line characters. Now the
pattern can search across multiple lines.
That explains the dot and the star and the interpretation of the dot in
the portion of the expression given by (.*?). If we didn't
include the question mark we would be performing a greedy search. The
effect would be that we would locate a string containing the first
occurrence of BEGIN:VTODO and the last occurrence of
END:VTODO. If you look back at our sample calendar, this
would capture both todos in a single string. Worse, if there was an event
between them it would capture that as well. To search for the first
occurrence of the END:VTODO, we insert the question mark
after the star. That leaves the parentheses. If we later want to refer to
a bit of the found string we enclose what we want to refer to in
parentheses. Now we can refer back to it. In this case the replacement
string BEGIN:VEVENT$1END:VEVENT results in the
following:
- Replace
BEGIN:VTODOwithBEGIN:VEVENT - Replace
END:VTODOwithEND:VEVENT - Everything identified by
(.*?)stays where it was because it is referred to by$1
The first note is that you don't really get an event by switching the todos to events. We'll need to change the insides as well. We need to set the date of the newly created event and a duration. We'll set the date as today and the duration as a full day. For the most part you could use the search and replacement strings given at the top of this section although the date is hardcoded there. I find the following version easier to read and more modifiable and maintainable.
package ical;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
import java.util.Calendar;
public class ICalendarTodoConverter {
private static final String SAVED_PORTION = "(.*?)";
public static String convert(String contents) {
Matcher todoMatcher = getTodoMatcher(contents);
return todoMatcher.replaceAll(setTodoRegEx());
}
private static Matcher getTodoMatcher(String contents) {
Pattern todoPattern = Pattern.compile(getTodoRegEx(), Pattern.DOTALL);
return todoPattern.matcher(contents);
}
private static String getTodoRegEx() {
return "BEGIN:VTODO" + getTodoContentsRegEx() + "END:VTODO";
}
private static String setTodoRegEx() {
return "BEGIN:VEVENT" + setTodoContentsRegEx() + "END:VEVENT";
}
private static String getTodoContentsRegEx() {
return SAVED_PORTION // $1
+ getSummaryRegEx()
+ getDateRegEx()
+ SAVED_PORTION; //$4
}
private static String setTodoContentsRegEx() {
return "$1"
+ setSummaryRegEx()
+ setDateRegEx()
+ "$4";
}
private static String getSummaryRegEx() {
return "SUMMARY:"
+ SAVED_PORTION; // $2
}
private static String setSummaryRegEx() {
return "SUMMARY:TODO "
+ "$2";
}
private static String getDateRegEx() {
return "DTSTART"
+ SAVED_PORTION //$3
+ "\n";
}
private static String setDateRegEx() {
return "DTSTART;VALUE=DATE:"
+ getCurrentDateAsString()
+ "\nDURATION:P1D\n";
}
private static String getCurrentDateAsString() {
Calendar now = Calendar.getInstance();
String rightNow = now.get(now.YEAR)
+ formatDateComponent(now.get(now.MONTH) + 1)
+ formatDateComponent(now.get(now.DAY_OF_MONTH));
return rightNow;
}
private static String formatDateComponent(int i) {
if (i < 10)
return "0" + i;
else
return "" + i;
}
}
The expression SAVED_PORTION is used for
(.*?). Also you can follow the getxxx() methods
to see how the calendar is pulled apart and compare to the
setxxx() methods to see how it will be reassembled. For
example you can see that we are inserting the words "TODO" as part of the
summary. In get/setSummaryRegEx() methods you can see that
the string SUMMARY: is going to be replaced by the string
SUMMARY: TODO . You can use the Calendar class
to format today's date but remember that the number corresponding to the
month starts with 0 instead of the expected value of 1. Also, you will
need to convert one digit months such as 5 for May to two digits 05 so
that iCal can correctly parse the date.



