org.gcaldaemon.core.GCalUtilitiesV3.java Source code

Java tutorial

Introduction

Here is the source code for org.gcaldaemon.core.GCalUtilitiesV3.java

Source

//
// GCALDaemon is an OS-independent Java program that offers two-way
// synchronization between Google Calendar and various iCalalendar (RFC 2445)
// compatible calendar applications (Sunbird, Rainlendar, iCal, Lightning, etc).
//
// Apache License
// Version 2.0, January 2004
// http://www.apache.org/licenses/
// 
// Project home:
// http://gcaldaemon.sourceforge.net
//
package org.gcaldaemon.core;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.TimeZone;

import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.component.Observance;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.component.VTimeZone;
import net.fortuna.ical4j.model.property.Clazz;
import net.fortuna.ical4j.model.property.Created;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.DtEnd;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.Priority;
import net.fortuna.ical4j.model.property.RecurrenceId;
import net.fortuna.ical4j.model.property.Status;
import net.fortuna.ical4j.model.property.Summary;
import net.fortuna.ical4j.model.property.Transp;
import net.fortuna.ical4j.model.property.TzId;
import net.fortuna.ical4j.model.property.TzOffsetTo;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Url;

import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.MultiThreadedHttpConnectionManager;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.params.HttpConnectionManagerParams;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.gcaldaemon.logger.QuickWriter;

import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.DateTime;
import com.google.api.client.util.store.FileDataStoreFactory;
import com.google.api.services.calendar.CalendarScopes;
import com.google.api.services.calendar.model.CalendarList;
import com.google.api.services.calendar.model.CalendarListEntry;
import com.google.api.services.calendar.model.Event;
import com.google.api.services.calendar.model.Event.ExtendedProperties;
import com.google.api.services.calendar.model.Event.Reminders;
import com.google.api.services.calendar.model.EventAttendee;
import com.google.api.services.calendar.model.EventDateTime;
import com.google.api.services.calendar.model.EventReminder;

/**
 * Google Calendar utilities.
 * 
 * <li>loadCalendar <li>updateEvents <li>removeEvents
 * 
 * Created: Jan 03, 2007 12:50:56 PM
 * 
 * @author Andras Berkes
 */
public final class GCalUtilitiesV3 {

    // --- CONSTANTS ---

    public static final String ERROR_MARKER = "gcaldaemon-error";

    private static final long GOOGLE_CONNECTION_TIMEOUT = 1000L * 60 * 5;
    private static final long GOOGLE_RETRY_MILLIS = 1000L;
    private static final int HTTP_CONNECTION_TIMEOUT = 10000;
    private static final int HTTP_WAIT_TIMEOUT = 60000;

    private static final int MAX_POOLED_CONNECTIONS = 100;

    private static final String GOOGLE_HTTPS_URL = "https://www.google.com";
    private static final String GOOGLE_HTTP_URL = "http://www.google.com";
    private static final String USER_AGENT = "Mozilla/5.0 (Windows; U;"
            + " Windows NT 5.1; hu; rv:1.8.0.8) Gecko/20061025 Thunderbird/1.5.0.8";
    private static final String UID_EXTENSION_NAME = "gcaldaemon-uid";
    private static final String CATEGORIES_EXTENSION_NAME = "gcaldaemon-categories";
    private static final String PRIORITY_EXTENSION_NAME = "gcaldaemon-priority";
    private static final String URL_EXTENSION_NAME = "gcaldaemon-url";

    private static final char[] CR_LF = "\r\n".toCharArray();
    private static final char[] ALARM_BEGIN = "\r\nBEGIN:VALARM\r\nTRIGGER;VALUE=DURATION:-P".toCharArray();
    private static final char[] ALARM_END = "\r\nACTION:AUDIO\r\nEND:VALARM\r\n".toCharArray();
    private static final char[] ALARM_MOZ_LASTACK = "\r\nX-MOZ-LASTACK:".toCharArray();
    private static final char[] ALARM_RAIN_LASTACK = "X-RAINLENDAR-LASTALARMACK:".toCharArray();

    private static final long LAST_ACK_TIMEOUT = 86400000L;

    private static final TimeZone UTC = TimeZone.getTimeZone("UTC");

    // --- LOGGER ---

    private static final Log log = LogFactory.getLog(GCalUtilitiesV3.class);

    // --- GLOBAL PROPERTIES ---

    private static boolean enableExtensions;
    private static boolean enableEmail;
    private static boolean enableSms;
    private static boolean enablePopup;

    // --- HTTP CONNECTION HANDLER ---

    private static final MultiThreadedHttpConnectionManager connectionManager = new MultiThreadedHttpConnectionManager();
    private static final HttpClient httpClient = new HttpClient(connectionManager);

    private static FileDataStoreFactory dataStoreFactory;
    private static HttpTransport httpTransport;
    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
    private static final java.io.File DATA_STORE_DIR = new java.io.File("store");
    private static final String APPLICATION_NAME = "gcaldaemon_v3";

    static {
        try {
            httpTransport = GoogleNetHttpTransport.newTrustedTransport();

            // initialize the data store factory
            dataStoreFactory = new FileDataStoreFactory(DATA_STORE_DIR);
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(-1);
        }
    }

    static final void globalInit() {
        try {

            // Set extended sync mode
            String value = System.getProperty("gcaldaemon.extended.sync", "false");
            enableExtensions = "true".equals(value);

            // Set enabled alarm types in the Google Calendar
            value = System.getProperty("gcaldaemon.remote.alarms", "email,sms,popup");
            boolean email = value.indexOf("mail") != -1;
            boolean sms = value.indexOf("sms") != -1;
            boolean popup = value.indexOf("pop") != -1;
            if (!email && !sms && !popup) {
                enableEmail = true;
                enableSms = true;
                enablePopup = true;
            } else {
                enableEmail = email;
                enableSms = sms;
                enablePopup = popup;
            }

            // Set proxy
            HttpConnectionManagerParams params = connectionManager.getParams();
            params.setConnectionTimeout(HTTP_CONNECTION_TIMEOUT);
            params.setSoTimeout(HTTP_WAIT_TIMEOUT);
            String proxyHost = System.getProperty("http.proxyHost");
            String proxyPort = System.getProperty("http.proxyPort");
            if (proxyHost != null && proxyPort != null) {
                httpClient.getHostConfiguration().setProxy(proxyHost, Integer.parseInt(proxyPort));
                String username = System.getProperty("http.proxyUserName");
                String password = System.getProperty("http.proxyPassword");
                if (username != null && password != null) {
                    Credentials credentials = new UsernamePasswordCredentials(username, password);
                    httpClient.getState().setProxyCredentials(AuthScope.ANY, credentials);
                }
            }
        } catch (Exception setupError) {
            log.warn("Unable to init proxy!", setupError);
        }
    }

    // --- PRIVATE CONSTRUCTOR ---

    private GCalUtilitiesV3() {
    }

    // --- GOOGLE ICALENDAR LOADER ---

    private static Credential authorize() throws Exception {
        // load client secrets
        GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(
                ClassLoader.getSystemClassLoader().getResourceAsStream("client_secrets.json")));
        if (clientSecrets.getDetails().getClientId().startsWith("Enter")
                || clientSecrets.getDetails().getClientSecret().startsWith("Enter ")) {
            System.out.println("Enter Client ID and Secret from https://code.google.com/apis/console/?api=calendar "
                    + "into calendar-cmdline-sample/src/main/resources/client_secrets.json");
            System.exit(1);
        }
        // set up authorization code flow
        GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(httpTransport, JSON_FACTORY,
                clientSecrets, Collections.singleton(CalendarScopes.CALENDAR)).setDataStoreFactory(dataStoreFactory)
                        .build();
        // authorize
        return new AuthorizationCodeInstalledApp(flow, new LocalServerReceiver()).authorize("user");
    }

    /**
     * Download iCalendar(.ics) file.
     * 
     * @param request
     * @return
     * @throws Exception
     */
    static final byte[] loadCalendar(Request request) throws Exception {
        GetMethod get = null;
        String icalURL;

        // Get auth token
        String token = null;
        if (request.url.indexOf("/private-") == -1) {

            // authorization
            Credential credential = authorize();

            // set up global Calendar instance
            new com.google.api.services.calendar.Calendar.Builder(httpTransport, JSON_FACTORY, credential)
                    .setApplicationName(APPLICATION_NAME).build();
        }

        // Load calendar
        for (int tries = 0;; tries++) {
            try {

                // Create ical URL
                if (tries < 2) {
                    icalURL = GOOGLE_HTTPS_URL + request.url;
                } else {
                    icalURL = GOOGLE_HTTP_URL + request.url;
                }
                int i = icalURL.indexOf("basic.ics");
                if (i != -1) {
                    icalURL = icalURL.substring(0, i + 9);
                }
                get = new GetMethod(icalURL);
                get.addRequestHeader("User-Agent", USER_AGENT);
                get.setFollowRedirects(true);
                if (token != null) {

                    // Set AuthSub token
                    get.addRequestHeader("Authorization", "GoogleLogin auth=\"" + token + '"');
                }

                // Load iCal file from Google
                log.debug("Loading calendar from " + icalURL + "...");
                int status = httpClient.executeMethod(get);
                if (status == -1) {
                    throw new Exception("Invalid HTTP response status (-1)!");
                }
                byte[] bytes = get.getResponseBody();

                // Validate content
                String content;
                if (enableExtensions) {
                    content = StringUtils.decodeToString(bytes, StringUtils.UTF_8);
                } else {
                    content = StringUtils.decodeToString(bytes, StringUtils.US_ASCII);
                }
                if (content.indexOf("BEGIN:VCALENDAR") == -1) {
                    log.warn("Received file from Google:\r\n" + content);
                    throw new Exception("Invalid iCal file: " + icalURL);
                }

                // Register time zones
                registerTimeZones(content, bytes);

                // Insert extended properties
                if (enableExtensions) {
                    bytes = insertExtensions(request, content, bytes);
                }

                // Cleanup cache
                uidMaps.remove(request.url);
                log.debug("Calendar loaded successfully (" + bytes.length + " bytes).");

                // Return ICS calendar file
                return bytes;
            } catch (UnknownHostException networkDown) {
                log.debug("Network down!");
                return exceptionToCalendar(networkDown);
            } catch (Exception loadError) {
                if (tries == 5) {
                    log.error("Unable to load calendar!", loadError);
                    return exceptionToCalendar(loadError);
                }
                log.debug("Connection refused, reconnecting...");
                Thread.sleep(GOOGLE_RETRY_MILLIS);
            } finally {
                if (get != null) {
                    get.releaseConnection();
                }
            }
        }
    }

    private static final byte[] exceptionToCalendar(Exception loadError) throws Exception {
        return GCalUtilities.exceptionToCalendar(loadError);
    }

    // --- ICAL CONVERTER ---

    private static final byte[] insertExtensions(Request request, String content, byte[] bytes) {
        QuickWriter writer = null;
        try {

            // Get service from pool
            com.google.api.services.calendar.Calendar service = getService(request);

            // Build edit map
            CachedCalendar calendar = new CachedCalendar();
            calendar.url = request.url;
            calendar.username = request.username;
            calendar.password = request.password;
            calendar.previousBody = bytes;
            Map<String, Object> extensions = createEditURLMap(service, calendar);
            if (extensions == null || extensions.isEmpty()) {
                return bytes;
            }

            // Last ack
            boolean containsValarm = content.indexOf("BEGIN:VALARM") != -1;
            long lastAck = System.currentTimeMillis() - LAST_ACK_TIMEOUT;
            net.fortuna.ical4j.model.DateTime now = new net.fortuna.ical4j.model.DateTime(lastAck);
            now.setUtc(true);
            char[] ack = now.toString().toCharArray();

            // Insert extensions
            StringTokenizer st = new StringTokenizer(content, "\r\n");
            writer = new QuickWriter(bytes.length * 2);
            String extension, line, id = null;
            int days, hours, mins, i;
            EventReminder reminder;
            Integer number;
            while (st.hasMoreTokens()) {
                line = st.nextToken();

                // Skip extended ical properties
                if (line.startsWith(Property.CATEGORIES) || line.startsWith(Property.PRIORITY)
                        || line.startsWith(Property.URL)) {
                    continue;
                }

                // Get event ID
                if (line.startsWith("UID")) {
                    id = line.substring(4);
                    writer.write(line);
                    writer.write(CR_LF);
                    continue;
                }

                // Get recurrence ID
                if (line.startsWith("RECURRENCE-ID") && id != null) {
                    i = line.lastIndexOf(':');
                    if (i != -1) {
                        try {
                            RecurrenceId recurrenceId = new RecurrenceId(line.substring(i + 1));
                            Date date = recurrenceId.getDate();
                            if (date != null) {
                                id = id + '!' + date.getTime();
                            }
                        } catch (Exception ignored) {
                            log.warn(ignored);
                        }
                    }
                    writer.write(line);
                    writer.write(CR_LF);
                    continue;
                }

                if (line.startsWith("END:VEVENT") && id != null) {

                    // Insert reminder
                    reminder = (EventReminder) extensions.get(id + "\ta");
                    if (reminder != null && !containsValarm) {
                        writer.write(ALARM_RAIN_LASTACK);
                        writer.write(ack);
                        writer.write(ALARM_BEGIN);
                        number = reminder.getMinutes();
                        if (number != null) {
                            mins = number.intValue();
                            if (mins <= 45) {

                                // Valid minutes: 5, 10, 15, 20, 25, 30, 45
                                mins = mins / 5 * 5;
                                if (mins == 35 || mins == 40) {
                                    mins = 45;
                                } else {
                                    if (mins == 0) {
                                        mins = 5;
                                    }
                                }

                                // T1M -> Minutes
                                writer.write('T');
                                writer.write(Integer.toString(mins));
                                writer.write('M');
                            } else {

                                // Valid hours: 1, 2, 3
                                hours = mins / 60;
                                if (hours == 0) {
                                    hours = 1;
                                }
                                if (hours <= 3) {

                                    // T1H -> Hours
                                    writer.write('T');
                                    writer.write(Integer.toString(hours));
                                    writer.write('H');
                                } else {

                                    // Valid days: 1, 2, 7
                                    days = hours / 24;
                                    if (days == 0) {
                                        days = 1;
                                    }
                                    if ((days > 2 && days < 7) || days > 7) {
                                        days = 7;
                                    }

                                    // 1D -> Days
                                    writer.write(Integer.toString(days));
                                    writer.write('D');
                                }
                            }
                        } else {
                            writer.write("T1H");
                        }
                        writer.write(ALARM_MOZ_LASTACK);
                        writer.write(ack);
                        writer.write(ALARM_END);
                    }

                    // Insert categories
                    extension = (String) extensions.get(id + "\tc");
                    if (extension != null && extension.length() != 0) {
                        writer.write(Property.CATEGORIES);
                        writer.write(':');
                        writer.write(extension);
                        writer.write(CR_LF);
                    }

                    // Insert priority
                    extension = (String) extensions.get(id + "\tp");
                    if (extension != null && extension.length() != 0) {
                        writer.write(Property.PRIORITY);
                        writer.write(':');
                        writer.write(extension);
                        writer.write(CR_LF);
                    }

                    // Insert URL
                    extension = (String) extensions.get(id + "\tu");
                    if (extension != null && extension.length() != 0) {
                        writer.write(Property.URL);
                        writer.write(':');
                        writer.write(extension);
                        writer.write(CR_LF);
                    }
                    id = null;
                }
                writer.write(line);
                writer.write(CR_LF);
            }

            // Encode extended ics file
            bytes = StringUtils.encodeArray(writer.getChars(), StringUtils.UTF_8);
        } catch (Exception ignored) {
            log.debug("Unable to insert extensions!", ignored);
        } finally {
            if (writer != null) {
                writer.close();
            }
        }
        return bytes;
    }

    // --- AUTOMATIC TIME ZONE MANAGEMENT ---

    private static final Set<String> registeredTimeZones = new HashSet<String>();

    private static final void registerTimeZones(String content, byte[] bytes) {
        try {
            StringTokenizer st = new StringTokenizer(content, "\r\n");
            Set<String> timeZones = new HashSet<String>();
            String line, timeZone;
            while (st.hasMoreTokens()) {
                line = st.nextToken();
                if (!line.startsWith("TZID:")) {
                    continue;
                }
                timeZone = line.substring(5);
                if (timeZone.length() == 0) {
                    continue;
                }
                if (registeredTimeZones.contains(timeZone)) {
                    continue;
                }
                timeZones.add(timeZone);
            }
            if (timeZones.isEmpty()) {
                return;
            }
            net.fortuna.ical4j.model.Calendar calendar = ICalUtilities.parseCalendar(bytes);
            VTimeZone[] zones = ICalUtilities.getTimeZones(calendar);
            if (zones.length == 0) {
                return;
            }
            Component seasonalTime;
            TzOffsetTo offsetTo;
            String id, offset;
            VTimeZone zone;
            for (int i = 0; i < zones.length; i++) {
                zone = zones[i];
                seasonalTime = zone.getObservances().getComponent(Observance.STANDARD);
                if (seasonalTime == null) {
                    seasonalTime = zone.getObservances().getComponent(Observance.DAYLIGHT);
                }
                id = zone.getTimeZoneId().getValue();
                if (registeredTimeZones.contains(id)) {
                    continue;
                }
                if (seasonalTime == null) {
                    continue;
                }
                offsetTo = (TzOffsetTo) seasonalTime.getProperty(Property.TZOFFSETTO);
                if (offsetTo == null) {
                    continue;
                }
                registeredTimeZones.add(id);
                offset = offsetTo.getValue();
                log.debug("Set the offset of " + id + " to GMT" + offset + ".");
                if (!ICalUtilities.setTimeZone(id, offset)) {
                    log.warn("Unknown time zone (" + id + ")!");
                }
            }
        } catch (Exception ignored) {
            log.debug(ignored);
        }
    }

    // --- EVENT FINDER ---

    static final Event findEvent(CachedCalendar calendar, VEvent event) throws Exception {

        // Get service from pool
        com.google.api.services.calendar.Calendar service = getService(calendar);

        // Find remote event
        try {
            return getGoogleEntry(service, calendar, event);
        } catch (Exception invalidEntry) {

            // Remap events
            uidMaps.remove(calendar.url);
            return getGoogleEntry(service, calendar, event);
        }
    }

    // --- EVENT CREATOR ---

    static final void insertEvents(CachedCalendar calendar, VTimeZone[] timeZones, VEvent[] events)
            throws Exception {

        // Get service from pool
        com.google.api.services.calendar.Calendar service = getService(calendar);

        // Find RRule
        boolean foundRRule = false;
        int n;
        for (n = 0; n < events.length; n++) {
            foundRRule = events[n].getProperty(Property.RRULE) != null;
            if (foundRRule) {
                break;
            }
        }

        // Loop on events
        for (n = 0; n < events.length; n++) {

            // Insert event
            insertEvent(calendar, timeZones, events[n], foundRRule, service);
        }

        // Clear cache
        uidMaps.remove(calendar.url);
    }

    private static String getCalendarIdFromURL(String url) {
        String id = "";
        id = url.replace("/calendar/ical/", "");
        id = id.substring(0, id.indexOf("/"));
        id = id.replace("%40", "@");
        return id;
    }

    private static final void insertEvent(CachedCalendar calendar, VTimeZone[] timeZones, VEvent event,
            boolean foundRRule, com.google.api.services.calendar.Calendar service) throws Exception {

        // Clear cache
        if (foundRRule && event.getRecurrenceId() != null) {
            foundRRule = false;
            uidMaps.remove(calendar.url);
            CachedCalendar swap = new CachedCalendar();
            swap.lastModified = calendar.lastModified;
            swap.url = calendar.url;
            swap.username = calendar.username;
            swap.password = calendar.password;
            swap.filePath = calendar.filePath;
            swap.toDoBlock = calendar.toDoBlock;
            swap.body = calendar.body;
            swap.previousBody = calendar.body;
            calendar = swap;
        }

        // Convert event to Google entry
        Event newEntry = convertVEvent(calendar, timeZones, event);

        // Absolute time = clear reminders mark
        Reminders reminders = newEntry.getReminders();
        if (reminders != null && !reminders.isEmpty()) {
            reminders.clear();
        }

        // Insert new event
        if (log.isDebugEnabled()) {
            log.debug("Inserting event (" + ICalUtilities.getEventTitle(event) + ") into Google Calendar...");
        }
        try {
            service.events().insert(getCalendarIdFromURL(calendar.url), newEntry).execute();
        } catch (Exception exception) {

            // Get remote message
            String msg = getMessageBody(exception);

            // Skip insert
            if (msg.indexOf("no instances") != -1 || msg.indexOf("read-only") != -1) {
                log.debug("Unable to insert event (" + ICalUtilities.getEventTitle(event) + ")!\r\n" + msg);
                return;
            }

            // Remove reminders
            if (msg.indexOf("many reminder") != -1) {
                Reminders reminder = newEntry.getReminders();
                log.warn("Too many reminders!");
                if (reminder != null) {
                    reminder.clear();
                }
            }

            // Resend request
            Thread.sleep(GOOGLE_RETRY_MILLIS);
            try {
                service.events().insert(getCalendarIdFromURL(calendar.url), newEntry).execute();
            } catch (Exception error) {
                log.warn("Unable to insert event (" + ICalUtilities.getEventTitle(event) + ")!\r\n" + msg);
            }
        }
    }

    private static final String getMessageBody(Exception exception) {
        if (exception == null) {
            return "";
        }
        String body = null;
        if (body == null || body.length() == 0) {
            body = exception.toString();
        }
        return body;
    }

    // --- EVENT UPDATER ---

    static final void updateEvents(CachedCalendar calendar, VTimeZone[] timeZones, VEvent[] events)
            throws Exception {

        // Get service from pool
        com.google.api.services.calendar.Calendar service = getService(calendar);

        // Loop on events
        boolean searchRRule = true;
        boolean foundRRule = false;
        VEvent event;
        for (int n = 0; n < events.length; n++) {
            event = events[n];

            // Find original event
            Event oldEntry = getGoogleEntry(service, calendar, event);

            if (oldEntry == null) {

                // Find RRule
                if (searchRRule) {
                    searchRRule = false;
                    for (int m = 0; m < events.length; m++) {
                        foundRRule = events[m].getProperty(Property.RRULE) != null;
                        if (foundRRule) {
                            break;
                        }
                    }
                }

                // Insert event
                insertEvent(calendar, timeZones, event, foundRRule, service);

                // Clear UID cache
                uidMaps.remove(calendar.url);
            } else {

                // Event found in Google Calendar
                // Link editLink = oldEntry.getEditLink();
                // if (editLink == null) {
                // log.warn("Unable to update read-only event ("
                // + ICalUtilities.getEventTitle(event) + ")!");
                // continue;
                // }
                // String editURL = editLink.getHref();

                // Convert event to Google entry
                Event newEntry = convertVEvent(calendar, timeZones, event);
                String iCalUID = event.getUid().getValue();
                newEntry.setId(iCalUID.substring(0, iCalUID.indexOf("@")));

                // Check recurrence
                boolean recurrenceChanged;
                List<String> recurrence = null;
                if (oldEntry.getRecurrence() == null) {
                    recurrence = newEntry.getRecurrence();
                    recurrenceChanged = recurrence != null;
                } else {
                    recurrence = newEntry.getRecurrence();
                    recurrenceChanged = recurrence == null;
                }

                // Copy reminders
                Reminders reminders = newEntry.getReminders();
                if (reminders == null || reminders.isEmpty()) {
                    if (!enableExtensions) {
                        Reminders oldReminders = oldEntry.getReminders();
                        if (oldReminders != null && !oldReminders.isEmpty()) {
                            reminders = new Reminders();
                            reminders.putAll(oldReminders);
                            newEntry.setReminders(reminders);
                        }
                    }
                } else {
                    reminders.clear();
                }

                // Do modifications
                if (recurrenceChanged) {
                    if (log.isDebugEnabled()) {
                        log.debug("Recreating event (" + ICalUtilities.getEventTitle(event)
                                + ") in Google Calendar...");
                    }
                    boolean deleted = false;
                    try {

                        // Remove and recreate entry
                        uidMaps.remove(calendar.url);
                        service.events().delete(getCalendarIdFromURL(calendar.url), newEntry.getId()).execute();
                        deleted = true;
                        service.events().insert(getCalendarIdFromURL(calendar.url), newEntry).execute();
                    } catch (Exception exception) {

                        // Get remote message
                        String msg = getMessageBody(exception);

                        // Skip insert
                        if (msg.indexOf("no instances") != -1 || msg.indexOf("read-only") != -1) {
                            log.debug("Unable to recreate event (" + ICalUtilities.getEventTitle(event) + ")!\r\n"
                                    + msg);
                            continue;
                        }

                        // Remove reminders
                        if (msg.indexOf("many reminder") != -1) {
                            Reminders reminder = newEntry.getReminders();
                            log.warn("Too many reminders!");
                            if (reminder != null) {
                                reminder.clear();
                            }
                        }

                        // Resend request
                        Thread.sleep(GOOGLE_RETRY_MILLIS);
                        try {
                            if (!deleted) {
                                service.events().delete(getCalendarIdFromURL(calendar.url), newEntry.getId())
                                        .execute();
                            }
                            service.events().insert(getCalendarIdFromURL(calendar.url), newEntry).execute();
                        } catch (Exception error) {
                            log.warn("Unable to recreate event (" + ICalUtilities.getEventTitle(event) + ")!\r\n"
                                    + msg);
                        }
                    }
                } else {
                    if (log.isDebugEnabled()) {
                        log.debug("Updating event (" + ICalUtilities.getEventTitle(event)
                                + ") in Google Calendar...");
                    }
                    try {

                        // Simple update
                        service.events().update(getCalendarIdFromURL(calendar.url), newEntry.getId(), newEntry)
                                .execute();
                    } catch (Exception exception) {

                        // Get remote message
                        String msg = getMessageBody(exception);

                        // Skip insert
                        if (msg.indexOf("cannot override") != -1 || msg.indexOf("read-only") != -1) {
                            log.debug("Unable to update event (" + ICalUtilities.getEventTitle(event) + ")!\r\n"
                                    + msg);
                            continue;
                        }

                        // Delete event
                        if (msg.indexOf("no instances") != -1) {
                            try {
                                removeRecurringEvent(calendar, service, event, calendar.url);
                            } catch (Exception ignored) {
                                log.debug("Unable to delete faulty event (" + ICalUtilities.getEventTitle(event)
                                        + ")!", ignored);
                            }
                            continue;
                        }

                        // Remove reminders
                        if (msg.indexOf("many reminder") != -1) {
                            Reminders reminder = newEntry.getReminders();
                            log.warn("Too many reminders!");
                            if (reminder != null) {
                                reminder.clear();
                            }
                        }

                        // Resend request
                        Thread.sleep(GOOGLE_RETRY_MILLIS);
                        try {
                            service.events().update(getCalendarIdFromURL(calendar.url), newEntry.getId(), newEntry)
                                    .execute();
                        } catch (Exception error) {
                            log.warn("Unable to update event (" + ICalUtilities.getEventTitle(event) + ")!\r\n"
                                    + msg);
                        }
                    }
                }
            }
        }
    }

    private static final void removeRecurringEvent(CachedCalendar calendar,
            com.google.api.services.calendar.Calendar service, VEvent parent, String editURL) throws Exception {
        uidMaps.remove(calendar.url);
        VEvent[] events = ICalUtilities.getEvents(ICalUtilities.parseCalendar(calendar.previousBody));
        String uid = ICalUtilities.getUid(parent);
        if (uid == null) {
            return;
        }
        Event oldEntry;
        VEvent child;
        String id;
        for (int c = 0; c < events.length; c++) {
            child = events[c];
            id = ICalUtilities.getUid(child);
            if (id == null) {
                continue;
            }
            if (id.startsWith(uid) && !id.equals(uid)) {
                oldEntry = getGoogleEntry(service, calendar, child);
                if (oldEntry != null) {
                    Thread.sleep(GOOGLE_RETRY_MILLIS);
                    service.events().delete(getCalendarIdFromURL(calendar.url), oldEntry.getId()).execute();
                }
            }
        }
        Thread.sleep(GOOGLE_RETRY_MILLIS);
        service.calendars().delete(getCalendarIdFromURL(calendar.url));
        uidMaps.remove(calendar.url);
    }

    // --- EVENT REMOVER ---

    static final void removeEvents(CachedCalendar calendar, VEvent[] events) throws Exception {

        // Get service from pool
        com.google.api.services.calendar.Calendar service = getService(calendar);

        // Loop on events
        VEvent event;
        for (int n = 0; n < events.length; n++) {
            event = events[n];

            Event oldEntry = getGoogleEntry(service, calendar, event);

            // Remove event
            if (oldEntry != null) {
                if (log.isDebugEnabled()) {
                    log.debug(
                            "Removing event (" + ICalUtilities.getEventTitle(event) + ") from Google Calendar...");
                }
                try {
                    service.events().delete(getCalendarIdFromURL(calendar.url), oldEntry.getId()).execute();
                } catch (Exception exception) {

                    // Get remote message
                    String msg = getMessageBody(exception);

                    // Skip delete
                    if (msg.indexOf("no instances") != -1 || msg.indexOf("read-only") != -1) {
                        log.debug("Unable to remove event (" + ICalUtilities.getEventTitle(event) + ")!\r\n" + msg);
                        continue;
                    }

                    // Resend request
                    Thread.sleep(GOOGLE_RETRY_MILLIS);
                    try {
                        service.calendars().delete(getCalendarIdFromURL(calendar.url));
                    } catch (Exception error) {
                        log.warn("Unable to remove event (" + ICalUtilities.getEventTitle(event) + ")!\r\n" + msg);
                    }
                }
            } else {
                log.warn("Event (" + ICalUtilities.getEventTitle(event) + ") not found in Google Calendar!");
            }
        }
    }

    // --- ICAL EVENT TO GOOGLE EVENT CONVERTER ---

    private static final Event convertVEvent(CachedCalendar calendar, VTimeZone[] timeZones, VEvent event)
            throws Exception {
        Event entry = new Event();

        // entry.setCanEdit(true);
        // entry.setDraft(new Boolean(false));
        // entry.setQuickAdd(false);
        entry.setUpdated(new DateTime(System.currentTimeMillis()));
        // entry.setSendEventNotifications(sendInvitations);
        String text;

        // Convert event UID to extended property
        String uid = ICalUtilities.getUid(event);
        if (uid != null) {
            ExtendedProperties extension = new ExtendedProperties();
            extension.put(UID_EXTENSION_NAME, uid);
            entry.setExtendedProperties(extension);
        }

        // Convert priority to extended property
        Priority priority = event.getPriority();
        if (priority != null) {
            text = priority.getValue();
            if (text != null && text.length() != 0) {
                ExtendedProperties extension = new ExtendedProperties();
                extension.put(PRIORITY_EXTENSION_NAME, text);
                entry.setExtendedProperties(extension);
            }
        }

        // Convert URL to extended property
        Url url = event.getUrl();
        if (url != null) {
            text = url.getValue();
            if (text != null && text.length() != 0) {
                ExtendedProperties extension = new ExtendedProperties();
                extension.put(URL_EXTENSION_NAME, text);
                entry.setExtendedProperties(extension);
            }
        }

        // Convert URL to extended property
        Property categories = event.getProperty(Property.CATEGORIES);
        if (categories != null) {
            text = categories.getValue();
            if (text != null && text.length() != 0 && !text.startsWith("http")) {
                ExtendedProperties extension = new ExtendedProperties();
                extension.put(CATEGORIES_EXTENSION_NAME, text);
                entry.setExtendedProperties(extension);
            }
        }

        // Convert created to published
        Created created = event.getCreated();
        if (created != null) {
            DateTime published = toDateTime(created.getDate());
            entry.setCreated(published);
        }

        // Convert summary to title
        Summary summary = event.getSummary();
        if (summary != null) {
            text = summary.getValue();
            if (text != null && text.length() != 0) {
                entry.setSummary(text);
            }
        }

        // Convert description to content
        Description desc = event.getDescription();
        if (desc != null) {
            text = desc.getValue();
            if (text != null && text.length() != 0) {
                entry.setDescription(text);
            }
        }

        // Convert start date
        DtStart start = event.getStartDate();
        if (start == null) {
            Date date = null;
            if (created != null) {
                date = created.getDate();
            }
            if (date == null) {
                date = new Date();
            }
            start = new DtStart(date);
        }
        Date startDate = start.getDate();

        // Convert end date
        DtEnd end = event.getEndDate();
        if (end == null) {
            end = new DtEnd(startDate);
        }
        Date endDate = end.getDate();

        // Check dates
        if (startDate.after(endDate)) {
            Date swap = startDate;
            startDate = endDate;
            endDate = swap;
        }

        // Set when
        boolean isAllDay = startDate.toString().indexOf('T') == -1;
        if (isAllDay) {
            entry.setStart(new EventDateTime().setDate(toDateTime(startDate)));
            entry.setEnd(new EventDateTime().setDate(toDateTime(endDate)));
        } else {
            entry.setStart(new EventDateTime().setDateTime(toDateTime(startDate)));
            entry.setEnd(new EventDateTime().setDateTime(toDateTime(endDate)));
        }

        // Convert location to where
        Location location = event.getLocation();
        if (location != null) {
            text = location.getValue();
            if (text != null) {
                entry.setLocation(text);
            }
        }

        // Convert status (tentative, confirmed, canceled)
        Status status = event.getStatus();
        if (status != null) {
            text = status.getValue();
            entry.setStatus(text.toLowerCase());
        }

        // Convert classification to visibility (public / private)
        Clazz clazz = event.getClassification();
        if (clazz != null) {
            text = clazz.getValue();
            entry.setVisibility(text.toLowerCase());
        } else {
            entry.setVisibility("default");
        }

        // Convert transparency (transparent / opaque = free / busy)
        Transp transp = event.getTransparency();
        if (transp == null) {

            // Default is 'Available' (=free or transparent)
            entry.setTransparency("transparent");
        } else {
            if (Transp.TRANSPARENT.getValue().equals(transp.getValue())) {
                entry.setTransparency("transparent");
            } else {
                entry.setTransparency("opaque");
            }
        }

        // Convert attendees
        String[] emails = ICalUtilities.getAttendees(event);
        if (emails != null) {
            List<EventAttendee> attendees = new ArrayList<EventAttendee>();
            for (int i = 0; i < emails.length; i++) {
                EventAttendee ea = new EventAttendee();
                ea.setEmail(emails[i]);
                attendees.add(ea);
            }
            entry.setAttendees(attendees);
        }

        // Convert recurrence
        if (start != null && end != null) {
            Property rRule = event.getProperty(Property.RRULE);
            if (rRule != null) {
                VTimeZone timeZone = null;

                // Find time zone
                timeZone = getRecurrenceTimeZone(timeZones, event);

                // Get recurrence exceptions
                net.fortuna.ical4j.model.Date[] dates = ICalUtilities.getExceptionDates(event);

                // Create recurrence value
                List<String> recurrence = new ArrayList<String>();
                QuickWriter writer = new QuickWriter(500);
                writer.write(start.toString().trim());
                writer.write(CR_LF);
                writer.write(end.toString().trim());
                writer.write(CR_LF);
                writer.write(rRule.toString().trim());
                if (dates != null) {
                    for (int i = 0; i < dates.length; i++) {
                        writer.write(CR_LF);
                        writer.write(Property.EXDATE);
                        writer.write(':');
                        if (dates[i] instanceof net.fortuna.ical4j.model.DateTime) {
                            net.fortuna.ical4j.model.DateTime dateTime = (net.fortuna.ical4j.model.DateTime) dates[i];
                            dateTime.setUtc(true);
                        }
                        writer.write(dates[i].toString());
                    }
                }
                if (timeZone != null) {
                    writer.write(CR_LF);
                    writer.write(timeZone.toString().trim());
                }
                writer.write(CR_LF);
                recurrence.add(writer.toString());
                entry.setRecurrence(recurrence);
                writer.close();
            }
        }

        // Convert recurrenceID
        RecurrenceId rid = event.getRecurrenceId();
        if (rid != null) {
            Uid property = event.getUid();
            if (property != null) {
                String id = property.getValue();
                if (id != null) {

                    // Get service from pool
                    com.google.api.services.calendar.Calendar service = getService(calendar);

                    // Get original event
                    Event parent = getGoogleEntryByUID(service, calendar, id);
                    if (parent != null) {
                        entry.setOriginalStartTime(new EventDateTime().setDateTime(toDateTime(rid.getDate())));
                    }
                }
            }
        }

        // Convert reminder
        int mins = ICalUtilities.getAlarmMinutes(event);
        if (mins != -1) {
            EventReminder reminder1 = new EventReminder();
            EventReminder reminder2 = new EventReminder();
            EventReminder reminder3 = new EventReminder();
            reminder1.setMethod("popup");
            reminder2.setMethod("email");
            reminder3.setMethod("sms");
            Integer holder;
            if (mins == 0) {
                reminder1.setMinutes(null);
                reminder2.setMinutes(null);
                reminder3.setMinutes(null);
            } else {
                if (mins <= 45) {

                    // Valid minutes: 5, 10, 15, 20, 25, 30, 45
                    mins = mins / 5 * 5;
                    if (mins == 35 || mins == 40) {
                        mins = 45;
                    } else {
                        if (mins == 0) {
                            mins = 5;
                        }
                    }
                    holder = new Integer(mins);
                    reminder1.setMinutes(holder);
                    reminder2.setMinutes(holder);
                    reminder3.setMinutes(holder);
                } else {

                    // Valid hours: 1, 2, 3
                    int hours = mins / 60;
                    if (hours == 0) {
                        hours = 1;
                    }
                    if (hours <= 3) {
                        holder = new Integer(hours);
                        reminder1.setMinutes(holder * 60);
                        reminder2.setMinutes(holder * 60);
                        reminder3.setMinutes(holder * 60);
                    } else {

                        // Valid days: 1, 2, 7
                        int days = hours / 24;
                        if (days == 0) {
                            days = 1;
                        }
                        if ((days > 2 && days < 7) || days > 7) {
                            days = 7;
                        }
                        holder = new Integer(days);
                        reminder1.setMinutes(holder * 24 * 60);
                        reminder2.setMinutes(holder * 24 * 60);
                        reminder3.setMinutes(holder * 24 * 60);
                    }
                }
            }
            // Set "Alert" alarm
            List<EventReminder> reminders = new ArrayList<EventReminder>();
            if (enablePopup) {
                reminders.add(reminder1);
            }

            // Set "E-mail" alarm
            if (enableEmail) {
                reminders.add(reminder2);
            }

            // Set "SMS" alarm
            if (enableSms) {
                reminders.add(reminder3);
            }
            if (!reminders.isEmpty()) {
                Reminders r = new Reminders();
                r.setOverrides(reminders);
                entry.setReminders(r);
            }
        }

        return entry;
    }

    static final DateTime toDateTime(Date date) throws Exception {
        if (date == null) {
            return null;
        }
        boolean isAllDay = date.toString().indexOf('T') == -1;
        DateTime dateTime;
        if (isAllDay) {
            dateTime = toOneDayEventDateTime(date);
        } else {
            dateTime = new DateTime(isAllDay, date.getTime(), 0);
        }
        return dateTime;
    }

    private static final DateTime toOneDayEventDateTime(Date date) throws Exception {

        // Convert one day event's date to UTC date
        String text = date.toString();
        GregorianCalendar calendar = new GregorianCalendar(UTC);
        calendar.set(GregorianCalendar.YEAR, Integer.parseInt(text.substring(0, 4)));
        calendar.set(GregorianCalendar.MONTH, Integer.parseInt(text.substring(4, 6)) - 1);
        calendar.set(GregorianCalendar.DAY_OF_MONTH, Integer.parseInt(text.substring(6)));
        calendar.set(GregorianCalendar.HOUR_OF_DAY, 0);
        calendar.set(GregorianCalendar.MINUTE, 0);
        calendar.set(GregorianCalendar.SECOND, 0);
        calendar.set(GregorianCalendar.MILLISECOND, 0);
        DateTime dateTime = new DateTime(true, calendar.getTime().getTime(), 0);
        return dateTime;
    }

    private static final VTimeZone getRecurrenceTimeZone(VTimeZone[] timeZones, VEvent event) throws Exception {
        if (timeZones == null || timeZones.length == 0) {
            return null;
        }
        String tzid = getTimeZoneID(event);
        if (tzid != null) {
            VTimeZone timeZone;
            for (int i = 0; i < timeZones.length; i++) {
                timeZone = timeZones[i];
                TzId id = timeZone.getTimeZoneId();
                if (tzid.toLowerCase().equals(id.getValue().toString().toLowerCase())) {
                    return timeZone;
                }
            }
        }
        return null;
    }

    private static final String getTimeZoneID(VEvent event) throws Exception {
        Property start = event.getProperty(Property.DTSTART);
        if (start != null) {
            String tzid = start.toString();
            if (tzid != null) {
                int s = tzid.indexOf(Property.TZID);
                if (s != -1) {
                    int e = tzid.indexOf(':', s);
                    if (e != -1) {
                        return tzid.substring(s + 5, e);
                    } else {
                        return null;
                    }
                } else {
                    return null;
                }
            }
        }
        return null;
    }

    // --- GOOGLE EVENT FEED ---

    private static final List<Event> getGoogleEntries(com.google.api.services.calendar.Calendar service,
            CachedCalendar calendar) throws Exception {

        // Request feed
        List<Event> feed;
        for (int tries = 0;; tries++) {
            try {
                feed = service.events().list(getCalendarIdFromURL(calendar.url)).execute().getItems();
                break;
            } catch (Exception loadError) {
                if (tries == 5) {
                    throw loadError;
                }
                log.debug("Connection refused, reconnecting...");

                // Rebuild connection
                Thread.sleep(GOOGLE_RETRY_MILLIS);
                servicePool.remove(calendar.url);
                service = getService(calendar);
            }
        }

        // Return list of CalendarEventEntries
        return feed;
    }

    // --- EVENT FINDER ---

    private static final Map<String, Object> uidMaps = new HashMap<String, Object>();

    private static final Event getGoogleEntry(com.google.api.services.calendar.Calendar service,
            CachedCalendar calendar, VEvent event) throws Exception {

        // Get local UID
        String uid = ICalUtilities.getUid(event);
        if (uid == null) {
            return null;
        }

        // Request entry from Google
        return getGoogleEntryByUID(service, calendar, uid);
    }

    /**
     * 
     * @param service
     * @param calendar
     * @param uid
     *            It's iCalUID
     * @return
     * @throws Exception
     */
    private final static Event getGoogleEntryByUID(com.google.api.services.calendar.Calendar service,
            CachedCalendar calendar, String uid) throws Exception {

        // get event UID from iCalUID
        int at = uid.indexOf("@");
        if (at != -1) {
            uid = uid.substring(0, at);
        }
        // Load event
        for (int tries = 0;; tries++) {
            try {
                return (Event) service.events().get(getCalendarIdFromURL(calendar.url), uid).execute();
            } catch (Exception loadError) {
                if (tries == 5) {
                    log.debug("Unable to load event (" + uid + ")!", loadError);
                    return null;
                }
                log.debug("Connection refused, reconnecting...");

                // Rebuild connection
                Thread.sleep(GOOGLE_RETRY_MILLIS);
                servicePool.remove(calendar.url);
                service = getService(calendar);
            }
        }
    }

    @SuppressWarnings("unchecked")
    static final String getRemoteUID(CachedCalendar calendar, String id) {
        Map<String, String> mappedUIDs = (Map<String, String>) uidMaps.get(calendar.url);
        if (mappedUIDs == null) {
            return null;
        }
        return mappedUIDs.get(id);
    }

    private static final Map<String, Object> createEditURLMap(com.google.api.services.calendar.Calendar service,
            CachedCalendar calendar) throws Exception {

        // Create alarm registry
        Map<String, Object> extensionMap;
        if (enableExtensions) {
            extensionMap = new HashMap<String, Object>();
        } else {
            extensionMap = null;
        }

        // Create edit URL map
        List<Event> entries = getGoogleEntries(service, calendar);
        Map<String, Object> remoteUIDs = new HashMap<String, Object>();
        uidMaps.put(calendar.url, remoteUIDs);
        net.fortuna.ical4j.model.Calendar oldCalendar = ICalUtilities.parseCalendar(calendar.previousBody);
        VEvent[] events = ICalUtilities.getEvents(oldCalendar);

        // Loop on events
        VEvent event;
        Map<String, String> dateCache = new HashMap<String, String>();
        for (int n = 0; n < events.length; n++) {
            event = events[n];

            // Get local UID and RID
            String uid = ICalUtilities.getUid(event);
            if (uid == null) {
                continue;
            }

            // Find original event
            Event oldEntry = findEntry(entries, event, dateCache);
            if (oldEntry == null) {
                continue;
            }

            // Get alarm
            if (enableExtensions) {
                Reminders reminders = oldEntry.getReminders();
                if (reminders != null && !reminders.isEmpty()) {
                    // FIXME
                    extensionMap.put(uid + "\ta", reminders.getOverrides().get(0));
                }
            }

            // Bind local UID to remote UID
            ExtendedProperties p = oldEntry.getExtendedProperties();
            if (p != null) {
                Map<String, String> extensionList = oldEntry.getExtendedProperties().getShared();
                for (Map.Entry<String, String> extension : extensionList.entrySet()) {
                    String name = extension.getKey();
                    if (UID_EXTENSION_NAME.equals(name)) {
                        String localUID = extension.getValue();
                        if (!uid.equals(localUID)) {
                            remoteUIDs.put(localUID, uid);
                        }
                        continue;
                    }
                    if (enableExtensions) {

                        // Store extensions
                        if (CATEGORIES_EXTENSION_NAME.equals(name)) {
                            extensionMap.put(uid + "\tc", extension.getValue());
                            continue;
                        }
                        if (PRIORITY_EXTENSION_NAME.equals(name)) {
                            extensionMap.put(uid + "\tp", extension.getValue());
                            continue;
                        }
                        if (URL_EXTENSION_NAME.equals(name)) {
                            extensionMap.put(uid + "\tu", extension.getValue());
                            continue;
                        }
                    }
                }
            }
        }

        // Return extensions registry (or null)
        return extensionMap;
    }

    private static final Event findEntry(List<Event> entries, VEvent event, Map<String, String> dateCache)
            throws Exception {

        // Get UID and RID
        String uid = ICalUtilities.getUid(event);

        // Get created
        long created = 0;
        Created createdDate = event.getCreated();
        if (createdDate != null) {
            created = createdDate.getDate().getTime();
        }

        // Get start date
        String startDate = null;
        DtStart dtStart = event.getStartDate();
        if (dtStart != null) {
            DateTime start = toDateTime(dtStart.getDate());
            if (start != null) {
                startDate = toUiString(start.getValue());
            }
        }

        // Get end date
        String endDate = null;
        DtEnd dtEnd = event.getEndDate();
        if (dtEnd != null) {
            DateTime end = toDateTime(dtEnd.getDate());
            if (end != null) {
                endDate = toUiString(end.getValue());
            }
        }

        // Get title
        String title = null;
        Summary summary = event.getSummary();
        if (summary != null) {
            title = ICalUtilities.normalizeLineBreaks(summary.getValue());
        }

        // Get content
        String content = null;
        Description description = event.getDescription();
        if (description != null) {
            content = ICalUtilities.normalizeLineBreaks(description.getValue());
        }

        // Loop on Google Calendar
        Event bestEntry = null;
        Event entry;
        int matchCounter, bestMatch = 0;
        Iterator<Event> entryIterator = entries.iterator();
        while (entryIterator.hasNext()) {
            entry = entryIterator.next();
            matchCounter = 0;

            // Compare extended UID
            ExtendedProperties p = entry.getExtendedProperties();
            if (uid != null && p != null) {
                Map<String, String> extensionList = entry.getExtendedProperties().getShared();
                for (Map.Entry<String, String> extension : extensionList.entrySet()) {
                    if (UID_EXTENSION_NAME.equals(extension.getKey()) && uid.equals(extension.getValue())) {

                        // UID found -> 100% match -> stop finding
                        if (log.isDebugEnabled()) {
                            log.debug("Found event (" + ICalUtilities.getEventTitle(event)
                                    + ") in Google Calendar by unique ID.");
                        }
                        entryIterator.remove();
                        return entry;
                    }
                }
            }

            // Compare created
            DateTime published = entry.getCreated();
            if (created != 0 && published != null) {
                long remoteCreated = published.getValue();
                if (created == remoteCreated) {
                    matchCounter++;
                } else {
                    if (remoteCreated != 0 && created > remoteCreated) {
                        continue;
                    }
                }
            }

            // Compare title
            String titleText = entry.getSummary();
            if (titleText != null) {
                titleText = ICalUtilities.normalizeLineBreaks(titleText);
                if (titleText.equals(title)) {
                    matchCounter++;
                }
            }

            // Compare content
            String contentText = entry.getDescription();
            if (contentText != null) {
                contentText = ICalUtilities.normalizeLineBreaks(contentText);
                if (content != null && content.length() != 0 && contentText.length() != 0
                        && contentText.equals(content)) {
                    matchCounter++;
                }
            }

            // Compare dates and times
            String id = entry.getId();
            String entryStart = null;
            String entryEnd = null;
            String startKey = "s\t" + id;
            String endKey = "e\t" + id;
            entryStart = (String) dateCache.get(startKey);
            if (startDate != null && entryStart != null) {
                if (startDate.equals(entryStart)) {
                    matchCounter++;
                }
            }
            entryEnd = (String) dateCache.get(endKey);
            if (endDate != null && entryEnd != null) {
                if (endDate.equals(entryEnd)) {
                    matchCounter++;
                }
            }
            if (entryStart == null || entryEnd == null) {
                DateTime start = entry.getStart().getDateTime();
                if (start != null && startDate != null) {
                    boolean entryStartNull = (entryStart == null);
                    entryStart = toUiString(start.getValue());
                    dateCache.put(startKey, entryStart);
                    if (entryStart.equals(startDate) && entryStartNull) {
                        matchCounter++;
                    }
                }

                DateTime end = entry.getEnd().getDateTime();
                if (end != null && endDate != null) {
                    boolean entryEndNull = (entryEnd == null);
                    entryEnd = toUiString(end.getValue());
                    dateCache.put(endKey, entryEnd);
                    if (entryEnd.equals(endDate) && entryEndNull) {
                        matchCounter++;
                    }
                }
            }

            if (matchCounter > bestMatch) {
                bestMatch = matchCounter;
                bestEntry = entry;
            }
        }
        if (bestMatch < 2) {
            if (log.isDebugEnabled()) {
                log.debug("Event (" + ICalUtilities.getEventTitle(event) + ") not found in Google Calendar.");
            }
            return null;
        }
        if (log.isDebugEnabled()) {
            log.debug("Found event (" + ICalUtilities.getEventTitle(event) + ") in Google Calendar by " + bestMatch
                    + " concordant property.");
        }
        entries.remove(bestEntry);
        return bestEntry;
    }

    // --- GOOGLE CONNECTION POOL ---

    private static final Map<String, PooledGoogleService> servicePool = new HashMap<String, PooledGoogleService>();

    private static final synchronized com.google.api.services.calendar.Calendar getService(Request request)
            throws Exception {
        long now = System.currentTimeMillis();
        PooledGoogleService service;
        service = servicePool.get(request.url);
        if (service != null) {
            if (now - service.lastUsed > GOOGLE_CONNECTION_TIMEOUT) {

                // Connection timeouted
                servicePool.remove(request.url);
                service = null;
            }
        }
        if (service == null) {

            // Create a new connection
            log.debug("Connecting to Google...");
            service = new PooledGoogleService();
            for (int tries = 0;; tries++) {
                try {
                    Credential credential = authorize();
                    service.service = new com.google.api.services.calendar.Calendar.Builder(httpTransport,
                            JSON_FACTORY, credential).setApplicationName(APPLICATION_NAME).build();
                    break;
                } catch (Exception ioException) {
                    if (tries == 5) {
                        log.fatal("Connection refused!", ioException);
                        throw ioException;
                    }
                    log.debug("Connection refused, reconnecting...");
                    Thread.sleep(GOOGLE_RETRY_MILLIS);
                }
            }
            if (servicePool.size() > MAX_POOLED_CONNECTIONS) {
                servicePool.clear();
            }
            servicePool.put(request.url, service);
        }
        service.lastUsed = now;
        return service.service;
    }

    // --- LIST CALENDARS ---

    private static final Properties calendarNames = new Properties();

    public static final String[] getCalendarURLs(Request request, File workDir) throws Exception {

        // Get service from pool
        if (request.url == null) {
            request.url = request.username;
        }
        com.google.api.services.calendar.Calendar service = getService(request);

        // Send the request and receive the response
        CalendarList resultFeed;
        for (int tries = 0;; tries++) {
            try {
                resultFeed = service.calendarList().list().execute();
                break;
            } catch (Exception loadError) {
                if (tries == 3) {
                    throw loadError;
                }
                log.debug("Connection refused, reconnecting...");

                // Rebuild connection
                Thread.sleep(GOOGLE_RETRY_MILLIS);
                servicePool.clear();
                service = getService(request);
            }
        }

        // Convert to array
        List<CalendarListEntry> entries = resultFeed.getItems();
        if (entries == null || entries.isEmpty()) {
            return new String[0];
        }
        List<String> urls = new LinkedList<String>();
        Iterator<CalendarListEntry> entryIterator = entries.iterator();
        CalendarListEntry entry;
        String url, text;
        while (entryIterator.hasNext()) {
            entry = entryIterator.next();
            // FIXME don't know how get private iCal URL, temporary return
            // calendar ids
            url = entry.getId();
            urls.add(url);
            text = entry.getSummary();
            if (text != null) {
                text = text.trim();
                if (text.length() != 0) {
                    calendarNames.put(url, text);
                }
            }
        }
        saveCalendarNamesToCache(workDir);
        String[] array = new String[urls.size()];
        urls.toArray(array);
        return array;
    }

    public static final String getCalendarName(String url, File workDir) {
        if (url == null || url.length() == 0) {
            return null;
        }
        if (calendarNames.isEmpty()) {
            loadCalendarNamesFromCache(workDir);
        }
        if (url.startsWith(GOOGLE_HTTP_URL)) {
            url = url.substring(GOOGLE_HTTP_URL.length());
        } else {
            if (url.startsWith(GOOGLE_HTTPS_URL)) {
                url = url.substring(GOOGLE_HTTPS_URL.length());
            }
        }
        String name = (String) calendarNames.get(url);
        if (name != null) {
            return name;
        }
        int i = url.indexOf("/private");
        if (i != -1) {
            url = url.substring(0, i);
            Iterator<Map.Entry<Object, Object>> names = calendarNames.entrySet().iterator();
            Map.Entry<Object, Object> entry;
            while (names.hasNext()) {
                entry = names.next();
                if (((String) entry.getKey()).startsWith(url)) {
                    return (String) entry.getValue();
                }
            }
        }
        return null;
    }

    private static final void loadCalendarNamesFromCache(File workDir) {
        try {
            File file = new File(workDir, "gcal-names.txt");
            if (!file.isFile()) {
                return;
            }
            BufferedInputStream in = new BufferedInputStream(new FileInputStream(file));
            calendarNames.load(in);
            in.close();
        } catch (Exception ioException) {
            log.warn("Unable to load 'gcal-names.txt'!", ioException);
        }
    }

    private static final void saveCalendarNamesToCache(File workDir) {
        try {
            File file = new File(workDir, "gcal-names.txt");
            BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file));
            calendarNames.store(out, "CALENDAR NAME CACHE");
            out.flush();
            out.close();
        } catch (Exception ioException) {
            log.warn("Unable to save 'gcal-names.txt'!", ioException);
        }
    }

    private static final TimeZone GMT = TimeZone.getTimeZone("GMT");

    private static String toUiString(long localTime) {

        StringBuilder sb = new StringBuilder();

        java.util.Calendar dateTime = new GregorianCalendar(GMT);

        dateTime.setTimeInMillis(localTime);

        try {

            appendInt(sb, dateTime.get(java.util.Calendar.YEAR), 4);
            sb.append('-');
            appendInt(sb, dateTime.get(java.util.Calendar.MONTH) + 1, 2);
            sb.append('-');
            appendInt(sb, dateTime.get(java.util.Calendar.DAY_OF_MONTH), 2);

        } catch (ArrayIndexOutOfBoundsException e) {
            throw new RuntimeException(e);
        }

        return sb.toString();
    }

    private static void appendInt(StringBuilder sb, int num, int numDigits) {

        if (num < 0) {
            sb.append('-');
            num = -num;
        }

        char[] digits = new char[numDigits];
        for (int digit = numDigits - 1; digit >= 0; --digit) {
            digits[digit] = (char) ('0' + num % 10);
            num /= 10;
        }

        sb.append(digits);
    }
}