diff options
author | Niels De Graef <nielsdegraef@gmail.com> | 2021-01-11 19:22:17 +0100 |
---|---|---|
committer | Niels De Graef <nielsdegraef@gmail.com> | 2022-08-06 12:45:49 +0200 |
commit | fcbc87c40406b322513c209844d3430bc4108b13 (patch) | |
tree | 74efc305e00d893a72b3c8fb22017435b6792111 | |
parent | f37176d5e04b3dc7cc2ad8cb9843d7d7fc35485e (diff) | |
download | gnome-contacts-fcbc87c40406b322513c209844d3430bc4108b13.tar.gz |
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
28 files changed, 2023 insertions, 3 deletions
diff --git a/data/ui/contacts-main-window.ui b/data/ui/contacts-main-window.ui index 289c56c..48e557b 100644 --- a/data/ui/contacts-main-window.ui +++ b/data/ui/contacts-main-window.ui @@ -16,6 +16,12 @@ </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> <attribute name="action">app.show-preferences</attribute> </item> @@ -152,9 +158,17 @@ <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 307a3e3..fef5f6b 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 b127554..b8ff1d7 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 0000000..bb860f8 --- /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 e395d7e..42d2073 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 0000000..65a9d1e --- /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 0000000..5c44a3f --- /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 0000000..8666b06 --- /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 0000000..7c04a26 --- /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 0000000..94b3659 --- /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 0000000..97cff61 --- /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 0000000..743a38c --- /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 0000000..b4bb512 --- /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 eb55c0b..0710d4a 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 0000000..82590ef --- /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 0000000..46beef2 --- /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 0000000..8407e2c --- /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 0000000..27b15ac --- /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 0000000..9da8319 --- /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 0000000..649b638 --- /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 0000000..45f2093 --- /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 0000000..cf4cdf9 --- /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 0000000..2f34960 --- /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 0000000..9967815 --- /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 0000000..b360c5a --- /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 0000000..bef6596 --- /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 92c3586..6dcfcf1 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 45fb07e..090d8c5 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; } |