[gnome-contacts/wip/nielsdg/vcard-import] Enable importing & exporting VCards




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

    Enable importing & exporting VCards
    
    This commit adds the experimental functionality in Contacts to import
    VCard (*.vcf) files.
    
    Since importing a contact means we have to take in untrusted/unvalidated
    input, let's give a high-level view of what happens:
    
    * Contacts starts a native file chooser dialog so the user can choose
      which file to import
    * According to the chosen file, Contacts will launch a subprocess to do
      the actual parsing using a `Contacts.Io.Parser`. At this point, we
      only have a single subclass, which allows importing VCards.
    * The helper process serializes the result to a `GLib.Variant`, and
      sends it to the main process, which will receive the result and
      parses it again.
    * After the parsing operation is done, we can then start up a
      `ImportOperation`, which will import the contacts using libfolks' API.
    
    Exporting contacts is quite a bit easier, since we don't have to deal
    with untrusted input: we serialize the list of selected contacts and
    asynchronously write each to the given output stream. In the app, that's
    a user chosen file; in tests, that can be a string.
    
    Fixes: https://gitlab.gnome.org/GNOME/gnome-contacts/-/issues/1
    Fixes: https://gitlab.gnome.org/GNOME/gnome-contacts/-/issues/38

 data/ui/contacts-main-window.ui                    |  14 +
 meson.build                                        |   1 +
 src/contacts-app.vala                              |  95 ++++-
 src/contacts-import-operation.vala                 |  67 +++
 src/contacts-main-window.vala                      |  56 +++
 src/io/contacts-io-export-operation.vala           |  49 +++
 src/io/contacts-io-parse-main.vala                 |  61 +++
 src/io/contacts-io-parse-operation.vala            | 106 +++++
 src/io/contacts-io-parser.vala                     |  39 ++
 src/io/contacts-io-vcard-export-operation.vala     | 269 ++++++++++++
 src/io/contacts-io-vcard-parser.vala               | 299 +++++++++++++
 src/io/contacts-io.vala                            | 465 +++++++++++++++++++++
 src/io/meson.build                                 |  33 ++
 src/meson.build                                    |   8 +
 tests/io/internal/meson.build                      |  29 ++
 tests/io/internal/test-serialise-birthday.vala     |  54 +++
 tests/io/internal/test-serialise-common.vala       |  66 +++
 tests/io/internal/test-serialise-emails.vala       |  41 ++
 tests/io/internal/test-serialise-full-name.vala    |  42 ++
 tests/io/internal/test-serialise-nickname.vala     |  40 ++
 .../internal/test-serialise-structured-name.vala   |  45 ++
 tests/io/internal/test-serialise-urls.vala         |  41 ++
 tests/io/meson.build                               |   2 +
 tests/io/vcard/meson.build                         |  32 ++
 tests/io/vcard/minimal.vcf                         |   4 +
 tests/io/vcard/test-vcard-minimal-import.vala      |  61 +++
 tests/meson.build                                  |   6 +-
 vapi/config.vapi                                   |   1 +
 28 files changed, 2023 insertions(+), 3 deletions(-)
---
diff --git a/data/ui/contacts-main-window.ui b/data/ui/contacts-main-window.ui
index 289c56c8..48e557b7 100644
--- a/data/ui/contacts-main-window.ui
+++ b/data/ui/contacts-main-window.ui
@@ -14,6 +14,12 @@
         <attribute name="target">surname</attribute>
       </item>
     </section>
+    <section>
+      <item>
+        <attribute name="label" translatable="yes">Import…</attribute>
+        <attribute name="action">app.import</attribute>
+      </item>
+    </section>
     <section>
       <item>
         <attribute name="label" translatable="yes">Preferences</attribute>
@@ -151,10 +157,18 @@
                             <child>
                               <object class="GtkActionBar" id="actions_bar">
                                 <property name="revealed">False</property>
+                                <child>
+                                  <object class="GtkButton" id="export_button">
+                                    <property name="label" translatable="yes" comments="Export refers to the 
verb">Export</property>
+                                    <property name="tooltip-text" translatable="yes">Export Selected 
Contacts</property>
+                                    <property name="action-name">win.export-marked-contacts</property>
+                                  </object>
+                                </child>
                                 <child>
                                   <object class="GtkButton" id="link_button">
                                     <property name="focus_on_click">False</property>
                                     <property name="label" translatable="yes" comments="Link refers to the 
verb, from linking contacts together">Link</property>
+                                    <property name="tooltip-text" translatable="yes">Link Selected Contacts 
Together</property>
                                     <property name="action-name">win.link-marked-contacts</property>
                                   </object>
                                 </child>
diff --git a/meson.build b/meson.build
index 307a3e3b..fef5f6b9 100644
--- a/meson.build
+++ b/meson.build
@@ -70,6 +70,7 @@ conf.set_quoted('APP_ID', contacts_app_id)
 conf.set_quoted('GETTEXT_PACKAGE', meson.project_name())
 conf.set_quoted('G_LOG_DOMAIN', meson.project_name())
 conf.set_quoted('LOCALEDIR', locale_dir)
+conf.set_quoted('LIBEXECDIR', get_option('prefix') / get_option('libexecdir'))
 conf.set_quoted('PACKAGE_NAME', meson.project_name())
 conf.set_quoted('PACKAGE_STRING', meson.project_name())
 conf.set_quoted('PACKAGE_VERSION', meson.project_version())
diff --git a/src/contacts-app.vala b/src/contacts-app.vala
index b127554c..b8ff1d78 100644
--- a/src/contacts-app.vala
+++ b/src/contacts-app.vala
@@ -37,7 +37,8 @@ public class Contacts.App : Adw.Application {
     { "help",             show_help           },
     { "about",            show_about          },
     { "show-preferences", show_preferences },
-    { "show-contact",     on_show_contact, "s"}
+    { "show-contact", on_show_contact, "s" },
+    { "import", on_import }
   };
 
   private const OptionEntry[] options = {
@@ -307,4 +308,96 @@ public class Contacts.App : Adw.Application {
       base.quit ();
     });
   }
+
+  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_file () == null) {
+          debug ("No file selected, or no path available");
+          chooser.destroy ();
+        }
+
+        import_file.begin (chooser.get_file ());
+        chooser.destroy ();
+    });
+    chooser.show ();
+  }
+
+  private async void import_file (GLib.File file) {
+    // First step: parse the data
+    var parse_op = new Io.ParseOperation (file);
+    HashTable<string, Value?>[]? parse_result = null;
+    try {
+      yield parse_op.execute ();
+      debug ("Successfully parsed a contact");
+      parse_result = parse_op.steal_parsed_result ();
+    } catch (GLib.Error err) {
+      warning ("Couldn't parse file: %s", err.message);
+      var dialog = new Adw.MessageDialog (this.window,
+                                          _("Error reading file"),
+                                          _("An error occurred reading the file '%s'".printf 
(file.get_basename ())));
+      dialog.add_response ("ok", _("_OK"));
+      dialog.set_default_response ("ok");
+      dialog.present ();
+      return;
+    }
+
+    if (parse_result.length == 0) {
+      var dialog = new Adw.MessageDialog (this.window,
+                                          _("No contacts founds"),
+                                          _("The imported file does not seem to contain any contacts"));
+      dialog.add_response ("ok", _("_OK"));
+      dialog.set_default_response ("ok");
+      dialog.present ();
+      return;
+    }
+
+    // Second step: ask the user for confirmation
+    var body = ngettext ("By continuing, you will import %u contact",
+                         "By continuing, you will import %u contacts",
+                         parse_result.length).printf (parse_result.length);
+    var dialog = new Adw.MessageDialog (this.window, _("Continue Import?"), body);
+    dialog.add_response ("continue", _("C_ontinue"));
+    dialog.set_default_response ("continue");
+    dialog.set_response_appearance ("continue", Adw.ResponseAppearance.SUGGESTED);
+
+    dialog.add_response ("cancel", _("_Cancel"));
+    dialog.set_close_response ("cancel");
+
+    dialog.response.connect ((response) => {
+      if (response != "continue")
+        return;
+
+      // Third step: import the parsed data
+      var import_op = new ImportOperation (this.contacts_store, parse_result);
+      import_op.execute.begin ((obj, res) => {
+        try {
+          import_op.execute.end (res);
+          debug ("Successfully imported a contact");
+        } catch (GLib.Error err) {
+          warning ("Couldn't import contacts: %s", err.message);
+        }
+      });
+    });
+    dialog.present ();
+  }
 }
diff --git a/src/contacts-import-operation.vala b/src/contacts-import-operation.vala
new file mode 100644
index 00000000..bb860f89
--- /dev/null
+++ b/src/contacts-import-operation.vala
@@ -0,0 +1,67 @@
+/*
+ * 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 ImportOperation takes an array of serialized contacts (represented by
+ * {@link GLib.HashTable}s) which can then be imported using
+ * {@link Folks.PersonaStore.add_persona_from_details}.
+ */
+public class Contacts.ImportOperation : Operation {
+
+  private HashTable<string, Value?>[] to_import;
+
+  private unowned Store store;
+
+  public override bool reversable { get { return false; } }
+
+  private string _description;
+  public override string description { owned get { return this._description; } }
+
+  public ImportOperation (Store store, HashTable<string, Value?>[] to_import) {
+    this.to_import = to_import;
+    this.store = store;
+
+    this._description = ngettext ("Imported %u contact",
+                                  "Imported %u contacts",
+                                  to_import.length).printf (to_import.length);
+  }
+
+  public override async void execute () throws GLib.Error {
+    unowned var primary_store = this.store.aggregator.primary_store;
+    debug ("Importing %u contacts to primary store '%s'",
+           this.to_import.length, primary_store.display_name);
+
+    uint new_count = 0;
+    foreach (unowned var hashtable in this.to_import) {
+      var persona = yield primary_store.add_persona_from_details (hashtable);
+      if (persona != null) {
+        debug ("Created new persona");
+        new_count++;
+      } else {
+        debug ("Added persona; no new created");
+      }
+    }
+
+    debug ("Done importing; got %u new contacts", new_count);
+  }
+
+  public override async void _undo () throws GLib.Error {
+    return_if_reached ();
+  }
+}
diff --git a/src/contacts-main-window.vala b/src/contacts-main-window.vala
index e395d7e1..42d20739 100644
--- a/src/contacts-main-window.vala
+++ b/src/contacts-main-window.vala
@@ -27,6 +27,7 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     { "stop-editing-contact", stop_editing_contact, "b" },
     { "link-marked-contacts", link_marked_contacts },
     { "delete-marked-contacts", delete_marked_contacts },
+    { "export-marked-contacts", export_marked_contacts },
     // { "share-contact", share_contact },
     { "unlink-contact", unlink_contact },
     { "delete-contact", delete_contact },
@@ -179,6 +180,9 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     unowned var action = lookup_action ("delete-marked-contacts");
     ((SimpleAction) action).set_enabled (n_selected > 0);
 
+    action = lookup_action ("export-marked-contacts");
+    ((SimpleAction) action).set_enabled (n_selected > 0);
+
     action = lookup_action ("link-marked-contacts");
     ((SimpleAction) action).set_enabled (n_selected > 1);
 
@@ -543,6 +547,58 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
     return toast;
   }
 
+  private void export_marked_contacts (GLib.SimpleAction action, GLib.Variant? parameter) {
+    // Take a copy, since we'll unselect everything later
+    var selection = this.marked_contacts.get_selection ().copy ();
+
+    // Go back to normal state as much as possible
+    this.store.selection.unselect_all ();
+    this.marked_contacts.unselect_all ();
+    this.state = UiState.NORMAL;
+
+    // Open up a file chooser
+    var chooser = new Gtk.FileChooserNative (_("Export to file"),
+                                             this,
+                                             Gtk.FileChooserAction.SAVE,
+                                             _("_Export"),
+                                             _("_Cancel"));
+    chooser.set_current_name ("contacts.vcf");
+    chooser.modal = true;
+    chooser.response.connect ((response) => {
+      if (response != Gtk.ResponseType.ACCEPT) {
+        chooser.destroy ();
+        return;
+      }
+
+      // Do the actual export
+      var individuals = bitset_to_individuals (this.store.filter_model,
+                                               selection);
+
+      OutputStream filestream = null;
+      try {
+        filestream = chooser.get_file ().replace (null, false, FileCreateFlags.NONE);
+      } catch (Error err) {
+        warning ("Couldn't create file: %s", err.message);
+        return;
+      }
+
+      var op = new Io.VCardExportOperation (individuals, filestream);
+      this.operations.execute.begin (op, null, (obj, res) => {
+        try {
+          this.operations.execute.end (res);
+          filestream.close ();
+        } catch (Error e) {
+          warning ("ERROR: %s", e.message);
+        }
+      });
+
+      chooser.destroy ();
+      add_toast_for_operation (op);
+    });
+
+    chooser.show ();
+  }
+
   // Little helper
   private Gee.LinkedList<Individual> bitset_to_individuals (GLib.ListModel model,
                                                             Gtk.Bitset bitset) {
diff --git a/src/io/contacts-io-export-operation.vala b/src/io/contacts-io-export-operation.vala
new file mode 100644
index 00000000..65a9d1e1
--- /dev/null
+++ b/src/io/contacts-io-export-operation.vala
@@ -0,0 +1,49 @@
+/*
+ * 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;
+
+/**
+ * An Io.ExportOperation is an object that can deal with exporting one or more
+ * contacts ({@link Folks.Individual}s) into a serialized format (VCard is the
+ * most common example, but there exist also CSV based formats and others).
+ *
+ * Note that unlike a Io.Importer, we can skip the whole {@link GLib.HashTable}
+ * dance, since we aren't dealing with untrusted data anymore.
+ */
+public abstract class Contacts.Io.ExportOperation : Contacts.Operation {
+
+  /** The list of individuals that will be exported */
+  public Gee.List<Individual> individuals { get; construct set; }
+
+  /**
+   * The generic output stream to export the individuals to.
+   *
+   * If you want to export to:
+   * - a file, use the result of {@link GLib.File.create}
+   * - a string, create a {@link GLib.MemoryOutputStream} and append a '\0'
+   *   terminator at the end
+   * - ...
+   */
+  public GLib.OutputStream output { get; construct set; }
+
+  public override bool reversable { get { return false; } }
+
+  protected override async void _undo () throws GLib.Error {
+    // No need to do anything, since reversable is false
+  }
+}
diff --git a/src/io/contacts-io-parse-main.vala b/src/io/contacts-io-parse-main.vala
new file mode 100644
index 00000000..5c44a3f7
--- /dev/null
+++ b/src/io/contacts-io-parse-main.vala
@@ -0,0 +1,61 @@
+/*
+ * 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.Parser parser;
+  switch (import_type) {
+    case "vcard":
+      parser = new Contacts.Io.VCardParser ();
+      break;
+    default:
+      error ("Unknown import type '%s'", import_type);
+  }
+
+  HashTable<string, Value?>[] details_list;
+  try {
+    var file = File.new_for_path (path);
+    var file_stream = file.read (null);
+    details_list = parser.parse (file_stream);
+  } catch (Error err) {
+    error ("Error while importing file '%s': %s", path, err.message);
+  }
+
+  // Serialize
+  var serialized = Contacts.Io.serialize_to_gvariant (details_list);
+
+  // TODO: Switch to raw bytes (performance). Use variant.print/parse while we're ironing out bugs
+#if 0
+  var bytes = serialized.get_data_as_bytes ();
+  stdout.write (bytes.get_data (), bytes.get_size ());
+#endif
+  stdout.write (serialized.print (false).data);
+
+  return 0;
+}
diff --git a/src/io/contacts-io-parse-operation.vala b/src/io/contacts-io-parse-operation.vala
new file mode 100644
index 00000000..8666b06f
--- /dev/null
+++ b/src/io/contacts-io-parse-operation.vala
@@ -0,0 +1,106 @@
+/*
+ * 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 ParseOperation launches a subprocess which asynchronously
+ * parses the given input into a set of {@link GLib.HashTable}s,
+ * which can then be imported using a
+ * {@link Contacts.Io.ImportOperation}
+ */
+public class Contacts.Io.ParseOperation : Operation {
+
+  private File input_file;
+
+  public override bool reversable { get { return false; } }
+
+  private string _description;
+  public override string description { owned get { return this._description; } }
+
+  /** The parsed output  */
+  private GenericArray<HashTable<string, Value?>> parsed
+    = new GenericArray<HashTable<string, Value?>> ();
+
+  public ParseOperation (File file) {
+    this._description = _("Importing contacts from '%s'").printf (file.get_uri ());
+
+    this.input_file = file;
+  }
+
+  public override async void execute () throws GLib.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 parse subprocess");
+    var subprocess = launcher.spawnv ({
+        Config.LIBEXECDIR + "/gnome-contacts/gnome-contacts-parser",
+        "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 ();
+
+    // Parse into a GLib.Variant
+    unowned var serialized_str = (string) stdout_stream.get_data ();
+    var variant = Variant.parse (new VariantType ("aa{sv}"), serialized_str);
+
+    // Now parse each into a hashtables
+    var new_details_list = Contacts.Io.deserialize_gvariant (variant);
+    foreach (unowned var new_details in new_details_list) {
+      if (new_details.size () == 0) {
+        warning ("Imported contact has zero fields, ignoring");
+        return;
+      }
+
+      this.parsed.add (new_details);
+    }
+  }
+
+  public override async void _undo () throws GLib.Error {
+    return_if_reached ();
+  }
+
+  public unowned HashTable<string, Value?>[] get_parsed_result () {
+    return this.parsed.data;
+  }
+
+  public HashTable<string, Value?>[] steal_parsed_result () {
+    return this.parsed.steal ();
+  }
+}
diff --git a/src/io/contacts-io-parser.vala b/src/io/contacts-io-parser.vala
new file mode 100644
index 00000000..7c04a268
--- /dev/null
+++ b/src/io/contacts-io-parser.vala
@@ -0,0 +1,39 @@
+/*
+ * 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;
+
+/**
+ * An Parser is an object that can deal with importing a specific format
+ * of describing a Contact (VCard is the most common example, but there exist
+ * also CSV based formats and others).
+ *
+ * The main purpose of an Io.Parser is to parser whatever input it gets into a
+ * {@link GLib.HashTable} with string keys and {@link Value} as values. After
+ * that, we can choose to either serialize (using the serializing methods in
+ * Contacts.Io), or to immediately import it in folks using
+ * {@link Folks.PersonaStore.add_from_details}.
+ */
+public abstract class Contacts.Io.Parser : Object {
+
+  /**
+   * Takes the given input stream 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?>[] parse (InputStream input) throws GLib.Error;
+}
diff --git a/src/io/contacts-io-vcard-export-operation.vala b/src/io/contacts-io-vcard-export-operation.vala
new file mode 100644
index 00000000..94b36591
--- /dev/null
+++ b/src/io/contacts-io-vcard-export-operation.vala
@@ -0,0 +1,269 @@
+/*
+ * 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;
+
+/**
+ * An implementation of {@link Contacts.Io.Exporter} that serializes a contact
+ * to the VCard format.
+ *
+ * Internally, it uses the E.VCard class to implement most of the logic.
+ */
+public class Contacts.Io.VCardExportOperation : ExportOperation {
+
+  // We _could_ parameterize this with our own enum, but there's no need for
+  // that at the moment.
+  private E.VCardFormat vcard_format = E.VCardFormat.@30;
+
+  // This should always be on false, except for debugging/troubleshooting
+  // purposes. It forces E-D-S personas to use our manual serialization instead
+  // of just returning their own internal E.VCard representation
+  private bool avoid_eds = false;
+
+  private string _description;
+  public override string description { owned get { return this._description; } }
+
+  public VCardExportOperation (Gee.List<Individual> individuals,
+                               GLib.OutputStream output) {
+    Object(individuals: individuals, output: output);
+
+    this._description = ngettext ("Exported %d contact",
+                                  "Exported %d contacts",
+                                  individuals.size).printf (individuals.size);
+  }
+
+  public override async void execute () throws GLib.Error {
+    foreach (var individual in this.individuals) {
+      // FIXME: should we aggregate personas somehow?
+
+      foreach (var persona in individual.personas) {
+        string vcard_str = persona_to_vcard (persona);
+        size_t written;
+        this.output.write_all (vcard_str.data, out written);
+        this.output.write_all ("\r\n\r\n".data, out written);
+      }
+    }
+  }
+
+  private string persona_to_vcard (Persona persona) {
+    // Take a shortcut in case we have an Edsf.Persona, since
+    // that's an E.VCard already
+    if (persona is Edsf.Persona && !avoid_eds) {
+      unowned var contact = ((Edsf.Persona) persona).contact;
+      return contact.to_string (this.vcard_format);
+    }
+
+    var vcard = new E.VCard ();
+
+    if (persona is AvatarDetails)
+      vcard_set_avatar_details (vcard, (AvatarDetails) persona);
+    if (persona is BirthdayDetails)
+      vcard_set_birthday_details (vcard, (BirthdayDetails) persona);
+    if (persona is EmailDetails)
+      vcard_set_email_details (vcard, (EmailDetails) persona);
+    if (persona is FavouriteDetails)
+      vcard_set_favourite_details (vcard, (FavouriteDetails) persona);
+    if (persona is NameDetails)
+      vcard_set_name_details (vcard, (NameDetails) persona);
+    if (persona is NoteDetails)
+      vcard_set_note_details (vcard, (NoteDetails) persona);
+    if (persona is PhoneDetails)
+      vcard_set_phone_details (vcard, (PhoneDetails) persona);
+    if (persona is PostalAddressDetails)
+      vcard_set_postal_address_details (vcard, (PostalAddressDetails) persona);
+    if (persona is RoleDetails)
+      vcard_set_role_details (vcard, (RoleDetails) persona);
+    if (persona is UrlDetails)
+      vcard_set_url_details (vcard, (UrlDetails) persona);
+
+    // The following don't really map properly atm, or are just not worth it.
+    // If we still want/need them later, we can add them still of course
+/*
+    if (persona is AliasDetails)
+      vcard_set_alias_details (vcard, (AliasDetails) persona);
+    if (persona is ExtendedInfo)
+      vcard_set_extended_info (vcard, (ExtendedInfo) persona);
+    if (persona is GenderDetails)
+      vcard_set_gender_details (vcard, (GenderDetails) persona);
+    if (persona is GroupDetails)
+      vcard_set_group_details (vcard, (GroupDetails) persona);
+    if (persona is ImDetails)
+      vcard_set_im_details (vcard, (ImDetails) persona);
+    if (persona is InteractionDetails)
+      vcard_set_interaction_details (vcard, (InteractionDetails) persona);
+    if (persona is LocalIdDetails)
+      vcard_set_localid_details (vcard, (LocalIdDetails) persona);
+    if (persona is LocationDetails)
+      vcard_set_location_details (vcard, (LocationDetails) persona);
+    if (persona is PresenceDetails)
+      vcard_set_presence_details (vcard, (PresenceDetails) persona);
+    if (persona is WebServiceDetails)
+      vcard_set_webservice_details (vcard, (WebServiceDetails) persona);
+*/
+
+    return vcard.to_string (this.vcard_format);
+  }
+
+  private void vcard_set_avatar_details (E.VCard vcard,
+                                         AvatarDetails details) {
+    // FIXME: not sure how we want to do this in such as way that doesn't break
+    // inside a sandbox or without embedding the data directly (which will blow
+    // up the file size)
+  }
+
+  private void vcard_set_birthday_details (E.VCard vcard,
+                                           BirthdayDetails details) {
+    if (details.birthday == null)
+      return;
+
+    var attr = new E.VCardAttribute (null, E.EVC_BDAY);
+    attr.add_param_with_value (new E.VCardAttributeParam (E.EVC_VALUE), "DATE");
+    vcard.add_attribute_with_value ((owned) attr, details.birthday.format ("%F"));
+  }
+
+  private void vcard_set_email_details (E.VCard vcard,
+                                        EmailDetails details) {
+    foreach (var email_field in details.email_addresses) {
+      if (email_field.value == "")
+        continue;
+
+      var attr = new E.VCardAttribute (null, E.EVC_EMAIL);
+      vcard.add_attribute_with_value (attr, email_field.value);
+      add_parameters_for_field_details (attr, email_field);
+    }
+  }
+
+  private void vcard_set_favourite_details (E.VCard vcard,
+                                            FavouriteDetails details) {
+    if (details.is_favourite) {
+      // See Edsf.Persona
+      var attr = new E.VCardAttribute (null, "X-FOLKS-FAVOURITE");
+      vcard.add_attribute_with_value ((owned) attr, "true");
+    }
+  }
+
+  private void vcard_set_name_details (E.VCard vcard,
+                                       NameDetails details) {
+    if (details.full_name != "") {
+      vcard.add_attribute_with_value (new E.VCardAttribute (null, E.EVC_FN),
+                                      details.full_name);
+    }
+
+    if (details.structured_name != null) {
+      var attr = new E.VCardAttribute (null, E.EVC_N);
+
+      attr.add_value (details.structured_name.family_name);
+      attr.add_value (details.structured_name.given_name);
+      attr.add_value (details.structured_name.additional_names);
+      attr.add_value (details.structured_name.prefixes);
+      attr.add_value (details.structured_name.suffixes);
+
+      vcard.add_attribute ((owned) attr);
+    }
+
+    if (details.nickname != "") {
+      vcard.add_attribute_with_value (new E.VCardAttribute (null, E.EVC_NICKNAME),
+                                      details.nickname);
+    }
+  }
+
+  private void vcard_set_note_details (E.VCard vcard,
+                                       NoteDetails details) {
+    foreach (var note_field in details.notes) {
+      if (note_field.value == "")
+        continue;
+
+      var attr = new E.VCardAttribute (null, E.EVC_NOTE);
+      add_parameters_for_field_details (attr, note_field);
+      vcard.add_attribute_with_value ((owned) attr, note_field.value);
+    }
+  }
+
+  private void vcard_set_phone_details (E.VCard vcard,
+                                        PhoneDetails details) {
+    foreach (var phone_field in details.phone_numbers) {
+      if (phone_field.value == "")
+        continue;
+
+      var attr = new E.VCardAttribute (null, E.EVC_TEL);
+      add_parameters_for_field_details (attr, phone_field);
+      vcard.add_attribute_with_value ((owned) attr, phone_field.value);
+    }
+  }
+
+  private void vcard_set_postal_address_details (E.VCard vcard,
+                                                 PostalAddressDetails details) {
+    foreach (var postal_field in details.postal_addresses) {
+      unowned var addr = postal_field.value;
+      if (addr.is_empty ())
+        continue;
+
+      var attr = new E.VCardAttribute (null, E.EVC_ADR);
+      add_parameters_for_field_details (attr, postal_field);
+
+      attr.add_value (addr.po_box);
+      attr.add_value (addr.extension);
+      attr.add_value (addr.street);
+      attr.add_value (addr.locality);
+      attr.add_value (addr.region);
+      attr.add_value (addr.postal_code);
+      attr.add_value (addr.country);
+
+      vcard.add_attribute ((owned) attr);
+    }
+  }
+
+  private void vcard_set_role_details (E.VCard vcard,
+                                       RoleDetails details) {
+    foreach (var role_field in details.roles) {
+      if (role_field.value.title != "") {
+        vcard.add_attribute_with_value (new E.VCardAttribute (null, E.EVC_TITLE),
+                                        role_field.value.title);
+      }
+      if (role_field.value.organisation_name != "") {
+        vcard.add_attribute_with_value (new E.VCardAttribute (null, E.EVC_ORG),
+                                        role_field.value.organisation_name);
+      }
+    }
+  }
+
+  private void vcard_set_url_details (E.VCard vcard,
+                                      UrlDetails details) {
+    foreach (var url_field in details.urls) {
+      if (url_field.value == "")
+        continue;
+
+      var attr = new E.VCardAttribute (null, E.EVC_URL);
+      add_parameters_for_field_details (attr, url_field);
+      vcard.add_attribute_with_value ((owned) attr, url_field.value);
+    }
+  }
+
+  // Helper to get common parameters (e.g. type)
+  private void add_parameters_for_field_details (E.VCardAttribute attr,
+                                                 AbstractFieldDetails field) {
+    Gee.Collection<string>? param_values = null;
+
+    param_values = field.get_parameter_values (AbstractFieldDetails.PARAM_TYPE);
+    if (param_values != null && !param_values.is_empty) {
+      var param = new E.VCardAttributeParam (E.EVC_TYPE);
+      foreach (var typestr in param_values)
+        param.add_value (typestr.up ());
+      attr.add_param ((owned) param);
+    }
+  }
+}
diff --git a/src/io/contacts-io-vcard-parser.vala b/src/io/contacts-io-vcard-parser.vala
new file mode 100644
index 00000000..97cff611
--- /dev/null
+++ b/src/io/contacts-io-vcard-parser.vala
@@ -0,0 +1,299 @@
+/*
+ * 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 {@link Contacts.Io.Parser} that specifically deals with parsing VCard
+ */
+public class Contacts.Io.VCardParser : Contacts.Io.Parser {
+
+  public VCardParser () {
+  }
+
+  public override HashTable<string, Value?>[] parse (InputStream input) throws GLib.Error {
+    // Read the whole input into a string.
+    // We can probably do better, but that takes a bit of extra work
+    var memory_stream = new MemoryOutputStream.resizable ();
+    memory_stream.splice (input, 0, null);
+    memory_stream.write ("\0".data);
+    memory_stream.close ();
+    var input_str = (string) memory_stream.get_data ();
+
+    var result = new GenericArray<HashTable<string, Value?>> ();
+
+    // Parse the input stream into a set of vcards
+    int begin_index = input_str.index_of ("BEGIN:VCARD");
+    while (begin_index != -1) {
+      // Find the END:VCARD attribute to know the substring
+      int end_vcard_index = input_str.index_of ("END:VCARD", begin_index + 1);
+      int end_index = end_vcard_index + "END:VCARD".length;
+      var vcard_str = input_str[begin_index:end_index];
+
+      // Parse this VCard
+      var vcard = new E.VCard.from_string (vcard_str);
+      // FIXME: we should have some kind of error check here
+
+      unowned var vcard_attrs = vcard.get_attributes ();
+      debug ("Got %u attributes in this vcard", vcard_attrs.length ());
+
+      var details = new HashTable<string, Value?> (GLib.str_hash, GLib.str_equal);
+      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;
+/* FIXME
+          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;
+        }
+      }
+
+      result.add (details);
+
+      begin_index = input_str.index_of ("BEGIN:VCARD", end_index);
+    }
+
+    return result.steal ();
+  }
+
+  // Handles the "FN" (Full Name) attribute
+  private void handle_fn (HashTable<string, Value?> details,
+                          E.VCardAttribute attr) {
+    var full_name = attr.get_value ();
+    debug ("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 ();
+    debug ("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..743a38c2
--- /dev/null
+++ b/src/io/contacts-io.vala
@@ -0,0 +1,465 @@
+/*
+ * 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;
+
+/**
+ * Everything in the Io namespace deals with importing and exporting contacts,
+ * both internally (between Contacts and a subprocess, using {@link GLib.Variant}
+ * serialization) and externally (VCard, CSV, ...).
+ */
+namespace Contacts.Io {
+
+  /**
+   * Serializes a list of {@link GLib.HashTable}s as returned by a
+   * {@link Contacts.Io.Parser} 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_list) {
+    var builder = new GLib.VariantBuilder (new VariantType ("aa{sv}"));
+
+    foreach (unowned var details in details_list) {
+      builder.add_value (serialize_to_gvariant_single (details));
+    }
+
+    return builder.end ();
+  }
+
+  /**
+   * Serializes a single {@link GLib.HashTable} into a {@link GLib.Variant}.
+   */
+  public GLib.Variant serialize_to_gvariant_single (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 (new VariantType ("aa{sv}")), null);
+
+    var result = new GenericArray<HashTable<string, Value?>> ();
+
+    var iter = variant.iterator ();
+    GLib.Variant element;
+    while (iter.next ("@a{sv}", out element)) {
+      result.add (deserialize_gvariant_single (element));
+    }
+
+    return result.steal ();
+  }
+
+  /**
+   * Deserializes the {@link GLib.Variant} back into a {@link GLib.HashTable}.
+   */
+  public HashTable<string, Value?> deserialize_gvariant_single (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_str (details, prop, variant,
+                                (str) => { return new PhoneFieldDetails (str); });
+  }
+
+  //
+  // 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_str (details, prop, variant,
+                                (str) => { return new EmailFieldDetails (str); });
+  }
+
+  //
+  // 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_str (details, prop, variant,
+                                (str) => { return new NoteFieldDetails (str); });
+  }
+
+  //
+  // 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_str (details, prop, variant,
+                                (str) => { return new UrlFieldDetails (str); });
+  }
+
+  //
+  // HELPER: AbstractFielDdetail<string>
+  // -----------------------------------
+  private const string AFD_STRING_TYPE = "(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 (AFD_STRING_TYPE, afd.value, serialize_parameters (afd));
+    }
+
+    dict.insert_value (prop, builder.end ());
+
+    return true;
+  }
+
+  // In an ideal world, we wouldn't need this delegate and we could just use
+  // GLib.Object.new(), but this is Vala and generics, so we find ourselves in
+  // a big mess here
+  delegate AbstractFieldDetails<string> CreateAbstractFieldStrFunc(string value);
+
+  private bool deserialize_afd_str (HashTable<string, Value?> details,
+                                    string prop,
+                                    Variant variant,
+                                    CreateAbstractFieldStrFunc create_afd_func) {
+    return_val_if_fail (variant.get_type ().equal (new VariantType ("a" + AFD_STRING_TYPE)), false);
+
+    var afds = new Gee.HashSet<AbstractFieldDetails> ();
+
+    // Turn the array variant into a set of field details
+    var iter = variant.iterator ();
+    string str;
+    GLib.Variant parameters;
+    while (iter.next (AFD_STRING_TYPE, out str, out parameters)) {
+      AbstractFieldDetails afd = create_afd_func (str);
+      deserialize_parameters (parameters, afd);
+
+      afds.add (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..b4bb5122
--- /dev/null
+++ b/src/io/meson.build
@@ -0,0 +1,33 @@
+contacts_io_sources = files(
+  'contacts-io-parser.vala',
+  'contacts-io-vcard-parser.vala',
+  'contacts-io.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,
+  folks_eds,
+  gee,
+  gio_unix,
+  glib,
+  libebook,
+]
+
+executable('gnome-contacts-parser',
+  contacts_io_sources + [ 'contacts-io-parse-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 eb55c0b3..0710d4a9 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',
@@ -10,6 +12,7 @@ libcontacts_sources = files(
   'contacts-esd-setup.vala',
   'contacts-fake-persona-store.vala',
   'contacts-im-service.vala',
+  'contacts-import-operation.vala',
   'contacts-individual-sorter.vala',
   'contacts-link-operation.vala',
   'contacts-operation.vala',
@@ -21,6 +24,11 @@ libcontacts_sources = files(
   'contacts-unlink-operation.vala',
   'contacts-utils.vala',
   'contacts-vcard-type-mapping.vala',
+
+  'io/contacts-io-export-operation.vala',
+  'io/contacts-io-vcard-export-operation.vala',
+  'io/contacts-io-parse-operation.vala',
+  'io/contacts-io.vala',
 )
 
 contacts_vala_args = [
diff --git a/tests/io/internal/meson.build b/tests/io/internal/meson.build
new file mode 100644
index 00000000..82590eff
--- /dev/null
+++ b/tests/io/internal/meson.build
@@ -0,0 +1,29 @@
+io_internal_testlib = library('io-internal-testlib',
+  files('test-serialise-common.vala'),
+  dependencies: libcontacts_dep,
+)
+
+io_internal_testlib_dep = declare_dependency(
+  link_with: io_internal_testlib,
+  include_directories: include_directories('.'),
+)
+
+io_internal_test_names = [
+  'serialise-full-name',
+  'serialise-structured-name',
+  'serialise-nickname',
+  'serialise-birthday',
+  'serialise-emails',
+  'serialise-urls',
+]
+
+foreach _test : io_internal_test_names
+  test_bin = executable(_test,
+    files('test-'+_test+'.vala'),
+    dependencies: [ libcontacts_dep, io_internal_testlib_dep ],
+  )
+
+  test(_test, test_bin,
+    suite: 'io-internal',
+  )
+endforeach
diff --git a/tests/io/internal/test-serialise-birthday.vala b/tests/io/internal/test-serialise-birthday.vala
new file mode 100644
index 00000000..46beef2e
--- /dev/null
+++ b/tests/io/internal/test-serialise-birthday.vala
@@ -0,0 +1,54 @@
+/*
+ * 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_birthday",
+                 Contacts.Tests.Io.test_serialize_birthday);
+  Test.add_func ("/io/serialize_birthday_pre_epoch",
+                 Contacts.Tests.Io.test_serialize_birthday_pre_epoch);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+
+  private void test_serialize_birthday () {
+    unowned var bd_key = PersonaStore.detail_key (PersonaDetail.BIRTHDAY);
+
+    DateTime old_bd = new GLib.DateTime.utc (1992, 8, 1, 0, 0, 0);
+    var old_bd_val = Value (typeof (DateTime));
+    old_bd_val.set_boxed (old_bd);
+
+    var new_bd_val = _transform_single_value (bd_key, old_bd_val);
+    assert_true (new_bd_val.type () == typeof (DateTime));
+    assert_true (old_bd.equal ((DateTime) new_bd_val.get_boxed ()));
+  }
+
+  private void test_serialize_birthday_pre_epoch () {
+    unowned var bd_key = PersonaStore.detail_key (PersonaDetail.BIRTHDAY);
+
+    DateTime old_bd = new GLib.DateTime.utc (1961, 7, 3, 0, 0, 0);
+    var old_bd_val = Value (typeof (DateTime));
+    old_bd_val.set_boxed (old_bd);
+
+    var new_bd_val = _transform_single_value (bd_key, old_bd_val);
+    assert_true (new_bd_val.type () == typeof (DateTime));
+    assert_true (old_bd.equal ((DateTime) new_bd_val.get_boxed ()));
+  }
+}
diff --git a/tests/io/internal/test-serialise-common.vala b/tests/io/internal/test-serialise-common.vala
new file mode 100644
index 00000000..8407e2c2
--- /dev/null
+++ b/tests/io/internal/test-serialise-common.vala
@@ -0,0 +1,66 @@
+/*
+ * 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;
+
+namespace Contacts.Tests.Io {
+
+  // Helper to serialize and deserialize an AbstractFieldDetails
+  public T _transform_single_afd<T> (string prop_key, T afd) {
+    Gee.Set<T> afd_set = new Gee.HashSet<T> ();
+    afd_set.add (afd);
+
+    Value val = Value (typeof (Gee.Set));
+    val.set_object (afd_set);
+
+    Value emails_value = _transform_single_value (prop_key, val);
+    var emails_set = emails_value.get_object () as Gee.Set<T>;
+    if (emails_set == null)
+      error ("GValue has null value");
+    if (emails_set.size != 1)
+      error ("Expected %d elements but got %d", 1, emails_set.size);
+
+    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
+  public 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
+    Variant serialized = Contacts.Io.serialize_to_gvariant_single (details);
+    if (serialized == null)
+      error ("Couldn't serialize single-value table for property %s", prop_key);
+
+    // Deserialize
+    var details_deserialized = Contacts.Io.deserialize_gvariant_single (serialized);
+    if (details_deserialized == null)
+      error ("Couldn't deserialize details for property %s", prop_key);
+
+    if (!details_deserialized.contains (prop_key))
+      error ("Deserialized details doesn't contain value for property %s", prop_key);
+    Value? val_deserialized = details_deserialized.lookup (prop_key);
+    if (val_deserialized.type() == GLib.Type.NONE)
+      error ("Deserialized Value is unset");
+
+    return val_deserialized;
+  }
+}
diff --git a/tests/io/internal/test-serialise-emails.vala b/tests/io/internal/test-serialise-emails.vala
new file mode 100644
index 00000000..27b15ac0
--- /dev/null
+++ b/tests/io/internal/test-serialise-emails.vala
@@ -0,0 +1,41 @@
+/*
+ * 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_emails",
+                 Contacts.Tests.Io.test_serialize_emails);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+
+  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);
+
+    if (!(new_fd is EmailFieldDetails))
+      error ("Expected EmailFieldDetails but got %s", new_fd.get_type ().name ());
+
+    if (old_fd.value != new_fd.value)
+      error ("Expected '%s' but got '%s'", old_fd.value, new_fd.value);
+  }
+}
diff --git a/tests/io/internal/test-serialise-full-name.vala b/tests/io/internal/test-serialise-full-name.vala
new file mode 100644
index 00000000..9da8319f
--- /dev/null
+++ b/tests/io/internal/test-serialise-full-name.vala
@@ -0,0 +1,42 @@
+/*
+ * 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_simple",
+                 Contacts.Tests.Io.test_serialize_full_name_simple);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+
+  private void test_serialize_full_name_simple () {
+    unowned var fn_key = PersonaStore.detail_key (PersonaDetail.FULL_NAME);
+
+    string old_fn = "Niels De Graef";
+    Value 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);
+    if (new_fn_val.type () != typeof (string))
+      error ("Expected G_TYPE_STRING but got %s", new_fn_val.type ().name ());
+    if (old_fn != new_fn_val.get_string ())
+      error ("Expected '%s' but got '%s'", old_fn, new_fn_val.get_string ());
+  }
+}
diff --git a/tests/io/internal/test-serialise-nickname.vala b/tests/io/internal/test-serialise-nickname.vala
new file mode 100644
index 00000000..649b6382
--- /dev/null
+++ b/tests/io/internal/test-serialise-nickname.vala
@@ -0,0 +1,40 @@
+/*
+ * 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_nickame",
+                 Contacts.Tests.Io.test_serialize_nickname);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+
+  private void test_serialize_nickname () {
+    unowned var nick_key = PersonaStore.detail_key (PersonaDetail.NICKNAME);
+
+    string old_nick = "nielsdg";
+    var old_nick_val = Value (typeof (string));
+    old_nick_val.set_string (old_nick);
+
+    var new_nick_val = _transform_single_value (nick_key, old_nick_val);
+    assert_true (new_nick_val.type () == typeof (string));
+    assert_true (old_nick == new_nick_val.get_string ());
+  }
+}
diff --git a/tests/io/internal/test-serialise-structured-name.vala 
b/tests/io/internal/test-serialise-structured-name.vala
new file mode 100644
index 00000000..45f2093e
--- /dev/null
+++ b/tests/io/internal/test-serialise-structured-name.vala
@@ -0,0 +1,45 @@
+/*
+ * 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_structured_name_simple",
+                 Contacts.Tests.Io.test_serialize_structured_name_simple);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+
+  private void test_serialize_structured_name_simple () {
+    unowned var sn_key = PersonaStore.detail_key (PersonaDetail.STRUCTURED_NAME);
+
+    var old_sn = new StructuredName.simple ("Niels", "De Graef");
+    Value old_sn_val = Value (typeof (StructuredName));
+    old_sn_val.set_object (old_sn);
+
+    var new_sn_val = _transform_single_value (sn_key, old_sn_val);
+
+    if (new_sn_val.type () != typeof (StructuredName))
+      error ("Expected FOLKS_TYPE_STRUCTURED_NAME but got %s", new_sn_val.type ().name ());
+
+    var new_sn = new_sn_val.get_object () as StructuredName;
+    if (!old_sn.equal (new_sn))
+      error ("Expected '%s' but got '%s'", old_sn.to_string (), new_sn.to_string ());
+  }
+}
diff --git a/tests/io/internal/test-serialise-urls.vala b/tests/io/internal/test-serialise-urls.vala
new file mode 100644
index 00000000..cf4cdf90
--- /dev/null
+++ b/tests/io/internal/test-serialise-urls.vala
@@ -0,0 +1,41 @@
+/*
+ * 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_urls_single",
+                 Contacts.Tests.Io.test_serialize_urls_single);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+
+  private void test_serialize_urls_single () {
+    unowned var urls_key = PersonaStore.detail_key (PersonaDetail.URLS);
+
+    var old_fd = new UrlFieldDetails ("http://www.islinuxaboutchoice.com/";);
+    var new_fd = _transform_single_afd<UrlFieldDetails> (urls_key, old_fd);
+
+    if (!(new_fd is UrlFieldDetails))
+      error ("Expected UrlFieldDetails but got %s", new_fd.get_type ().name ());
+
+    if (old_fd.value != new_fd.value)
+      error ("Expected '%s' but got '%s'", old_fd.value, new_fd.value);
+  }
+}
diff --git a/tests/io/meson.build b/tests/io/meson.build
new file mode 100644
index 00000000..2f349605
--- /dev/null
+++ b/tests/io/meson.build
@@ -0,0 +1,2 @@
+subdir('internal')
+subdir('vcard')
diff --git a/tests/io/vcard/meson.build b/tests/io/vcard/meson.build
new file mode 100644
index 00000000..9967815e
--- /dev/null
+++ b/tests/io/vcard/meson.build
@@ -0,0 +1,32 @@
+io_vcard_files = [
+  'minimal',
+]
+
+test_deps = [
+  gee,
+  folks,
+  libebook,
+]
+
+foreach vcard_name : io_vcard_files
+  vcf_file = meson.current_source_dir() / vcard_name + '.vcf'
+
+  # Ideally we'd do this using a preprocessor symbol or something
+  vcf_test_env = environment()
+  vcf_test_env.append('_VCF_FILE', vcf_file)
+
+  test_sources = [
+    contacts_io_sources,
+    'test-vcard-'+vcard_name+'-import.vala',
+  ]
+
+  test_bin = executable(vcard_name,
+    test_sources,
+    dependencies: test_deps,
+  )
+
+  test(vcard_name, test_bin,
+    suite: 'io-vcard',
+    env: vcf_test_env,
+  )
+endforeach
diff --git a/tests/io/vcard/minimal.vcf b/tests/io/vcard/minimal.vcf
new file mode 100644
index 00000000..b360c5a6
--- /dev/null
+++ b/tests/io/vcard/minimal.vcf
@@ -0,0 +1,4 @@
+BEGIN:VCARD
+VERSION:3.0
+FN:Niels De Graef
+END:VCARD
diff --git a/tests/io/vcard/test-vcard-minimal-import.vala b/tests/io/vcard/test-vcard-minimal-import.vala
new file mode 100644
index 00000000..bef6596b
--- /dev/null
+++ b/tests/io/vcard/test-vcard-minimal-import.vala
@@ -0,0 +1,61 @@
+/*
+ * 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/test_vcard_minimal",
+                 Contacts.Tests.Io.test_vcard_minimal);
+  Test.run ();
+}
+
+namespace Contacts.Tests.Io {
+  private void test_vcard_minimal () {
+    unowned var vcf_path = Environment.get_variable ("_VCF_FILE");
+    if (vcf_path == null || vcf_path == "")
+      error ("No .vcf file set as envvar. Please use the meson test suite");
+
+    var file = GLib.File.new_for_path (vcf_path);
+    if (!file.query_exists ())
+      error (".vcf file that is used as test input doesn't exist");
+
+    var parser = new Contacts.Io.VCardParser ();
+    HashTable<string, Value?>[] details_list = null;
+    try {
+      details_list = parser.parse (file.read (null));
+    } catch (Error err) {
+      error ("Error while importing: %s", err.message);
+    }
+    if (details_list == null)
+      error ("VCardParser returned null");
+
+    if (details_list.length != 1)
+      error ("VCardParser parsed %u elements instead of 1", details_list.length);
+
+    unowned var details = details_list[0];
+
+    unowned var fn_key = PersonaStore.detail_key (PersonaDetail.FULL_NAME);
+    if (!details.contains (fn_key))
+      error ("No FN value");
+
+    var fn_value = details.lookup (fn_key);
+    unowned var fn = fn_value as string;
+    if (fn != "Niels De Graef")
+      error ("Expected '%s' but got '%s'", "Niels De Graef", fn);
+  }
+}
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
diff --git a/vapi/config.vapi b/vapi/config.vapi
index 45fb07e9..090d8c5b 100644
--- a/vapi/config.vapi
+++ b/vapi/config.vapi
@@ -16,5 +16,6 @@ public const string GETTEXT_PACKAGE;
 /* Configured paths - these variables are not present in config.h, they are
 * passed to underlying C code as cmd line macros. */
 public const string LOCALEDIR; /* /usr/local/share/locale */
+public const string LIBEXECDIR;
 }
 


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