[california] Send invites to event attendees via xdg-email: Bug #740088



commit 82b189501a6aae8d03e770a3c2b8bd87493d817e
Author: Jim Nelson <jim yorba org>
Date:   Thu Nov 20 15:38:18 2014 -0800

    Send invites to event attendees via xdg-email: Bug #740088
    
    This allows for adding an Organizer to the event and marking Attendees
    for sending/not sending an invite.  If Attendees are marked for
    invites, when creating an event the user's email application is
    launched with a template message and the .ics attached.

 configure.ac                                       |    4 +
 debian/control                                     |    3 +-
 src/activator/activator-instance-list.vala         |    2 +-
 .../google/google-authenticating-pane.vala         |    2 +-
 src/activator/google/google-login-pane.vala        |    2 +-
 src/backing/backing-source.vala                    |    8 +
 src/backing/eds/backing-eds-calendar-source.vala   |   95 +++++++++
 src/calendar/calendar-exact-time-span.vala         |   60 +++++-
 src/component/component-icalendar.vala             |    2 +-
 src/component/component-instance.vala              |   31 +++
 src/component/component-person.vala                |   15 ++-
 src/component/component-recurrence-rule.vala       |    2 +
 src/host/host-attendees-editor.vala                |  147 ++++++++++++--
 src/host/host-create-update-event.vala             |  205 +++++++++++++++++++-
 src/host/host-create-update-recurring.vala         |    2 +-
 src/host/host-event-time-settings.vala             |    2 +-
 src/host/host-show-event.vala                      |   11 +-
 src/manager/manager-calendar-list.vala             |    4 +-
 src/rc/attendees-editor.ui                         |  176 ++++++++++++------
 src/rc/create-update-event.ui                      |   47 ++++-
 src/toolkit/toolkit-card.vala                      |   12 +-
 src/toolkit/toolkit-deck.vala                      |   28 +--
 src/toolkit/toolkit-listbox-model.vala             |   11 +-
 vapi/libecal-1.2.vapi                              |    6 +-
 24 files changed, 727 insertions(+), 150 deletions(-)
---
diff --git a/configure.ac b/configure.ac
index 4b1223d..2d9c27d 100644
--- a/configure.ac
+++ b/configure.ac
@@ -64,6 +64,10 @@ AC_LINK_IFELSE([AC_LANG_PROGRAM([[#include <langinfo.h>]],
 AC_MSG_RESULT($california_ok)
 AM_CONDITIONAL(HAVE__NL_TIME_FIRST_WEEKDAY, test "$california_ok" = "yes")
 
+# xdg-utils (specifically, xdg-email)
+AC_CHECK_PROG([XDG_EMAIL], [xdg-email], [yes], [no])
+AS_IF([test "x$XDG_EMAIL" != xyes], [AC_MSG_ERROR([xdg-email required. Please install xdg-utils package.])])
+
 #
 # configure switches
 #
diff --git a/debian/control b/debian/control
index e4fd93f..6d700dc 100644
--- a/debian/control
+++ b/debian/control
@@ -14,7 +14,8 @@ Build-Depends: debhelper (>= 8),
  libgoa-1.0-dev (>= 3.8.3),
  gnome-common,
  libgirepository1.0-dev,
- yelp-tools
+ yelp-tools,
+ xdg-utils
 Standards-Version: 3.8.3
 Homepage: https://wiki.gnome.org/Apps/California
 
diff --git a/src/activator/activator-instance-list.vala b/src/activator/activator-instance-list.vala
index f3bf575..16532bc 100644
--- a/src/activator/activator-instance-list.vala
+++ b/src/activator/activator-instance-list.vala
@@ -63,7 +63,7 @@ public class InstanceList : Gtk.Grid, Toolkit.Card {
     }
     
     private void start(Instance activator) {
-        jump_to_card_by_name(activator.first_card_id, null);
+        jump_to_card_by_id(activator.first_card_id, null);
     }
     
     private Gtk.Widget model_presentation(Instance activator) {
diff --git a/src/activator/google/google-authenticating-pane.vala 
b/src/activator/google/google-authenticating-pane.vala
index 12c107e..a329b3e 100644
--- a/src/activator/google/google-authenticating-pane.vala
+++ b/src/activator/google/google-authenticating-pane.vala
@@ -134,7 +134,7 @@ public class AuthenticatingPane : Gtk.Grid, Toolkit.Card {
         // delay gives the user a chance to see what's transpired
         yield sleep_msec_async(SUCCESS_DELAY_MSEC);
         
-        jump_to_card_by_name(CalendarListPane.ID, new CalendarListPane.Message(
+        jump_to_card_by_id(CalendarListPane.ID, new CalendarListPane.Message(
             credentials.username, own_calendars, all_calendars));
     }
     
diff --git a/src/activator/google/google-login-pane.vala b/src/activator/google/google-login-pane.vala
index 956bfab..e1f5907 100644
--- a/src/activator/google/google-login-pane.vala
+++ b/src/activator/google/google-login-pane.vala
@@ -56,7 +56,7 @@ internal class LoginPane : Gtk.Grid, Toolkit.Card {
     
     [GtkCallback]
     private void on_login_button_clicked() {
-        jump_to_card_by_name(AuthenticatingPane.ID, new AuthenticatingPane.Message(
+        jump_to_card_by_id(AuthenticatingPane.ID, new AuthenticatingPane.Message(
             account_entry.text, password_entry.text));
     }
 }
diff --git a/src/backing/backing-source.vala b/src/backing/backing-source.vala
index 26a889f..2529721 100644
--- a/src/backing/backing-source.vala
+++ b/src/backing/backing-source.vala
@@ -22,6 +22,7 @@ public abstract class Source : BaseObject, Gee.Comparable<Source> {
     public const string PROP_VISIBLE = "visible";
     public const string PROP_READONLY = "read-only";
     public const string PROP_COLOR = "color";
+    public const string PROP_MAILBOX = "mailbox";
     
     /**
      * A unique identifier for the { link Source}.
@@ -96,6 +97,13 @@ public abstract class Source : BaseObject, Gee.Comparable<Source> {
      */
     public string color { get; set; }
     
+    /**
+     * The mailbox (email address) associated with this { link Source}.
+     *
+     * This is the RFC822 mailbox address with no human-readable portion, i.e. "alice example com"
+     */
+    public string? mailbox { get; protected set; default = null; }
+    
     protected Source(Store store, string id, string title) {
         this.store = store;
         this.id = id;
diff --git a/src/backing/eds/backing-eds-calendar-source.vala 
b/src/backing/eds/backing-eds-calendar-source.vala
index d3219fb..9e30a2d 100644
--- a/src/backing/eds/backing-eds-calendar-source.vala
+++ b/src/backing/eds/backing-eds-calendar-source.vala
@@ -49,6 +49,8 @@ internal class EdsCalendarSource : CalendarSource {
         notify[PROP_TITLE].connect(on_title_changed);
         notify[PROP_VISIBLE].connect(on_visible_changed);
         notify[PROP_COLOR].connect(on_color_changed);
+        
+        // see note in open_async() about setting the "mailbox" property
     }
     
     ~EdsCalendarSource() {
@@ -162,6 +164,76 @@ internal class EdsCalendarSource : CalendarSource {
         }
     }
     
+    private string? get_webdav_email() {
+        E.SourceWebdav? webdav = eds_source.get_extension(E.SOURCE_EXTENSION_WEBDAV_BACKEND)
+            as E.SourceWebdav;
+        if (webdav == null)
+            return null;
+        
+        // watch for empty and malformed strings
+        if (String.is_empty(webdav.email_address) || !Email.is_valid_mailbox(webdav.email_address))
+            return null;
+        
+        debug("WebDAV email for %s: %s", to_string(), webdav.email_address);
+        
+        return webdav.email_address;
+    }
+    
+    // Can only be called after open_async() has been called
+    private string? get_backend_email(Cancellable? cancellable) {
+        try {
+            string mailbox_string;
+            client.get_backend_property_sync(E.CAL_BACKEND_PROPERTY_CAL_EMAIL_ADDRESS, out mailbox_string,
+                cancellable);
+            if (!String.is_empty(mailbox_string)) {
+                debug("Using backend email for %s: %s", to_string(), mailbox_string);
+                
+                return mailbox_string;
+            }
+        } catch (Error err) {
+            debug("Unable to fetch calendar email from backend for %s: %s", to_string(), err.message);
+        }
+        
+        return null;
+    }
+    
+    private string? get_authentication_email(string? calendar_domain, string? email_domain) {
+        E.SourceAuthentication? auth = eds_source.get_extension(E.SOURCE_EXTENSION_AUTHENTICATION)
+            as E.SourceAuthentication;
+        if (auth == null)
+            return null;
+        
+        // watch for empty string
+        if (String.is_empty(auth.user))
+            return null;
+        
+        // if email address, use that
+        if (Email.is_valid_mailbox(auth.user)) {
+            debug("Using authentication email for %s: %s", to_string(), auth.user);
+            
+            return auth.user;
+        }
+        
+        // if calendar is on a known service, try tacking on email_domain, but only if both spec'd
+        if (calendar_domain == null || email_domain == null)
+            return null;
+        
+        // ... but this only works if an at-sign isn't already present in the username
+        if (auth.user.contains("@"))
+            return null;
+        
+        if (auth.host != calendar_domain && !auth.host.has_suffix("." + calendar_domain))
+            return null;
+        
+        string manufactured = "%s%s".printf(auth.user, email_domain);
+        if (!Email.is_valid_mailbox(manufactured))
+            return null;
+        
+        debug("Manufactured email for %s: %s", to_string(), manufactured);
+        
+        return manufactured;
+    }
+    
     // Invoked by EdsStore prior to making it available outside of unit
     internal async void open_async(Cancellable? cancellable) throws Error {
         client = (E.CalClient) yield E.CalClient.connect(eds_source, E.CalClientSourceType.EVENTS,
@@ -171,6 +243,29 @@ internal class EdsCalendarSource : CalendarSource {
         client.notify["readonly"].connect(() => {
             debug("%s readonly: %s", to_string(), client.readonly.to_string());
         });
+        
+        
+        //
+        // Unfortunately, obtaining an email address associated with a calendar is not guaranteed
+        // in a lot of ways with EDS, so use an approach that looks for it in the most likely
+        // places .. one approach has to wait until open_async() is called.  First location with
+        // valid email wins.
+        //
+        // Ordering:
+        // * WebDAV extension's email address
+        // * Use backend extension's email address
+        // * Authentication username (if valid email address)
+        // * Same with Google, but appending "@gmail.com" if a plain username (i.e.
+        //   "alice" -> "alice gmail com")
+        // * TODO: Same with Yahoo! Calendar, when supported
+        //
+        mailbox = get_webdav_email();
+        if (mailbox == null)
+            mailbox = get_backend_email(cancellable);
+        if (mailbox == null)
+            mailbox = get_authentication_email(null, null);
+        if (mailbox == null)
+            mailbox = get_authentication_email("google.com", "@gmail.com");
     }
     
     // Invoked by EdsStore when closing and dropping all its refs
diff --git a/src/calendar/calendar-exact-time-span.vala b/src/calendar/calendar-exact-time-span.vala
index 20ba263..4db2ca1 100644
--- a/src/calendar/calendar-exact-time-span.vala
+++ b/src/calendar/calendar-exact-time-span.vala
@@ -25,7 +25,11 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
         /**
          * Use multiple lines to format string if lengthy.
          */
-        ALLOW_MULTILINE
+        ALLOW_MULTILINE,
+        /**
+         * Include timezone information in the string.
+         */
+        INCLUDE_TIMEZONE
     }
     
     /**
@@ -144,6 +148,7 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
      */
     public string to_pretty_string(Calendar.Date.PrettyFlag date_flags, PrettyFlag time_flags) {
         bool allow_multiline = (time_flags & PrettyFlag.ALLOW_MULTILINE) != 0;
+        bool include_timezone = (time_flags & PrettyFlag.INCLUDE_TIMEZONE) != 0;
         
         if (!start_date.year.equal_to(Calendar.System.today.year)
             || !end_date.year.equal_to(Calendar.System.today.year)) {
@@ -151,17 +156,32 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
         }
         
         if (is_same_day) {
-            // A span of time, i.e. "3:30pm to 4:30pm"
-            string timespan = _("%s to %s").printf(
-                start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
-                end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE));
+            string pretty_start_time = 
start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE);
+            string pretty_end_time = end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE);
+            
+            string timespan;
+            if (!include_timezone) {
+                // A span of time, i.e. "3:30pm to 4:30pm"
+                timespan = _("%s to %s").printf(pretty_start_time, pretty_end_time);
+            } else if (start_exact_time.tzid == end_exact_time.tzid) {
+                // A span of time followed by the timezone, i.e. "3:30pm to 4:30pm EST"
+                timespan = _("%s to %s %s").printf(pretty_start_time, pretty_end_time,
+                    start_exact_time.tzid);
+            } else {
+                // A span of time with each timezone's indicated, i.e.
+                // "12:30AM EDT to 2:30PM EST"
+                timespan = _("%s %s to %s %s").printf(pretty_start_time, start_exact_time.tzid,
+                    pretty_end_time, end_exact_time.tzid);
+            }
             
             // Single-day timed event, print "<full date>, <full start time> to <full end time>",
             // including year if not current year
-            return "%s, %s".printf(start_date.to_pretty_string(date_flags), timespan);
+            
+            // Date and time, i.e. "September 13, 4:30pm"
+            return _("%s, %s").printf(start_date.to_pretty_string(date_flags), timespan);
         }
         
-        if (allow_multiline) {
+        if (allow_multiline && !include_timezone) {
             // Multi-day timed event, print "<full time>, <full date>" on both lines,
             // including year if either not current year
             // Prints two full time and date strings on separate lines, i.e.:
@@ -172,6 +192,32 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
                 start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
                 end_exact_time.to_pretty_date_string(date_flags),
                 end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE));
+        } else if (allow_multiline && include_timezone) {
+            // Multi-day timed event, print "<full time>, <full date>" on both lines,
+            // including year if either not current year,
+            // *and* including timezone
+            // Prints two full time and date strings on separate lines, i.e.:
+            // 12 January 2012, 3:30pm PST
+            // 13 January 2013, 6:30am PST
+            return _("%s, %s %s\n%s, %s %s").printf(
+                start_exact_time.to_pretty_date_string(date_flags),
+                start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
+                start_exact_time.tzid,
+                end_exact_time.to_pretty_date_string(date_flags),
+                end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
+                end_exact_time.tzid);
+        }
+        
+        if (include_timezone) {
+            // Prints full time and date strings on a single line with timezone, i.e.:
+            // 12 January 2012, 3:30pm PST to 13 January 2013, 6:30am PST
+            return _("%s, %s %s to %s, %s %s").printf(
+                    start_exact_time.to_pretty_date_string(date_flags),
+                    start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
+                    start_exact_time.tzid,
+                    end_exact_time.to_pretty_date_string(date_flags),
+                    end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
+                    end_exact_time.tzid);
         }
         
         // Prints full time and date strings on a single line, i.e.:
diff --git a/src/component/component-icalendar.vala b/src/component/component-icalendar.vala
index c629f8b..b02af1e 100644
--- a/src/component/component-icalendar.vala
+++ b/src/component/component-icalendar.vala
@@ -85,7 +85,7 @@ public class iCalendar : BaseObject {
      * later modifications will allow for Instances to be added and removed dynamically.
      */
     public iCalendar(iCal.icalproperty_method method, string? prodid, string? version, string? calscale,
-        Gee.List<Instance>? instances) {
+        Gee.Collection<Instance>? instances) {
         this.prodid = prodid;
         this.version = version;
         this.calscale = calscale;
diff --git a/src/component/component-instance.vala b/src/component/component-instance.vala
index 1334ddc..b65602b 100644
--- a/src/component/component-instance.vala
+++ b/src/component/component-instance.vala
@@ -180,8 +180,12 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
      * merely modify the list, the property can be watched for changes with the "notify" and/or
      * "altered" signals.
      *
+     * Note that it's possible for an ORGANIZER to also be an ATTENDEE.
+     *
      * See [[https://tools.ietf.org/html/rfc5545#section-3.8.4.3]]  In particular, note that the
      * { link organizer} must be specified in group-scheduled calendar entity.
+     *
+     * @see attendees
      */
     public Gee.Set<Person> organizers { get; private set; default = new Gee.HashSet<Person>(); }
     
@@ -193,7 +197,11 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
      * merely modify the list, the property can be watched for changes with the "notify" and/or
      * "altered" signals.
      *
+     * Note that it's possible for an ATTENDEE to also be an ORGANIZER.
+     *
      * See [[https://tools.ietf.org/html/rfc5545#section-3.8.4.1]]
+     *
+     * @see organizers
      */
     public Gee.Set<Person> attendees { get; private set; default = new Gee.HashSet<Person>(); }
     
@@ -562,6 +570,29 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
     }
     
     /**
+     * Export this { link Instance} as an iCalendar.
+     *
+     * @see export_master
+     * @see is_generated_instance
+     */
+    public iCalendar export(iCal.icalproperty_method method) {
+        return new iCalendar(method, ICAL_PRODID, ICAL_VERSION, null,
+            iterate<Instance>(this).to_array_list());
+    }
+    
+    /**
+     * Export this { link Instance}'s { link master} as an iCalendar.
+     *
+     * If this Instance is the master, this is functionally the same as { link export}.
+     *
+     * @see is_master
+     */
+    public iCalendar export_master(iCal.icalproperty_method method) {
+        return new iCalendar(method, ICAL_PRODID, ICAL_VERSION, null,
+            iterate<Instance>(master ?? this).to_array_list());
+    }
+    
+    /**
      * Returns an appropriate { link Component} instance for the iCalendar component.
      *
      * VCALENDARs should use { link Component.iCalendar}.
diff --git a/src/component/component-person.vala b/src/component/component-person.vala
index 5c92f68..6e56279 100644
--- a/src/component/component-person.vala
+++ b/src/component/component-person.vala
@@ -7,11 +7,15 @@
 namespace California.Component {
 
 /**
- * An immutable representation of an iCalendar CAL-ADDRESS (ATTENDEE, ORGANIZER, etc.)
+ * A (mostly) immutable representation of an iCalendar CAL-ADDRESS (ATTENDEE, ORGANIZER, etc.)
  *
  * Person is not guaranteed to represent an individual per se, but it always represents an RFC822
  * mailbox (i.e. email address), which may be a group list address, multiuser mailbox, etc.
  *
+ * Person is mostly immutable in the sense that the { link send_invite} property is mutable, but
+ * this parameter is application-specific and not represented in the iCalendar component.  Notably,
+ * this property is not used for any comparison operations.
+ *
  * For equality purposes, only the { link mailto} is used.  All other parameters are ignored when
  * comparing Persons for equality.
  *
@@ -22,6 +26,8 @@ namespace California.Component {
  */
 
 public class Person : BaseObject, Gee.Hashable<Person>, Gee.Comparable<Person> {
+    public const string PROP_SEND_INVITE = "send-invite";
+    
     /**
      * The relationship of this { link Person} to the { link Instance}.
      */
@@ -82,6 +88,13 @@ public class Person : BaseObject, Gee.Hashable<Person>, Gee.Comparable<Person> {
      */
     public string full_mailbox { get; private set; }
     
+    /**
+     * A mutable property indicating an invitation should be sent to the { link Person}.
+     *
+     * In general, invites are not sent to organizers.
+     */
+    public bool send_invite { get; set; default = true; }
+    
     private Gee.HashSet<string> parameters = new Gee.HashSet<string>(String.ci_hash, String.ci_equal);
     
     /**
diff --git a/src/component/component-recurrence-rule.vala b/src/component/component-recurrence-rule.vala
index abfffed..cd94c8b 100644
--- a/src/component/component-recurrence-rule.vala
+++ b/src/component/component-recurrence-rule.vala
@@ -589,6 +589,8 @@ public class RecurrenceRule : BaseObject {
     /**
      * Returns a natural-language string explaining the { link RecurrenceRule} for the user.
      *
+     * The start_date should be the starting date of the associated { link Instance}.
+     *
      * Returns null if the RRULE is beyond the comprehension of this parser.
      */
     public string? explain(Calendar.Date start_date) {
diff --git a/src/host/host-attendees-editor.vala b/src/host/host-attendees-editor.vala
index 74d465b..0b8024a 100644
--- a/src/host/host-attendees-editor.vala
+++ b/src/host/host-attendees-editor.vala
@@ -8,7 +8,52 @@ namespace California.Host {
 
 [GtkTemplate (ui = "/org/yorba/california/rc/attendees-editor.ui")]
 public class AttendeesEditor : Gtk.Box, Toolkit.Card {
-    public const string ID = "CaliforniaHostAttendeesEditor";
+    private const string ID = "CaliforniaHostAttendeesEditor";
+    
+    private class Message : Object {
+        public Component.Event event;
+        public Backing.CalendarSource calendar_source;
+        
+        public Message(Component.Event event, Backing.CalendarSource calendar_source) {
+            this.event = event;
+            this.calendar_source = calendar_source;
+        }
+    }
+    
+    private class AttendeePresentation : Gtk.Box {
+        public Component.Person attendee { get; private set; }
+        
+        private Gtk.Button invite_button = new Gtk.Button();
+        
+        public AttendeePresentation(Component.Person attendee) {
+            Object (orientation: Gtk.Orientation.HORIZONTAL, spacing: 4);
+            
+            this.attendee = attendee;
+            
+            invite_button.relief = Gtk.ReliefStyle.NONE;
+            invite_button.clicked.connect(on_invite_clicked);
+            update_invite_button();
+            
+            Gtk.Label email_label = new Gtk.Label(attendee.full_mailbox);
+            email_label.xalign = 0.0f;
+            
+            add(invite_button);
+            add(email_label);
+        }
+        
+        private void on_invite_clicked() {
+            attendee.send_invite = !attendee.send_invite;
+            update_invite_button();
+        }
+        
+        private void update_invite_button() {
+            invite_button.image = new Gtk.Image.from_icon_name(
+                attendee.send_invite ? "mail-unread-symbolic" : "mail-read-symbolic",
+                Gtk.IconSize.BUTTON);
+            
+            invite_button.tooltip_text = attendee.send_invite ? _("Send invite") : _("Don't send invite");
+        }
+    }
     
     public string card_id { get { return ID; } }
     
@@ -16,7 +61,10 @@ public class AttendeesEditor : Gtk.Box, Toolkit.Card {
     
     public Gtk.Widget? default_widget { get { return accept_button; } }
     
-    public Gtk.Widget? initial_focus { get { return add_guest_entry; } }
+    public Gtk.Widget? initial_focus { get { return organizer_entry; } }
+    
+    [GtkChild]
+    private Gtk.Entry organizer_entry;
     
     [GtkChild]
     private Gtk.Entry add_guest_entry;
@@ -34,15 +82,35 @@ public class AttendeesEditor : Gtk.Box, Toolkit.Card {
     private Gtk.Button accept_button;
     
     private new Component.Event? event = null;
+    private Backing.CalendarSource? calendar_source = null;
     private Toolkit.ListBoxModel<Component.Person> guest_model;
+    private Toolkit.EntryClearTextConnector entry_clear_connector = new Toolkit.EntryClearTextConnector();
     
     public AttendeesEditor() {
         guest_model = new Toolkit.ListBoxModel<Component.Person>(guest_listbox, model_presentation);
         
+        organizer_entry.bind_property("text", accept_button, "sensitive", BindingFlags.SYNC_CREATE,
+            transform_to_accept_sensitive);
+        guest_model.bind_property(Toolkit.ListBoxModel.PROP_SIZE, accept_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_to_accept_sensitive);
+        
         add_guest_entry.bind_property("text", add_guest_button, "sensitive", BindingFlags.SYNC_CREATE,
             transform_add_guest_text_to_button);
+        
         guest_model.bind_property(Toolkit.ListBoxModel.PROP_SELECTED, remove_guest_button, "sensitive",
             BindingFlags.SYNC_CREATE, transform_list_selected_to_button);
+        
+        entry_clear_connector.connect_to(organizer_entry);
+        entry_clear_connector.connect_to(add_guest_entry);
+    }
+    
+    private bool transform_to_accept_sensitive(Binding binding, Value source_value, ref Value target_value) {
+        if (guest_model.size > 0 || !String.is_empty(organizer_entry.text))
+            target_value = Email.is_valid_mailbox(organizer_entry.text);
+        else
+            target_value = true;
+        
+        return true;
     }
     
     private bool transform_add_guest_text_to_button(Binding binding, Value source_value,
@@ -59,10 +127,16 @@ public class AttendeesEditor : Gtk.Box, Toolkit.Card {
         return true;
     }
     
-    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
-        event = message as Component.Event;
-        if (event == null)
-            return;
+    public static void pass_message(Toolkit.Card caller, Component.Event event,
+        Backing.CalendarSource calendar_source) {
+        caller.jump_to_card_by_id(ID, new Message(event, calendar_source));
+    }
+    
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message_value) {
+        Message message = (Message) message_value;
+        
+        event = message.event;
+        calendar_source = message.calendar_source;
         
         // clear list and add all attendees who are not organizers
         guest_model.clear();
@@ -70,6 +144,22 @@ public class AttendeesEditor : Gtk.Box, Toolkit.Card {
             .filter(attendee => !event.organizers.contains(attendee))
             .to_array_list()
         );
+        
+        // clear organizer entry and populate from supplied information
+        organizer_entry.text = "";
+        
+        // we only support one organizer, so use first one in form, otherwise use default from
+        // calendar source
+        if (!event.organizers.is_empty)
+            organizer_entry.text = traverse<Component.Person>(event.organizers).first().mailbox;
+        else if (!String.is_empty(calendar_source.mailbox))
+            organizer_entry.text = calendar_source.mailbox;
+        
+        // if organizer has been filled-in, give focus to guest entry
+        if (String.is_empty(organizer_entry.text))
+            organizer_entry.grab_focus();
+        else
+            add_guest_entry.grab_focus();
     }
     
     [GtkCallback]
@@ -88,23 +178,31 @@ public class AttendeesEditor : Gtk.Box, Toolkit.Card {
         return false;
     }
     
-    [GtkCallback]
-    private void on_add_guest_button_clicked() {
-        string mailbox = add_guest_entry.text.strip();
+    private Component.Person? make_person(string text, Component.Person.Relationship relationship) {
+        string mailbox = text.strip();
         if (!Email.is_valid_mailbox(mailbox))
-            return;
+            return null;
         
         try {
-            // add to model (which adds to listbox) and clear entry
-            guest_model.add(new Component.Person(Component.Person.Relationship.ATTENDEE,
-                Email.generate_mailto_uri(mailbox)));
-            add_guest_entry.text = "";
+            return new Component.Person(relationship, Email.generate_mailto_uri(mailbox));
         } catch (Error err) {
             debug("Unable to generate mailto from \"%s\": %s", mailbox, err.message);
+            
+            return null;
         }
     }
     
     [GtkCallback]
+    private void on_add_guest_button_clicked() {
+        // add to model (which adds to listbox) and clear entry
+        Component.Person? attendee = make_person(add_guest_entry.text, 
Component.Person.Relationship.ATTENDEE);
+        if (attendee != null)
+            guest_model.add(attendee);
+        
+        add_guest_entry.text = "";
+    }
+    
+    [GtkCallback]
     private void on_remove_guest_button_clicked() {
         if (guest_model.selected != null)
             guest_model.remove(guest_model.selected);
@@ -112,10 +210,24 @@ public class AttendeesEditor : Gtk.Box, Toolkit.Card {
     
     [GtkCallback]
     private void on_accept_button_clicked() {
+        // organizer required if one or more guests invited
+        Component.Person? organizer = null;
+        if (guest_model.size > 0) {
+            organizer = make_person(organizer_entry.text, Component.Person.Relationship.ORGANIZER);
+            if (organizer == null)
+                return;
+        }
+        
+        // remove organizer if no guests, set organizer if guests
+        event.clear_organizers();
+        if (organizer != null)
+            event.add_organizers(iterate<Component.Person>(organizer).to_array_list());
+        
+        // add all guests as attendees
         event.clear_attendees();
         event.add_attendees(guest_model.all());
         
-        jump_to_card_by_name(CreateUpdateEvent.ID, event);
+        jump_to_card_by_id(CreateUpdateEvent.ID, event);
     }
     
     [GtkCallback]
@@ -124,10 +236,7 @@ public class AttendeesEditor : Gtk.Box, Toolkit.Card {
     }
     
     private Gtk.Widget model_presentation(Component.Person person) {
-        Gtk.Label label = new Gtk.Label(person.full_mailbox);
-        label.xalign = 0.0f;
-        
-        return label;
+        return new AttendeePresentation(person);
     }
 }
 
diff --git a/src/host/host-create-update-event.vala b/src/host/host-create-update-event.vala
index dd38e62..b8e5624 100644
--- a/src/host/host-create-update-event.vala
+++ b/src/host/host-create-update-event.vala
@@ -42,6 +42,12 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     private Gtk.Entry location_entry;
     
     [GtkChild]
+    private Gtk.Label organizer_label;
+    
+    [GtkChild]
+    private Gtk.Label organizer_text;
+    
+    [GtkChild]
     private Gtk.Label attendees_text;
     
     [GtkChild]
@@ -93,6 +99,9 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         update_this_button.clicked.connect(on_update_this_button_clicked);
         cancel_recurring_button.clicked.connect(on_cancel_recurring_button_clicked);
         
+        organizer_text.query_tooltip.connect(on_organizer_text_query_tooltip);
+        organizer_text.has_tooltip = true;
+        
         attendees_text.query_tooltip.connect(on_attendees_text_query_tooltip);
         attendees_text.has_tooltip = true;
         
@@ -160,10 +169,20 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         
         location_entry.text = event.location ?? "";
         description_textview.buffer.text = event.description ?? "";
+        
+        // Only show "Organizer" and associated text if something to show
+        organizer_text.label = traverse<Component.Person>(event.organizers)
+            .sort()
+            .to_string(stringify_persons);
+        bool has_organizer = !String.is_empty(organizer_text.label);
+        organizer_label.visible = organizer_text.visible = has_organizer;
+        organizer_label.no_show_all = organizer_text.no_show_all = !has_organizer;
+        
+        // Don't count organizers as attendees
         attendees_text.label = traverse<Component.Person>(event.attendees)
             .filter(attendee => !event.organizers.contains(attendee))
             .sort()
-            .to_string(stringify_attendees);
+            .to_string(stringify_persons);
         if (String.is_empty(attendees_text.label)) {
             // "None" as in "no people"
             attendees_text.label = _("None");
@@ -188,6 +207,18 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         rotating_button_box.family = FAMILY_NORMAL;
     }
     
+    private bool on_organizer_text_query_tooltip(Gtk.Widget widget, int x, int y, bool keyboard,
+        Gtk.Tooltip tooltip) {
+        if (!organizer_text.get_layout().is_ellipsized())
+            return false;
+        
+        tooltip.set_text(traverse<Component.Person>(event.organizers)
+            .sort()
+            .to_string(stringify_persons_tooltip));
+        
+        return true;
+    }
+    
     private bool on_attendees_text_query_tooltip(Gtk.Widget widget, int x, int y, bool keyboard,
         Gtk.Tooltip tooltip) {
         if (!attendees_text.get_layout().is_ellipsized())
@@ -196,17 +227,17 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         tooltip.set_text(traverse<Component.Person>(event.attendees)
             .filter(attendee => !event.organizers.contains(attendee))
             .sort()
-            .to_string(stringify_attendees_tooltip));
+            .to_string(stringify_persons_tooltip));
         
         return true;
     }
     
-    private string? stringify_attendees(Component.Person person, bool is_first, bool is_last) {
+    private string? stringify_persons(Component.Person person, bool is_first, bool is_last) {
         // Email address followed by common separator, i.e. "alice example com, bob example com"
         return !is_last ? _("%s, ").printf(person.full_mailbox) : person.full_mailbox;
     }
     
-    private string? stringify_attendees_tooltip(Component.Person person, bool is_first, bool is_last) {
+    private string? stringify_persons_tooltip(Component.Person person, bool is_first, bool is_last) {
         return !is_last ? "%s\n".printf(person.full_mailbox) : person.full_mailbox;
     }
     
@@ -229,7 +260,7 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         update_component(event, true);
         
         // send off to recurring editor
-        jump_to_card_by_name(CreateUpdateRecurring.ID, event);
+        jump_to_card_by_id(CreateUpdateRecurring.ID, event);
     }
     
     [GtkCallback]
@@ -240,12 +271,13 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         // save changes with what's in the component now
         update_component(event, true);
         
-        jump_to_card_by_name(EventTimeSettings.ID, dt);
+        jump_to_card_by_id(EventTimeSettings.ID, dt);
     }
     
     [GtkCallback]
     private void on_attendees_button_clicked() {
-        jump_to_card_by_name(AttendeesEditor.ID, event);
+        if (calendar_model.active != null)
+            AttendeesEditor.pass_message(this, event, calendar_model.active);
     }
     
     private void on_accept_button_clicked() {
@@ -338,6 +370,8 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         
         Toolkit.set_unbusy(this, cursor);
         
+        invite_attendees(target, true);
+        
         if (create_err == null)
             notify_success();
         else
@@ -389,12 +423,169 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         
         Toolkit.set_unbusy(this, cursor);
         
+        // PUBLISH is used to update an existing event
+        invite_attendees(target, false);
+        
         if (update_err == null)
             notify_success();
         else
             report_error(_("Unable to update event: %s").printf(update_err.message));
     }
     
+    private void invite_attendees(Component.Event event, bool is_create) {
+        // Make list of invitees, which are attendees who are not organizers
+        Gee.List<Component.Person> invitees = traverse<Component.Person>(event.attendees)
+            .filter(attendee => !event.organizers.contains(attendee))
+            .filter(attendee => attendee.send_invite)
+            .sort()
+            .to_array_list();
+        
+        // no invitees, no invites
+        if (invitees.size == 0)
+            return;
+        
+        // TODO: Differentiate between instance updates and master updates
+        Component.iCalendar ics = event.export_master(iCal.icalproperty_method.REQUEST);
+        
+        // export .ics to temporary directory so the filename is a pristine "invite.ics"
+        string? temporary_filename = null;
+        try {
+            // "invite.ics" is the name of the file for an event invite delivered via email ...
+            // please translate but keep the .ics extension, as that's common to most calendar
+            // applications
+            temporary_filename = 
File.new_for_path(DirUtils.make_tmp("california-XXXXXX")).get_child(_("invite.ics")).get_path();
+            FileUtils.set_contents(temporary_filename, ics.source);
+            
+            // ensure this file is only readable by the user
+            FileUtils.chmod(temporary_filename, (int) (Posix.S_IRUSR | Posix.S_IWUSR));
+        } catch (Error err) {
+            Application.instance.error_message(deck.get_toplevel() as Gtk.Window,
+                _("Unable to export .ics to %s: %s").printf(
+                    temporary_filename ?? "(filename not generated)", err.message));
+            
+            return;
+        }
+        
+        //
+        // send using xdg-email, *not* Gtk.show_uri() w/ a mailto: URI, as handling attachments
+        // is best left to xdg-email
+        //
+        
+        string[] argv = new string[0];
+        argv += "xdg-email";
+        argv += "--utf8";
+        
+        foreach (Component.Person invitee in invitees)
+            argv += invitee.mailbox;
+        
+        argv += "--subject";
+        if (String.is_empty(event.summary)) {
+            argv += is_create ? _("Event invitation") : _("Updated event invitation");
+        } else if (String.is_empty(event.location)) {
+            argv += (is_create ? _("Invitation: %s") : _("Updated invitation: %s")).printf(event.summary);
+        } else {
+            // Invitation: <summary> at <location>
+            argv += (is_create ? _("Invitation: %s at %s") : _("Updated invitation: %s at %s")).printf(
+                event.summary, event.location);
+        }
+        
+        argv += "--body";
+        argv += generate_invite_body(event, is_create);
+        
+        argv += "--attach";
+        argv += temporary_filename;
+        
+        try {
+            Pid child_pid;
+            Process.spawn_async(null, argv, null, SpawnFlags.SEARCH_PATH, null, out child_pid);
+            Process.close_pid(child_pid);
+        } catch (SpawnError err) {
+            Application.instance.error_message(deck.get_toplevel() as Gtk.Window,
+                _("Unable to launch mail client: %s").printf(err.message));
+        }
+    }
+    
+    private static string generate_invite_body(Component.Event event, bool is_create) {
+        StringBuilder builder = new StringBuilder();
+        
+        // Salutations for an email
+        append_line(builder, _("Hello,"));
+        append_line(builder);
+        append_line(builder, is_create
+            ? _("Attached is an invitation to a new event:")
+            : _("Attached is an updated event invitation:")
+        );
+        append_line(builder);
+        
+        // Summary
+        if (!String.is_empty(event.summary))
+            append_line(builder, event.summary);
+        
+        // Date/Time span
+        string? pretty_time = event.get_event_time_pretty_string(
+            Calendar.Date.PrettyFlag.NO_TODAY | Calendar.Date.PrettyFlag.INCLUDE_OTHER_YEAR,
+            Calendar.ExactTimeSpan.PrettyFlag.INCLUDE_TIMEZONE,
+            Calendar.Timezone.local
+        );
+        if (!String.is_empty(pretty_time)) {
+            // Date/time of an event
+            append_line(builder, _("When: %s").printf(pretty_time));
+        }
+        
+        // Recurrences
+        if (event.rrule != null) {
+            string? rrule_explanation = 
event.rrule.explain(event.get_event_date_span(Calendar.Timezone.local).start_date);
+            if (!String.is_empty(rrule_explanation))
+                append_line(builder, rrule_explanation);
+        }
+        
+        // Location
+        if (!String.is_empty(event.location)) {
+            // Location of an event
+            append_line(builder, _("Where: %s").printf(event.location));
+        }
+        
+        // Organizer (only list one)
+        Component.Person? organizer = null;
+        if (!event.organizers.is_empty) {
+            organizer = traverse<Component.Person>(event.organizers)
+                .sort()
+                .first();
+            // Who organized (scheduled or planned) the event
+            append_line(builder, _("Organizer: %s").printf(organizer.full_mailbox));
+        }
+        
+        // Attendees (strip Organizer from list)
+        Gee.List<Component.Person> attendees = traverse<Component.Person>(event.attendees)
+            .filter(person => organizer == null || !person.equal_to(organizer))
+            .sort()
+            .to_array_list();
+        if (attendees.size > 0) {
+            // People attending event
+            append_line(builder, ngettext("Guest: %s", "Guests: %s", attendees.size).printf(
+                traverse<Component.Person>(attendees).to_string(stringify_people)));
+        }
+        
+        // Description
+        if (!String.is_empty(event.description)) {
+            append_line(builder);
+            append_line(builder, event.description);
+        }
+        
+        return builder.str;
+    }
+    
+    private static void append_line(StringBuilder builder, string? str = null) {
+        if (!String.is_empty(str))
+            builder.append(str);
+        
+        builder.append("\n");
+    }
+    
+    private static string? stringify_people(Component.Person person, bool is_first, bool is_last) {
+        // Email separator, i.e. "alice example com, bob example com"
+        return !is_last ? _("%s, ").printf(person.full_mailbox) : person.full_mailbox;
+    }
 }
 
 }
diff --git a/src/host/host-create-update-recurring.vala b/src/host/host-create-update-recurring.vala
index f52f899..7fec886 100644
--- a/src/host/host-create-update-recurring.vala
+++ b/src/host/host-create-update-recurring.vala
@@ -492,7 +492,7 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
     [GtkCallback]
     private void on_ok_button_clicked() {
         update_master();
-        jump_to_card_by_name(CreateUpdateEvent.ID, event);
+        jump_to_card_by_id(CreateUpdateEvent.ID, event);
     }
     
     private bool can_make_rrule() {
diff --git a/src/host/host-event-time-settings.vala b/src/host/host-event-time-settings.vala
index ee30a08..917623a 100644
--- a/src/host/host-event-time-settings.vala
+++ b/src/host/host-event-time-settings.vala
@@ -159,7 +159,7 @@ public class EventTimeSettings : Gtk.Box, Toolkit.Card {
         else
             message.reset_exact_time_span(get_exact_time_span());
         
-        jump_to_card_by_name(CreateUpdateEvent.ID, message);
+        jump_to_card_by_id(CreateUpdateEvent.ID, message);
     }
     
     private void freeze_widget_notifications() {
diff --git a/src/host/host-show-event.vala b/src/host/host-show-event.vala
index f5cd1d0..c826247 100644
--- a/src/host/host-show-event.vala
+++ b/src/host/host-show-event.vala
@@ -322,14 +322,9 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
             return;
         
         // if switch available and active, export master not the generated instance
-        Component.Instance to_export = (export_master_checkbutton != null && 
export_master_checkbutton.active)
-            ? event.master
-            : event;
-        
-        // Export as a self-contained iCalendar
-        Component.iCalendar icalendar = new Component.iCalendar(iCal.icalproperty_method.PUBLISH,
-            Component.ICAL_PRODID, Component.ICAL_VERSION, null,
-            iterate<Component.Instance>(to_export).to_array_list());
+        Component.iCalendar icalendar = (export_master_checkbutton != null && 
export_master_checkbutton.active)
+            ? event.export_master(iCal.icalproperty_method.PUBLISH)
+            : event.export(iCal.icalproperty_method.PUBLISH);
         
         try {
             FileUtils.set_contents(filename, icalendar.source);
diff --git a/src/manager/manager-calendar-list.vala b/src/manager/manager-calendar-list.vala
index 78cbf4a..0c964d1 100644
--- a/src/manager/manager-calendar-list.vala
+++ b/src/manager/manager-calendar-list.vala
@@ -128,7 +128,7 @@ internal class CalendarList : Gtk.Grid, Toolkit.Card {
     
     [GtkCallback]
     private void on_add_button_clicked() {
-        jump_to_card_by_name(Activator.InstanceList.ID, null);
+        jump_to_card_by_id(Activator.InstanceList.ID, null);
     }
     
     [GtkCallback]
@@ -143,7 +143,7 @@ internal class CalendarList : Gtk.Grid, Toolkit.Card {
     [GtkCallback]
     private void on_remove_button_clicked() {
         if (model.selected != null)
-            jump_to_card_by_name(RemoveCalendar.ID, model.selected);
+            jump_to_card_by_id(RemoveCalendar.ID, model.selected);
     }
     
     [GtkCallback]
diff --git a/src/rc/attendees-editor.ui b/src/rc/attendees-editor.ui
index cfb058c..81277c9 100644
--- a/src/rc/attendees-editor.ui
+++ b/src/rc/attendees-editor.ui
@@ -24,94 +24,158 @@
       </packing>
     </child>
     <child>
-      <object class="GtkBox" id="box2">
+      <object class="GtkGrid" id="grid1">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
-        <property name="spacing">4</property>
+        <property name="row_spacing">4</property>
+        <property name="column_spacing">6</property>
         <child>
-          <object class="GtkEntry" id="add_guest_entry">
+          <object class="GtkLabel" id="organizer_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="margin_bottom">4</property>
+            <property name="xalign">1</property>
+            <property name="label" translatable="yes">Organizer</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEntry" id="organizer_entry">
             <property name="visible">True</property>
             <property name="can_focus">True</property>
-            <property name="tooltip_text" translatable="yes">For example, bob example com</property>
+            <property name="tooltip_text" translatable="yes">For example, alice example com</property>
+            <property name="margin_bottom">4</property>
+            <property name="hexpand">True</property>
             <property name="activates_default">True</property>
-            <property name="placeholder_text" translatable="yes">Email address</property>
+            <property name="placeholder_text" translatable="yes">Email address (required if guests are 
invited)</property>
             <property name="input_purpose">email</property>
-            <signal name="focus-in-event" handler="on_add_guest_entry_focus_in_event" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
-            <signal name="focus-out-event" handler="on_add_guest_entry_focus_out_event" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
           </object>
           <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
+            <property name="left_attach">1</property>
+            <property name="top_attach">0</property>
           </packing>
         </child>
         <child>
-          <object class="GtkButton" id="add_guest_button">
-            <property name="label" translatable="yes">A_dd Guest</property>
+          <object class="GtkBox" id="box2">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="spacing">4</property>
+            <child>
+              <object class="GtkEntry" id="add_guest_entry">
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="tooltip_text" translatable="yes">For example, bob example com</property>
+                <property name="activates_default">True</property>
+                <property name="placeholder_text" translatable="yes">Email address</property>
+                <property name="input_purpose">email</property>
+                <signal name="focus-in-event" handler="on_add_guest_entry_focus_in_event" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
+                <signal name="focus-out-event" handler="on_add_guest_entry_focus_out_event" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkButton" id="add_guest_button">
+                <property name="label" translatable="yes">A_dd Guest</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="xalign">0.60000002384185791</property>
+                <signal name="clicked" handler="on_add_guest_button_clicked" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="pack_type">end</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="remove_guest_button">
+            <property name="label" translatable="yes">_Remove Guest</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="halign">end</property>
             <property name="use_underline">True</property>
-            <property name="xalign">0.60000002384185791</property>
-            <signal name="clicked" handler="on_add_guest_button_clicked" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
+            <signal name="clicked" handler="on_remove_guest_button_clicked" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
           </object>
           <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="pack_type">end</property>
-            <property name="position">1</property>
+            <property name="left_attach">1</property>
+            <property name="top_attach">3</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="GtkScrolledWindow" id="scrolledwindow1">
-        <property name="visible">True</property>
-        <property name="can_focus">True</property>
-        <property name="shadow_type">in</property>
         <child>
-          <object class="GtkViewport" id="viewport1">
+          <object class="GtkScrolledWindow" id="scrolledwindow1">
             <property name="visible">True</property>
-            <property name="can_focus">False</property>
+            <property name="can_focus">True</property>
+            <property name="shadow_type">in</property>
             <child>
-              <object class="GtkListBox" id="guest_listbox">
+              <object class="GtkViewport" id="viewport1">
                 <property name="visible">True</property>
                 <property name="can_focus">False</property>
-                <property name="hexpand">True</property>
-                <property name="vexpand">True</property>
-                <property name="activate_on_single_click">False</property>
+                <child>
+                  <object class="GtkListBox" id="guest_listbox">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="hexpand">True</property>
+                    <property name="vexpand">True</property>
+                    <property name="activate_on_single_click">False</property>
+                  </object>
+                </child>
               </object>
             </child>
           </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">2</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="guest_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">1</property>
+            <property name="yalign">0</property>
+            <property name="label" translatable="yes">Guests</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">2</property>
+          </packing>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
         </child>
-      </object>
-      <packing>
-        <property name="expand">True</property>
-        <property name="fill">True</property>
-        <property name="position">2</property>
-      </packing>
-    </child>
-    <child>
-      <object class="GtkButton" id="remove_guest_button">
-        <property name="label" translatable="yes">_Remove Guest</property>
-        <property name="visible">True</property>
-        <property name="can_focus">True</property>
-        <property name="receives_default">True</property>
-        <property name="halign">end</property>
-        <property name="use_underline">True</property>
-        <signal name="clicked" handler="on_remove_guest_button_clicked" 
object="CaliforniaHostAttendeesEditor" swapped="no"/>
       </object>
       <packing>
         <property name="expand">False</property>
         <property name="fill">True</property>
-        <property name="position">3</property>
+        <property name="position">2</property>
       </packing>
     </child>
     <child>
@@ -161,7 +225,7 @@
       <packing>
         <property name="expand">False</property>
         <property name="fill">True</property>
-        <property name="position">4</property>
+        <property name="position">5</property>
       </packing>
     </child>
   </template>
diff --git a/src/rc/create-update-event.ui b/src/rc/create-update-event.ui
index 43fb9d0..de2bd36 100644
--- a/src/rc/create-update-event.ui
+++ b/src/rc/create-update-event.ui
@@ -175,7 +175,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">7</property>
+        <property name="top_attach">8</property>
         <property name="width">2</property>
       </packing>
     </child>
@@ -191,7 +191,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">6</property>
+        <property name="top_attach">7</property>
       </packing>
     </child>
     <child>
@@ -202,7 +202,7 @@
       </object>
       <packing>
         <property name="left_attach">1</property>
-        <property name="top_attach">6</property>
+        <property name="top_attach">7</property>
       </packing>
     </child>
     <child>
@@ -221,7 +221,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">5</property>
+        <property name="top_attach">6</property>
       </packing>
     </child>
     <child>
@@ -248,7 +248,7 @@
       </object>
       <packing>
         <property name="left_attach">1</property>
-        <property name="top_attach">5</property>
+        <property name="top_attach">6</property>
       </packing>
     </child>
     <child>
@@ -265,7 +265,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">4</property>
+        <property name="top_attach">5</property>
       </packing>
     </child>
     <child>
@@ -276,7 +276,7 @@
       </object>
       <packing>
         <property name="left_attach">1</property>
-        <property name="top_attach">4</property>
+        <property name="top_attach">5</property>
       </packing>
     </child>
     <child>
@@ -285,7 +285,7 @@
         <property name="can_focus">False</property>
         <property name="no_show_all">True</property>
         <property name="xalign">1</property>
-        <property name="label" translatable="yes">Invited Guests</property>
+        <property name="label" translatable="yes">Guests</property>
         <property name="use_underline">True</property>
         <style>
           <class name="dim-label"/>
@@ -293,7 +293,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">3</property>
+        <property name="top_attach">4</property>
       </packing>
     </child>
     <child>
@@ -328,7 +328,7 @@
               <object class="GtkImage" id="image3">
                 <property name="visible">True</property>
                 <property name="can_focus">False</property>
-                <property name="icon_name">mail-unread-symbolic</property>
+                <property name="icon_name">system-users-symbolic</property>
               </object>
             </child>
           </object>
@@ -341,6 +341,33 @@
       </object>
       <packing>
         <property name="left_attach">1</property>
+        <property name="top_attach">4</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="organizer_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="label" translatable="yes">Organizer</property>
+        <style>
+          <class name="dim-label"/>
+        </style>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">3</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="organizer_text">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">0</property>
+        <property name="label">(none)</property>
+      </object>
+      <packing>
+        <property name="left_attach">1</property>
         <property name="top_attach">3</property>
       </packing>
     </child>
diff --git a/src/toolkit/toolkit-card.vala b/src/toolkit/toolkit-card.vala
index fbdd817..2150176 100644
--- a/src/toolkit/toolkit-card.vala
+++ b/src/toolkit/toolkit-card.vala
@@ -97,21 +97,11 @@ public interface Card : Gtk.Widget {
     public Deck? deck { get { return parent as Deck; } }
     
     /**
-     * Fired when the { link Card} wishes to jump to another Card in the same { link Deck.}
-     *
-     * Each Card can accept a message which parameterizes its activation.  It's up to Cards
-     * navigating to the new one to construct and pass an appropriate message.
-     *
-     * @see jump_to_card_by_name
-     */
-    public signal void jump_to_card(Card next, Value? message);
-    
-    /**
      * Fired when the { link Card} wishes to jump to another Card by its name.
      *
      * @see jump_to_card
      */
-    public signal void jump_to_card_by_name(string name, Value? message);
+    public signal void jump_to_card_by_id(string id, Value? message);
     
     /**
      * Fired when the { link Card} wishes to jump to the previous Card in the { link Deck}.
diff --git a/src/toolkit/toolkit-deck.vala b/src/toolkit/toolkit-deck.vala
index 9a3563b..b1a5646 100644
--- a/src/toolkit/toolkit-deck.vala
+++ b/src/toolkit/toolkit-deck.vala
@@ -41,7 +41,7 @@ public class Deck : Gtk.Stack {
     
     private Gee.List<Card> list = new Gee.LinkedList<Card>();
     private Gee.Deque<Card> navigation_stack = new Gee.LinkedList<Card>();
-    private Gee.HashMap<string, Card> names = new Gee.HashMap<string, Card>();
+    private Gee.HashMap<string, Card> ids = new Gee.HashMap<string, Card>();
     
     /**
      * Fired before { link Card}s are added or removed.
@@ -78,7 +78,7 @@ public class Deck : Gtk.Stack {
     }
     
     ~Deck() {
-        foreach (Card card in names.values) {
+        foreach (Card card in ids.values) {
             card.map.disconnect(on_card_mapped);
             card.realize.disconnect(on_card_realized);
         }
@@ -87,8 +87,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_instance);
-            top.jump_to_card_by_name.disconnect(on_jump_to_card_by_name);
+            top.jump_to_card_by_id.disconnect(on_jump_to_card_by_id);
             top.jump_back.disconnect(on_jump_back);
             top.jump_home.disconnect(on_jump_home);
             top.dismiss.disconnect(on_dismiss);
@@ -101,8 +100,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_instance);
-            top.jump_to_card_by_name.connect(on_jump_to_card_by_name);
+            top.jump_to_card_by_id.connect(on_jump_to_card_by_id);
             top.jump_back.connect(on_jump_back);
             top.jump_home.connect(on_jump_home);
             top.dismiss.connect(on_dismiss);
@@ -138,14 +136,14 @@ public class Deck : Gtk.Stack {
         foreach (Card card in cards) {
             // each card must have a unique name
             assert(!String.is_empty(card.card_id));
-            assert(!names.has_key(card.card_id));
+            assert(!ids.has_key(card.card_id));
             
             if (String.is_empty(card.title))
                 add_named(card, card.card_id);
             else
                 add_titled(card, card.card_id, card.title);
             
-            names.set(card.card_id, card);
+            ids.set(card.card_id, card);
             
             // deal with initial_focus and default_widget when mapped, as the calls aren't
             // guaranteed to work during programmatic navigation (especially for the first card,
@@ -179,7 +177,7 @@ public class Deck : Gtk.Stack {
         adding_removing_cards(null, cards);
         
         foreach (Card card in cards) {
-            if (!names.has_key(card.card_id)) {
+            if (!ids.has_key(card.card_id)) {
                 message("Card %s not found in Deck", card.card_id);
                 
                 continue;
@@ -194,7 +192,7 @@ public class Deck : Gtk.Stack {
                 top = null;
             
             navigation_stack.remove(card);
-            names.unset(card.card_id);
+            ids.unset(card.card_id);
             list.remove(card);
         }
         
@@ -252,7 +250,7 @@ public class Deck : Gtk.Stack {
         }
         
         // do nothing if not registered with this Deck
-        if (!names.values.contains(next)) {
+        if (!ids.values.contains(next)) {
             GLib.message("Card %s not registered with Deck", next.card_id);
             
             return;
@@ -262,12 +260,8 @@ public class Deck : Gtk.Stack {
         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);
+    private void on_jump_to_card_by_id(Card card, string id, Value? message) {
+        Card? next = ids.get(id);
         if (next != null)
             on_jump_to_card(card, next, Card.Jump.DIRECT, message);
         else
diff --git a/src/toolkit/toolkit-listbox-model.vala b/src/toolkit/toolkit-listbox-model.vala
index efe3e95..92ef654 100644
--- a/src/toolkit/toolkit-listbox-model.vala
+++ b/src/toolkit/toolkit-listbox-model.vala
@@ -19,6 +19,7 @@ namespace California.Toolkit {
 
 public class ListBoxModel<G> : BaseObject {
     public const string PROP_SELECTED = "selected";
+    public const string PROP_SIZE = "size";
     
     private const string KEY = "org.yorba.california.listbox-model.model";
     
@@ -35,9 +36,9 @@ public class ListBoxModel<G> : BaseObject {
     public Gtk.ListBox listbox { get; private set; }
     
     /**
-     * The number if items in the { link ListBoxModel}.
+     * The number of items in the { link ListBoxModel}.
      */
-    public int size { get { return items.size; } }
+    public int size { get; private set; default = 0; }
     
     /**
      * The item currently selected by the { link listbox}, null if no selection has been made.
@@ -126,6 +127,9 @@ public class ListBoxModel<G> : BaseObject {
         listbox.add(row);
         row.show_all();
         
+        // adjust size before signalling
+        size = size + 1;
+        
         added(item);
         
         return true;
@@ -184,6 +188,9 @@ public class ListBoxModel<G> : BaseObject {
         if (remove_from_listbox)
             row.destroy();
         
+        // adjust before signalling
+        size = (size - 1).clamp(0, int.MAX);
+        
         removed(item);
         
         return true;
diff --git a/vapi/libecal-1.2.vapi b/vapi/libecal-1.2.vapi
index d692d2e..6ead3ec 100644
--- a/vapi/libecal-1.2.vapi
+++ b/vapi/libecal-1.2.vapi
@@ -560,11 +560,11 @@ namespace E {
        public delegate bool CalRecurInstanceFn (E.CalComponent comp, time_t instance_start, time_t 
instance_end);
        [CCode (cheader_filename = "libecal/libecal.h")]
        public delegate iCal.icaltimezone CalRecurResolveTimezoneFn (string tzid);
-       [CCode (cheader_filename = "libecal/libecal.h")]
+       [CCode (cheader_filename = "libecal/libecal.h", cname = "CAL_BACKEND_PROPERTY_ALARM_EMAIL_ADDRESS")]
        public const string CAL_BACKEND_PROPERTY_ALARM_EMAIL_ADDRESS;
-       [CCode (cheader_filename = "libecal/libecal.h")]
+       [CCode (cheader_filename = "libecal/libecal.h", cname = "CAL_BACKEND_PROPERTY_CAL_EMAIL_ADDRESS")]
        public const string CAL_BACKEND_PROPERTY_CAL_EMAIL_ADDRESS;
-       [CCode (cheader_filename = "libecal/libecal.h")]
+       [CCode (cheader_filename = "libecal/libecal.h", cname = "CAL_BACKEND_PROPERTY_DEFAULT_OBJECT")]
        public const string CAL_BACKEND_PROPERTY_DEFAULT_OBJECT;
        [CCode (cheader_filename = "libecal/libecal.h")]
        public const string CAL_STATIC_CAPABILITY_ALARM_DESCRIPTION;


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