[gnome-contacts/wip/nielsdg/vcard-import] Create an object to import VCard




commit a2b6c7cfb2161fc50a9117c0a1d07eeeb958c149
Author: Niels De Graef <nielsdegraef gmail com>
Date:   Mon Jan 11 19:22:17 2021 +0100

    Create an object to import VCard

 data/ui/contacts-main-window.ui        |   8 +
 src/contacts-app.vala                  |  42 +++-
 src/contacts-importer-launcher.vala    |  86 +++++++
 src/io/contacts-io-import-main.vala    |  58 +++++
 src/io/contacts-io-importer.vala       |  46 ++++
 src/io/contacts-io-vcard-importer.vala | 279 +++++++++++++++++++++
 src/io/contacts-io.vala                | 427 +++++++++++++++++++++++++++++++++
 src/io/meson.build                     |  33 +++
 src/meson.build                        |   5 +-
 tests/io/meson.build                   |  14 ++
 tests/io/test-serialise.vala           |  88 +++++++
 tests/meson.build                      |   6 +-
 12 files changed, 1088 insertions(+), 4 deletions(-)
---
diff --git a/data/ui/contacts-main-window.ui b/data/ui/contacts-main-window.ui
index de3e0de6..a3e7bf15 100644
--- a/data/ui/contacts-main-window.ui
+++ b/data/ui/contacts-main-window.ui
@@ -43,6 +43,14 @@
             <property name="visible">True</property>
           </object>
         </child>
+        <child>
+          <object class="GtkModelButton">
+            <property name="can_focus">True</property>
+            <property name="text" translatable="yes">Import</property>
+            <property name="action-name">app.import</property>
+            <property name="visible">True</property>
+          </object>
+        </child>
         <child>
           <object class="GtkModelButton">
             <property name="can_focus">True</property>
diff --git a/src/contacts-app.vala b/src/contacts-app.vala
index b1adaf40..4d36d6be 100644
--- a/src/contacts-app.vala
+++ b/src/contacts-app.vala
@@ -31,7 +31,8 @@ public class Contacts.App : Gtk.Application {
     { "change-book",      change_address_book },
     { "online-accounts",  online_accounts     },
     { "new-contact",      new_contact         },
-    { "show-contact",     on_show_contact, "s"}
+    { "show-contact",     on_show_contact, "s"},
+    { "import",           on_import           }
   };
 
   private const OptionEntry[] options = {
@@ -305,4 +306,43 @@ public class Contacts.App : Gtk.Application {
       show_individual.begin (individual);
   }
 
+  private void on_import(SimpleAction action, Variant? param) {
+    var chooser = new Gtk.FileChooserNative ("Select contact file",
+                                             this.window,
+                                             Gtk.FileChooserAction.OPEN,
+                                             _("Import"),
+                                             _("Cancel"));
+    chooser.modal = true;
+    chooser.select_multiple = false;
+
+    // TODO: somehow get this from the list of importers we have
+    var filter = new Gtk.FileFilter ();
+    filter.set_filter_name ("VCard files");
+    filter.add_pattern ("*.vcf");
+    filter.add_pattern ("*.vcard");
+    chooser.add_filter (filter);
+
+    chooser.response.connect ((response) => {
+        if (response != Gtk.ResponseType.ACCEPT) {
+          chooser.destroy ();
+          return;
+        }
+
+        if (chooser.get_filename () == null) {
+          debug ("No file selected, or no path available");
+          chooser.destroy ();
+        }
+
+        try {
+          var file = File.new_for_path (chooser.get_filename ());
+          var import = new ImportOperation (file);
+          import.execute ();
+        } catch (GLib.Error err) {
+          warning ("Couldn't import file: %s", err.message);
+        }
+
+        chooser.destroy ();
+    });
+    chooser.run ();
+  }
 }
diff --git a/src/contacts-importer-launcher.vala b/src/contacts-importer-launcher.vala
new file mode 100644
index 00000000..eeb40c6a
--- /dev/null
+++ b/src/contacts-importer-launcher.vala
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2021 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;
+
+/**
+ * Launches a subprocess to deal with the import of a Contact file
+ */
+public class Contacts.ImportOperation {
+
+    private File input_file;
+
+    public ImportOperation (File file) {
+      this.input_file = file;
+    }
+
+    public async void execute () throws Error {
+      var launcher = new SubprocessLauncher (SubprocessFlags.STDOUT_PIPE);
+      // Make sure we're not accidentally propagating the G_MESSAGES_DEBUG variable
+      launcher.set_environ ({});
+
+      debug ("Spawning import subprocess");
+      var subprocess = launcher.spawnv ({
+          "/home/niels/jhbuild/install/libexec/gnome-contacts/gnome-contacts-import",
+          "vcard",
+          this.input_file.get_path ()
+      });
+
+      // Hook up stdout to a MemoryOutputStream, so we can easily fetch the output
+      var proc_stdout = subprocess.get_stdout_pipe ();
+      var stdout_stream = new MemoryOutputStream.resizable ();
+      try {
+        yield stdout_stream.splice_async (proc_stdout, 0, Priority.DEFAULT, null);
+      } catch (Error err) {
+        warning ("Error fetching stdout of import subprocess: %s", err.message);
+        return;
+      }
+
+      debug ("Waiting for import subprocess to finish");
+      var success = yield subprocess.wait_check_async ();
+      debug ("Import subprocess finished");
+      if (!success) {
+        warning ("Import process exited with error status %d", subprocess.get_exit_status ());
+        return;
+      }
+
+      // Ensure we have a proper string by adding a NULL terminator
+      stdout_stream.write ("\0".data);
+      stdout_stream.close ();
+
+      unowned var serialized_str = (string) stdout_stream.get_data ();
+
+      try {
+        var variant = Variant.parse (VariantType.VARDICT, serialized_str);
+
+        var new_details = Contacts.Io.deserialize_gvariant (variant);
+        if (new_details.size () == 0) {
+          warning ("Imported contact has zero fields");
+          return;
+        }
+
+        // TODO now what? :p
+
+      } catch (VariantParseError err) {
+        Variant.parse_error_print_context (err, serialized_str);
+      }
+
+      // TODO bytes or string?
+      // var variant = Variant.new_from_data<void> (VariantType.VARDICT, stdout_stream.get_data (), false);
+
+    }
+}
diff --git a/src/io/contacts-io-import-main.vala b/src/io/contacts-io-import-main.vala
new file mode 100644
index 00000000..75a65597
--- /dev/null
+++ b/src/io/contacts-io-import-main.vala
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2021 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;
+
+int main (string[] args) {
+  if (args.length != 3)
+    error ("Expected exactly 2 arguments, but got %d", args.length - 1);
+
+  unowned var import_type = args[1];
+  if (import_type == "")
+    error ("Invalid import type: got empty import type");
+
+  unowned var path = args[2];
+  if (path == "")
+    error ("Invalid path: path is empty");
+
+  Contacts.Io.Importer importer;
+  switch (import_type) {
+    case "vcard":
+      importer = new Contacts.Io.VCardImporter ();
+      break;
+    default:
+      error ("Unknown import type '%s'", import_type);
+  }
+
+  HashTable<string, Value?> details;
+  try {
+    var file = File.new_for_path (path);
+    details = importer.import_file (file);
+  } catch (Error err) {
+    error ("Error while importing file '%s': %s", path, err.message);
+  }
+
+  // Serialize
+  var serialized = Contacts.Io.serialize_to_gvariant (details);
+
+  // TODO: raw bytes (performance) or variant.print/parse?
+  // var bytes = serialized.get_data_as_bytes ();
+  // stdout.write (bytes.get_data (), bytes.get_size ());
+  stdout.write (serialized.print (false).data);
+
+  return 0;
+}
diff --git a/src/io/contacts-io-importer.vala b/src/io/contacts-io-importer.vala
new file mode 100644
index 00000000..114c5483
--- /dev/null
+++ b/src/io/contacts-io-importer.vala
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2021 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 generic interface for importing
+ */
+public abstract class Contacts.Io.Importer {
+
+  /**
+   * Takes the given {@link GLib.File} containing a VCard string and tries to
+   * parse it into a {@link GLib.HashTable}, which can then be used for methods
+   * like {@link Folks.PersonaStore.add_persona_from_details}.
+   */
+  public HashTable<string, Value?> import_file (GLib.File file) throws GLib.Error {
+    var path = file.get_path ();
+    if (path == null)
+      throw new GLib.IOError.INVALID_FILENAME ("Couldn't import file: file doesn't have a path");
+
+    string vcard_str;
+    FileUtils.get_contents (path, out vcard_str);
+    return import_string (vcard_str);
+  }
+
+  /**
+   * Takes the given input string and tries to parse it into a
+   * {@link GLib.HashTable}, which can then be used for methods like
+   * {@link Folks.PersonaStore.add_persona_from_details}.
+   */
+  public abstract GLib.HashTable<string, Value?> import_string (string vcard_str);
+}
diff --git a/src/io/contacts-io-vcard-importer.vala b/src/io/contacts-io-vcard-importer.vala
new file mode 100644
index 00000000..a43b8a50
--- /dev/null
+++ b/src/io/contacts-io-vcard-importer.vala
@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2021 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.Io.VCardImporter : Contacts.Io.Importer {
+
+  public VCardImporter () {
+  }
+
+  /**
+   * Takes the given VCard string and tries to parse it into a
+   * {@link GLib.HashTable}, which can then be used for methods like
+   * {@link Folks.PersonaStore.add_persona_from_details}.
+   */
+  public override HashTable<string, Value?> import_string (string vcard_str) {
+    var details = new HashTable<string, Value?> (GLib.str_hash, GLib.str_equal);
+    var vcard = new E.VCard.from_string (vcard_str);
+
+    unowned var vcard_attrs = vcard.get_attributes ();
+    message ("Got %u attributes in this vcard", vcard_attrs.length ());
+
+    foreach (unowned E.VCardAttribute attr in vcard_attrs) {
+      switch (attr.get_name ()) {
+        // Identification Properties
+        case E.EVC_FN:
+          handle_fn (details, attr);
+          break;
+        case E.EVC_N:
+          handle_n (details, attr);
+          break;
+        case E.EVC_NICKNAME:
+          handle_nickname (details, attr);
+          break;
+/*
+        case E.EVC_PHOTO:
+          handle_photo (details, attr);
+          break;
+*/
+        case E.EVC_BDAY:
+          handle_bday (details, attr);
+          break;
+        // Delivery Addressing Properties
+        case E.EVC_ADR:
+          handle_adr (details, attr);
+          break;
+        // Communications Properties
+        case E.EVC_TEL:
+          handle_tel (details, attr);
+          break;
+        case E.EVC_EMAIL:
+          handle_email (details, attr);
+          break;
+        // Explanatory Properties
+        case E.EVC_NOTE:
+          handle_note (details, attr);
+          break;
+        case E.EVC_URL:
+          handle_url (details, attr);
+          break;
+
+        default:
+          debug ("Unknown property name '%s'", attr.get_name ());
+          break;
+      }
+    }
+
+    return details;
+  }
+
+  // Handles the "FN" (Full Name) attribute
+  private void handle_fn (HashTable<string, Value?> details,
+                          E.VCardAttribute attr) {
+    var full_name = attr.get_value ();
+    message ("Got FN '%s'", full_name);
+
+    Value? fn_v = Value (typeof (string));
+    fn_v.set_string (full_name);
+    details.insert (Folks.PersonaStore.detail_key (PersonaDetail.FULL_NAME),
+                    (owned) fn_v);
+  }
+
+  // Handles the "N" (structured Name) attribute
+  private void handle_n (HashTable<string, Value?> details,
+                         E.VCardAttribute attr) {
+    unowned var values = attr.get_values ();
+
+    // From the VCard spec:
+    // The structured property value corresponds, in sequence, to the Family
+    // Names (also known as surnames), Given Names, Additional Names, Honorific
+    // Prefixes, and Honorific Suffixes.
+    unowned var family_name = values.nth_data (0) ?? "";
+    unowned var given_name = values.nth_data (1) ?? "";
+    unowned var additional_names = values.nth_data (2) ?? "";
+    unowned var prefixes = values.nth_data (3) ?? "";
+    unowned var suffixes = values.nth_data (4) ?? "";
+
+    var structured_name = new StructuredName (family_name, given_name,
+                                              additional_names,
+                                              prefixes, suffixes);
+    Value? n_v = Value (typeof (StructuredName));
+    n_v.take_object ((owned) structured_name);
+    details.insert (Folks.PersonaStore.detail_key (PersonaDetail.STRUCTURED_NAME),
+                    (owned) n_v);
+  }
+
+  private void handle_nickname (HashTable<string, Value?> details,
+                                E.VCardAttribute attr) {
+    var nickname = attr.get_value ();
+    message ("Got nickname '%s'", nickname);
+
+    Value? nick_v = Value (typeof (string));
+    nick_v.set_string (nickname);
+    details.insert (Folks.PersonaStore.detail_key (PersonaDetail.NICKNAME),
+                    (owned) nick_v);
+  }
+
+  // Handles the "BDAY" (birthday) attribute
+  private void handle_bday (HashTable<string, Value?> details,
+                            E.VCardAttribute attr) {
+    // Get the attribute valuec
+    var bday = attr.get_value ();
+
+    // Parse it using the logic in E.ContactDate
+    var e_date = E.ContactDate.from_string (bday);
+
+    // Turn it into a GLib.DateTime
+    var datetime = new DateTime.utc ((int) e_date.year,
+                                     (int) e_date.month,
+                                     (int) e_date.day,
+                                     0, 0, 0.0);
+
+    // Insert it into the hashtable as a GLib.Value
+    Value? bday_val = Value (typeof (DateTime));
+    bday_val.take_boxed ((owned) datetime);
+    details.insert (Folks.PersonaStore.detail_key (PersonaDetail.BIRTHDAY),
+                    (owned) bday_val);
+  }
+
+  private void handle_email (HashTable<string, Value?> details,
+                             E.VCardAttribute attr) {
+    var email = attr.get_value ();
+    if (email == null || email == "")
+      return;
+
+    var email_fd = new EmailFieldDetails (email);
+    add_params (email_fd, attr);
+    insert_field_details<EmailFieldDetails> (details, PersonaDetail.EMAIL_ADDRESSES,
+                                             email_fd,
+                                             AbstractFieldDetails<string>.hash_static,
+                                             AbstractFieldDetails<string>.equal_static);
+  }
+
+  private void handle_tel (HashTable<string, Value?> details,
+                           E.VCardAttribute attr) {
+    var phone_nr = attr.get_value ();
+    if (phone_nr == null || phone_nr == "")
+      return;
+
+    var phone_fd = new PhoneFieldDetails (phone_nr);
+    add_params (phone_fd, attr);
+    insert_field_details<PhoneFieldDetails> (details, PersonaDetail.PHONE_NUMBERS,
+                                             phone_fd,
+                                             AbstractFieldDetails<string>.hash_static,
+                                             AbstractFieldDetails<string>.equal_static);
+  }
+
+  // Handles the ADR (postal address) attributes
+  private void handle_adr (HashTable<string, Value?> details,
+                           E.VCardAttribute attr) {
+    unowned var values = attr.get_values ();
+
+    // From the VCard spec:
+    // ADR-value = ADR-component-pobox ";" ADR-component-ext ";"
+    //             ADR-component-street ";" ADR-component-locality ";"
+    //             ADR-component-region ";" ADR-component-code ";"
+    //             ADR-component-country
+    unowned var po_box = values.nth_data (0) ?? "";
+    unowned var extension = values.nth_data (1) ?? "";
+    unowned var street = values.nth_data (2) ?? "";
+    unowned var locality = values.nth_data (3) ?? "";
+    unowned var region = values.nth_data (4) ?? "";
+    unowned var postal_code = values.nth_data (5) ?? "";
+    unowned var country = values.nth_data (6) ?? "";
+
+    var addr = new PostalAddress (po_box, extension, street, locality, region,
+                                  postal_code, country, "", null);
+    var addr_fd = new PostalAddressFieldDetails ((owned) addr);
+    add_params (addr_fd, attr);
+
+    insert_field_details<PostalAddressFieldDetails> (details,
+                                                     PersonaDetail.POSTAL_ADDRESSES,
+                                                     addr_fd,
+                                                     AbstractFieldDetails<PostalAddress>.hash_static,
+                                                     AbstractFieldDetails<PostalAddress>.equal_static);
+  }
+
+  private void handle_url (HashTable<string, Value?> details,
+                           E.VCardAttribute attr) {
+    var url = attr.get_value ();
+    if (url == null || url == "")
+      return;
+
+    var url_fd = new UrlFieldDetails (url);
+    add_params (url_fd, attr);
+    insert_field_details<UrlFieldDetails> (details, PersonaDetail.URLS,
+                                           url_fd,
+                                           AbstractFieldDetails<string>.hash_static,
+                                           AbstractFieldDetails<string>.equal_static);
+  }
+
+  private void handle_note (HashTable<string, Value?> details,
+                            E.VCardAttribute attr) {
+    var note = attr.get_value ();
+    if (note == null || note == "")
+      return;
+
+    var note_fd = new NoteFieldDetails (note);
+    add_params (note_fd, attr);
+    insert_field_details<NoteFieldDetails> (details, PersonaDetail.NOTES,
+                                            note_fd,
+                                            AbstractFieldDetails<string>.hash_static,
+                                            AbstractFieldDetails<string>.equal_static);
+
+  }
+
+  // Helper method for inserting aggregated properties
+  private bool insert_field_details<T> (HashTable<string, Value?> details,
+                                        PersonaDetail key,
+                                        T field_details,
+                                        owned Gee.HashDataFunc<T>? hash_func,
+                                        owned Gee.EqualDataFunc<T>? equal_func) {
+
+    // Get the existing set, or create a new one and add it
+    unowned var old_val = details.lookup (Folks.PersonaStore.detail_key (key));
+    if (old_val != null) {
+      unowned var values = old_val as Gee.HashSet<T>;
+      return values.add (field_details);
+    }
+
+    var values = new Gee.HashSet<T> ((owned) hash_func, (owned) equal_func);
+    Value? new_val = Value (typeof (Gee.Set));
+    new_val.set_object (values);
+    details.insert (Folks.PersonaStore.detail_key (key), (owned) new_val);
+
+    return values.add (field_details);
+  }
+
+  // Helper method to get VCard parameters into an AbstractFieldDetails object.
+  // Will take care of setting the correct "type"
+  private void add_params (AbstractFieldDetails details, E.VCardAttribute attr) {
+    foreach (unowned E.VCardAttributeParam param in attr.get_params ()) {
+      string param_name = param.get_name ().down ();
+      foreach (unowned string param_value in param.get_values ()) {
+        if (param_name == AbstractFieldDetails.PARAM_TYPE)
+          details.add_parameter (param_name, param_value.down ());
+        else
+          details.add_parameter (param_name, param_value);
+      }
+    }
+  }
+}
diff --git a/src/io/contacts-io.vala b/src/io/contacts-io.vala
new file mode 100644
index 00000000..78ccdeb4
--- /dev/null
+++ b/src/io/contacts-io.vala
@@ -0,0 +1,427 @@
+/*
+ * Copyright (C) 2021 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;
+
+/**
+ * Importing and exporting contacts, both internally (using
+ * GVariant serialization) and externally (VCard, CSV, ...)
+ */
+namespace Contacts.Io {
+
+  /**
+   * Serializes the {@link GLib.HashTable} as returned by a
+   * {@link Contacts.Io.Importer} into a {@link GLib.Variant} so it can be sent
+   * from one process to another.
+   */
+  public GLib.Variant serialize_to_gvariant (HashTable<string, Value?> details) {
+    var dict = new GLib.VariantDict ();
+
+    var iter = HashTableIter<string, Value?> (details);
+    unowned string prop;
+    unowned Value? val;
+    while (iter.next (out prop, out val)) {
+
+      if (prop == Folks.PersonaStore.detail_key (PersonaDetail.FULL_NAME)) {
+        serialize_full_name (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.STRUCTURED_NAME)) {
+        serialize_structured_name (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.NICKNAME)) {
+        serialize_nickname (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.BIRTHDAY)) {
+        serialize_birthday (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.POSTAL_ADDRESSES)) {
+        serialize_addresses (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.PHONE_NUMBERS)) {
+        serialize_phone_nrs (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.EMAIL_ADDRESSES)) {
+        serialize_emails (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.NOTES)) {
+        serialize_notes (dict, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.URLS)) {
+        serialize_urls (dict, prop, val);
+      } else {
+        warning ("Couldn't serialize unknown property '%s'", prop);
+      }
+    }
+
+    return dict.end ();
+  }
+
+  /**
+   * Deserializes the {@link GLib.Variant} back into a {@link GLib.HashTable}.
+   */
+  public HashTable<string, Value?> deserialize_gvariant (GLib.Variant variant) {
+    return_val_if_fail (variant.get_type ().equal (VariantType.VARDICT), null);
+
+    var details = new HashTable<string, Value?> (GLib.str_hash, GLib.str_equal);
+
+    var iter = variant.iterator ();
+    string prop;
+    GLib.Variant val;
+    while (iter.next ("{sv}", out prop, out val)) {
+
+      if (prop == Folks.PersonaStore.detail_key (PersonaDetail.FULL_NAME)) {
+        deserialize_full_name (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.STRUCTURED_NAME)) {
+        deserialize_structured_name (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.NICKNAME)) {
+        deserialize_nickname (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.BIRTHDAY)) {
+        deserialize_birthday (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.POSTAL_ADDRESSES)) {
+        deserialize_addresses (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.PHONE_NUMBERS)) {
+        deserialize_phone_nrs (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.EMAIL_ADDRESSES)) {
+        deserialize_emails (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.NOTES)) {
+        deserialize_notes (details, prop, val);
+      } else if (prop == Folks.PersonaStore.detail_key (PersonaDetail.URLS)) {
+        deserialize_urls (details, prop, val);
+      } else {
+        warning ("Couldn't serialize unknown property '%s'", prop);
+      }
+    }
+
+    return details;
+  }
+
+  //
+  // FULL NAME
+  // -----------------------------------
+  private const string FULL_NAME_TYPE = "s";
+
+  private bool serialize_full_name (GLib.VariantDict dict, string prop, Value? val) {
+    return_val_if_fail (val.type () == typeof (string), false);
+
+    unowned string full_name = val as string;
+    return_val_if_fail (full_name != null, false);
+
+    dict.insert (prop, FULL_NAME_TYPE, full_name);
+
+    return true;
+  }
+
+  private bool deserialize_full_name (HashTable<string, Value?> details, string prop, Variant variant) {
+    return_val_if_fail (variant.get_type ().equal (VariantType.STRING), false);
+
+    unowned string full_name = variant.get_string ();
+    return_val_if_fail (full_name != null, false);
+
+    details.insert (prop, full_name);
+
+    return true;
+  }
+
+  //
+  // NICKNAME
+  // -----------------------------------
+  private const string STRUCTURED_NAME_TYPE = "(sssss)";
+
+  private bool serialize_structured_name (GLib.VariantDict dict, string prop, Value? val) {
+    return_val_if_fail (val.type () == typeof (StructuredName), false);
+
+    unowned var name = val as StructuredName;
+    return_val_if_fail (name != null, false);
+
+    dict.insert (prop, STRUCTURED_NAME_TYPE,
+                 name.family_name, name.given_name, name.additional_names,
+                 name.prefixes, name.suffixes);
+
+    return true;
+  }
+
+  private bool deserialize_structured_name (HashTable<string, Value?> details, string prop, Variant variant) 
{
+    return_val_if_fail (variant.get_type ().equal (new VariantType (STRUCTURED_NAME_TYPE)), false);
+
+    string family_name, given_name, additional_names, prefixes, suffixes;
+    variant.get (STRUCTURED_NAME_TYPE,
+                 out family_name,
+                 out given_name,
+                 out additional_names,
+                 out prefixes,
+                 out suffixes);
+
+    var structured_name = new StructuredName (family_name, given_name, additional_names,
+                                              prefixes, suffixes);
+    details.insert (prop, structured_name);
+
+    return true;
+  }
+
+  //
+  // NICKNAME
+  // -----------------------------------
+  private const string NICKNAME_TYPE = "s";
+
+  private bool serialize_nickname (GLib.VariantDict dict, string prop, Value? val) {
+    return_val_if_fail (val.type () == typeof (string), false);
+
+    unowned string nickname = val as string;
+    return_val_if_fail (nickname != null, false);
+
+    dict.insert (prop, NICKNAME_TYPE, nickname);
+
+    return true;
+  }
+
+  private bool deserialize_nickname (HashTable<string, Value?> details, string prop, Variant variant) {
+    return_val_if_fail (variant.get_type ().equal (VariantType.STRING), false);
+
+    unowned string nickname = variant.get_string ();
+    return_val_if_fail (nickname != null, false);
+
+    details.insert (prop, nickname);
+
+    return true;
+  }
+
+  //
+  // BIRTHDAY
+  // -----------------------------------
+  private const string BIRTHDAY_TYPE = "(iii)"; // Year-Month-Day
+
+  private bool serialize_birthday (GLib.VariantDict dict, string prop, Value? val) {
+    return_val_if_fail (val.type () == typeof (DateTime), false);
+
+    unowned var bd = val as DateTime;
+    return_val_if_fail (bd != null, false);
+
+    int year, month, day;
+    bd.get_ymd (out year, out month, out day);
+    dict.insert (prop, BIRTHDAY_TYPE, year, month, day);
+
+    return true;
+  }
+
+  private bool deserialize_birthday (HashTable<string, Value?> details, string prop, Variant variant) {
+    return_val_if_fail (variant.get_type ().equal (new VariantType (BIRTHDAY_TYPE)), false);
+
+    int year, month, day;
+    variant.get (BIRTHDAY_TYPE, out year, out month, out day);
+
+    var bd = new DateTime.utc (year, month, day, 0, 0, 0.0);
+
+    details.insert (prop, bd);
+
+    return true;
+  }
+
+  //
+  // POSTAL ADDRESSES
+  // -----------------------------------
+  private const string ADDRESS_TYPE = "(sssssssv)";
+  private const string ADDRESSES_TYPE = "a" + ADDRESS_TYPE;
+
+  private bool serialize_addresses (GLib.VariantDict dict, string prop, Value? val) {
+    return_val_if_fail (val.type () == typeof (Gee.Set), false);
+
+    // Get the list of field details
+    unowned var afds = val as Gee.Set<PostalAddressFieldDetails>;
+    return_val_if_fail (afds != null, false);
+
+    // Turn the set of field details into an array Variant
+    var builder = new GLib.VariantBuilder (GLib.VariantType.ARRAY);
+    foreach (var afd in afds) {
+      unowned PostalAddress addr = afd.value;
+
+      builder.add (ADDRESS_TYPE,
+          addr.po_box,
+          addr.extension,
+          addr.street,
+          addr.locality,
+          addr.region,
+          addr.postal_code,
+          addr.country,
+          serialize_parameters (afd));
+    }
+
+    dict.insert_value (prop, builder.end ());
+
+    return true;
+  }
+
+  private bool deserialize_addresses (HashTable<string, Value?> details, string prop, Variant variant) {
+    return_val_if_fail (variant.get_type ().equal (new VariantType ("a" + ADDRESS_TYPE)), false);
+
+    var afds = new Gee.HashSet<PostalAddressFieldDetails> ();
+
+    // Turn the array variant into a set of field details
+    var iter = variant.iterator ();
+
+    string po_box, extension, street, locality, region, postal_code, country;
+    GLib.Variant parameters;
+    while (iter.next (ADDRESS_TYPE,
+                      out po_box,
+                      out extension,
+                      out street,
+                      out locality,
+                      out region,
+                      out postal_code,
+                      out country,
+                      out parameters)) {
+      if (po_box == "" && extension == "" && street == "" && locality == ""
+          && region == "" && postal_code == "" && country == "") {
+        warning ("Got empty postal address");
+        continue;
+      }
+
+      var addr = new PostalAddress (po_box, extension, street, locality, region,
+                                    postal_code, country, "", null);
+
+      var afd = new PostalAddressFieldDetails (addr);
+      deserialize_parameters (parameters, afd);
+
+      afds.add (afd);
+    }
+
+    details.insert (prop, afds);
+
+    return true;
+  }
+
+  //
+  // PHONE NUMBERS
+  // -----------------------------------
+  private bool serialize_phone_nrs (GLib.VariantDict dict, string prop, Value? val) {
+    return serialize_afd_strings (dict, prop, val);
+  }
+
+  private bool deserialize_phone_nrs (HashTable<string, Value?> details, string prop, Variant variant) {
+    return deserialize_afd_strings<PhoneFieldDetails> (details, prop, variant);
+  }
+
+  //
+  // EMAILS
+  // -----------------------------------
+  private bool serialize_emails (GLib.VariantDict dict, string prop, Value? val) {
+    return serialize_afd_strings (dict, prop, val);
+  }
+
+  private bool deserialize_emails (HashTable<string, Value?> details, string prop, Variant variant) {
+    return deserialize_afd_strings<EmailFieldDetails> (details, prop, variant);
+  }
+
+  //
+  // NOTES
+  // -----------------------------------
+  private bool serialize_notes (GLib.VariantDict dict, string prop, Value? val) {
+    return serialize_afd_strings (dict, prop, val);
+  }
+
+  private bool deserialize_notes (HashTable<string, Value?> details, string prop, Variant variant) {
+    return deserialize_afd_strings<NoteFieldDetails> (details, prop, variant);
+  }
+
+  //
+  // URLS
+  // -----------------------------------
+  private bool serialize_urls (GLib.VariantDict dict, string prop, Value? val) {
+    return serialize_afd_strings (dict, prop, val);
+  }
+
+  private bool deserialize_urls (HashTable<string, Value?> details, string prop, Variant variant) {
+    return deserialize_afd_strings<UrlFieldDetails> (details, prop, variant);
+  }
+
+  //
+  // HELPER: AbstractFielDdetail<string>
+  // -----------------------------------
+  private const string AFD_STRING_TYPE = "a(sv)";
+
+  private bool serialize_afd_strings (GLib.VariantDict dict, string prop, Value? val) {
+    return_val_if_fail (val.type () == typeof (Gee.Set), false);
+
+    // Get the list of field details
+    unowned var afds = val as Gee.Set<AbstractFieldDetails<string>>;
+    return_val_if_fail (afds != null, false);
+
+    // Turn the set of field details into an array Variant
+    var builder = new GLib.VariantBuilder (GLib.VariantType.ARRAY);
+    foreach (var afd in afds) {
+      builder.add ("(sv)", afd.value, serialize_parameters (afd));
+    }
+
+    dict.insert_value (prop, builder.end ());
+
+    return true;
+  }
+
+  private bool deserialize_afd_strings<T> (HashTable<string, Value?> details, string prop, Variant variant) {
+    return_val_if_fail (variant.get_type ().equal (new VariantType (AFD_STRING_TYPE)), false);
+
+    var afds = new Gee.HashSet<T> ();
+
+    // Turn the array variant into a set of field details
+    var iter = variant.iterator ();
+    string str;
+    GLib.Variant parameters;
+    while (iter.next ("(sv)", out str, out parameters)) {
+      if (str == "") {
+        warning ("Got empty string for type %s", typeof (T).name ());
+        continue;
+      }
+
+      var afd = GLib.Object.new (typeof (T), "value", str) as AbstractFieldDetails<string>;
+      deserialize_parameters (parameters, afd);
+
+      afds.add ((T) afd);
+    }
+
+    details.insert (prop, afds);
+
+    return true;
+  }
+
+  //
+  // HELPER: Parameters
+  // -----------------------------------
+  // We can't use a vardict here, since one key can map to multiple values.
+  private const string PARAMS_TYPE = "a(ss)";
+
+  private Variant serialize_parameters (AbstractFieldDetails details) {
+
+    if (details.parameters == null || details.parameters.size == 0) {
+      return new GLib.Variant (PARAMS_TYPE, null); // Empty array
+    }
+
+    var builder = new GLib.VariantBuilder (GLib.VariantType.ARRAY);
+    var iter = details.parameters.map_iterator ();
+    while (iter.next ()) {
+      string param_name = iter.get_key ();
+      string param_value = iter.get_value ();
+
+      builder.add ("(ss)", param_name, param_value);
+    }
+
+    return builder.end ();
+  }
+
+  private void deserialize_parameters (Variant parameters, AbstractFieldDetails details) {
+    return_if_fail (parameters.get_type ().is_array ());
+
+    var iter = parameters.iterator ();
+    string param_name, param_value;
+    while (iter.next ("(ss)", out param_name, out param_value)) {
+      if (param_name == AbstractFieldDetails.PARAM_TYPE)
+        details.add_parameter (param_name, param_value.down ());
+      else
+        details.add_parameter (param_name, param_value);
+    }
+  }
+}
diff --git a/src/io/meson.build b/src/io/meson.build
new file mode 100644
index 00000000..7bbead4b
--- /dev/null
+++ b/src/io/meson.build
@@ -0,0 +1,33 @@
+# Common library
+contacts_io_sources = files(
+  'contacts-io.vala',
+  'contacts-io-importer.vala',
+  'contacts-io-vcard-importer.vala',
+)
+
+contacts_vala_args = [
+  '--target-glib=@0@'.format(min_glib_version),
+  '--pkg', 'config',
+  '--pkg', 'custom',
+]
+
+contacts_c_args = [
+  '-include', 'config.h',
+  '-DGNOME_DESKTOP_USE_UNSTABLE_API',
+  '-DLOCALEDIR="@0@"'.format(locale_dir),
+]
+
+contacts_io_deps = [
+  folks,
+  gee,
+  gio_unix,
+  glib,
+  libebook,
+]
+
+executable('gnome-contacts-import',
+  [ contacts_io_sources, files('contacts-io-import-main.vala') ],
+  dependencies: contacts_io_deps,
+  install: true,
+  install_dir: get_option('libexecdir') / 'gnome-contacts',
+)
diff --git a/src/meson.build b/src/meson.build
index d9776384..3d82dc6c 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -1,3 +1,5 @@
+subdir ('io')
+
 # GSettings
 compiled = gnome.compile_schemas()
 install_data('org.gnome.Contacts.gschema.xml',
@@ -9,6 +11,7 @@ libcontacts_sources = files(
   'contacts-esd-setup.vala',
   'contacts-fake-persona-store.vala',
   'contacts-im-service.vala',
+  'contacts-importer-launcher.vala',
   'contacts-store.vala',
   'contacts-typeset.vala',
   'contacts-type-descriptor.vala',
@@ -56,7 +59,7 @@ if get_option('telepathy')
 endif
 
 libcontacts = static_library('contacts',
-  libcontacts_sources,
+  [ libcontacts_sources, contacts_io_sources ],
   include_directories: config_h_dir,
   vala_args: contacts_vala_args,
   c_args: contacts_c_args,
diff --git a/tests/io/meson.build b/tests/io/meson.build
new file mode 100644
index 00000000..5b58cbac
--- /dev/null
+++ b/tests/io/meson.build
@@ -0,0 +1,14 @@
+test_names = [
+  'test-serialise',
+]
+
+foreach _test : test_names
+  test_bin = executable(_test,
+    files('@0@.vala'.format(_test)),
+    dependencies: libcontacts_dep,
+  )
+
+  test(_test, test_bin,
+    suite: 'io',
+  )
+endforeach
diff --git a/tests/io/test-serialise.vala b/tests/io/test-serialise.vala
new file mode 100644
index 00000000..35ffe93b
--- /dev/null
+++ b/tests/io/test-serialise.vala
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2021 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;
+
+void main (string[] args) {
+  Test.init (ref args);
+  Test.add_func ("/io/serialize_full_name", Contacts.Tests.Io.test_serialize_full_name);
+  Test.add_func ("/io/serialize_emails", Contacts.Tests.Io.test_serialize_emails);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+
+  private void test_serialize_full_name () {
+    unowned var fn_key = PersonaStore.detail_key (PersonaDetail.FULL_NAME);
+
+    var old_fn = "Niels De Graef";
+    var old_fn_val = Value (typeof (string));
+    old_fn_val.set_string (old_fn);
+
+    var new_fn_val = _transform_single_value (fn_key, old_fn_val);
+    assert_true (new_fn_val.type () == typeof (string));
+    assert_true (old_fn == new_fn_val.get_string ());
+  }
+
+  private void test_serialize_emails () {
+    unowned var emails_key = PersonaStore.detail_key (PersonaDetail.EMAIL_ADDRESSES);
+
+    var old_fd = new EmailFieldDetails ("nielsdegraef gmail com");
+    var new_fd = _transform_single_afd<EmailFieldDetails> (emails_key, old_fd);
+
+    assert_true (old_fd.value == new_fd.value);
+  }
+
+  // Helper to serialize and deserialize an AbstractFieldDetails
+  private T _transform_single_afd<T> (string prop_key, T afd) {
+    var afd_set = new Gee.HashSet<T> ();
+    afd_set.add (afd);
+
+    var val = Value (typeof (Gee.Set));
+    val.set_object (afd_set);
+
+    var emails_value = _transform_single_value (prop_key, val);
+    var emails_set = emails_value.get_object () as Gee.Set<T>;
+    assert_nonnull (emails_set);
+    assert_true (emails_set.size == 1);
+
+    var deserialized_fd = Utils.get_first<T> (emails_set);
+    assert_nonnull (deserialized_fd);
+
+    return deserialized_fd;
+  }
+
+  // Helper to serialize and deserialize a single property with a GLib.Value
+  private GLib.Value _transform_single_value (string prop_key, GLib.Value val) {
+    var details = new HashTable<string, Value?> (GLib.str_hash, GLib.str_equal);
+    details.insert (prop_key, val);
+
+    // Serialize
+    var serialized = Contacts.Io.serialize_to_gvariant (details);
+    assert_nonnull (serialized);
+
+    // Deserialize
+    var details_deserialized = Contacts.Io.deserialize_gvariant (serialized);
+    assert_nonnull (details_deserialized);
+
+    assert_true (details_deserialized.contains (prop_key));
+    var val_deserialized = details_deserialized.lookup (prop_key);
+    assert_true (val_deserialized != null);
+
+    return val_deserialized;
+  }
+}
diff --git a/tests/meson.build b/tests/meson.build
index 92c35863..6dcfcf12 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -1,14 +1,16 @@
+subdir('io')
+
 test_names = [
   'basic-test',
 ]
 
 foreach _test : test_names
   test_bin = executable(_test,
-    '@0@.vala'.format(_test),
+    files('@0@.vala'.format(_test)),
     dependencies: libcontacts_dep,
   )
 
   test(_test, test_bin,
-    suite: 'gnome-contacts',
+    suite: 'src',
   )
 endforeach


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