From 8d9eb034c7b4a3a396da16dc79cfa854c1dad73c Mon Sep 17 00:00:00 2001 From: David Young Date: Thu, 29 Oct 2026 08:18:52 -0600 Subject: [PATCH] Invite people to events on Federated Core calendars --- build-aux/org.gnome.Calendar.json | 1 + meson.build | 4 + meson_options.txt | 1 + src/core/gcal-event.c | 247 +++++++++++++++++ src/core/gcal-event.h | 20 ++ src/federated/gcal-federated-core.c | 255 ++++++++++++++++++ src/federated/gcal-federated-core.h | 34 +++ src/federated/meson.build | 5 + .../event-editor/gcal-attendee-details-page.c | 56 +++- .../event-editor/gcal-attendee-details-page.h | 3 + src/gui/event-editor/gcal-attendee-row.blp | 12 + src/gui/event-editor/gcal-attendee-row.c | 28 ++ .../event-editor/gcal-attendees-section.blp | 10 + src/gui/event-editor/gcal-attendees-section.c | 176 +++++++++++- src/gui/event-editor/gcal-attendees-section.h | 4 + .../event-editor/gcal-event-editor-dialog.c | 84 +++++- src/gui/event-editor/gcal-organizer-row.c | 4 + src/meson.build | 1 + src/utils/gcal-utils.c | 87 ++++++ src/utils/gcal-utils.h | 4 + tests/meson.build | 1 + tests/test-event.c | 94 +++++++ tests/test-federated-core.c | 165 ++++++++++++ tests/test-utils.c | 47 ++++ 24 files changed, 1324 insertions(+), 19 deletions(-) create mode 100644 src/federated/gcal-federated-core.c create mode 100644 src/federated/gcal-federated-core.h create mode 100644 src/federated/meson.build create mode 100644 tests/test-federated-core.c diff --git a/build-aux/org.gnome.Calendar.json b/build-aux/org.gnome.Calendar.json index 5221800c..5565da08 100644 --- a/build-aux/org.gnome.Calendar.json +++ b/build-aux/org.gnome.Calendar.json @@ -10,6 +10,7 @@ "finish-args" : [ "--env=G_DEBUG=fatal-criticals", "--device=dri", + "--filesystem=xdg-config/coredesktop:ro", "--share=ipc", "--share=network", "--socket=fallback-x11", diff --git a/meson.build b/meson.build index b71733e6..9fd839e3 100644 --- a/meson.build +++ b/meson.build @@ -96,6 +96,7 @@ assert(cc.has_function('strerror'), '"strerror" not found') # options config_h.set('ENABLE_TRACING', enable_tracing) +config_h.set('ENABLE_FEDERATED_CORE', get_option('federated_core')) config_h.set('HANDY_USE_UNSTABLE_API', true) # _NL_TIME_FIRST_WEEKDAY is an enum and not a define @@ -210,6 +211,9 @@ summary({ 'Source': meson.global_source_root(), 'Prefix': calendar_prefix, }) +summary({ + 'Federated Core': get_option('federated_core'), +}, section: 'Features') summary({ 'Debug': calendar_debug, 'Tracing': enable_tracing, diff --git a/meson_options.txt b/meson_options.txt index 70f70509..8c19dff8 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -1,2 +1,3 @@ option('tracing', type: 'boolean', value: false, description: 'add extra debugging information') option('profile', type: 'combo', choices: ['default', 'development'], value: 'default') +option('federated_core', type: 'boolean', value: true, description: 'invite people to events on the calendars of a Federated Computer Core') diff --git a/src/core/gcal-event.c b/src/core/gcal-event.c index 468c5054..f21da6bf 100644 --- a/src/core/gcal-event.c +++ b/src/core/gcal-event.c @@ -2023,3 +2023,250 @@ gcal_event_get_organizer (GcalEvent *self) return self->organizer; } + +static gboolean +mailto_equal (const gchar *uri1, + const gchar *uri2) +{ + if (!uri1 || !uri2) + return FALSE; + + if (g_ascii_strncasecmp (uri1, "mailto:", 7) == 0) + uri1 += 7; + + if (g_ascii_strncasecmp (uri2, "mailto:", 7) == 0) + uri2 += 7; + + return g_ascii_strcasecmp (uri1, uri2) == 0; +} + +static void +set_component_attendees (GcalEvent *self, + GSList *ecal_attendees) +{ + e_cal_component_set_attendees (self->component, ecal_attendees); + e_cal_component_commit_sequence (self->component); + + g_list_store_remove_all (self->attendees); + load_attendees (self); +} + +/** + * gcal_event_set_organizer: + * @self: a #GcalEvent + * @email: (nullable): the email address of the organizer + * @name: (nullable): the common name of the organizer + * + * Sets the organizer of @self. Passing %NULL as @email removes the + * organizer. + */ +void +gcal_event_set_organizer (GcalEvent *self, + const gchar *email, + const gchar *name) +{ + ECalComponentOrganizer *organizer = NULL; + + g_return_if_fail (GCAL_IS_EVENT (self)); + + g_clear_object (&self->organizer); + + if (email && *email) + { + g_autofree gchar *uri = g_strdup_printf ("mailto:%s", email); + + organizer = e_cal_component_organizer_new_full (uri, NULL, (name && *name) ? name : NULL, NULL); + self->organizer = gcal_event_organizer_new (organizer); + } + + e_cal_component_set_organizer (self->component, organizer); + e_cal_component_commit_sequence (self->component); + + e_cal_component_organizer_free (organizer); +} + +/** + * gcal_event_has_attendee: + * @self: a #GcalEvent + * @email: an email address, with or without the "mailto:" prefix + * + * Returns: %TRUE if @email is one of the attendees of @self + */ +gboolean +gcal_event_has_attendee (GcalEvent *self, + const gchar *email) +{ + guint n_items; + + g_return_val_if_fail (GCAL_IS_EVENT (self), FALSE); + + n_items = g_list_model_get_n_items (G_LIST_MODEL (self->attendees)); + + for (guint i = 0; i < n_items; i++) + { + g_autoptr (GcalEventAttendee) attendee = g_list_model_get_item (G_LIST_MODEL (self->attendees), i); + + if (mailto_equal (gcal_event_attendee_get_uri (attendee), email)) + return TRUE; + } + + return FALSE; +} + +/** + * gcal_event_add_attendee: + * @self: a #GcalEvent + * @email: the email address of the attendee + * @name: (nullable): the common name of the attendee + * + * Adds a person to the attendees of @self, who is asked to respond. + * + * Returns: %FALSE if @email already was an attendee + */ +gboolean +gcal_event_add_attendee (GcalEvent *self, + const gchar *email, + const gchar *name) +{ + g_autofree gchar *uri = NULL; + GSList *ecal_attendees; + + g_return_val_if_fail (GCAL_IS_EVENT (self), FALSE); + g_return_val_if_fail (email && *email, FALSE); + + if (gcal_event_has_attendee (self, email)) + return FALSE; + + uri = g_strdup_printf ("mailto:%s", email); + + ecal_attendees = e_cal_component_get_attendees (self->component); + ecal_attendees = g_slist_append (ecal_attendees, + e_cal_component_attendee_new_full (uri, + NULL, + I_CAL_CUTYPE_INDIVIDUAL, + I_CAL_ROLE_REQPARTICIPANT, + I_CAL_PARTSTAT_NEEDSACTION, + TRUE, + NULL, + NULL, + NULL, + (name && *name) ? name : NULL, + NULL)); + + set_component_attendees (self, ecal_attendees); + + g_slist_free_full (ecal_attendees, e_cal_component_attendee_free); + + return TRUE; +} + +/** + * gcal_event_remove_attendee: + * @self: a #GcalEvent + * @email: an email address, with or without the "mailto:" prefix + * + * Removes @email from the attendees of @self. + */ +void +gcal_event_remove_attendee (GcalEvent *self, + const gchar *email) +{ + GSList *ecal_attendees; + GSList *l; + + g_return_if_fail (GCAL_IS_EVENT (self)); + + ecal_attendees = e_cal_component_get_attendees (self->component); + + for (l = ecal_attendees; l != NULL; l = l->next) + { + if (mailto_equal (e_cal_component_attendee_get_value (l->data), email)) + break; + } + + if (l) + { + e_cal_component_attendee_free (l->data); + ecal_attendees = g_slist_delete_link (ecal_attendees, l); + + set_component_attendees (self, ecal_attendees); + } + + g_slist_free_full (ecal_attendees, e_cal_component_attendee_free); +} + +/** + * gcal_event_copy_attendees: + * @self: a #GcalEvent + * @event: the #GcalEvent to copy from + * + * Replaces the organizer and attendees of @self with those of @event. + */ +void +gcal_event_copy_attendees (GcalEvent *self, + GcalEvent *event) +{ + ECalComponentOrganizer *organizer; + GSList *ecal_attendees; + + g_return_if_fail (GCAL_IS_EVENT (self)); + g_return_if_fail (GCAL_IS_EVENT (event)); + + organizer = e_cal_component_get_organizer (event->component); + + g_clear_object (&self->organizer); + if (organizer) + self->organizer = gcal_event_organizer_new (organizer); + + e_cal_component_set_organizer (self->component, organizer); + + ecal_attendees = e_cal_component_get_attendees (event->component); + set_component_attendees (self, ecal_attendees); + + g_slist_free_full (ecal_attendees, e_cal_component_attendee_free); + e_cal_component_organizer_free (organizer); +} + +/** + * gcal_event_attendees_equal: + * @event1: a #GcalEvent + * @event2: a #GcalEvent + * + * Checks if @event1 and @event2 have the same organizer and attendees. + * Only the email addresses are compared. + * + * Returns: %TRUE if the events' organizer and attendees are identical + */ +gboolean +gcal_event_attendees_equal (GcalEvent *event1, + GcalEvent *event2) +{ + const gchar *organizer1 = NULL; + const gchar *organizer2 = NULL; + guint n_items; + + g_assert (GCAL_IS_EVENT (event1) && GCAL_IS_EVENT (event2)); + + if (event1->organizer) + organizer1 = gcal_event_organizer_get_uri (event1->organizer); + if (event2->organizer) + organizer2 = gcal_event_organizer_get_uri (event2->organizer); + + if ((organizer1 || organizer2) && !mailto_equal (organizer1, organizer2)) + return FALSE; + + n_items = g_list_model_get_n_items (G_LIST_MODEL (event1->attendees)); + + if (n_items != g_list_model_get_n_items (G_LIST_MODEL (event2->attendees))) + return FALSE; + + for (guint i = 0; i < n_items; i++) + { + g_autoptr (GcalEventAttendee) attendee = g_list_model_get_item (G_LIST_MODEL (event1->attendees), i); + + if (!gcal_event_has_attendee (event2, gcal_event_attendee_get_uri (attendee))) + return FALSE; + } + + return TRUE; +} diff --git a/src/core/gcal-event.h b/src/core/gcal-event.h index e603098f..b4f034f3 100644 --- a/src/core/gcal-event.h +++ b/src/core/gcal-event.h @@ -142,6 +142,26 @@ GListModel * gcal_event_get_attendees (GcalEvent GcalEventOrganizer * gcal_event_get_organizer (GcalEvent *self); +void gcal_event_set_organizer (GcalEvent *self, + const gchar *email, + const gchar *name); + +gboolean gcal_event_has_attendee (GcalEvent *self, + const gchar *email); + +gboolean gcal_event_add_attendee (GcalEvent *self, + const gchar *email, + const gchar *name); + +void gcal_event_remove_attendee (GcalEvent *self, + const gchar *email); + +void gcal_event_copy_attendees (GcalEvent *self, + GcalEvent *event); + +gboolean gcal_event_attendees_equal (GcalEvent *event1, + GcalEvent *event2); + gboolean gcal_event_alarms_equal (GcalEvent *event1, GcalEvent *event2); diff --git a/src/federated/gcal-federated-core.c b/src/federated/gcal-federated-core.c new file mode 100644 index 00000000..6263bed7 --- /dev/null +++ b/src/federated/gcal-federated-core.c @@ -0,0 +1,255 @@ +/* gcal-federated-core.c + * + * Copyright (C) 2026 David Young + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#define G_LOG_DOMAIN "GcalFederatedCore" + +#include "config.h" + +#include "gcal-federated-core.h" + +#include +#include + +/** + * SECTION:gcal-federated-core + * @short_description: Federated Computer Core integration + * @title:GcalFederatedCore + * + * When CoreDesktop's `configure` has connected this computer to a Federated + * Computer Core, each Core is described in ~/.config/coredesktop/cores/.conf + * and its Nextcloud calendars are added through GNOME Online Accounts. + * + * Nextcloud schedules events on the server: when an event whose organizer + * is the calendar's owner is saved with attendees, Nextcloud emails the + * invitations, updates and cancellations. So inviting people to an event on + * a Core calendar only takes setting its organizer and attendees. + */ + +typedef struct +{ + gchar *domain; + gchar *email; + gchar *name; +} CoreConf; + +static void +core_conf_free (CoreConf *conf) +{ + g_free (conf->domain); + g_free (conf->email); + g_free (conf->name); + g_free (conf); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC (CoreConf, core_conf_free) + +/* + * The files are written by bash with printf %q, e.g. CORE_NAME=David\ Young, + * which g_shell_unquote() reads. + */ +static CoreConf * +load_core_conf (const gchar *path) +{ + g_autoptr (CoreConf) conf = NULL; + g_autofree gchar *contents = NULL; + g_auto (GStrv) lines = NULL; + + if (!g_file_get_contents (path, &contents, NULL, NULL)) + return NULL; + + conf = g_new0 (CoreConf, 1); + lines = g_strsplit (contents, "\n", -1); + + for (gint i = 0; lines[i]; i++) + { + g_autofree gchar *value = NULL; + gchar *equals; + gchar **field; + + equals = strchr (lines[i], '='); + if (!equals) + continue; + + *equals = '\0'; + + if (g_str_equal (lines[i], "CORE_DOMAIN")) + field = &conf->domain; + else if (g_str_equal (lines[i], "CORE_EMAIL")) + field = &conf->email; + else if (g_str_equal (lines[i], "CORE_NAME")) + field = &conf->name; + else + continue; + + value = g_shell_unquote (equals + 1, NULL); + if (value) + g_set_str (field, value); + } + + if (!conf->domain || !*conf->domain || !conf->email || !strchr (conf->email, '@')) + return NULL; + + return g_steal_pointer (&conf); +} + +static GPtrArray * +load_cores (const gchar *config_dir) +{ + g_autofree gchar *cores_dir = NULL; + g_autoptr (GPtrArray) cores = NULL; + g_autoptr (GDir) dir = NULL; + const gchar *filename; + + cores = g_ptr_array_new_with_free_func ((GDestroyNotify) core_conf_free); + cores_dir = g_build_filename (config_dir, "cores", NULL); + dir = g_dir_open (cores_dir, 0, NULL); + + while (dir && (filename = g_dir_read_name (dir)) != NULL) + { + g_autofree gchar *path = NULL; + CoreConf *conf; + + if (!g_str_has_suffix (filename, ".conf")) + continue; + + path = g_build_filename (cores_dir, filename, NULL); + conf = load_core_conf (path); + + if (conf) + g_ptr_array_add (cores, conf); + } + + /* Before CoreDesktop supported several Cores, there was only core.conf */ + if (cores->len == 0) + { + g_autofree gchar *path = g_build_filename (config_dir, "core.conf", NULL); + CoreConf *conf = load_core_conf (path); + + if (conf) + g_ptr_array_add (cores, conf); + } + + return g_steal_pointer (&cores); +} + +/** + * gcal_federated_core_find_core: + * @config_dir: CoreDesktop's configuration directory + * @host: the host name of a CalDAV server + * @out_email: (out) (optional): return location for the Core email + * @out_name: (out) (optional): return location for the name + * + * Looks for the Core whose Nextcloud is at @host. + * + * Returns: %TRUE if @host is the Nextcloud of a Core set up on this computer + */ +gboolean +gcal_federated_core_find_core (const gchar *config_dir, + const gchar *host, + gchar **out_email, + gchar **out_name) +{ + g_autoptr (GPtrArray) cores = NULL; + + g_return_val_if_fail (config_dir != NULL, FALSE); + + if (!host) + return FALSE; + + cores = load_cores (config_dir); + + for (guint i = 0; i < cores->len; i++) + { + CoreConf *conf = g_ptr_array_index (cores, i); + g_autofree gchar *nextcloud_host = g_strconcat ("nextcloud.", conf->domain, NULL); + + if (g_ascii_strcasecmp (host, nextcloud_host) != 0) + continue; + + if (out_email) + *out_email = g_strdup (conf->email); + if (out_name) + *out_name = g_strdup (conf->name); + + return TRUE; + } + + return FALSE; +} + +/** + * gcal_federated_core_get_organizer: + * @calendar: a #GcalCalendar + * @out_email: (out) (optional): return location for the organizer's email + * @out_name: (out) (optional): return location for the organizer's name + * + * Checks whether @calendar is on the Nextcloud of a Federated Computer Core + * set up on this computer, where people can be invited to events, and who + * the organizer of those events is. + * + * Returns: %TRUE if people can be invited to events in @calendar + */ +gboolean +gcal_federated_core_get_organizer (GcalCalendar *calendar, + gchar **out_email, + gchar **out_name) +{ +#ifdef ENABLE_FEDERATED_CORE + g_autofree gchar *config_dir = NULL; + g_autofree gchar *server_email = NULL; + g_autofree gchar *core_email = NULL; + g_autofree gchar *core_name = NULL; + g_autoptr (GUri) uri = NULL; + ESourceWebdav *webdav; + ESource *source; + + g_return_val_if_fail (GCAL_IS_CALENDAR (calendar), FALSE); + + if (gcal_calendar_is_read_only (calendar)) + return FALSE; + + source = gcal_calendar_get_source (calendar); + if (!source || !e_source_has_extension (source, E_SOURCE_EXTENSION_WEBDAV_BACKEND)) + return FALSE; + + webdav = e_source_get_extension (source, E_SOURCE_EXTENSION_WEBDAV_BACKEND); + uri = e_source_webdav_dup_uri (webdav); + + /* CoreDesktop writes to ~/.config, even where XDG_CONFIG_HOME is elsewhere (e.g. Flatpak) */ + config_dir = g_build_filename (g_get_home_dir (), ".config", "coredesktop", NULL); + + if (!uri || !gcal_federated_core_find_core (config_dir, g_uri_get_host (uri), &core_email, &core_name)) + return FALSE; + + /* + * Nextcloud only sends invitations when the organizer is one of the + * owner's addresses. The CalDAV server says which address that is, so + * prefer it to the Core email. + */ + server_email = e_source_webdav_dup_email_address (webdav); + + if (out_email) + *out_email = (server_email && *server_email) ? g_steal_pointer (&server_email) : g_steal_pointer (&core_email); + if (out_name) + *out_name = g_steal_pointer (&core_name); + + return TRUE; +#else + return FALSE; +#endif +} diff --git a/src/federated/gcal-federated-core.h b/src/federated/gcal-federated-core.h new file mode 100644 index 00000000..a00bc5c7 --- /dev/null +++ b/src/federated/gcal-federated-core.h @@ -0,0 +1,34 @@ +/* gcal-federated-core.h + * + * Copyright (C) 2026 David Young + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "gcal-calendar.h" + +G_BEGIN_DECLS + +gboolean gcal_federated_core_get_organizer (GcalCalendar *calendar, + gchar **out_email, + gchar **out_name); + +gboolean gcal_federated_core_find_core (const gchar *config_dir, + const gchar *host, + gchar **out_email, + gchar **out_name); + +G_END_DECLS diff --git a/src/federated/meson.build b/src/federated/meson.build new file mode 100644 index 00000000..7168f1ed --- /dev/null +++ b/src/federated/meson.build @@ -0,0 +1,5 @@ +calendar_incs += include_directories('.') + +sources += files( + 'gcal-federated-core.c', +) diff --git a/src/gui/event-editor/gcal-attendee-details-page.c b/src/gui/event-editor/gcal-attendee-details-page.c index 5b266c3b..b2215170 100644 --- a/src/gui/event-editor/gcal-attendee-details-page.c +++ b/src/gui/event-editor/gcal-attendee-details-page.c @@ -54,6 +54,8 @@ struct _GcalAttendeeDetailsPage GtkWidget *group_not_responded; GcalEventAttendeeTypeFilterFlags attendee_type_filter_flags; + + gboolean removable; }; enum @@ -64,6 +66,7 @@ enum PROP_MODEL_DELEGATED, PROP_MODEL_NOT_RESPONDED, PROP_ATTENDEE_TYPE, + PROP_REMOVABLE, N_PROPS }; @@ -83,7 +86,11 @@ static GtkWidget * create_attendee_row_widget (void *attendee, void *user_data) { - return gcal_attendee_row_new (attendee); + GtkWidget *row = gcal_attendee_row_new (attendee); + + g_object_bind_property (user_data, "removable", row, "removable", G_BINDING_SYNC_CREATE); + + return row; } static void @@ -237,6 +244,9 @@ gcal_attendee_details_page_get_property (GObject *object, case PROP_ATTENDEE_TYPE: g_value_set_flags (value, self->attendee_type_filter_flags); break; + case PROP_REMOVABLE: + g_value_set_boolean (value, self->removable); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); } @@ -255,6 +265,9 @@ gcal_attendee_details_page_set_property (GObject *object, case PROP_ATTENDEE_TYPE: set_type_filter_flags (self, g_value_get_flags (value)); break; + case PROP_REMOVABLE: + gcal_attendee_details_page_set_removable (self, g_value_get_boolean (value)); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); } @@ -275,27 +288,27 @@ gcal_attendee_details_page_init (GcalAttendeeDetailsPage *instance) adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_accepted), G_LIST_MODEL (instance->filter_list_accepted), create_attendee_row_widget, - NULL, NULL); + instance, NULL); adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_tentative), G_LIST_MODEL (instance->filter_list_tentative), create_attendee_row_widget, - NULL, NULL); + instance, NULL); adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_declined), G_LIST_MODEL (instance->filter_list_declined), create_attendee_row_widget, - NULL, NULL); + instance, NULL); adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_delegated), G_LIST_MODEL (instance->filter_list_delegated), create_attendee_row_widget, - NULL, NULL); + instance, NULL); adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_not_responded), G_LIST_MODEL (instance->filter_list_not_responded), create_attendee_row_widget, - NULL, NULL); + instance, NULL); } static void @@ -370,6 +383,16 @@ gcal_attendee_details_page_class_init (GcalAttendeeDetailsPageClass *klass) GCAL_EVENT_ATTENDEE_TYPE_FILTER_NONE, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS); + /** + * GcalAttendeeDetailsPage:removable: + * + * Whether attendees can be removed from the event. + */ + properties[PROP_REMOVABLE] = + g_param_spec_boolean ("removable", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + g_object_class_install_properties (object_class, N_PROPS, properties); /* widget */ @@ -420,3 +443,24 @@ gcal_attendee_details_page_set_type_filter (GcalAttendeeDetailsPage *sel g_assert (GCAL_IS_ATTENDEE_DETAILS_PAGE (self)); set_type_filter_flags (self, flags); } + +/** + * gcal_attendee_details_page_set_removable: + * @self: a #GcalAttendeeDetailsPage instance. + * @removable: whether attendees can be removed + * + * Sets whether each attendee is shown with a button that removes them + * from the event. + */ +void +gcal_attendee_details_page_set_removable (GcalAttendeeDetailsPage *self, + gboolean removable) +{ + g_assert (GCAL_IS_ATTENDEE_DETAILS_PAGE (self)); + + if (self->removable == removable) + return; + + self->removable = removable; + g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_REMOVABLE]); +} diff --git a/src/gui/event-editor/gcal-attendee-details-page.h b/src/gui/event-editor/gcal-attendee-details-page.h index 958ed798..baaf933e 100644 --- a/src/gui/event-editor/gcal-attendee-details-page.h +++ b/src/gui/event-editor/gcal-attendee-details-page.h @@ -34,6 +34,9 @@ void gcal_attendee_details_page_set_attendees (GcalAtte void gcal_attendee_details_page_set_type_filter (GcalAttendeeDetailsPage *self, GcalEventAttendeeTypeFilterFlags flags); +void gcal_attendee_details_page_set_removable (GcalAttendeeDetailsPage *self, + gboolean removable); + G_END_DECLS #endif diff --git a/src/gui/event-editor/gcal-attendee-row.blp b/src/gui/event-editor/gcal-attendee-row.blp index 5a190602..3b0434b0 100644 --- a/src/gui/event-editor/gcal-attendee-row.blp +++ b/src/gui/event-editor/gcal-attendee-row.blp @@ -12,6 +12,18 @@ template $GcalAttendeeRow: Adw.ActionRow { "dimmed", ] } + + [suffix] + Button remove_button { + visible: bind template.removable; + valign: center; + icon-name: "list-remove-symbolic"; + tooltip-text: _("Remove Participant"); + + styles [ + "flat", + ] + } } PopoverMenu context_menu { diff --git a/src/gui/event-editor/gcal-attendee-row.c b/src/gui/event-editor/gcal-attendee-row.c index d5d72b9d..2834529e 100644 --- a/src/gui/event-editor/gcal-attendee-row.c +++ b/src/gui/event-editor/gcal-attendee-row.c @@ -34,10 +34,13 @@ struct _GcalAttendeeRow GcalEventAttendee *attendee; GtkWidget *role; + GtkWidget *remove_button; GtkWidget *context_menu; GtkGestureClick *gesture_click; GSimpleActionGroup *action_group; + + gboolean removable; }; G_DEFINE_FINAL_TYPE (GcalAttendeeRow, gcal_attendee_row, ADW_TYPE_ACTION_ROW); @@ -46,6 +49,7 @@ enum { PROP_0, PROP_ATTENDEE, + PROP_REMOVABLE, N_PROPS, }; @@ -164,6 +168,16 @@ gcal_attendee_row_set_property (GObject *object, g_assert (self->attendee == NULL); self->attendee = g_value_get_object (value); set_up_row_content (self); + gtk_actionable_set_action_target (GTK_ACTIONABLE (self->remove_button), "s", + gcal_event_attendee_get_uri (self->attendee) ? gcal_event_attendee_get_uri (self->attendee) : ""); + gtk_actionable_set_action_name (GTK_ACTIONABLE (self->remove_button), "event-editor.remove-attendee"); + break; + case PROP_REMOVABLE: + if (self->removable != g_value_get_boolean (value)) + { + self->removable = g_value_get_boolean (value); + g_object_notify_by_pspec (object, pspec); + } break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); @@ -183,6 +197,9 @@ gcal_attendee_row_get_property (GObject *object, case PROP_ATTENDEE: g_value_set_object (value, self->attendee); break; + case PROP_REMOVABLE: + g_value_set_boolean (value, self->removable); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); } @@ -209,12 +226,23 @@ gcal_attendee_row_class_init (GcalAttendeeRowClass *klass) GCAL_TYPE_EVENT_ATTENDEE, G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS); + /** + * GcalAttendeeRow:removable: + * + * Whether the row offers to remove the attendee from the event. + */ + properties[PROP_REMOVABLE] = + g_param_spec_boolean ("removable", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + g_object_class_install_properties (object_class, N_PROPS, properties); gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/calendar/ui/event-editor/gcal-attendee-row.ui"); gtk_widget_class_bind_template_child (widget_class, GcalAttendeeRow, context_menu); gtk_widget_class_bind_template_child (widget_class, GcalAttendeeRow, gesture_click); + gtk_widget_class_bind_template_child (widget_class, GcalAttendeeRow, remove_button); gtk_widget_class_bind_template_child (widget_class, GcalAttendeeRow, role); } diff --git a/src/gui/event-editor/gcal-attendees-section.blp b/src/gui/event-editor/gcal-attendees-section.blp index 8066b286..8926e2ab 100644 --- a/src/gui/event-editor/gcal-attendees-section.blp +++ b/src/gui/event-editor/gcal-attendees-section.blp @@ -11,6 +11,16 @@ template $GcalAttendeesSection: $GcalEventEditorSection { filter-flags: GCAL_EVENT_ATTENDEE_TYPE_FILTER_PERSON; } + Adw.EntryRow invite_row { + title: _("Invite by Email"); + visible: bind template.can-invite; + input-purpose: email; + show-apply-button: true; + apply => $on_invite_row_apply_cb(template) not-swapped; + entry-activated => $on_invite_row_apply_cb(template) not-swapped; + changed => $on_invite_row_changed_cb(template) not-swapped; + } + $GcalAttendeeSummaryRow { title: _("Resources"); attendees: bind template.attendees; diff --git a/src/gui/event-editor/gcal-attendees-section.c b/src/gui/event-editor/gcal-attendees-section.c index 3b36685c..cdc338eb 100644 --- a/src/gui/event-editor/gcal-attendees-section.c +++ b/src/gui/event-editor/gcal-attendees-section.c @@ -27,17 +27,25 @@ #include "gcal-event-organizer.h" #include "gcal-event.h" #include "gcal-organizer-row.h" +#include "gcal-utils.h" #include "glib-object.h" struct _GcalAttendeesSection { GcalEventEditorSection parent_instance; + GcalEvent *event; GcalEventOrganizer *organizer; + GtkWidget *invite_row; + GtkWidget *organizer_row; AdwActionRow *summary_row; GListModel *attendees; + + /* Who invitations come from; NULL when people can't be invited */ + gchar *inviter_email; + gchar *inviter_name; }; G_DEFINE_FINAL_TYPE (GcalAttendeesSection, gcal_attendees_section, GCAL_TYPE_EVENT_EDITOR_SECTION) @@ -46,6 +54,7 @@ enum { PROP_0, PROP_ATTENDEES, + PROP_CAN_INVITE, PROP_ORGANIZER, N_PROPS }; @@ -53,6 +62,97 @@ enum static GParamSpec *properties[N_PROPS] = { NULL, }; +/* + * Auxiliary methods + */ + +static void +update_from_event (GcalAttendeesSection *self) +{ + self->organizer = self->event ? gcal_event_get_organizer (self->event) : NULL; + self->attendees = self->event ? gcal_event_get_attendees (self->event) : NULL; + + gtk_widget_set_visible (self->organizer_row, self->organizer != NULL); + + g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ATTENDEES]); + g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ORGANIZER]); +} + +static gboolean +invite (GcalAttendeesSection *self, + const gchar *text) +{ + g_autofree gchar *email = NULL; + g_autofree gchar *name = NULL; + + if (!gcal_util_parse_email_address (text, &email, &name)) + return FALSE; + + /* The organizer doesn't invite themselves */ + if (g_ascii_strcasecmp (email, self->inviter_email) == 0) + return TRUE; + + if (!gcal_event_get_organizer (self->event)) + gcal_event_set_organizer (self->event, self->inviter_email, self->inviter_name); + + gcal_event_add_attendee (self->event, email, name); + + return TRUE; +} + + +/* + * Callbacks + */ + +static void +on_invite_row_apply_cb (AdwEntryRow *row, + GcalAttendeesSection *self) +{ + g_autoptr (GStrvBuilder) not_invited = NULL; + g_auto (GStrv) addresses = NULL; + g_auto (GStrv) remaining = NULL; + g_autofree gchar *remaining_text = NULL; + + if (!self->event || !self->inviter_email) + return; + + /* Several addresses can be pasted at once */ + addresses = g_strsplit_set (gtk_editable_get_text (GTK_EDITABLE (row)), ",;\n", -1); + not_invited = g_strv_builder_new (); + + for (gint i = 0; addresses[i]; i++) + { + g_strstrip (addresses[i]); + + if (*addresses[i] && !invite (self, addresses[i])) + g_strv_builder_add (not_invited, addresses[i]); + } + + /* Leave what isn't an email address, so it can be corrected */ + remaining = g_strv_builder_end (not_invited); + remaining_text = g_strjoinv (", ", remaining); + + gtk_editable_set_text (GTK_EDITABLE (row), remaining_text); + + if (*remaining_text) + gtk_widget_add_css_class (GTK_WIDGET (row), "error"); +} + +static void +on_invite_row_changed_cb (AdwEntryRow *row, + GcalAttendeesSection *self) +{ + gtk_widget_remove_css_class (GTK_WIDGET (row), "error"); +} + +static void +on_attendees_changed_cb (GcalAttendeesSection *self) +{ + update_from_event (self); +} + + /* * GcalEventEditorSection overrides */ @@ -63,19 +163,23 @@ gcal_attendees_section_event_set_cb (GcalEventEditorSection *section, { GcalAttendeesSection *self = GCAL_ATTENDEES_SECTION (section); + gtk_editable_set_text (GTK_EDITABLE (self->invite_row), ""); + + if (self->event) + g_signal_handlers_disconnect_by_func (gcal_event_get_attendees (self->event), on_attendees_changed_cb, self); + + g_set_weak_pointer (&self->event, event); + if (event) { - self->organizer = gcal_event_get_organizer (event); - self->attendees = gcal_event_get_attendees (event); - } - else - { - self->organizer = NULL; - self->attendees = NULL; + g_signal_connect_object (gcal_event_get_attendees (event), + "items-changed", + G_CALLBACK (on_attendees_changed_cb), + self, + G_CONNECT_SWAPPED); } - g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ATTENDEES]); - g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ORGANIZER]); + update_from_event (self); } @@ -97,6 +201,10 @@ gcal_attendees_section_get_property (GObject *object, g_value_set_object (value, self->attendees); break; + case PROP_CAN_INVITE: + g_value_set_boolean (value, self->inviter_email != NULL); + break; + case PROP_ORGANIZER: g_value_set_object (value, self->organizer); break; @@ -132,6 +240,13 @@ gcal_attendees_section_finalize (GObject *object) self->attendees = NULL; /* not owned */ + if (self->event) + g_signal_handlers_disconnect_by_func (gcal_event_get_attendees (self->event), on_attendees_changed_cb, self); + g_clear_weak_pointer (&self->event); + + g_clear_pointer (&self->inviter_email, g_free); + g_clear_pointer (&self->inviter_name, g_free); + G_OBJECT_CLASS (gcal_attendees_section_parent_class)->finalize (object); } @@ -168,6 +283,17 @@ gcal_attendees_section_class_init (GcalAttendeesSectionClass *klass) G_TYPE_LIST_MODEL, G_PARAM_READABLE | G_PARAM_STATIC_STRINGS); + /** + * GcalAttendeesSection:can-invite: + * + * Whether people can be invited to the event. + * See gcal_attendees_section_set_inviter(). + */ + properties[PROP_CAN_INVITE] = + g_param_spec_boolean ("can-invite", NULL, NULL, + FALSE, + G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + /** * GcalAttendeesSection:organizer: * @@ -183,5 +309,37 @@ gcal_attendees_section_class_init (GcalAttendeesSectionClass *klass) gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/calendar/ui/event-editor/gcal-attendees-section.ui"); + gtk_widget_class_bind_template_child (widget_class, GcalAttendeesSection, invite_row); + gtk_widget_class_bind_template_child (widget_class, GcalAttendeesSection, organizer_row); gtk_widget_class_bind_template_child (widget_class, GcalAttendeesSection, summary_row); + + gtk_widget_class_bind_template_callback (widget_class, on_invite_row_apply_cb); + gtk_widget_class_bind_template_callback (widget_class, on_invite_row_changed_cb); +} + +/** + * gcal_attendees_section_set_inviter: + * @self: a #GcalAttendeesSection + * @email: (nullable): the email address invitations come from + * @name: (nullable): the name invitations come from + * + * Lets people be invited to the event, on behalf of @email, who becomes + * its organizer. Passing %NULL as @email stops people from being invited. + */ +void +gcal_attendees_section_set_inviter (GcalAttendeesSection *self, + const gchar *email, + const gchar *name) +{ + gboolean could_invite; + + g_return_if_fail (GCAL_IS_ATTENDEES_SECTION (self)); + + could_invite = self->inviter_email != NULL; + + g_set_str (&self->inviter_email, email); + g_set_str (&self->inviter_name, email ? name : NULL); + + if (could_invite != (self->inviter_email != NULL)) + g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_CAN_INVITE]); } diff --git a/src/gui/event-editor/gcal-attendees-section.h b/src/gui/event-editor/gcal-attendees-section.h index a882d5d0..389aad56 100644 --- a/src/gui/event-editor/gcal-attendees-section.h +++ b/src/gui/event-editor/gcal-attendees-section.h @@ -27,4 +27,8 @@ G_BEGIN_DECLS #define GCAL_TYPE_ATTENDEES_SECTION (gcal_attendees_section_get_type ()) G_DECLARE_FINAL_TYPE (GcalAttendeesSection, gcal_attendees_section, GCAL, ATTENDEES_SECTION, GcalEventEditorSection) +void gcal_attendees_section_set_inviter (GcalAttendeesSection *self, + const gchar *email, + const gchar *name); + G_END_DECLS diff --git a/src/gui/event-editor/gcal-event-editor-dialog.c b/src/gui/event-editor/gcal-event-editor-dialog.c index 1ad1df34..0811f487 100644 --- a/src/gui/event-editor/gcal-event-editor-dialog.c +++ b/src/gui/event-editor/gcal-event-editor-dialog.c @@ -28,6 +28,7 @@ #include "gcal-event-editor-dialog.h" #include "gcal-event-editor-section.h" #include "gcal-event.h" +#include "gcal-federated-core.h" #include "gcal-notes-section.h" #include "gcal-reminders-section.h" #include "gcal-schedule-section.h" @@ -128,6 +129,48 @@ set_writable (GcalEventEditorDialog *self, g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_WRITABLE]); } +/* + * People can be invited to events on the calendars of a Federated Computer + * Core, which sends the invitations, unless someone else organizes the event. + */ +static void +update_invite_state (GcalEventEditorDialog *self) +{ + g_autofree gchar *email = NULL; + g_autofree gchar *name = NULL; + GcalEventOrganizer *organizer; + GcalCalendar *calendar; + gboolean can_invite = FALSE; + + if (!self->edited_event) + return; + + calendar = gcal_calendar_combo_row_get_calendar (self->calendar_combo_row); + if (!calendar) + calendar = gcal_event_get_calendar (self->edited_event); + + organizer = gcal_event_get_organizer (self->edited_event); + + if (self->writable && calendar && gcal_federated_core_get_organizer (calendar, &email, &name)) + { + g_autofree const gchar *organizer_email = NULL; + + if (organizer) + organizer_email = gcal_util_get_email_from_mailto_uri (gcal_event_organizer_get_uri (organizer)); + + can_invite = !organizer_email || g_ascii_strcasecmp (organizer_email, email) == 0; + } + + gcal_attendees_section_set_inviter (GCAL_ATTENDEES_SECTION (self->attendees_section), + can_invite ? email : NULL, + can_invite ? name : NULL); + + gcal_attendee_details_page_set_removable (GCAL_ATTENDEE_DETAILS_PAGE (self->attendee_details_page), can_invite); + gtk_widget_action_set_enabled (GTK_WIDGET (self), "event-editor.remove-attendee", can_invite); + + gtk_widget_set_visible (GTK_WIDGET (self->attendees_section), organizer != NULL || can_invite); +} + static void set_event (GcalEventEditorDialog *self, GcalEvent *event, @@ -165,8 +208,7 @@ set_event (GcalEventEditorDialog *self, gcal_calendar_combo_row_set_calendar (self->calendar_combo_row, calendar); set_writable (self, !gcal_calendar_is_read_only (calendar)); - gboolean has_participants = gcal_event_get_organizer (self->edited_event) != NULL; - gtk_widget_set_visible (GTK_WIDGET (self->attendees_section), has_participants); + update_invite_state (self); gtk_widget_set_visible (self->cancel_button, self->writable); @@ -235,6 +277,9 @@ apply_event_properties_to_template_event (GcalEvent *template_event, if (alarm) gcal_event_add_alarm (template_event, alarm); } + + if (!gcal_event_attendees_equal (template_event, event)) + gcal_event_copy_attendees (template_event, event); } static void @@ -275,7 +320,7 @@ save_event (GcalEventEditorDialog *self, GcalContext *context; GcalManager *manager; - gboolean calendar_equal, alarms_equal, schedule_equal, strings_equal, event_equal; + gboolean calendar_equal, alarms_equal, schedule_equal, strings_equal, attendees_equal, event_equal; GCAL_ENTRY; @@ -284,11 +329,20 @@ save_event (GcalEventEditorDialog *self, selected_calendar = gcal_calendar_combo_row_get_calendar (self->calendar_combo_row); + /* Everyone invited here was removed again, so there's nothing to organize */ + if (!gcal_event_get_organizer (self->event) && + gcal_event_get_organizer (self->edited_event) && + g_list_model_get_n_items (gcal_event_get_attendees (self->edited_event)) == 0) + { + gcal_event_set_organizer (self->edited_event, NULL, NULL); + } + calendar_equal = selected_calendar && calendar == selected_calendar; alarms_equal = gcal_event_alarms_equal (self->event, self->edited_event); schedule_equal = gcal_event_schedule_equal (self->event, self->edited_event); strings_equal = gcal_event_strings_equal (self->event, self->edited_event); - event_equal = calendar_equal && alarms_equal && schedule_equal && strings_equal; + attendees_equal = gcal_event_attendees_equal (self->event, self->edited_event); + event_equal = calendar_equal && alarms_equal && schedule_equal && strings_equal && attendees_equal; if (self->event_is_new) { @@ -357,6 +411,25 @@ on_show_attendees_detail_page_action_activated_cb (GtkWidget *widget, ADW_NAVIGATION_PAGE (self->attendee_details_page)); } +static void +on_remove_attendee_action_activated_cb (GtkWidget *widget, + const char *action_name, + GVariant *param) +{ + GcalEventEditorDialog *self = GCAL_EVENT_EDITOR_DIALOG (widget); + + gcal_event_remove_attendee (self->edited_event, g_variant_get_string (param, NULL)); + + if (g_list_model_get_n_items (self->attendees_model) == 0) + adw_navigation_view_pop (ADW_NAVIGATION_VIEW (self->nav_view)); +} + +static void +on_calendar_selected_cb (GcalEventEditorDialog *self) +{ + update_invite_state (self); +} + static void on_cancel_button_clicked_cb (GtkButton *button, GcalEventEditorDialog *self) @@ -638,6 +711,8 @@ gcal_event_editor_dialog_class_init (GcalEventEditorDialogClass *klass) gtk_widget_class_install_action (widget_class, "event-editor.save", NULL, on_event_editor_save_action_activated_cb); gtk_widget_class_install_action (widget_class, "event-editor.show-attendees-detail-page", "u", on_show_attendees_detail_page_action_activated_cb); + gtk_widget_class_install_action (widget_class, "event-editor.remove-attendee", "s", + on_remove_attendee_action_activated_cb); } static void @@ -648,6 +723,7 @@ gcal_event_editor_dialog_init (GcalEventEditorDialog *self) set_up_context (self); g_signal_connect_swapped (self, "validation-requested", G_CALLBACK (on_validation_requested_cb), self); + g_signal_connect_swapped (self->calendar_combo_row, "notify::selected-item", G_CALLBACK (on_calendar_selected_cb), self); } /** diff --git a/src/gui/event-editor/gcal-organizer-row.c b/src/gui/event-editor/gcal-organizer-row.c index a235e20f..09874cde 100644 --- a/src/gui/event-editor/gcal-organizer-row.c +++ b/src/gui/event-editor/gcal-organizer-row.c @@ -89,6 +89,10 @@ generate_subtitle (GcalOrganizerRow *self, return NULL; email = gcal_util_get_email_from_mailto_uri (gcal_event_organizer_get_uri (organizer)); + + if (!gcal_event_organizer_get_name (organizer)) + return g_strdup (email); + name_with_email = g_strdup_printf ("%s (%s)", gcal_event_organizer_get_name (organizer), email); return g_strdup (name_with_email); diff --git a/src/meson.build b/src/meson.build index 34795327..71d6fdab 100644 --- a/src/meson.build +++ b/src/meson.build @@ -75,6 +75,7 @@ sources = files() subdir('appdata') subdir('contrib') subdir('core') +subdir('federated') subdir('gui') subdir('search') subdir('theme') diff --git a/src/utils/gcal-utils.c b/src/utils/gcal-utils.c index 0b3aa9bb..b3d45c51 100644 --- a/src/utils/gcal-utils.c +++ b/src/utils/gcal-utils.c @@ -1081,6 +1081,93 @@ gcal_util_get_email_from_mailto_uri (const gchar *mailto_uri) return address; } +static gboolean +is_valid_email (const gchar *email) +{ + const gchar *at; + const gchar *domain; + + at = strchr (email, '@'); + if (!at || at == email || strchr (at + 1, '@')) + return FALSE; + + domain = at + 1; + if (!strchr (domain, '.') || *domain == '.' || g_str_has_suffix (domain, ".")) + return FALSE; + + for (const gchar *c = email; *c; c++) + { + if (g_ascii_isspace (*c) || strchr ("<>()[],;:\\\"", *c)) + return FALSE; + } + + return TRUE; +} + +/** + * gcal_util_parse_email_address: + * @text: an address as typed, e.g. "ana@example.com" or "Ana Lima " + * @out_email: (out) (optional): return location for the email address + * @out_name: (out) (optional) (nullable): return location for the name, if any + * + * Reads an email address, optionally with a name in front of it. + * + * Returns: %TRUE if @text is an email address + */ +gboolean +gcal_util_parse_email_address (const gchar *text, + gchar **out_email, + gchar **out_name) +{ + g_autofree gchar *stripped = NULL; + g_autofree gchar *email = NULL; + g_autofree gchar *name = NULL; + gchar *open_bracket; + + if (!text) + return FALSE; + + stripped = g_strstrip (g_strdup (text)); + open_bracket = strrchr (stripped, '<'); + + if (open_bracket && g_str_has_suffix (stripped, ">")) + { + stripped[strlen (stripped) - 1] = '\0'; + email = g_strstrip (g_strdup (open_bracket + 1)); + + *open_bracket = '\0'; + name = g_strstrip (g_strdup (stripped)); + + /* "Ana Lima" */ + if (g_str_has_prefix (name, "\"") && g_str_has_suffix (name, "\"") && strlen (name) >= 2) + { + name[strlen (name) - 1] = '\0'; + memmove (name, name + 1, strlen (name)); + g_strstrip (name); + } + + if (!*name) + g_clear_pointer (&name, g_free); + } + else + { + email = g_strdup (stripped); + } + + if (g_ascii_strncasecmp (email, "mailto:", 7) == 0) + memmove (email, email + 7, strlen (email + 7) + 1); + + if (!is_valid_email (email)) + return FALSE; + + if (out_email) + *out_email = g_steal_pointer (&email); + if (out_name) + *out_name = g_steal_pointer (&name); + + return TRUE; +} + /** * gcal_create_activate_signal_shortcuts: * @widget_class: the class to add the bindings to diff --git a/src/utils/gcal-utils.h b/src/utils/gcal-utils.h index ef685f23..fd97e1bb 100644 --- a/src/utils/gcal-utils.h +++ b/src/utils/gcal-utils.h @@ -118,6 +118,10 @@ GListModel * gcal_util_create_writable_calendars_model (GcalManager const gchar * gcal_util_get_email_from_mailto_uri (const gchar *mailto_uri); +gboolean gcal_util_parse_email_address (const gchar *text, + gchar **out_email, + gchar **out_name); + guint gcal_util_create_activate_signal_and_shortcuts (GtkWidgetClass *widget_class, GType widget_type); #endif /* __GCAL_UTILS_H__ */ diff --git a/tests/meson.build b/tests/meson.build index 0d29c853..b213dcc2 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -43,6 +43,7 @@ tests = [ 'server', 'utils', 'event-attendee', + 'federated-core', ] foreach test : tests diff --git a/tests/test-event.c b/tests/test-event.c index 9dd7340f..01c70b32 100644 --- a/tests/test-event.c +++ b/tests/test-event.c @@ -17,6 +17,7 @@ */ #include +#include #include "gcal-event-attendee.h" #include "gcal-event-organizer.h" @@ -454,6 +455,97 @@ event_get_attendees (void) /*********************************************************************************************************************/ +static void +event_invite_attendees (void) +{ + g_autoptr (GcalEvent) event = NULL; + g_autoptr (GcalEvent) clone = NULL; + g_autoptr (GError) error = NULL; + g_autofree gchar *ical = NULL; + GcalEventOrganizer *organizer; + GListModel *attendees; + + event = create_event_for_string (STUB_EVENT, &error); + g_assert_no_error (error); + + attendees = gcal_event_get_attendees (event); + g_assert_cmpuint (g_list_model_get_n_items (attendees), ==, 0); + g_assert_null (gcal_event_get_organizer (event)); + + clone = gcal_event_new_from_event (event); + g_assert_true (gcal_event_attendees_equal (event, clone)); + + gcal_event_set_organizer (clone, "me@example.com", "Me Myself"); + organizer = gcal_event_get_organizer (clone); + g_assert_nonnull (organizer); + g_assert_cmpstr (gcal_event_organizer_get_uri (organizer), ==, "mailto:me@example.com"); + g_assert_cmpstr (gcal_event_organizer_get_name (organizer), ==, "Me Myself"); + g_assert_false (gcal_event_attendees_equal (event, clone)); + + g_assert_true (gcal_event_add_attendee (clone, "ana@example.com", "Ana Lima")); + g_assert_true (gcal_event_add_attendee (clone, "bo@example.com", NULL)); + g_assert_false (gcal_event_add_attendee (clone, "ANA@example.com", NULL)); + + attendees = gcal_event_get_attendees (clone); + g_assert_cmpuint (g_list_model_get_n_items (attendees), ==, 2); + g_assert_true (gcal_event_has_attendee (clone, "mailto:Ana@Example.com")); + g_assert_false (gcal_event_has_attendee (clone, "carl@example.com")); + + { + g_autoptr (GcalEventAttendee) attendee = g_list_model_get_item (attendees, 0); + + g_assert_cmpstr (gcal_event_attendee_get_uri (attendee), ==, "mailto:ana@example.com"); + g_assert_cmpstr (gcal_event_attendee_get_name (attendee), ==, "Ana Lima"); + g_assert_cmpint (gcal_event_attendee_get_attendee_type (attendee), ==, GCAL_EVENT_ATTENDEE_TYPE_INDIVIDUAL); + g_assert_cmpint (gcal_event_attendee_get_role (attendee), ==, GCAL_EVENT_ATTENDEE_ROLE_REQUIRED); + g_assert_cmpint (gcal_event_attendee_get_part_status (attendee), ==, GCAL_EVENT_ATTENDEE_PART_NEEDS_ACTION); + g_assert_true (gcal_event_attendee_get_requires_rsvp (attendee)); + } + + /* What the server sees */ + ical = e_cal_component_get_as_string (gcal_event_get_component (clone)); + g_assert_nonnull (strstr (ical, "ORGANIZER;CN=Me Myself:mailto:me@example.com")); + g_assert_nonnull (strstr (ical, "mailto:ana@example.com")); + g_assert_nonnull (strstr (ical, "mailto:bo@example.com")); + + gcal_event_remove_attendee (clone, "mailto:ANA@example.com"); + g_assert_cmpuint (g_list_model_get_n_items (attendees), ==, 1); + g_assert_false (gcal_event_has_attendee (clone, "ana@example.com")); + + /* Removing someone who isn't invited does nothing */ + gcal_event_remove_attendee (clone, "carl@example.com"); + g_assert_cmpuint (g_list_model_get_n_items (attendees), ==, 1); + + gcal_event_remove_attendee (clone, "bo@example.com"); + gcal_event_set_organizer (clone, NULL, NULL); + g_assert_null (gcal_event_get_organizer (clone)); + g_assert_true (gcal_event_attendees_equal (event, clone)); +} + +static void +event_copy_attendees (void) +{ + g_autoptr (GcalEvent) with_attendees = NULL; + g_autoptr (GcalEvent) event = NULL; + g_autoptr (GError) error = NULL; + + with_attendees = create_event_for_string (STUB_EVENT_WITH_ATTENDEES, &error); + g_assert_no_error (error); + + event = create_event_for_string (STUB_EVENT, &error); + g_assert_no_error (error); + + g_assert_false (gcal_event_attendees_equal (event, with_attendees)); + + gcal_event_copy_attendees (event, with_attendees); + + g_assert_true (gcal_event_attendees_equal (event, with_attendees)); + g_assert_cmpuint (g_list_model_get_n_items (gcal_event_get_attendees (event)), ==, 2); + g_assert_cmpstr (gcal_event_organizer_get_uri (gcal_event_get_organizer (event)), ==, "mailto:alice@example.com"); +} + +/*********************************************************************************************************************/ + static GDateTime * dt_add_second_random (GDateTime *dt) { @@ -595,6 +687,8 @@ main (gint argc, g_test_add_func ("/event/date/create-tzid", event_date_create_tzid); g_test_add_func ("/event/date/check-tz", event_date_check_tz); g_test_add_func ("/event/date/get-attendees", event_get_attendees); + g_test_add_func ("/event/attendees/invite", event_invite_attendees); + g_test_add_func ("/event/attendees/copy", event_copy_attendees); g_test_add_func ("/event/equal/schedule", event_schedule_equal); return g_test_run (); diff --git a/tests/test-federated-core.c b/tests/test-federated-core.c new file mode 100644 index 00000000..656b250e --- /dev/null +++ b/tests/test-federated-core.c @@ -0,0 +1,165 @@ +/* test-federated-core.c + * + * Copyright (C) 2026 David Young + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include + +#include "gcal-federated-core.h" + +/* + * Written by CoreDesktop's configure with bash's printf %q + */ +#define CORE_CONF(domain, email, name) \ + "CORE_DOMAIN=" domain "\n" \ + "CORE_EMAIL=" email "\n" \ + "CORE_NAME=" name "\n" + +static gchar * +create_config_dir (void) +{ + g_autoptr (GError) error = NULL; + gchar *config_dir; + + config_dir = g_dir_make_tmp ("gcal-federated-core-XXXXXX", &error); + g_assert_no_error (error); + + return config_dir; +} + +static void +write_file (const gchar *config_dir, + const gchar *filename, + const gchar *contents) +{ + g_autoptr (GError) error = NULL; + g_autofree gchar *path = g_build_filename (config_dir, filename, NULL); + g_autofree gchar *dir = g_path_get_dirname (path); + + g_mkdir_with_parents (dir, 0700); + g_file_set_contents (path, contents, -1, &error); + g_assert_no_error (error); +} + +static void +remove_config_dir (const gchar *config_dir) +{ + g_autoptr (GFile) dir = g_file_new_for_path (config_dir); + g_autoptr (GFileEnumerator) children = NULL; + GFileInfo *info; + GFile *child; + + children = g_file_enumerate_children (dir, G_FILE_ATTRIBUTE_STANDARD_NAME, G_FILE_QUERY_INFO_NOFOLLOW_SYMLINKS, NULL, NULL); + + while (children && g_file_enumerator_iterate (children, &info, &child, NULL, NULL) && info) + { + if (g_file_info_get_file_type (info) == G_FILE_TYPE_DIRECTORY) + { + g_autofree gchar *path = g_file_get_path (child); + remove_config_dir (path); + } + else + { + g_file_delete (child, NULL, NULL); + } + } + + g_file_delete (dir, NULL, NULL); +} + +/*********************************************************************************************************************/ + +static void +federated_core_find_core (void) +{ + g_autofree gchar *config_dir = create_config_dir (); + g_autofree gchar *email = NULL; + g_autofree gchar *name = NULL; + + /* Nothing set up */ + g_assert_false (gcal_federated_core_find_core (config_dir, "nextcloud.example.com", NULL, NULL)); + + write_file (config_dir, "cores/example.com.conf", CORE_CONF ("example.com", "ana@example.com", "Ana\\ Lima")); + write_file (config_dir, "cores/example.org.conf", CORE_CONF ("example.org", "bo@example.org", "Bo")); + + g_assert_true (gcal_federated_core_find_core (config_dir, "nextcloud.example.com", &email, &name)); + g_assert_cmpstr (email, ==, "ana@example.com"); + g_assert_cmpstr (name, ==, "Ana Lima"); + + g_clear_pointer (&email, g_free); + g_clear_pointer (&name, g_free); + + g_assert_true (gcal_federated_core_find_core (config_dir, "NextCloud.Example.Org", &email, &name)); + g_assert_cmpstr (email, ==, "bo@example.org"); + g_assert_cmpstr (name, ==, "Bo"); + + /* Only the Core's own Nextcloud */ + g_assert_false (gcal_federated_core_find_core (config_dir, "example.com", NULL, NULL)); + g_assert_false (gcal_federated_core_find_core (config_dir, "caldav.example.com", NULL, NULL)); + g_assert_false (gcal_federated_core_find_core (config_dir, "nextcloud.example.com.evil.net", NULL, NULL)); + g_assert_false (gcal_federated_core_find_core (config_dir, NULL, NULL, NULL)); + + remove_config_dir (config_dir); +} + +static void +federated_core_single_core_conf (void) +{ + g_autofree gchar *config_dir = create_config_dir (); + g_autofree gchar *email = NULL; + g_autofree gchar *name = NULL; + + /* Before CoreDesktop supported several Cores */ + write_file (config_dir, "core.conf", CORE_CONF ("example.com", "ana@example.com", "\\'Ana\\'")); + + g_assert_true (gcal_federated_core_find_core (config_dir, "nextcloud.example.com", &email, &name)); + g_assert_cmpstr (email, ==, "ana@example.com"); + g_assert_cmpstr (name, ==, "'Ana'"); + + remove_config_dir (config_dir); +} + +static void +federated_core_incomplete_conf (void) +{ + g_autofree gchar *config_dir = create_config_dir (); + + write_file (config_dir, "cores/example.com.conf", "CORE_DOMAIN=example.com\n"); + write_file (config_dir, "cores/example.org.conf", CORE_CONF ("example.org", "not-an-email", "Bo")); + write_file (config_dir, "cores/example.net.conf.bak", CORE_CONF ("example.net", "cy@example.net", "Cy")); + + g_assert_false (gcal_federated_core_find_core (config_dir, "nextcloud.example.com", NULL, NULL)); + g_assert_false (gcal_federated_core_find_core (config_dir, "nextcloud.example.org", NULL, NULL)); + g_assert_false (gcal_federated_core_find_core (config_dir, "nextcloud.example.net", NULL, NULL)); + + remove_config_dir (config_dir); +} + +/*********************************************************************************************************************/ + +gint +main (gint argc, + gchar *argv[]) +{ + g_test_init (&argc, &argv, NULL); + + g_test_add_func ("/federated-core/find-core", federated_core_find_core); + g_test_add_func ("/federated-core/single-core-conf", federated_core_single_core_conf); + g_test_add_func ("/federated-core/incomplete-conf", federated_core_incomplete_conf); + + return g_test_run (); +} diff --git a/tests/test-utils.c b/tests/test-utils.c index 947189b6..aa128b64 100644 --- a/tests/test-utils.c +++ b/tests/test-utils.c @@ -233,6 +233,52 @@ test_first_weekday (void) /*********************************************************************************************************************/ +static void +parse_email_address (void) +{ + struct { + const gchar *text; + const gchar *email; + const gchar *name; + } valid[] = { + { "ana@example.com", "ana@example.com", NULL }, + { " ana@example.com ", "ana@example.com", NULL }, + { "mailto:ana@example.com", "ana@example.com", NULL }, + { "Ana Lima ", "ana@example.com", "Ana Lima" }, + { "\"Lima, Ana\" ", "ana@example.com", "Lima, Ana" }, + { "", "ana@example.com", NULL }, + { "ana.lima+work@mail.example.co.uk", "ana.lima+work@mail.example.co.uk", NULL }, + }; + const gchar *invalid[] = { + NULL, + "", + "ana", + "ana@", + "@example.com", + "ana@example", + "ana@example.", + "ana@.example.com", + "ana@@example.com", + "ana lima@example.com", + "Ana Lima ", + }; + + for (gsize i = 0; i < G_N_ELEMENTS (valid); i++) + { + g_autofree gchar *email = NULL; + g_autofree gchar *name = NULL; + + g_assert_true (gcal_util_parse_email_address (valid[i].text, &email, &name)); + g_assert_cmpstr (email, ==, valid[i].email); + g_assert_cmpstr (name, ==, valid[i].name); + } + + for (gsize i = 0; i < G_N_ELEMENTS (invalid); i++) + g_assert_false (gcal_util_parse_email_address (invalid[i], NULL, NULL)); +} + +/*********************************************************************************************************************/ + gint main (gint argc, gchar *argv[]) @@ -245,6 +291,7 @@ main (gint argc, g_test_add_func ("/utils/date-time/date_time_from_icaltime", date_time_from_icaltime); g_test_add_func ("/utils/misc/extract_meeting_url", extract_meeting_url); g_test_add_func ("/utils/misc/get_first_weekday", test_first_weekday); + g_test_add_func ("/utils/misc/parse_email_address", parse_email_address); return g_test_run (); }