package plume;
import net.fortuna.ical4j.model.*;
import net.fortuna.ical4j.model.component.*;
import net.fortuna.ical4j.model.parameter.*;
import net.fortuna.ical4j.model.property.*;
import net.fortuna.ical4j.data.*;
import java.io.*;
import java.net.URL;
import java.text.*;
import java.util.regex.*;
// Can't "import java.util.*;" because of Date, etc.
import java.util.Iterator;
import java.util.List;
import java.util.ArrayList;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.Map;
import java.util.HashMap;
// If you are perplexed because of odd results, maybe it is because of the
// transparency of your iCal items (this shows up as "available/busy" in
// Google calendar).
// TODO: Fix "Problem: any all-day events will be treated as UTC." (see below)
/**
* Given one or more calendars in <a href="http://en.wikipedia.org/wiki/ICalendar">iCalendar format</a>, produces a textual summary
* of available times.
* This is useful for sending someone a list of acceptable times for a meeting.
* Also see the <tt>ical-available</tt> Emacs function, which inserts the
* output of this program.
*
* The command-line options are as follows:
* <!-- start options doc (DO NOT EDIT BY HAND) -->
* <ul>
* <li><b>--date=</b><i>string</i>. first date to summarize [default today]</li>
* <li><b>--days=</b><i>int</i>. number of calendar days to summarize [default 8]</li>
* <li><b>--iCal-URL=</b><i>url</i> <tt>[+]</tt>. For a Google calendar: go to settings, then click on the green "ICAL"
* icon for the "private address".</li>
* <li><b>--business-hours=</b><i>string</i>. A list of time ranges, expressed as a String.
* Example: 9am-5pm,7:30pm-9:30pm [default 9am-5pm]</li>
* <li><b>--timezone1=</b><i>timezone</i>. Time zone as an Olson timezone ID, e.g.: America/New_York.
* Available times are printed in this time zone. It defaults to the
* system time zone.</li>
* <li><b>--timezone2=</b><i>timezone</i>. Time zone as an Olson timezone ID, e.g.: America/New_York.
* If set, then free times are printed in two time zones.</li>
* <li><b>--debug=</b><i>boolean</i>. enable debugging output [default false]</li>
* </ul>
* <tt>[+]</tt> marked option can be specified multiple times
* <!-- end options doc -->
**/
public class ICalAvailable {
/// User options
@Option("first date to summarize")
public static String date = "today";
public static DateTime start_date = new DateTime();
@Option("number of calendar days to summarize")
public static int days = 8;
/**
* For a Google calendar: go to settings, then click on the green "ICAL"
* icon for the "private address".
*/
@Option("<url> schedule in iCal format")
public static List<String> iCal_URL = new ArrayList<String>();
/**
* A list of time ranges, expressed as a String.
* Example: 9am-5pm,7:30pm-9:30pm */
@Option("time ranges during which appointments are permitted")
public static String business_hours = "9am-5pm";
static List<Period> businessHours; // initialize to 9am-5pm
static List<Integer> businessDays; // initialize to Mon-Fri
static TimeZoneRegistry tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry();
/**
* Time zone as an Olson timezone ID, e.g.: America/New_York.
* Available times are printed in this time zone. It defaults to the
* system time zone.
**/
// don't need "e.g.: America/New_York" in message: the default is an example
@Option(value="<timezone> time zone, e.g.: America/New_York", noDocDefault=true)
public static String timezone1 = TimeZone.getDefault().getID();
static TimeZone tz1;
// If I'm outputting in a different timezone, then my notion of a "day"
// may be different than the other timezone's notion of a "day". This
// doesn't seem important enough to fix right now.
/**
* Time zone as an Olson timezone ID, e.g.: America/New_York.
* If set, then free times are printed in two time zones. */
@Option("<timezone> optional second time zone, e.g.: America/New_York")
public static /*@Nullable*/ String timezone2;
static /*@Nullable*/ TimeZone tz2;
/// Other variables
@Option("enable debugging output")
public static boolean debug = false;
/** The appointments (the times that are unavailable for meeting) */
static List<Calendar> calendars = new ArrayList<Calendar>();
static DateFormat tf = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.US);
static DateFormat df = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.US);
static DateFormat dffull = DateFormat.getDateInstance(DateFormat.FULL, Locale.US);
/// Procedures
@SuppressWarnings("deprecation") // for iCal4j
static void processOptions(String[] args) {
Options options = new Options ("ICalAvailable [options]", ICalAvailable.class);
String[] remaining_args = options.parse_or_usage (args);
if (remaining_args.length != 0) {
System.err.println("Unrecognized arguments: " + remaining_args);
System.exit(1);
}
if (iCal_URL.isEmpty()) {
System.err.println("Option iCal_URL must be specified.");
System.exit(1);
}
// Convert Strings to TimeZones
tz1 = tzRegistry.getTimeZone(canonicalizeTimezone(timezone1));
assert tz1 != null;
if (tz1 == null) {
throw new Error("didn't find timezone " + timezone1);
}
if (timezone2 != null) {
tz2 = tzRegistry.getTimeZone(canonicalizeTimezone(timezone2));
if (tz2 == null) {
System.err.println("Unrecognized time zone (see http://www.php.net/manual/en/timezones.php): " + timezone2);
System.exit(1);
}
}
try {
if (! date.equals("today")) {
start_date = new DateTime(parseDate(date));
}
} catch (Exception e) {
if (Pattern.matches(".*/.*", date)
&& ! Pattern.matches(".*/.*/", date)) {
System.err.println("Could not parse date (missing year?): " + date);
System.exit(1);
} else {
System.err.println("Could not parse date: " + date);
System.exit(1);
}
}
if (start_date == null) {
System.err.println("Could not parse date: " + date);
System.exit(1);
}
// Problem: this may change the actual time?
start_date.setTimeZone(tz1);
start_date.setMinutes((start_date.getMinutes() / 15) * 15);
for (String URL : iCal_URL) {
try {
URL url = new URL(URL);
CalendarBuilder builder = new CalendarBuilder();
Calendar c = builder.build(url.openStream());
calendars.add(c);
} catch (Exception e) {
e.printStackTrace(System.err);
System.err.println("Could not read calendar from " + URL);
System.exit(1);
}
}
businessHours = new ArrayList<Period>();
for (String range : business_hours.split(",")) {
String[] startEnd = range.split("-");
if (startEnd.length != 2) {
System.err.println("Bad time range: " + range);
System.exit(1);
}
DateTime busStart = parseTime(startEnd[0]);
DateTime busEnd = parseTime(startEnd[1]);
businessHours.add(new Period(busStart, busEnd));
}
businessDays = new ArrayList<Integer>();
businessDays.add(1);
businessDays.add(2);
businessDays.add(3);
businessDays.add(4);
businessDays.add(5);
}
static Map<String,String> canonicalTimezones = new HashMap<String,String>();
static Map<String,String> printedTimezones = new HashMap<String,String>();
// Yuck, this should really be a separate configuration file.
static {
canonicalTimezones.put("eastern", "America/New_York");
canonicalTimezones.put("est", "America/New_York");
canonicalTimezones.put("edt", "America/New_York");
canonicalTimezones.put("boston", "America/New_York");
canonicalTimezones.put("america/boston", "America/New_York");
canonicalTimezones.put("central", "America/Chicago");
canonicalTimezones.put("pacific", "America/Los_Angeles");
canonicalTimezones.put("pst", "America/Los_Angeles");
canonicalTimezones.put("pacific standard time", "America/Los_Angeles");
canonicalTimezones.put("pdt", "America/Los_Angeles");
canonicalTimezones.put("india", "Asia/Calcutta");
canonicalTimezones.put("china", "Asia/Shanghai");
canonicalTimezones.put("berlin", "Europe/Berlin");
canonicalTimezones.put("israel", "Asia/Tel_Aviv");
printedTimezones.put("Eastern Standard Time", "Eastern");
printedTimezones.put("Central Standard Time", "Central");
printedTimezones.put("Pacific Standard Time", "Pacific");
}
static String canonicalizeTimezone(String timezone) {
String result = canonicalTimezones.get(timezone.toLowerCase());
return (result == null) ? timezone : result;
}
/*@Pure*/
static String printedTimezone(TimeZone tz) {
String tzString = tz.getDisplayName();
String result = printedTimezones.get(tzString);
return (result == null) ? tzString : result;
}
static Pattern timeRegexp = Pattern.compile("([0-2]?[0-9])(:([0-5][0-9]))?([aApP][mM])?");
// Parse a time like "9:30pm"
@SuppressWarnings("deprecation") // for iCal4j
static DateTime parseTime(String time) {
Matcher m = timeRegexp.matcher(time);
if (! m.matches()) {
System.err.println("Bad time: " + time);
System.exit(1);
}
@SuppressWarnings("nullness") // group 1 always exists in regexp
/*@NonNull*/ String hourString = m.group(1);
String minuteString = m.group(3);
String ampmString = m.group(4);
int hour = Integer.parseInt(hourString);
if ((ampmString != null) && ampmString.toLowerCase().equals("pm")) {
hour += 12;
}
int minute = 0;
if (minuteString != null) {
minute = Integer.parseInt(minuteString);
}
DateTime result = new DateTime();
result.setTimeZone(tz1);
result.setHours(hour);
result.setMinutes(minute);
result.setSeconds(0);
return result;
}
// For debugging
static void printOptions() {
System.out.println("business_hours: " + business_hours);
System.out.println("businessHours: " + businessHours);
System.out.println("businessDays: " + businessDays);
System.out.println("timezone1: " + timezone1);
System.out.println("timezone2: " + timezone2);
System.out.println("start_date: " + start_date);
System.out.println("days: " + days);
System.out.println("iCal_URL: " + iCal_URL);
}
public static void main(String[] args) {
processOptions(args);
List<Period> available = new ArrayList<Period>();
if (debug) {
System.err.printf("Testing %d days%n", days);
}
for (int i = 0; i<days; i++) {
available.addAll(oneDayAvailable(start_date, calendars));
start_date = new DateTime(start_date.getTime() + 1000 * 60 * 60 * 24);
start_date.setTimeZone(tz1);
}
if (tz2 != null) {
System.out.printf("Timezone: %s [Timezone: %s]%n",
printedTimezone(tz1), printedTimezone(tz2));
}
String lastDateString = null;
for (Period p : available) {
String dateString = formatDate(p.getStart(), tz1);
if (! dateString.equals(lastDateString)) {
lastDateString = dateString;
System.out.println();
System.out.println(dateString + ":");
}
String rangeString = rangeString(p, tz1);
if (tz2 == null) {
System.out.println(rangeString);
} else {
String rangeString2 = rangeString(p, tz2);
System.out.printf("%-20s[%s]%n", rangeString, rangeString2);
}
}
}
static String rangeString(Period p, TimeZone tz) {
tf.setTimeZone(tz);
DateTime pstart = p.getStart();
DateTime pend = p.getEnd();
String rangeString = tf.format(pstart) + " to " + tf.format(pend);
rangeString = rangeString.replace(" AM", "am");
rangeString = rangeString.replace(" PM", "pm");
return rangeString;
}
static String periodListString(PeriodList pl, TimeZone tz) {
tf.setTimeZone(tz);
StringBuilder result = new StringBuilder();
for (Object p : pl) {
assert p != null : "@SuppressWarnings(nullness): non-generic container class; elements are non-null";
result.append(rangeString((/*@NonNull*/ Period)p, tz) + "\n");
}
return result.toString();
}
/**
* Creates a new DateTime with date taken from the first argument and
* time taken from the second argument.
**/
@SuppressWarnings("deprecation") // for iCal4j
static DateTime mergeDateAndTime(DateTime date, DateTime time) {
if (! date.getTimeZone().equals(time.getTimeZone())) {
throw new Error(String.format("non-matching timezones: %s %s", date.getTimeZone(), time.getTimeZone()));
}
DateTime result = new DateTime(date);
result.setHours(time.getHours());
result.setMinutes(time.getMinutes());
result.setSeconds(time.getSeconds());
return result;
}
// TODO: don't propose times that are before the current moment.
// Process day-by-day because otherwise weekends and evenings are included.
@SuppressWarnings("unchecked") // for iCal4j
static List<Period> oneDayAvailable(DateTime day, List<Calendar> calendars) {
if (debug) {
System.err.printf("oneDayAvailable(%s, ...)%n", day);
}
List<Period> result = new ArrayList<Period>();
@SuppressWarnings("deprecation") // for iCal4j
int dayOfWeek = day.getDay();
if (! businessDays.contains(dayOfWeek)) {
return result;
}
for (Period bh : businessHours) {
DateTime start = mergeDateAndTime(day, bh.getStart());
DateTime end = mergeDateAndTime(day, bh.getEnd());
VFreeBusy request = new VFreeBusy(start, end, new Dur(0, 0, 0, 1));
if (debug) {
System.out.println("Request = " + request);
}
ComponentList busyTimes = new ComponentList();
// Problem: any all-day events will be treated as UTC.
// Instead, they should be converted to local time (tz1).
// But VFreeBusy does not support this, so I may need to convert
// daily events into a different format before inserting them.
for (Calendar calendar : calendars) {
// getComponents() returns a raw ArrayList. Expose its element type.
ArrayList</*@NonNull*/ Component> clist = calendar.getComponents();
for (Component c : clist) {
if (c instanceof VEvent) {
VEvent v = (VEvent) c;
DtStart dts = v.getStartDate();
Parameter dtsValue = dts.getParameter("VALUE");
boolean allDay = (dtsValue != null) && dtsValue.getValue().equals("DATE");
// TODO: convert to the proper timezone.
// Tricky: must deal with the possibility of RRULE:FREQ=
}
busyTimes.add(c);
}
}
VFreeBusy response = new VFreeBusy(request, busyTimes);
if (debug) {
System.out.println("Response = " + response);
}
FreeBusy freefb = (FreeBusy) response.getProperty("FREEBUSY");
if (freefb == null) {
if (debug) {
System.out.println("FREEBUSY property is null");
}
continue;
}
@SuppressWarnings("interning")
boolean isFree = (freefb.getParameter(Parameter.FBTYPE) == FbType.FREE);
assert isFree;
PeriodList freePeriods = freefb.getPeriods();
if (debug) {
System.out.printf("Free periods: %n%s%n", periodListString(freePeriods, tz1));
}
result.addAll(freePeriods);
}
if (debug) {
System.err.printf("oneDayAvailable(%s, ...) => %s elements%n", day, result.size());
}
return result;
}
static SimpleDateFormat[] dateFormats
= { new SimpleDateFormat( "yyyy/MM/dd" ),
new SimpleDateFormat( "MM/dd/yyyy" ),
new SimpleDateFormat( "MM/dd/yy" ),
// Bad idea: sets year to 1970. So require the year, at least for now.
// new SimpleDateFormat( "MM/dd" ),
};
/**
* Parses a date when formatted in several common formats.
* @see dateFormats
**/
static java.util.Date parseDate( String strDate ) throws ParseException {
if (Pattern.matches("^[0-9][0-9]?/[0-9][0-9]?$", date)) {
@SuppressWarnings("deprecation") // for iCal4j
int year = new Date().getYear() + 1900;
strDate = strDate + "/" + year;
}
for (DateFormat this_df : dateFormats) {
this_df.setLenient(false);
try {
java.util.Date result = this_df.parse( strDate );
return result;
} catch ( ParseException e ) {
// Try the next format in the list.
}
}
throw new ParseException("bad date " + strDate, 0);
}
static String formatDate(DateTime d, TimeZone tz) {
df.setTimeZone(tz);
String result = df.format(d);
// Don't remove trailing year; it's a good double-check.
// Remove trailing year, such as ", 1952".
// result = result.substring(0, result.length() - 6);
// Prepend day of week.
result = dffull.format(d).substring(0,3) + " " + result;
return result;
}
}