summaryrefslogtreecommitdiff
path: root/src/core
diff options
context:
space:
mode:
authorNiels De Graef <nielsdegraef@gmail.com>2022-08-16 12:36:08 +0200
committerNiels De Graef <nielsdegraef@gmail.com>2022-09-03 08:50:19 +0200
commit1d44c11483e3d00611c0beed9afc8f2c9facc3a8 (patch)
tree6a38fecd4acca16fb8c6d1b4322b2e10fbe0d5ad /src/core
parent4d0710b8a6c7a3cd2ee0311b62f5e2707c34c305 (diff)
downloadgnome-contacts-1d44c11483e3d00611c0beed9afc8f2c9facc3a8.tar.gz
Introduce the concept of Contacts.Chunk
This commit introduces a new class `Contacts.Chunk`. Just like libfolks, we see a contact as a collection of data, or to word it differently: a collection built up from "chunks" of information. The net result of adding this concept adds quite a bit of lines of code, but it does have some major benefits: * Rather than stuffing new properties into yet another if-else spread out over multiple places in contacts-utils (and quite a bit of other files), we can create a new subclass of `Contacts.Chunk` * This also goes for property-specific logic, which we can consolidate within their appropriate classes/files. * All of our logic is now unit-testable In the future, this would allow for more cleanups/features: * We can put the serialization code for each property inside the `Contacts.Chunk` * We can extend ContactSheet to show a vCard's information, before actually importing it into a Folks.Individual. * We can write unit tests on the set of chunks, rather than regularly having to deal with yet another regression in e.g. the birthday editor.
Diffstat (limited to 'src/core')
-rw-r--r--src/core/contacts-addresses-chunk.vala138
-rw-r--r--src/core/contacts-alias-chunk.vala57
-rw-r--r--src/core/contacts-avatar-chunk.vala53
-rw-r--r--src/core/contacts-bin-chunk.vala171
-rw-r--r--src/core/contacts-birthday-chunk.vala62
-rw-r--r--src/core/contacts-chunk.vala63
-rw-r--r--src/core/contacts-contact.vala303
-rw-r--r--src/core/contacts-email-addresses-chunk.vala93
-rw-r--r--src/core/contacts-full-name-chunk.vala61
-rw-r--r--src/core/contacts-im-addresses-chunk.vala99
-rw-r--r--src/core/contacts-nickname-chunk.vala60
-rw-r--r--src/core/contacts-notes-chunk.vala85
-rw-r--r--src/core/contacts-phones-chunk.vala97
-rw-r--r--src/core/contacts-roles-chunk.vala94
-rw-r--r--src/core/contacts-structured-name-chunk.vala69
-rw-r--r--src/core/contacts-urls-chunk.vala95
16 files changed, 1600 insertions, 0 deletions
diff --git a/src/core/contacts-addresses-chunk.vala b/src/core/contacts-addresses-chunk.vala
new file mode 100644
index 0000000..c322e91
--- /dev/null
+++ b/src/core/contacts-addresses-chunk.vala
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the postal addresses of a contact (similar
+ * to {@link Folks.PostalAddressDetails}}. Each element is a {@link Address}.
+ */
+public class Contacts.AddressesChunk : BinChunk {
+
+ public override string property_name { get { return "postal-addresses"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is PostalAddressDetails);
+ unowned var postal_address_details = (PostalAddressDetails) persona;
+
+ foreach (var address_field in postal_address_details.postal_addresses) {
+ var address = new Address.from_field_details (address_field);
+ add_child (address);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Address ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is PostalAddressDetails) {
+ var afds = (Gee.Set<PostalAddressFieldDetails>) get_abstract_field_details ();
+ yield ((PostalAddressDetails) this.persona).change_postal_addresses (afds);
+ }
+}
+
+public class Contacts.Address : BinChunkChild {
+
+ public PostalAddress address {
+ get { return this._address; }
+ set {
+ if (this._address.equal (value))
+ return;
+
+ bool was_empty = this._address.is_empty ();
+ this._address = value;
+ notify_property ("address");
+ if (was_empty != value.is_empty ())
+ notify_property ("is-empty");
+ }
+ }
+ private PostalAddress _address = new PostalAddress ("", "", "", "", "", "", "", "", "");
+
+ public override bool is_empty {
+ get { return this.address.is_empty (); }
+ }
+
+ public override string icon_name {
+ get { return "mark-location-symbolic"; }
+ }
+
+ public Address () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "HOME";
+ }
+
+ public Address.from_field_details (PostalAddressFieldDetails address_field) {
+ this.address = address_field.value;
+ this.parameters = address_field.parameters;
+ }
+
+ /**
+ * Returns the TypeDescriptor that describes the type of this address
+ * (for example home, work, ...)
+ */
+ public TypeDescriptor get_address_type () {
+ return TypeSet.general.lookup_by_parameters (this.parameters);
+ }
+
+ /**
+ * Returns the address as a single string, with the several parts of
+ * the address joined together with @parts_separator.
+ */
+ public string to_string (string parts_separator) {
+ string[] lines = {};
+
+ if (this.address.street != "")
+ lines += this.address.street;
+ if (this.address.extension != "")
+ lines += this.address.extension;
+ if (this.address.locality != "")
+ lines += this.address.locality;
+ if (this.address.region != "")
+ lines += this.address.region;
+ if (this.address.postal_code != "")
+ lines += this.address.postal_code;
+ if (this.address.po_box != "")
+ lines += this.address.po_box;
+ if (this.address.country != "")
+ lines += this.address.country;
+ if (this.address.address_format != "")
+ lines += this.address.address_format;
+
+ return string.joinv (parts_separator, lines);
+ }
+
+ /**
+ * Returns the address as a "maps:q=..." URI, which can then be used
+ * by supported apps to open up the specified location.
+ */
+ public string to_maps_uri () {
+ var address_parts = to_string (" ");
+ return "maps:q=%s".printf (GLib.Uri.escape_string (address_parts));
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new PostalAddressFieldDetails (this.address, this.parameters);
+ }
+}
diff --git a/src/core/contacts-alias-chunk.vala b/src/core/contacts-alias-chunk.vala
new file mode 100644
index 0000000..921e9cf
--- /dev/null
+++ b/src/core/contacts-alias-chunk.vala
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+public class Contacts.AliasChunk : Chunk {
+
+ public string alias {
+ get { return this._alias; }
+ set {
+ if (this._alias == value)
+ return;
+
+ bool was_empty = this.is_empty;
+ this._alias = value;
+ notify_property ("alias");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private string _alias = "";
+
+ public override string property_name { get { return "alias"; } }
+
+ public override bool is_empty { get { return this._alias.strip () == ""; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is AliasDetails);
+ persona.bind_property ("alias", this, "alias", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.alias;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is AliasDetails) {
+
+ yield ((AliasDetails) this.persona).change_alias (this.alias);
+ }
+}
diff --git a/src/core/contacts-avatar-chunk.vala b/src/core/contacts-avatar-chunk.vala
new file mode 100644
index 0000000..56dbce2
--- /dev/null
+++ b/src/core/contacts-avatar-chunk.vala
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+public class Contacts.AvatarChunk : Chunk {
+
+ public LoadableIcon? avatar {
+ get { return this._avatar; }
+ set {
+ if (this._avatar == value)
+ return;
+ this._avatar = value;
+ notify_property ("avatar");
+ notify_property ("is-empty");
+ }
+ }
+ private LoadableIcon? _avatar = null;
+
+ public override string property_name { get { return "avatar"; } }
+
+ public override bool is_empty { get { return this._avatar == null; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is AvatarDetails);
+ persona.bind_property ("avatar", this, "avatar", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this._avatar;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is AvatarDetails) {
+ yield ((AvatarDetails) this.persona).change_avatar (this.avatar);
+ }
+}
diff --git a/src/core/contacts-bin-chunk.vala b/src/core/contacts-bin-chunk.vala
new file mode 100644
index 0000000..3229ddd
--- /dev/null
+++ b/src/core/contacts-bin-chunk.vala
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that aggregates multiple values associated to a property
+ * (for example, a chunk for phone numbers, or email addresses). These values
+ * are represented as {@link BinChunkChild}ren, which BinChunk exposes through
+ * the {@link GLib.ListModel} interface.
+ *
+ * One important property of BinkChunk is that it makes sure at least one empty
+ * child exists. This allows us to expose an immutable interface, while being
+ * able to synchronize with our UI (which expects this kind of behavior)
+ */
+public abstract class Contacts.BinChunk : Chunk, GLib.ListModel {
+
+ private GenericArray<BinChunkChild> elements = new GenericArray<BinChunkChild> ();
+
+ public override bool is_empty {
+ get {
+ if (this.elements.length == 0)
+ return true;
+ foreach (var chunk_element in this.elements) {
+ if (!chunk_element.is_empty)
+ return false;
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Should be called by subclasses when they add a child.
+ * It will make sure to attach the emptines check is appropriately applied.
+ */
+ protected void add_child (BinChunkChild child) {
+ if (child.is_empty && has_empty_child ())
+ return;
+
+ child.notify["is-empty"].connect ((obj, pspec) => {
+ debug ("Child 'is-empty' changed, doing emptiness check");
+ emptiness_check ();
+ });
+ this.elements.add (child);
+ items_changed (this.elements.length - 1, 0, 1);
+ }
+
+ /**
+ * Subclasses should implement this to create an empty child (which will be
+ * used for the emptiness check).
+ */
+ protected abstract BinChunkChild create_empty_child ();
+
+ // A method to check if we have at least one empty row
+ // if we don't, it adds an empty child
+ protected void emptiness_check () {
+ if (has_empty_child ())
+ return;
+
+ // We only have non-empty rows, add one
+ var child = create_empty_child ();
+ add_child (child);
+ }
+
+ private bool has_empty_child () {
+ for (uint i = 0; i < this.elements.length; i++) {
+ if (this.elements[i].is_empty)
+ return true;
+ }
+ return false;
+ }
+
+ public override Value? to_value () {
+ var afds = new Gee.HashSet<AbstractFieldDetails> ();
+ for (uint i = 0; i < this.elements.length; i++) {
+ var afd = this.elements[i].create_afd ();
+ if (afd != null)
+ afds.add (afd);
+ }
+ return (afds.size != 0)? afds : null;
+ }
+
+ /** A helper function to collect the AbstractFieldDetails of the children */
+ protected Gee.Set<AbstractFieldDetails> get_abstract_field_details ()
+ requires (this.persona != null) {
+ var afds = new Gee.HashSet<AbstractFieldDetails> ();
+ for (uint i = 0; i < this.elements.length; i++) {
+ var afd = this.elements[i].create_afd ();
+ if (afd != null)
+ afds.add (afd);
+ }
+
+ return afds;
+ }
+
+ // ListModel implementation
+
+ public uint n_items { get { return this.elements.length; } }
+
+ public GLib.Type item_type { get { return typeof (BinChunkChild); } }
+
+ public Object? get_item (uint i) {
+ if (i > this.elements.length)
+ return null;
+ return (Object) this.elements[i];
+ }
+
+ public uint get_n_items () {
+ return this.elements.length;
+ }
+
+ public GLib.Type get_item_type () {
+ return typeof (BinChunkChild);
+ }
+}
+
+/**
+ * A child of a {@link BinChunk}
+ */
+public abstract class Contacts.BinChunkChild : GLib.Object {
+
+ public Gee.MultiMap<string, string> parameters { get; set; }
+
+ /**
+ * Whether this BinChunkChild is empty. You can use the notify signal to
+ * listen for changes.
+ */
+ public abstract bool is_empty { get; }
+
+ /**
+ * The icon name that best represents this BinChunkChild
+ */
+ public abstract string icon_name { get; }
+
+ /**
+ * Creates an AbstractFieldDetails from the contents of this child
+ *
+ * If the contents are invalid (or empty), it returns null.
+ */
+ public abstract AbstractFieldDetails? create_afd ();
+
+ // A helper to change a string field with the proper propery notifies
+ protected void change_string_prop (string prop_name,
+ ref string old_value,
+ string new_value) {
+ if (new_value == old_value)
+ return;
+
+ bool notify_empty = ((new_value.strip () == "") != (old_value.strip () == ""));
+ // Don't strip value when setting the old one, since we don't want to
+ // prevent users from entering a space or a newline :D
+ old_value = new_value;
+ notify_property (prop_name);
+ if (notify_empty)
+ notify_property ("is-empty");
+ }
+}
diff --git a/src/core/contacts-birthday-chunk.vala b/src/core/contacts-birthday-chunk.vala
new file mode 100644
index 0000000..087da6a
--- /dev/null
+++ b/src/core/contacts-birthday-chunk.vala
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the birthday of a contact (similar to
+ * {@link Folks.BirthdayDetails}}.
+ */
+public class Contacts.BirthdayChunk : Chunk {
+
+ public DateTime? birthday {
+ get { return this._birthday; }
+ set {
+ if (this._birthday == null && value == null)
+ return;
+
+ if (this._birthday != null && value != null
+ && this._birthday.equal (value.to_utc ()))
+ return;
+
+ this._birthday = (value != null)? value.to_utc () : null;
+ notify_property ("birthday");
+ notify_property ("is-empty");
+ }
+ }
+ private DateTime? _birthday = null;
+
+ public override string property_name { get { return "birthday"; } }
+
+ public override bool is_empty { get { return this.birthday == null; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is BirthdayDetails);
+ persona.bind_property ("birthday", this, "birthday", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.birthday;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is BirthdayDetails) {
+ yield ((BirthdayDetails) this.persona).change_birthday (this.birthday);
+ }
+}
diff --git a/src/core/contacts-chunk.vala b/src/core/contacts-chunk.vala
new file mode 100644
index 0000000..998ad69
--- /dev/null
+++ b/src/core/contacts-chunk.vala
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A "chunk" is a piece of data that describes a specific property of a
+ * {@link Contact}. Each chunk usually maps to a specific vCard property, or an
+ * interface related to a property of a {@link Folks.Persona}.
+ */
+public abstract class Contacts.Chunk : GLib.Object {
+
+ /** The associated persona (or null if we're creating a new one) */
+ public Persona? persona { get; construct set; default = null; }
+
+ /**
+ * The specific property of this chunk.
+ *
+ * Note that this should match with the string representation of a
+ * {@link Folks.PersonaDetail}.
+ */
+ public abstract string property_name { get; }
+
+ /**
+ * Whether this is empty. As an example, you can use to changes in this
+ * property to update any UI.
+ */
+ public abstract bool is_empty { get; }
+
+ /**
+ * A separate field to keep track of whether something has changed.
+ * If it did, we know we'll have to (possibly) save the changes.
+ */
+ public bool changed { get; protected set; default = false; }
+
+ /**
+ * Converts this chunk into a GLib.Value, as expected by API like
+ * {@link Folks.PersonaStore.add_persona_from_details}
+ *
+ * If the field is empty or non-existent, it should return null.
+ */
+ public abstract Value? to_value ();
+
+ /**
+ * Calls the appropriate API to save to the persona.
+ */
+ public abstract async void save_to_persona () throws GLib.Error
+ requires (this.persona != null);
+}
diff --git a/src/core/contacts-contact.vala b/src/core/contacts-contact.vala
new file mode 100644
index 0000000..889284f
--- /dev/null
+++ b/src/core/contacts-contact.vala
@@ -0,0 +1,303 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A Contact is an object that represents a data model around a set of
+ * contact properties. This can either come from a {@link Folks.Individual}, an
+ * empty set (when creating contacts) or a different data source (like a vCard).
+ *
+ * Since the classes Folks provides assume valid data, we can't/shouldn't
+ * really use them (for example, a PostalAddresFieldDetails does not allow
+ * empty addresses), so that is another easy use for this separate class.
+ */
+public class Contacts.Contact : GLib.Object, GLib.ListModel {
+
+ private GenericArray<Chunk> chunks = new GenericArray<Chunk> ();
+
+ /** The underlying individual, if any */
+ public unowned Individual? individual { get; construct set; default = null; }
+
+ public unowned Store contacts_store { get; construct set; }
+
+ /** Similar to fetch_display_name(), but never returns null */
+ public string display_name {
+ owned get { return fetch_display_name () ?? _("Unnamed Person"); }
+ }
+
+ construct {
+ if (this.individual != null) {
+ this.individual.personas_changed.connect (on_individual_personas_changed);
+ on_individual_personas_changed (this.individual,
+ this.individual.personas,
+ Gee.Set.empty<Persona> ());
+ } else {
+ // At the very least let's add an empty full-name chunk
+ create_chunk ("full-name", null);
+ }
+ }
+
+ /** Creates a Contact that acts as a wrapper around an Individual */
+ public Contact.for_individual (Individual individual, Store contacts_store) {
+ Object (individual: individual, contacts_store: contacts_store);
+ }
+
+ /** Creates a new empty contact */
+ public Contact.for_new (Store contacts_store) {
+ Object (individual: null, contacts_store: contacts_store);
+ }
+
+ private void on_individual_personas_changed (Individual individual,
+ Gee.Set<Persona> added,
+ Gee.Set<Persona> removed) {
+ uint old_size = this.chunks.length;
+ foreach (var persona in added)
+ add_persona (persona);
+ items_changed (old_size - 1, 0, this.chunks.length - old_size);
+
+ foreach (var persona in removed) {
+ for (uint i = 0; i < this.chunks.length; i++) {
+ if (this.chunks[i].persona == persona) {
+ this.chunks.remove_index (i);
+ items_changed (i, 1, 0);
+ i--;
+ }
+ }
+ }
+ }
+
+ private void add_persona (Persona persona) {
+ if (persona is AliasDetails)
+ create_chunk_internal ("alias", persona);
+ if (persona is AvatarDetails)
+ create_chunk_internal ("avatar", persona);
+ if (persona is BirthdayDetails)
+ create_chunk_internal ("birthday", persona);
+ if (persona is EmailDetails)
+ create_chunk_internal ("email-addresses", persona);
+ if (persona is ImDetails)
+ create_chunk_internal ("im-addresses", persona);
+ if (persona is NameDetails) {
+ create_chunk_internal ("full-name", persona);
+ create_chunk_internal ("structured-name", persona);
+ create_chunk_internal ("nickname", persona);
+ }
+ if (persona is NoteDetails)
+ create_chunk_internal ("notes", persona);
+ if (persona is PhoneDetails)
+ create_chunk_internal ("phone-numbers", persona);
+ if (persona is PostalAddressDetails)
+ create_chunk_internal ("postal-addresses", persona);
+ if (persona is RoleDetails)
+ create_chunk_internal ("roles", persona);
+ if (persona is UrlDetails)
+ create_chunk_internal ("urls", persona);
+ }
+
+ public unowned Chunk? create_chunk (string property_name, Persona? persona) {
+ var pos = create_chunk_internal (property_name, persona);
+ if (pos == Gtk.INVALID_LIST_POSITION)
+ return null;
+ items_changed (pos, 0, 1);
+ return this.chunks[pos];
+ }
+
+ // Helper to create a chunk and return its position, without items_changed()
+ private uint create_chunk_internal (string property_name, Persona? persona) {
+ var chunk_gtype = chunk_gtype_for_property (property_name);
+ if (chunk_gtype == GLib.Type.NONE) {
+ debug ("unsupported property '%s', ignoring", property_name);
+ return Gtk.INVALID_LIST_POSITION;
+ }
+
+ var chunk = (Chunk) Object.new (chunk_gtype,
+ "persona", persona,
+ null);
+ this.chunks.add (chunk);
+ return this.chunks.length - 1;
+ }
+
+ private GLib.Type chunk_gtype_for_property (string property_name) {
+ switch (property_name) { // Please keep these sorted
+ case "alias":
+ return typeof (AliasChunk);
+ case "avatar":
+ return typeof (AvatarChunk);
+ case "birthday":
+ return typeof (BirthdayChunk);
+ case "email-addresses":
+ return typeof (EmailAddressesChunk);
+ case "full-name":
+ return typeof (FullNameChunk);
+ case "im-addresses":
+ return typeof (ImAddressesChunk);
+ case "nickname":
+ return typeof (NicknameChunk);
+ case "notes":
+ return typeof (NotesChunk);
+ case "phone-numbers":
+ return typeof (PhonesChunk);
+ case "postal-addresses":
+ return typeof (AddressesChunk);
+ case "roles":
+ return typeof (RolesChunk);
+ case "structured-name":
+ return typeof (StructuredNameChunk);
+ case "urls":
+ return typeof (UrlsChunk);
+ }
+
+ return GLib.Type.NONE;
+ }
+
+ /**
+ * Tries to get the name for the contact by iterating over the chunks that
+ * represent some form of name. If none is found, it returns null.
+ */
+ public string? fetch_name () {
+ var alias_chunk = get_most_relevant_chunk ("alias");
+ if (alias_chunk != null)
+ return ((AliasChunk) alias_chunk).alias;
+
+ var fn_chunk = get_most_relevant_chunk ("full-name");
+ if (fn_chunk != null)
+ return ((FullNameChunk) fn_chunk).full_name;
+
+ var sn_chunk = get_most_relevant_chunk ("structured-name");
+ if (sn_chunk != null)
+ return ((StructuredNameChunk) sn_chunk).structured_name.to_string ();
+
+ var nick_chunk = get_most_relevant_chunk ("nickname");
+ if (nick_chunk != null)
+ return ((NicknameChunk) nick_chunk).nickname;
+
+ return null;
+ }
+
+ /**
+ * Tries to get the displayable name for the contact. Similar to fetch_name,
+ * but also checks for fields that are not a name, but might still represent
+ * a contact (for example an email address)
+ */
+ public string? fetch_display_name () {
+ var name = fetch_name ();
+ if (name != null)
+ return name;
+
+ var emails_chunk = get_most_relevant_chunk ("email-addresses");
+ if (emails_chunk != null) {
+ var email = ((EmailAddressesChunk) emails_chunk).get_item (0);
+ return ((EmailAddress) email).raw_address;
+ }
+
+ var phones_chunk = get_most_relevant_chunk ("phone-numbers");
+ if (phones_chunk != null) {
+ var phone = ((PhonesChunk) phones_chunk).get_item (0);
+ return ((Phone) phone).raw_number;
+ }
+
+ return null;
+ }
+
+ /**
+ * A helper function to return the {@link Chunk} that best represents the
+ * property of the contact (or null if none).
+ */
+ public Chunk? get_most_relevant_chunk (string property_name, bool allow_empty = false) {
+ var filter = new ChunkFilter.for_property (property_name);
+ filter.allow_empty = allow_empty;
+ var chunks = new Gtk.FilterListModel (this, (owned) filter);
+
+ // From these chunks, select the one from the primary store. If there's
+ // none, just select the first one
+ unowned var primary_store = this.contacts_store.aggregator.primary_store;
+ for (uint i = 0; i < chunks.get_n_items (); i++) {
+ var chunk = (Chunk) chunks.get_item (i);
+ if (chunk.persona != null && chunk.persona.store == primary_store)
+ return chunk;
+ }
+ return (Chunk?) chunks.get_item (0);
+ }
+
+ public Object? get_item (uint i) {
+ if (i > this.chunks.length)
+ return null;
+ return this.chunks[i];
+ }
+
+ public uint get_n_items () {
+ return this.chunks.length;
+ }
+
+ public GLib.Type get_item_type () {
+ return typeof (Chunk);
+ }
+
+ /**
+ * Applies any pending changes to all chunks. This can mean either a new
+ * persona is made, or it is saved in the chunk's referenced persona.
+ */
+ public async void apply_changes () throws GLib.Error {
+ // For those that were a persona: save the properties using the API
+ for (uint i = 0; i < this.chunks.length; i++) {
+ unowned var chunk = this.chunks[i];
+ if (chunk.persona == null)
+ continue;
+
+ if (!(chunk.property_name in chunk.persona.writeable_properties)) {
+ warning ("Can't save to unwriteable property '%s' to persona %s",
+ chunk.property_name, chunk.persona.uid);
+ // TODO: maybe add a fallback to save to a different persona?
+ // We could maybe store it and add it to a new one, but that might make
+ // properties overlap
+ continue;
+ }
+
+ debug ("Saving property '%s' to persona %s",
+ chunk.property_name, chunk.persona.uid);
+ yield chunk.save_to_persona ();
+ debug ("Saved property '%s' to persona %s",
+ chunk.property_name, chunk.persona.uid);
+ }
+
+ // Find those without a persona, and save them into the primary store
+ var new_details = new HashTable<string, Value?> (str_hash, str_equal);
+ for (uint i = 0; i < this.chunks.length; i++) {
+ unowned var chunk = this.chunks[i];
+ if (chunk.persona != null)
+ continue;
+
+ var value = chunk.to_value ();
+ if (value == null // Skip empty properties
+ || value.peek_pointer () == null) // ugh, Vala
+ continue;
+
+ if (chunk.property_name in new_details)
+ warning ("Got multiple chunks for property '%s'", chunk.property_name);
+ new_details.insert (chunk.property_name, (owned) value);
+ }
+ if (new_details.size () != 0) {
+ debug ("Creating new persona with %u properties", new_details.size ());
+ unowned var primary_store = this.contacts_store.aggregator.primary_store;
+ return_if_fail (primary_store != null);
+ var persona = yield primary_store.add_persona_from_details (new_details);
+ debug ("Successfully created new persona %p", persona);
+ // FIXME: should we set the persona for these chunks?
+ }
+ }
+}
diff --git a/src/core/contacts-email-addresses-chunk.vala b/src/core/contacts-email-addresses-chunk.vala
new file mode 100644
index 0000000..1119a2c
--- /dev/null
+++ b/src/core/contacts-email-addresses-chunk.vala
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+public class Contacts.EmailAddressesChunk : BinChunk {
+
+ public override string property_name { get { return "email-addresses"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is EmailDetails);
+ unowned var email_details = (EmailDetails) persona;
+
+ foreach (var email_field in email_details.email_addresses) {
+ var email = new EmailAddress.from_field_details (email_field);
+ add_child (email);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new EmailAddress ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is EmailDetails) {
+ var afds = (Gee.Set<EmailFieldDetails>) get_abstract_field_details ();
+ yield ((EmailDetails) this.persona).change_email_addresses (afds);
+ }
+}
+
+public class Contacts.EmailAddress : BinChunkChild {
+
+ public string raw_address {
+ get { return this._raw_address; }
+ set { change_string_prop ("raw-address", ref this._raw_address, value); }
+ }
+ private string _raw_address = "";
+
+ public override bool is_empty {
+ get { return this.raw_address.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "mail-unread-symbolic"; }
+ }
+
+ public EmailAddress () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "PERSONAL";
+ }
+
+ public EmailAddress.from_field_details (EmailFieldDetails email_field) {
+ this.raw_address = email_field.value;
+ this.parameters = email_field.parameters;
+ }
+
+ /**
+ * Returns the TypeDescriptor that describes the type of the email address
+ * (for example personal, work, ...)
+ */
+ public TypeDescriptor get_email_address_type () {
+ return TypeSet.email.lookup_by_parameters (this.parameters);
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new EmailFieldDetails (this.raw_address, this.parameters);
+ }
+
+ public string get_mailto_uri () {
+ return "mailto:" + Uri.escape_string (this.raw_address, "@" , false);
+ }
+}
diff --git a/src/core/contacts-full-name-chunk.vala b/src/core/contacts-full-name-chunk.vala
new file mode 100644
index 0000000..647f556
--- /dev/null
+++ b/src/core/contacts-full-name-chunk.vala
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the full name of a contact as a single
+ * string (contrary to the structured name, where the name is split up in the
+ * several constituent parts}.
+ */
+public class Contacts.FullNameChunk : Chunk {
+
+ public string full_name {
+ get { return this._full_name; }
+ set {
+ if (this._full_name == value)
+ return;
+
+ bool was_empty = this.is_empty;
+ this._full_name = value;
+ notify_property ("full-name");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private string _full_name = "";
+
+ public override string property_name { get { return "full-name"; } }
+
+ public override bool is_empty { get { return this._full_name.strip () == ""; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NameDetails);
+ persona.bind_property ("full-name", this, "full-name", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.full_name;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is NameDetails) {
+ yield ((NameDetails) this.persona).change_full_name (this.full_name);
+ }
+}
diff --git a/src/core/contacts-im-addresses-chunk.vala b/src/core/contacts-im-addresses-chunk.vala
new file mode 100644
index 0000000..031f804
--- /dev/null
+++ b/src/core/contacts-im-addresses-chunk.vala
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the internet messaging (IM) addresses of a
+ * contact (similar to {@link Folks.ImDetails}}. Each element is a
+ * {@link ImAddress}.
+ */
+public class Contacts.ImAddressesChunk : BinChunk {
+
+ public override string property_name { get { return "im-addresses"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is ImDetails);
+ unowned var im_details = (ImDetails) persona;
+
+ var iter = im_details.im_addresses.map_iterator ();
+ while (iter.next ()) {
+ var protocol = iter.get_key ();
+ var im = new ImAddress.from_field_details (iter.get_value (), protocol);
+ add_child (im);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new ImAddress ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is ImDetails) {
+ // We can't use get_abstract_field_details() here, since we need the
+ // protocol as well, and to use a Gee.MultiMap for it
+ var afds = new Gee.HashMultiMap<string, ImFieldDetails> ();
+ for (uint i = 0; i < get_n_items (); i++) {
+ var im_addr = (ImAddress) get_item (i);
+ var afd = (ImFieldDetails) im_addr.create_afd ();
+ if (afd != null)
+ afds[im_addr.protocol] = afd;
+ }
+
+ yield ((ImDetails) this.persona).change_im_addresses (afds);
+ }
+}
+
+public class Contacts.ImAddress : BinChunkChild {
+
+ public string protocol { get; private set; default = ""; }
+
+ public string address {
+ get { return this._address; }
+ set { change_string_prop ("address", ref this._address, value); }
+ }
+ private string _address = "";
+
+ public override bool is_empty {
+ get { return this.address.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "chat-symbolic"; }
+ }
+
+ public ImAddress () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ }
+
+ public ImAddress.from_field_details (ImFieldDetails im_field, string protocol) {
+ this.address = im_field.value;
+ this.protocol = protocol;
+ this.parameters = im_field.parameters;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new ImFieldDetails (this.address, this.parameters);
+ }
+}
diff --git a/src/core/contacts-nickname-chunk.vala b/src/core/contacts-nickname-chunk.vala
new file mode 100644
index 0000000..ba505f0
--- /dev/null
+++ b/src/core/contacts-nickname-chunk.vala
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the nickname of a contact.
+ */
+public class Contacts.NicknameChunk : Chunk {
+
+ public string nickname {
+ get { return this._nickname; }
+ set {
+ if (this._nickname == value)
+ return;
+
+ bool was_empty = this.is_empty;
+ this._nickname = value;
+ notify_property ("nickname");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private string _nickname = "";
+
+ public override string property_name { get { return "nickname"; } }
+
+ public override bool is_empty { get { return this._nickname.strip () == ""; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NameDetails);
+ persona.bind_property ("nickname", this, "nickname", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.nickname;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is NameDetails) {
+
+ yield ((NameDetails) this.persona).change_nickname (this.nickname);
+ }
+}
diff --git a/src/core/contacts-notes-chunk.vala b/src/core/contacts-notes-chunk.vala
new file mode 100644
index 0000000..45b5c43
--- /dev/null
+++ b/src/core/contacts-notes-chunk.vala
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the freeform notes attached to a contact
+ * (similar to {@link Folks.NoteDetails}}. Each element is a {@link Note}.
+ */
+public class Contacts.NotesChunk : BinChunk {
+
+ public override string property_name { get { return "notes"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NoteDetails);
+ unowned var note_details = (NoteDetails) persona;
+
+ foreach (var note_field in note_details.notes) {
+ var note = new Note.from_field_details (note_field);
+ add_child (note);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Note ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is PhoneDetails) {
+ var afds = (Gee.Set<NoteFieldDetails>) get_abstract_field_details ();
+ yield ((NoteDetails) this.persona).change_notes (afds);
+ }
+}
+
+public class Contacts.Note : BinChunkChild {
+
+ public string text {
+ get { return this._text; }
+ set { change_string_prop ("text", ref this._text, value); }
+ }
+ private string _text = "";
+
+ public override bool is_empty {
+ get { return this.text.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "note-symbolic"; }
+ }
+
+ public Note () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "PERSONAL";
+ }
+
+ public Note.from_field_details (NoteFieldDetails note_field) {
+ this.text = note_field.value;
+ this.parameters = note_field.parameters;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new NoteFieldDetails (this.text, this.parameters);
+ }
+}
diff --git a/src/core/contacts-phones-chunk.vala b/src/core/contacts-phones-chunk.vala
new file mode 100644
index 0000000..8135d98
--- /dev/null
+++ b/src/core/contacts-phones-chunk.vala
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the phone numbers of a contact (similar to
+ * {@link Folks.PhoneDetails}}. Each element is a {@link Phone}.
+ */
+public class Contacts.PhonesChunk : BinChunk {
+
+ public override string property_name { get { return "phone-numbers"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is PhoneDetails);
+ unowned var phone_details = (PhoneDetails) persona;
+
+ foreach (var phone_field in phone_details.phone_numbers) {
+ var phone = new Phone.from_field_details (phone_field);
+ add_child (phone);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Phone ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is PhoneDetails) {
+ var afds = (Gee.Set<PhoneFieldDetails>) get_abstract_field_details ();
+ yield ((PhoneDetails) this.persona).change_phone_numbers (afds);
+ }
+}
+
+public class Contacts.Phone : BinChunkChild {
+
+ /**
+ * The "raw" phone number as inputted by a user or from a contact. It may or
+ * may not be an actual valid phone number.
+ */
+ public string raw_number {
+ get { return this._raw_number; }
+ set { change_string_prop ("raw-number", ref this._raw_number, value); }
+ }
+ private string _raw_number = "";
+
+ public override bool is_empty {
+ get { return this.raw_number.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "phone-symbolic"; }
+ }
+
+ public Phone () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "CELL";
+ }
+
+ public Phone.from_field_details (PhoneFieldDetails phone_field) {
+ this.raw_number = phone_field.value;
+ this.parameters = phone_field.parameters;
+ }
+
+ /**
+ * Returns the TypeDescriptor that describes the type of phone number
+ * (for example mobile, work, fax, ...)
+ */
+ public TypeDescriptor get_phone_type () {
+ return TypeSet.phone.lookup_by_parameters (this.parameters);
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new PhoneFieldDetails (this.raw_number, this.parameters);
+ }
+}
diff --git a/src/core/contacts-roles-chunk.vala b/src/core/contacts-roles-chunk.vala
new file mode 100644
index 0000000..bec585b
--- /dev/null
+++ b/src/core/contacts-roles-chunk.vala
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the organizations and/or roles of a contact
+ * (similar to {@link Folks.RoleDetails}}. Each element is a
+ * {@link Contacts.OrgRole}.
+ */
+public class Contacts.RolesChunk : BinChunk {
+
+ public override string property_name { get { return "roles"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is RoleDetails);
+ unowned var role_details = (RoleDetails) persona;
+
+ foreach (var role_field in role_details.roles) {
+ var role = new OrgRole.from_field_details (role_field);
+ add_child (role);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new OrgRole ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is RoleDetails) {
+ var afds = (Gee.Set<RoleFieldDetails>) get_abstract_field_details ();
+ yield ((RoleDetails) this.persona).change_roles (afds);
+ }
+}
+
+public class Contacts.OrgRole : BinChunkChild {
+
+ public Role role { get; private set; default = new Role (); }
+
+ public override bool is_empty {
+ get { return this.role.is_empty (); }
+ }
+
+ public override string icon_name {
+ get { return "building-symbolic"; }
+ }
+
+ public OrgRole () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ }
+
+ public OrgRole.from_field_details (RoleFieldDetails role_field) {
+ this.role = role_field.value;
+ this.parameters = role_field.parameters;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new RoleFieldDetails (this.role, this.parameters);
+ }
+
+ public string to_string () {
+ if (this.role.title != "") {
+ if (this.role.organisation_name != "") {
+ // TRANSLATORS: "$ROLE at $ORGANISATION", e.g. "CEO at Linux Inc."
+ return _("%s at %s").printf (this.role.title, this.role.organisation_name);
+ }
+
+ return this.role.title;
+ }
+
+ return this.role.organisation_name;
+ }
+}
diff --git a/src/core/contacts-structured-name-chunk.vala b/src/core/contacts-structured-name-chunk.vala
new file mode 100644
index 0000000..07cbc8f
--- /dev/null
+++ b/src/core/contacts-structured-name-chunk.vala
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the structured name of a contact.
+ *
+ * The structured represents a full name split in its constituent parts (given
+ * name, family name, etc.)
+ */
+public class Contacts.StructuredNameChunk : Chunk {
+
+ public StructuredName structured_name {
+ get { return this._structured_name; }
+ set {
+ if (this._structured_name == value)
+ return;
+ if (this._structured_name != null && value != null
+ && this._structured_name.equal (value))
+ return;
+
+ bool was_empty = this.is_empty;
+ this._structured_name = value;
+ notify_property ("structured-name");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private StructuredName _structured_name = new StructuredName.simple (null, null);
+
+ public override string property_name { get { return "structured-name"; } }
+
+ public override bool is_empty {
+ get {
+ return this._structured_name == null || this._structured_name.is_empty ();
+ }
+ }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NameDetails);
+ persona.bind_property ("structured-name", this, "structured-name", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.structured_name;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is NameDetails) {
+ yield ((NameDetails) this.persona).change_structured_name (this.structured_name);
+ }
+}
diff --git a/src/core/contacts-urls-chunk.vala b/src/core/contacts-urls-chunk.vala
new file mode 100644
index 0000000..671fc4d
--- /dev/null
+++ b/src/core/contacts-urls-chunk.vala
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * 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 2 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/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the associated URLs of a contact (similar to
+ * {@link Folks.UrlDetails}}. Each element is a {@link Contacts.Url}.
+ */
+public class Contacts.UrlsChunk : BinChunk {
+
+ public override string property_name { get { return "urls"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is UrlDetails);
+ unowned var url_details = (UrlDetails) persona;
+
+ foreach (var url_field in url_details.urls) {
+ var url = new Url.from_field_details (url_field);
+ add_child (url);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Url ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is UrlDetails) {
+ var afds = (Gee.Set<UrlFieldDetails>) get_abstract_field_details ();
+ yield ((UrlDetails) this.persona).change_urls (afds);
+ }
+}
+
+public class Contacts.Url : BinChunkChild {
+
+ public string raw_url {
+ get { return this._raw_url; }
+ set { change_string_prop ("raw-url", ref this._raw_url, value); }
+ }
+ private string _raw_url = "";
+
+ public override bool is_empty {
+ get { return this.raw_url.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "website-symbolic"; }
+ }
+
+ public Url () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "PERSONAL";
+ }
+
+ public Url.from_field_details (UrlFieldDetails url_field) {
+ this.raw_url = url_field.value;
+ this.parameters = url_field.parameters;
+ }
+
+ /**
+ * Tries to return an absolute URL (with a scheme).
+ * Since we know contact URL values are for web addresses, we try to fall
+ * back to https if there is no known scheme
+ */
+ public string get_absolute_url () {
+ string scheme = Uri.parse_scheme (this.raw_url);
+ return (scheme != null)? this.raw_url : "https://" + this.raw_url;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new UrlFieldDetails (this.raw_url, this.parameters);
+ }
+}