[california] Update/edit recurring events: Bug #725786



commit 1c187bfa3fdb5e44954ca403644f1357c885297b
Author: Jim Nelson <jim yorba org>
Date:   Wed Jul 16 19:36:50 2014 -0700

    Update/edit recurring events: Bug #725786
    
    This rather large commit allows for the user to update (edit)
    recurring events.  It also allows for recurring events to be created
    with a GUI display rather than the Quick Add interface.
    
    This commit fleshes out the notion of a master Instance versus a
    generated Instance, important for editing.  It also introduces a
    simple widget for rotating GtkButtonBoxes to allow the user to make
    a second-level decision without a separate dialog box.
    
    Updating "this and all future events" will be ticketed for later,
    as this patch was getting too beefy to allow for more functionality
    to bleed in.

 po/POTFILES.in                                     |    2 +
 src/Makefile.am                                    |    5 +
 src/activator/activator-instance-list.vala         |    2 +-
 .../activator-google-authenticating-pane.vala      |    2 +-
 .../activator-google-calendar-list-pane.vala       |    2 +-
 .../google/activator-google-login-pane.vala        |    2 +-
 src/activator/webcal/activator-webcal-pane.vala    |    2 +-
 .../backing-calendar-source-subscription.vala      |  114 +++--
 src/backing/backing-calendar-source.vala           |    6 +-
 src/backing/backing-error.vala                     |    6 +-
 .../backing-eds-calendar-source-subscription.vala  |  156 ++++--
 src/backing/eds/backing-eds-calendar-source.vala   |    9 +-
 src/california-resources.xml                       |    3 +
 src/collection/collection-iterable.vala            |   13 +
 src/collection/collection.vala                     |   14 +
 src/component/component-date-time.vala             |  111 +++--
 src/component/component-event.vala                 |  173 +++---
 src/component/component-instance.vala              |  229 ++++++++-
 src/component/component-recurrence-rule.vala       |   59 ++-
 src/host/host-create-update-event.vala             |  159 +++++-
 src/host/host-create-update-recurring.vala         |  585 ++++++++++++++++++++
 src/host/host-main-window.vala                     |   31 +-
 src/host/host-quick-create-event.vala              |   44 +-
 src/host/host-show-event.vala                      |   23 +-
 src/manager/manager-calendar-list.vala             |    2 +-
 src/rc/create-update-event.ui                      |   65 +--
 src/rc/create-update-recurring.ui                  |  551 ++++++++++++++++++
 src/tests/tests-iterable.vala                      |   45 ++
 src/tests/tests.vala                               |    1 +
 src/toolkit/toolkit-calendar-popup.vala            |   26 +-
 src/toolkit/toolkit-card.vala                      |   31 +-
 src/toolkit/toolkit-deck.vala                      |   24 +-
 src/toolkit/toolkit-rotating-button-box.vala       |   89 +++
 src/util/util-numeric.vala                         |   17 +
 vapi/libecal-1.2.vapi                              |    3 +-
 vapi/libecal-1.2/libecal-1.2.metadata              |    5 +-
 36 files changed, 2225 insertions(+), 386 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index d6deb65..e3a056f 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -11,6 +11,7 @@ src/calendar/calendar-date.vala
 src/calendar/calendar.vala
 src/component/component.vala
 src/host/host-create-update-event.vala
+src/host/host-create-update-recurring.vala
 src/host/host-import-calendar.vala
 src/host/host-main-window.vala
 src/host/host-show-event.vala
@@ -22,6 +23,7 @@ src/view/week/week-controller.vala
 [type: gettext/glade]src/rc/calendar-manager-list-item.ui
 [type: gettext/glade]src/rc/calendar-manager-list.ui
 [type: gettext/glade]src/rc/create-update-event.ui
+[type: gettext/glade]src/rc/create-update-recurring.ui
 [type: gettext/glade]src/rc/google-authenticating.ui
 [type: gettext/glade]src/rc/google-calendar-list.ui
 [type: gettext/glade]src/rc/google-login.ui
diff --git a/src/Makefile.am b/src/Makefile.am
index f4c5fd0..3976255 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -92,6 +92,7 @@ california_VALASOURCES = \
        host/host.vala \
        host/host-calendar-list-item.vala \
        host/host-create-update-event.vala \
+       host/host-create-update-recurring.vala \
        host/host-import-calendar.vala \
        host/host-main-window.vala \
        host/host-quick-create-event.vala \
@@ -107,6 +108,7 @@ california_VALASOURCES = \
        tests/tests-calendar-month-of-year.vala \
        tests/tests-calendar-month-span.vala \
        tests/tests-calendar-wall-time.vala \
+       tests/tests-iterable.vala \
        tests/tests-quick-add.vala \
        tests/tests-quick-add-recurring.vala \
        tests/tests-string.vala \
@@ -126,12 +128,14 @@ california_VALASOURCES = \
        toolkit/toolkit-motion-event.vala \
        toolkit/toolkit-mutable-widget.vala \
        toolkit/toolkit-popup.vala \
+       toolkit/toolkit-rotating-button-box.vala \
        toolkit/toolkit-stack-model.vala \
        \
        util/util.vala \
        util/util-gfx.vala \
        util/util-markup.vala \
        util/util-memory.vala \
+       util/util-numeric.vala \
        util/util-scheduled.vala \
        util/util-string.vala \
        util/util-uri.vala \
@@ -175,6 +179,7 @@ california_RC = \
        rc/calendar-manager-list.ui \
        rc/calendar-manager-list-item.ui \
        rc/create-update-event.ui \
+       rc/create-update-recurring.ui \
        rc/google-authenticating.ui \
        rc/google-calendar-list.ui \
        rc/google-login.ui \
diff --git a/src/activator/activator-instance-list.vala b/src/activator/activator-instance-list.vala
index c5d560a..324deed 100644
--- a/src/activator/activator-instance-list.vala
+++ b/src/activator/activator-instance-list.vala
@@ -43,7 +43,7 @@ public class InstanceList : Gtk.Grid, Toolkit.Card {
         return true;
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
     }
     
     private void on_item_activated(Instance activator) {
diff --git a/src/activator/google/activator-google-authenticating-pane.vala 
b/src/activator/google/activator-google-authenticating-pane.vala
index e950173..cc9fbcc 100644
--- a/src/activator/google/activator-google-authenticating-pane.vala
+++ b/src/activator/google/activator-google-authenticating-pane.vala
@@ -56,7 +56,7 @@ public class GoogleAuthenticatingPane : Gtk.Grid, Toolkit.Card {
             app_id = "yorba-california-%s".printf(Application.VERSION);
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
         Message? credentials = message as Message;
         assert(credentials != null);
         
diff --git a/src/activator/google/activator-google-calendar-list-pane.vala 
b/src/activator/google/activator-google-calendar-list-pane.vala
index 8d37d7a..6dc545f 100644
--- a/src/activator/google/activator-google-calendar-list-pane.vala
+++ b/src/activator/google/activator-google-calendar-list-pane.vala
@@ -67,7 +67,7 @@ public class GoogleCalendarListPane : Gtk.Grid, Toolkit.Card {
         return label;
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
         Message? feeds = message as Message;
         assert(feeds != null);
         
diff --git a/src/activator/google/activator-google-login-pane.vala 
b/src/activator/google/activator-google-login-pane.vala
index 856db52..15d66fc 100644
--- a/src/activator/google/activator-google-login-pane.vala
+++ b/src/activator/google/activator-google-login-pane.vala
@@ -34,7 +34,7 @@ internal class GoogleLoginPane : Gtk.Grid, Toolkit.Card {
             BindingFlags.SYNC_CREATE, on_entry_changed);
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? msg) {
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? msg) {
         password_entry.text = "";
     }
     
diff --git a/src/activator/webcal/activator-webcal-pane.vala b/src/activator/webcal/activator-webcal-pane.vala
index c66dc91..4d3b31e 100644
--- a/src/activator/webcal/activator-webcal-pane.vala
+++ b/src/activator/webcal/activator-webcal-pane.vala
@@ -46,7 +46,7 @@ internal class WebCalActivatorPane : Gtk.Grid, Toolkit.Card {
             BindingFlags.SYNC_CREATE, on_entry_changed);
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
     }
     
     private bool on_entry_changed(Binding binding, Value source_value, ref Value target_value) {
diff --git a/src/backing/backing-calendar-source-subscription.vala 
b/src/backing/backing-calendar-source-subscription.vala
index 12a1342..0ffd6f7 100644
--- a/src/backing/backing-calendar-source-subscription.vala
+++ b/src/backing/backing-calendar-source-subscription.vala
@@ -9,15 +9,13 @@ namespace California.Backing {
 /**
  * A subscription to an active timespan of interest of a calendar.
  *
- * CalendarSourceSubscription generates { link Component.Instance}s of the various events in a
- * window of time in a calendar.  Note that for recurring events, there is no interface to directly
- * edit or remove the "original" iCalendar source.  Rather, the caller should update or remove
- * generated instances of that recurring event with flags to indicate how the original is to be
- * altered to match these changes.  See { link CalendarSource} for the interface to make these
- * alterations.
+ * The subscription notifies of calendar updates via its signals.  It can list complete or partial
+ * collections of { link Component.Instances} it has reported via those signals.
  *
- * The subscription can notify of calendar event updates and list a complete or partial collections
- * of the same.
+ * CalendarSourceSubscription generates { link Component.Instance}s of the various events in a
+ * window of time in a calendar.  Both master instances and generated recurring instances are
+ * reported through the interface.  Operations performed on instances should be done with the
+ * subscription's { link CalendarSource}.
  */
 
 public abstract class CalendarSourceSubscription : BaseObject {
@@ -50,22 +48,63 @@ public abstract class CalendarSourceSubscription : BaseObject {
     /**
      * Fired as existing { link Component.Instance}s are discovered when starting a subscription.
      *
+     * Like { link instance_added}, this is only fired for master Instances which do not
+     * generate recurring instances and generated Instances.  See { link master_discovered} to
+     * be notified of all master Instances.
+     *
      * This is fired while { link start} is working, either in the foreground or in the background.
      * It won't fire until start() is invoked.
+     *
+     * @see master_discovered
      */
     public signal void instance_discovered(Component.Instance instance);
     
     /**
-     * Indicates that an { link Instance} within the { link window} has been added to the calendar.
+     * Fired as existing master { link Component.Instance}s are discovered when starting a
+     * subscription.
+     *
+     * This is fired for all discovered master Instances whether or not they may generate
+     * Instances for this subscription.
+     *
+     * This is fired while { link start} is working, either in the foreground or in the background.
+     * It won't fire until start() is invoked.
+     *
+     * @see instance_discovered
+     */
+    public signal void master_discovered(Component.Instance instance);
+    
+    /**
+     * Fired when a { link Component.Instance} within the { link window} has been added to the
+     * { link CalendarSource}.
+     *
+     * This is fired only for master Instances which do not generate recurring Instances and for
+     * generated Instances.  See { link master_added} to be notified of all master Instances.
      *
      * The signal is fired for both local additions (added through this interface) and remote
      * additions.
      *
      * This signal won't fire until { link start} is called.
+     *
+     * @see master_added
      */
     public signal void instance_added(Component.Instance instance);
     
     /**
+     * Fired as existing master { link Component.Instance}s are added to the { link CalendarSource}.
+     *
+     * This is fired for all discovered master Instances whether or not they may generate
+     * Instances for this subscription.
+     *
+     * The signal is fired for both local additions (added through this interface) and remote
+     * additions.
+     *
+     * This signal won't fire until { link start} is called.
+     *
+     * @see instance_added
+     */
+    public signal void master_added(Component.Instance instance);
+    
+    /**
      * Indicates that an { link Instance} within the { link date_window} has been removed from the
      * calendar.
      *
@@ -143,10 +182,17 @@ public abstract class CalendarSourceSubscription : BaseObject {
      * @see instance_discovered
      */
     protected virtual void notify_instance_discovered(Component.Instance instance) {
-        if (add_instance(instance))
-            instance_discovered(instance);
-        else
+        if (!add_instance(instance)) {
             debug("Cannot add discovered component %s to %s: already known", instance.to_string(), 
to_string());
+            
+            return;
+        }
+        
+        if (instance.is_master_instance)
+            master_discovered(instance);
+        
+        if (!instance.can_generate_instances)
+            instance_discovered(instance);
     }
     
     /**
@@ -156,10 +202,17 @@ public abstract class CalendarSourceSubscription : BaseObject {
      * @see instance_added
      */
     protected virtual void notify_instance_added(Component.Instance instance) {
-        if (add_instance(instance))
-            instance_added(instance);
-        else
+        if (!add_instance(instance)) {
             debug("Cannot add component %s to %s: already known", instance.to_string(), to_string());
+            
+            return;
+        }
+        
+        if (instance.is_master_instance)
+            master_added(instance);
+        
+        if (!instance.can_generate_instances)
+            instance_added(instance);
     }
     
     /**
@@ -195,13 +248,13 @@ public abstract class CalendarSourceSubscription : BaseObject {
      * Notify that the { link Component.Instance}s have been dropped due to the { link Source} going
      * unavailable.
      */
-    protected virtual void notify_instance_dropped(Component.Instance instance) {
+    protected virtual void notify_instance_dropped(Component.UID uid) {
         Gee.Collection<Component.Instance> removed_instances;
-        if (remove_instance(instance.uid, out removed_instances)) {
+        if (remove_instance(uid, out removed_instances)) {
             foreach (Component.Instance removed_instance in removed_instances)
                 instance_dropped(removed_instance);
         } else {
-            debug("Cannot notify dropped component %s in %s: not known", instance.to_string(), to_string());
+            debug("Cannot notify dropped component %s in %s: not known", uid.to_string(), to_string());
         }
     }
     
@@ -264,7 +317,7 @@ public abstract class CalendarSourceSubscription : BaseObject {
         // the multimap
         debug("Dropping %d instances to %s: unavailable", instances.size, calendar.to_string());
         foreach (Component.Instance instance in instances.get_values().to_array())
-            notify_instance_dropped(instance);
+            notify_instance_dropped(instance.uid);
     }
     
     /**
@@ -284,29 +337,6 @@ public abstract class CalendarSourceSubscription : BaseObject {
         return instances.contains(uid) ? instances.get(uid) : null;
     }
     
-    /**
-     * Returns the seen { link Component.Instance} matching the supplied (possibly partially
-     * filled-out) Instance.
-     *
-     * This is for duplicate detection, especially if the { link Backing} is receiving raw iCal
-     * source and needs to verify if it's been parsed and introduced into the system.
-     *
-     * A blank Instance with partial fields filled out can be supplied.
-     */
-    public Component.Instance? has_instance(Component.Instance instance) {
-        Gee.Collection<Component.Instance>? seen_instances = for_uid(instance.uid);
-        if (seen_instances == null || seen_instances.size == 0)
-            return null;
-        
-        // for every instance matching its UID, look for the original
-        foreach (Component.Instance seen_instance in seen_instances) {
-            if (seen_instance.equal_to(instance))
-                return seen_instance;
-        }
-        
-        return null;
-    }
-    
     public override string to_string() {
         return "%s::%s".printf(calendar.to_string(), window.to_string());
     }
diff --git a/src/backing/backing-calendar-source.vala b/src/backing/backing-calendar-source.vala
index 4e6589b..fbfb6dd 100644
--- a/src/backing/backing-calendar-source.vala
+++ b/src/backing/backing-calendar-source.vala
@@ -19,7 +19,7 @@ namespace California.Backing {
 
 public abstract class CalendarSource : Source {
     /**
-     * The affected range of a modification or removal operation.
+     * The affected range of a removal operation.
      *
      * Note that zero (0) does ''not'' mean "none", it means { link AffectedInstances.THIS}.  The
      * additional enums merely expand the scope of the default, which is the supplied instance.
@@ -64,6 +64,10 @@ public abstract class CalendarSource : Source {
     /**
      * Updates an existing { link Component} instance on the backing { link CalendarSource}.
      *
+     * To update all { link Instance}s of a recurring { link Instance}, submit the
+     * { link Component.Instance.master} with modifications rather than one of its generated
+     * instances.  Submit a generated instance to update only that one.
+     *
      * Outstanding { link CalendarSourceSubscriptions} will eventually report the changes when
      * ready.
      */
diff --git a/src/backing/backing-error.vala b/src/backing/backing-error.vala
index 0857010..3f30d3a 100644
--- a/src/backing/backing-error.vala
+++ b/src/backing/backing-error.vala
@@ -22,7 +22,11 @@ public errordomain BackingError {
     /**
      * The method or object is unavailable due to a state change (not open or removed).
      */
-    UNAVAILABLE
+    UNAVAILABLE,
+    /**
+     * The object or identifier is not recognized.
+     */
+    UNKNOWN
 }
 
 }
diff --git a/src/backing/eds/backing-eds-calendar-source-subscription.vala 
b/src/backing/eds/backing-eds-calendar-source-subscription.vala
index b3003a6..ab1d582 100644
--- a/src/backing/eds/backing-eds-calendar-source-subscription.vala
+++ b/src/backing/eds/backing-eds-calendar-source-subscription.vala
@@ -11,17 +11,21 @@ namespace California.Backing {
  */
 
 internal class EdsCalendarSourceSubscription : CalendarSourceSubscription {
+    private delegate void InstanceNotifier(Component.Instance instance);
+    
     private E.CalClientView view;
+    private string sexp;
     // this is different than "active", which gets set when start completes
     private bool started = false;
     private Error? start_err = null;
     
     // Called from EdsCalendarSource.subscribe_async().  The CalClientView should not be started
     public EdsCalendarSourceSubscription(EdsCalendarSource eds_calendar, Calendar.ExactTimeSpan window,
-        E.CalClientView view) {
+        E.CalClientView view, string sexp) {
         base (eds_calendar, window);
         
         this.view = view;
+        this.sexp = sexp;
     }
     
     ~EdsCalendarSourceSubscription() {
@@ -82,36 +86,36 @@ internal class EdsCalendarSourceSubscription : CalendarSourceSubscription {
         // next
         view.start();
         
-        // prime with the list of known events
-        view.client.generate_instances(
-            window.start_exact_time.to_time_t(),
-            window.end_exact_time.to_time_t(),
-            cancellable,
-            on_instance_generated,
-            on_generate_finished);
+        discovery_async.begin(cancellable);
     }
     
-    private bool on_instance_generated(E.CalComponent eds_component, time_t instance_start,
-        time_t instance_end) {
+    private async void discovery_async(Cancellable? cancellable) {
+        SList<unowned iCal.icalcomponent> ical_components;
         try {
-            Component.Event? event = Component.Instance.convert(calendar, eds_component.get_icalcomponent())
-                as Component.Event;
-            if (event != null)
-                notify_instance_discovered(event);
+            yield view.client.get_object_list(sexp, cancellable, out ical_components);
         } catch (Error err) {
-            debug("Unable to generate discovered event for %s: %s", to_string(), err.message);
+            start_err = err;
+            
+            start_failed(err);
+            
+            return;
         }
         
-        return true;
-    }
-    
-    private void on_generate_finished() {
+        // process all known objects within the sexp range
+        on_objects_discovered_added(ical_components, notify_instance_discovered);
+        
         // only set when generation (start) is finished
         active = true;
     }
     
-    private void on_objects_added(SList<weak iCal.icalcomponent> objects) {
-        foreach (weak iCal.icalcomponent ical_component in objects) {
+    private void on_objects_added(SList<unowned iCal.icalcomponent> ical_components) {
+        // process all added objects
+        on_objects_discovered_added(ical_components, notify_instance_added);
+    }
+    
+    private void on_objects_discovered_added(SList<unowned iCal.icalcomponent> ical_components,
+        InstanceNotifier notifier) {
+        foreach (unowned iCal.icalcomponent ical_component in ical_components) {
             if (String.is_empty(ical_component.get_uid()))
                 continue;
             
@@ -121,47 +125,53 @@ internal class EdsCalendarSourceSubscription : CalendarSourceSubscription {
             if (has_uid(uid))
                 notify_instance_removed(uid);
             
-            // if no recurrences, add this alone
-            if (!E.Util.component_has_recurrences(ical_component)) {
-                add_instance(ical_component);
-                
+            // add all instances, master and generated
+            Component.Instance? master = add_instance(null, ical_component, notifier);
+            if (master == null)
+                continue;
+            
+            // if no recurrences, done
+            if (!E.Util.component_has_recurrences(ical_component))
                 continue;
-            }
             
             // generate recurring instances
-            view.client.generate_instances_for_object(
+            view.client.generate_instances_for_object_sync(
                 ical_component,
                 window.start_exact_time.to_time_t(),
                 window.end_exact_time.to_time_t(),
-                null,
-                on_instance_added,
-                null);
+                (eds_component, start, end) => {
+                    add_instance(master, eds_component.get_icalcomponent(), notifier);
+                    
+                    return true;
+                }
+            );
         }
     }
     
-    private bool on_instance_added(E.CalComponent eds_component, time_t instance_start,
-        time_t instance_end) {
-        add_instance(eds_component.get_icalcomponent());
-        
-        return true;
-    }
-    
     // Assumes all existing events with UID/RID have been removed already
-    private void add_instance(iCal.icalcomponent ical_component) {
+    private Component.Instance? add_instance(Component.Instance? master, iCal.icalcomponent ical_component,
+        InstanceNotifier notifier) {
         // convert the added component into a new Event
-        Component.Event? added_event;
+        Component.Event? added_event = null;
         try {
             added_event = Component.Instance.convert(calendar, ical_component) as Component.Event;
-            if (added_event != null)
-                notify_instance_added(added_event);
+            if (added_event != null) {
+                // assign the master (if this isn't the master already)
+                added_event.master = master;
+                
+                // notify of didscovery/addition
+                notifier(added_event);
+            }
         } catch (Error err) {
             debug("Unable to process added event: %s", err.message);
         }
+        
+        return added_event;
     }
     
-    private void on_objects_modified(SList<weak iCal.icalcomponent> objects) {
-        SList<weak iCal.icalcomponent> add_list = new SList<weak iCal.icalcomponent>();
-        foreach (weak iCal.icalcomponent ical_component in objects) {
+    private void on_objects_modified(SList<unowned iCal.icalcomponent> ical_components) {
+        SList<unowned iCal.icalcomponent> add_list = new SList<unowned iCal.icalcomponent>();
+        foreach (unowned iCal.icalcomponent ical_component in ical_components) {
             // if not an instance and has recurring, treat as an add (which removes and adds generated
             // instances)
             if (!E.Util.component_is_instance(ical_component) && 
E.Util.component_has_recurrences(ical_component)) {
@@ -178,37 +188,61 @@ internal class EdsCalendarSourceSubscription : CalendarSourceSubscription {
             if (!has_uid(uid))
                 continue;
             
-            // find original for this one
+            Component.DateTime? rid = null;
+            try {
+                rid = new Component.DateTime(ical_component, iCal.icalproperty_kind.RECURRENCEID_PROPERTY);
+            } catch (ComponentError comperr) {
+                if (!(comperr is ComponentError.UNAVAILABLE)) {
+                    debug("Unable to get RID of modified component: %s", comperr.message);
+                    
+                    continue;
+                }
+            }
+            
+            // get all instances known for this UID to find original to alter
             Gee.Collection<Component.Instance>? instances = for_uid(uid);
-            if (instances == null || instances.size == 0)
-                continue;
             
-            foreach (Component.Instance instance in instances) {
-                Component.Event? known_event = instance as Component.Event;
-                if (known_event == null)
+            // if no RID, then only one should be returned
+            Component.Instance? instance = null;
+            if (rid == null) {
+                instance = traverse<Component.Instance>(instances).one();
+                if (instance == null) {
+                    debug("%d instances found for modified instance, expected 1", 
Collection.size(instances));
+                    
                     continue;
-                
-                try {
-                    known_event.full_update(ical_component, null);
-                } catch (Error err) {
-                    debug("Unable to update event %s: %s", known_event.to_string(), err.message);
+                }
+            } else {
+                // if RID != null, then find the matching instance
+                instance = traverse<Component.Instance>(instances)
+                    .first_matching(inst => inst.rid != null && inst.rid.equal_to(rid));
+                if (instance == null) {
+                    debug("Cannot find instance with UID %s RID %s, skipping", uid.to_string(), 
rid.to_string());
                     
                     continue;
                 }
+            }
+            
+            Component.Event? modified_event = instance as Component.Event;
+            if (modified_event == null)
+                continue;
+            
+            try {
+                modified_event.full_update(ical_component, null);
+            } catch (Error err) {
+                debug("Unable to update event %s: %s", modified_event.to_string(), err.message);
                 
-                notify_instance_altered(known_event);
+                continue;
             }
             
-            if (instances.size > 1)
-                debug("Warning: updated %d modified events, expecting only 1", instances.size);
+            notify_instance_altered(modified_event);
         }
         
-        // add any recurring events
+        // remove and re-add any recurring events
         on_objects_added(add_list);
     }
     
-    private void on_objects_removed(SList<weak E.CalComponentId?> ids) {
-        foreach (weak E.CalComponentId id in ids)
+    private void on_objects_removed(SList<unowned E.CalComponentId?> ids) {
+        foreach (unowned E.CalComponentId id in ids)
             notify_instance_removed(new Component.UID(id.uid));
     }
 }
diff --git a/src/backing/eds/backing-eds-calendar-source.vala 
b/src/backing/eds/backing-eds-calendar-source.vala
index 440327e..3533c8d 100644
--- a/src/backing/eds/backing-eds-calendar-source.vala
+++ b/src/backing/eds/backing-eds-calendar-source.vala
@@ -135,7 +135,7 @@ internal class EdsCalendarSource : CalendarSource {
         E.CalClientView view;
         yield client.get_view(sexp, cancellable, out view);
         
-        return new EdsCalendarSourceSubscription(this, window, view);
+        return new EdsCalendarSourceSubscription(this, window, view, sexp);
     }
     
     public override async Component.UID? create_component_async(Component.Instance instance,
@@ -152,7 +152,10 @@ internal class EdsCalendarSource : CalendarSource {
         Cancellable? cancellable = null) throws Error {
         check_open();
         
-        yield client.modify_object(instance.ical_component, E.CalObjModType.THIS, cancellable);
+        E.CalObjModType modtype =
+            instance.can_generate_instances ? E.CalObjModType.ALL : E.CalObjModType.THIS;
+        
+        yield client.modify_object(instance.ical_component, modtype, cancellable);
     }
     
     public override async void remove_all_instances_async(Component.UID uid,
@@ -171,7 +174,7 @@ internal class EdsCalendarSource : CalendarSource {
         // include an EXDATE in the original iCal source ... I don't quite understand the benefit of
         // this, as this suggests (a) other calendar clients won't learn of the removal and (b) the
         // instance will be re-generated the next time the user runs an EDS calendar client.  In
-        // either case, ONLY maps to our desired effect by adding an EXDATE to the iCal source.
+        // either case, THIS maps to our desired effect by adding an EXDATE to the iCal source.
         switch (affected) {
             case CalendarSource.AffectedInstances.THIS:
                 yield client.remove_object(uid.value, rid.value, E.CalObjModType.THIS, cancellable);
diff --git a/src/california-resources.xml b/src/california-resources.xml
index e52d60d..edf38ea 100644
--- a/src/california-resources.xml
+++ b/src/california-resources.xml
@@ -22,6 +22,9 @@
         <file compressed="false">rc/create-update-event.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
+        <file compressed="false">rc/create-update-recurring.ui</file>
+    </gresource>
+    <gresource prefix="/org/yorba/california">
         <file compressed="true">rc/google-authenticating.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
diff --git a/src/collection/collection-iterable.vala b/src/collection/collection-iterable.vala
index 395247c..5648456 100644
--- a/src/collection/collection-iterable.vala
+++ b/src/collection/collection-iterable.vala
@@ -191,6 +191,19 @@ public class Iterable<G> : Object {
             .map<A>(g => { return (A) g; }));
     }
     
+    /**
+     * Returns the first element in the { link Iterable} if and only if it is the only one,
+     * otherwise returns null.
+     */
+    public G? one() {
+        if (!i.next())
+            return null;
+        
+        G element = i  get();
+        
+        return !i.next() ? element : null;
+    }
+    
     public G? first() {
         return (i.next() ? i  get() : null);
     }
diff --git a/src/collection/collection.vala b/src/collection/collection.vala
index ba28f21..6557566 100644
--- a/src/collection/collection.vala
+++ b/src/collection/collection.vala
@@ -18,5 +18,19 @@ public void terminate() {
         return;
 }
 
+/**
+ * Returns true if the Collection is null or empty (zero elements).
+ */
+public inline bool is_empty(Gee.Collection? c) {
+    return c == null || c.size == 0;
+}
+
+/**
+ * Returns the size of the Collection, zero if null.
+ */
+public inline int size(Gee.Collection? c) {
+    return !is_empty(c) ? c.size : 0;
+}
+
 }
 
diff --git a/src/component/component-date-time.vala b/src/component/component-date-time.vala
index d52bd00..1ad627c 100644
--- a/src/component/component-date-time.vala
+++ b/src/component/component-date-time.vala
@@ -62,13 +62,7 @@ public class DateTime : BaseObject, Gee.Hashable<DateTime>, Gee.Comparable<DateT
     public iCal.icalproperty_kind kind;
     
     /**
-     * Creates a new { link DateTime} for the iCal component of the property kind.
-     *
-     * Note that the only properties currently supported are:
-     * * DTSTART_PROPERTY
-     * * DTEND_PROPERTY
-     * * DTSTAMP_PROPERTY
-     * * RECURRENCEID_PROPERTY
+     * Creates a new { link DateTime} for the first iCal property of the kind.
      *
      * @throws ComponentError.UNAVAILABLE if not found
      * @throws ComponentError.INVALID if not a valid DATE or DATE-TIME
@@ -79,32 +73,33 @@ public class DateTime : BaseObject, Gee.Hashable<DateTime>, Gee.Comparable<DateT
         if (prop == null)
             throw new ComponentError.UNAVAILABLE("No property of kind %s", ical_prop_kind.to_string());
         
-        switch (ical_prop_kind) {
-            case iCal.icalproperty_kind.DTSTAMP_PROPERTY:
-                dt = prop.get_dtstamp();
-            break;
-            
-            case iCal.icalproperty_kind.DTSTART_PROPERTY:
-                dt = prop.get_dtstart();
-            break;
-            
-            case iCal.icalproperty_kind.DTEND_PROPERTY:
-                dt = prop.get_dtend();
+        init_from_property(prop);
+    }
+    
+    public DateTime.from_property(iCal.icalproperty prop) throws ComponentError {
+        init_from_property(prop);
+    }
+    
+    private void init_from_property(iCal.icalproperty prop) throws ComponentError {
+        unowned iCal.icalvalue prop_value = prop.get_value();
+        switch (prop_value.isa()) {
+            case iCal.icalvalue_kind.DATE_VALUE:
+                dt = prop_value.get_date();
             break;
             
-            case iCal.icalproperty_kind.RECURRENCEID_PROPERTY:
-                dt = prop.get_recurrenceid();
+            case iCal.icalvalue_kind.DATETIME_VALUE:
+                dt = prop_value.get_datetime();
             break;
             
             default:
-                assert_not_reached();
+                throw new ComponentError.INVALID("Not a DATE/DATE-TIME value: %s", 
prop_value.isa().to_string());
         }
         
         if (iCal.icaltime_is_null_time(dt) != 0)
-            throw new ComponentError.INVALID("DATE-TIME for %s is null time", ical_prop_kind.to_string());
+            throw new ComponentError.INVALID("DATE-TIME for %s is null time", prop.isa().to_string());
         
         if (iCal.icaltime_is_valid_time(dt) == 0)
-            throw new ComponentError.INVALID("DATE-TIME for %s is invalid", ical_prop_kind.to_string());
+            throw new ComponentError.INVALID("DATE-TIME for %s is invalid", prop.isa().to_string());
         
         unowned iCal.icalparameter? param = prop.get_first_parameter(iCal.icalparameter_kind.TZID_PARAMETER);
         if (param != null) {
@@ -120,45 +115,51 @@ public class DateTime : BaseObject, Gee.Hashable<DateTime>, Gee.Comparable<DateT
             zone = new Calendar.OlsonZone(dt.zone->get_location());
         }
         
-        kind = ical_prop_kind;
+        kind = prop.isa();
         value = prop.get_value_as_string();
     }
     
     /**
      * Creates a new { link DateTime} for a component's RRULE UNTIL property.
+     *
+     * Strict will attempt to adhere to the MUSTs and SHALLs present in the iCal specification
+     * regarding RRULE's UNTIL property. See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]]
      */
-    public DateTime.rrule_until(iCal.icalrecurrencetype rrule, DateTime dtstart) throws ComponentError {
+    public DateTime.rrule_until(iCal.icalrecurrencetype rrule, DateTime dtstart, bool strict)
+        throws ComponentError {
         if (iCal.icaltime_is_null_time(rrule.until) != 0)
-            throw new ComponentError.INVALID("DATE-TIME for RRULE UNTIL is null time");
+            throw new ComponentError.UNAVAILABLE("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");
+        if (iCal.icaltime_is_valid_time(rrule.until) == 0)
+            throw new ComponentError.UNAVAILABLE("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 (strict) {
+            // "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");
         }
         
-        // "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;
@@ -210,6 +211,20 @@ public class DateTime : BaseObject, Gee.Hashable<DateTime>, Gee.Comparable<DateT
     }
     
     /**
+     * Returns an iCal value for the { link DateTime}.
+     */
+    internal iCal.icalvalue to_ical_value() {
+        iCal.icalvalue prop_value = new iCal.icalvalue(
+            is_date ? iCal.icalvalue_kind.DATE_VALUE : iCal.icalvalue_kind.DATE_VALUE);
+        if (is_date)
+            prop_value.set_date(dt);
+        else
+            prop_value.set_datetime(dt);
+        
+        return prop_value;
+    }
+    
+    /**
      * Convert two { link DateTime}s into a { link Calendar.DateSpan} or a
      * { link Calendar.ExactTimeSpan} depending on what they represent.
      *
diff --git a/src/component/component-event.vala b/src/component/component-event.vala
index 9a14b3d..b4c2d34 100644
--- a/src/component/component-event.vala
+++ b/src/component/component-event.vala
@@ -20,7 +20,6 @@ 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,
@@ -87,15 +86,6 @@ 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.
@@ -161,12 +151,6 @@ 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) {
@@ -235,15 +219,6 @@ 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;
@@ -254,6 +229,17 @@ public class Event : Instance, Gee.Comparable<Event> {
     }
     
     /**
+     * @inheritDoc
+     */
+    public override Component.Instance clone() throws Error {
+        Component.Event cloned_event = new Component.Event(calendar_source, ical_component);
+        if (master != null)
+            cloned_event.master = new Component.Event(master.calendar_source, master.ical_component);
+        
+        return cloned_event;
+    }
+    
+    /**
      * Returns a { link Calendar.DateSpan} for the { link Event}.
      *
      * This will return a DateSpan whether the Event is a DATE or DATE-TIME VEVENT.
@@ -274,8 +260,12 @@ public class Event : Instance, Gee.Comparable<Event> {
      * @see set_event_exact_time_span
      */
     public void set_event_date_span(Calendar.DateSpan date_span) {
+        freeze_notify();
+        
         this.date_span = date_span;
         exact_time_span = null;
+        
+        thaw_notify();
     }
     
     /**
@@ -286,8 +276,72 @@ public class Event : Instance, Gee.Comparable<Event> {
      * @see set_event_date_span
      */
     public void set_event_exact_time_span(Calendar.ExactTimeSpan exact_time_span) {
+        freeze_notify();
+        
         this.exact_time_span = exact_time_span;
         date_span = null;
+        
+        thaw_notify();
+    }
+    
+    /**
+     * Adjusts the dates of an { link Event} while preserving { link WallTime}, if present.
+     *
+     * This will preserve the DATE/DATE-TIME aspect of an Event while adjusting the start and
+     * end { link Calendar.Date}s.  If a DATE Event, then this is functionally equivalent to
+     * { link set_event_date_span}.  If a DATE-TIME event, then this is like
+     * { link set_event_exact_time_span} but without the hassle of preserving start and end times
+     * while changing the dates.
+     */
+    public void adjust_event_date_span(Calendar.DateSpan date_span) {
+        if (is_all_day) {
+            set_event_date_span(date_span);
+            
+            return;
+        }
+        
+        Calendar.ExactTime new_start_time = new Calendar.ExactTime(
+            exact_time_span.start_exact_time.tz,
+            date_span.start_date,
+            exact_time_span.start_exact_time.to_wall_time()
+        );
+        
+        Calendar.ExactTime new_end_time = new Calendar.ExactTime(
+            exact_time_span.end_exact_time.tz,
+            date_span.end_date,
+            exact_time_span.end_exact_time.to_wall_time()
+        );
+        
+        set_event_exact_time_span(new Calendar.ExactTimeSpan(new_start_time, new_end_time));
+    }
+    
+    /**
+     * Convert an { link Event} from an all-day to a timed event by only adding the time.
+     *
+     * Returns with no changes if { link is_all_day} is false.
+     */
+    public void all_day_to_timed_event(Calendar.WallTime start_time, Calendar.WallTime end_time,
+        Calendar.Timezone timezone) {
+        if (!is_all_day)
+            return;
+        
+        // create exact time span using these parameters
+        set_event_exact_time_span(
+            new Calendar.ExactTimeSpan(
+                new Calendar.ExactTime(timezone, date_span.start_date, start_time),
+                new Calendar.ExactTime(timezone, date_span.end_date, end_time)
+            )
+        );
+    }
+    
+    /**
+     * Convert an { link Event} from a timed event to an all-day event by removing the time.
+     *
+     * Returns with no changes if { link is_all_day} is true.
+     */
+    public void timed_to_all_day_event() {
+        if (!is_all_day)
+            set_event_date_span(get_event_date_span(null));
     }
     
     /**
@@ -350,34 +404,6 @@ 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(bool and_useful) {
@@ -428,6 +454,13 @@ public class Event : Instance, Gee.Comparable<Event> {
         if (compare != 0)
             return compare;
         
+        // rid
+        if (rid != null && other.rid != null) {
+            compare = rid.compare_to(other.rid);
+            if (compare != 0)
+                return compare;
+        }
+        
         // summary
         compare = strcmp(summary, other.summary);
         if (compare != 0)
@@ -438,41 +471,15 @@ public class Event : Instance, Gee.Comparable<Event> {
         if (compare != 0)
             return compare;
         
-        // if recurring, go by sequence number, as the UID and RID are the same for all instances
-        if (is_recurring) {
-            compare = sequence - other.sequence;
-            if (compare != 0)
-                return compare;
-        }
+        // use sequence number if available
+        compare = sequence - other.sequence;
+        if (compare != 0)
+            return compare;
         
         // stabilize with UIDs
         return uid.compare_to(other.uid);
     }
     
-    public override bool equal_to(Component.Instance other) {
-        Component.Event? other_event = other as Component.Event;
-        if (other_event == null)
-            return false;
-        
-        if (this == other_event)
-            return true;
-        
-        if (is_recurring != other_event.is_recurring)
-            return false;
-        
-        if (is_recurring && !rid.equal_to(other_event.rid))
-            return false;
-        
-        if (sequence != other_event.sequence)
-            return false;
-        
-        return base.equal_to(other);
-    }
-    
-    public override uint hash() {
-        return uid.hash() ^ ((rid != null) ? rid.hash() : 0) ^ sequence;
-    }
-    
     public override string to_string() {
         return "Event %s/rid=%s/%d \"%s\" (%s)".printf(
             uid.to_string(),
diff --git a/src/component/component-instance.vala b/src/component/component-instance.vala
index 56c86ab..aff3575 100644
--- a/src/component/component-instance.vala
+++ b/src/component/component-instance.vala
@@ -19,6 +19,10 @@ namespace California.Component {
  * The second is to update the mutable properties themselves, which will then update the underlying
  * iCal component.
  *
+ * Instances produced by { link Backing.CalendarSourceSubscription}s will be updated by the
+ * subscription if the Instance is updated or removed locally or remotely.  Cloned Instances,
+ * however, are not automatically updated.  See { link clone}.
+ *
  * Alarms will be contained within Instance components.  Timezones are handled separately.
  *
  * Instance also offers a number of methods to convert iCal structures into internal objects.
@@ -29,8 +33,12 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     public const string PROP_DTSTAMP = "dtstamp";
     public const string PROP_UID = "uid";
     public const string PROP_ICAL_COMPONENT = "ical-component";
+    public const string PROP_RRULE = "rrule";
     public const string PROP_RID = "rid";
+    public const string PROP_EXDATES = "exdates";
+    public const string PROP_RDATES = "rdates";
     public const string PROP_SEQUENCE = "sequence";
+    public const string PROP_MASTER = "master";
     
     protected const string PROP_IN_FULL_UPDATE = "in-full-update";
     
@@ -61,6 +69,15 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     public UID uid { get; private set; }
     
     /**
+     * { link RecurrenceRule} (RRULE) for { link Instance}.
+     *
+     * If the RecurrenceRule is itself altered, that signal is reflected to { link altered}.
+     *
+     * @see make_recurring
+     */
+    public RecurrenceRule? rrule { get; private set; default = null; }
+    
+    /**
      * The RECURRENCE-ID of a recurring component.
      *
      * See [[https://tools.ietf.org/html/rfc5545#section-3.8.4.4]]
@@ -68,11 +85,83 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     public Component.DateTime? rid { get; set; default = null; }
     
     /**
-     * Returns true if the { link Recurrable} is in fact a recurring instance.
+     * All EXDATEs (DATE-TIME exceptions for recurring instances) in the { link Instance}.
+     *
+     * Returns a read-only set of { link DateTime}s.  Use { link set_exdates} to change.
+     *
+     * See [[https://tools.ietf.org/html/rfc5545#section-3.8.5.1]]
+     */
+    private Gee.SortedSet<DateTime>? _exdates = null;
+    public Gee.SortedSet<DateTime>? exdates {
+        owned get {
+            return Collection.is_empty(_exdates) ? null : _exdates.read_only_view;
+        }
+        
+        set {
+            _exdates = value;
+        }
+    }
+    
+    /**
+     * All RDATEs (DATE-TIMEs manually set for recurring instances) in the { link Instance}.
+     *
+     * See [[https://tools.ietf.org/html/rfc5545#section-3.8.5.2]]
+     */
+    private Gee.SortedSet<DateTime>? _rdates = null;
+    public Gee.SortedSet<DateTime>? rdates {
+        owned get {
+            return Collection.is_empty(_rdates) ? null : _rdates.read_only_view;
+        }
+        
+        set {
+            _rdates = value;
+        }
+    }
+    
+    /**
+     * Returns true if the { link Instance} is a master instance.
+     *
+     * A master instance is one that has not been generated from another Instance's recurring
+     * rule (RRULE).  In practice, this means the Instance does not have a RECURRENCE-ID.
+     *
+     * @see rid
+     * @see rrule
+     */
+    public bool is_master_instance { get { return rid == null; } }
+    
+    /**
+     * Returns true if the { link Instance} is a generated recurring instance.
+     *
+     * A generated recurring instance is one that has been artificially constructed from another
+     * Instance's recurring rule (RRULE).  In practice, this means the Instance has a
+     * RECURRENCE-ID.
      *
      * @see rid
+     * @see Backing.CalendarSource.fetch_master_component_async
      */
-    public bool is_recurring { get { return rid != null; } }
+    public bool is_generated_instance { get { return rid != null; } }
+    
+    /**
+     * Returns true if the master { link Instance} can generate recurring instances.
+     *
+     * This indicates the Instance is a master Instance and can generate recurring instances from
+     * its RRULE.  In practice, this means the Instance has no RECURRENCE-ID but does have an
+     * RRULE.  (Generated instances will have the RRULE that construct them as well as a
+     * RECURRENCE-ID.)
+     *
+     * @see rid
+     * @see Backing.CalendarSource.fetch_master_component_async
+     */
+    public bool can_generate_instances { get { return rid == null && rrule != null; } }
+    
+    /**
+     * If a generated { link Instance}, holds a reference to the master Instance that generated it.
+     *
+     * The { link Backing} should do everything it can to provide a master Instance for all
+     * generated Instances.  However, if this is null it is not a guarantee this is a master
+     * Instance.  Use { link is_master_instance}.
+     */
+    public Instance? master { get; internal set; default = null; }
     
     /**
      * The SEQUENCE of a VEVENT, VTODO, or VJOURNAL.
@@ -251,6 +340,17 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
         
         sequence = ical_component.get_sequence();
         
+        try {
+            make_recurring(new RecurrenceRule.from_ical(ical_component, false));
+        } catch (ComponentError comperr) {
+            // ignored; generally means no RRULE in component
+            if (!(comperr is ComponentError.UNAVAILABLE))
+                debug("Unable to parse RRULE for %s: %s", to_string(), comperr.message);
+        }
+        
+        exdates = get_multiple_date_times(iCal.icalproperty_kind.EXDATE_PROPERTY);
+        rdates = get_multiple_date_times(iCal.icalproperty_kind.RDATE_PROPERTY);
+        
         // save own copy of component; no ownership transferrance w/ current bindings
         if (_ical_component != ical_component)
             _ical_component = ical_component.clone();
@@ -274,6 +374,29 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
                 ical_component.set_sequence(sequence);
             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;
+            
+            case PROP_EXDATES:
+                if (Collection.is_empty(exdates))
+                    remove_all_properties(iCal.icalproperty_kind.EXDATE_PROPERTY);
+                else
+                    set_multiple_date_times(iCal.icalproperty_kind.EXDATE_PROPERTY, exdates);
+            break;
+            
+            case PROP_RDATES:
+                if (Collection.is_empty(rdates))
+                    remove_all_properties(iCal.icalproperty_kind.RDATE_PROPERTY);
+                else
+                    set_multiple_date_times(iCal.icalproperty_kind.RDATE_PROPERTY, rdates);
+            break;
+            
             default:
                 altered = false;
             break;
@@ -284,6 +407,47 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     }
     
     /**
+     * Make a detached copy of the { link Instance}.
+     *
+     * This produces an exact copy of the Instance at the time of the call.  Unlike Instances
+     * produced by { link Backing.CalendarSourceSubscription}s, cloned Instances are not
+     * automatically updated as local and/or remote changes are made.  This makes them good for
+     * editing (where a number of changes are made and stored in the Instance, only being submitted
+     * when the user gives the okay).
+     *
+     * Cloning will also clone the { link master}, if present.
+     */
+    public abstract Component.Instance clone() throws Error;
+    
+    /**
+     * Add a { link RecurrenceRule} to the { link Instance}.
+     *
+     * Pass null to make the Instance 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);
+    }
+    
+    /**
      * Returns an appropriate { link Component} instance for the iCalendar component.
      *
      * VCALENDARs should use { link Component.iCalendar}.
@@ -305,6 +469,46 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     }
     
     /**
+     * Convenience method to convert a collection of DATE/DATE-TIME properties into a SortedSet of
+     * { link DateTime}s.
+     *
+     * @see set_multiple_date_times
+     */
+    protected Gee.SortedSet<DateTime>? get_multiple_date_times(iCal.icalproperty_kind kind) {
+        Gee.SortedSet<DateTime> date_times = new Gee.TreeSet<DateTime>();
+        
+        unowned iCal.icalproperty? prop = ical_component.get_first_property(kind);
+        while (prop != null) {
+            try {
+                date_times.add(new DateTime.from_property(prop));
+            } catch (ComponentError comperr) {
+                debug("Unable to parse DATE/DATE-TIME for %s: %s", kind.to_string(), comperr.message);
+            }
+            
+            prop = ical_component.get_next_property(kind);
+        }
+        
+        return date_times.size > 0 ? date_times : null;
+    }
+    
+    /**
+     * Convenience method to set (replace) a collection of DATE/DATE-TIME properties from a
+     * Collection of { link DateTime}s.
+     *
+     * @see get_multiple_date_times
+     */
+    protected void set_multiple_date_times(iCal.icalproperty_kind kind, Gee.Collection<DateTime> date_times) 
{
+        remove_all_properties(kind);
+        
+        foreach (DateTime date_time in date_times) {
+            iCal.icalproperty prop = new iCal.icalproperty(kind);
+            prop.set_value(date_time.to_ical_value());
+            
+            ical_component.add_property(prop);
+        }
+    }
+    
+    /**
      * 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
@@ -358,21 +562,34 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     }
     
     /**
-     * Equality is defined as { link Component.Instance}s having the same UID.
+     * Equality is defined as { link Component.Instance}s having the same { link uid}, { link rid},
+     * and { link sequence}.
      *
      * Subclasses should override this and { link hash} if more definite equality is necessary.
      */
     public virtual bool equal_to(Instance other) {
-        return (this != other) ? uid.equal_to(other.uid) : true;
+        if (this == other)
+            return true;
+        
+        if (is_generated_instance != other.is_generated_instance)
+            return false;
+        
+        if (is_generated_instance && !rid.equal_to(other.rid))
+            return false;
+        
+        if (sequence != other.sequence)
+            return false;
+        
+        return uid.equal_to(other.uid);
     }
     
     /**
-     * Hash is calculated using the { link Instance} { link UID}.
+     * Hash is calculated using the { link Instance} { link UID}, { link rid}, and { link sequence}.
      *
      * Subclasses should override if they override { link equal_to}.
      */
     public virtual uint hash() {
-        return uid.hash();
+        return uid.hash() ^ ((rid != null) ? rid.hash() : 0) ^ sequence;
     }
 }
 
diff --git a/src/component/component-recurrence-rule.vala b/src/component/component-recurrence-rule.vala
index 289a4b3..9bd45e3 100644
--- a/src/component/component-recurrence-rule.vala
+++ b/src/component/component-recurrence-rule.vala
@@ -24,7 +24,7 @@ public class RecurrenceRule : BaseObject {
      * Enumeration of various BY rules (BYSECOND, BYMINUTE, etc.)
      */
     public enum ByRule {
-        SECOND,
+        SECOND = 0,
         MINUTE,
         HOUR,
         DAY,
@@ -32,7 +32,11 @@ public class RecurrenceRule : BaseObject {
         YEAR_DAY,
         WEEK_NUM,
         MONTH,
-        SET_POS
+        SET_POS,
+        /**
+         * The number of { link ByRule}s, this is not a valid value.
+         */
+        COUNT;
     }
     
     /**
@@ -137,7 +141,7 @@ public class RecurrenceRule : BaseObject {
         this.freq = freq;
     }
     
-    internal RecurrenceRule.from_ical(iCal.icalcomponent ical_component) throws Error {
+    internal RecurrenceRule.from_ical(iCal.icalcomponent ical_component, bool strict) throws Error {
         // need DTSTART for timezone purposes
         DateTime dtstart = new DateTime(ical_component, iCal.icalproperty_kind.DTSTART_PROPERTY);
         
@@ -155,11 +159,16 @@ public class RecurrenceRule : BaseObject {
         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());
+            try {
+                Component.DateTime date_time = new DateTime.rrule_until(rrule, dtstart, strict);
+                if (date_time.is_date)
+                    set_recurrence_end_date(date_time.to_date());
+                else
+                    set_recurrence_end_exact_time(date_time.to_exact_time());
+            } catch (ComponentError comperr) {
+                if (!(comperr is ComponentError.UNAVAILABLE))
+                    throw comperr;
+            }
         }
         
         switch (rrule.week_start) {
@@ -253,6 +262,23 @@ public class RecurrenceRule : BaseObject {
     }
     
     /**
+     * Returns the UNTIL property as a { link Calendar.Date}.
+     *
+     * If { link until_exact_time} is set, only the Date portion is returned.
+     *
+     * @returns null if neither { link until_date} or until_exact_time is set.
+     */
+    public Calendar.Date? get_recurrence_end_date() {
+        if (until_date != null)
+            return until_date;
+        
+        if (until_exact_time != null)
+            return new Calendar.Date.from_exact_time(until_exact_time);
+        
+        return null;
+    }
+    
+    /**
      * Sets the { link count} property.
      *
      * Also clears { link until_date} and { link until_exact_time}.
@@ -316,6 +342,8 @@ public class RecurrenceRule : BaseObject {
                 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 false;
             }
         }
         
@@ -465,6 +493,21 @@ public class RecurrenceRule : BaseObject {
     }
     
     /**
+     * Returns a Gee.Set of { link ByRule}s that are active, i.e. have defined rules.
+     */
+    public Gee.Set<ByRule> get_active_by_rules() {
+        Gee.Set<ByRule> active = new Gee.HashSet<ByRule>();
+        for (int ctr = 0; ctr < ByRule.COUNT; ctr++) {
+            ByRule by_rule = (ByRule) ctr;
+            
+            if (get_by_set(by_rule).size > 0)
+                active.add(by_rule);
+        }
+        
+        return active;
+    }
+    
+    /**
      * Converts a { link RecurrenceRule} into an iCalendar RRULE property and adds it to the
      * iCal component.
      *
diff --git a/src/host/host-create-update-event.vala b/src/host/host-create-update-event.vala
index 77694f7..f8a755d 100644
--- a/src/host/host-create-update-event.vala
+++ b/src/host/host-create-update-event.vala
@@ -8,6 +8,9 @@ namespace California.Host {
 
 /**
  * A blank "form" of widgets for the user to enter or update event details.
+ *
+ * Message IN: If creating a new event, send Component.Event.blank() (pre-filled with any known
+ * details).  If updating an existing event, send Component.Event.clone().
  */
 
 [GtkTemplate (ui = "/org/yorba/california/rc/create-update-event.ui")]
@@ -20,6 +23,9 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     private const int END_HOUR = 23;
     private const int MIN_DIVISIONS = 15;
     
+    private const string FAMILY_NORMAL = "normal";
+    private const string FAMILY_RECURRING = "recurring";
+    
     public string card_id { get { return ID; } }
     
     public string? title { get { return null; } }
@@ -56,7 +62,7 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     private Gtk.ComboBoxText calendar_combo;
     
     [GtkChild]
-    private Gtk.Button accept_button;
+    private Gtk.Box rotating_button_box_container;
     
     public Calendar.DateSpan selected_date_span { get; set; }
     
@@ -69,6 +75,14 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     private Gtk.Button? last_date_button_touched = null;
     private bool both_date_buttons_touched = false;
     
+    private Toolkit.RotatingButtonBox rotating_button_box = new Toolkit.RotatingButtonBox();
+    
+    private Gtk.Button accept_button = new Gtk.Button();
+    private Gtk.Button cancel_button = new Gtk.Button.with_mnemonic(_("_Cancel"));
+    private Gtk.Button update_all_button = new Gtk.Button.with_mnemonic(_("Update A_ll Events"));
+    private Gtk.Button update_this_button = new Gtk.Button.with_mnemonic(_("Update _This Event"));
+    private Gtk.Button cancel_recurring_button = new Gtk.Button.with_mnemonic(_("_Cancel"));
+    
     public CreateUpdateEvent() {
         // when selected_date_span updates, update date buttons as well
         notify[PROP_SELECTED_DATE_SPAN].connect(() => {
@@ -101,16 +115,40 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
             calendar_model.add(calendar_source);
         }
         
+        accept_button.get_style_context().add_class("suggested-action");
+        
+        accept_button.clicked.connect(on_accept_button_clicked);
+        cancel_button.clicked.connect(on_cancel_button_clicked);
+        update_all_button.clicked.connect(on_update_all_button_clicked);
+        update_this_button.clicked.connect(on_update_this_button_clicked);
+        cancel_recurring_button.clicked.connect(on_cancel_recurring_button_clicked);
+        
+        rotating_button_box.pack_end(FAMILY_NORMAL, cancel_button);
+        rotating_button_box.pack_end(FAMILY_NORMAL, accept_button);
+        
+        rotating_button_box.pack_end(FAMILY_RECURRING, cancel_recurring_button);
+        rotating_button_box.pack_end(FAMILY_RECURRING, update_all_button);
+        rotating_button_box.pack_end(FAMILY_RECURRING, update_this_button);
+        
+        // The cancel-recurring-update button looks big compared to other buttons, so allow for the
+        // ButtonBox to reduce it in size
+        
rotating_button_box.get_family_container(FAMILY_RECURRING).child_set_property(cancel_recurring_button,
+            "non-homogeneous", true);
+        
+        rotating_button_box.expand = true;
+        rotating_button_box.halign = Gtk.Align.FILL;
+        rotating_button_box.valign = Gtk.Align.END;
+        rotating_button_box_container.add(rotating_button_box);
+        
         update_controls();
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
-        if (message != null) {
-            event = message as Component.Event;
-            assert(event != null);
-        } else {
-            event = new Component.Event.blank();
-        }
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
+        // if no message, leave everything as it is
+        if (message == null)
+            return;
+        
+        event = (Component.Event) message;
         
         update_controls();
     }
@@ -139,6 +177,13 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
             selected_date_span = new Calendar.DateSpan(Calendar.System.today, Calendar.System.today);
             initial_start_time = Calendar.System.now.to_wall_time();
             initial_end_time = Calendar.System.now.adjust_time(1, Calendar.TimeUnit.HOUR).to_wall_time();
+            
+            // set in Component.Event as well, to at least initialize it for use elsewhere while
+            // editing (such as the RRULE)
+            event.set_event_exact_time_span(new Calendar.ExactTimeSpan(
+                new Calendar.ExactTime(Calendar.Timezone.local, Calendar.System.today, initial_start_time),
+                new Calendar.ExactTime(Calendar.Timezone.local, Calendar.System.today, initial_end_time)
+            ));
         }
         
         // initialize start and end time controls (as in, wall clock time)
@@ -190,6 +235,10 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         description_textview.buffer.text = event.description ?? "";
         
         accept_button.label = is_update ? _("_Update") : _("C_reate");
+        accept_button.use_underline = true;
+        
+        rotating_button_box.family = FAMILY_NORMAL;
+        
         original_calendar_source = event.calendar_source;
     }
     
@@ -231,23 +280,64 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     }
     
     [GtkCallback]
-    private void on_accept_clicked() {
+    private void on_recurring_button_clicked() {
+        // update the component with what's in the controls now
+        update_component(event, true);
+        
+        // send off to recurring editor
+        jump_to_card_by_name(CreateUpdateRecurring.ID, event);
+    }
+    
+    private void on_accept_button_clicked() {
         if (calendar_model.active == null)
             return;
         
-        event.calendar_source = calendar_model.active;
-        event.summary = summary_entry.text;
-        event.location = location_entry.text;
-        event.description = description_textview.buffer.text;
+        // if updating a recurring event, need to ask about update scope
+        if (event.is_generated_instance && is_update) {
+            rotating_button_box.family = FAMILY_RECURRING;
+            
+            return;
+        }
+        
+        // create/update this instance of the event
+        create_update_event(event, true);
+    }
+    
+    // TODO: Now that a clone is being used for editing, can directly bind controls properties to
+    // Event's properties and update that way ... doesn't quite work when updating the master event,
+    // however
+    private void update_component(Component.Event target, bool replace_dtstart) {
+        target.calendar_source = calendar_model.active;
+        target.summary = summary_entry.text;
+        target.location = location_entry.text;
+        target.description = description_textview.buffer.text;
+        
+        // if updating the master, don't replace the dtstart/dtend, but do want to adjust it from
+        // DATE to DATE-TIME or vice-versa
+        if (!replace_dtstart) {
+            if (target.is_all_day != all_day_toggle.active) {
+                if (all_day_toggle.active) {
+                    target.timed_to_all_day_event();
+                } else {
+                    target.all_day_to_timed_event(
+                        time_map.get(dtstart_time_combo.get_active_text()),
+                        time_map.get(dtend_time_combo.get_active_text()),
+                        Calendar.Timezone.local
+                    );
+                }
+            }
+            
+            return;
+        }
         
         if (all_day_toggle.active) {
-            event.set_event_date_span(selected_date_span);
+            target.set_event_date_span(selected_date_span);
         } else {
             // use existing timezone unless not specified in original event
-            Calendar.Timezone tz = (event.exact_time_span != null)
-                ? event.exact_time_span.start_exact_time.tz
+            Calendar.Timezone tz = (target.exact_time_span != null)
+                ? target.exact_time_span.start_exact_time.tz
                 : Calendar.Timezone.local;
-            event.set_event_exact_time_span(
+            target.set_event_exact_time_span(
                 new Calendar.ExactTimeSpan(
                     new Calendar.ExactTime(tz, selected_date_span.start_date,
                         time_map.get(dtstart_time_combo.get_active_text())),
@@ -256,20 +346,35 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
                 )
             );
         }
+    }
+    
+    private void create_update_event(Component.Event target, bool replace_dtstart) {
+        update_component(target, replace_dtstart);
         
         if (is_update)
-            update_event_async.begin(null);
+            update_event_async.begin(target, null);
         else
-            create_event_async.begin(null);
+            create_event_async.begin(target, null);
     }
     
-    [GtkCallback]
     private void on_cancel_button_clicked() {
-        jump_home_or_user_closed();
+        notify_user_closed();
+    }
+    
+    private void on_update_all_button_clicked() {
+        create_update_event(event.is_master_instance ? event : (Component.Event) event.master, false);
+    }
+    
+    private void on_update_this_button_clicked() {
+        create_update_event(event, true);
+    }
+    
+    private void on_cancel_recurring_button_clicked() {
+        rotating_button_box.family = FAMILY_NORMAL;
     }
     
-    private async void create_event_async(Cancellable? cancellable) {
-        if (event.calendar_source == null) {
+    private async void create_event_async(Component.Event target, Cancellable? cancellable) {
+        if (target.calendar_source == null) {
             notify_failure(_("Unable to create event: calendar must be specified"));
             
             return;
@@ -279,7 +384,7 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         
         Error? create_err = null;
         try {
-            yield event.calendar_source.create_component_async(event, cancellable);
+            yield event.calendar_source.create_component_async(target, cancellable);
         } catch (Error err) {
             create_err = err;
         }
@@ -293,8 +398,8 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     }
     
     // TODO: Delete from original source if not the same as the new source
-    private async void update_event_async(Cancellable? cancellable) {
-        if (event.calendar_source == null) {
+    private async void update_event_async(Component.Event target, Cancellable? cancellable) {
+        if (target.calendar_source == null) {
             notify_failure(_("Unable to update event: calendar must be specified"));
             
             return;
@@ -304,7 +409,7 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         
         Error? update_err = null;
         try {
-            yield event.calendar_source.update_component_async(event, cancellable);
+            yield event.calendar_source.update_component_async(target, cancellable);
         } catch (Error err) {
             update_err = err;
         }
diff --git a/src/host/host-create-update-recurring.vala b/src/host/host-create-update-recurring.vala
new file mode 100644
index 0000000..ce6c4eb
--- /dev/null
+++ b/src/host/host-create-update-recurring.vala
@@ -0,0 +1,585 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Host {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/create-update-recurring.ui")]
+public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
+    public const string ID = "CreateUpdateRecurring";
+    
+    private const string PROP_START_DATE = "start-date";
+    private const string PROP_END_DATE = "end-date";
+    
+    // DO NOT CHANGE VALUES UNLESS YOU KNOW WHAT YOU'RE DOING.  These values are mirrored in the
+    // Glade file's repeats_combobox model.
+    private enum Repeats {
+        DAILY = 0,
+        WEEKLY = 1,
+        DAY_OF_THE_WEEK = 2,
+        DAY_OF_THE_MONTH = 3,
+        YEARLY = 4
+    }
+    
+    public string card_id { get { return ID; } }
+    
+    public string? title { get { return null; } }
+    
+    public Gtk.Widget? default_widget { get { return ok_button; } }
+    
+    public Gtk.Widget? initial_focus { get { return make_recurring_checkbutton; } }
+    
+    public Calendar.Date? start_date { get; private set; default = null; }
+    public Calendar.Date? end_date { get; private set; default = null; }
+    
+    [GtkChild]
+    private Gtk.CheckButton make_recurring_checkbutton;
+    
+    [GtkChild]
+    private Gtk.Grid child_grid;
+    
+    [GtkChild]
+    private Gtk.ComboBoxText repeats_combobox;
+    
+    [GtkChild]
+    private Gtk.Entry every_entry;
+    
+    [GtkChild]
+    private Gtk.Label every_label;
+    
+    [GtkChild]
+    private Gtk.Label on_days_label;
+    
+    [GtkChild]
+    private Gtk.Box on_days_box;
+    
+    [GtkChild]
+    private Gtk.CheckButton sunday_checkbutton;
+    
+    [GtkChild]
+    private Gtk.CheckButton monday_checkbutton;
+    
+    [GtkChild]
+    private Gtk.CheckButton tuesday_checkbutton;
+    
+    [GtkChild]
+    private Gtk.CheckButton wednesday_checkbutton;
+    
+    [GtkChild]
+    private Gtk.CheckButton thursday_checkbutton;
+    
+    [GtkChild]
+    private Gtk.CheckButton friday_checkbutton;
+    
+    [GtkChild]
+    private Gtk.CheckButton saturday_checkbutton;
+    
+    [GtkChild]
+    private Gtk.Button start_date_button;
+    
+    [GtkChild]
+    private Gtk.RadioButton never_radiobutton;
+    
+    [GtkChild]
+    private Gtk.RadioButton after_radiobutton;
+    
+    [GtkChild]
+    private Gtk.Entry after_entry;
+    
+    [GtkChild]
+    private Gtk.Label after_label;
+    
+    [GtkChild]
+    private Gtk.RadioButton ends_on_radiobutton;
+    
+    [GtkChild]
+    private Gtk.Label warning_label;
+    
+    [GtkChild]
+    private Gtk.Button end_date_button;
+    
+    [GtkChild]
+    private Gtk.Button ok_button;
+    
+    private new Component.Event? event = null;
+    private Component.Event? master = null;
+    private Gee.HashMap<Calendar.DayOfWeek, Gtk.CheckButton> on_day_checkbuttons = new Gee.HashMap<
+        Calendar.DayOfWeek, Gtk.CheckButton>();
+    private bool blocking_insert_text_numbers_only_signal = false;
+    
+    public CreateUpdateRecurring() {
+        // "Repeating event" checkbox activates almost every other control in this dialog
+        make_recurring_checkbutton.bind_property("active", child_grid, "sensitive",
+            BindingFlags.SYNC_CREATE);
+        
+        // On Days and its checkbox are only visible when Repeats is set to Weekly
+        repeats_combobox.bind_property("active", on_days_label, "visible",
+            BindingFlags.SYNC_CREATE, transform_repeats_active_to_on_days_visible);
+        repeats_combobox.bind_property("active", on_days_box, "visible",
+            BindingFlags.SYNC_CREATE, transform_repeats_active_to_on_days_visible);
+        
+        // Ends radio buttons need to make their assoc. controls sensitive when active
+        after_radiobutton.bind_property("active", after_entry, "sensitive",
+            BindingFlags.SYNC_CREATE);
+        ends_on_radiobutton.bind_property("active", end_date_button, "sensitive",
+            BindingFlags.SYNC_CREATE);
+        
+        // use private Date properties to synchronize with date button labels
+        bind_property(PROP_START_DATE, start_date_button, "label", BindingFlags.SYNC_CREATE,
+            transform_date_to_string);
+        bind_property(PROP_END_DATE, end_date_button, "label", BindingFlags.SYNC_CREATE,
+            transform_date_to_string);
+        
+        // map on-day checkboxes to days of week
+        on_day_checkbuttons[Calendar.DayOfWeek.SUN] = sunday_checkbutton;
+        on_day_checkbuttons[Calendar.DayOfWeek.MON] = monday_checkbutton;
+        on_day_checkbuttons[Calendar.DayOfWeek.TUE] = tuesday_checkbutton;
+        on_day_checkbuttons[Calendar.DayOfWeek.WED] = wednesday_checkbutton;
+        on_day_checkbuttons[Calendar.DayOfWeek.THU] = thursday_checkbutton;
+        on_day_checkbuttons[Calendar.DayOfWeek.FRI] = friday_checkbutton;
+        on_day_checkbuttons[Calendar.DayOfWeek.SAT] = saturday_checkbutton;
+        
+        // Ok button's sensitivity is tied to a whole-lotta controls here
+        make_recurring_checkbutton.bind_property("active", ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        every_entry.bind_property("text", ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        repeats_combobox.bind_property("active", ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        foreach (Gtk.CheckButton checkbutton in on_day_checkbuttons.values) {
+            checkbutton.bind_property("active", ok_button, "sensitive",
+                BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        }
+        bind_property(PROP_START_DATE, ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        ends_on_radiobutton.bind_property("active", ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        bind_property(PROP_END_DATE, ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        after_radiobutton.bind_property("active", ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+        after_entry.bind_property("text", ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
+    }
+    
+    private bool transform_repeats_active_to_on_days_visible(Binding binding, Value source_value,
+        ref Value target_value) {
+        target_value = (repeats_combobox.active == Repeats.WEEKLY);
+        
+        return true;
+    }
+    
+    private bool transform_date_to_string(Binding binding, Value source_value, ref Value target_value) {
+        Calendar.Date? date = (Calendar.Date?) source_value;
+        target_value = (date != null) ? date.to_standard_string() : "";
+        
+        return true;
+    }
+    
+    private bool transform_to_ok_button_sensitive(Binding binding, Value source_value, ref Value 
target_value) {
+        target_value = is_ok_ready();
+        
+        return true;
+    }
+    
+    // if controls are added or removed here, that needs to be reflected in the ctor by binding/
+    // unbinding to its properties
+    private bool is_ok_ready() {
+        // if not recurring, ok
+        if (!make_recurring_checkbutton.active)
+            return true;
+        
+        // every entry must be positive value
+        if (String.is_empty(every_entry.text) || int.parse(every_entry.text) <= 0)
+            return false;
+        
+        // if weekly, at least one checkbox must be active
+        if (repeats_combobox.active == Repeats.WEEKLY) {
+            if (!traverse<Gtk.CheckButton>(on_day_checkbuttons.values).any(checkbutton => 
checkbutton.active))
+                return false;
+        }
+        
+        // need a start date
+        if (start_date == null)
+            return false;
+        
+        // end date required if specified
+        if (ends_on_radiobutton.active && end_date == null)
+            return false;
+        
+        // count required if specified
+        if (after_radiobutton.active) {
+            if (String.is_empty(after_entry.text) || int.parse(after_entry.text) <= 0)
+                return false;
+        }
+        
+        return true;
+    }
+    
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
+        assert(message != null);
+        
+        event = (Component.Event) message;
+        master = event.is_master_instance ? event : (Component.Event) event.master;
+        
+        update_controls();
+    }
+    
+    private void update_controls() {
+        make_recurring_checkbutton.active = (master.rrule != null);
+        
+        // some defaults that may not be set even if an RRULE is present
+        
+        // "Ends ... After" entry
+        after_entry.text = "1";
+        
+        // "Starts" and "Ends...On" entries
+        Calendar.DateSpan event_span = master.get_event_date_span(Calendar.Timezone.local);
+        start_date = event_span.start_date;
+        end_date = event_span.end_date;
+        
+        // Clear all "On days" checkboxes for sanity's sake
+        foreach (Gtk.CheckButton checkbutton in on_day_checkbuttons.values)
+            checkbutton.active = false;
+        
+        // set remaining defaults if not a recurring event
+        if (master.rrule == null) {
+            repeats_combobox.active = Repeats.DAILY;
+            every_entry.text = "1";
+            never_radiobutton.active = true;
+            warning_label.visible = false;
+            
+            return;
+        }
+        
+        // "Repeats" combobox
+        switch (master.rrule.freq) {
+            case iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE:
+                repeats_combobox.active = Repeats.WEEKLY;
+            break;
+            
+            case iCal.icalrecurrencetype_frequency.MONTHLY_RECURRENCE:
+                bool by_day = master.rrule.get_by_rule(Component.RecurrenceRule.ByRule.DAY).size > 0;
+                bool by_monthday = master.rrule.get_by_rule(Component.RecurrenceRule.ByRule.MONTH_DAY).size 
0;
+                
+                // fall back on month day of the week
+                if (!by_day && by_monthday)
+                    repeats_combobox.active = Repeats.DAY_OF_THE_MONTH;
+                else
+                    repeats_combobox.active = Repeats.DAY_OF_THE_WEEK;
+            break;
+            
+            case iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE:
+                repeats_combobox.active = Repeats.YEARLY;
+            break;
+            
+            // Fall back on Daily for default, warning label is shown if anything not supported
+            case iCal.icalrecurrencetype_frequency.DAILY_RECURRENCE:
+            default:
+                repeats_combobox.active = Repeats.DAILY;
+            break;
+        }
+        
+        // "Every" entry
+        every_entry.text = master.rrule.interval.to_string();
+        
+        // "On days" week day checkboxes are only visible if a WEEKLY event
+        if (master.rrule.is_weekly) {
+            Gee.Map<Calendar.DayOfWeek?, int> by_days =
+                
Component.RecurrenceRule.decode_days(master.rrule.get_by_rule(Component.RecurrenceRule.ByRule.DAY));
+            
+            // the presence of a "null" day means every or all days
+            if (by_days.has_key(null)) {
+                foreach (Gtk.CheckButton checkbutton in on_day_checkbuttons.values)
+                    checkbutton.active = true;
+            } else {
+                foreach (Calendar.DayOfWeek dow in by_days.keys)
+                    on_day_checkbuttons[dow].active = true;
+            }
+        }
+        
+        // "Ends" choices
+        if (!master.rrule.has_duration) {
+            never_radiobutton.active = true;
+        } else if (master.rrule.count > 0) {
+            after_radiobutton.active = true;
+            after_entry.text = master.rrule.count.to_string();
+        } else {
+            assert(master.rrule.until_date != null || master.rrule.until_exact_time != null);
+            
+            ends_on_radiobutton.active = true;
+            end_date = master.rrule.get_recurrence_end_date();
+        }
+        
+        // look for RRULEs that our editor cannot deal with
+        string? supported = is_supported_rrule();
+        if (supported != null)
+            debug("Unsupported RRULE: %s", supported);
+        
+        warning_label.visible = supported != null;
+    }
+    
+    // Returns a logging string for why not reported, null if supported
+    private string? is_supported_rrule() {
+        // only some frequencies support, and in some of those, certain requirements
+        switch (master.rrule.freq) {
+            case iCal.icalrecurrencetype_frequency.DAILY_RECURRENCE:
+            case iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE:
+                // do nothing, continue
+            break;
+            
+            case iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE:
+                // can only hold BYDAY rules and all BYDAY rules must be zero
+                Gee.Set<Component.RecurrenceRule.ByRule> active = master.rrule.get_active_by_rules();
+                if (!active.contains(Component.RecurrenceRule.ByRule.DAY))
+                    return "weekly-not-byday";
+                
+                if (active.size > 1)
+                    return "weekly-multiple-byrules";
+                
+                foreach (int day in master.rrule.get_by_rule(Component.RecurrenceRule.ByRule.DAY)) {
+                    int position;
+                    if (!Component.RecurrenceRule.decode_day(day, null, out position))
+                        return "weekly-undecodeable-byday";
+                    
+                    if (position != 0)
+                        return "weekly-nonzero-byday-position";
+                }
+            break;
+            
+            // Must be a "simple" monthly recurrence
+            case iCal.icalrecurrencetype_frequency.MONTHLY_RECURRENCE:
+                bool by_day = master.rrule.get_by_rule(Component.RecurrenceRule.ByRule.DAY).size > 0;
+                bool by_monthday = master.rrule.get_by_rule(Component.RecurrenceRule.ByRule.MONTH_DAY).size 
0;
+                
+                // can support one and only one
+                if (by_day == by_monthday)
+                    return "monthly-byday-and-bymonthday";
+                
+                if (master.rrule.get_active_by_rules().size > 1)
+                    return "monthly-multiple-byrules";
+            break;
+            
+            default:
+                return "unsupported-frequency";
+        }
+        
+        // do not support editing w/ EXDATEs
+        if (!Collection.is_empty(master.exdates))
+            return "exdates";
+        
+        // do not support editing w/ RDATEs
+        if (!Collection.is_empty(master.rdates))
+            return "rdates";
+        
+        return null;
+    }
+    
+    [GtkCallback]
+    private void on_repeats_combobox_changed() {
+        on_repeats_combobox_or_every_entry_changed();
+    }
+    
+    [GtkCallback]
+    private void on_every_entry_changed() {
+        on_repeats_combobox_or_every_entry_changed();
+    }
+    
+    private void on_repeats_combobox_or_every_entry_changed() {
+        int every_count = !String.is_empty(every_entry.text) ? int.parse(every_entry.text) : 1;
+        every_count = every_count.clamp(1, int.MAX);
+        
+        unowned string text;
+        switch (repeats_combobox.active) {
+            case Repeats.DAY_OF_THE_MONTH:
+            case Repeats.DAY_OF_THE_WEEK:
+                text = ngettext("month", "months", every_count);
+            break;
+            
+            case Repeats.WEEKLY:
+                text = ngettext("week", "weeks", every_count);
+            break;
+            
+            case Repeats.YEARLY:
+                text = ngettext("year", "years", every_count);
+            break;
+            
+            case Repeats.DAILY:
+            default:
+                text = ngettext("day", "days", every_count);
+            break;
+        }
+        
+        every_label.label = text;
+    }
+    
+    [GtkCallback]
+    private void on_after_entry_changed() {
+        int after_count = !String.is_empty(after_entry.text) ? int.parse(after_entry.text) : 1;
+        after_count = after_count.clamp(1, int.MAX);
+        
+        after_label.label = ngettext("event", "events", after_count);
+    }
+    
+    [GtkCallback]
+    private void on_date_button_clicked(Gtk.Button date_button) {
+        bool is_dtstart = (date_button == start_date_button);
+        
+        Toolkit.CalendarPopup popup = new Toolkit.CalendarPopup(date_button,
+            is_dtstart ? start_date : end_date);
+        
+        popup.date_activated.connect((date) => {
+            if (is_dtstart)
+                start_date = date;
+            else
+                end_date = date;
+        });
+        
+        popup.dismissed.connect(() => {
+            popup.destroy();
+        });
+        
+        popup.show_all();
+    }
+    
+    [GtkCallback]
+    private void on_insert_text_numbers_only(Gtk.Editable editable, string new_text, int new_text_length,
+        ref int position) {
+        // prevent recursion when our modified text is inserted (i.e. allow the base handler to
+        // deal new text directly)
+        if (blocking_insert_text_numbers_only_signal)
+            return;
+        
+        // filter out everything not a number
+        string numbers_only = from_string(new_text)
+            .filter(ch => ch.isdigit())
+            .to_string(ch => ch.to_string());
+        
+        // insert new text into place, ensure this handler doesn't attempt to process this
+        // modified text ... would use SignalHandler.block_by_func() and unblock_by_func(), but
+        // the bindings are ungood
+        if (!String.is_empty(numbers_only)) {
+            blocking_insert_text_numbers_only_signal = true;
+            editable.insert_text(numbers_only, numbers_only.length, ref position);
+            blocking_insert_text_numbers_only_signal = false;
+        }
+        
+        // don't let the base handler have at the original text
+        Signal.stop_emission_by_name(editable, "insert-text");
+    }
+    
+    [GtkCallback]
+    private void on_cancel_button_clicked() {
+        jump_back();
+    }
+    
+    [GtkCallback]
+    private void on_ok_button_clicked() {
+        update_master();
+        jump_to_card_by_name(CreateUpdateEvent.ID, event);
+    }
+    
+    private void update_master() {
+        if (!make_recurring_checkbutton.active) {
+            master.make_recurring(null);
+            
+            return;
+        }
+        
+        iCal.icalrecurrencetype_frequency freq;
+        switch (repeats_combobox.active) {
+            case Repeats.DAILY:
+                freq = iCal.icalrecurrencetype_frequency.DAILY_RECURRENCE;
+            break;
+            
+            case Repeats.WEEKLY:
+                freq = iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE;
+            break;
+            
+            case Repeats.DAY_OF_THE_WEEK:
+            case Repeats.DAY_OF_THE_MONTH:
+                freq = iCal.icalrecurrencetype_frequency.MONTHLY_RECURRENCE;
+            break;
+            
+            case Repeats.YEARLY:
+                freq = iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE;
+            break;
+            
+            default:
+                assert_not_reached();
+        }
+        
+        Component.RecurrenceRule rrule = new Component.RecurrenceRule(freq);
+        rrule.interval = Numeric.floor_int(int.parse(every_entry.text), 1);
+        
+        if (rrule.is_weekly) {
+            Gee.HashMap<Calendar.DayOfWeek?, int> by_day = new Gee.HashMap<Calendar.DayOfWeek?, int>();
+            foreach (Calendar.DayOfWeek dow in on_day_checkbuttons.keys) {
+                if (on_day_checkbuttons[dow].active)
+                    by_day[dow] = 0;
+            }
+            
+            // although control sensitivity should prevent this from happening, be double-sure to
+            // prevent infinite loops below
+            if (by_day.size == 0)
+                by_day[start_date.day_of_week] = 0;
+            
+            rrule.set_by_rule(Component.RecurrenceRule.ByRule.DAY,
+                Component.RecurrenceRule.encode_days(by_day));
+            
+            // need to also update the start date to fall on one of the selected days of the week
+            // start by looking backward
+            Calendar.Date new_start_date = start_date.prior(true, (date) => {
+                return date.day_of_week in by_day.keys;
+            });
+            
+            // if start date is prior to today's day, move forward
+            if (new_start_date.compare_to(Calendar.System.today) < 0) {
+                new_start_date = start_date.upcoming(true, (date) => {
+                    return date.day_of_week in by_day.keys;
+                });
+            }
+            
+            start_date = new_start_date;
+        }
+        
+        // set start and end dates (which may actually be date-times, so use adjust)
+        if (never_radiobutton.active) {
+            // no duration
+            master.adjust_event_date_span(start_date.to_date_span());
+            rrule.set_recurrence_end_date(null);
+        } else if (ends_on_radiobutton.active) {
+            master.adjust_event_date_span(new Calendar.DateSpan(start_date, end_date));
+            rrule.set_recurrence_end_date(end_date);
+        } else {
+            assert(after_radiobutton.active);
+            
+            master.adjust_event_date_span(start_date.to_date_span());
+            rrule.set_recurrence_count(Numeric.floor_int(int.parse(after_entry.text), 1));
+        }
+        
+        if (rrule.is_monthly) {
+            if (repeats_combobox.active == Repeats.DAY_OF_THE_WEEK) {
+                Gee.HashMap<Calendar.DayOfWeek?, int> by_day = new Gee.HashMap<Calendar.DayOfWeek?, int>();
+                by_day[start_date.day_of_week] = 
start_date.week_of(Calendar.System.first_of_week).week_of_month;
+                rrule.set_by_rule(Component.RecurrenceRule.ByRule.DAY,
+                    Component.RecurrenceRule.encode_days(by_day));
+            } else {
+                Gee.Collection<int> by_month_day = new Gee.ArrayList<int>();
+                by_month_day.add(start_date.day_of_month.value);
+                rrule.set_by_rule(Component.RecurrenceRule.ByRule.MONTH_DAY, by_month_day);
+            }
+        }
+        
+        // remove EXDATEs and RDATEs, those are not currently supported
+        master.exdates = null;
+        master.rdates = null;
+        
+        master.make_recurring(rrule);
+    }
+}
+
+}
+
diff --git a/src/host/host-main-window.vala b/src/host/host-main-window.vala
index 71a2cac..a753a18 100644
--- a/src/host/host-main-window.vala
+++ b/src/host/host-main-window.vala
@@ -351,6 +351,10 @@ public class MainWindow : Gtk.ApplicationWindow {
             Toolkit.spin_event_loop();
         });
         
+        deck_window.deck.failure.connect((msg) => {
+            Application.instance.error_message(msg);
+        });
+        
         deck_window.show_all();
         deck_window.run();
         deck_window.destroy();
@@ -424,12 +428,21 @@ public class MainWindow : Gtk.ApplicationWindow {
     }
     
     private void quick_create_event(Component.Event? initial, Gtk.Widget relative_to, Gdk.Point? 
for_location) {
-        QuickCreateEvent quick_create = new QuickCreateEvent(initial);
+        QuickCreateEvent quick_create = new QuickCreateEvent();
+        
         CreateUpdateEvent create_update = new CreateUpdateEvent();
         create_update.is_update = false;
         
+        CreateUpdateRecurring create_update_recurring = new CreateUpdateRecurring();
+        
         Toolkit.Deck deck = new Toolkit.Deck();
-        deck.add_cards(iterate<Toolkit.Card>(quick_create, create_update).to_array_list());
+        deck.add_cards(
+            iterate<Toolkit.Card>(quick_create, create_update, create_update_recurring)
+            .to_array_list()
+        );
+        
+        // initialize the Deck with the initial event (if any)
+        deck.go_home(initial);
         
         show_deck(relative_to, for_location, deck);
     }
@@ -438,12 +451,18 @@ public class MainWindow : Gtk.ApplicationWindow {
         Gdk.Point? for_location) {
         ShowEvent show_event = new ShowEvent();
         
-        CreateUpdateEvent create_update_event = new CreateUpdateEvent();
-        create_update_event.is_update = true;
+        CreateUpdateEvent create_update = new CreateUpdateEvent();
+        create_update.is_update = true;
+        
+        CreateUpdateRecurring create_update_recurring = new CreateUpdateRecurring();
         
         Toolkit.Deck deck = new Toolkit.Deck();
-        deck.add_card(show_event);
-        deck.add_card(create_update_event);
+        deck.add_cards(
+            iterate<Toolkit.Card>(show_event, create_update, create_update_recurring)
+            .to_array_list()
+        );
+        
+        // "initialize" the Deck with the requested Event (because ShowEvent is first, it's home)
         deck.go_home(event);
         
         show_deck(relative_to, for_location, deck);
diff --git a/src/host/host-quick-create-event.vala b/src/host/host-quick-create-event.vala
index 267a0b0..4ce6d9b 100644
--- a/src/host/host-quick-create-event.vala
+++ b/src/host/host-quick-create-event.vala
@@ -40,15 +40,18 @@ public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
     
     private Toolkit.ComboBoxTextModel<Backing.CalendarSource> model;
     
-    public QuickCreateEvent(Component.Event? initial) {
-        event = initial;
+    public QuickCreateEvent() {
+    }
+    
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
+        event = (message != null) ? message as Component.Event : null;
         
         // if initial date/times supplied, reveal to the user and change the example
         string eg;
-        if (initial != null && (initial.date_span != null || initial.exact_time_span != null)) {
+        if (event != null && (event.date_span != null || event.exact_time_span != null)) {
             when_box.visible = true;
-            when_text_label.label = initial.get_event_time_pretty_string(Calendar.Timezone.local);
-            if (initial.date_span != null)
+            when_text_label.label = event.get_event_time_pretty_string(Calendar.Timezone.local);
+            if (event.date_span != null)
                 eg = _("Example: Dinner at Tadich Grill 7:30pm");
             else
                 eg = _("Example: Dinner at Tadich Grill");
@@ -76,9 +79,6 @@ public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
         calendar_combo_box.active = 0;
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
-    }
-    
     [GtkCallback]
     private void on_details_entry_icon_release(Gtk.Entry entry, Gtk.EntryIconPosition icon,
         Gdk.Event event) {
@@ -97,11 +97,7 @@ public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
         string details = details_entry.text.strip();
         
         if (String.is_empty(details)) {
-            // jump to Create/Update dialog and remove this Card from the Deck ... this ensures
-            // that if the user presses Cancel in the Create/Update dialog the Deck exits rather
-            // than returns here (via jump_home_or_user_closed())
-            jump_to_card_by_name(CreateUpdateEvent.ID, event);
-            deck.remove_cards(iterate<Toolkit.Card>(this).to_array_list());
+            create_empty_event();
             
             return;
         }
@@ -110,14 +106,22 @@ public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
             event);
         event = parser.event;
         
-        if (event.is_valid(true)) {
+        if (event.is_valid(true))
             create_event_async.begin(null);
-        } else {
-            // see note above about why the Deck jumps to Create/Update and then this Card is
-            // removed
-            jump_to_card_by_name(CreateUpdateEvent.ID, event);
-            deck.remove_cards(iterate<Toolkit.Card>(this).to_array_list());
-        }
+        else
+            create_empty_event();
+    }
+    
+    private void create_empty_event() {
+        // Must pass some kind of event to create/update, so use blank if required
+        if (event == null)
+            event = new Component.Event.blank();
+        
+        // jump to Create/Update dialog and remove this Card from the Deck ... this ensures
+        // that if the user presses Cancel in the Create/Update dialog the Deck exits rather
+        // than returns here (via jump_home_or_user_closed())
+        jump_to_card_by_name(CreateUpdateEvent.ID, event);
+        deck.remove_cards(iterate<Toolkit.Card>(this).to_array_list());
     }
     
     private async void create_event_async(Cancellable? cancellable) {
diff --git a/src/host/host-show-event.vala b/src/host/host-show-event.vala
index d16301f..21c5db0 100644
--- a/src/host/host-show-event.vala
+++ b/src/host/host-show-event.vala
@@ -6,6 +6,10 @@
 
 namespace California.Host {
 
+/**
+ * MESSAGE IN: Send the Component.Event to be displayed.
+ */
+
 [GtkTemplate (ui = "/org/yorba/california/rc/show-event.ui")]
 public class ShowEvent : Gtk.Grid, Toolkit.Card {
     public const string ID = "ShowEvent";
@@ -63,7 +67,8 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
         Calendar.System.instance.today_changed.disconnect(build_display);
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
+        // no message, don't update display
         if (message == null)
             return;
         
@@ -86,13 +91,10 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
         // description
         set_label(null, description_text, Markup.linkify(escape(event.description), linkify_delegate));
         
-        // don't current support updating recurring events properly; see
-        // https://bugzilla.gnome.org/show_bug.cgi?id=725786
         bool read_only = event.calendar_source != null && event.calendar_source.read_only;
         
-        bool updatable = !event.is_recurring && !read_only;
-        update_button.visible = updatable;
-        update_button.no_show_all = updatable;
+        update_button.visible = !read_only;
+        update_button.no_show_all = !read_only;
         
         remove_button.visible = !read_only;
         remove_button.no_show_all = !read_only;
@@ -142,7 +144,7 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
         //
         // TODO: Gtk.Stack would be a better widget for this animation, but it's unavailable in
         // Glade as of GTK+ 3.12.
-        if (event.is_recurring) {
+        if (event.is_generated_instance) {
             button_box_revealer.reveal_child = false;
             remove_recurring_revealer.reveal_child = true;
             
@@ -175,7 +177,12 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
     
     [GtkCallback]
     private void on_update_button_clicked() {
-        jump_to_card_by_name(CreateUpdateEvent.ID, event);
+        // pass a clone of the existing event for editing
+        try {
+            jump_to_card_by_name(CreateUpdateEvent.ID, event.clone() as Component.Event);
+        } catch (Error err) {
+            notify_failure(_("Unable to update event: %s").printf(err.message));
+        }
     }
     
     [GtkCallback]
diff --git a/src/manager/manager-calendar-list.vala b/src/manager/manager-calendar-list.vala
index 8287e7d..be48193 100644
--- a/src/manager/manager-calendar-list.vala
+++ b/src/manager/manager-calendar-list.vala
@@ -53,7 +53,7 @@ internal class CalendarList : Gtk.Grid, Toolkit.Card {
         Backing.Manager.instance.notify[Backing.Manager.PROP_IS_OPEN].disconnect(on_manager_opened_closed);
     }
     
-    public void jumped_to(Toolkit.Card? from, Value? message) {
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
     }
     
     private void on_manager_opened_closed() {
diff --git a/src/rc/create-update-event.ui b/src/rc/create-update-event.ui
index e12c832..92b2637 100644
--- a/src/rc/create-update-event.ui
+++ b/src/rc/create-update-event.ui
@@ -127,10 +127,26 @@
           <packing>
             <property name="expand">False</property>
             <property name="fill">True</property>
-            <property name="pack_type">end</property>
             <property name="position">6</property>
           </packing>
         </child>
+        <child>
+          <object class="GtkButton" id="recurring_button">
+            <property name="label" translatable="yes">Re_peats...</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="margin_left">8</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_recurring_button_clicked" 
object="CaliforniaHostCreateUpdateEvent" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="pack_type">end</property>
+            <property name="position">7</property>
+          </packing>
+        </child>
       </object>
       <packing>
         <property name="left_attach">0</property>
@@ -252,53 +268,14 @@
       </packing>
     </child>
     <child>
-      <object class="GtkButtonBox" id="button_box">
+      <object class="GtkBox" id="rotating_button_box_container">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
-        <property name="valign">end</property>
         <property name="margin_top">8</property>
-        <property name="vexpand">True</property>
-        <property name="spacing">6</property>
-        <property name="homogeneous">True</property>
-        <property name="layout_style">end</property>
-        <child>
-          <object class="GtkButton" id="cancel_button">
-            <property name="label" translatable="yes">_Cancel</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="can_default">True</property>
-            <property name="receives_default">True</property>
-            <property name="use_underline">True</property>
-            <property name="image_position">bottom</property>
-            <signal name="clicked" handler="on_cancel_button_clicked" 
object="CaliforniaHostCreateUpdateEvent" swapped="no"/>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
+        <property name="hexpand">True</property>
+        <property name="vexpand">False</property>
         <child>
-          <object class="GtkButton" id="accept_button">
-            <property name="label" translatable="yes">C_reate</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="can_default">True</property>
-            <property name="has_default">True</property>
-            <property name="receives_default">True</property>
-            <property name="use_underline">True</property>
-            <property name="image_position">bottom</property>
-            <signal name="clicked" handler="on_accept_clicked" object="CaliforniaHostCreateUpdateEvent" 
swapped="no"/>
-            <style>
-              <class name="suggested-action"/>
-            </style>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="pack_type">end</property>
-            <property name="position">1</property>
-          </packing>
+          <placeholder/>
         </child>
       </object>
       <packing>
diff --git a/src/rc/create-update-recurring.ui b/src/rc/create-update-recurring.ui
new file mode 100644
index 0000000..205cef3
--- /dev/null
+++ b/src/rc/create-update-recurring.ui
@@ -0,0 +1,551 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <template class="CaliforniaHostCreateUpdateRecurring" parent="GtkGrid">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="margin_left">8</property>
+    <property name="margin_right">8</property>
+    <property name="margin_top">8</property>
+    <property name="margin_bottom">8</property>
+    <property name="row_spacing">6</property>
+    <child>
+      <object class="GtkCheckButton" id="make_recurring_checkbutton">
+        <property name="label" translatable="yes">_Repeating event</property>
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="receives_default">False</property>
+        <property name="halign">start</property>
+        <property name="valign">start</property>
+        <property name="use_underline">True</property>
+        <property name="xalign">0</property>
+        <property name="draw_indicator">True</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkGrid" id="child_grid">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_left">16</property>
+        <property name="row_spacing">8</property>
+        <property name="column_spacing">8</property>
+        <child>
+          <object class="GtkLabel" id="label1">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">1</property>
+            <property name="label" translatable="yes">Re_peats</property>
+            <property name="use_underline">True</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="label2">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">1</property>
+            <property name="label" translatable="yes">_Every</property>
+            <property name="use_underline">True</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="label4">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">1</property>
+            <property name="label" translatable="yes">En_ds</property>
+            <property name="use_underline">True</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">4</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="label3">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">1</property>
+            <property name="label" translatable="yes">_Starts</property>
+            <property name="use_underline">True</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">3</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="on_days_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">1</property>
+            <property name="label" translatable="yes">_On days</property>
+            <property name="use_underline">True</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">2</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkComboBoxText" id="repeats_combobox">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="halign">start</property>
+            <items>
+              <item id="0" translatable="yes">Daily</item>
+              <item id="1" translatable="yes">Weekly</item>
+              <item id="2" translatable="yes">Day of the week</item>
+              <item id="3" translatable="yes">Day of the month</item>
+              <item id="4" translatable="yes">Yearly</item>
+            </items>
+            <signal name="changed" handler="on_repeats_combobox_changed" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="on_days_box">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="spacing">8</property>
+            <child>
+              <object class="GtkCheckButton" id="sunday_checkbutton">
+                <property name="label" translatable="yes">_Sun</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="monday_checkbutton">
+                <property name="label" translatable="yes">_Mon</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="tuesday_checkbutton">
+                <property name="label" translatable="yes">_Tues</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">2</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="wednesday_checkbutton">
+                <property name="label" translatable="yes">_Wed</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">3</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="thursday_checkbutton">
+                <property name="label" translatable="yes">T_hu</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">4</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="friday_checkbutton">
+                <property name="label" translatable="yes">_Fri</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">5</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="saturday_checkbutton">
+                <property name="label" translatable="yes">S_at</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">6</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">2</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="box1">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="spacing">16</property>
+            <child>
+              <object class="GtkRadioButton" id="never_radiobutton">
+                <property name="label" translatable="yes">_Never</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0</property>
+                <property name="active">True</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkBox" id="on_box">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="spacing">4</property>
+                <child>
+                  <object class="GtkRadioButton" id="ends_on_radiobutton">
+                    <property name="label" translatable="yes" comments="As in, an event &quot;ends on&quot; 
a date">_On</property>
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="receives_default">False</property>
+                    <property name="use_underline">True</property>
+                    <property name="xalign">0</property>
+                    <property name="draw_indicator">True</property>
+                    <property name="group">never_radiobutton</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkButton" id="end_date_button">
+                    <property name="label">(none)</property>
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="receives_default">True</property>
+                    <signal name="clicked" handler="on_date_button_clicked" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkBox" id="box2">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="spacing">4</property>
+                <child>
+                  <object class="GtkRadioButton" id="after_radiobutton">
+                    <property name="label" translatable="yes" comments="As in, &quot;After n 
events&quot;">Aft_er</property>
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="receives_default">False</property>
+                    <property name="use_underline">True</property>
+                    <property name="xalign">0</property>
+                    <property name="active">True</property>
+                    <property name="draw_indicator">True</property>
+                    <property name="group">never_radiobutton</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkEntry" id="after_entry">
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="max_length">4</property>
+                    <property name="activates_default">True</property>
+                    <property name="width_chars">5</property>
+                    <property name="input_purpose">number</property>
+                    <signal name="changed" handler="on_after_entry_changed" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+                    <signal name="insert-text" handler="on_insert_text_numbers_only" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkLabel" id="after_label">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label">(none)</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">2</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">2</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">4</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="start_date_button">
+            <property name="label">(none)</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="halign">start</property>
+            <signal name="clicked" handler="on_date_button_clicked" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">3</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="every_box">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="spacing">4</property>
+            <child>
+              <object class="GtkEntry" id="every_entry">
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="max_length">4</property>
+                <property name="activates_default">True</property>
+                <property name="width_chars">5</property>
+                <property name="input_purpose">number</property>
+                <signal name="changed" handler="on_every_entry_changed" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+                <signal name="insert-text" handler="on_insert_text_numbers_only" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkLabel" id="every_label">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label">(none)</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkButtonBox" id="buttonbox1">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="halign">end</property>
+        <property name="valign">end</property>
+        <property name="margin_top">8</property>
+        <property name="hexpand">True</property>
+        <property name="vexpand">True</property>
+        <property name="spacing">8</property>
+        <property name="homogeneous">True</property>
+        <property name="layout_style">start</property>
+        <child>
+          <object class="GtkButton" id="cancel_button">
+            <property name="label" translatable="yes">_Cancel</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_cancel_button_clicked" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="ok_button">
+            <property name="label" translatable="yes">_OK</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="can_default">True</property>
+            <property name="has_default">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_ok_button_clicked" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+            <style>
+              <class name="suggested-action"/>
+            </style>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">3</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="warning_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="no_show_all">True</property>
+        <property name="margin_left">16</property>
+        <property name="margin_top">8</property>
+        <property name="xalign">0</property>
+        <property name="label" translatable="yes">WARNING: California cannot edit this event's recurring 
criteria.
+• Press Cancel to keep the current criteria.
+• Press OK to overwrite the existing criteria with your changes.</property>
+        <attributes>
+          <attribute name="weight" value="bold"/>
+        </attributes>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+  </template>
+</interface>
diff --git a/src/tests/tests-iterable.vala b/src/tests/tests-iterable.vala
new file mode 100644
index 0000000..905c721
--- /dev/null
+++ b/src/tests/tests-iterable.vala
@@ -0,0 +1,45 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Tests {
+
+private class Iterable : UnitTest.Harness {
+    public Iterable() {
+        add_case("one-zero", one_zero);
+        add_case("one-one", one_one);
+        add_case("one_many", one_many);
+    }
+    
+    protected override void setup() throws Error {
+        Collection.init();
+    }
+    
+    protected override void teardown() {
+        Collection.terminate();
+    }
+    
+    private bool one_zero() throws Error {
+        return traverse<int?>(new Gee.ArrayList<int?>()).one() == null;
+    }
+    
+    private bool one_one() throws Error {
+        Gee.ArrayList<int?> list = new Gee.ArrayList<int?>();
+        list.add(1);
+        
+        return traverse<int?>(list).one() == 1;
+    }
+    
+    private bool one_many() throws Error {
+        Gee.ArrayList<int?> list = new Gee.ArrayList<int?>();
+        list.add(1);
+        list.add(2);
+        
+        return traverse<int?>(list).one() == null;
+    }
+}
+
+}
+
diff --git a/src/tests/tests.vala b/src/tests/tests.vala
index b8dd74c..24c171e 100644
--- a/src/tests/tests.vala
+++ b/src/tests/tests.vala
@@ -12,6 +12,7 @@ public int run(string[] args) {
         LogLevelFlags.LEVEL_WARNING | LogLevelFlags.LEVEL_ERROR | LogLevelFlags.LEVEL_CRITICAL);
     
     UnitTest.Harness.register(new String());
+    UnitTest.Harness.register(new Iterable());
     UnitTest.Harness.register(new CalendarDate());
     UnitTest.Harness.register(new CalendarMonthSpan());
     UnitTest.Harness.register(new CalendarMonthOfYear());
diff --git a/src/toolkit/toolkit-calendar-popup.vala b/src/toolkit/toolkit-calendar-popup.vala
index 0c0633c..9bfe8a3 100644
--- a/src/toolkit/toolkit-calendar-popup.vala
+++ b/src/toolkit/toolkit-calendar-popup.vala
@@ -25,13 +25,19 @@ public class CalendarPopup : Popup {
     /**
      * Fired when the user selects a day of a month and year.
      *
-     * In current implementation the { link Popup} will be { link dismissed} with any selection.
-     * Future work may allow the user to single-click on a day but require another action to
-     * dismiss the Popup.  Best for users to subscribe to { link dismissed} as well as this signal.
+     * @see date_activated
      */
     public signal void date_selected(Calendar.Date date);
     
     /**
+     * Fired when the user activates (double-clicks) a day of a month and year.
+     *
+     * Note that a double-click will result in { link date_selected} followed by this signal
+     * followed by { link dismissed}.
+     */
+    public signal void date_activated(Calendar.Date date);
+    
+    /**
      * inheritDoc
      */
     public CalendarPopup(Gtk.Widget relative_to, Calendar.Date initial_date) {
@@ -41,16 +47,17 @@ public class CalendarPopup : Popup {
         calendar.month = initial_date.month.value - 1;
         calendar.year = initial_date.year.value;
         
-        calendar.day_selected.connect(on_day_selected);
+        calendar.day_selected.connect(() => {
+            on_day_selected(false);
+        });
         calendar.day_selected_double_click.connect(() => {
-            on_day_selected();
-            dismiss();
+            on_day_selected(true);
         });
         
         add(calendar);
     }
     
-    private void on_day_selected() {
+    private void on_day_selected(bool activated) {
         Calendar.Date date;
         try {
             date = new Calendar.Date(
@@ -65,6 +72,11 @@ public class CalendarPopup : Popup {
         }
         
         date_selected(date);
+        
+        if (activated) {
+            date_activated(date);
+            dismiss();
+        }
     }
 }
 
diff --git a/src/toolkit/toolkit-card.vala b/src/toolkit/toolkit-card.vala
index ed7ef81..8e2daee 100644
--- a/src/toolkit/toolkit-card.vala
+++ b/src/toolkit/toolkit-card.vala
@@ -15,6 +15,30 @@ namespace California.Toolkit {
 
 public interface Card : Gtk.Widget {
     /**
+     * Enumerates the various reasons a { link Card} may be jumped to.
+     */
+    public enum Jump {
+        /**
+         * The { link Card} was jumped to because it's home and { link jump_home} was fired by
+         * another Card.
+         */
+        HOME,
+        /**
+         * The { link Card} was jumped to because another Card fired { link jump_back} and this is
+         * the previous Card in the { link Deck}.
+         */
+        BACK,
+        /**
+         * The { link Card} was jumped directly to by another Card, either by { link card_id} or
+         * by an object instance.
+         *
+         * @see jump_to_card
+         * @see jump_to_card_by_name
+         */
+        DIRECT
+    }
+    
+    /**
      * Each { link Card} has its own identifier that should be unique within the { link Deck}.
      *
      * In the Gtk.Stack, this is its name.
@@ -124,7 +148,9 @@ public interface Card : Gtk.Widget {
      * the Deck.
      *
      * message may be null even if the Card expects one; generally this means { link jump_back}
-     * or { link jump_home} was invoked, resulting in this Card being activated.
+     * or { link jump_home} was invoked, resulting in this Card being activated.  The supplied
+     * { link Jump} reason is useful for context.  There are code paths where { link Jump.HOME}
+     * accepts a message; { link Jump.BACK} will never supply a message.
      *
      * Due to some mechanism inside of GSignal or Vala, it's possible for a caller to pass null
      * that gets translated into a Value object holding a null pointer.  Deck will watch for this
@@ -137,7 +163,8 @@ public interface Card : Gtk.Widget {
      * This is called before dealing with { link default_widget} and { link initial_focus}, so
      * changes to those properties in this call, if need be.
      */
-    public abstract void jumped_to(Card? from, Value? message);
+    // TODO: Use a JumpContext object instead.
+    public abstract void jumped_to(Card? from, Jump reason, Value? message);
     
     /**
      * Dismiss the { link Deck} due to the user requesting it be closed or cancelled.
diff --git a/src/toolkit/toolkit-deck.vala b/src/toolkit/toolkit-deck.vala
index c738acf..6592690 100644
--- a/src/toolkit/toolkit-deck.vala
+++ b/src/toolkit/toolkit-deck.vala
@@ -80,7 +80,7 @@ public class Deck : Gtk.Stack {
     private void on_child_to_top() {
         // disconnect from previous top card and push onto nav stack
         if (top != null) {
-            top.jump_to_card.disconnect(on_jump_to_card);
+            top.jump_to_card.disconnect(on_jump_to_card_instance);
             top.jump_to_card_by_name.disconnect(on_jump_to_card_by_name);
             top.jump_back.disconnect(on_jump_back);
             top.jump_home.disconnect(on_jump_home);
@@ -95,7 +95,7 @@ public class Deck : Gtk.Stack {
         // make new visible child top Card and connect to its signals
         top = visible_child as Card;
         if (top != null) {
-            top.jump_to_card.connect(on_jump_to_card);
+            top.jump_to_card.connect(on_jump_to_card_instance);
             top.jump_to_card_by_name.connect(on_jump_to_card_by_name);
             top.jump_back.connect(on_jump_back);
             top.jump_home.connect(on_jump_home);
@@ -151,7 +151,7 @@ public class Deck : Gtk.Stack {
         
         if (set_home_visible && home != null) {
             set_visible_child(home);
-            home.jumped_to(null, null);
+            home.jumped_to(null, Card.Jump.HOME, null);
         }
     }
     
@@ -187,7 +187,7 @@ public class Deck : Gtk.Stack {
         if (displaying && top == null && home != null) {
             navigation_stack.clear();
             set_visible_child(home);
-            home.jumped_to(null, null);
+            home.jumped_to(null, Card.Jump.HOME, null);
         }
     }
     
@@ -223,10 +223,10 @@ public class Deck : Gtk.Stack {
         navigation_stack.clear();
         
         set_visible_child(home);
-        home.jumped_to(null, strip_null_value(message));
+        home.jumped_to(null, Card.Jump.HOME, strip_null_value(message));
     }
     
-    private void on_jump_to_card(Card card, Card next, Value? message) {
+    private void on_jump_to_card(Card card, Card next, Card.Jump reason, Value? message) {
         // do nothing if already visible
         if (get_visible_child() == next) {
             debug("Already showing card %s", next.card_id);
@@ -242,13 +242,17 @@ public class Deck : Gtk.Stack {
         }
         
         set_visible_child(next);
-        next.jumped_to(card, strip_null_value(message));
+        next.jumped_to(card, reason, strip_null_value(message));
+    }
+    
+    private void on_jump_to_card_instance(Card card, Card next, Value? message) {
+        on_jump_to_card(card, next, Card.Jump.DIRECT, message);
     }
     
     private void on_jump_to_card_by_name(Card card, string name, Value? message) {
         Card? next = names.get(name);
         if (next != null)
-            on_jump_to_card(card, next, message);
+            on_jump_to_card(card, next, Card.Jump.DIRECT, message);
         else
             GLib.message("Card %s not found in Deck", name);
     }
@@ -256,7 +260,7 @@ public class Deck : Gtk.Stack {
     private void on_jump_back(Card card) {
         // if still not empty, next card is "back", so pop that off and jump to it
         if (!navigation_stack.is_empty)
-            on_jump_to_card(card, navigation_stack.poll_head(), null);
+            on_jump_to_card(card, navigation_stack.poll_head(), Card.Jump.BACK, null);
     }
     
     private void on_jump_home(Card card) {
@@ -264,7 +268,7 @@ public class Deck : Gtk.Stack {
         navigation_stack.clear();
         
         if (home != null)
-            on_jump_to_card(card, home, null);
+            on_jump_to_card(card, home, Card.Jump.HOME, null);
         else
             message("No home card in Deck");
     }
diff --git a/src/toolkit/toolkit-rotating-button-box.vala b/src/toolkit/toolkit-rotating-button-box.vala
new file mode 100644
index 0000000..13778f7
--- /dev/null
+++ b/src/toolkit/toolkit-rotating-button-box.vala
@@ -0,0 +1,89 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Toolkit {
+
+/**
+ * RotatingButtonBox is a specialty widget for displaying groups ("families") of buttons, with each
+ * family silding (rotating) into view when required.
+ *
+ * Each family of Gtk.Buttons are held in Gtk.ButtonBoxes.  They are always laid out horizontally
+ * with an END layout style and fixed spacing.  This widget is designed specifically for buttons
+ * which populate the bottom edge of a dialog or popover.
+ *
+ * Families are created on-demand.  The direction of them sliding into view is determined by the
+ * order they are created, i.e. the first family created is to the "left" of subsequent families.
+ *
+ * Families are described by a string name.  Family names are case-sensitive.
+ */
+
+public class RotatingButtonBox : Gtk.Stack {
+    public const string PROP_FAMILY = "family";
+    
+    public Gtk.Orientation ORIENTATION = Gtk.Orientation.HORIZONTAL;
+    public Gtk.ButtonBoxStyle LAYOUT_STYLE = Gtk.ButtonBoxStyle.END;
+    public int SPACING = 8;
+    
+    /**
+     * The family name currently visible.
+     */
+    public string? family { get; set; }
+    
+    private Gee.HashMap<string, Gtk.ButtonBox> button_boxes = new Gee.HashMap<string, Gtk.ButtonBox>();
+    
+    public RotatingButtonBox() {
+        homogeneous = true;
+        transition_duration = SLOW_STACK_TRANSITION_DURATION_MSEC;
+        transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT;
+        
+        bind_property("visible-child-name", this, PROP_FAMILY,
+            BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+    }
+    
+    /**
+     * Pack a Gtk.Button at the start of a particular family, creating the family if necessary.
+     *
+     * See Gtk.Box.pack_start().
+     */
+    public void pack_start(string family, Gtk.Button button) {
+        get_family_container(family).pack_start(button);
+    }
+    
+    /**
+     * Pack a Gtk.Button at the end of a particular family, creating the family if necessary.
+     *
+     * See Gtk.Box.pack_end().
+     */
+    public void pack_end(string family, Gtk.Button button) {
+        get_family_container(family).pack_end(button);
+    }
+    
+    /**
+     * Direct access to the Gtk.ButtonBox holding the named family.
+     *
+     * If the family doesn't exist, it will be created.
+     */
+    public Gtk.ButtonBox get_family_container(string family) {
+        if (button_boxes.has_key(family))
+            return button_boxes.get(family);
+        
+        // create new family of buttons
+        Gtk.ButtonBox button_box = new Gtk.ButtonBox(ORIENTATION);
+        button_box.layout_style = LAYOUT_STYLE;
+        button_box.spacing = SPACING;
+        
+        // add to internal lookup
+        button_boxes.set(family, button_box);
+        
+        // add to Gtk.Stack using the family name
+        add_named(button_box, family);
+        
+        return button_box;
+    }
+}
+
+}
+
diff --git a/src/util/util-numeric.vala b/src/util/util-numeric.vala
new file mode 100644
index 0000000..518ab0d
--- /dev/null
+++ b/src/util/util-numeric.vala
@@ -0,0 +1,17 @@
+/* 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.Numeric {
+
+/**
+ * Returns the value if it is greater than or equal to floor, floor otherwise.
+ */
+public inline int floor_int(int value, int floor) {
+    return (value >= floor) ? value : floor;
+}
+
+}
+
diff --git a/vapi/libecal-1.2.vapi b/vapi/libecal-1.2.vapi
index 4d9a21a..d692d2e 100644
--- a/vapi/libecal-1.2.vapi
+++ b/vapi/libecal-1.2.vapi
@@ -53,7 +53,8 @@ namespace E {
                public unowned string get_local_attachment_store ();
                [CCode (finish_name = "e_cal_client_get_object_finish")]
                public async void get_object (string uid, string? rid, GLib.Cancellable? cancellable, out 
iCal.icalcomponent out_icalcomp) throws GLib.Error;
-               public async bool get_object_list (string sexp, GLib.Cancellable? cancellable) throws 
GLib.Error;
+               [CCode (finish_name = "e_cal_client_get_object_list_finish")]
+               public async bool get_object_list (string sexp, GLib.Cancellable? cancellable, out 
GLib.SList<weak iCal.icalcomponent> out_icalcomps) throws GLib.Error;
                public async bool get_object_list_as_comps (string sexp, GLib.Cancellable? cancellable) 
throws GLib.Error;
                public bool get_object_list_as_comps_sync (string sexp, GLib.SList out_ecalcomps, 
GLib.Cancellable? cancellable) throws GLib.Error;
                public bool get_object_list_sync (string sexp, GLib.SList out_icalcomps, GLib.Cancellable? 
cancellable) throws GLib.Error;
diff --git a/vapi/libecal-1.2/libecal-1.2.metadata b/vapi/libecal-1.2/libecal-1.2.metadata
index e93a428..2577912 100644
--- a/vapi/libecal-1.2/libecal-1.2.metadata
+++ b/vapi/libecal-1.2/libecal-1.2.metadata
@@ -89,9 +89,10 @@ e_cal_client_get_object.cancellable nullable="1"
 e_cal_client_get_object_finish type_name="void"
 e_cal_client_get_object_finish.out_icalcomp is_out="1" transfer_ownership="1"
 
-e_cal_client_get_object_list async="1"
+e_cal_client_get_object_list async="1" finish_name="e_cal_client_get_object_list_finish"
 e_cal_client_get_object_list.cancellable nullable="1"
-e_cal_client_get_object_list_finish.icalcomps is_out="1" value_owned="1" type_arguments="iCal.icalcomponent"
+
+e_cal_client_get_object_list_finish.out_icalcomps is_out="1" transfer_ownership="1" type_arguments="unowned 
iCal.icalcomponent"
 
 e_cal_client_get_object_list_as_comps async="1"
 e_cal_client_get_object_list_as_comps.cancellable nullable="1"


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