I’m working on a small app to sync a local calendar database with a Google Calendar using the GData API for Java. I’ll be keeping everyone up to date on this page.
Update: The code for GoogleSync DR1:
import gnu.getopt.Getopt;
import gnu.getopt.LongOpt;
import java.io.IOException;
import java.net.URL;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Date;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.List;
import com.google.gdata.client.calendar.CalendarService;
import com.google.gdata.data.DateTime;
import com.google.gdata.data.HtmlTextConstruct;
import com.google.gdata.data.PlainTextConstruct;
import com.google.gdata.data.calendar.CalendarEventEntry;
import com.google.gdata.data.extensions.When;
import com.google.gdata.util.AuthenticationException;
import com.google.gdata.util.ServiceException;
/*
* TODO add regex validation for Google Calendar username as valid e-mail address.
* TODO add help text.
* TODO add version message.
*/
/*
* This is very GNU-centric code. It's portable but will still behave as if it were a GNU command.
*/
/**
* @author imbrius An executable class that connects to a Drupal database,
* extracts events that are flagged to by synched with Google Calendar,
* connects to Google Calendar, and uploads the marked Drupal events.
*/
public class GoogleSync {
/**
* The string to send to Google Calendar as the organization's name. Cannot
* have spaces or dashes.
*/
public static final String ORGNAME = "lakemasoniccenter";
/**
* The string to send to Google Calendar as the name of the program. Cannot
* have spaces or dashes.
*/
public static final String PROGNAME = "googlesync";
/**
* The string to send to Google Calendar as the version of the program.
* Cannot have spaces or dashes.
*/
public static final String VERSION = "0.1";
/**
* The database (MySQL) server to connect to if none is specified.
*/
public static final String DEFAULT_DBHOST = "localhost";
/**
* The database schema to use if none is specified.
*/
public static final String DEFAULT_DBSCHEMA = "drupaldb";
/**
* The MySQL server port to use if none is specified.
*/
public static final int DEFAULT_DBPORT = 3306;
/**
* @param args
* the command line arguments
* @throws ImpossibleReturnException
* when a call to getopt() returns 0.
* @throws UnhandledCaseException
* when getopt() returns a character that doesn't match any
* known option case.
* @throws SQLException
* when an SQL query generates an error on the MySQL server.
* @throws ClassNotFoundException
* when the MySQL Connector/J driver cannot be loaded.
* @throws IOException
* when the app fails to connect to Google due to network
* errors.
*/
public static void main(String[] args) throws ImpossibleReturnException,
UnhandledCaseException, ClassNotFoundException, SQLException,
IOException {
String dbUser = null; // no reasonable default
String dbPass = ""; // allows for empty password
String dbHost = DEFAULT_DBHOST;
int dbPort = DEFAULT_DBPORT;
String dbSchema = DEFAULT_DBSCHEMA;
// this sets our default last modified date to be yesterday
Calendar gregorianCalendar = GregorianCalendar.getInstance();
gregorianCalendar.add(Calendar.DAY_OF_MONTH, -1);
Date lastChanged = new java.sql.Date(gregorianCalendar
.getTimeInMillis());
// command line argument parsing
try {
int c; // the character representing the option the user chose
LongOpt[] longopts = new LongOpt[8];
//
// StringBuffer sb = new StringBuffer(); // note - not StringBugger
// like
// I keep wanting to type!
longopts[0] = new LongOpt("help", LongOpt.NO_ARGUMENT, null, 'h');
longopts[1] = new LongOpt("version", LongOpt.NO_ARGUMENT, null, 'v');
longopts[2] = new LongOpt("dbuser", LongOpt.REQUIRED_ARGUMENT,
null, 'u');
longopts[3] = new LongOpt("dbpass", LongOpt.REQUIRED_ARGUMENT,
null, 'p');
longopts[4] = new LongOpt("dbhost", LongOpt.REQUIRED_ARGUMENT,
null, 'H');
longopts[5] = new LongOpt("dbport", LongOpt.REQUIRED_ARGUMENT,
null, 'P');
longopts[6] = new LongOpt("dbschema", LongOpt.REQUIRED_ARGUMENT,
null, 's');
longopts[7] = new LongOpt("lastchanged", LongOpt.REQUIRED_ARGUMENT,
null, 'e');
//
Getopt g = new Getopt(PROGNAME, args, "-:hvu:p:H:P:s:e:", longopts);
g.setOpterr(true); // complain to the user when a REQUIRED_ARGUMENT
// isn't present
//
while ((c = g.getopt()) != -1) {
switch (c) {
case 0:
// can't happen
throw new ImpossibleReturnException(
"A call to getopt() returned 0. This should not happen!");
case 1:
// someone tried to give us data without an option - doesn't
// work that way
throw new ArgumentException(
"Argument given without option.");
case 'h':
printHelpMessage();
System.exit(0);
break; // unreachable code
case 'v':
printVersionInfo();
System.exit(0);
break; // unreachable code
case 'u':
dbUser = g.getOptarg();
break;
case 'p':
dbPass = g.getOptarg();
break;
case 'H':
dbHost = g.getOptarg();
break;
case 'P':
dbPort = Integer.parseInt(g.getOptarg());
break;
case 's':
dbSchema = g.getOptarg();
break;
case 'e':
lastChanged = (Date) new SimpleDateFormat().parse(g
.getOptarg());
break;
case '?': // Getopt didn't find the option in the list of
// choices we gave it
throw new ArgumentException("The option "
+ (char) g.getOptopt() + " is not valid.");
default:
// shouldn't happen
throw new UnhandledCaseException(
"The programmer screwed up the command-line argument handling. This is a bug.");
}
}
for (int i = g.getOptind(); i < args.length; i++) {
// This shouldn't be able to happen - getopt() returns 1 when
// there's an option argument given with no option specified.
// But if the user terminates the options list with a -- and
// then puts stuff, getopt() will return -1 and break the loop
// and this code will handle the arguments after the --.
System.out
.println("I don't know what "
+ args[i]
+ " means so I'm ignoring it. I hope that's okay. If not, CTRL+C is your friend right now.");
}
}
catch (ArgumentException argex) {
System.err.println(argex.getMessage());
System.exit(-1);
}
catch (ParseException pex) {
System.err.println(pex.getMessage()
+ " Unable to parse the date format given.");
System.exit(-1);
}
try {
if (dbUser == null || dbPass == null || dbUser.isEmpty()) {
// I had to fix this to put the null checks first in case dbUser
// really is null. Calling isEmpty() would cause a
// NullPointerException.
System.err
.println("Username and password need to be specified.");
System.exit(-1);
}
List<Event> eventList = getMarkedEvents(dbUser, dbPass, dbHost,
dbPort, dbSchema, lastChanged);
List<ConnectionBucket> connectionBuckets = makeConnectionBuckets(eventList);
for (ConnectionBucket cb : connectionBuckets) {
CalendarService calService = new CalendarService(
makeGoogleAppString());
calService.setUserCredentials(cb.getUsername(), cb
.getPassword());
// This might be an old URL. If it fails with an HTTP exception,
// try changing this.
// TODO look up how to post events to a calendar other than the
// user's private calendar.
URL postUrl = new URL("http://www.google.com/calendar/feeds/"
+ dbUser + "/private/full");
for (Event e : cb) {
try {
CalendarEventEntry entry = new CalendarEventEntry();
entry.setTitle(new PlainTextConstruct(e.getTitle()));
entry.setContent(new HtmlTextConstruct(e.getBody()));
DateTime startTime = DateTime.parseDateTime(e
.getEventStart().toString());
DateTime endTime = DateTime.parseDateTime(e
.getEventEnd().toString());
When eventTimes = new When();
eventTimes.setStartTime(startTime);
eventTimes.setEndTime(endTime);
entry.addTime(eventTimes);
calService.insert(postUrl, entry);
System.out.println("Inserted entry " + e.toString());
}
catch (ServiceException sex) {
System.err.println("Insert failed for event "
+ e.toString() + ". Google said: "
+ sex.getMessage());
}
}
}
}
catch (IllegalPortException ipex) {
System.err.println(ipex.getMessage());
System.exit(-1);
}
catch (AuthenticationException authex) {
System.err.println(authex.getMessage());
System.exit(-1);
}
}
/**
* Combines the organization name, program name, and version to send to
* Google.
*
* @return the formatted Google App string
*/
private static final String makeGoogleAppString() {
return new String(ORGNAME + "-" + PROGNAME + "-" + VERSION);
}
/**
* Prints the version info to the command line.
*
*/
private static void printVersionInfo() {
// TODO Auto-generated method stub
}
/**
* Prints the GNU help message to the command line.
*
*/
private static void printHelpMessage() {
// TODO Auto-generated method stub
}
/**
* Checks for similarities in event metadata and bunches them together to
* minimize the number of connections needed to Google.
*
* @param eventList
* the list of events to check
* @return a list of ConnectionBuckets, each with similar events in them
*/
private static List<ConnectionBucket> makeConnectionBuckets(
List<Event> eventList) {
List<ConnectionBucket> connectionBuckets = new ArrayList<ConnectionBucket>();
for (Event e : eventList) {
boolean bucketFound = false;
for (ConnectionBucket cb : connectionBuckets) {
if (e.getCalendarConnection().equals(cb.toString())) {
// We found a bucket that matches this event, so add it.
cb.add(e);
bucketFound = true;
}
}
if (!bucketFound) {
// We did not find a bucket to match the event, so make one and
// then add the event to it.
ConnectionBucket cb = new ConnectionBucket();
cb.setCalendarName(e.getGoogleCalendarName());
cb.setPassword(e.getGoogleCalendarPassword());
cb.setUsername(e.getGoogleCalendarUsername());
cb.add(e);
connectionBuckets.add(cb);
}
}
return connectionBuckets;
}
/**
* Fetches the marked events from the database.
*
* @param dbUser
* the database username
* @param dbPass
* the database password
* @param dbHost
* the database server hostname
* @param dbPort
* the MySQL server port
* @param dbSchema
* the schema to use
* @param lastChanged
* the minimum last changed date to fetch
* @return a list of events marked for Google Calendar sync
* @throws ClassNotFoundException
* when the JRE can't load the MySQL Connector/J driver.
* @throws SQLException
* when an SQL statement causes an error on the MySQL server.
* @throws IllegalPortException
* when the specified port number is less than 1 or greater than
* 65535.
*/
private static List<Event> getMarkedEvents(String dbUser, String dbPass,
String dbHost, int dbPort, String dbSchema, Date lastChanged)
throws ClassNotFoundException, SQLException, IllegalPortException {
Class.forName("com.mysql.jdbc.Driver"); // MySQL Connector/J driver
// this code is redundant
// if (dbHost.isEmpty() || dbHost == null) {
// dbHost = "localhost";
// }
if (dbPort < 1 || dbPort > 65535) {
throw new IllegalPortException(
"Port number must be between 1 and 65535.");
}
Connection con = DriverManager.getConnection("jdbc:mysql://" + dbHost
+ ":" + dbPort + "/" + dbSchema, dbUser, dbPass);
CallableStatement cs = con
.prepareCall("{call lake_get_google_events(?)}");
ResultSet rs;
List<Event> markedEvents = new ArrayList<Event>();
cs.setDate(0, lastChanged);
rs = cs.executeQuery();
while (rs.next()) {
Event evt = new Event();
evt.setBody(rs.getString("body"));
evt.setEventEnd(rs.getDate("event_end"));
evt.setEventStart(rs.getDate("event_start"));
evt.setGoogleCalendarName(rs
.getString("field_google_calendar_name_value"));
evt.setGoogleCalendarPassword(rs
.getString("field_google_calendar_password_value"));
evt.setGoogleCalendarUsername(rs
.getString("field_google_calendar_username_value"));
evt.setNid(rs.getInt("nid"));
evt.setTeaser(rs.getString("teaser"));
evt.setTimezone(rs.getString("timezone"));
evt.setTitle(rs.getString("title"));
evt.setCreated(rs.getDate("created"));
evt.setChanged(rs.getDate("changed"));
markedEvents.add(evt);
}
con.close();
return markedEvents;
}
}
Update: Proof of Concept: It’s on like Diddy Kong!
I created a document in GoogleDocs and saved it as “It’s on like diddy kong.” When I run the test code that should display the titles of all my docs, it does indeed show my diddy kong document. GData API is a go!
Update: The last two Sequence Diagrams:
Specifying Criteria is assumed in list mode – if list mode is invoked without criteria-specifying options, then it does the listing using the Default Case below.
Update: Sequence Diagram for Default Case:
Update: New class diagram:
Here’s the class diagram so far:
Here’s the code I’ve written so far:
/**
*
*/
package authentication;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.mail.internet.ParseException;
/**
* @author imbrius
*
*/
public class UserCredentials {
public static final String EMAIL_REGEX = ".+@.+\\.[a-z]+";
public static Pattern pattern;
private String userName = "";
private String password = "";
static {
pattern = Pattern.compile(EMAIL_REGEX);
}
/**
* Default constructor
*/
public UserCredentials() {
}
/**
*
* @param userName
* the userName to set
* @param password
* the password to set
* @throws ParseException
* if userName fails validation as e-mail address.
*/
public UserCredentials(String userName, String password)
throws ParseException {
setUserName(userName);
setPassword(password);
}
/**
* @param userName
* the userName to set
* @throws ParseException
* if userName fails validation as e-mail address.
*/
public void setUserName(String userName) throws ParseException {
if (validateUserName(userName)) {
this.userName = userName;
}
else {
throw new ParseException(
"Invalid E-mail address format. Username must be a valid E-mail address.");
}
}
/**
* @return the userName
*/
public String getUserName() {
return userName;
}
/**
* @param password
* the password to set
*/
public void setPassword(String password) {
this.password = password;
}
/**
* @return the password
*/
public String getPassword() {
return password;
}
private static boolean validateUserName(String uName) {
Matcher matcher = pattern.matcher(uName);
boolean found = false;
while (matcher.find()) {
found = true;
}
return found;
}
/**
* @return the userName
*/
public String toString() {
return getUserName();
}
}
And the JUnit test code for this class:
/**
package authentication;
import javax.mail.internet.ParseException;
import junit.framework.TestCase;
/**
* @author imbrius
*
*/
public class UserCredentialsTest extends TestCase {
public static final String TEST_USERNAME = "test@fake.net";
public static final String TEST_PASSWORD = "password";
/**
* @param name
*/
public UserCredentialsTest(String name) {
super(name);
}
/*
* (non-Javadoc)
*
* @see junit.framework.TestCase#setUp()
*/
protected void setUp() throws Exception {
super.setUp();
}
/*
* (non-Javadoc)
*
* @see junit.framework.TestCase#tearDown()
*/
protected void tearDown() throws Exception {
super.tearDown();
}
/**
* Test method for {@link authentication.UserCredentials#UserCredentials()}.
*/
public final void testUserCredentials() {
new UserCredentials();
}
/**
* Test method for
* {@link authentication.UserCredentials#UserCredentials(java.lang.String, java.lang.String)}
* .
*/
public final void testUserCredentialsStringString() {
try {
new UserCredentials(TEST_USERNAME, TEST_PASSWORD);
} catch (ParseException e) {
// Auto-generated catch block
e.printStackTrace();
}
}
/**
* Test method for
* {@link authentication.UserCredentials#setUserName(java.lang.String)}.
*/
public final void testSetUserName() {
UserCredentials uc = new UserCredentials();
try {
uc.setUserName(TEST_USERNAME);
} catch (ParseException e) {
// Auto-generated catch block
e.printStackTrace();
}
if (!uc.getUserName().equals(TEST_USERNAME)) {
fail("Incorrect username.");
}
}
/**
* Test method for {@link authentication.UserCredentials#getUserName()}.
*/
public final void testGetUserName() {
try {
UserCredentials uc = new UserCredentials(TEST_USERNAME,
TEST_PASSWORD);
if (!uc.getUserName().equals(TEST_USERNAME)) {
fail("Incorrect username.");
}
} catch (ParseException e) {
// Auto-generated catch block
e.printStackTrace();
}
}
/**
* Test method for
* {@link authentication.UserCredentials#setPassword(java.lang.String)}.
*/
public final void testSetPassword() {
UserCredentials uc = new UserCredentials();
uc.setPassword(TEST_PASSWORD);
if (!uc.getPassword().equals(TEST_PASSWORD)) {
fail("Incorrect password.");
}
}
/**
* Test method for {@link authentication.UserCredentials#getPassword()}.
*/
public final void testGetPassword() {
try {
UserCredentials uc = new UserCredentials(TEST_USERNAME,
TEST_PASSWORD);
if (!uc.getPassword().equals(TEST_PASSWORD)) {
fail("Incorrect password.");
}
} catch (ParseException e) {
// Auto-generated catch block
e.printStackTrace();
}
}
/**
* Test method for {@link authentication.UserCredentials#toString()}.
*/
public final void testToString() {
try {
UserCredentials uc = new UserCredentials(TEST_USERNAME,
TEST_PASSWORD);
if (!uc.toString().equals(TEST_USERNAME)) {
fail("Incorrect username.");
}
} catch (ParseException e) {
// Auto-generated catch block
e.printStackTrace();
}
}
/**
* Test method for {@link java.lang.Object#hashCode()}.
*/
public final void testHashCode() {
int hashSample1 = 0;
int hashSample2 = 0;
int hashSample3 = 0;
try {
UserCredentials uc1 = new UserCredentials(TEST_USERNAME,
TEST_PASSWORD);
UserCredentials uc2 = new UserCredentials();
hashSample1 = uc1.hashCode();
hashSample2 = uc2.hashCode();
if (hashSample1 == hashSample2) {
fail("Hash code not unique to object instance.");
}
hashSample3 = uc1.hashCode();
if (hashSample3 != hashSample1) {
fail("Hash code changed for same object instance.");
}
} catch (ParseException e) {
// Auto-generated catch block
e.printStackTrace();
}
}
/**
* Test method for {@link java.lang.Object#equals(java.lang.Object)}.
*/
public final void testEquals() {
try {
UserCredentials uc1 = new UserCredentials(TEST_USERNAME,
TEST_PASSWORD);
UserCredentials uc2 = new UserCredentials("test2@fake.net",
"password2");
if (uc1.equals(uc2)) {
fail("Different values tested equal.");
}
} catch (ParseException e) {
// Auto-generated catch block
e.printStackTrace();
fail(e.getMessage());
}
}
}






#1 by Joshua on April 13, 2010 - 6:56 AM
I should probably do proper encapsulation for the Pattern so it can’t be modified outside of the class.
#2 by Joshua on May 7, 2010 - 1:15 PM
Gah! Posting code in wordpress is worse than coding in EMACS on an 80×20 terminal.
#3 by Joshua on May 7, 2010 - 1:25 PM
Also, I forgot that I needed to change
to
So everywhere you see a naked List or ArrayList in the above code, it should have an <Event> after it.
#4 by Nikolay Bachiyski on August 26, 2010 - 2:36 PM
Here is how can easily post source code:
http://en.support.wordpress.com/code/posting-source-code/
You won’t have to encode anything again
#5 by Joshua on August 26, 2010 - 4:27 PM
Hell yeah! I got some editing to do now.