[california] Create recurring events with Quick Add: Bug #725785



commit c390436733c59e2a14093d5ecb144b922589c20d
Author: Jim Nelson <jim yorba org>
Date:   Wed Jun 25 13:08:32 2014 -0700

    Create recurring events with Quick Add: Bug #725785
    
    Quick Add parser now accepts keywords for creating recurring events.
    This patch also introduces code for interpreting and generating
    iCal recurring rules (RRULE) and associated tests.

 src/Makefile.am                              |    2 +
 src/calendar/calendar-date.vala              |   40 +-
 src/calendar/calendar-day-of-month.vala      |   10 +-
 src/calendar/calendar-day-of-week.vala       |   42 ++-
 src/calendar/calendar-duration.vala          |   35 --
 src/calendar/calendar-week.vala              |   16 +
 src/calendar/calendar.vala                   |   21 -
 src/collection/collection-iterable.vala      |  111 ++++-
 src/component/component-date-time.vala       |   40 ++
 src/component/component-details-parser.vala  |  360 ++++++++++++-
 src/component/component-event.vala           |   53 ++
 src/component/component-instance.vala        |   39 +--
 src/component/component-recurrence-rule.vala |  543 +++++++++++++++++++
 src/component/component.vala                 |  132 +++++-
 src/tests/tests-calendar-date.vala           |  110 ++++-
 src/tests/tests-quick-add-recurring.vala     |  715 ++++++++++++++++++++++++++
 src/tests/tests-quick-add.vala               |   56 ++-
 src/tests/tests-string.vala                  |   18 +
 src/tests/tests.vala                         |    7 +-
 src/unit-test/unit-test-harness.vala         |   12 +-
 src/util/util-string.vala                    |    4 +-
 vapi/libical.vapi                            |   64 +--
 22 files changed, 2220 insertions(+), 210 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index 5bed4ce..f4c5fd0 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -85,6 +85,7 @@ california_VALASOURCES = \
        component/component-event.vala \
        component/component-icalendar.vala \
        component/component-instance.vala \
+       component/component-recurrence-rule.vala \
        component/component-uid.vala \
        component/component-vtype.vala \
        \
@@ -107,6 +108,7 @@ california_VALASOURCES = \
        tests/tests-calendar-month-span.vala \
        tests/tests-calendar-wall-time.vala \
        tests/tests-quick-add.vala \
+       tests/tests-quick-add-recurring.vala \
        tests/tests-string.vala \
        \
        toolkit/toolkit.vala \
diff --git a/src/calendar/calendar-date.vala b/src/calendar/calendar-date.vala
index 3181742..9f0e71f 100644
--- a/src/calendar/calendar-date.vala
+++ b/src/calendar/calendar-date.vala
@@ -72,6 +72,7 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
     
     public DayOfWeek day_of_week { get; private set; }
     public DayOfMonth day_of_month { get; private set; }
+    public int day_of_year { get; private set; }
     public Month month { get; private set; }
     public Year year { get; private set; }
     
@@ -93,6 +94,7 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
         
         day_of_week = DayOfWeek.from_gdate(gdate);
         this.day_of_month = day_of_month;
+        day_of_year = (int) gdate.get_day_of_year();
         this.month = month;
         this.year = year;
     }
@@ -112,6 +114,7 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
         assert(gdate.valid());
         
         day_of_week = DayOfWeek.from_gdate(gdate);
+        day_of_year = (int) gdate.get_day_of_year();
     }
     
     /**
@@ -130,6 +133,7 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
         
         day_of_week = DayOfWeek.from_gdate(gdate);
         day_of_month = DayOfMonth.from_gdate(gdate);
+        day_of_year = (int) gdate.get_day_of_year();
         month = Month.from_gdate(gdate);
         year = new Year.from_gdate(gdate);
     }
@@ -252,41 +256,35 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
     }
     
     /**
-     * Returns the { link Date} of the upcoming (next chronological) { link DayOfWeek}.
+     * Returns the { link Date} of the upcoming (next chronological) Date that matches
+     * the predicate's requirements.
      *
-     * Set { link includes_this_day} to true if this Date is to be considered "upcoming", that is,
-     * if it falls on the day of the week, it is returned.
+     * inclusive indicates if this Date is included in the search.
      *
      * @see prior
      */
-    public Date upcoming(DayOfWeek dow, bool includes_this_day) {
-        return upcoming_prior(dow, includes_this_day, 1);
+    public Date upcoming(bool inclusive, Gee.Predicate<Calendar.Date> predicate) {
+        return upcoming_prior(inclusive, 1, predicate);
     }
     
     /**
-     * Returns the { link Date} of the prior (previous chronological) { link DayOfWeek}.
+     * Returns the { link Date} of the prior (next chronological) Date that matches
+     * the predicate's requirements.
      *
-     * Set { link includes_this_day} to true if this Date is to be considered "prior", that is,
-     * if it falls on the day of the week, it is returned.
+     * inclusive indicates if this Date is included in the search.
      *
      * @see upcoming
      */
-    public Date prior(DayOfWeek dow, bool includes_this_day) {
-        return upcoming_prior(dow, includes_this_day, -1);
+    public Date prior(bool inclusive, Gee.Predicate<Calendar.Date> predicate) {
+        return upcoming_prior(inclusive, -1, predicate);
     }
     
-    private Date upcoming_prior(DayOfWeek dow, bool includes_this_day, int adjustment) {
-        // look for current date being the one
-        if (day_of_week.equal_to(dow) && includes_this_day)
-            return this;
+    private Date upcoming_prior(bool inclusive, int adjustment, Gee.Predicate<Calendar.Date> predicate) {
+        Calendar.Date current = inclusive ? this : adjust(adjustment);
+        while (!predicate(current))
+            current = current.adjust(adjustment);
         
-        // find a Date for day of the week ... brute force isn't great, but it works
-        Date upcoming_prior = this;
-        for (;;) {
-            upcoming_prior = upcoming_prior.adjust(adjustment);
-            if (upcoming_prior.day_of_week.equal_to(dow))
-                return upcoming_prior;
-        }
+        return current;
     }
     
     /**
diff --git a/src/calendar/calendar-day-of-month.vala b/src/calendar/calendar-day-of-month.vala
index a46ecf2..d616c38 100644
--- a/src/calendar/calendar-day-of-month.vala
+++ b/src/calendar/calendar-day-of-month.vala
@@ -16,6 +16,7 @@ namespace California.Calendar {
 public class DayOfMonth : BaseObject, Gee.Comparable<DayOfMonth>, Gee.Hashable<DayOfMonth> {
     public const int MIN = 1;
     public const int MAX = 31;
+    public const int COUNT = MAX - MIN + 1;
     
     private static DayOfMonth[]? days = null;
     
@@ -25,6 +26,13 @@ public class DayOfMonth : BaseObject, Gee.Comparable<DayOfMonth>, Gee.Hashable<D
     public int value { get; private set; }
     
     /**
+     * Returns the 1-based week of the month this day resides in.
+     *
+     * For example, if this is the first Monday of the month, returns 1.
+     */
+    public int week_of_month { get { return ((value - 1) / DayOfWeek.COUNT) + 1; } }
+    
+    /**
      * Returns the day number as an informal (no leading zero) string.
      */
     public string informal_number { get; private set; }
@@ -44,7 +52,7 @@ public class DayOfMonth : BaseObject, Gee.Comparable<DayOfMonth>, Gee.Hashable<D
     }
     
     internal static void init() {
-        days = new DayOfMonth[MAX - MIN + 1];
+        days = new DayOfMonth[COUNT];
         for (int ctr = MIN; ctr <= MAX; ctr++)
             days[ctr - MIN] = new DayOfMonth(ctr);
     }
diff --git a/src/calendar/calendar-day-of-week.vala b/src/calendar/calendar-day-of-week.vala
index 4c2024c..4fc8500 100644
--- a/src/calendar/calendar-day-of-week.vala
+++ b/src/calendar/calendar-day-of-week.vala
@@ -52,6 +52,9 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
     public static DayOfWeek SAT;
     public static DayOfWeek SUN;
     
+    public static DayOfWeek[] weekdays;
+    public static DayOfWeek[] weekend_days;
+    
     public const int MIN = 1;
     public const int MAX = 7;
     public const int COUNT = MAX - MIN + 1;
@@ -142,11 +145,23 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
         days_of_week_sunday[4] = THU;
         days_of_week_sunday[5] = FRI;
         days_of_week_sunday[6] = SAT;
+        
+        weekdays = new DayOfWeek[5];
+        weekdays[0] = MON;
+        weekdays[1] = TUE;
+        weekdays[2] = WED;
+        weekdays[3] = THU;
+        weekdays[4] = FRI;
+        
+        weekend_days = new DayOfWeek[2];
+        weekend_days[0] = SAT;
+        weekend_days[1] = SUN;
     }
     
     internal static void terminate() {
         days_of_week_monday = days_of_week_sunday = null;
         MON = TUE = WED = THU = FRI = SAT = SUN = null;
+        weekdays = weekend_days = null;
     }
     
     /**
@@ -158,16 +173,7 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
         if (index < 0 || index >= COUNT)
             throw new CalendarError.INVALID("Invalid day of week value %d", value);
         
-        switch (first_of_week) {
-            case FirstOfWeek.MONDAY:
-                return days_of_week_monday[index];
-            
-            case FirstOfWeek.SUNDAY:
-                return days_of_week_sunday[index];
-            
-            default:
-                assert_not_reached();
-        }
+        return all(first_of_week)[index];
     }
     
     /**
@@ -182,6 +188,22 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
         }
     }
     
+    /**
+     * Return all { link DayOfWeeks} ordered by the { link FirstOfWeek}.
+     */
+    public static unowned DayOfWeek[] all(FirstOfWeek first_of_week) {
+        switch (first_of_week) {
+            case FirstOfWeek.MONDAY:
+                return days_of_week_monday;
+            
+            case FirstOfWeek.SUNDAY:
+                return days_of_week_sunday;
+            
+            default:
+                assert_not_reached();
+        }
+    }
+    
     internal static DayOfWeek from_gdate(GLib.Date date) {
         assert(date.valid());
         
diff --git a/src/calendar/calendar-duration.vala b/src/calendar/calendar-duration.vala
index 1fdc9fb..24d13ac 100644
--- a/src/calendar/calendar-duration.vala
+++ b/src/calendar/calendar-duration.vala
@@ -42,41 +42,6 @@ public class Duration : BaseObject {
             + 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 "%ss".printf(seconds.to_string());
     }
diff --git a/src/calendar/calendar-week.vala b/src/calendar/calendar-week.vala
index 2ff35a0..f33292c 100644
--- a/src/calendar/calendar-week.vala
+++ b/src/calendar/calendar-week.vala
@@ -19,6 +19,9 @@ namespace California.Calendar {
  */
 
 public class Week : Unit<Week>, Gee.Comparable<Week>, Gee.Hashable<Week> {
+    public const int MIN_WEEK_OF_MONTH = 1;
+    public const int MAX_WEEK_OF_MONTH = 6;
+    
     /**
      * The one-based week of the month (1 to 6).
      */
@@ -61,6 +64,19 @@ public class Week : Unit<Week>, Gee.Comparable<Week>, Gee.Hashable<Week> {
     }
     
     /**
+     * Returns the { link Date} for the { link DayOfWeek}.
+     */
+    public Date date_at(DayOfWeek dow) {
+        // although mixing FirstOfWeek is dangerous, don't trust simple math here because of this issue
+        foreach (Date date in to_date_span()) {
+            if (date.day_of_week.equal_to(dow))
+                return date;
+        }
+        
+        assert_not_reached();
+    }
+    
+    /**
      * @inheritDoc
      */
     public override Week adjust(int quantity) {
diff --git a/src/calendar/calendar.vala b/src/calendar/calendar.vala
index 96fa49a..f16735d 100644
--- a/src/calendar/calendar.vala
+++ b/src/calendar/calendar.vala
@@ -75,10 +75,6 @@ private unowned string FMT_24HOUR_MIN_SEC;
 private unowned string MIDNIGHT;
 private unowned string NOON;
 
-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;
@@ -207,21 +203,6 @@ public void init() throws Error {
     // The 24-hour time with minutes and seconds, i.e. "17:06:31"
     FMT_24HOUR_MIN_SEC = _("%02d:%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.
-    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
-    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.
-    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
-    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.
-    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
-    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);
@@ -262,8 +243,6 @@ public void terminate() {
     DayOfWeek.terminate();
     OlsonZone.terminate();
     Collection.terminate();
-    
-    UNIT_DAYS = UNIT_HOURS = UNIT_MINS = null;
 }
 
 }
diff --git a/src/collection/collection-iterable.vala b/src/collection/collection-iterable.vala
index 2276d8e..b551cd2 100644
--- a/src/collection/collection-iterable.vala
+++ b/src/collection/collection-iterable.vala
@@ -30,6 +30,32 @@ public California.Iterable<G> iterate<G>(G g, ...) {
 }
 
 /**
+ * Take a non-null array of non-null items (all of type G) and return a California.Iterable
+ * for convenience.
+ */
+public California.Iterable<G> from_array<G>(G[] ar) {
+    Gee.ArrayList<G> list = new Gee.ArrayList<G>();
+    foreach (G item in ar)
+        list.add(item);
+    
+    return California.traverse<G>(list);
+}
+
+/**
+ * Returns an { link Iterable} of Unicode characters for each in the supplied string.
+ */
+public Iterable<unichar> from_string(string str) {
+    Gee.ArrayList<unichar> list = new Gee.ArrayList<unichar>();
+    
+    int index = 0;
+    unichar ch;
+    while (str.get_next_char(ref index, out ch))
+        list.add(ch);
+    
+    return California.traverse<unichar>(list);
+}
+
+/**
  * An Iterable that simply wraps an existing Iterator.  You get one iteration,
  * and only one iteration.  Basically every method triggers one iteration and
  * returns a new object.
@@ -39,12 +65,17 @@ public California.Iterable<G> iterate<G>(G g, ...) {
  * works in foreach.
  */
 
-public class Iterable<G> : BaseObject {
+public class Iterable<G> : Object {
+    /**
+     * For { link to_string}.
+     */
+    public delegate string? ToString<G>(G element);
+    
     /**
      * A private class that lets us take a California.Iterable and convert it back
      * into a Gee.Iterable.
      */
-    private class GeeIterable<G> : Gee.Traversable<G>, Gee.Iterable<G>, BaseObject {
+    private class GeeIterable<G> : Gee.Traversable<G>, Gee.Iterable<G>, Object {
         private Gee.Iterator<G> i;
         
         public GeeIterable(Gee.Iterator<G> iterator) {
@@ -63,10 +94,6 @@ public class Iterable<G> : BaseObject {
             }
             return true;
         }
-        
-        public override string to_string() {
-            return "GeeIterable";
-        }
     }
     
     private Gee.Iterator<G> i;
@@ -165,6 +192,17 @@ public class Iterable<G> : BaseObject {
         return new GeeIterable<G>(i);
     }
     
+    /**
+     * Convert the { link Iterable} into a flat array of elements.
+     */
+    public G[] to_array() {
+        G[] ar = new G[0];
+        while (i.next())
+            ar += i.get();
+        
+        return ar;
+    }
+    
     public Gee.Collection<G> add_all_to(Gee.Collection<G> c) {
         while (i.next())
             c.add(i  get());
@@ -188,7 +226,11 @@ public class Iterable<G> : BaseObject {
         return (Gee.TreeSet<G>) add_all_to(new Gee.TreeSet<G>((owned) compare_func));
     }
     
-    public Gee.Map<K, G> add_all_to_map<K>(Gee.Map<K, G> c, Gee.MapFunc<K, G> key_func) {
+    /**
+     * Add this { link Iterable}'s values to an existing Gee.Map, with this Iterable's values as
+     * values for the map.
+     */
+    public Gee.Map<K, G> add_to_map_values<K>(Gee.Map<K, G> c, Gee.MapFunc<K, G> key_func) {
         while (i.next()) {
             G g = i  get();
             c  set(key_func(g), g);
@@ -196,16 +238,63 @@ public class Iterable<G> : BaseObject {
         return c;
     }
     
-    public Gee.HashMap<K, G> to_hash_map<K>(Gee.MapFunc<K, G> key_func,
+    /**
+     * Add this { link Iterable}'s values to an existing Gee.Map, with this Iterable's values as
+     * keys for the map.
+     *
+     * @see add_to_map_keys
+     */
+    public Gee.Map<G, V> add_to_map_keys<V>(Gee.Map<G, V> map, Gee.MapFunc<V, G> value_func) {
+        while (i.next()) {
+            G g = i.get();
+            map.set(g, value_func(g));
+        }
+        
+        return map;
+    }
+    
+    /**
+     * Transform the { link Iterable} into a Gee.HashMap, with this Iterable's values as values
+     * for the map.
+     *
+     * @see add_to_map_values
+     */
+    public Gee.HashMap<K, G> to_hash_map_as_values<K>(Gee.MapFunc<K, G> key_func,
         owned Gee.HashDataFunc<K>? key_hash_func = null,
         owned Gee.EqualDataFunc<K>? key_equal_func = null,
         owned Gee.EqualDataFunc<G>? value_equal_func = null) {
-        return (Gee.HashMap<K, G>) add_all_to_map<K>(new Gee.HashMap<K, G>(
+        return (Gee.HashMap<K, G>) add_to_map_values<K>(new Gee.HashMap<K, G>(
             (owned) key_hash_func, (owned) key_equal_func, (owned) value_equal_func), key_func);
     }
     
-    public override string to_string() {
-        return "Iterable";
+    /**
+     * Transform the { link Iterable} into a Gee.HashMap, with this Iterable's values as keys
+     * for the map.
+     */
+    public Gee.HashMap<G, V> to_hash_map_as_keys<V>(Gee.MapFunc<V, G> value_func,
+        owned Gee.HashDataFunc<G>? key_hash_func = null,
+        owned Gee.EqualDataFunc<G>? key_equal_func = null,
+        owned Gee.EqualDataFunc<V>? value_equal_func = null) {
+        return (Gee.HashMap<G, V>) add_to_map_keys<V>(new Gee.HashMap<G, V>(
+            (owned) key_hash_func, (owned) key_equal_func, (owned) value_equal_func), value_func);
+    }
+    
+    /**
+     * Convert the { link Iterable}'s values into a single plain string.
+     *
+     * If { link ToString} returns null or an empty string, nothing is appended to the final string.
+     *
+     * If the final string is empty, null is returned instead.
+     */
+    public string? to_string(ToString<G> string_cb) {
+        StringBuilder builder = new StringBuilder();
+        foreach (G element in this) {
+            string? str = string_cb(element);
+            if (!String.is_empty(str))
+                builder.append(str);
+        }
+        
+        return !String.is_empty(builder.str) ? builder.str : null;
     }
 }
 
diff --git a/src/component/component-date-time.vala b/src/component/component-date-time.vala
index 546e807..d398f59 100644
--- a/src/component/component-date-time.vala
+++ b/src/component/component-date-time.vala
@@ -117,6 +117,46 @@ public class DateTime : BaseObject, Gee.Hashable<DateTime>, Gee.Comparable<DateT
     }
     
     /**
+     * Creates a new { link DateTime} for a component's RRULE UNTIL property.
+     */
+    public DateTime.rrule_until(iCal.icalrecurrencetype rrule, DateTime dtstart) throws ComponentError {
+        if (iCal.icaltime_is_null_time(rrule.until) != 0)
+            throw new ComponentError.INVALID("DATE-TIME for RRULE UNTIL is null time");
+        
+        if (iCal.icaltime_is_valid_time(rrule.until) != 0)
+            throw new ComponentError.INVALID("DATE-TIME for RRULE UNTIL is invalid");
+        
+        bool until_is_date = (iCal.icaltime_is_date(rrule.until) != 0);
+        bool until_is_utc = (iCal.icaltime_is_utc(rrule.until) != 0);
+        
+        // "The value of the UNTIL rule part MUST have the same value type as the 'DTSTART'
+        // property."
+        if (dtstart.is_date != until_is_date)
+            throw new ComponentError.INVALID("RRULE UNTIL and DTSTART must be of same type 
(DATE/DATE-TIME)");
+        
+        // "If the 'DTSTART' property is specified as a date with local time, then the UNTIL rule
+        // part MUST also be specified as a date with local time."
+        if (dtstart.is_utc != until_is_utc)
+            throw new ComponentError.INVALID("RRULE UNTIL and DTSTART must be of same time type 
(UTC/local)");
+        
+        // "if the 'DTSTART' property is specified as a date with UTC time or a date with local time
+        // and a time zone reference, then the UNTIL rule part MUST be specified as a date with
+        // UTC time."
+        if (dtstart.is_date || (!dtstart.is_utc && dtstart.zone != null)) {
+            if (!until_is_utc)
+                throw new ComponentError.INVALID("RRULE UNTIL must be UTC for DTSTART DATE or w/ time zone");
+        }
+        
+        // "If specified as a DATE-TIME value, then it MUST be specified in a UTC time format."
+        if (!until_is_date && !until_is_utc)
+            throw new ComponentError.INVALID("RRULE DATE-TIME UNTIL must be UTC");
+        
+        kind = iCal.icalproperty_kind.RRULE_PROPERTY;
+        dt = rrule.until;
+        zone = (!until_is_date || until_is_utc) ? Calendar.OlsonZone.utc : null;
+    }
+    
+    /**
      * Converts the stored iCal DATE-TIME to an { link Calendar.ExactTime}.
      *
      * Returns null if { link is_date} is true.
diff --git a/src/component/component-details-parser.vala b/src/component/component-details-parser.vala
index fc784f4..d7481eb 100644
--- a/src/component/component-details-parser.vala
+++ b/src/component/component-details-parser.vala
@@ -25,7 +25,7 @@ public class DetailsParser : BaseObject {
         
         public Token(string token) {
             original = token;
-            casefolded = token.casefold();
+            casefolded = from_string(token).filter(c => !c.ispunct()).to_string(c => c.to_string());
         }
         
         public bool equal_to(Token other) {
@@ -64,6 +64,7 @@ public class DetailsParser : BaseObject {
     private Calendar.Date? end_date = null;
     private Calendar.Duration? duration = null;
     private bool adding_location = false;
+    private RecurrenceRule? rrule = null;
     
     /**
      * Parses a user-entered string of event details into an { link Event}.
@@ -158,6 +159,13 @@ public class DetailsParser : BaseObject {
                 continue;
             stack.restore();
             
+            // A recurring preposition suggests a regular occurrance is being described by the next
+            // two tokens
+            stack.mark();
+            if (token.casefolded in RECURRING_PREPOSITIONS && parse_recurring(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
@@ -171,6 +179,24 @@ public class DetailsParser : BaseObject {
                 continue;
             }
             
+            // if a recurring rule has been started and are adding to it, drop common prepositions
+            // that indicate linkage
+            if (rrule != null && token.casefolded in COMMON_PREPOSITIONS)
+                continue;
+            
+            // if a recurring rule has not been started, look for keywords which transform the
+            // event into one
+            stack.mark();
+            if (rrule == null && parse_recurring_indicator(token))
+                continue;
+            stack.restore();
+            
+            // if a recurring rule has been started, attempt to parse into additions for the rule
+            stack.mark();
+            if (rrule != null && parse_recurring(token))
+                continue;
+            stack.restore();
+            
             // if this token and next describe a duration, use them
             stack.mark();
             if (parse_duration(token, stack.pop()))
@@ -191,6 +217,9 @@ public class DetailsParser : BaseObject {
         // assemble accumulated information in an Event, using defaults wherever appropriate
         //
         
+        // track if end_date is "artificially" generated to complete the Event
+        bool generated_end_date = (end_date == null);
+        
         // 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) {
@@ -237,10 +266,26 @@ public class DetailsParser : BaseObject {
                 new Calendar.ExactTime(Calendar.System.timezone, start_date, start_time),
                 new Calendar.ExactTime(Calendar.System.timezone, end_date, end_time)
             ));
+            
+            // for parser, RRULE UNTIL is always DTEND's date unless a duration (i.e. a count, as
+            // the parser doesn't set UNTIL elsewhere) is specified; parser only deals in date-based
+            // recurrences, but don't add UNTIL if parser auto-generated DTEND, since that's us
+            // filling in "obvious" details about the whole of the event that may not necessarily
+            // apply to the recurrence rule
+            if (rrule != null && !rrule.has_duration && !generated_end_date)
+                rrule.set_recurrence_end_date(end_date);
         } else if (start_date != null && end_date != null) {
             event.set_event_date_span(new Calendar.DateSpan(start_date, end_date));
+            
+            // see above note about RRULE UNTIL and DTEND
+            if (rrule != null && !rrule.has_duration && !generated_end_date)
+                rrule.set_recurrence_end_date(end_date);
         }
         
+        // recurrence rule, if specified
+        if (rrule != null)
+            event.make_recurring(rrule);
+        
         // other event details
         if (!String.is_empty(summary.str))
             event.summary = summary.str;
@@ -324,10 +369,52 @@ public class DetailsParser : BaseObject {
         if (amount == null || unit == null)
             return false;
         
+        // if setting up a recurring rule, duration can be used as a count
+        if (rrule != null) {
+            // if duration already specified, not interested
+            if (rrule.has_duration)
+                return false;
+            
+            // convert duration into unit appropriate to rrule ... note that only date-based
+            // rrules are allowed by parser
+            int count = -1;
+            switch (rrule.freq) {
+                case iCal.icalrecurrencetype_frequency.DAILY_RECURRENCE:
+                    if (unit.casefolded in UNIT_DAYS)
+                        count = parse_amount(amount);
+                break;
+                
+                case iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE:
+                    if (unit.casefolded in UNIT_WEEKS)
+                        count = parse_amount(amount);
+                break;
+                
+                case iCal.icalrecurrencetype_frequency.MONTHLY_RECURRENCE:
+                    if (unit.casefolded in UNIT_MONTHS)
+                        count = parse_amount(amount);
+                break;
+                
+                case iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE:
+                    if (unit.casefolded in UNIT_YEARS)
+                        count = parse_amount(amount);
+                break;
+                
+                default:
+                    assert_not_reached();
+            }
+            
+            if (count > 0) {
+                rrule.set_recurrence_count(count);
+                
+                return true;
+            }
+        }
+        
+        // otherwise, if an end time or duration is already known, then done here
         if (end_time != null || duration != null)
             return false;
         
-        duration = Calendar.Duration.parse(amount.casefolded, unit.casefolded);
+        duration = parse_amount_of_time(amount, unit);
         
         return duration != null;
     }
@@ -340,7 +427,7 @@ public class DetailsParser : BaseObject {
         if (start_time != null)
             return false;
         
-        Calendar.Duration? delay = Calendar.Duration.parse(amount.casefolded, unit.casefolded);
+        Calendar.Duration? delay = parse_amount_of_time(amount, unit);
         if (delay == null)
             return false;
         
@@ -350,6 +437,249 @@ public class DetailsParser : BaseObject {
         return true;
     }
     
+    // Returns negative value if amount is invalid
+    private int parse_amount(Token? amount) {
+        if (amount == null)
+            return -1;
+        
+        return String.is_numeric(amount.casefolded) ? int.parse(amount.casefolded) : -1;
+    }
+    
+    // Returns negative value if ordinal is invalid
+    private int parse_ordinal(Token? ordinal) {
+        if (ordinal == null)
+            return -1;
+        
+        // strip ordinal suffix if present
+        string ordinal_number = ordinal.casefolded;
+        foreach (string suffix in ORDINAL_SUFFIXES) {
+            if (!String.is_empty(suffix) && ordinal_number.has_suffix(suffix)) {
+                ordinal_number = ordinal_number.slice(0, ordinal_number.length - suffix.length);
+                
+                break;
+            }
+        }
+        
+        return String.is_numeric(ordinal_number) ? int.parse(ordinal_number) : -1;
+    }
+    
+    private Calendar.Duration? parse_amount_of_time(Token? amount, Token? unit) {
+        if (amount == null || unit == null)
+            return null;
+        
+        int amt = parse_amount(amount);
+        if (amt < 0)
+            return null;
+        
+        if (unit.casefolded in UNIT_DAYS)
+            return new Calendar.Duration(amt);
+        
+        if (unit.casefolded in UNIT_HOURS)
+            return new Calendar.Duration(0, amt);
+        
+        if (unit.casefolded in UNIT_MINS)
+            return new Calendar.Duration(0, 0, amt);
+        
+        return null;
+    }
+    
+    // this can create a new RRULE if the token indicates a one-time event should be recurring
+    private bool parse_recurring_indicator(Token? specifier) {
+        // rrule can't already exist
+        if (rrule != null || specifier == null)
+            return false;
+        
+        if (specifier.casefolded == DAILY)
+            return set_rrule_daily(1);
+        
+        if (specifier.casefolded == WEEKLY) {
+            if (start_date != null)
+                set_rrule_weekly(iterate<Calendar.DayOfWeek>(start_date.day_of_week).to_array(), 1);
+            else
+                set_rrule(iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE, 1);
+            
+            return true;
+        }
+        
+        if (specifier.casefolded == YEARLY) {
+            set_rrule(iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE, 1);
+            
+            return true;
+        }
+        
+        if (specifier.casefolded in UNIT_WEEKDAYS)
+            return set_rrule_weekly(Calendar.DayOfWeek.weekdays, 1);
+        
+        if (specifier.casefolded in UNIT_WEEKENDS)
+            return set_rrule_weekly(Calendar.DayOfWeek.weekend_days, 1);
+        
+        return false;
+    }
+    
+    // this can create a new RRULE or edit an existing one, but will not create multiple RRULEs
+    // for the same VEVENT
+    private bool parse_recurring(Token? specifier) {
+        if (specifier == null)
+            return false;
+        
+        // take ownership in case specifier is an ordinal amount
+        Token? unit = specifier;
+        
+        // look for an amount modifying the specifier (creating an interval, i.e. "every 2 days"
+        // or "every 2nd day", hence parsing for ordinal)
+        bool is_ordinal = false;
+        int interval = parse_ordinal(unit);
+        if (interval >= 1) {
+            unit = stack.pop();
+            if (unit == null)
+                return false;
+            
+            is_ordinal = true;
+        } else {
+            interval = 1;
+        }
+        
+        // a day of the week
+        Calendar.DayOfWeek? dow = Calendar.DayOfWeek.parse(unit.casefolded);
+        if (dow != null) {
+            Calendar.DayOfWeek[] by_days = iterate<Calendar.DayOfWeek>(dow).to_array();
+            
+            // if interval is an ordinal, the rule is for "nth day of the month", so it's a position
+            // (i.e. "1st tuesday")
+            if (!is_ordinal)
+                return set_rrule_weekly(by_days, interval);
+            else
+                return set_rrule_nth_day_of_week(by_days, interval);
+        }
+        
+        // "day"
+        if (unit.casefolded in UNIT_DAYS)
+            return set_rrule_daily(interval);
+        
+        // "weekday"
+        if (unit.casefolded in UNIT_WEEKDAYS)
+            return set_rrule_weekly(Calendar.DayOfWeek.weekdays, interval);
+        
+        // "weekend"
+        if (unit.casefolded in UNIT_WEEKENDS)
+            return set_rrule_weekly(Calendar.DayOfWeek.weekend_days, interval);
+        
+        //parse for date, and if so, treat as yearly event
+        stack.mark();
+        {
+            if (unit == specifier)
+                unit = stack.pop();
+            
+            if (unit != null) {
+                Calendar.Date? date = parse_day_month(specifier, unit);
+                if (date == null)
+                    date = parse_day_month(unit, specifier);
+                
+                if (date != null)
+                    return set_rrule_nth_day_of_year(date, 1);
+            }
+        }
+        stack.restore();
+        
+        return false;
+    }
+    
+    private void set_rrule(iCal.icalrecurrencetype_frequency freq, int interval) {
+        rrule = new RecurrenceRule(freq);
+        rrule.interval = interval;
+        rrule.first_of_week = Calendar.System.first_of_week.as_day_of_week();
+    }
+    
+    // Using the supplied by days, find the first upcoming start_date that matches one of them
+    // that is also the position (unless zero, which means "any")
+    private void set_byday_start_date(Calendar.DayOfWeek[]? by_days, int position) {
+        assert(position >= 0);
+        
+        // find the earliest date in the by_days; if it's earlier than the start_date or the
+        // start_date isn't defined, use the earliest
+        if (by_days != null) {
+            Gee.Set<Calendar.DayOfWeek> dows = from_array<Calendar.DayOfWeek>(by_days).to_hash_set();
+             Calendar.Date earliest = Calendar.System.today.upcoming(true, (date) => {
+                if (position != 0 && date.day_of_month.week_of_month != position)
+                    return false;
+                
+                return dows.contains(date.day_of_week);
+            });
+            if (start_date == null || earliest.compare_to(start_date) < 0)
+                start_date = earliest;
+        }
+        
+        // no start_date at this point, then today is it
+        if (start_date == null)
+            start_date = Calendar.System.today;
+    }
+    
+    // "every day"
+    private bool set_rrule_daily(int interval) {
+        if (rrule != null)
+            return false;
+        
+        // no start_date at this point, then today is it
+        if (start_date == null)
+            start_date = Calendar.System.today;
+        
+        set_rrule(iCal.icalrecurrencetype_frequency.DAILY_RECURRENCE, interval);
+        
+        return true;
+    }
+    
+    // "every tuesday"
+    private bool set_rrule_weekly(Calendar.DayOfWeek[]? by_days, int interval) {
+        if (rrule == null)
+            set_rrule(iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE, interval);
+        else if (!rrule.is_weekly)
+            return false;
+        
+        Gee.Map<Calendar.DayOfWeek?, int> map = from_array<Calendar.DayOfWeek>(by_days)
+            .to_hash_map_as_keys<int>(dow => 0);
+        rrule.add_by_rule(RecurrenceRule.ByRule.DAY, RecurrenceRule.encode_days(map));
+        
+        set_byday_start_date(by_days, 0);
+        
+        return true;
+    }
+    
+    // "every 1st tuesday"
+    private bool set_rrule_nth_day_of_week(Calendar.DayOfWeek[]? by_days, int position) {
+        // Although a month can span 6 calendar weeks, a day of a week never appears in more than
+        // five of them
+        if (position < 1 || position > 5)
+            return false;
+        
+        if (rrule == null)
+            set_rrule(iCal.icalrecurrencetype_frequency.MONTHLY_RECURRENCE, 1);
+        else if (!rrule.is_monthly)
+            return false;
+        
+        Gee.Map<Calendar.DayOfWeek?, int> map = from_array<Calendar.DayOfWeek>(by_days)
+            .to_hash_map_as_keys<int>(dow => position);
+        rrule.add_by_rule(RecurrenceRule.ByRule.DAY, RecurrenceRule.encode_days(map));
+        
+        set_byday_start_date(by_days, position);
+        
+        return true;
+    }
+    
+    // "every july 4th"
+    private bool set_rrule_nth_day_of_year(Calendar.Date date, int interval) {
+        if (rrule == null)
+            set_rrule(iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE, interval);
+        else if (!rrule.is_yearly)
+            return false;
+        
+        if (start_date == null)
+            start_date = date;
+        
+        rrule.add_by_rule(RecurrenceRule.ByRule.YEAR_DAY, iterate<int>(date.day_of_year).to_array_list());
+        
+        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
@@ -396,22 +726,15 @@ public class DetailsParser : BaseObject {
         // attempt to parse into day of the week
         Calendar.DayOfWeek? dow = Calendar.DayOfWeek.parse(token.casefolded);
         
-        return (dow != null) ? Calendar.System.today.upcoming(dow, true) : null;
+        return (dow != null)
+            ? Calendar.System.today.upcoming(true, date => date.day_of_week.equal_to(dow))
+            : 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))
+        int day_ordinal = parse_ordinal(day);
+        if (day_ordinal < 0)
             return null;
         
         Calendar.Month? month = Calendar.Month.parse(mon.casefolded);
@@ -422,8 +745,7 @@ public class DetailsParser : BaseObject {
             year = Calendar.System.today.year;
         
         try {
-            return new Calendar.Date(Calendar.DayOfMonth.for(int.parse(day.casefolded)),
-                month, year);
+            return new Calendar.Date(Calendar.DayOfMonth.for(day_ordinal), month, year);
         } catch (CalendarError calerr) {
             // probably an out-of-bounds day of month
             return null;
@@ -448,8 +770,10 @@ public class DetailsParser : BaseObject {
     private bool add_date(Calendar.Date date) {
         if (start_date == null)
             start_date = date;
-        else if (end_date == null)
+        else if (end_date == null && rrule == null)
             end_date = date;
+        else if (rrule != null && rrule.until_date == null)
+            rrule.set_recurrence_end_date(date);
         else
             return false;
         
diff --git a/src/component/component-event.vala b/src/component/component-event.vala
index 3d5960f..99ea3bf 100644
--- a/src/component/component-event.vala
+++ b/src/component/component-event.vala
@@ -20,6 +20,7 @@ public class Event : Instance, Gee.Comparable<Event> {
     public const string PROP_IS_ALL_DAY = "is-all-day";
     public const string PROP_LOCATION = "location";
     public const string PROP_STATUS = "status";
+    public const string PROP_RRULE = "rrule";
     
     public enum Status {
         TENTATIVE,
@@ -86,6 +87,15 @@ public class Event : Instance, Gee.Comparable<Event> {
     public Status status { get; set; default = Status.CONFIRMED; }
     
     /**
+     * { link RecurrenceRule} (RRULE) for { link Event}.
+     *
+     * If the RecurrenceRule is itself altered, that signal is reflected to { link Instance.altered}.
+     *
+     * @see make_recurring
+     */
+    public RecurrenceRule? rrule { get; private set; default = null; }
+    
+    /**
      * Create an { link Event} { link Component} from an EDS CalComponent object.
      *
      * Throws a BackingError if the E.CalComponent's VTYPE is not VEVENT.
@@ -151,6 +161,12 @@ public class Event : Instance, Gee.Comparable<Event> {
                 status = Status.CONFIRMED;
             break;
         }
+        
+        try {
+            make_recurring(new RecurrenceRule.from_ical(ical_component));
+        } catch (ComponentError comperr) {
+            // ignored; generally means no RRULE in component
+        }
     }
     
     private void on_notify(ParamSpec pspec) {
@@ -219,6 +235,15 @@ public class Event : Instance, Gee.Comparable<Event> {
                 }
             break;
             
+            case PROP_RRULE:
+                // always remove existing RRULE
+                remove_all_properties(iCal.icalproperty_kind.RRULE_PROPERTY);
+                
+                // add new one, if added
+                if (rrule != null)
+                    rrule.add_to_ical(ical_component);
+            break;
+            
             default:
                 altered = false;
             break;
@@ -325,6 +350,34 @@ public class Event : Instance, Gee.Comparable<Event> {
     }
     
     /**
+     * Add a { link RecurrenceRule} to the { link Event}.
+     *
+     * Pass null to make Event non-recurring.
+     */
+    public void make_recurring(RecurrenceRule? rrule) {
+        if (this.rrule != null) {
+            this.rrule.notify.disconnect(on_rrule_updated);
+            this.rrule.by_rule_updated.disconnect(on_rrule_updated);
+        }
+        
+        if (rrule != null) {
+            rrule.notify.connect(on_rrule_updated);
+            rrule.by_rule_updated.connect(on_rrule_updated);
+        }
+        
+        this.rrule = rrule;
+    }
+    
+    private void on_rrule_updated() {
+        // remove old property, replace with new one
+        remove_all_properties(iCal.icalproperty_kind.RRULE_PROPERTY);
+        rrule.add_to_ical(ical_component);
+        
+        // count this as an alteration
+        notify_altered(false);
+    }
+    
+    /**
      * @inheritDoc
      */
     public override bool is_valid() {
diff --git a/src/component/component-instance.vala b/src/component/component-instance.vala
index 7ad5445..4444d9c 100644
--- a/src/component/component-instance.vala
+++ b/src/component/component-instance.vala
@@ -88,6 +88,11 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     public iCal.icalcomponent ical_component { get { return _ical_component; } }
     
     /**
+     * Returns the iCal source for this { link Instance}.
+     */
+    public string source { get { return ical_component.as_ical_string(); } }
+    
+    /**
      * True if inside { link full_update}.
      *
      * Subclasses want to ignore updates to various properties (their own and { link Instance}'s)
@@ -300,22 +305,6 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     }
     
     /**
-     * Convenience method to convert a { link Calendar.Date} to an iCal DATE.
-     */
-    protected static void date_to_ical(Calendar.Date date, iCal.icaltimetype *ical_dt) {
-        ical_dt->year = date.year.value;
-        ical_dt->month = date.month.value;
-        ical_dt->day = date.day_of_month.value;
-        ical_dt->hour = 0;
-        ical_dt->minute = 0;
-        ical_dt->second = 0;
-        ical_dt->is_utc = 0;
-        ical_dt->is_date = 1;
-        ical_dt->is_daylight = 0;
-        ical_dt->zone = null;
-    }
-    
-    /**
      * Convenience method to convert a { link Calendar.DateSpan} to a pair of iCal DATEs.
      *
      * dtend_inclusive indicates whether the dt_end should be treated as inclusive or exclusive
@@ -330,24 +319,6 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     }
     
     /**
-     * Convenience method to convert a { link Calendar.ExactTime} to an iCal DATE-TIME.
-     */
-    protected static void exact_time_to_ical(Calendar.ExactTime exact_time, iCal.icaltimetype *ical_dt) {
-        ical_dt->year = exact_time.year.value;
-        ical_dt->month = exact_time.month.value;
-        ical_dt->day = exact_time.day_of_month.value;
-        ical_dt->hour = exact_time.hour;
-        ical_dt->minute = exact_time.minute;
-        ical_dt->second = exact_time.second;
-        ical_dt->is_utc = exact_time.tz.is_utc ? 1 : 0;
-        ical_dt->is_date = 0;
-        ical_dt->is_daylight = exact_time.is_dst ? 1 : 0;
-        ical_dt->zone = iCal.icaltimezone.get_builtin_timezone(exact_time.tz.zone.value);
-        if (ical_dt->zone == null)
-            message("Unable to get builtin iCal timezone for %s", exact_time.tz.zone.to_string());
-    }
-    
-    /**
      * Convenience method to convert a { link Calendar.ExactTimeSpan} to a pair of iCal DATE-TIMEs.
      */
     protected static void exact_time_span_to_ical(Calendar.ExactTimeSpan exact_time_span,
diff --git a/src/component/component-recurrence-rule.vala b/src/component/component-recurrence-rule.vala
new file mode 100644
index 0000000..289a4b3
--- /dev/null
+++ b/src/component/component-recurrence-rule.vala
@@ -0,0 +1,543 @@
+/* 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 {
+
+/**
+ * A mutable convenience representation of an iCalendar recurrence rule (RRULE).
+ *
+ * See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]]
+ * and [[https://tools.ietf.org/html/rfc5545#section-3.8.5.3]]
+ */
+
+public class RecurrenceRule : BaseObject {
+    public const string PROP_FREQ = "freq";
+    public const string PROP_UNTIL = "until";
+    public const string PROP_COUNT = "count";
+    public const string PROP_INTERVAL = "interval";
+    public const string PROP_FIRST_OF_WEEK = "first-of-week";
+    
+    /**
+     * Enumeration of various BY rules (BYSECOND, BYMINUTE, etc.)
+     */
+    public enum ByRule {
+        SECOND,
+        MINUTE,
+        HOUR,
+        DAY,
+        MONTH_DAY,
+        YEAR_DAY,
+        WEEK_NUM,
+        MONTH,
+        SET_POS
+    }
+    
+    /**
+     * Frequency.
+     *
+     * This is the only required field in an RRULE.
+     */
+    public iCal.icalrecurrencetype_frequency freq { get; set; }
+    
+    /**
+     * Returns true if { link freq} is iCal.icalrecurrencetype_frequence.DAILY_RECURRENCE,
+     */
+    public bool is_daily { get { return freq == iCal.icalrecurrencetype_frequency.DAILY_RECURRENCE; } }
+    
+    /**
+     * Returns true if { link freq} is iCal.icalrecurrencetype_frequence.DAILY_RECURRENCE,
+     */
+    public bool is_weekly { get { return freq == iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE; } }
+    
+    /**
+     * Returns true if { link freq} is iCal.icalrecurrencetype_frequence.MONTHLY_RECURRENCE,
+     */
+    public bool is_monthly { get { return freq == iCal.icalrecurrencetype_frequency.MONTHLY_RECURRENCE; } }
+    
+    /**
+     * Returns true if { link freq} is iCal.icalrecurrencetype_frequence.YEARLY_RECURRENCE,
+     */
+    public bool is_yearly { get { return freq == iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE; } }
+    
+    /**
+     * Until (end date), inclusive.
+     *
+     * This is mutually exclusive with { link count} and { link until_exact_time}.
+     *
+     * @see set_until_date
+     */
+    public Calendar.Date? until_date { get; private set; default = null; }
+    
+    /**
+     * Until (end date/time).
+     *
+     * This is mutually exclusive with { link count} and { link until_date}.
+     *
+     * @see set_until_exact_time
+     */
+    public Calendar.ExactTime? until_exact_time { get; private set; default = null; }
+    
+    /**
+     * Total number of recurrences.
+     *
+     * Zero indicates "not set", not zero recurrences.
+     *
+     * This is mutually exclusive with { link until_date} and { link until_exact_time}.
+     *
+     * @see set_recurrence_count
+     */
+    public int count { get; private set; default = 0; }
+    
+    /**
+     * Returns true if the recurrence rule has a duration.
+     *
+     * @see until
+     * @see count
+     */
+    public bool has_duration { get { return until_date != null || until_exact_time != null || count > 0; } }
+    
+    /**
+     * Interval between recurrences.
+     *
+     * A positive integer representing the interval (duration between) of each recurrence.  The
+     * actual amount of time elapsed is determined by the { link frequency} property.
+     *
+     * interval may be any value from 1 to short.MAX.
+     */
+    private int _interval = 1;
+    public int interval {
+        get { return _interval; }
+        set { _interval = value.clamp(1, short.MAX); }
+    }
+    
+    /**
+     * Start of work week (WKST).
+     */
+    public Calendar.DayOfWeek? first_of_week { get; set; default = null; }
+    
+    private Gee.SortedSet<int> by_second = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_minute = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_hour = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_day = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_month_day = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_year_day = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_week_num = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_month = new Gee.TreeSet<int>();
+    private Gee.SortedSet<int> by_set_pos = new Gee.TreeSet<int>();
+    
+    /**
+     * Fired when a BY rule is updated (BYSECOND, BYMINUTE, etc.)
+     */
+    public signal void by_rule_updated(ByRule by_rule);
+    
+    public RecurrenceRule(iCal.icalrecurrencetype_frequency freq) {
+        this.freq = freq;
+    }
+    
+    internal RecurrenceRule.from_ical(iCal.icalcomponent ical_component) throws Error {
+        // need DTSTART for timezone purposes
+        DateTime dtstart = new DateTime(ical_component, iCal.icalproperty_kind.DTSTART_PROPERTY);
+        
+        // fetch the RRULE from the component
+        unowned iCal.icalproperty? rrule_property = ical_component.get_first_property(
+            iCal.icalproperty_kind.RRULE_PROPERTY);
+        if (rrule_property == null)
+            throw new ComponentError.UNAVAILABLE("No RRULE found in component");
+        
+        iCal.icalrecurrencetype rrule = rrule_property.get_rrule();
+        
+        freq = rrule.freq;
+        interval = rrule.interval;
+        
+        if (rrule.count > 0) {
+            set_recurrence_count(rrule.count);
+        } else {
+            Component.DateTime date_time = new DateTime.rrule_until(rrule, dtstart);
+            if (date_time.is_date)
+                set_recurrence_end_date(date_time.to_date());
+            else
+                set_recurrence_end_exact_time(date_time.to_exact_time());
+        }
+        
+        switch (rrule.week_start) {
+            case iCal.icalrecurrencetype_weekday.SUNDAY_WEEKDAY:
+                first_of_week = Calendar.DayOfWeek.SUN;
+            break;
+            
+            case iCal.icalrecurrencetype_weekday.MONDAY_WEEKDAY:
+                first_of_week = Calendar.DayOfWeek.MON;
+            break;
+            
+            case iCal.icalrecurrencetype_weekday.TUESDAY_WEEKDAY:
+                first_of_week = Calendar.DayOfWeek.TUE;
+            break;
+            
+            case iCal.icalrecurrencetype_weekday.WEDNESDAY_WEEKDAY:
+                first_of_week = Calendar.DayOfWeek.WED;
+            break;
+            
+            case iCal.icalrecurrencetype_weekday.THURSDAY_WEEKDAY:
+                first_of_week = Calendar.DayOfWeek.THU;
+            break;
+            
+            case iCal.icalrecurrencetype_weekday.FRIDAY_WEEKDAY:
+                first_of_week = Calendar.DayOfWeek.FRI;
+            break;
+            
+            case iCal.icalrecurrencetype_weekday.SATURDAY_WEEKDAY:
+                first_of_week = Calendar.DayOfWeek.SAT;
+            break;
+            
+            case iCal.icalrecurrencetype_weekday.NO_WEEKDAY:
+            default:
+                first_of_week = null;
+            break;
+        }
+        
+        fill_by(rrule.by_second, by_second);
+        fill_by(rrule.by_minute, by_minute);
+        fill_by(rrule.by_hour, by_hour);
+        fill_by(rrule.by_day, by_day);
+        fill_by(rrule.by_month_day, by_month_day);
+        fill_by(rrule.by_year_day, by_year_day);
+        fill_by(rrule.by_week_no, by_week_num);
+        fill_by(rrule.by_month, by_month);
+        fill_by(rrule.by_set_pos, by_set_pos);
+    }
+    
+    private void fill_by(short[] ical_by_ar, Gee.SortedSet<int> by_set) {
+        for (int ctr = 0; ctr < ical_by_ar.length; ctr++) {
+            short by = ical_by_ar[ctr];
+            if (by == iCal.RECURRENCE_ARRAY_MAX)
+                break;
+            
+            by_set.add(by);
+        }
+    }
+    
+    /**
+     * Sets the { link until_date} property.
+     *
+     * Also sets { link count} to zero and nulls out { link until_exact_time}.
+     *
+     * Passing null will clear all these properties.
+     */
+    public void set_recurrence_end_date(Calendar.Date? date) {
+        freeze_notify();
+        
+        until_date = date;
+        until_exact_time = null;
+        count = 0;
+        
+        thaw_notify();
+    }
+    
+    /**
+     * Sets the { link until_exact_time} property.
+     *
+     * Also sets { link count} to zero and nulls out { link until_date}.
+     *
+     * Passing null will clear all these properties.
+     */
+    public void set_recurrence_end_exact_time(Calendar.ExactTime? exact_time) {
+        freeze_notify();
+        
+        until_date = null;
+        until_exact_time = exact_time;
+        count = 0;
+        
+        thaw_notify();
+    }
+    
+    /**
+     * Sets the { link count} property.
+     *
+     * Also clears { link until_date} and { link until_exact_time}.
+     *
+     * Passing zero will clear all these properties.
+     */
+    public void set_recurrence_count(int count) {
+        freeze_notify();
+        
+        until_date = null;
+        until_exact_time = null;
+        this.count = count.clamp(0, int.MAX);
+        
+        thaw_notify();
+    }
+    
+    /**
+     * Encode a { link Calendar.DayOfWeek} and its position (i.e. second Thursday of the month,
+     * last Wednesday of the year) into a value for { link set_by_rule} when using
+     * { link ByRule.DAY}.
+     *
+     * For position, 1 = first, 2 = second, -1 = last, -2 = second to last, etc.
+     *
+     * See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]] for information how these values
+     * operate according to this RRULE's { link freq}.
+     *
+     * Use null for DayOfWeek and zero for position to mean "any" or "every".
+     *
+     * @see encode_days
+     * @see decode_day
+     */
+    public static int encode_day(Calendar.DayOfWeek? dow, int position) {
+        // these encodings are mapped to iCal.icalrecurrencetype_weekday, which is SUNDAY-based
+        int dow_value = (dow != null) ? dow.ordinal(Calendar.FirstOfWeek.SUNDAY) : 0;
+        
+        position = position.clamp(short.MIN, short.MAX);
+        int value = (position * 8) + (position >= 0 ? dow_value : 0 - dow_value);
+        
+        return value;
+    }
+    
+    /**
+     * Decode the integer returned by { link get_by_rule} when { link ByRule.DAY} passed in.
+     *
+     * If null is returned for DayOfWeek or zero for position, that indicates "any" or "every".
+     * See { link encode_day} for more information.
+     *
+     * See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]] for information how these values
+     * operate according to this RRULE's { link freq}.
+     *
+     * Returns false if the supplied value is definitely not encoded correctly.
+     */
+    public static bool decode_day(int value, out Calendar.DayOfWeek? dow, out int position) {
+        position = iCal.icalrecurrencetype.day_position((short) value);
+        
+        dow = null;
+        int dow_value = (int) iCal.icalrecurrencetype.day_day_of_week((short) value);
+        if (dow_value != 0) {
+            try {
+                // iCal.icalrecurrencetype_weekday is SUNDAY-based
+                dow = Calendar.DayOfWeek.for(dow_value, Calendar.FirstOfWeek.SUNDAY);
+            } catch (CalendarError calerr) {
+                debug("Unable to decode day of week value %d: %s", dow_value, calerr.message);
+            }
+        }
+        
+        return true;
+    }
+    
+    /**
+     * Encode a Gee.Map of { link Calendar.DayOfWeek} and its position into a value for
+     * { link set_by_rule} when using { link ByRule.DAY}.
+     *
+     * Use null for DayOfWeek and zero for position to mean "any" or "every".
+     *
+     * @see encode_day
+     */
+    public static Gee.Collection<int> encode_days(Gee.Map<Calendar.DayOfWeek?, int>? day_values) {
+        if (day_values == null || day_values.size == 0)
+            return Gee.Collection.empty<int>();
+        
+        Gee.Collection<int> encoded = new Gee.ArrayList<int>();
+        Gee.MapIterator<Calendar.DayOfWeek?, int> iter = day_values.map_iterator();
+        while (iter.next())
+            encoded.add(encode_day(iter.get_key(), iter.get_value()));
+        
+        return encoded;
+    }
+    
+    /**
+     * Decode a Gee.Collection of encoded { link ByRule.DAY} values into their positions and
+     * { link Calendar.DayOfWeek}.
+     *
+     * Invalid values are skipped.
+     *
+     * @see encode_day
+     * @see encode_days
+     * @see decode_day
+     */
+    public static Gee.Map<Calendar.DayOfWeek?, int> decode_days(Gee.Collection<int>? values) {
+        if (values == null || values.size == 0)
+            return Gee.Map.empty<Calendar.DayOfWeek?, int>();
+        
+        Gee.Map<Calendar.DayOfWeek?, int> decoded = new Gee.HashMap<Calendar.DayOfWeek?, int>();
+        foreach (int value in values) {
+            Calendar.DayOfWeek? dow;
+            int position;
+            if (decode_day(value, out dow, out position))
+                decoded.set(dow, position);
+        }
+        
+        return decoded;
+    }
+    
+    private Gee.SortedSet<int> get_by_set(ByRule by_rule) {
+        switch (by_rule) {
+            case ByRule.SECOND:
+                return by_second;
+            
+            case ByRule.MINUTE:
+                return by_minute;
+            
+            case ByRule.HOUR:
+                return by_hour;
+            
+            case ByRule.DAY:
+                return by_day;
+            
+            case ByRule.MONTH_DAY:
+                return by_month_day;
+            
+            case ByRule.YEAR_DAY:
+                return by_year_day;
+            
+            case ByRule.WEEK_NUM:
+                return by_week_num;
+            
+            case ByRule.MONTH:
+                return by_month;
+            
+            case ByRule.SET_POS:
+                return by_set_pos;
+            
+            default:
+                assert_not_reached();
+        }
+    }
+    
+    /**
+     * Returns a read-only sorted set of BY rule settings for the specified { link ByRule}.
+     *
+     * See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]] for information how these values
+     * operate according to their associated ByRule and this RRULE's { link freq}.
+     */
+    public Gee.SortedSet<int> get_by_rule(ByRule by_rule) {
+        return get_by_set(by_rule).read_only_view;
+    }
+    
+    private bool is_int_short(int value) {
+        return value >= short.MIN && value <= short.MAX;
+    }
+    
+    /**
+     * Replaces the existing set of values for the BY rules with the supplied values.
+     *
+     * See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]] for information how these values
+     * operate according to their associated ByRule and this RRULE's { link freq}.
+     *
+     * Pass null or an empty Collection to clear the by-rules values.
+     *
+     * Any value greater than short.MAX or less than short.MIN will be dropped.
+     *
+     * Use { link encode_days} when passing values for { link ByRule.DAY}.
+     *
+     * @see add_by_rule
+     * @see by_rule_updated
+     */
+    public void set_by_rule(ByRule by_rule, Gee.Collection<int>? values) {
+        Gee.SortedSet<int> by_set = get_by_set(by_rule);
+        
+        by_set.clear();
+        if (values != null && values.size > 0)
+            by_set.add_all(traverse<int>(values).filter(is_int_short).to_array_list());
+        
+        by_rule_updated(by_rule);
+    }
+    
+    /**
+     * Adds the supplied values to the existing set of values for the BY rules.
+     *
+     * See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]] for information how these values
+     * operate according to their associated ByRule and this RRULE's { link freq}.
+     *
+     * Null or an empty Collection is a no-op.
+     *
+     * Any value greater than short.MAX or less than short.MIN will be dropped.
+     *
+     * Use { link encode_days} when passing values for { link ByRule.DAY}.
+     *
+     * @see set_by_rule
+     * @see by_rule_updated
+     */
+    public void add_by_rule(ByRule by_rule, Gee.Collection<int>? values) {
+        Gee.SortedSet<int> by_set = get_by_set(by_rule);
+        
+        if (values != null && values.size > 0)
+            by_set.add_all(traverse<int>(values).filter(is_int_short).to_array_list());
+        
+        by_rule_updated(by_rule);
+    }
+    
+    /**
+     * Converts a { link RecurrenceRule} into an iCalendar RRULE property and adds it to the
+     * iCal component.
+     *
+     * This call makes no attempt to remove an existing RRULE property; that should be performed by
+     * the caller first.
+     */
+    internal void add_to_ical(iCal.icalcomponent ical_component) {
+        iCal.icalrecurrencetype rrule = { 0 };
+        
+        rrule.freq = freq;
+        
+        if (until_date != null)
+            date_to_ical(until_date, &rrule.until);
+        else if (until_exact_time != null)
+            exact_time_to_ical(until_exact_time, &rrule.until);
+        else if (count > 0)
+            rrule.count = count;
+        
+        rrule.interval = (short) interval;
+        
+        if (first_of_week == null)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.NO_WEEKDAY;
+        else if (first_of_week == Calendar.DayOfWeek.SUN)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.SUNDAY_WEEKDAY;
+        else if (first_of_week == Calendar.DayOfWeek.MON)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.MONDAY_WEEKDAY;
+        else if (first_of_week == Calendar.DayOfWeek.TUE)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.TUESDAY_WEEKDAY;
+        else if (first_of_week == Calendar.DayOfWeek.WED)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.WEDNESDAY_WEEKDAY;
+        else if (first_of_week == Calendar.DayOfWeek.THU)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.THURSDAY_WEEKDAY;
+        else if (first_of_week == Calendar.DayOfWeek.FRI)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.FRIDAY_WEEKDAY;
+        else if (first_of_week == Calendar.DayOfWeek.SAT)
+            rrule.week_start = iCal.icalrecurrencetype_weekday.SATURDAY_WEEKDAY;
+        else
+            assert_not_reached();
+        
+        fill_ical_by(by_second, &rrule.by_second[0], rrule.by_second.length);
+        fill_ical_by(by_minute, &rrule.by_minute[0], rrule.by_minute.length);
+        fill_ical_by(by_hour, &rrule.by_hour[0], rrule.by_hour.length);
+        fill_ical_by(by_day, &rrule.by_day[0], rrule.by_day.length);
+        fill_ical_by(by_month_day, &rrule.by_month_day[0], rrule.by_month_day.length);
+        fill_ical_by(by_year_day, &rrule.by_year_day[0], rrule.by_year_day.length);
+        fill_ical_by(by_week_num, &rrule.by_week_no[0], rrule.by_week_no.length);
+        fill_ical_by(by_month, &rrule.by_month[0], rrule.by_month.length);
+        fill_ical_by(by_set_pos, &rrule.by_set_pos[0], rrule.by_set_pos.length);
+        
+        iCal.icalproperty rrule_property = new iCal.icalproperty(iCal.icalproperty_kind.RRULE_PROPERTY);
+        rrule_property.set_rrule(rrule);
+        
+        ical_component.add_property(rrule_property);
+    }
+    
+    private void fill_ical_by(Gee.SortedSet<int> by_set, short *ical_by_ar, int ar_length) {
+        int index = 0;
+        foreach (int by in by_set) {
+            ical_by_ar[index++] = (short) by;
+            
+            // watch for overflow
+            if (index >= ar_length)
+                break;
+        }
+        
+        if (index < ar_length)
+            ical_by_ar[index] = (short) iCal.RECURRENCE_ARRAY_MAX;
+    }
+    
+    public override string to_string() {
+        return "RRULE %s".printf(freq.to_string());
+    }
+}
+
+}
+
diff --git a/src/component/component.vala b/src/component/component.vala
index 9c917c8..d4521d8 100644
--- a/src/component/component.vala
+++ b/src/component/component.vala
@@ -20,10 +20,23 @@ private int init_count = 0;
 private string TODAY;
 private string TOMORROW;
 private string YESTERDAY;
+private string DAILY;
+private string WEEKLY;
+private string YEARLY;
+private string[] UNIT_WEEKDAYS;
+private string[] UNIT_WEEKENDS;
+private string[] UNIT_YEARS;
+private string[] UNIT_MONTHS;
+private string[] UNIT_WEEKS;
+private string[] UNIT_DAYS;
+private string[] UNIT_HOURS;
+private string[] UNIT_MINS;
+private string[] COMMON_PREPOSITIONS;
 private string[] TIME_PREPOSITIONS;
 private string[] LOCATION_PREPOSITIONS;
 private string[] DURATION_PREPOSITIONS;
 private string[] DELAY_PREPOSITIONS;
+private string[] RECURRING_PREPOSITIONS;
 private string[] ORDINAL_SUFFIXES;
 
 public void init() throws Error {
@@ -46,14 +59,78 @@ public void init() throws Error {
     // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
     YESTERDAY = _("yesterday").casefold();
     
+    // Used by quick-add to indicate the user wants to create a daily recurring event
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    DAILY = _("daily").casefold();
+    
+    // Used by quick-add to indicate the user wants to create a weekly recurring event
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    WEEKLY = _("weekly").casefold();
+    
+    // Used by quick-add to indicate the user wants to create a yearly recurring event
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    YEARLY = _("yearly").casefold();
+    
+    // Used by quick-add to indicate the user wants to create an event for every weekday
+    // (in most Western countries, this means Monday through Friday, i.e. the work week)
+    // Common abbreviations (without punctuation) should be included.  Each word must be separated
+    // by semi-colons.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    UNIT_WEEKDAYS = _("weekday;weekdays;").casefold().split(";");
+    
+    // Used by quick-add to indicate the user wants to create an event for every weekend
+    // (in most Western countries, this means Saturday and Sunday, i.e. non-work days)
+    // Common abbreviations (without punctuation) should be included.  Each word must be separated
+    // by semi-colons.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    UNIT_WEEKENDS = _("weekend;weekends;").casefold().split(";");
+    
+    // Used by quick-add to convert a user's years unit into an internal value.  Common abbreviations
+    // (without punctuation) should be included.  Each word must be separated by semi-colons.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    UNIT_YEARS = _("year;years;yr;yrs;").casefold().split(";");
+    
+    // Used by quick-add to convert a user's month unit into an internal value.  Common abbreviations
+    // (without punctuation) should be included.  Each word must be separated by semi-colons.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    UNIT_MONTHS = _("month;months;mo;mos;").casefold().split(";");
+    
+    // Used by quick-add to convert a user's week unit into an internal value.  Common abbreviations
+    // (without punctuation) should be included.  Each word must be separated by semi-colons.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    UNIT_WEEKS = _("week;weeks;wk;weeks;").casefold().split(";");
+    
+    // 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.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    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.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    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.
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    UNIT_MINS = _("minute;minutes;min;mins").casefold().split(";");
+    
+    // Used by quick-add to determine if the word is a COMMON preposition (indicating linkage or a
+    // connection).  Each word must be separate by semi-colons.
+    // These words should not be duplicated in another other preposition list.
+    // This list can be empty but that will limit the parser or cause unexpected results.
+    // Examples: "wednesday and thursday", "monday or friday"
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    COMMON_PREPOSITIONS = _("and;or;").casefold().split(";");
+    
     // Used by quick-add to determine if the word is a TIME preposition (indicating a
     // specific time of day, 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"
+    // Examples: "at 9am", "from 10pm to 11:30pm", "on monday", "until June 3rd"
     // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
-    TIME_PREPOSITIONS = _("at;from;to;on;").casefold().split(";");
+    TIME_PREPOSITIONS = _("at;from;to;on;until;").casefold().split(";");
     
     // Used by quick-add to determine if the word is a DURATION preposition (indicating a
     // a duration of time, not a specific time).  Each word must be separated by semi-colons.
@@ -73,6 +150,15 @@ public void init() throws Error {
     // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
     DELAY_PREPOSITIONS = _("in;").casefold().split(";");
     
+    // Used by quick-add to determine if the word is a RECURRING preposition (indicating a
+    // regular occurrance in 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.
+    // Example: "every 3 days", "every Friday"
+    // For more information see https://wiki.gnome.org/Apps/California/TranslatingQuickAdd
+    RECURRING_PREPOSITIONS = _("every;").casefold().split(";");
+    
     // Used by quick-add to determine if the word is a LOCATION 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
@@ -95,13 +181,49 @@ 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;
+    TIME_PREPOSITIONS = LOCATION_PREPOSITIONS = DURATION_PREPOSITIONS = ORDINAL_SUFFIXES = null;
+    COMMON_PREPOSITIONS = DELAY_PREPOSITIONS = RECURRING_PREPOSITIONS = null;
+    TODAY = TOMORROW = YESTERDAY = DAILY = WEEKLY = YEARLY = null;
+    UNIT_WEEKDAYS = UNIT_WEEKENDS = UNIT_YEARS = UNIT_MONTHS = UNIT_WEEKS = UNIT_DAYS = UNIT_HOURS
+        = UNIT_MINS = null;
     
     Calendar.terminate();
     Collection.terminate();
 }
 
+/**
+ * Convenience method to convert a { link Calendar.Date} to an iCal DATE.
+ */
+private void date_to_ical(Calendar.Date date, iCal.icaltimetype *ical_dt) {
+    ical_dt->year = date.year.value;
+    ical_dt->month = date.month.value;
+    ical_dt->day = date.day_of_month.value;
+    ical_dt->hour = 0;
+    ical_dt->minute = 0;
+    ical_dt->second = 0;
+    ical_dt->is_utc = 0;
+    ical_dt->is_date = 1;
+    ical_dt->is_daylight = 0;
+    ical_dt->zone = null;
+}
+
+/**
+ * Convenience method to convert a { link Calendar.ExactTime} to an iCal DATE-TIME.
+ */
+private void exact_time_to_ical(Calendar.ExactTime exact_time, iCal.icaltimetype *ical_dt) {
+    ical_dt->year = exact_time.year.value;
+    ical_dt->month = exact_time.month.value;
+    ical_dt->day = exact_time.day_of_month.value;
+    ical_dt->hour = exact_time.hour;
+    ical_dt->minute = exact_time.minute;
+    ical_dt->second = exact_time.second;
+    ical_dt->is_utc = exact_time.tz.is_utc ? 1 : 0;
+    ical_dt->is_date = 0;
+    ical_dt->is_daylight = exact_time.is_dst ? 1 : 0;
+    ical_dt->zone = iCal.icaltimezone.get_builtin_timezone(exact_time.tz.zone.value);
+    if (ical_dt->zone == null)
+        message("Unable to get builtin iCal timezone for %s", exact_time.tz.zone.to_string());
+}
+
 }
 
diff --git a/src/tests/tests-calendar-date.vala b/src/tests/tests-calendar-date.vala
index 0291696..0276c3e 100644
--- a/src/tests/tests-calendar-date.vala
+++ b/src/tests/tests-calendar-date.vala
@@ -14,10 +14,17 @@ private class CalendarDate : UnitTest.Harness {
         add_case("clamp-neither", clamp_neither);
         add_case("difference-pos", difference_pos);
         add_case("difference-neg", difference_neg);
-        add_case("upcoming", upcoming);
-        add_case("prior", prior);
+        add_case("upcoming-inclusive", upcoming_inclusive);
+        add_case("upcoming-exclusive", upcoming_exclusive);
+        add_case("prior-inclusive", prior_inclusive);
+        add_case("prior-exclusive", prior_exclusive);
         add_case("upcoming-today", upcoming_today);
         add_case("upcoming-next-week", upcoming_next_week);
+        add_case("day-of-week-position-1", day_of_week_position_1);
+        add_case("day-of-week-position-2", day_of_week_position_2);
+        add_case("day-of-week-position-3", day_of_week_position_3);
+        add_case("day-of-week-position-4", day_of_week_position_4);
+        add_case("day-of-week-position-5", day_of_week_position_5);
     }
     
     protected override void setup() throws Error {
@@ -82,25 +89,70 @@ private class CalendarDate : UnitTest.Harness {
         return today.difference(day_before_yesterday) == -2;
     }
     
-    private bool upcoming() throws Error {
+    private bool upcoming(bool inclusive, out string? dump) throws Error {
+        dump = null;
+        
         Calendar.Date today = Calendar.System.today;
-        Calendar.Date upcoming_fri = today.upcoming(Calendar.DayOfWeek.FRI, false);
-        int diff = today.difference(upcoming_fri);
         
-        return diff > 0 && diff <= 7;
+        foreach (Calendar.DayOfWeek dow in Calendar.DayOfWeek.all(Calendar.FirstOfWeek.SUNDAY)) {
+            Calendar.Date upcoming = Calendar.System.today.upcoming(inclusive,
+                date => date.day_of_week.equal_to(dow));
+            int diff = today.difference(upcoming);
+            
+            dump = "%s - %s = %d".printf(today.to_string(), upcoming.to_string(), diff);
+            
+            if (!inclusive && diff == 0)
+                return false;
+            
+            if (diff < 0 || diff > 7)
+                return false;
+        }
+        
+        return true;
+    }
+    
+    private bool upcoming_inclusive(out string? dump) throws Error {
+        return upcoming(true, out dump);
     }
     
-    private bool prior() throws Error {
+    private bool upcoming_exclusive(out string? dump) throws Error {
+        return upcoming(false, out dump);
+    }
+    
+    private bool prior(bool inclusive, out string? dump) throws Error {
+        dump = null;
+        
         Calendar.Date today = Calendar.System.today;
-        Calendar.Date prior_tue = today.prior(Calendar.DayOfWeek.TUE, false);
-        int diff = today.difference(prior_tue);
         
-        return diff < 0 && diff >= -7;
+        foreach (Calendar.DayOfWeek dow in Calendar.DayOfWeek.all(Calendar.FirstOfWeek.SUNDAY)) {
+            Calendar.Date upcoming = Calendar.System.today.prior(inclusive,
+                date => date.day_of_week.equal_to(dow));
+            int diff = today.difference(upcoming);
+            
+            dump = "%s - %s = %d".printf(today.to_string(), upcoming.to_string(), diff);
+            
+            if (!inclusive && diff == 0)
+                return false;
+            
+            if (diff > 0 || diff < -7)
+                return false;
+        }
+        
+        return true;
+    }
+    
+    private bool prior_inclusive(out string? dump) throws Error {
+        return prior(false, out dump);
+    }
+    
+    private bool prior_exclusive(out string? dump) throws Error {
+        return prior(false, out dump);
     }
     
     private bool upcoming_today() throws Error {
         Calendar.Date today = Calendar.System.today;
-        Calendar.Date another_today = today.upcoming(today.day_of_week, true);
+        Calendar.Date another_today = today.upcoming(true,
+            date => date.day_of_week.equal_to(today.day_of_week));
         int diff = today.difference(another_today);
         
         return diff == 0;
@@ -108,11 +160,45 @@ private class CalendarDate : UnitTest.Harness {
     
     private bool upcoming_next_week() throws Error {
         Calendar.Date today = Calendar.System.today;
-        Calendar.Date next_week = today.upcoming(today.day_of_week, false);
+        Calendar.Date next_week = today.upcoming(false,
+            date => date.day_of_week.equal_to(today.day_of_week));
         int diff = today.difference(next_week);
         
         return diff == 7;
     }
+    
+    private bool test_dow_position(Calendar.Date date, int expected, out string? dump) throws Error {
+        int position = date.day_of_month.week_of_month;
+        
+        dump = "%s position=%d, expected=%d".printf(date.to_string(), position, expected);
+        
+        return position == expected;
+    }
+    
+    private Calendar.Date jun2014(int dom) throws Error {
+        return new Calendar.Date(Calendar.DayOfMonth.for(dom), Calendar.Month.JUN,
+            new Calendar.Year(2014));
+    }
+    
+    private bool day_of_week_position_1(out string? dump) throws Error {
+        return test_dow_position(jun2014(1), 1, out dump);
+    }
+    
+    private bool day_of_week_position_2(out string? dump) throws Error {
+        return test_dow_position(jun2014(9), 2, out dump);
+    }
+    
+    private bool day_of_week_position_3(out string? dump) throws Error {
+        return test_dow_position(jun2014(20), 3, out dump);
+    }
+    
+    private bool day_of_week_position_4(out string? dump) throws Error {
+        return test_dow_position(jun2014(23), 4, out dump);
+    }
+    
+    private bool day_of_week_position_5(out string? dump) throws Error {
+        return test_dow_position(jun2014(30), 5, out dump);
+    }
 }
 
 }
diff --git a/src/tests/tests-quick-add-recurring.vala b/src/tests/tests-quick-add-recurring.vala
new file mode 100644
index 0000000..2db35d4
--- /dev/null
+++ b/src/tests/tests-quick-add-recurring.vala
@@ -0,0 +1,715 @@
+/* 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 {
+
+/**
+ * Note that some tests are repeated with different days of the week to avoid false positives when
+ * the current day of the week (at time of execution) matches quick-add details.
+ */
+
+private class QuickAddRecurring : UnitTest.Harness {
+    public QuickAddRecurring() {
+        // ByRule.DAY encoding/decoding tests
+        add_case("encode-decode-day-every-week", encode_decode_day_every_week);
+        add_case("encode-decode-day-every-month", encode_decode_day_every_month);
+        add_case("encode-decode-days-every-week", encode_decode_days_every_week);
+        add_case("encode-decode-days-every-month", encode_decode_days_every_month);
+        add_case("encode-decode-all", encode_decode_all);
+        add_case("encode-decode-all_negative", encode_decode_all_negative);
+        
+        // DAILY tests
+        add_case("every-day", every_day);
+        add_case("all-day", all_day);
+        add_case("daily", daily);
+        add_case("every-day-10-days", every_day_10_days);
+        add_case("every-2-days", every_2_days);
+        add_case("every-3rd-day", every_3rd_day);
+        add_case("every-2-days-for-10-days", every_2_days_for_10_days);
+        add_case("every-2-days-until", every_2_days_until);
+        
+        // WEEKLY
+        add_case("every-tuesday", every_tuesday);
+        add_case("every-friday", every_friday);
+        add_case("every-saturday-until", every_saturday_until);
+        add_case("all-day-saturday-until", all_day_saturday_until);
+        add_case("weekly-meeting-monday", weekly_meeting_monday);
+        add_case("weekly-meeting-tuesday", weekly_meeting_tuesday);
+        add_case("tuesday_weekly", tuesday_weekly);
+        add_case("thursday-weekly", thursday_weekly);
+        add_case("weekdays_to_1pm", weekdays_to_1pm);
+        add_case("weekends", weekends);
+        add_case("every_weekend", every_weekend);
+        add_case("every-tuesday-thursday", every_tuesday_thursday);
+        add_case("every-tuesday-and-thursday", every_tuesday_and_thursday);
+        add_case("every-tuesday-and-thursday-for-3-weeks", every_tuesday_and_thursday_for_3_weeks);
+        
+        // MONTHLY
+        add_case("every-first-tuesday", every_first_tuesday);
+        add_case("every-first-tuesday-for-3-weeks", every_first_tuesday_for_3_weeks);
+        add_case("every-second-sunday-until", every_second_sunday_until);
+        add_case("every-sixth-tuesday", every_sixth_tuesday);
+        
+        // YEARLY
+        add_case("every-july-4th", every_july_4th);
+        add_case("every-july-15th", every_july_15th);
+        add_case("every-4th-july", every_4th_july);
+        add_case("every-15th-july", every_15th_july);
+        add_case("july-4th-yearly", july_4th_yearly);
+        add_case("july-15th-yearly", july_15th_yearly);
+        add_case("yearly-july-4th", yearly_july_4th);
+        add_case("yearly-july-15th", yearly_july_15th);
+        add_case("yearly-meeting-july-4th", yearly_meeting_july_4th);
+        add_case("yearly-meeting-july-15th", yearly_meeting_july_15th);
+        add_case("meeting-every-july-4th-15th", meeting_every_july_4th_15th);
+        add_case("every-july-4th-3-years", every_july_4th_3_years);
+        add_case("every-aug-1st-until", every_aug_1st_until);
+    }
+    
+    protected override void setup() throws Error {
+        Component.init();
+        Calendar.init();
+    }
+    
+    protected override void teardown() {
+        Component.terminate();
+        Calendar.terminate();
+    }
+    
+    private bool encode_decode_day_every_week(out string? dump) throws Error {
+        int value = Component.RecurrenceRule.encode_day(Calendar.DayOfWeek.THU, 0);
+        
+        dump = "THU 0 -> %d".printf(value);
+        
+        Calendar.DayOfWeek? dow;
+        int position;
+        return Component.RecurrenceRule.decode_day(value, out dow, out position)
+            && dow != null
+            && dow.equal_to(Calendar.DayOfWeek.THU)
+            && position == 0;
+    }
+    
+    private bool encode_decode_day_every_month(out string? dump) throws Error {
+        int value = Component.RecurrenceRule.encode_day(Calendar.DayOfWeek.MON, 3);
+        
+        dump = "MON 3 -> %d".printf(value);
+        
+        Calendar.DayOfWeek? dow;
+        int position;
+        return Component.RecurrenceRule.decode_day(value, out dow, out position)
+            && dow != null
+            && dow.equal_to(Calendar.DayOfWeek.MON)
+            && position == 3;
+    }
+    
+    private bool encode_decode_days_every_week(out string? dump) throws Error {
+        Gee.Collection<int> values = Component.RecurrenceRule.encode_days(
+            iterate<Calendar.DayOfWeek?>(Calendar.DayOfWeek.TUE, 
Calendar.DayOfWeek.THU).to_hash_map_as_keys<int>(dow => 0));
+        Gee.Map<Calendar.DayOfWeek?, int> dows = Component.RecurrenceRule.decode_days(values);
+        
+        dump = "values.size=%d size=%d".printf(values.size, dows.size);
+        
+        return dows.size == 2
+            && dows.contains(Calendar.DayOfWeek.TUE)
+            && dows.contains(Calendar.DayOfWeek.THU)
+            && dows[Calendar.DayOfWeek.TUE] == 0
+            && dows[Calendar.DayOfWeek.THU] == 0;
+    }
+    
+    private bool encode_decode_days_every_month(out string? dump) throws Error {
+        int iter = 1;
+        Gee.Collection<int> values = Component.RecurrenceRule.encode_days(
+            iterate<Calendar.DayOfWeek?>(Calendar.DayOfWeek.MON, 
Calendar.DayOfWeek.WED).to_hash_map_as_keys<int>(dow => iter++));
+        Gee.Map<Calendar.DayOfWeek?, int> dows = Component.RecurrenceRule.decode_days(values);
+        
+        dump = "values.size=%d size=%d".printf(values.size, dows.size);
+        
+        return dows.size == 2
+            && dows.contains(Calendar.DayOfWeek.MON)
+            && dows.contains(Calendar.DayOfWeek.WED)
+            && dows[Calendar.DayOfWeek.MON] == 1
+            && dows[Calendar.DayOfWeek.WED] == 2;
+    }
+    
+    private bool encode_decode_all() throws Error {
+        Gee.Collection<Calendar.DayOfWeek> all =
+            
from_array<Calendar.DayOfWeek?>(Calendar.DayOfWeek.all(Calendar.FirstOfWeek.SUNDAY)).to_array_list();
+        
+        int iter = 0;
+        Gee.Collection<int> values = Component.RecurrenceRule.encode_days(
+            traverse<Calendar.DayOfWeek>(all).to_hash_map_as_keys<int>(dow => iter++));
+        Gee.Map<Calendar.DayOfWeek?, int> dows = Component.RecurrenceRule.decode_days(values);
+        
+        return dows.size == 7
+            && dows.has_key(Calendar.DayOfWeek.SUN)
+            && dows[Calendar.DayOfWeek.SUN] == 0
+            && dows[Calendar.DayOfWeek.MON] == 1
+            && dows[Calendar.DayOfWeek.TUE] == 2
+            && dows[Calendar.DayOfWeek.WED] == 3
+            && dows[Calendar.DayOfWeek.THU] == 4
+            && dows[Calendar.DayOfWeek.FRI] == 5
+            && dows[Calendar.DayOfWeek.SAT] == 6;
+    }
+    
+    private bool encode_decode_all_negative() throws Error {
+        Gee.Collection<Calendar.DayOfWeek> all =
+            
from_array<Calendar.DayOfWeek?>(Calendar.DayOfWeek.all(Calendar.FirstOfWeek.SUNDAY)).to_array_list();
+        
+        int iter = -1;
+        Gee.Collection<int> values = Component.RecurrenceRule.encode_days(
+            traverse<Calendar.DayOfWeek>(all).to_hash_map_as_keys<int>(dow => iter--));
+        Gee.Map<Calendar.DayOfWeek?, int> dows = Component.RecurrenceRule.decode_days(values);
+        
+        return dows.size == 7
+            && dows.has_key(Calendar.DayOfWeek.SUN)
+            && dows[Calendar.DayOfWeek.SUN] == -1
+            && dows[Calendar.DayOfWeek.MON] == -2
+            && dows[Calendar.DayOfWeek.TUE] == -3
+            && dows[Calendar.DayOfWeek.WED] == -4
+            && dows[Calendar.DayOfWeek.THU] == -5
+            && dows[Calendar.DayOfWeek.FRI] == -6
+            && dows[Calendar.DayOfWeek.SAT] == -7;
+    }
+    
+    // Checks that an RRULE was generated,
+    // the summary is       meeting at work
+    // the location is      work
+    // the start time is    10am
+    private bool basic(string details, out Component.Event event, out string? dump) {
+        Component.DetailsParser parser = new Component.DetailsParser(details, null);
+        event = parser.event;
+        
+        dump = "%s\n%s".printf(details, event.source);
+        
+        return event.rrule != null
+            && event.summary == "meeting at work"
+            && event.location == "work"
+            && !event.is_all_day
+            && event.exact_time_span.start_exact_time.to_wall_time().equal_to(new Calendar.WallTime(10, 0, 
0));
+    }
+    
+    // Checks that an RRULE was generated,
+    // the summary is       meeting at work
+    // the location is      work
+    // is all day
+    private bool multiday(string details, out Component.Event event, out string? dump) {
+        Component.DetailsParser parser = new Component.DetailsParser(details, null);
+        event = parser.event;
+        
+        dump = "%s\n%s".printf(details, event.source);
+        
+        return event.rrule != null
+            && event.summary == "meeting at work"
+            && event.location == "work"
+            && event.is_all_day;
+    }
+    
+    //
+    // DAILY
+    //
+    
+    private bool every_day(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work every day at 10am", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration;
+    }
+    
+    private bool all_day(out string? dump) throws Error {
+        Component.Event event;
+        return multiday("meeting at work every day", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration;
+    }
+    
+    private bool daily(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work daily at 10am", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration;
+    }
+    
+    private bool every_day_10_days(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work every day at 10am for 10 days", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 1
+            && event.rrule.count == 10;
+    }
+    
+    private bool every_2_days(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at 10am every 2 days at work", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 2
+            && !event.rrule.has_duration;
+    }
+    
+    private bool every_3rd_day(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at 10am every 3rd day at work", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 3
+            && !event.rrule.has_duration;
+    }
+    
+    private bool every_2_days_for_10_days(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work every 2 days for 10 days at 10am", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 2
+            && event.rrule.count == 10;
+    }
+    
+    private bool every_2_days_until(out string? dump) throws Error {
+        Calendar.Date end = new Calendar.Date(Calendar.DayOfMonth.for(31), Calendar.Month.DEC,
+            Calendar.System.today.year);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every 2 days until December 31", out event, out dump)
+            && event.rrule.is_daily
+            && event.rrule.interval == 2
+            && event.rrule.until_date != null
+            && event.rrule.until_date.equal_to(end);
+    }
+    
+    //
+    // WEEKLY
+    //
+    
+    private bool check_byrule_day(Component.Event event, Gee.Map<Calendar.DayOfWeek?, int> by_days) {
+        Gee.SortedSet<int> values = event.rrule.get_by_rule(Component.RecurrenceRule.ByRule.DAY);
+        if (values.size != by_days.size)
+            return false;
+        
+        foreach (int value in values) {
+            Calendar.DayOfWeek? dow;
+            int position;
+            if (!Component.RecurrenceRule.decode_day(value, out dow, out position))
+                return false;
+            
+            if (!by_days.has_key(dow) || by_days.get(dow) != position)
+                return false;
+        }
+        
+        return true;
+    }
+    
+    private bool every_tuesday(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every tuesday", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_friday(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.FRI).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every friday", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.FRI)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_saturday_until(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.SAT).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every saturday until dec 31", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && event.rrule.until_date != null
+            && event.rrule.until_date.equal_to(new Calendar.Date(Calendar.DayOfMonth.for(31),
+                Calendar.Month.DEC, Calendar.System.today.year))
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.SAT)
+            && check_byrule_day(event, by_days)
+            && event.exact_time_span.end_date.equal_to(event.exact_time_span.start_date);
+    }
+    
+    private bool all_day_saturday_until(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.SAT).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return multiday("meeting at work every saturday until dec 31", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && event.rrule.until_date != null
+            && event.rrule.until_date.equal_to(new Calendar.Date(Calendar.DayOfMonth.for(31),
+                Calendar.Month.DEC, Calendar.System.today.year))
+            && event.date_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.SAT)
+            && check_byrule_day(event, by_days)
+            && event.date_span.end_date.equal_to(event.date_span.start_date);
+    }
+    
+    private bool weekly_meeting_monday(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.MON).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("weekly meeting at work monday at 10am", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.MON)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool weekly_meeting_tuesday(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("weekly meeting at work tuesday at 10am", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool tuesday_weekly(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work tuesday at 10am weekly", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool thursday_weekly(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.THU).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work thursday at 10am weekly", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.THU)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool weekdays_to_1pm(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = from_array<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.weekdays).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work weekdays from 10am to 1pm", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && by_days.keys.contains(event.exact_time_span.start_date.day_of_week)
+            && event.exact_time_span.end_exact_time.to_wall_time().equal_to(new Calendar.WallTime(13, 0, 0))
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool weekends(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = from_array<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.weekend_days).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting weekends at work at 10am", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && by_days.keys.contains(event.exact_time_span.start_date.day_of_week)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_weekend(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = from_array<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.weekend_days).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work every weekend at 10am", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && by_days.keys.contains(event.exact_time_span.start_date.day_of_week)
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_tuesday_thursday(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE, Calendar.DayOfWeek.THU).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every tuesday, thursday", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && (event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+                || event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.THU))
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_tuesday_and_thursday(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE, Calendar.DayOfWeek.THU).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every tuesday and thursday", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && (event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+                || event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.THU))
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_tuesday_and_thursday_for_3_weeks(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE, Calendar.DayOfWeek.THU).to_hash_map_as_keys<int>(dow => 0);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every tuesday and thursday for 3 weeks", out event, out dump)
+            && event.rrule.is_weekly
+            && event.rrule.interval == 1
+            && (event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+                || event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.THU))
+            && check_byrule_day(event, by_days)
+            && event.rrule.count == 3;
+    }
+    
+    //
+    // MONTHLY
+    //
+    
+    private bool every_first_tuesday(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE).to_hash_map_as_keys<int>(dow => 1);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every 1st tuesday", out event, out dump)
+            && event.rrule.is_monthly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+            && event.exact_time_span.start_date.day_of_month.value <= 7
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_first_tuesday_for_3_weeks(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.TUE).to_hash_map_as_keys<int>(dow => 1);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every 1st tuesday for 3 months", out event, out dump)
+            && event.rrule.is_monthly
+            && event.rrule.interval == 1
+            && event.rrule.count == 3
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.TUE)
+            && event.exact_time_span.start_date.day_of_month.value <= 7
+            && check_byrule_day(event, by_days);
+    }
+    
+    private bool every_second_sunday_until(out string? dump) throws Error {
+        Gee.Map<Calendar.DayOfWeek?, int> by_days = iterate<Calendar.DayOfWeek?>(
+            Calendar.DayOfWeek.SUN).to_hash_map_as_keys<int>(dow => 2);
+        
+        Component.Event event;
+        return basic("meeting at work at 10am every 2nd sunday until august 1st", out event, out dump)
+            && event.rrule.is_monthly
+            && event.rrule.interval == 1
+            && event.rrule.until_date != null
+            && event.rrule.until_date.month == Calendar.Month.AUG
+            && event.rrule.until_date.day_of_month.value == 1
+            && event.rrule.until_date.year.compare_to(Calendar.System.today.year) >= 0
+            && event.exact_time_span.start_date.day_of_week.equal_to(Calendar.DayOfWeek.SUN)
+            && event.exact_time_span.start_date.day_of_month.value >= 7
+            && event.exact_time_span.start_date.day_of_month.value <= 14
+            && check_byrule_day(event, by_days);
+    }
+    
+    // bad input
+    private bool every_sixth_tuesday(out string? dump) throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser(
+            "meeting at work at 10am every 6th tuesday", null);
+        Component.Event event = parser.event;
+        
+        dump = event.source;
+        
+        return event.rrule == null
+            && event.summary == "meeting at work every 6th";
+    }
+    
+    //
+    // YEARLY
+    //
+    
+    private bool check_byrule_yearday(Component.Event event, Gee.Collection<int> by_yeardays) {
+        Gee.SortedSet<int> values = event.rrule.get_by_rule(Component.RecurrenceRule.ByRule.YEAR_DAY);
+        if (values.size != by_yeardays.size)
+            return false;
+        
+        return traverse<int>(by_yeardays).all(yearday => values.contains(yearday));
+    }
+    
+    private bool every_july_4th(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work at 10am every july 4th", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 4;
+    }
+    
+    private bool every_july_15th(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work at 10am every july 15th", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 15;
+    }
+    
+    private bool every_4th_july(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work at 10am every 4th july", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 4;
+    }
+    
+    private bool every_15th_july(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work at 10am every 15th july", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 15;
+    }
+    
+    private bool july_4th_yearly(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work july 4th 10am yearly", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 4;
+    }
+    
+    private bool july_15th_yearly(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work july 15th 10am yearly", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 15;
+    }
+    
+    private bool yearly_july_4th(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work yearly july 4th 10am", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 4;
+    }
+    
+    private bool yearly_july_15th(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work yearly july 15th 10am", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 15;
+    }
+    
+    private bool yearly_meeting_july_4th(out string? dump) throws Error {
+        Component.Event event;
+        return basic("yearly meeting at work july 4th 10am", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 4;
+    }
+    
+    private bool yearly_meeting_july_15th(out string? dump) throws Error {
+        Component.Event event;
+        return basic("yearly meeting at work july 15th 10am", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 15;
+    }
+    
+    private bool meeting_every_july_4th_15th(out string? dump) throws Error {
+        Calendar.Date july4 = new Calendar.Date(Calendar.DayOfMonth.for(4), Calendar.Month.JUL,
+            Calendar.System.today.year);
+        Calendar.Date july15 = new Calendar.Date(Calendar.DayOfMonth.for(15), Calendar.Month.JUL,
+            Calendar.System.today.year);
+        
+        Component.Event event;
+        return basic("meeting every july 4th and july 15 10am at work", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && !event.rrule.has_duration
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && (event.exact_time_span.start_date.day_of_month.value == 15
+                || event.exact_time_span.start_date.day_of_month.value == 4)
+            && event.exact_time_span.start_date.equal_to(event.exact_time_span.end_date)
+            && check_byrule_yearday(event, iterate<Calendar.Date>(july4, july15).map<int>(d => 
d.day_of_year).to_array_list());
+    }
+    
+    private bool every_july_4th_3_years(out string? dump) throws Error {
+        Component.Event event;
+        return basic("meeting at work at 10am every july 4th for 3 years", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && event.rrule.count == 3
+            && event.exact_time_span.start_date.month == Calendar.Month.JUL
+            && event.exact_time_span.start_date.day_of_month.value == 4;
+    }
+    
+    private bool every_aug_1st_until(out string? dump) throws Error {
+        Component.Event event;
+        return multiday("meeting at work aug 15 yearly until sep 1", out event, out dump)
+            && event.rrule.is_yearly
+            && event.rrule.interval == 1
+            && event.rrule.until_date != null
+            && event.rrule.until_date.month == Calendar.Month.SEP
+            && event.rrule.until_date.day_of_month.value == 1
+            && event.rrule.until_date.year.compare_to(Calendar.System.today.year) >= 0
+            && event.date_span.start_date.month == Calendar.Month.AUG
+            && event.date_span.start_date.day_of_month.value == 15
+            && event.date_span.start_date.year.compare_to(Calendar.System.today.year) >= 0
+            && event.date_span.end_date.equal_to(event.date_span.start_date);
+    }
+}
+
+}
+
diff --git a/src/tests/tests-quick-add.vala b/src/tests/tests-quick-add.vala
index e24e58c..c01ca84 100644
--- a/src/tests/tests-quick-add.vala
+++ b/src/tests/tests-quick-add.vala
@@ -8,7 +8,10 @@ namespace California.Tests {
 
 private class QuickAdd : UnitTest.Harness {
     public QuickAdd() {
+        add_case("null", null_details);
+        add_case("blank", blank);
         add_case("summary", summary);
+        add_case("summary-with-blanks", summary_with_blanks);
         add_case("summary-location", summary_location);
         add_case("with-12hr-time", with_12hr_time);
         add_case("with-24hr-time", with_24hr_time);
@@ -27,6 +30,8 @@ private class QuickAdd : UnitTest.Harness {
         add_case("midnight-to-one", midnight_to_one);
         add_case("separate-am", separate_am);
         add_case("separate-pm", separate_pm);
+        add_case("start-date-ordinal", start_date_ordinal);
+        add_case("end-date-ordinal", end_date_ordinal);
     }
     
     protected override void setup() throws Error {
@@ -39,6 +44,18 @@ private class QuickAdd : UnitTest.Harness {
         Calendar.terminate();
     }
     
+    private bool null_details() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser(null, null);
+        
+        return !parser.event.is_valid();
+    }
+    
+    private bool blank() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser(" ", null);
+        
+        return !parser.event.is_valid();
+    }
+    
     private bool summary() throws Error {
         Component.DetailsParser parser = new Component.DetailsParser("meet with Alice", null);
         
@@ -48,6 +65,15 @@ private class QuickAdd : UnitTest.Harness {
             && parser.event.date_span == null;
     }
     
+    private bool summary_with_blanks() 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);
         
@@ -177,7 +203,8 @@ private class QuickAdd : UnitTest.Harness {
         Component.DetailsParser parser = new Component.DetailsParser(
             "12:30pm Friday Lunch with Eric and Charles", null);
         
-        Calendar.Date friday = Calendar.System.today.upcoming(Calendar.DayOfWeek.FRI, true);
+        Calendar.Date friday = Calendar.System.today.upcoming(true,
+            date => date.day_of_week.equal_to(Calendar.DayOfWeek.FRI));
         
         Calendar.ExactTime start = new Calendar.ExactTime(Calendar.Timezone.local, friday,
             new Calendar.WallTime(12, 30, 0));
@@ -261,6 +288,33 @@ private class QuickAdd : UnitTest.Harness {
         return parser.event.summary == "Dinner"
             && parser.event.exact_time_span.start_exact_time.equal_to(start);
     }
+    
+    private bool start_date_ordinal() throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser(
+            "Dinner May 1st", null);
+        
+        Calendar.Date start = new Calendar.Date(Calendar.DayOfMonth.for(1), Calendar.Month.MAY,
+            Calendar.System.today.year);
+        
+        return parser.event.summary == "Dinner"
+            && parser.event.date_span.start_date.equal_to(start);
+    }
+    
+    private bool end_date_ordinal(out string? dump) throws Error {
+        Component.DetailsParser parser = new Component.DetailsParser(
+            "Off-site May 1st to May 2nd", null);
+        
+        dump = parser.event.source;
+        
+        Calendar.Date start = new Calendar.Date(Calendar.DayOfMonth.for(1), Calendar.Month.MAY,
+            Calendar.System.today.year);
+        Calendar.Date end = new Calendar.Date(Calendar.DayOfMonth.for(2), Calendar.Month.MAY,
+            Calendar.System.today.year);
+        
+        return parser.event.summary == "Off-site"
+            && parser.event.date_span.start_date.equal_to(start)
+            && parser.event.date_span.end_date.equal_to(end);
+    }
 }
 
 }
diff --git a/src/tests/tests-string.vala b/src/tests/tests-string.vala
index 436cf81..e1bc457 100644
--- a/src/tests/tests-string.vala
+++ b/src/tests/tests-string.vala
@@ -11,6 +11,8 @@ private class String : UnitTest.Harness {
         add_case("strip-zeroes-space", strip_zeroes_space);
         add_case("strip-zeroes-slash", strip_zeroes_slash);
         add_case("strip-zeroes-multiple", strip_zeroes_multiple);
+        add_case("reduce-whitespace", reduce_whitespace);
+        add_case("reduce-nonspace-whitespace", reduce_nonspace_whitespace);
     }
     
     protected override void setup() throws Error {
@@ -38,6 +40,22 @@ private class String : UnitTest.Harness {
         
         return result == "1/2/3/4";
     }
+    
+    private bool test_reduce_whitespace(string instr, string expected, out string? dump) throws Error {
+        string result = California.String.reduce_whitespace(instr);
+        
+        dump = "\"%s\" => \"%s\", expected \"%s\"".printf(instr, result, expected);
+        
+        return result == expected;
+    }
+    
+    private bool reduce_whitespace(out string? dump) throws Error {
+        return test_reduce_whitespace("  a  b  c  ", "a b c", out dump);
+    }
+    
+    private bool reduce_nonspace_whitespace(out string? dump) throws Error {
+        return test_reduce_whitespace("\t\ta\n\nb\r\rc\t\t", "a\nb\rc", out dump);
+    }
 }
 
 }
diff --git a/src/tests/tests.vala b/src/tests/tests.vala
index c85530b..b8dd74c 100644
--- a/src/tests/tests.vala
+++ b/src/tests/tests.vala
@@ -7,12 +7,17 @@
 namespace California.Tests {
 
 public int run(string[] args) {
+    // make warnings and criticals fatal to catch during tests
+    GLib.Log.set_always_fatal(
+        LogLevelFlags.LEVEL_WARNING | LogLevelFlags.LEVEL_ERROR | LogLevelFlags.LEVEL_CRITICAL);
+    
     UnitTest.Harness.register(new String());
-    UnitTest.Harness.register(new QuickAdd());
     UnitTest.Harness.register(new CalendarDate());
     UnitTest.Harness.register(new CalendarMonthSpan());
     UnitTest.Harness.register(new CalendarMonthOfYear());
     UnitTest.Harness.register(new CalendarWallTime());
+    UnitTest.Harness.register(new QuickAdd());
+    UnitTest.Harness.register(new QuickAddRecurring());
     
     return UnitTest.Harness.exec_all();
 }
diff --git a/src/unit-test/unit-test-harness.vala b/src/unit-test/unit-test-harness.vala
index 249aae9..759801d 100644
--- a/src/unit-test/unit-test-harness.vala
+++ b/src/unit-test/unit-test-harness.vala
@@ -11,7 +11,7 @@ namespace California.UnitTest {
  */
 
 public abstract class Harness : BaseObject {
-    public delegate bool Case() throws Error;
+    public delegate bool Case(out string? dump = null) throws Error;
     
     private class TestCase : BaseObject {
         public string name;
@@ -113,17 +113,21 @@ public abstract class Harness : BaseObject {
             }
             
             bool success = false;
+            string? dump = null;
             Error? err = null;
             try {
-                success = test_case.unit_test();
+                success = test_case.unit_test(out dump);
             } catch (Error caught) {
                 err = caught;
             }
             
             if (err != null)
-                stdout.printf("\nFailed: %s.%s\n\t%s\n", name, test_case.name, err.message);
+                stdout.printf("failed (thrown error):\n\t\"%s\"\n", err.message);
             else if (!success)
-                stdout.printf("\nFailed: %s.%s\n", name, test_case.name);
+                stdout.printf("failed (test):\n");
+            
+            if ((err != null || !success) && !String.is_empty(dump))
+                stdout.printf("%s\n", dump);
             
             if (err != null || !success)
                 Posix.exit(Posix.EXIT_FAILURE);
diff --git a/src/util/util-string.vala b/src/util/util-string.vala
index 05b9c33..b4ca45e 100644
--- a/src/util/util-string.vala
+++ b/src/util/util-string.vala
@@ -49,7 +49,9 @@ public string reduce_whitespace(string str) {
         last_ch = ch;
     }
     
-    return builder.str;
+    // due to get_next_char()'s interface, don't know when char is last, so it's possible for trailing
+    // whitespace to exist
+    return builder.str.chomp();
 }
 
 /**
diff --git a/vapi/libical.vapi b/vapi/libical.vapi
index 837687e..2bf00a2 100644
--- a/vapi/libical.vapi
+++ b/vapi/libical.vapi
@@ -57,9 +57,9 @@ namespace iCal {
                [CCode (cname = "icalcomponent_add_property")]
                public void add_property (iCal.icalproperty property);
                [CCode (cname = "icalcomponent_as_ical_string")]
-               public string as_ical_string ();
+               public unowned string as_ical_string ();
                [CCode (cname = "icalcomponent_as_ical_string_r")]
-               public unowned string as_ical_string_r ();
+               public string as_ical_string_r ();
                [CCode (cname = "icalcomponent_begin_component")]
                public unowned iCal.icalcompiter begin_component (iCal.icalcomponent_kind kind);
                [CCode (cname = "icalcomponent_check_restrictions")]
@@ -225,7 +225,7 @@ namespace iCal {
                [CCode (cname = "icaldurationtype_as_ical_string")]
                public unowned string as_ical_string ();
                [CCode (cname = "icaldurationtype_as_ical_string_r")]
-               public unowned string as_ical_string_r ();
+               public string as_ical_string_r ();
                [CCode (cname = "icaldurationtype_as_int")]
                public int as_int ();
                [CCode (cname = "icaldurationtype_bad_duration")]
@@ -253,7 +253,7 @@ namespace iCal {
                [CCode (cname = "icalparameter_as_ical_string")]
                public unowned string as_ical_string ();
                [CCode (cname = "icalparameter_as_ical_string_r")]
-               public unowned string as_ical_string_r ();
+               public string as_ical_string_r ();
                [CCode (cname = "icalparameter_new_charset", has_construct_function = false)]
                public icalparameter.charset (string v);
                [CCode (cname = "icalparameter_new_clone", has_construct_function = false)]
@@ -507,7 +507,7 @@ namespace iCal {
                [CCode (cname = "icalproperty_as_ical_string")]
                public unowned string as_ical_string ();
                [CCode (cname = "icalproperty_as_ical_string_r")]
-               public unowned string as_ical_string_r ();
+               public string as_ical_string_r ();
                [CCode (cname = "icalproperty_new_attach", has_construct_function = false)]
                public icalproperty.attach (iCal.icalattach v);
                [CCode (cname = "icalproperty_new_attendee", has_construct_function = false)]
@@ -579,7 +579,7 @@ namespace iCal {
                [CCode (cname = "icalproperty_enum_to_string")]
                public static unowned string enum_to_string (int e);
                [CCode (cname = "icalproperty_enum_to_string_r")]
-               public static unowned string enum_to_string_r (int e);
+               public string enum_to_string_r (int e);
                [CCode (cname = "icalproperty_new_exdate", has_construct_function = false)]
                public icalproperty.exdate (iCal.icaltimetype v);
                [CCode (cname = "icalproperty_new_expand", has_construct_function = false)]
@@ -703,7 +703,7 @@ namespace iCal {
                [CCode (cname = "icalproperty_get_parameter_as_string")]
                public unowned string get_parameter_as_string (string name);
                [CCode (cname = "icalproperty_get_parameter_as_string_r")]
-               public unowned string get_parameter_as_string_r (string name);
+               public string get_parameter_as_string_r (string name);
                [CCode (cname = "icalproperty_get_percentcomplete")]
                public int get_percentcomplete ();
                [CCode (cname = "icalproperty_get_permission")]
@@ -715,7 +715,7 @@ namespace iCal {
                [CCode (cname = "icalproperty_get_property_name")]
                public unowned string get_property_name ();
                [CCode (cname = "icalproperty_get_property_name_r")]
-               public unowned string get_property_name_r ();
+               public string get_property_name_r ();
                [CCode (cname = "icalproperty_get_query")]
                public unowned string get_query ();
                [CCode (cname = "icalproperty_get_queryid")]
@@ -783,7 +783,7 @@ namespace iCal {
                [CCode (cname = "icalproperty_get_value_as_string")]
                public unowned string get_value_as_string ();
                [CCode (cname = "icalproperty_get_value_as_string_r")]
-               public unowned string get_value_as_string_r ();
+               public string get_value_as_string_r ();
                [CCode (cname = "icalproperty_get_version")]
                public unowned string get_version ();
                [CCode (cname = "icalproperty_get_x")]
@@ -1442,7 +1442,7 @@ namespace iCal {
                [CCode (cname = "icalvalue_as_ical_string")]
                public unowned global::string as_ical_string ();
                [CCode (cname = "icalvalue_as_ical_string_r")]
-               public unowned global::string as_ical_string_r ();
+               public global::string as_ical_string_r ();
                [CCode (cname = "icalvalue_new_attach", has_construct_function = false)]
                public icalvalue.attach (iCal.icalattach attach);
                [CCode (cname = "icalvalue_new_binary", has_construct_function = false)]
@@ -1696,7 +1696,7 @@ namespace iCal {
                [CCode (cname = "icalperiodtype_as_ical_string")]
                public unowned string as_ical_string ();
                [CCode (cname = "icalperiodtype_as_ical_string_r")]
-               public unowned string as_ical_string_r ();
+               public string as_ical_string_r ();
                [CCode (cname = "icalperiodtype_from_string")]
                public static iCal.icalperiodtype from_string (string str);
                [CCode (cname = "icalperiodtype_is_null_period")]
@@ -1706,35 +1706,29 @@ namespace iCal {
                [CCode (cname = "icalperiodtype_null_period")]
                public static iCal.icalperiodtype null_period ();
        }
-       [CCode (cheader_filename = "libical/ical.h")]
+       public const int RECURRENCE_ARRAY_MAX;
+       public const int RUCURRENCE_ARRAY_MAX_BYTE;
+       [CCode (cheader_filename = "libical/ical.h", cname="struct icalrecurrencetype")]
+       [SimpleType]
        public struct icalrecurrencetype {
                public iCal.icalrecurrencetype_frequency freq;
                public iCal.icaltimetype until;
                public int count;
                public short interval;
                public iCal.icalrecurrencetype_weekday week_start;
-               [CCode (array_length = false)]
-               public weak short[] by_second;
-               [CCode (array_length = false)]
-               public weak short[] by_minute;
-               [CCode (array_length = false)]
-               public weak short[] by_hour;
-               [CCode (array_length = false)]
-               public weak short[] by_day;
-               [CCode (array_length = false)]
-               public weak short[] by_month_day;
-               [CCode (array_length = false)]
-               public weak short[] by_year_day;
-               [CCode (array_length = false)]
-               public weak short[] by_week_no;
-               [CCode (array_length = false)]
-               public weak short[] by_month;
-               [CCode (array_length = false)]
-               public weak short[] by_set_pos;
+               public unowned short by_second[61];
+               public unowned short by_minute[61];
+               public unowned short by_hour[25];
+               public unowned short by_day[364];
+               public unowned short by_month_day[32];
+               public unowned short by_year_day[367];
+               public unowned short by_week_no[54];
+               public unowned short by_month[13];
+               public unowned short by_set_pos[367];
                [CCode (cname = "icalrecurrencetype_as_string")]
                public unowned string as_string ();
                [CCode (cname = "icalrecurrencetype_as_string_r")]
-               public unowned string as_string_r ();
+               public string as_string_r ();
                [CCode (cname = "icalrecurrencetype_clear")]
                public void clear ();
                [CCode (cname = "icalrecurrencetype_day_day_of_week")]
@@ -1752,7 +1746,7 @@ namespace iCal {
                [CCode (cname = "icalreqstattype_as_string")]
                public unowned string as_string ();
                [CCode (cname = "icalreqstattype_as_string_r")]
-               public unowned string as_string_r ();
+               public string as_string_r ();
                [CCode (cname = "icalreqstattype_from_string")]
                public static iCal.icalreqstattype from_string (string str);
        }
@@ -2460,7 +2454,7 @@ namespace iCal {
        [CCode (cheader_filename = "libical/ical.h", cname = "icalenum_reqstat_code")]
        public static unowned string icalenum_reqstat_code (iCal.icalrequeststatus stat);
        [CCode (cheader_filename = "libical/ical.h", cname = "icalenum_reqstat_code_r")]
-       public static unowned string icalenum_reqstat_code_r (iCal.icalrequeststatus stat);
+       public static string icalenum_reqstat_code_r (iCal.icalrequeststatus stat);
        [CCode (cheader_filename = "libical/ical.h", cname = "icalenum_reqstat_desc")]
        public static unowned string icalenum_reqstat_desc (iCal.icalrequeststatus stat);
        [CCode (cheader_filename = "libical/ical.h", cname = "icalenum_reqstat_major")]
@@ -2518,11 +2512,11 @@ namespace iCal {
        [CCode (cheader_filename = "libical/ical.h", cname = "icallangbind_property_eval_string")]
        public static unowned string icallangbind_property_eval_string (iCal.icalproperty prop, string sep);
        [CCode (cheader_filename = "libical/ical.h", cname = "icallangbind_property_eval_string_r")]
-       public static unowned string icallangbind_property_eval_string_r (iCal.icalproperty prop, string sep);
+       public static string icallangbind_property_eval_string_r (iCal.icalproperty prop, string sep);
        [CCode (cheader_filename = "libical/ical.h", cname = "icallangbind_quote_as_ical")]
        public static unowned string icallangbind_quote_as_ical (string str);
        [CCode (cheader_filename = "libical/ical.h", cname = "icallangbind_quote_as_ical_r")]
-       public static unowned string icallangbind_quote_as_ical_r (string str);
+       public static string icallangbind_quote_as_ical_r (string str);
        [CCode (cheader_filename = "libical/ical.h", cname = "icallangbind_string_to_open_flag")]
        public static int icallangbind_string_to_open_flag (string str);
        [CCode (cheader_filename = "libical/ical.h", cname = "icalmemory_add_tmp_buffer")]


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