Invite people to events on Federated Core calendars

This commit is contained in:
2026-10-29 08:18:52 -06:00
parent eeea79bb9f
commit 8d9eb034c7
24 changed files with 1324 additions and 19 deletions
+1
View File
@@ -10,6 +10,7 @@
"finish-args" : [ "finish-args" : [
"--env=G_DEBUG=fatal-criticals", "--env=G_DEBUG=fatal-criticals",
"--device=dri", "--device=dri",
"--filesystem=xdg-config/coredesktop:ro",
"--share=ipc", "--share=ipc",
"--share=network", "--share=network",
"--socket=fallback-x11", "--socket=fallback-x11",
+4
View File
@@ -96,6 +96,7 @@ assert(cc.has_function('strerror'), '"strerror" not found')
# options # options
config_h.set('ENABLE_TRACING', enable_tracing) 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) config_h.set('HANDY_USE_UNSTABLE_API', true)
# _NL_TIME_FIRST_WEEKDAY is an enum and not a define # _NL_TIME_FIRST_WEEKDAY is an enum and not a define
@@ -210,6 +211,9 @@ summary({
'Source': meson.global_source_root(), 'Source': meson.global_source_root(),
'Prefix': calendar_prefix, 'Prefix': calendar_prefix,
}) })
summary({
'Federated Core': get_option('federated_core'),
}, section: 'Features')
summary({ summary({
'Debug': calendar_debug, 'Debug': calendar_debug,
'Tracing': enable_tracing, 'Tracing': enable_tracing,
+1
View File
@@ -1,2 +1,3 @@
option('tracing', type: 'boolean', value: false, description: 'add extra debugging information') option('tracing', type: 'boolean', value: false, description: 'add extra debugging information')
option('profile', type: 'combo', choices: ['default', 'development'], value: 'default') 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')
+247
View File
@@ -2023,3 +2023,250 @@ gcal_event_get_organizer (GcalEvent *self)
return self->organizer; 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;
}
+20
View File
@@ -142,6 +142,26 @@ GListModel * gcal_event_get_attendees (GcalEvent
GcalEventOrganizer * gcal_event_get_organizer (GcalEvent *self); 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, gboolean gcal_event_alarms_equal (GcalEvent *event1,
GcalEvent *event2); GcalEvent *event2);
+255
View File
@@ -0,0 +1,255 @@
/* gcal-federated-core.c
*
* Copyright (C) 2026 David Young <david@federated.computer>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#define G_LOG_DOMAIN "GcalFederatedCore"
#include "config.h"
#include "gcal-federated-core.h"
#include <libedataserver/libedataserver.h>
#include <string.h>
/**
* 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/<domain>.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
}
+34
View File
@@ -0,0 +1,34 @@
/* gcal-federated-core.h
*
* Copyright (C) 2026 David Young <david@federated.computer>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#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
+5
View File
@@ -0,0 +1,5 @@
calendar_incs += include_directories('.')
sources += files(
'gcal-federated-core.c',
)
@@ -54,6 +54,8 @@ struct _GcalAttendeeDetailsPage
GtkWidget *group_not_responded; GtkWidget *group_not_responded;
GcalEventAttendeeTypeFilterFlags attendee_type_filter_flags; GcalEventAttendeeTypeFilterFlags attendee_type_filter_flags;
gboolean removable;
}; };
enum enum
@@ -64,6 +66,7 @@ enum
PROP_MODEL_DELEGATED, PROP_MODEL_DELEGATED,
PROP_MODEL_NOT_RESPONDED, PROP_MODEL_NOT_RESPONDED,
PROP_ATTENDEE_TYPE, PROP_ATTENDEE_TYPE,
PROP_REMOVABLE,
N_PROPS N_PROPS
}; };
@@ -83,7 +86,11 @@ static GtkWidget *
create_attendee_row_widget (void *attendee, create_attendee_row_widget (void *attendee,
void *user_data) 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 static void
@@ -237,6 +244,9 @@ gcal_attendee_details_page_get_property (GObject *object,
case PROP_ATTENDEE_TYPE: case PROP_ATTENDEE_TYPE:
g_value_set_flags (value, self->attendee_type_filter_flags); g_value_set_flags (value, self->attendee_type_filter_flags);
break; break;
case PROP_REMOVABLE:
g_value_set_boolean (value, self->removable);
break;
default: default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); 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: case PROP_ATTENDEE_TYPE:
set_type_filter_flags (self, g_value_get_flags (value)); set_type_filter_flags (self, g_value_get_flags (value));
break; break;
case PROP_REMOVABLE:
gcal_attendee_details_page_set_removable (self, g_value_get_boolean (value));
break;
default: default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); 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), adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_accepted),
G_LIST_MODEL (instance->filter_list_accepted), G_LIST_MODEL (instance->filter_list_accepted),
create_attendee_row_widget, create_attendee_row_widget,
NULL, NULL); instance, NULL);
adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_tentative), adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_tentative),
G_LIST_MODEL (instance->filter_list_tentative), G_LIST_MODEL (instance->filter_list_tentative),
create_attendee_row_widget, create_attendee_row_widget,
NULL, NULL); instance, NULL);
adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_declined), adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_declined),
G_LIST_MODEL (instance->filter_list_declined), G_LIST_MODEL (instance->filter_list_declined),
create_attendee_row_widget, create_attendee_row_widget,
NULL, NULL); instance, NULL);
adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_delegated), adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_delegated),
G_LIST_MODEL (instance->filter_list_delegated), G_LIST_MODEL (instance->filter_list_delegated),
create_attendee_row_widget, create_attendee_row_widget,
NULL, NULL); instance, NULL);
adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_not_responded), adw_preferences_group_bind_model (ADW_PREFERENCES_GROUP (instance->group_not_responded),
G_LIST_MODEL (instance->filter_list_not_responded), G_LIST_MODEL (instance->filter_list_not_responded),
create_attendee_row_widget, create_attendee_row_widget,
NULL, NULL); instance, NULL);
} }
static void static void
@@ -370,6 +383,16 @@ gcal_attendee_details_page_class_init (GcalAttendeeDetailsPageClass *klass)
GCAL_EVENT_ATTENDEE_TYPE_FILTER_NONE, GCAL_EVENT_ATTENDEE_TYPE_FILTER_NONE,
G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS); 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); g_object_class_install_properties (object_class, N_PROPS, properties);
/* widget */ /* widget */
@@ -420,3 +443,24 @@ gcal_attendee_details_page_set_type_filter (GcalAttendeeDetailsPage *sel
g_assert (GCAL_IS_ATTENDEE_DETAILS_PAGE (self)); g_assert (GCAL_IS_ATTENDEE_DETAILS_PAGE (self));
set_type_filter_flags (self, flags); 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]);
}
@@ -34,6 +34,9 @@ void gcal_attendee_details_page_set_attendees (GcalAtte
void gcal_attendee_details_page_set_type_filter (GcalAttendeeDetailsPage *self, void gcal_attendee_details_page_set_type_filter (GcalAttendeeDetailsPage *self,
GcalEventAttendeeTypeFilterFlags flags); GcalEventAttendeeTypeFilterFlags flags);
void gcal_attendee_details_page_set_removable (GcalAttendeeDetailsPage *self,
gboolean removable);
G_END_DECLS G_END_DECLS
#endif #endif
@@ -12,6 +12,18 @@ template $GcalAttendeeRow: Adw.ActionRow {
"dimmed", "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 { PopoverMenu context_menu {
+28
View File
@@ -34,10 +34,13 @@ struct _GcalAttendeeRow
GcalEventAttendee *attendee; GcalEventAttendee *attendee;
GtkWidget *role; GtkWidget *role;
GtkWidget *remove_button;
GtkWidget *context_menu; GtkWidget *context_menu;
GtkGestureClick *gesture_click; GtkGestureClick *gesture_click;
GSimpleActionGroup *action_group; GSimpleActionGroup *action_group;
gboolean removable;
}; };
G_DEFINE_FINAL_TYPE (GcalAttendeeRow, gcal_attendee_row, ADW_TYPE_ACTION_ROW); G_DEFINE_FINAL_TYPE (GcalAttendeeRow, gcal_attendee_row, ADW_TYPE_ACTION_ROW);
@@ -46,6 +49,7 @@ enum
{ {
PROP_0, PROP_0,
PROP_ATTENDEE, PROP_ATTENDEE,
PROP_REMOVABLE,
N_PROPS, N_PROPS,
}; };
@@ -164,6 +168,16 @@ gcal_attendee_row_set_property (GObject *object,
g_assert (self->attendee == NULL); g_assert (self->attendee == NULL);
self->attendee = g_value_get_object (value); self->attendee = g_value_get_object (value);
set_up_row_content (self); 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; break;
default: default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec);
@@ -183,6 +197,9 @@ gcal_attendee_row_get_property (GObject *object,
case PROP_ATTENDEE: case PROP_ATTENDEE:
g_value_set_object (value, self->attendee); g_value_set_object (value, self->attendee);
break; break;
case PROP_REMOVABLE:
g_value_set_boolean (value, self->removable);
break;
default: default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); 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, GCAL_TYPE_EVENT_ATTENDEE,
G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS); 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); 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_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, context_menu);
gtk_widget_class_bind_template_child (widget_class, GcalAttendeeRow, gesture_click); 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); gtk_widget_class_bind_template_child (widget_class, GcalAttendeeRow, role);
} }
@@ -11,6 +11,16 @@ template $GcalAttendeesSection: $GcalEventEditorSection {
filter-flags: GCAL_EVENT_ATTENDEE_TYPE_FILTER_PERSON; 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 { $GcalAttendeeSummaryRow {
title: _("Resources"); title: _("Resources");
attendees: bind template.attendees; attendees: bind template.attendees;
+167 -9
View File
@@ -27,17 +27,25 @@
#include "gcal-event-organizer.h" #include "gcal-event-organizer.h"
#include "gcal-event.h" #include "gcal-event.h"
#include "gcal-organizer-row.h" #include "gcal-organizer-row.h"
#include "gcal-utils.h"
#include "glib-object.h" #include "glib-object.h"
struct _GcalAttendeesSection struct _GcalAttendeesSection
{ {
GcalEventEditorSection parent_instance; GcalEventEditorSection parent_instance;
GcalEvent *event;
GcalEventOrganizer *organizer; GcalEventOrganizer *organizer;
GtkWidget *invite_row;
GtkWidget *organizer_row;
AdwActionRow *summary_row; AdwActionRow *summary_row;
GListModel *attendees; 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) G_DEFINE_FINAL_TYPE (GcalAttendeesSection, gcal_attendees_section, GCAL_TYPE_EVENT_EDITOR_SECTION)
@@ -46,6 +54,7 @@ enum
{ {
PROP_0, PROP_0,
PROP_ATTENDEES, PROP_ATTENDEES,
PROP_CAN_INVITE,
PROP_ORGANIZER, PROP_ORGANIZER,
N_PROPS N_PROPS
}; };
@@ -53,6 +62,97 @@ enum
static GParamSpec *properties[N_PROPS] = { NULL, }; 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 * GcalEventEditorSection overrides
*/ */
@@ -63,19 +163,23 @@ gcal_attendees_section_event_set_cb (GcalEventEditorSection *section,
{ {
GcalAttendeesSection *self = GCAL_ATTENDEES_SECTION (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) if (event)
{ {
self->organizer = gcal_event_get_organizer (event); g_signal_connect_object (gcal_event_get_attendees (event),
self->attendees = gcal_event_get_attendees (event); "items-changed",
} G_CALLBACK (on_attendees_changed_cb),
else self,
{ G_CONNECT_SWAPPED);
self->organizer = NULL;
self->attendees = NULL;
} }
g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ATTENDEES]); update_from_event (self);
g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ORGANIZER]);
} }
@@ -97,6 +201,10 @@ gcal_attendees_section_get_property (GObject *object,
g_value_set_object (value, self->attendees); g_value_set_object (value, self->attendees);
break; break;
case PROP_CAN_INVITE:
g_value_set_boolean (value, self->inviter_email != NULL);
break;
case PROP_ORGANIZER: case PROP_ORGANIZER:
g_value_set_object (value, self->organizer); g_value_set_object (value, self->organizer);
break; break;
@@ -132,6 +240,13 @@ gcal_attendees_section_finalize (GObject *object)
self->attendees = NULL; /* not owned */ 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); 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_TYPE_LIST_MODEL,
G_PARAM_READABLE | G_PARAM_STATIC_STRINGS); 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: * 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_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_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]);
} }
@@ -27,4 +27,8 @@ G_BEGIN_DECLS
#define GCAL_TYPE_ATTENDEES_SECTION (gcal_attendees_section_get_type ()) #define GCAL_TYPE_ATTENDEES_SECTION (gcal_attendees_section_get_type ())
G_DECLARE_FINAL_TYPE (GcalAttendeesSection, gcal_attendees_section, GCAL, ATTENDEES_SECTION, GcalEventEditorSection) 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 G_END_DECLS
@@ -28,6 +28,7 @@
#include "gcal-event-editor-dialog.h" #include "gcal-event-editor-dialog.h"
#include "gcal-event-editor-section.h" #include "gcal-event-editor-section.h"
#include "gcal-event.h" #include "gcal-event.h"
#include "gcal-federated-core.h"
#include "gcal-notes-section.h" #include "gcal-notes-section.h"
#include "gcal-reminders-section.h" #include "gcal-reminders-section.h"
#include "gcal-schedule-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]); 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 static void
set_event (GcalEventEditorDialog *self, set_event (GcalEventEditorDialog *self,
GcalEvent *event, GcalEvent *event,
@@ -165,8 +208,7 @@ set_event (GcalEventEditorDialog *self,
gcal_calendar_combo_row_set_calendar (self->calendar_combo_row, calendar); gcal_calendar_combo_row_set_calendar (self->calendar_combo_row, calendar);
set_writable (self, !gcal_calendar_is_read_only (calendar)); set_writable (self, !gcal_calendar_is_read_only (calendar));
gboolean has_participants = gcal_event_get_organizer (self->edited_event) != NULL; update_invite_state (self);
gtk_widget_set_visible (GTK_WIDGET (self->attendees_section), has_participants);
gtk_widget_set_visible (self->cancel_button, self->writable); gtk_widget_set_visible (self->cancel_button, self->writable);
@@ -235,6 +277,9 @@ apply_event_properties_to_template_event (GcalEvent *template_event,
if (alarm) if (alarm)
gcal_event_add_alarm (template_event, 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 static void
@@ -275,7 +320,7 @@ save_event (GcalEventEditorDialog *self,
GcalContext *context; GcalContext *context;
GcalManager *manager; 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; GCAL_ENTRY;
@@ -284,11 +329,20 @@ save_event (GcalEventEditorDialog *self,
selected_calendar = gcal_calendar_combo_row_get_calendar (self->calendar_combo_row); 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; calendar_equal = selected_calendar && calendar == selected_calendar;
alarms_equal = gcal_event_alarms_equal (self->event, self->edited_event); alarms_equal = gcal_event_alarms_equal (self->event, self->edited_event);
schedule_equal = gcal_event_schedule_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); 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) 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)); 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 static void
on_cancel_button_clicked_cb (GtkButton *button, on_cancel_button_clicked_cb (GtkButton *button,
GcalEventEditorDialog *self) 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.save", NULL, on_event_editor_save_action_activated_cb);
gtk_widget_class_install_action (widget_class, "event-editor.show-attendees-detail-page", "u", gtk_widget_class_install_action (widget_class, "event-editor.show-attendees-detail-page", "u",
on_show_attendees_detail_page_action_activated_cb); 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 static void
@@ -648,6 +723,7 @@ gcal_event_editor_dialog_init (GcalEventEditorDialog *self)
set_up_context (self); set_up_context (self);
g_signal_connect_swapped (self, "validation-requested", G_CALLBACK (on_validation_requested_cb), 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);
} }
/** /**
@@ -89,6 +89,10 @@ generate_subtitle (GcalOrganizerRow *self,
return NULL; return NULL;
email = gcal_util_get_email_from_mailto_uri (gcal_event_organizer_get_uri (organizer)); 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); name_with_email = g_strdup_printf ("%s (%s)", gcal_event_organizer_get_name (organizer), email);
return g_strdup (name_with_email); return g_strdup (name_with_email);
+1
View File
@@ -75,6 +75,7 @@ sources = files()
subdir('appdata') subdir('appdata')
subdir('contrib') subdir('contrib')
subdir('core') subdir('core')
subdir('federated')
subdir('gui') subdir('gui')
subdir('search') subdir('search')
subdir('theme') subdir('theme')
+87
View File
@@ -1081,6 +1081,93 @@ gcal_util_get_email_from_mailto_uri (const gchar *mailto_uri)
return address; 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 <ana@example.com>"
* @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" <ana@example.com> */
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: * gcal_create_activate_signal_shortcuts:
* @widget_class: the class to add the bindings to * @widget_class: the class to add the bindings to
+4
View File
@@ -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); 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, guint gcal_util_create_activate_signal_and_shortcuts (GtkWidgetClass *widget_class,
GType widget_type); GType widget_type);
#endif /* __GCAL_UTILS_H__ */ #endif /* __GCAL_UTILS_H__ */
+1
View File
@@ -43,6 +43,7 @@ tests = [
'server', 'server',
'utils', 'utils',
'event-attendee', 'event-attendee',
'federated-core',
] ]
foreach test : tests foreach test : tests
+94
View File
@@ -17,6 +17,7 @@
*/ */
#include <glib.h> #include <glib.h>
#include <string.h>
#include "gcal-event-attendee.h" #include "gcal-event-attendee.h"
#include "gcal-event-organizer.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 * static GDateTime *
dt_add_second_random (GDateTime *dt) 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/create-tzid", event_date_create_tzid);
g_test_add_func ("/event/date/check-tz", event_date_check_tz); 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/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); g_test_add_func ("/event/equal/schedule", event_schedule_equal);
return g_test_run (); return g_test_run ();
+165
View File
@@ -0,0 +1,165 @@
/* test-federated-core.c
*
* Copyright (C) 2026 David Young <david@federated.computer>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#include <glib.h>
#include <glib/gstdio.h>
#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 ();
}
+47
View File
@@ -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@example.com", "Ana Lima" },
{ "\"Lima, Ana\" <ana@example.com>", "ana@example.com", "Lima, Ana" },
{ "<ana@example.com>", "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 <ana>",
};
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 gint
main (gint argc, main (gint argc,
gchar *argv[]) 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/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/extract_meeting_url", extract_meeting_url);
g_test_add_func ("/utils/misc/get_first_weekday", test_first_weekday); 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 (); return g_test_run ();
} }