[california] Send invites to event attendees via xdg-email: Bug #740088
- From: Jim Nelson <jnelson src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [california] Send invites to event attendees via xdg-email: Bug #740088
- Date: Thu, 20 Nov 2014 23:41:12 +0000 (UTC)
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]