[california] Quick add event: Closes bgo#725792



commit b273a50c85cd23a4e7e6a8d9c38b2066c3a40d3e
Author: Jim Nelson <jim yorba org>
Date:   Tue Apr 22 17:35:36 2014 -0700

    Quick add event: Closes bgo#725792
    
    Basic parser will fill in relevant event information and add to
    calendar.

 src/Makefile.am                                |   13 +
 src/application/main.vala                      |    2 +-
 src/base/base-properties.vala                  |   31 ++
 src/calendar/calendar-date.vala                |   14 +
 src/calendar/calendar-day-of-week.vala         |   24 ++-
 src/calendar/calendar-duration.vala            |   86 +++++
 src/calendar/calendar-month.vala               |   25 ++-
 src/calendar/calendar-wall-time.vala           |   71 ++++
 src/calendar/calendar.vala                     |   20 ++
 src/california-resources.xml                   |    3 +
 src/collection/collection-lookahead-stack.vala |  133 ++++++++
 src/collection/collection.vala                 |   22 ++
 src/component/component-details-parser.vala    |  422 ++++++++++++++++++++++++
 src/component/component-event.vala             |   19 +
 src/component/component-instance.vala          |   10 +
 src/component/component.vala                   |   63 ++++
 src/host/host-create-update-event.vala         |   16 +-
 src/host/host-main-window.vala                 |   45 ++-
 src/host/host-quick-create-event.vala          |   78 +++++
 src/rc/quick-create-event.ui                   |  165 +++++++++
 src/tests/tests-quick-add.vala                 |  143 ++++++++
 src/tests/tests.vala                           |   16 +
 src/toolkit/toolkit-combo-box-text-model.vala  |  166 ++++++++++
 src/unit-test/unit-test-harness.vala           |  143 ++++++++
 src/util/util-string.vala                      |   25 ++
 vapi/libical.vapi                              |    2 +-
 26 files changed, 1731 insertions(+), 26 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index f535a81..57105f0 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -43,6 +43,7 @@ california_VALASOURCES = \
        backing/eds/backing-eds-store.vala \
        \
        base/base-object.vala \
+       base/base-properties.vala \
        base/base-unit.vala \
        \
        calendar/calendar.vala \
@@ -51,6 +52,7 @@ california_VALASOURCES = \
        calendar/calendar-day-of-week.vala \
        calendar/calendar-date.vala \
        calendar/calendar-dbus.vala \
+       calendar/calendar-duration.vala \
        calendar/calendar-error.vala \
        calendar/calendar-exact-time.vala \
        calendar/calendar-exact-time-span.vala \
@@ -67,12 +69,15 @@ california_VALASOURCES = \
        calendar/calendar-week-span.vala \
        calendar/calendar-year.vala \
        \
+       collection/collection.vala \
        collection/collection-iterable.vala \
+       collection/collection-lookahead-stack.vala \
        collection/collection-simple-iterator.vala \
        collection/collection-simple-iterable.vala \
        \
        component/component.vala \
        component/component-date-time.vala \
+       component/component-details-parser.vala \
        component/component-error.vala \
        component/component-event.vala \
        component/component-icalendar.vala \
@@ -85,6 +90,7 @@ california_VALASOURCES = \
        host/host-create-update-event.vala \
        host/host-import-calendar.vala \
        host/host-main-window.vala \
+       host/host-quick-create-event.vala \
        host/host-show-event.vala \
        \
        manager/manager.vala \
@@ -92,9 +98,13 @@ california_VALASOURCES = \
        manager/manager-calendar-list-item.vala \
        manager/manager-window.vala \
        \
+       tests/tests.vala \
+       tests/tests-quick-add.vala \
+       \
        toolkit/toolkit.vala \
        toolkit/toolkit-calendar-popup.vala \
        toolkit/toolkit-card.vala \
+       toolkit/toolkit-combo-box-text-model.vala \
        toolkit/toolkit-deck.vala \
        toolkit/toolkit-deck-window.vala \
        toolkit/toolkit-listbox-model.vala \
@@ -106,6 +116,8 @@ california_VALASOURCES = \
        util/util-string.vala \
        util/util-uri.vala \
        \
+       unit-test/unit-test-harness.vala \
+       \
        view/view.vala \
        view/view-controllable.vala \
        \
@@ -131,6 +143,7 @@ california_RC = \
        rc/google-authenticating.ui \
        rc/google-calendar-list.ui \
        rc/google-login.ui \
+       rc/quick-create-event.ui \
        rc/show-event.ui \
        rc/webcal-subscribe.ui \
        $(NULL)
diff --git a/src/application/main.vala b/src/application/main.vala
index 1211e0a..61e5a77 100644
--- a/src/application/main.vala
+++ b/src/application/main.vala
@@ -5,6 +5,6 @@
  */
 
 int main(string[] args) {
-    return California.Application.instance.run(args);
+    return args[1] != "--tests" ? California.Application.instance.run(args) : California.Tests.run(args);
 }
 
diff --git a/src/base/base-properties.vala b/src/base/base-properties.vala
new file mode 100644
index 0000000..f71a691
--- /dev/null
+++ b/src/base/base-properties.vala
@@ -0,0 +1,31 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * Helper functions for using GObject properties and bindings.
+ */
+
+namespace California.Properties {
+
+public delegate bool ValueToBoolCallback(Value source_value);
+
+/**
+ * Simplified binding transformation of a property of any value to a boolean.
+ *
+ * The transformation is always considered successful.  Use bind_property directly if finer control
+ * is required.
+ */
+public void value_to_bool(Object source, string source_property, Object target, string target_property,
+    BindingFlags flags, ValueToBoolCallback cb) {
+    source.bind_property(source_property, target, target_property, flags, (binding, source, ref target) => {
+        target = cb(source);
+        
+        return true;
+    });
+}
+
+}
+
diff --git a/src/calendar/calendar-date.vala b/src/calendar/calendar-date.vala
index c9166b0..5ba97ba 100644
--- a/src/calendar/calendar-date.vala
+++ b/src/calendar/calendar-date.vala
@@ -250,6 +250,20 @@ public class Date : BaseObject, Gee.Comparable<Date>, Gee.Hashable<Date> {
         return new Date.from_gdate(clone);
     }
     
+    /**
+     * Returns the next date;
+     */
+    public Date next() {
+        return adjust(1, DateUnit.DAY);
+    }
+    
+    /**
+     * Returns the previous date.
+     */
+    public Date previous() {
+        return adjust(-1, DateUnit.DAY);
+    }
+    
     public int compare_to(Date other) {
         return (this != other) ? gdate.compare(other.gdate) : 0;
     }
diff --git a/src/calendar/calendar-day-of-week.vala b/src/calendar/calendar-day-of-week.vala
index e5d6f2f..4c2024c 100644
--- a/src/calendar/calendar-day-of-week.vala
+++ b/src/calendar/calendar-day-of-week.vala
@@ -59,6 +59,8 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
     private static DayOfWeek[]? days_of_week_monday = null;
     private static DayOfWeek[]? days_of_week_sunday = null;
     
+    private static Gee.Map<string, DayOfWeek> parse_map;
+    
     /**
      * The abbreviated locale-specific name for the day of the week.
      */
@@ -110,10 +112,18 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
             date.add_days(1);
         }
         
+        parse_map = new Gee.HashMap<string, DayOfWeek>(String.ci_hash, String.ci_equal);
+        
         // Following GLib's lead, days of week Monday-first is straightforward
         days_of_week_monday = new DayOfWeek[COUNT];
-        for (int ctr = MIN; ctr <= MAX; ctr++)
-            days_of_week_monday[ctr - MIN] = new DayOfWeek(ctr, abbrevs[ctr - MIN], fulls[ctr - MIN]);
+        for (int ctr = MIN; ctr <= MAX; ctr++) {
+            DayOfWeek dow = new DayOfWeek(ctr, abbrevs[ctr - MIN], fulls[ctr - MIN]);
+            days_of_week_monday[ctr - MIN] = dow;
+            
+            // add to parse map by abbreivated and full name
+            parse_map.set(dow.abbrev_name, dow);
+            parse_map.set(dow.full_name, dow);
+        }
         
         MON = days_of_week_monday[0];
         TUE = days_of_week_monday[1];
@@ -180,6 +190,16 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
     }
     
     /**
+     * Parses the string looking for a match with any of the { link DayOfWeek}'s { link abbrev_name}
+     * or { link full_name}.
+     *
+     * parse() is case-insensitive.
+     */
+    public static DayOfWeek? parse(string str) {
+        return parse_map.get(str);
+    }
+    
+    /**
      * The one-based ordinal value of the day of the week, depended on what the definition of
      * the first day of the week.
      */
diff --git a/src/calendar/calendar-duration.vala b/src/calendar/calendar-duration.vala
new file mode 100644
index 0000000..7eae77d
--- /dev/null
+++ b/src/calendar/calendar-duration.vala
@@ -0,0 +1,86 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Calendar {
+
+/**
+ * An immutable representation of duration, as in a positive span of time.
+ *
+ * See [[https://tools.ietf.org/html/rfc5545#section-3.8.2.5]]
+ */
+
+public class Duration : BaseObject {
+    /**
+     * Number of absolute days the duration spans.
+     */
+    public uint days { get { return hours / WallTime.HOURS_PER_DAY; } }
+    
+    /**
+     * Number of absolute hours the duration spans.
+     */
+    public uint hours { get { return minutes / WallTime.MINUTES_PER_HOUR; } }
+    
+    /**
+     * Number of absolute minutes the duration spans.
+     */
+    public uint minutes { get { return seconds / WallTime.SECONDS_PER_MINUTE; } }
+    
+    /**
+     * Number of absolute seconds the duration spans.
+     */
+    public uint seconds { get; private set; }
+    
+    public Duration(uint days = 0, uint hours = 0, uint minutes = 0, uint seconds = 0) {
+        // internally stored as seconds
+        this.seconds =
+            (days * WallTime.SECONDS_PER_MINUTE * WallTime.MINUTES_PER_HOUR * WallTime.HOURS_PER_DAY)
+            + (hours * WallTime.SECONDS_PER_MINUTE * WallTime.MINUTES_PER_HOUR)
+            + (minutes * WallTime.SECONDS_PER_MINUTE)
+            + seconds;
+    }
+    
+    /**
+     * Parses the two tokens into a { link Duration}.
+     *
+     * parse() is looking for a pattern where the first token is a number and the second a string
+     * of units of time (localized), either hours, minutes, or seconds.  null is returned if that
+     * pattern is not located.
+     *
+     * Future expansion could include a pattern where the first token has a unit as a suffix, i.e.
+     * "3hrs" or "4m".
+     *
+     * It's possible for this call to return a Duration of zero time.
+     */
+    public static Duration? parse(string value, string unit) {
+        if (String.is_empty(value) || String.is_empty(unit))
+            return null;
+        
+        if (!String.is_numeric(value))
+            return null;
+        
+        int duration = int.parse(value);
+        if (duration < 0)
+            return null;
+        
+        if (unit in UNIT_DAYS)
+            return new Duration(duration);
+        
+        if (unit in UNIT_HOURS)
+            return new Duration(0, duration);
+        
+        if (unit in UNIT_MINS)
+            return new Duration(0, 0, duration);
+        
+        return null;
+    }
+    
+    public override string to_string() {
+        return "%us".printf(seconds);
+    }
+}
+
+}
+
diff --git a/src/calendar/calendar-month.vala b/src/calendar/calendar-month.vala
index 5a62adc..536cad9 100644
--- a/src/calendar/calendar-month.vala
+++ b/src/calendar/calendar-month.vala
@@ -24,6 +24,8 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
     public static Month NOV;
     public static Month DEC;
     
+    private static Gee.Map<string, Month> parse_map;
+    
     public const int MIN = 1;
     public const int MAX = 12;
     public const int COUNT = MAX - MIN + 1;
@@ -78,9 +80,17 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
     }
     
     internal static void init() {
+        parse_map = new Gee.HashMap<string, Month>(String.ci_hash, String.ci_equal);
+        
         months = new Month[COUNT];
-        for (int ctr = MIN; ctr <= MAX; ctr++)
-            months[ctr - MIN] = new Month(ctr);
+        for (int ctr = MIN; ctr <= MAX; ctr++) {
+            Month month = new Month(ctr);
+            months[ctr - MIN] = month;
+            
+            // build parse map of abbreviated and full name to the Month
+            parse_map.set(month.abbrev_name, month);
+            parse_map.set(month.full_name, month);
+        }
         
         JAN = months[0];
         FEB = months[1];
@@ -97,6 +107,7 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
     }
     
     internal static void terminate() {
+        parse_map = null;
         months = null;
         JAN = FEB = MAR = APR = MAY = JUN = JUL = AUG = SEP = OCT = NOV = DEC = null;
     }
@@ -131,6 +142,16 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
         return for_checked(gdate.get_month());
     }
     
+    /**
+     * Compares the supplied string with all translated { link Month} names, both { link abbrev_name}
+     * and { link full_name}.
+     *
+     * parse() is case-insensitive.
+     */
+    public static Month? parse(string str) {
+        return parse_map.get(str);
+    }
+    
     internal inline DateMonth to_date_month() {
         return (DateMonth) value;
     }
diff --git a/src/calendar/calendar-wall-time.vala b/src/calendar/calendar-wall-time.vala
index 7eecb7b..6ff682c 100644
--- a/src/calendar/calendar-wall-time.vala
+++ b/src/calendar/calendar-wall-time.vala
@@ -92,6 +92,8 @@ public class WallTime : BaseObject, Gee.Comparable<WallTime>, Gee.Hashable<WallT
     /**
      * Generate a new { link WallTime} object with the specified values.
      *
+     * Note that hour must be in 24-hour time.
+     *
      * Values will be clamped to create a valid time.
      */
     public WallTime(int hour, int minute, int second) {
@@ -134,6 +136,75 @@ public class WallTime : BaseObject, Gee.Comparable<WallTime>, Gee.Hashable<WallT
     }
     
     /**
+     * Attempt to convert a string into { link WallTime}.
+     *
+     * 24-hour and 12-hour time is recognized, as are localized versions of AM and PM.  If the time
+     * was "liberally" parsed (in other words, "8" is converted to 8am), the returned flag is
+     * cleared.
+     */
+    public static WallTime? parse(string str, out bool strictly_parsed) {
+        strictly_parsed = false;
+        
+        string token = str.strip().casefold();
+        if (String.is_empty(token))
+            return null;
+        
+        // look for meridiem tacked on to end
+        bool pm = false;
+        bool meridiem_unknown = false;
+        if (token.has_suffix(FMT_AM.casefold())) {
+            token = token.slice(0, token.length - FMT_AM.casefold().length);
+        } else if (token.has_suffix(FMT_BRIEF_AM.casefold())) {
+            token = token.slice(0, token.length - FMT_BRIEF_AM.casefold().length);
+        } else if (token.has_suffix(FMT_PM.casefold())) {
+            token = token.slice(0, token.length - FMT_PM.casefold().length);
+            pm = true;
+        } else if (token.has_suffix(FMT_BRIEF_PM.casefold())) {
+            token = token.slice(0, token.length - FMT_BRIEF_PM.casefold().length);
+            pm = true;
+        } else {
+            meridiem_unknown = true;
+        }
+        
+        // remove colon (can be present for 12- or 24-hour time)
+        token = token.replace(":", "");
+        int length = token.length;
+        
+        // rest of string better be numeric and under the common lengths for specifying time
+        if (!String.is_numeric(token) || length == 0 || length > 4)
+            return null;
+        
+        // look for 24-hour time or a fully-detailed 12-hour time
+        if ((length == 3 || length == 4)) {
+            int h, m;
+            if (length == 3) {
+                h = int.parse(token.slice(0, 1));
+                m = int.parse(token.slice(1, 3));
+            } else {
+                h = int.parse(token.slice(0, 2));
+                m = int.parse(token.slice(2, 4));
+            }
+            
+            if (!meridiem_unknown && pm)
+                h += 12;
+            
+            strictly_parsed = true;
+            
+            return new WallTime(h, m, 0);
+        }
+        
+        // otherwise, treat as short-form 12-hour time (even if meridiem is unknown, i.e. "8" is
+        // treated as "8:00am"
+        int h = int.parse(token);
+        if (!meridiem_unknown && pm)
+            h += 12;
+        
+        strictly_parsed = !meridiem_unknown;
+        
+        return new WallTime(h, 0, 0);
+    }
+    
+    /**
      * Returns { link WallTime} adjusted before or after this one.
      *
      * To subtract time, use a negative value.
diff --git a/src/calendar/calendar.vala b/src/calendar/calendar.vala
index cafb6cb..112066e 100644
--- a/src/calendar/calendar.vala
+++ b/src/calendar/calendar.vala
@@ -39,6 +39,10 @@ private static unowned string FMT_12HOUR_MIN_SEC_MERIDIEM;
 private static unowned string FMT_24HOUR_MIN;
 private static unowned string FMT_24HOUR_MIN_SEC;
 
+private string[] UNIT_DAYS;
+private string[] UNIT_HOURS;
+private string[] UNIT_MINS;
+
 public void init() throws Error {
     if (!California.Unit.do_init(ref init_count))
         return;
@@ -132,6 +136,18 @@ public void init() throws Error {
     /// The 24-hour time with minutes and seconds, i.e. "17:06:31"
     FMT_24HOUR_MIN_SEC = _("%d:%02d:%02d");
     
+    // Used by quick-add to convert a user's day unit into an internal value.  Common abbreviations
+    // (without punctuation) should be included.  Each word must be separated by semi-colons.
+    UNIT_DAYS = _("day;days;").casefold().split(";");
+    
+    // Used by quick-add to convert a user's hours unit into an internal value.  Common abbreviations
+    // (without punctuation) should be included.  Each word must be separated by semi-colons.
+    UNIT_HOURS = _("hour;hours;hr;hrs").casefold().split(";");
+    
+    // Used by quick-add to convert a user's minute unit into an internal value.  Common abbreviations
+    // (without punctuation) should be included.  Each word must be separated by semi-colons.
+    UNIT_MINS = _("minute;minutes;min;mins").casefold().split(";");
+    
     // return LC_MESSAGES back to proper locale and return LANGUAGE environment variable
     if (messages_locale != null)
         Intl.setlocale(LocaleCategory.MESSAGES, messages_locale);
@@ -142,6 +158,7 @@ public void init() throws Error {
     System.preinit();
     
     // internal initialization
+    Collection.init();
     OlsonZone.init();
     DayOfWeek.init();
     DayOfMonth.init();
@@ -162,6 +179,9 @@ public void terminate() {
     DayOfMonth.terminate();
     DayOfWeek.terminate();
     OlsonZone.terminate();
+    Collection.terminate();
+    
+    UNIT_DAYS = UNIT_HOURS = UNIT_MINS = null;
 }
 
 }
diff --git a/src/california-resources.xml b/src/california-resources.xml
index 03bb412..9bae540 100644
--- a/src/california-resources.xml
+++ b/src/california-resources.xml
@@ -31,6 +31,9 @@
         <file compressed="true">rc/google-login.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
+        <file compressed="true">rc/quick-create-event.ui</file>
+    </gresource>
+    <gresource prefix="/org/yorba/california">
         <file compressed="false">rc/show-event.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
diff --git a/src/collection/collection-lookahead-stack.vala b/src/collection/collection-lookahead-stack.vala
new file mode 100644
index 0000000..88bcf24
--- /dev/null
+++ b/src/collection/collection-lookahead-stack.vala
@@ -0,0 +1,133 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Collection {
+
+/**
+ * A remove-only stack of elements that allows for marking (saving state) and restoration.
+ *
+ * To make saving and restoring as efficient as possible, additions are not possible with this
+ * collection.  The stack is initialized with elements in the constructor.  Thereafter, elements
+ * may only be { link pop}ped and elements added back via { link restore}.
+ */
+
+public class LookaheadStack<G> : BaseObject {
+    /**
+     * Returns true if no elements are in the queue.
+     */
+    public bool is_empty { get { return stack.is_empty; } }
+    
+    /**
+     * Returns the number of elements remaining in the stack.
+     */
+    public int size { get { return stack.size; } }
+    
+    /**
+     * Returns number of saved markpoints.
+     *
+     * @see mark
+     */
+    public int markpoint_count { get { return markpoints.size + (markpoint != null ? 1 : 0); } }
+    
+    /**
+     * Returns the current element at the top of the stack.
+     */
+    public G? top { owned get { return stack.peek_head(); } }
+    
+    private Gee.Deque<G> stack;
+    private Gee.Deque<Gee.Deque<G>>? markpoints;
+    private Gee.Deque<G>? markpoint = null;
+    
+    public LookaheadStack(Gee.Collection<G> init) {
+        // must be initialized here; see
+        // https://bugzilla.gnome.org/show_bug.cgi?id=523767
+        stack = new Gee.LinkedList<G>();
+        stack.add_all(init);
+        
+        markpoints = new Gee.LinkedList<Gee.Deque<G>>();
+    }
+    
+    /**
+     * Returns null if empty.
+     */
+    public G? pop() {
+        if (stack.is_empty)
+            return null;
+        
+        G element = stack.poll_head();
+        
+        // if markpoint set, save element for later
+        if (markpoint != null)
+            markpoint.offer_head(element);
+        
+        return element;
+    }
+    
+    /**
+     * Marks the state of the stack so it can be restored with { link restore}.
+     *
+     * Multiple markpoints can be made, each requiring a matching { link restore} to return to the
+     * state.
+     */
+    public void mark() {
+        if (markpoint != null)
+            markpoints.offer_head(markpoint);
+        
+        markpoint = new Gee.LinkedList<G>();
+    }
+    
+    /**
+     * Restores the state of the stack to the point when the last markpoint was made.
+     *
+     * This does nothing if { link mark} was not first called.
+     */
+    public void restore() {
+        if (markpoint != null) {
+            // restore elements as stored in marked queue
+            while (!markpoint.is_empty)
+                stack.offer_head(markpoint.poll_head());
+        }
+        
+        // pop last marked state, if any, as the current marked state
+        pop_markpoint();
+    }
+    
+    /**
+     * Drops the last markpoint, if any.
+     *
+     * This is functionally equivalent to { link restore}, but the current markpoint elements are
+     * not added back to the stack.  Prior markpoints remain.
+     *
+     * @see mark
+     */
+    public void unmark() {
+        pop_markpoint();
+    }
+    
+    /**
+     * Drops all markpoints.
+     *
+     * @see mark
+     */
+    public void clear_markpoints() {
+        markpoint = null;
+        markpoints.clear();
+    }
+    
+    private void pop_markpoint() {
+        if (!markpoints.is_empty)
+            markpoint = markpoints.poll_head();
+        else
+            markpoint = null;
+    }
+    
+    public override string to_string() {
+        return "LookaheadStack (%d elements)".printf(size);
+    }
+}
+
+}
+
diff --git a/src/collection/collection.vala b/src/collection/collection.vala
new file mode 100644
index 0000000..ba28f21
--- /dev/null
+++ b/src/collection/collection.vala
@@ -0,0 +1,22 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Collection {
+
+private int init_count = 0;
+
+public void init() throws Error {
+    if (!Unit.do_init(ref init_count))
+        return;
+}
+
+public void terminate() {
+    if (!Unit.do_terminate(ref init_count))
+        return;
+}
+
+}
+
diff --git a/src/component/component-details-parser.vala b/src/component/component-details-parser.vala
new file mode 100644
index 0000000..2811fb5
--- /dev/null
+++ b/src/component/component-details-parser.vala
@@ -0,0 +1,422 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Component {
+
+/**
+ * Parse the details of a user-entered string into an { link Event}.
+ *
+ * DetailsParser makes no claims of natural language parsing or interpretation.  It merely
+ * looks for keywords and patterns within the tokenized stream and guesses what Event details
+ * they refer to.
+ *
+ * The fields the parser attempts to fill-in are { link Event.date_span} (or
+ * { link Event.exact_time_span}, { link Event.summary}, and { link Event.location}.  Other fields
+ * may be considered in the future.
+ */
+
+public class DetailsParser : BaseObject {
+    private class Token : BaseObject, Gee.Hashable<Token> {
+        public string original;
+        public string casefolded;
+        
+        public Token(string token) {
+            original = token;
+            casefolded = token.casefold();
+        }
+        
+        public bool equal_to(Token other) {
+            return (this != other) ? original == other.original : true;
+        }
+        
+        public uint hash() {
+            return original.hash();
+        }
+        
+        public override string to_string() {
+            return original;
+        }
+    }
+    
+    /**
+     * The original string of text generating the { link event}.
+     *
+     * If null is passed to constructor, this will be the empty string.
+     */
+    public string details { get; private set; }
+    
+    /**
+     * The generated { link Event}.
+     */
+    public Component.Event event { get; private set; default = new Component.Event.blank(); }
+    
+    private Collection.LookaheadStack<Token> stack;
+    private StringBuilder summary = new StringBuilder();
+    private StringBuilder location = new StringBuilder();
+    private Calendar.WallTime? start_time = null;
+    private bool start_time_strict = true;
+    private Calendar.WallTime? end_time = null;
+    private bool end_time_strict = true;
+    private Calendar.Date? start_date = null;
+    private Calendar.Date? end_date = null;
+    private Calendar.Duration? duration = null;
+    private bool adding_location = false;
+    
+    /**
+     * Parses a user-entered string of { link Event} details into an Event.
+     *
+     * This always generates an Event, but very little in it may be available.  Its backup case
+     * is to use the details string as a summary and leave all other fields empty.  The caller
+     * should complete the other fields to generate a valid VEVENT.
+     *
+     * If the details string is empty, a blank Event is generated.
+     */
+    public DetailsParser(string? details, Backing.CalendarSource? calendar_source) {
+        event.calendar_source = calendar_source;
+        this.details = details ?? "";
+        
+        // tokenize the string and arrange as a stack for the parser
+        string[] tokenized = String.reduce_whitespace(this.details).split(" ");
+        Gee.LinkedList<Token> list = new Gee.LinkedList<Token>();
+        foreach (string token in tokenized)
+            list.add(new Token(token));
+        
+        stack = new Collection.LookaheadStack<Token>(list);
+        
+        parse();
+    }
+    
+    private void parse() {
+        for (;;) {
+            Token? token = stack.pop();
+            if (token == null)
+                break;
+            
+            // mark the stack branch for each parsing branch so if it fails the state can be
+            // restored and the next branch's read-ahead gets a chance; don't restore on success
+            // as each method is responsible for consuming all tokens it needs to complete its work
+            // and no more.
+            
+            // look for prepositions indicating time or location follows; this depends on
+            // translated strings, obviously, and does not apply to all languages, but we do what
+            // we can here.
+            
+            // A time preposition suggests a specific point of time is being described in the
+            // following token.  Don't require strict parsing of time ("8" -> "8am") because the
+            // preposition offers a clue that a time is being specified.
+            stack.mark();
+            if (token.casefolded in TIME_PREPOSITIONS && parse_time(stack.pop(), false))
+                continue;
+            stack.restore();
+            
+            // A duration preposition suggests a specific amount of positive time is being described
+            // by the next two tokens.
+            stack.mark();
+            if (token.casefolded in DURATION_PREPOSITIONS && parse_duration(stack.pop(), stack.pop()))
+                continue;
+            stack.restore();
+            
+            // A delay preposition suggests a specific point of time is being described by a
+            // positive duration of time after the current time by the next two tokens.
+            stack.mark();
+            if (token.casefolded in DELAY_PREPOSITIONS && parse_delay(stack.pop(), stack.pop()))
+                continue;
+            stack.restore();
+            
+            // only look for location prepositions if not already adding text to the location field
+            if (!adding_location && token.casefolded in LOCATION_PREPOSITIONS) {
+                // add current token (the preposition) to summary but not location (because location
+                // tokens are added to summary, i.e. "dinner at John's" yields "John's" for location
+                // and "dinner at John's" for summary)
+                add_text(token);
+                
+                // now adding to both summary and location
+                adding_location = true;
+                
+                continue;
+            }
+            
+            // if this token and next describe a duration, use them
+            stack.mark();
+            if (parse_duration(token, stack.pop()))
+                continue;
+            stack.restore();
+            
+            // attempt to (strictly) parse into wall-clock time
+            stack.mark();
+            if (parse_time(token, true))
+                continue;
+            stack.restore();
+            
+            // append original to current text field(s) as fallback
+            add_text(token);
+        }
+        
+        //
+        // assemble accumulated information in an Event, using defaults wherever appropriate
+        //
+        
+        // if no start time or date but a duration was specified, assume start is now and use
+        // duration for end time
+        if (start_time == null && start_date == null && duration != null) {
+            start_time = new Calendar.WallTime.from_exact_time(Calendar.System.now);
+            end_time = new Calendar.WallTime.from_exact_time(
+                Calendar.System.now.adjust_time((int) duration.minutes, Calendar.TimeUnit.MINUTE));
+            duration = null;
+        }
+        
+        // if a start time was described but not end time, use a 1 hour duration default
+        bool midnight_crossed = false;
+        if (start_time != null && end_time == null) {
+            if (duration != null) {
+                end_time = start_time.adjust((int) duration.minutes, Calendar.TimeUnit.MINUTE,
+                    out midnight_crossed);
+            } else {
+                end_time = start_time.adjust(1, Calendar.TimeUnit.HOUR, out midnight_crossed);
+            }
+        }
+        
+        // if no start date was described but a start time was, assume for today
+        if (start_date == null && start_time != null)
+            start_date = Calendar.System.today;
+        
+        // if no end date was describe, assume ends today as well (unless midnight was crossed
+        // due to duration)
+        if (start_date != null && end_date == null)
+            end_date = midnight_crossed ? start_date.adjust(1, Calendar.DateUnit.DAY) : start_date;
+        
+        // Event start/end time, if specified
+        if (start_time != null && end_time != null) {
+            assert(start_date != null);
+            assert(end_date != null);
+            
+            event.set_event_exact_time_span(new Calendar.ExactTimeSpan(
+                new Calendar.ExactTime(Calendar.System.timezone, start_date, start_time),
+                new Calendar.ExactTime(Calendar.System.timezone, end_date, end_time)
+            ));
+        } else if (start_date != null && end_date != null) {
+            event.set_event_date_span(new Calendar.DateSpan(start_date, end_date));
+        }
+        
+        // other event details
+        if (!String.is_empty(summary.str))
+            event.summary = summary.str;
+        
+        if (!String.is_empty(location.str))
+            event.location = location.str;
+        
+        // store full detail text in the event description for user and for debugging
+        event.description = details;
+    }
+    
+    private bool parse_time(Token? specifier, bool strict) {
+        if (specifier == null)
+            return false;
+        
+        // look for day/month specifiers, in any order
+        stack.mark();
+        {
+            Token? second = stack.pop();
+            if (second != null) {
+                Calendar.Date? date = parse_day_month(specifier, second);
+                if (date == null)
+                    date = parse_day_month(second, specifier);
+                
+                if (date != null && add_date(date))
+                    return true;
+            }
+        }
+        stack.restore();
+        
+        // look for day/month/year specifiers
+        stack.mark();
+        {
+            Token? second = stack.pop();
+            Token? third = stack.pop();
+            if (second != null && third != null) {
+                // try d/m/y followed by m/d/y ... every other combination seems overkill
+                Calendar.Date? date = parse_day_month_year(specifier, second, third);
+                if (date == null)
+                    date = parse_day_month_year(second, specifier, third);
+                
+                if (date != null && add_date(date))
+                    return true;
+            }
+        }
+        stack.restore();
+        
+        // parse single specifier looking for date first, then time
+        Calendar.Date? date = parse_relative_date(specifier);
+        if (date != null && add_date(date))
+            return true;
+        
+        bool strictly_parsed;
+        Calendar.WallTime? wall_time = Calendar.WallTime.parse(specifier.casefolded,
+            out strictly_parsed);
+        if (wall_time != null && !strictly_parsed && strict)
+            return false;
+        
+        return (wall_time != null) ? add_wall_time(wall_time, strictly_parsed) : false;
+    }
+    
+    // Add a duration to the event if not already specified and an end time has not already been
+    // specified
+    private bool parse_duration(Token? amount, Token? unit) {
+        if (amount == null || unit == null)
+            return false;
+        
+        if (end_time != null || duration != null)
+            return false;
+        
+        duration = Calendar.Duration.parse(amount.casefolded, unit.casefolded);
+        
+        return duration != null;
+    }
+    
+    private bool parse_delay(Token? amount, Token? unit) {
+        if (amount == null || unit == null)
+            return false;
+        
+        // Since delay is a way of specifying the start time, don't add if already known
+        if (start_time != null)
+            return false;
+        
+        Calendar.Duration? delay = Calendar.Duration.parse(amount.casefolded, unit.casefolded);
+        if (delay == null)
+            return false;
+        
+        start_time = new Calendar.WallTime.from_exact_time(
+            Calendar.System.now.adjust_time((int) delay.minutes, Calendar.TimeUnit.MINUTE));
+        
+        return true;
+    }
+    
+    // Adds the text to the summary and location field, if adding_location is set
+    private void add_text(Token token) {
+        // always add to summary
+        add_to_builder(summary, token);
+        
+        // add to location if in that mode
+        if (adding_location)
+            add_to_builder(location, token);
+    }
+    
+    private static void add_to_builder(StringBuilder builder, Token token) {
+        // keep everything space-delimited
+        if (!String.is_empty(builder.str))
+            builder.append_unichar(' ');
+        
+        builder.append(token.original);
+    }
+    
+    // Adds a time to the event, start time first, then end time, dropping thereafter
+    private bool add_wall_time(Calendar.WallTime wall_time, bool strictly_parsed) {
+        if (start_time == null) {
+            start_time = wall_time;
+            start_time_strict = strictly_parsed;
+        } else if (end_time == null) {
+            end_time = wall_time;
+            end_time_strict = strictly_parsed;
+        } else {
+            return false;
+        }
+        
+        return true;
+    }
+    
+    // Parses a potential date specifier into a calendar date relative to today
+    private Calendar.Date? parse_relative_date(Token token) {
+        // attempt to parse into common words for relative dates
+        if (token.casefolded == TODAY)
+            return Calendar.System.today;
+        else if (token.casefolded == TOMORROW)
+            return Calendar.System.today.next();
+        else if (token.casefolded == YESTERDAY)
+            return Calendar.System.today.previous();
+        
+        // attempt to parse into day of the week
+        Calendar.DayOfWeek? dow = Calendar.DayOfWeek.parse(token.casefolded);
+        if (dow == null)
+            return null;
+        
+        // find a Date for day of the week ... starting today, move forward up to one
+        // week
+        Calendar.Date upcoming = Calendar.System.today;
+        Calendar.Date next_week = upcoming.adjust(1, Calendar.DateUnit.WEEK);
+        do {
+            if (upcoming.day_of_week.equal_to(dow))
+                return upcoming;
+            
+            upcoming = upcoming.next();
+        } while (!upcoming.equal_to(next_week));
+        
+        return null;
+    }
+    
+    // Parses potential date specifiers into a specific calendar date
+    private Calendar.Date? parse_day_month(Token day, Token mon, Calendar.Year? year = null) {
+        // strip ordinal suffix if present
+        string day_number = day.casefolded;
+        foreach (string suffix in ORDINAL_SUFFIXES) {
+            if (!String.is_empty(suffix) && day_number.has_suffix(suffix)) {
+                day_number = day_number.slice(0, day_number.length - suffix.length);
+                
+                break;
+            }
+        }
+        
+        if (!String.is_numeric(day_number))
+            return null;
+        
+        Calendar.Month? month = Calendar.Month.parse(mon.casefolded);
+        if (month == null)
+            return null;
+        
+        if (year == null)
+            year = Calendar.System.today.year;
+        
+        try {
+            return new Calendar.Date(Calendar.DayOfMonth.for(int.parse(day.casefolded)),
+                month, year);
+        } catch (CalendarError calerr) {
+            // probably an out-of-bounds day of month
+            return null;
+        }
+    }
+    
+    // Parses potential date specifiers into a specific calendar date
+    private Calendar.Date? parse_day_month_year(Token day, Token mon, Token yr) {
+        if (!String.is_numeric(yr.casefolded))
+            return null;
+        
+        // a *sane* year
+        int year = int.parse(yr.casefolded);
+        int current_year = Calendar.System.today.year.value;
+        if (year < (current_year - 1) || (year > current_year + 10))
+            return null;
+        
+        return parse_day_month(day, mon, new Calendar.Year(year));
+    }
+    
+    // Adds a date to the event, start time first, then end time, dropping dates thereafter
+    private bool add_date(Calendar.Date date) {
+        if (start_date == null)
+            start_date = date;
+        else if (end_date == null)
+            end_date = date;
+        else
+            return false;
+        
+        return true;
+    }
+    
+    public override string to_string() {
+        return "DetailsParser:%s".printf(event.to_string());
+    }
+}
+
+}
+
diff --git a/src/component/component-event.vala b/src/component/component-event.vala
index 08f0e69..bd68069 100644
--- a/src/component/component-event.vala
+++ b/src/component/component-event.vala
@@ -18,6 +18,7 @@ public class Event : Instance, Gee.Comparable<Event> {
     public const string PROP_EXACT_TIME_SPAN = "exact-time-span";
     public const string PROP_DATE_SPAN = "date-span";
     public const string PROP_IS_ALL_DAY = "is-all-day";
+    public const string PROP_LOCATION = "location";
     public const string PROP_STATUS = "status";
     
     public enum Status {
@@ -62,6 +63,11 @@ public class Event : Instance, Gee.Comparable<Event> {
     public bool is_all_day { get; private set; }
     
     /**
+     * Location of an { link Event}.
+     */
+    public string? location { get; set; default = null; }
+    
+    /**
      * Status (confirmation) of an { link Event}.
      */
     public Status status { get; set; default = Status.CONFIRMED; }
@@ -118,6 +124,8 @@ public class Event : Instance, Gee.Comparable<Event> {
         // need to set this here because on_notify() doesn't update inside full update
         is_all_day = (date_span != null);
         
+        location = ical_component.get_location();
+        
         switch (ical_component.get_status()) {
             case iCal.icalproperty_status.TENTATIVE:
                 status = Status.TENTATIVE;
@@ -175,6 +183,10 @@ public class Event : Instance, Gee.Comparable<Event> {
                 is_all_day = (date_span != null);
             break;
             
+            case PROP_LOCATION:
+                ical_component.set_location(location);
+            break;
+            
             case PROP_STATUS:
                 switch(status) {
                     case Status.TENTATIVE:
@@ -239,6 +251,13 @@ public class Event : Instance, Gee.Comparable<Event> {
     }
     
     /**
+     * @inheritDoc
+     */
+    public override bool is_valid() {
+        return base.is_valid() && (date_span != null || exact_time_span != null);
+    }
+    
+    /**
      * Compares an { link Event} to another and returns which is chronologically first.
      *
      * The method attempts to compare DATE-TIMEs first, then DATEs, coercing a DATE-TIME into a DATE
diff --git a/src/component/component-instance.vala b/src/component/component-instance.vala
index 037f851..8c56076 100644
--- a/src/component/component-instance.vala
+++ b/src/component/component-instance.vala
@@ -352,6 +352,16 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     }
     
     /**
+     * Returns true if all the fields necessary for creating/updating the { link Instance} are
+     * present with proper values.
+     *
+     * The presence of { link calendar_source} is not necessary to deem an Instance valid.
+     */
+    public virtual bool is_valid() {
+        return dtstamp != null;
+    }
+    
+    /**
      * Equality is defined as { link Component.Instance}s having the same UID.
      *
      * Subclasses should override this and { link hash} if more definite equality is necessary.
diff --git a/src/component/component.vala b/src/component/component.vala
index 21eb074..850e897 100644
--- a/src/component/component.vala
+++ b/src/component/component.vala
@@ -17,19 +17,82 @@ namespace California.Component {
 
 private int init_count = 0;
 
+private string TODAY;
+private string TOMORROW;
+private string YESTERDAY;
+private string[] TIME_PREPOSITIONS;
+private string[] LOCATION_PREPOSITIONS;
+private string[] DURATION_PREPOSITIONS;
+private string[] DELAY_PREPOSITIONS;
+private string[] ORDINAL_SUFFIXES;
+
 public void init() throws Error {
     if (!Unit.do_init(ref init_count))
         return;
     
     // external unit init
+    Collection.init();
     Calendar.init();
+    
+    // Used by quick-add to indicate the user wants to create an event for today.
+    TODAY = _("today").casefold();
+    
+    // Used by quick-add to indicate the user wants to create an event for tomorrow.
+    TOMORROW = _("tomorrow").casefold();
+    
+    // Used by quick-add to indicate the user wants to create an event for yesterday.
+    YESTERDAY = _("yesterday").casefold();
+    
+    // Used by quick-add to determine if the word is a time-based preposition (indicating a
+    // specific time, not a duration).  Each word must be separated by semi-colons.
+    // It's allowable for some or all of these words to
+    // be duplicated in the location prepositions list (elsewhere) but not another time list.
+    // The list can be empty, but that will limit the parser.
+    // Examples: "at 9am", "from 10pm to 11:30pm", "on monday"
+    TIME_PREPOSITIONS = _("at;from;to;on;").casefold().split(";");
+    
+    // Used by quick-add to determine if the word is a duration-based preposition (indicating a
+    // a duration, not a specific time).  Each word must be separated by semi-colons.
+    // It's allowable for some or all of these words to
+    // be duplicated in the location prepositions list (elsewhere) but not another time list.
+    // The list can be empty, but that will limit the parser.
+    // Examples: "for 3 hours", "for 90 minutes"
+    DURATION_PREPOSITIONS = _("for;").casefold().split(";");
+    
+    // Used by quick-add to determine if the word is a delay preposition (indicating a specific
+    // time from the current moment).  Each word must be separated by semi-colons.
+    // It's allowable for some or all of these words to
+    // be duplicated in the location prepositions list (elsewhere) but not another time list.
+    // The list can be empty, but that will limit the parser.
+    // Example: "in 3 hours" (meaning 3 hours from now)
+    DELAY_PREPOSITIONS = _("in;").casefold().split(";");
+    
+    // Used by quick-add to determine if the word is a location-based preposition (indicating a
+    // specific place).  Each word must be separated by semi-colons.
+    // It's allowable for some or all of these words to be duplicated in
+    // the time prepositions list (elsewhere).  The list can be empty, but that will limit the
+    // parser.
+    // Example: "at supermarket", "at Eiffel Tower"
+    LOCATION_PREPOSITIONS = _("at;").casefold().split(";");
+    
+    // Used by quick-add to strip date numbers of common ordinal suffices.  Each word must be
+    // separated by semi-colons.
+    // The list can be empty, but that will limit the parser if your language supports ordinal
+    // suffixes.
+    // Example: "1st", "2nd", "3rd", "4th"
+    ORDINAL_SUFFIXES = _("st;nd;rd;th").casefold().split(";");
 }
 
 public void terminate() {
     if (!Unit.do_terminate(ref init_count))
         return;
     
+    TIME_PREPOSITIONS = LOCATION_PREPOSITIONS = DURATION_PREPOSITIONS = ORDINAL_SUFFIXES =
+        DELAY_PREPOSITIONS =null;
+    TODAY = TOMORROW = YESTERDAY = null;
+    
     Calendar.terminate();
+    Collection.terminate();
 }
 
 }
diff --git a/src/host/host-create-update-event.vala b/src/host/host-create-update-event.vala
index 4e4d3e0..1e1fec8 100644
--- a/src/host/host-create-update-event.vala
+++ b/src/host/host-create-update-event.vala
@@ -93,6 +93,12 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         init();
     }
     
+    public CreateUpdateEvent.finish(Component.Event event) {
+        this.event = event;
+        
+        init();
+    }
+    
     private void init() {
         if (event.summary != null)
             summary_entry.text = event.summary;
@@ -111,14 +117,18 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
                 event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local));
             initial_end_time = new Calendar.WallTime.from_exact_time(
                 event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local));
-        } else {
-            assert(event.date_span != null);
-            
+        } else if (event.date_span != null) {
             all_day_toggle.active = true;
             selected_date_span = event.date_span;
             initial_start_time = new Calendar.WallTime.from_exact_time(Calendar.System.now);
             initial_end_time = new Calendar.WallTime.from_exact_time(
                 Calendar.System.now.adjust_time(1, Calendar.TimeUnit.HOUR));
+        } else {
+            all_day_toggle.active = false;
+            selected_date_span = new Calendar.DateSpan(Calendar.System.today, Calendar.System.today);
+            initial_start_time = new Calendar.WallTime.from_exact_time(Calendar.System.now);
+            initial_end_time = new Calendar.WallTime.from_exact_time(
+                Calendar.System.now.adjust_time(1, Calendar.TimeUnit.HOUR));
         }
         
         // initialize start and end time (as in, wall clock time)
diff --git a/src/host/host-main-window.vala b/src/host/host-main-window.vala
index 1706122..f4f666d 100644
--- a/src/host/host-main-window.vala
+++ b/src/host/host-main-window.vala
@@ -13,8 +13,8 @@ namespace California.Host {
 public class MainWindow : Gtk.ApplicationWindow {
     private const string PROP_FIRST_OF_WEEK = "first-of-week";
     
-    private const string ACTION_NEW_EVENT = "win.new-event";
-    private const string ACCEL_NEW_EVENT = "<Primary>n";
+    private const string ACTION_QUICK_CREATE_EVENT = "win.quick-create-event";
+    private const string ACCEL_QUICK_CREATE_EVENT = "<Primary>n";
     
     private const string ACTION_JUMP_TO_TODAY = "win.jump-to-today";
     private const string ACCEL_JUMP_TO_TODAY = "<Primary>t";
@@ -26,7 +26,7 @@ public class MainWindow : Gtk.ApplicationWindow {
     private const string ACCEL_PREVIOUS = "<Alt>Left";
     
     private static const ActionEntry[] action_entries = {
-        { "new-event", on_new_event },
+        { "quick-create-event", on_quick_create_event },
         { "jump-to-today", on_jump_to_today },
         { "next", on_next },
         { "previous", on_previous }
@@ -37,6 +37,7 @@ public class MainWindow : Gtk.ApplicationWindow {
     
     private View.Controllable current_view;
     private View.Month.Controllable month_view = new View.Month.Controllable();
+    private Gtk.Button quick_add_button;
     
     public MainWindow(Application app) {
         Object (application: app);
@@ -49,7 +50,7 @@ public class MainWindow : Gtk.ApplicationWindow {
         bool rtl = get_direction() == Gtk.TextDirection.RTL;
         
         add_action_entries(action_entries, this);
-        Application.instance.add_accelerator(ACCEL_NEW_EVENT, ACTION_NEW_EVENT, null);
+        Application.instance.add_accelerator(ACCEL_QUICK_CREATE_EVENT, ACTION_QUICK_CREATE_EVENT, null);
         Application.instance.add_accelerator(ACCEL_JUMP_TO_TODAY, ACTION_JUMP_TO_TODAY, null);
         Application.instance.add_accelerator(rtl ? ACCEL_PREVIOUS : ACCEL_NEXT, ACTION_NEXT, null);
         Application.instance.add_accelerator(rtl ? ACCEL_NEXT : ACCEL_PREVIOUS, ACTION_PREVIOUS, null);
@@ -91,9 +92,9 @@ public class MainWindow : Gtk.ApplicationWindow {
         headerbar.pack_start(today);
         headerbar.pack_start(nav_buttons);
         
-        Gtk.Button new_event = new Gtk.Button.from_icon_name("list-add-symbolic", Gtk.IconSize.MENU);
-        new_event.tooltip_text = _("Create a new event (Ctrl+N)");
-        new_event.set_action_name(ACTION_NEW_EVENT);
+        quick_add_button = new Gtk.Button.from_icon_name("list-add-symbolic", Gtk.IconSize.MENU);
+        quick_add_button.tooltip_text = _("Quick add event (Ctrl+N)");
+        quick_add_button.set_action_name(ACTION_QUICK_CREATE_EVENT);
         
         Gtk.Button calendars = new Gtk.Button.from_icon_name("x-office-calendar-symbolic",
             Gtk.IconSize.MENU);
@@ -101,7 +102,7 @@ public class MainWindow : Gtk.ApplicationWindow {
         calendars.set_action_name(Application.ACTION_CALENDAR_MANAGER);
         
         // pack right-side of window
-        headerbar.pack_end(new_event);
+        headerbar.pack_end(quick_add_button);
         headerbar.pack_end(calendars);
         
         Gtk.Box layout = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
@@ -137,12 +138,20 @@ public class MainWindow : Gtk.ApplicationWindow {
         deck_window.destroy();
     }
     
-    private void on_new_event() {
-        // create all-day event for today
-        Calendar.DateSpan initial = new Calendar.DateSpan(Calendar.System.today, Calendar.System.today);
+    private void on_quick_create_event() {
+        QuickCreateEvent quick_create = new QuickCreateEvent();
+        
+        quick_create.completed.connect(() => {
+            if (quick_create.parsed_event == null)
+                return;
+            
+            if (quick_create.parsed_event.is_valid())
+                create_event_async.begin(quick_create.parsed_event, null);
+            else
+                create_event(null, null, quick_create.parsed_event, true, quick_add_button, null);
+        });
         
-        // revert to today's date and use the widget for the popover
-        create_event(null, initial, null, current_view.today(), null);
+        show_deck(quick_add_button, null, iterate<Toolkit.Card>(quick_create).to_array_list());
     }
     
     private void on_jump_to_today() {
@@ -159,16 +168,16 @@ public class MainWindow : Gtk.ApplicationWindow {
     
     private void on_request_create_timed_event(Calendar.ExactTimeSpan initial, Gtk.Widget relative_to,
         Gdk.Point? for_location) {
-        create_event(initial, null, null, relative_to, for_location);
+        create_event(initial, null, null, false, relative_to, for_location);
     }
     
     private void on_request_create_all_day_event(Calendar.DateSpan initial, Gtk.Widget relative_to,
         Gdk.Point? for_location) {
-        create_event(null, initial, null, relative_to, for_location);
+        create_event(null, initial, null, false, relative_to, for_location);
     }
     
     private void create_event(Calendar.ExactTimeSpan? time_span, Calendar.DateSpan? date_span,
-        Component.Event? existing, Gtk.Widget relative_to, Gdk.Point? for_location) {
+        Component.Event? existing, bool create_existing, Gtk.Widget relative_to, Gdk.Point? for_location) {
         assert(time_span != null || date_span != null || existing != null);
         
         CreateUpdateEvent create_update_event;
@@ -176,6 +185,8 @@ public class MainWindow : Gtk.ApplicationWindow {
             create_update_event = new CreateUpdateEvent(time_span);
         else if (date_span != null)
             create_update_event = new CreateUpdateEvent.all_day(date_span);
+        else if (create_existing)
+            create_update_event = new CreateUpdateEvent.finish(existing);
         else
             create_update_event = new CreateUpdateEvent.update(existing);
         
@@ -222,7 +233,7 @@ public class MainWindow : Gtk.ApplicationWindow {
         });
         
         show_event.update_event.connect(() => {
-            create_event(null, null, event, relative_to, for_location);
+            create_event(null, null, event, false, relative_to, for_location);
         });
         
         show_deck(relative_to, for_location, iterate<Toolkit.Card>(show_event).to_array_list());
diff --git a/src/host/host-quick-create-event.vala b/src/host/host-quick-create-event.vala
new file mode 100644
index 0000000..09c0273
--- /dev/null
+++ b/src/host/host-quick-create-event.vala
@@ -0,0 +1,78 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Host {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/quick-create-event.ui")]
+public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
+    public const string ID = "QuickCreateEvent";
+    
+    public string card_id { get { return ID; } }
+    
+    public string? title { get { return null; } }
+    
+    public Component.Event? parsed_event { get; private set; default = null; }
+    
+    public Gtk.Widget? default_widget { get { return create_button; } }
+    
+    public Gtk.Widget? initial_focus { get { return details_entry; } }
+    
+    [GtkChild]
+    private Gtk.Entry details_entry;
+    
+    [GtkChild]
+    private Gtk.ComboBoxText calendar_combo_box;
+    
+    [GtkChild]
+    private Gtk.Button create_button;
+    
+    private Toolkit.ComboBoxTextModel<Backing.CalendarSource> model;
+    
+    public QuickCreateEvent() {
+        // create and initialize combo box model
+        model = new Toolkit.ComboBoxTextModel<Backing.CalendarSource>(calendar_combo_box,
+            (cal) => cal.title);
+        foreach (Backing.CalendarSource calendar_source in
+            Backing.Manager.instance.get_sources_of_type<Backing.CalendarSource>()) {
+            if (calendar_source.visible)
+                model.add(calendar_source);
+        }
+        
+        // make first item active
+        calendar_combo_box.active = 0;
+        
+        Properties.value_to_bool(details_entry, "text-length", create_button, "sensitive",
+            BindingFlags.SYNC_CREATE, () => !String.is_empty(details_entry.text));
+    }
+    
+    public void jumped_to(Toolkit.Card? from, Value? message) {
+    }
+    
+    [GtkCallback]
+    private void on_details_entry_icon_release(Gtk.Entry entry, Gtk.EntryIconPosition icon,
+        Gdk.Event event) {
+        // check for clear icon being pressed
+        if (icon == Gtk.EntryIconPosition.SECONDARY)
+            details_entry.text = "";
+    }
+    
+    [GtkCallback]
+    private void on_cancel_button_clicked() {
+        dismissed(true);
+    }
+    
+    [GtkCallback]
+    private void on_create_button_clicked() {
+        Component.DetailsParser parser = new Component.DetailsParser(details_entry.text, model.active);
+        parsed_event = parser.event;
+        
+        completed();
+        dismissed(true);
+    }
+}
+
+}
+
diff --git a/src/rc/quick-create-event.ui b/src/rc/quick-create-event.ui
new file mode 100644
index 0000000..431e8a8
--- /dev/null
+++ b/src/rc/quick-create-event.ui
@@ -0,0 +1,165 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <template class="CaliforniaHostQuickCreateEvent" parent="GtkGrid">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="row_spacing">8</property>
+    <child>
+      <object class="GtkLabel" id="title_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">0</property>
+        <property name="label" translatable="yes">_Quick add event:</property>
+        <property name="use_underline">True</property>
+        <property name="mnemonic_widget">details_entry</property>
+        <attributes>
+          <attribute name="weight" value="bold"/>
+        </attributes>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkButtonBox" id="buttonbox1">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_top">8</property>
+        <property name="spacing">8</property>
+        <property name="homogeneous">True</property>
+        <property name="layout_style">end</property>
+        <child>
+          <object class="GtkButton" id="cancel_button">
+            <property name="label" translatable="yes">C_ancel</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_cancel_button_clicked" 
object="CaliforniaHostQuickCreateEvent" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="create_button">
+            <property name="label" translatable="yes">_Create</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="can_default">True</property>
+            <property name="has_default">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_create_button_clicked" 
object="CaliforniaHostQuickCreateEvent" swapped="no"/>
+            <style>
+              <class name="suggested-action"/>
+            </style>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">3</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkBox" id="box1">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="orientation">vertical</property>
+        <property name="spacing">2</property>
+        <child>
+          <object class="GtkEntry" id="details_entry">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="hexpand">True</property>
+            <property name="activates_default">True</property>
+            <property name="width_chars">40</property>
+            <property name="secondary_icon_name">edit-delete-symbolic</property>
+            <signal name="icon-release" handler="on_details_entry_icon_release" 
object="CaliforniaHostQuickCreateEvent" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="example_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">0</property>
+            <property name="label" translatable="yes">&lt;small&gt;&lt;i&gt;Example: Dinner at Tadich Grill 
7:30pm tomorrow&lt;/i&gt;&lt;/small&gt;</property>
+            <property name="use_markup">True</property>
+            <property name="ellipsize">start</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkBox" id="box2">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="spacing">8</property>
+        <child>
+          <object class="GtkLabel" id="calendar_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">0</property>
+            <property name="label" translatable="yes">Ca_lendar:</property>
+            <property name="use_underline">True</property>
+            <property name="mnemonic_widget">calendar_combo_box</property>
+            <property name="ellipsize">start</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkComboBoxText" id="calendar_combo_box">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+  </template>
+</interface>
diff --git a/src/tests/tests-quick-add.vala b/src/tests/tests-quick-add.vala
new file mode 100644
index 0000000..c65302a
--- /dev/null
+++ b/src/tests/tests-quick-add.vala
@@ -0,0 +1,143 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Tests {
+
+private class QuickAdd : UnitTest.Harness {
+    public QuickAdd() {
+        add_case("summary", summary);
+        add_case("summary-location", summary_location);
+        add_case("with-12hr-time", with_12hr_time);
+        add_case("with-24hr-time", with_24hr_time);
+        add_case("with-day-of-week", with_day_of_week);
+        add_case("with-delay", with_delay);
+        add_case("with-duration", with_duration);
+        add_case("with-delay-and-duration", with_delay_and_duration);
+        add_case("indeterminate-time", indeterminate_time);
+        add_case("dialog-example", dialog_example);
+    }
+    
+    protected override void setup() throws Error {
+        Component.init();
+        Calendar.init();
+    }
+    
+    protected override void teardown() {
+        Component.terminate();
+        Calendar.terminate();
+    }
+    
+    private bool summary() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser("meet with Alice", null);
+        
+        return parser.event.summary == "meet with Alice"
+            && parser.event.location == null
+            && parser.event.exact_time_span == null
+            && parser.event.date_span == null;
+    }
+    
+    private bool summary_location() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser("meet with Alice at Bob's", null);
+        
+        return parser.event.summary == "meet with Alice at Bob's"
+            && parser.event.location == "Bob's"
+            && parser.event.exact_time_span == null
+            && parser.event.date_span == null;
+    }
+    
+    private bool with_12hr_time() throws Error {
+        return with_time(new Component.DetailsParser("dinner at 7pm with Alice", null));
+    }
+    
+    private bool with_24hr_time() throws Error {
+        return with_time(new Component.DetailsParser("dinner at 1900 with Alice", null));
+    }
+    
+    private bool with_time(Component.DetailsParser parser) {
+        Calendar.ExactTime time = new Calendar.ExactTime(
+            Calendar.System.timezone,
+            Calendar.System.today,
+            new Calendar.WallTime(19, 0, 0)
+        );
+        
+        return parser.event.summary == "dinner with Alice"
+            && parser.event.location == null
+            && parser.event.exact_time_span.start_exact_time.equal_to(time)
+            && parser.event.exact_time_span.end_exact_time.equal_to(time.adjust_time(1, 
Calendar.TimeUnit.HOUR));
+    }
+    
+    private bool with_day_of_week() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser("dinner Monday at Bob's with Alice", 
null);
+        
+        return parser.event.summary == "dinner at Bob's with Alice"
+            && parser.event.location == "Bob's with Alice"
+            && parser.event.date_span.start_date.day_of_week == Calendar.DayOfWeek.MON;
+    }
+    
+    private bool with_delay() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser("meet Alice in 3 hours", null);
+        
+        Calendar.WallTime start = new Calendar.WallTime.from_exact_time(Calendar.System.now).adjust(
+            3, Calendar.TimeUnit.HOUR, null);
+        Calendar.WallTime end = start.adjust(1, Calendar.TimeUnit.HOUR, null);
+        
+        assert(parser.event.summary == "meet Alice");
+        assert(new 
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.start_exact_time).equal_to(start));
+        assert(new Calendar.WallTime.from_exact_time(
+                parser.event.exact_time_span.start_exact_time).adjust(1, Calendar.TimeUnit.HOUR, 
null).equal_to(end));
+        
+        return true;
+    }
+    
+    private bool with_duration() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser("meet Alice for 2 hrs", null);
+        
+        Calendar.WallTime start = new Calendar.WallTime.from_exact_time(Calendar.System.now);
+        Calendar.WallTime end = start.adjust(2, Calendar.TimeUnit.HOUR, null);
+        
+        return parser.event.summary == "meet Alice"
+            && new 
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.start_exact_time).equal_to(start)
+            && new 
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.end_exact_time).equal_to(end);
+    }
+    
+    private bool with_delay_and_duration() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser("meet Alice in 3 hours for 30 min", 
null);
+        
+        Calendar.WallTime start = new Calendar.WallTime.from_exact_time(Calendar.System.now.adjust_time(3, 
Calendar.TimeUnit.HOUR));
+        Calendar.WallTime end = start.adjust(30, Calendar.TimeUnit.MINUTE, null);
+        
+        return parser.event.summary == "meet Alice"
+            && new 
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.start_exact_time).equal_to(start)
+            && new 
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.end_exact_time).equal_to(end);
+    }
+    
+    private bool indeterminate_time() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser("meet Alice 4", null);
+        
+        return parser.event.summary == "meet Alice 4"
+            && parser.event.exact_time_span == null
+            && parser.event.date_span == null;
+    }
+    
+    private bool dialog_example() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser(
+            "Dinner at Tadich Grill 7:30pm tomorrow", null);
+        
+        Calendar.ExactTime time = new Calendar.ExactTime(
+            Calendar.System.timezone,
+            Calendar.System.today.next(),
+            new Calendar.WallTime(19, 30, 0)
+        );
+        
+        return parser.event.summary == "Dinner at Tadich Grill"
+            && parser.event.location == "Tadich Grill"
+            && parser.event.exact_time_span.start_exact_time.equal_to(time)
+            && parser.event.exact_time_span.end_exact_time.equal_to(time.adjust_time(1, 
Calendar.TimeUnit.HOUR));
+    }
+}
+
+}
+
diff --git a/src/tests/tests.vala b/src/tests/tests.vala
new file mode 100644
index 0000000..327609f
--- /dev/null
+++ b/src/tests/tests.vala
@@ -0,0 +1,16 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Tests {
+
+public int run(string[] args) {
+    UnitTest.Harness.register(new QuickAdd());
+    
+    return UnitTest.Harness.exec_all();
+}
+
+}
+
diff --git a/src/toolkit/toolkit-combo-box-text-model.vala b/src/toolkit/toolkit-combo-box-text-model.vala
new file mode 100644
index 0000000..7bd613d
--- /dev/null
+++ b/src/toolkit/toolkit-combo-box-text-model.vala
@@ -0,0 +1,166 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Toolkit {
+
+/**
+ * A simple model for a Gtk.ComboBoxText.
+ */
+
+public class ComboBoxTextModel<G> : BaseObject {
+    public const string PROP_ACTIVE = "active";
+    
+    /**
+     * Returns a string that is the representation of the item in the Gtk.ComboBoxText.
+     */
+    public delegate string ModelPresentation<G>(G item);
+    
+    public Gtk.ComboBoxText combo_box { get; private set; }
+    
+    /**
+     * Synchronized to the active property of { link combo_box}.
+     */
+    public G? active { get; private set; }
+    
+    private unowned ModelPresentation<G> model_presentation;
+    private unowned CompareDataFunc<G>? comparator;
+    private unowned Gee.HashDataFunc<G> hash_func;
+    private unowned Gee.EqualDataFunc<G>? equal_func;
+    private Gee.ArrayList<G> items;
+    private Gee.HashMap<G, int> indices;
+    
+    public ComboBoxTextModel(Gtk.ComboBoxText combo_box, ModelPresentation<G> model_presentation,
+        CompareDataFunc<G>? comparator = null, Gee.HashDataFunc<G>? hash_func = null,
+        Gee.EqualDataFunc<G>? equal_func = null) {
+        this.combo_box = combo_box;
+        this.model_presentation = model_presentation;
+        this.comparator = comparator;
+        this.hash_func = hash_func;
+        this.equal_func = equal_func;
+        
+        items = new Gee.ArrayList<G>(item_equal_func);
+        indices = new Gee.HashMap<G, int>(item_hash_func, item_equal_func);
+        
+        combo_box.notify["active"].connect(on_combo_box_active);
+    }
+    
+    ~ComboBoxTextModel() {
+        combo_box.notify["active"].disconnect(on_combo_box_active);
+    }
+    
+    private int item_comparator(G a, G b) {
+        if (comparator != null)
+            return comparator(a, b);
+        
+        return Gee.Functions.get_compare_func_for(typeof(G))(a, b);
+    }
+    
+    private bool item_equal_func(G a, G b) {
+        if (equal_func != null)
+            return equal_func(a, b);
+        
+        return Gee.Functions.get_equal_func_for(typeof(G))(a, b);
+    }
+    
+    private uint item_hash_func(G item) {
+        if (hash_func != null)
+            return hash_func(item);
+        
+        return Gee.Functions.get_hash_func_for(typeof(G))(item);
+    }
+    
+    /**
+     * Add an item to the model and the Gtk.ComboBoxText.
+     *
+     * Returns false if the item was not added (already present in model).
+     */
+    public bool add(G item) {
+        if (!items.add(item))
+            return false;
+        
+        // sort item according to comparator and determine its index
+        items.sort(item_comparator);
+        int added_index = items.index_of(item);
+        
+        // any existing indices need to be incremented
+        foreach (G key in indices.keys) {
+            int existing_index = indices.get(key);
+            if (existing_index >= added_index)
+                indices.set(key, existing_index + 1);
+        }
+        
+        // add new item to index map
+        indices.set(item, added_index);
+        
+        combo_box.insert_text(added_index, model_presentation(item));
+        
+        return true;
+    }
+    
+    /**
+     * Removes the item from the model and the Gtk.ComboBoxText.
+     *
+     * Returns false if not removed (not present in model).
+     */
+    public bool remove(G item) {
+        if (!items.remove(item))
+            return false;
+        
+        int removed_index;
+        if (!indices.unset(item, out removed_index))
+            return false;
+        
+        foreach (G key in indices.keys) {
+            int existing_index = indices.get(key);
+            assert(existing_index != removed_index);
+            
+            if (existing_index > removed_index)
+                indices.set(key, existing_index - 1);
+        }
+        
+        combo_box.remove(removed_index);
+        
+        return true;
+    }
+    
+    /**
+     * Makes the item active in the Gtk.ComboBoxText.
+     *
+     * Returns true if the item is present in the model, whether or not it's already active.
+     */
+    public bool set_item_active(G item) {
+        if (!indices.has_key(item))
+            return false;
+        
+        combo_box.active = indices.get(item);
+        
+        return true;
+    }
+    
+    /**
+     * Returns the item at the Gtk.ComboBoxText index.
+     */
+    public G? get_item_at(int index) {
+        Gee.MapIterator<G, int> iter = indices.map_iterator();
+        while (iter.next()) {
+            if (iter.get_value() == index)
+                return iter.get_key();
+        }
+        
+        return null;
+    }
+    
+    private void on_combo_box_active() {
+        active = get_item_at(combo_box.active);
+    }
+    
+    public override string to_string() {
+        return "ComboBoxTextModel (%d items)".printf(items.size);
+    }
+}
+
+}
+
diff --git a/src/unit-test/unit-test-harness.vala b/src/unit-test/unit-test-harness.vala
new file mode 100644
index 0000000..249aae9
--- /dev/null
+++ b/src/unit-test/unit-test-harness.vala
@@ -0,0 +1,143 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.UnitTest {
+
+/**
+ * Base class for suites of related tests.
+ */
+
+public abstract class Harness : BaseObject {
+    public delegate bool Case() throws Error;
+    
+    private class TestCase : BaseObject {
+        public string name;
+        public unowned Case unit_test;
+        
+        public TestCase(string name, Case unit_test) {
+            this.name = name;
+            this.unit_test = unit_test;
+        }
+        
+        public override string to_string() {
+            return name;
+        }
+    }
+    
+    private static Gee.ArrayList<Harness>? harnesses = null;
+    
+    /**
+     * Name of the { link Harness}.
+     */
+    public string name { get; private set; }
+    
+    private Gee.ArrayList<TestCase> test_cases = new Gee.ArrayList<TestCase>();
+    
+    protected Harness(string? name = null) {
+        this.name = name ?? get_class().get_type().name();
+    }
+    
+    /**
+     * Register a { link Harness} to the total list of Harneses.
+     */
+    public static void register(Harness harness) {
+        if (harnesses == null)
+            harnesses = new Gee.ArrayList<Harness>();
+        
+        harnesses.add(harness);
+    }
+    
+    /**
+     * Execute all { link register}ed { link Harness}es.
+     */
+    public static int exec_all() {
+        if (harnesses == null || harnesses.size == 0)
+            return 0;
+        
+        foreach (Harness harness in harnesses) {
+            try {
+                harness.setup();
+            } catch (Error err) {
+                stdout.printf("Unable to setup harness %s: %s", harness.name, err.message);
+                Posix.exit(Posix.EXIT_FAILURE);
+            }
+            
+            harness.exec();
+            harness.teardown();
+        }
+        
+        return 0;
+    }
+    
+    /**
+     * Executed before running any test cases.
+     */
+    protected abstract void setup() throws Error;
+    
+    /**
+     * Executed after all test cases have completed.
+     */
+    protected abstract void teardown();
+    
+    /**
+     * Executed prior to each test case.
+     */
+    protected virtual void prepare() throws Error {
+    }
+    
+    /**
+     * Executed after each test case.
+     */
+    protected virtual void cleanup() {
+    }
+    
+    /**
+     * Add a test case to the { link Harness}.
+     */
+    protected void add_case(string name, Case unit_test) {
+        test_cases.add(new TestCase(name, unit_test));
+    }
+    
+    private void exec() {
+        foreach (TestCase test_case in test_cases) {
+            stdout.printf("Executing test: %s.%s...", name, test_case.name);
+            
+            try {
+                prepare();
+            } catch (Error err) {
+                stdout.printf("prepare failed: %s\n", err.message);
+                Posix.exit(Posix.EXIT_FAILURE);
+            }
+            
+            bool success = false;
+            Error? err = null;
+            try {
+                success = test_case.unit_test();
+            } catch (Error caught) {
+                err = caught;
+            }
+            
+            if (err != null)
+                stdout.printf("\nFailed: %s.%s\n\t%s\n", name, test_case.name, err.message);
+            else if (!success)
+                stdout.printf("\nFailed: %s.%s\n", name, test_case.name);
+            
+            if (err != null || !success)
+                Posix.exit(Posix.EXIT_FAILURE);
+            
+            cleanup();
+            
+            stdout.printf("success\n");
+        }
+    }
+    
+    public override string to_string() {
+        return name;
+    }
+}
+
+}
+
diff --git a/src/util/util-string.vala b/src/util/util-string.vala
index 693cb11..b9561af 100644
--- a/src/util/util-string.vala
+++ b/src/util/util-string.vala
@@ -16,6 +16,14 @@ public int stricmp(string a, string b) {
     return strcmp(a.casefold(), b.casefold());
 }
 
+public uint ci_hash(string str) {
+    return str.casefold().hash();
+}
+
+public bool ci_equal(string a, string b) {
+    return stricmp(a, b) == 0;
+}
+
 /**
  * Removes redundant whitespace (including tabs and newlines) and strips whitespace from beginning
  * and end of string.
@@ -44,5 +52,22 @@ public string reduce_whitespace(string str) {
     return builder.str;
 }
 
+/**
+ * Returns true if every character in the string is a numeric digit.
+ */
+public bool is_numeric(string? str) {
+    if (is_empty(str))
+        return false;
+    
+    unichar ch;
+    int index = 0;
+    while (str.get_next_char(ref index, out ch)) {
+        if (!ch.isdigit())
+            return false;
+    }
+    
+    return true;
+}
+
 }
 
diff --git a/vapi/libical.vapi b/vapi/libical.vapi
index 14ea598..a04d261 100644
--- a/vapi/libical.vapi
+++ b/vapi/libical.vapi
@@ -109,7 +109,7 @@ namespace iCal {
                [CCode (cname = "icalcomponent_get_inner")]
                public unowned iCal.icalcomponent get_inner ();
                [CCode (cname = "icalcomponent_get_location")]
-               public unowned string get_location ();
+               public unowned string? get_location ();
                [CCode (cname = "icalcomponent_get_method")]
                public iCal.icalproperty_method get_method ();
                [CCode (cname = "icalcomponent_get_next_component")]


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]