/* * Copyright (C) 2011 Alexander Larsson * * 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 . */ using Gtk; using Folks; using Gee; public errordomain ContactError { NOT_IMPLEMENTED, NO_PRIMARY } public class Contacts.Contact : GLib.Object { private weak Store store; public bool is_main; public Individual individual; uint changed_id; public Persona? fake_persona = null; public static bool persona_has_writable_property (Persona persona, string property) { // TODO: This should check the writibility on the FakePersona store, // but that is not availible in folks yet if (persona is FakePersona) return true; foreach (unowned string p in persona.writeable_properties) { if (p == property) return true; } return false; } public signal void changed (); /** * There are 2 reasons why we want to hide a contact in the UI: * 1. The contact is going to be deleted (but isn't yet, since we support undoing) * 2. The contact comes from an uninteresting or untrusted source. */ public bool hidden { get { return this.ignored || this.to_be_deleted; } set { if (this.to_be_deleted != value) { this.to_be_deleted = value; notify_property("hidden"); } } } private bool to_be_deleted; // this.hidden, part 1 private bool ignored; // this.hidden, part 2 public Contact (Store store, Individual i) { this.store = store; this.individual = i; this.individual.set_data ("contact", this); this.individual.notify.connect(notify_cb); this.ignored = is_ignorable (); this.is_main = calc_is_main (); } public static unowned Contact from_individual (Individual i) { return i.get_data ("contact"); } public static bool persona_is_main (Persona persona) { var store = persona.store; if (!store.is_primary_store) return false; // Mark google contacts not in "My Contacts" as non-main return !persona_is_google_other (persona); } private bool calc_is_main () { foreach (var p in this.individual.personas) if (persona_is_main (p)) return true; return false; } public void replace_individual (Individual new_individual) { individual.notify.disconnect(notify_cb); individual = new_individual; individual.set_data ("contact", this); individual.notify.connect(notify_cb); queue_changed (); } public void remove () { unqueue_changed (); individual.notify.disconnect(notify_cb); } private bool is_ignorable () { var i = this.individual.personas.iterator(); // Look for single-persona individuals if (i.next() && !i.has_next ()) { var persona_store = i.get().store; // Filter out pure key-file persona individuals as these are not very interesting if (persona_store.type_id == "key-file") return true; // Filter out uncertain things like link-local xmpp if (persona_store.type_id == "telepathy" && persona_store.trust_level == PersonaStoreTrust.NONE) return true; } return false; } public bool has_email (string email_address) { var addrs = individual.email_addresses; foreach (var detail in addrs) { if (detail.value == email_address) return true; } return false; } private static bool has_pref (AbstractFieldDetails details) { var evolution_pref = details.get_parameter_values ("x-evolution-ui-slot"); if (evolution_pref != null && Utils.get_first (evolution_pref) == "1") return true; foreach (var param in details.parameters["type"]) { if (param.ascii_casecmp ("PREF") == 0) return true; } return false; } private static TypeSet select_typeset_from_fielddetails (AbstractFieldDetails a) { if (a is EmailFieldDetails) return TypeSet.email; if (a is PhoneFieldDetails) return TypeSet.phone; return TypeSet.general; } public static int compare_fields (void* _a, void* _b) { var a = (AbstractFieldDetails) _a; var b = (AbstractFieldDetails) _b; // Fields with a PREF hint always go first (see VCard PREF attribute) var a_has_pref = has_pref (a); if (a_has_pref != has_pref (b)) return (a_has_pref)? -1 : 1; // sort by field type first (e.g. "Home", "Work") var type_set = select_typeset_from_fielddetails (a); var result = type_set.format_type (a).ascii_casecmp (type_set.format_type (b)); if (result != 0) return result; // Try to compare by value if types are equal var aa = a as AbstractFieldDetails; var bb = b as AbstractFieldDetails; if (aa != null && bb != null) return strcmp (aa.value, bb.value); // No heuristics to fall back to. warning ("Unsupported AbstractFieldDetails value type"); return 0; } public static Gee.List sort_fields (Collection fields) { var res = new ArrayList(); res.add_all (fields); res.sort (Contact.compare_fields); return res; } public static string[] format_address (PostalAddress addr) { string[] lines = {}; if (is_set (addr.street)) lines += addr.street; if (is_set (addr.extension)) lines += addr.extension; if (is_set (addr.locality)) lines += addr.locality; if (is_set (addr.region)) lines += addr.region; if (is_set (addr.postal_code)) lines += addr.postal_code; if (is_set (addr.po_box)) lines += addr.po_box; if (is_set (addr.country)) lines += addr.country; if (is_set (addr.address_format)) lines += addr.address_format; return lines; } #if HAVE_TELEPATHY public Tpf.Persona? find_im_persona (string protocol, string im_address) { var iid = protocol + ":" + im_address; foreach (var p in individual.personas) { var tp = p as Tpf.Persona; if (tp != null && tp.iid == iid) { return tp; } } return null; } #endif private bool changed_cb () { this.changed_id = 0; this.is_main = calc_is_main (); changed (); return false; } private void unqueue_changed () { if (changed_id != 0) { Source.remove (changed_id); changed_id = 0; } } public void queue_changed () { if (this.changed_id == 0) this.changed_id = Idle.add (changed_cb); } private void notify_cb (ParamSpec pspec) { queue_changed (); } /* We claim something is "removable" if at least one persona is removable, that will typically unlink the rest. */ public bool can_remove_personas () { foreach (var p in this.individual.personas) if (p.store.can_remove_personas == MaybeBool.TRUE) return true; return false; } public async void remove_personas () throws Folks.PersonaStoreError { var personas = new HashSet (); foreach (var p in this.individual.personas) if (p.store.can_remove_personas == MaybeBool.TRUE) personas.add (p); foreach (var persona in personas) yield persona.store.remove_persona (persona); } public async Persona ensure_primary_persona () throws IndividualAggregatorError, ContactError, PropertyError { Persona? p = find_primary_persona (); if (p != null) return p; // There is no primary persona, so we link all the current personas // together. This will create a new persona in the primary store // that links all the personas together // HACK-ATTACK: // We need to create a fake persona since link_personas is a no-op // for single-persona sets var persona_set = new HashSet(); persona_set.add_all (individual.personas); if (persona_set.size == 1) persona_set.add (new FakePersona (this.store, this)); yield store.aggregator.link_personas (persona_set); p = find_primary_persona (); if (p == null) throw new ContactError.NO_PRIMARY (_("Unexpected internal error: created contact was not found")); return p; } public Gee.List get_personas_for_display () { CompareDataFunc compare_persona_by_store = (a, b) => { var store_a = a.store; var store_b = b.store; // In the same store, sort Google 'other' contacts last if (store_a == store_b) { if (!persona_is_google (a)) return 0; var a_is_other = persona_is_google_other (a); if (a_is_other != persona_is_google_other (b)) return a_is_other? 1 : -1; } // Sort primary stores before others if (store_a.is_primary_store != store_b.is_primary_store) return (store_a.is_primary_store)? -1 : 1; // E-D-S stores get prioritized if ((store_a.type_id == "eds") != (store_b.type_id == "eds")) return (store_a.type_id == "eds")? -1 : 1; // Normal case: use alphabetical sorting return strcmp (store_a.id, store_b.id); }; var persona_list = new ArrayList(); foreach (var persona in individual.personas) if (persona.store.type_id != "key-file") persona_list.add (persona); persona_list.sort ((owned) compare_persona_by_store); return persona_list; } public Persona? find_primary_persona () { foreach (var p in individual.personas) if (p.store.is_primary_store) return p; return null; } public Persona? find_persona_from_uid (string uid) { foreach (var p in individual.personas) { if (p.uid == uid) return p; } if (uid == "uid-fake-persona" && this.fake_persona != null) return this.fake_persona; return null; } public string format_persona_stores () { string stores = ""; bool first = true; foreach (var p in individual.personas) { if (!first) stores += ", "; stores += format_persona_store_name_for_contact (p); first = false; } return stores; } public static string format_persona_store_name (PersonaStore store) { if (store.type_id == "eds") { string? eds_name = lookup_esource_name_by_uid (store.id); if (eds_name != null) return eds_name; } #if HAVE_TELEPATHY if (store.type_id == "telepathy") { var account = (store as Tpf.PersonaStore).account; return Contacts.ImService.get_display_name (account.service); } #endif return store.display_name; } /* These are "regular" address book contacts, i.e. they contain a persona that would be "main" if that persona was the primary store */ public bool has_mainable_persona () { foreach (var p in individual.personas) { if (p.store.type_id == "eds" && !persona_is_google_other (p)) return true; } return false; } /* We never want to suggest linking to google contacts that are not My Contacts nor Profiles */ private bool non_linkable () { bool all_unlinkable = true; foreach (var p in individual.personas) { if (!persona_is_google_other (p) || persona_is_google_profile (p)) all_unlinkable = false; } return all_unlinkable; } public bool suggest_link_to (Contact other) { if (this.non_linkable () || other.non_linkable ()) return false; if (!this.store.may_suggest_link (this, other)) return false; /* Only connect main contacts with non-mainable contacts. non-main contacts can link to any other */ return !this.is_main || !other.has_mainable_persona(); } private static bool persona_is_google (Persona persona) { return persona.store.type_id == "eds" && esource_uid_is_google (persona.store.id); } /** * Return true only for personas which are in a Google address book, but which * are not in the user's "My Contacts" group in the address book. */ public static bool persona_is_google_other (Persona persona) { if (!persona_is_google (persona)) return false; var p = persona as Edsf.Persona; return p != null && !p.in_google_personal_group; } public static bool persona_is_google_profile (Persona persona) { if (!persona_is_google_other (persona)) return false; var u = persona as UrlDetails; if (u != null && u.urls.size == 1) { foreach (var url in u.urls) { if (/https?:\/\/www.google.com\/profiles\/[0-9]+$/.match(url.value)) return true; } } return false; } public static string format_persona_store_name_for_contact (Persona persona) { var store = persona.store; if (store.type_id == "eds") { if (persona_is_google_profile (persona)) return _("Google Circles"); else if (persona_is_google_other (persona)) return _("Google"); string? eds_name = lookup_esource_name_by_uid_for_contact (store.id); if (eds_name != null) return eds_name; } #if HAVE_TELEPATHY if (store.type_id == "telepathy") { var account = (store as Tpf.PersonaStore).account; return Contacts.ImService.get_display_name (account.service); } #endif return store.display_name; } private const string[] SORTED_PROPERTIES = { "email-addresses" , "phone-numbers" , "im-addresses", "urls", "nickname", "birthday", "postal-addresses", "notes" }; public static string[] sort_persona_properties (string[] props) { CompareDataFunc compare_properties = (a, b) => { foreach (var prop in SORTED_PROPERTIES) { if (a == prop) return (b == prop)? 0 : -1; if (b == prop) return 1; } return 0; }; var sorted_props = new ArrayList (); foreach (var s in props) sorted_props.add (s); sorted_props.sort ((owned) compare_properties); return sorted_props.to_array (); } /* Tries to set the property on all persons that have it writeable, and * if none, creates a new persona and writes to it, returning the new * persona. */ public async Persona? set_individual_property (string property_name, Value value) throws GLib.Error, PropertyError { bool did_set = false; // Need to make a copy here as it could change during the yields var personas_copy = this.individual.personas.to_array (); foreach (var p in personas_copy) { if (property_name in p.writeable_properties) { did_set = true; yield Contact.set_persona_property (p, property_name, value); } } if (!did_set) { var fake = new FakePersona (this.store, this); return yield fake.make_real_and_set (property_name, value); } return null; } public static async Persona? create_primary_persona_for_details (Folks.PersonaStore store, HashTable details) throws GLib.Error { var p = yield store.add_persona_from_details (details); return p; } internal static async void set_persona_property (Persona persona, string property_name, Value new_value) throws PropertyError, IndividualAggregatorError, ContactError, PropertyError { if (persona is FakePersona) { var fake = persona as FakePersona; yield fake.make_real_and_set (property_name, new_value); return; } /* FIXME: It should be possible to move these all to being delegates which are * passed to the functions which currently call this one; but only once bgo#604827 is fixed. */ switch (property_name) { case "alias": yield (persona as AliasDetails).change_alias ((string) new_value); break; case "avatar": yield (persona as AvatarDetails).change_avatar ((LoadableIcon?) new_value); break; case "birthday": yield (persona as BirthdayDetails).change_birthday ((DateTime?) new_value); break; case "calendar-event-id": yield (persona as BirthdayDetails).change_calendar_event_id ((string?) new_value); break; case "email-addresses": yield (persona as EmailDetails).change_email_addresses ((Set) new_value); break; case "is-favourite": yield (persona as FavouriteDetails).change_is_favourite ((bool) new_value); break; case "gender": yield (persona as GenderDetails).change_gender ((Gender) new_value); break; case "groups": yield (persona as GroupDetails).change_groups ((Set) new_value); break; case "im-addresses": yield (persona as ImDetails).change_im_addresses ((MultiMap) new_value); break; case "local-ids": yield (persona as LocalIdDetails).change_local_ids ((Set) new_value); break; case "structured-name": yield (persona as NameDetails).change_structured_name ((StructuredName?) new_value); break; case "full-name": yield (persona as NameDetails).change_full_name ((string) new_value); break; case "nickname": yield (persona as NameDetails).change_nickname ((string) new_value); break; case "notes": yield (persona as NoteDetails).change_notes ((Set) new_value); break; case "phone-numbers": yield (persona as PhoneDetails).change_phone_numbers ((Set) new_value); break; case "postal-addresses": yield (persona as PostalAddressDetails).change_postal_addresses ((Set) new_value); break; case "roles": yield (persona as RoleDetails).change_roles ((Set) new_value); break; case "urls": yield (persona as UrlDetails).change_urls ((Set) new_value); break; case "web-service-addresses": yield (persona as WebServiceDetails).change_web_service_addresses ((MultiMap) new_value); break; default: critical ("Unknown property '%s' in Contact.set_persona_property().", property_name); break; } } public void keep_widget_uptodate (Widget w, owned Gtk.Callback callback) { callback(w); ulong id = this.changed.connect ( () => { callback(w); }); w.destroy.connect (() => { this.disconnect (id); }); } #if HAVE_TELEPATHY public void fetch_contact_info () { /* TODO: Ideally Folks should have API for this (#675131) */ foreach (var p in individual.personas) { var tp = p as Tpf.Persona; if (tp != null) { tp.contact.request_contact_info_async.begin (null); } } } #endif } public class Contacts.FakePersonaStore : PersonaStore { public static FakePersonaStore _the_store; public static FakePersonaStore the_store() { if (_the_store == null) _the_store = new FakePersonaStore (); return _the_store; } private HashMap _personas; private Map _personas_ro; public override string type_id { get { return "fake"; } } public FakePersonaStore () { Object (id: "uri", display_name: "fake store"); this._personas = new HashMap (); this._personas_ro = this._personas.read_only_view; } public override Map personas { get { return this._personas_ro; } } public override MaybeBool can_add_personas { get { return MaybeBool.FALSE; } } public override MaybeBool can_alias_personas { get { return MaybeBool.FALSE; } } public override MaybeBool can_group_personas { get { return MaybeBool.FALSE; } } public override MaybeBool can_remove_personas { get { return MaybeBool.FALSE; } } public override bool is_prepared { get { return true; } } public override bool is_quiescent { get { return true; } } private string[] _always_writeable_properties = {}; public override string[] always_writeable_properties { get { return this._always_writeable_properties; } } public override async void prepare () throws GLib.Error { } public override async Persona? add_persona_from_details (HashTable details) throws Folks.PersonaStoreError { return null; } public override async void remove_persona (Persona persona) throws Folks.PersonaStoreError { } } public class Contacts.FakePersona : Persona { public Contact contact; private class PropVal { public string property; public Value value; } private ArrayList prop_vals; private bool now_real; private bool has_full_name; public static FakePersona? maybe_create_for (Store store, Contact contact) { var primary_persona = contact.find_primary_persona (); if (primary_persona != null) return null; foreach (var p in contact.individual.personas) { // Don't fake a primary persona if we have an eds // persona on a non-readonly store if (p.store.type_id == "eds" && p.store.can_add_personas == MaybeBool.TRUE && p.store.can_remove_personas == MaybeBool.TRUE) return null; } return new FakePersona (store, contact); } private const string[] _linkable_properties = {}; private const string[] _writeable_properties = {}; public override string[] linkable_properties { get { return _linkable_properties; } } public override string[] writeable_properties { get { return _writeable_properties; } } public async Persona? make_real_and_set (string property, Value value) throws IndividualAggregatorError, ContactError, PropertyError { var v = new PropVal (); v.property = property; v.value = value; if (property == "full-name") has_full_name = true; if (prop_vals == null) { prop_vals = new ArrayList (); prop_vals.add (v); Persona p = yield contact.ensure_primary_persona (); if (!has_full_name) p.set ("full-name", contact.individual.display_name); foreach (var pv in prop_vals) { yield Contact.set_persona_property (p, pv.property, pv.value); } now_real = true; return p; } else { assert (!now_real); prop_vals.add (v); return null; } } public FakePersona (Store? store, Contact contact) { Object (display_id: "display_id", uid: "uid-fake-persona", iid: "iid", store: store.aggregator.primary_store ?? FakePersonaStore.the_store(), is_user: false); this.contact = contact; this.contact.fake_persona = this; } }