Fix a typo in the IndividualAggregator.individuals_changed docs.
[platform/upstream/folks.git] / folks / individual-aggregator.vala
index 4a30d6e..779b11b 100644 (file)
@@ -18,7 +18,6 @@
  *       Travis Reitter <travis.reitter@collabora.co.uk>
  */
 
-using Folks;
 using Gee;
 using GLib;
 
@@ -28,11 +27,6 @@ using GLib;
 public errordomain Folks.IndividualAggregatorError
 {
   /**
-   * A specified {@link PersonaStore} could not be found.
-   */
-  STORE_NOT_FOUND,
-
-  /**
    * Adding a {@link Persona} to a {@link PersonaStore} failed.
    */
   ADD_FAILED,
@@ -40,25 +34,73 @@ public errordomain Folks.IndividualAggregatorError
   /**
    * An operation which required the use of a writeable store failed because no
    * writeable store was available.
+   *
+   * @since 0.1.13
    */
   NO_WRITEABLE_STORE,
+
+  /**
+   * The {@link PersonaStore} was offline (ie, this is a temporary failure).
+   *
+   * @since 0.3.0
+   */
+  STORE_OFFLINE,
 }
 
 /**
- * Allows access to the {@link Individual}s which have been created through
+ * Stores {@link Individual}s which have been created through
  * aggregation of all the {@link Persona}s provided by the various
- * {@link Backend}s. This is the main interface for client applications.
+ * {@link Backend}s.
+ *
+ * This is the main interface for client applications.
  */
 public class Folks.IndividualAggregator : Object
 {
-  private BackendStore backend_store;
-  private HashMap<string, PersonaStore> stores;
-  private unowned PersonaStore writeable_store;
-  private HashSet<Backend> backends;
-  private HashTable<string, Individual> link_map;
+  private BackendStore _backend_store;
+  private HashMap<string, PersonaStore> _stores;
+  private unowned PersonaStore _writeable_store;
+  private HashSet<Backend> _backends;
+  private HashTable<string, Individual> _link_map;
+  private bool _linking_enabled = true;
+  private bool _is_prepared = false;
+  private Debug _debug;
+  private string _configured_writeable_store_type_id;
+  private static const string _FOLKS_CONFIG_KEY =
+    "/system/folks/backends/primary_store";
 
   /**
-   * A table mapping {@link Individual.id}s to their {@link Individual}s.
+   * Whether {@link IndividualAggregator.prepare} has successfully completed for
+   * this aggregator.
+   *
+   * @since 0.3.0
+   */
+  public bool is_prepared
+    {
+      get { return this._is_prepared; }
+    }
+
+  /**
+   * Our configured primary (writeable) store.
+   *
+   * Which one to use is decided (in order or precedence)
+   * by:
+   *
+   * - the FOLKS_WRITEABLE_STORE env var (mostly for debugging)
+   * - the GConf key set in _FOLKS_CONFIG_KEY (system set store)
+   * - going with the `key-file` store as the fall-back option
+   *
+   * @since 0.5.0
+   */
+  public PersonaStore primary_store
+    {
+      get { return this._writeable_store; }
+    }
+
+  private Map<string, Individual> _individuals;
+  private Map<string, Individual> _individuals_ro;
+
+  /**
+   * A map from {@link Individual.id}s to their {@link Individual}s.
    *
    * This is the canonical set of {@link Individual}s provided by this
    * IndividualAggregator.
@@ -66,8 +108,29 @@ public class Folks.IndividualAggregator : Object
    * {@link Individual}s may be added or removed using
    * {@link IndividualAggregator.add_persona_from_details} and
    * {@link IndividualAggregator.remove_individual}, respectively.
+   *
+   * @since 0.5.1
+   */
+  public Map<string, Individual> individuals
+    {
+      get { return this._individuals_ro; }
+      private set
+        {
+          this._individuals = value;
+          this._individuals_ro = this._individuals.read_only_view;
+        }
+    }
+
+  /**
+   * The {@link Individual} representing the user.
+   *
+   * If it exists, this holds the {@link Individual} who is the user: the
+   * {@link Individual} containing the {@link Persona}s who are the owners of
+   * the accounts for their respective backends.
+   *
+   * @since 0.3.0
    */
-  public HashTable<string, Individual> individuals { get; private set; }
+  public Individual user { get; private set; }
 
   /**
    * Emitted when one or more {@link Individual}s are added to or removed from
@@ -76,17 +139,19 @@ public class Folks.IndividualAggregator : Object
    * This will not be emitted until after {@link IndividualAggregator.prepare}
    * has been called.
    *
-   * @param added a list of {@link Individual}s which have been removed
+   * @param added a list of {@link Individual}s which have been added
    * @param removed a list of {@link Individual}s which have been removed
    * @param message a string message from the backend, if any
    * @param actor the {@link Persona} who made the change, if known
    * @param reason the reason for the change
+   *
+   * @since 0.5.1
    */
-  public signal void individuals_changed (GLib.List<Individual>? added,
-      GLib.List<Individual>? removed,
+  public signal void individuals_changed (Set<Individual> added,
+      Set<Individual> removed,
       string? message,
       Persona? actor,
-      Groups.ChangeReason reason);
+      GroupDetails.ChangeReason reason);
 
   /* FIXME: make this a singleton? */
   /**
@@ -106,17 +171,135 @@ public class Folks.IndividualAggregator : Object
    */
   public IndividualAggregator ()
     {
-      this.stores = new HashMap<string, PersonaStore> ();
-      this.individuals = new HashTable<string, Individual> (str_hash,
-          str_equal);
-      this.link_map = new HashTable<string, Individual> (str_hash, str_equal);
+      this._stores = new HashMap<string, PersonaStore> ();
+      this._individuals = new HashMap<string, Individual> ();
+      this._individuals_ro = this._individuals.read_only_view;
+      this._link_map = new HashTable<string, Individual> (str_hash, str_equal);
+
+      this._backends = new HashSet<Backend> ();
+      this._debug = Debug.dup ();
+      this._debug.print_status.connect (this._debug_print_status);
+
+      /* Check out the configured writeable store */
+      var store_type_id = Environment.get_variable ("FOLKS_WRITEABLE_STORE");
+      if (store_type_id != null)
+        {
+          this._configured_writeable_store_type_id = store_type_id;
+        }
+      else
+        {
+          this._configured_writeable_store_type_id = "key-file";
+          try
+            {
+              unowned GConf.Client client = GConf.Client.get_default ();
+              GConf.Value? val = client.get (this._FOLKS_CONFIG_KEY);
+              if (val != null)
+                this._configured_writeable_store_type_id = val.get_string ();
+            }
+          catch (GLib.Error e)
+            {
+              /* We ignore errors and go with the default store */
+            }
+        }
+
+      var disable_linking = Environment.get_variable ("FOLKS_DISABLE_LINKING");
+      if (disable_linking != null)
+        disable_linking = disable_linking.strip ().down ();
+      this._linking_enabled = (disable_linking == null ||
+          disable_linking == "no" || disable_linking == "0");
+
+      this._backend_store = BackendStore.dup ();
+      this._backend_store.backend_available.connect (
+          this._backend_available_cb);
+    }
+
+  ~IndividualAggregator ()
+    {
+      this._backend_store.backend_available.disconnect (
+          this._backend_available_cb);
+      this._backend_store = null;
+
+      this._debug.print_status.disconnect (this._debug_print_status);
+    }
+
+  private void _debug_print_status (Debug debug)
+    {
+      const string domain = Debug.STATUS_LOG_DOMAIN;
+      const LogLevelFlags level = LogLevelFlags.LEVEL_INFO;
+
+      debug.print_heading (domain, level, "IndividualAggregator (%p)", this);
+      debug.print_key_value_pairs (domain, level,
+          "Ref. count", this.ref_count.to_string (),
+          "Writeable store", "%p".printf (this._writeable_store),
+          "Linking enabled?", this._linking_enabled ? "yes" : "no",
+          "Prepared?", this._is_prepared ? "yes" : "no"
+      );
+
+      debug.print_line (domain, level,
+          "%u Individuals:", this.individuals.size);
+      debug.indent ();
+
+      foreach (var individual in this.individuals.values)
+        {
+          string trust_level = null;
+
+          switch (individual.trust_level)
+            {
+              case TrustLevel.NONE:
+                trust_level = "none";
+                break;
+              case TrustLevel.PERSONAS:
+                trust_level = "personas";
+                break;
+              default:
+                assert_not_reached ();
+            }
+
+          debug.print_heading (domain, level, "Individual (%p)", individual);
+          debug.print_key_value_pairs (domain, level,
+              "Ref. count", individual.ref_count.to_string (),
+              "ID", individual.id,
+              "User?", individual.is_user ? "yes" : "no",
+              "Trust level", trust_level
+          );
+          debug.print_line (domain, level, "%u Personas:",
+              individual.personas.size);
+
+          debug.indent ();
+
+          foreach (var persona in individual.personas)
+            {
+              debug.print_heading (domain, level, "Persona (%p)", persona);
+              debug.print_key_value_pairs (domain, level,
+                  "Ref. count", persona.ref_count.to_string (),
+                  "UID", persona.uid,
+                  "IID", persona.iid,
+                  "Display ID", persona.display_id,
+                  "User?", persona.is_user ? "yes" : "no"
+              );
+            }
+
+          debug.unindent ();
+        }
 
-      this.backends = new HashSet<Backend> ();
+      debug.unindent ();
 
-      Debug.set_flags (Environment.get_variable ("FOLKS_DEBUG"));
+      debug.print_line (domain, level, "%u entries in the link map:",
+          this._link_map.size ());
+      debug.indent ();
 
-      this.backend_store = new BackendStore ();
-      this.backend_store.backend_available.connect (this.backend_available_cb);
+      var iter = HashTableIter<string, Individual> (this._link_map);
+      string link_key;
+      Individual individual;
+      while (iter.next (out link_key, out individual) == true)
+        {
+          debug.print_line (domain, level,
+              "%s → %p", link_key, individual);
+        }
+
+      debug.unindent ();
+
+      debug.print_line (domain, level, "");
     }
 
   /**
@@ -127,51 +310,145 @@ public class Folks.IndividualAggregator : Object
    * {@link IndividualAggregator.individuals_changed} signal, or a race
    * condition could occur, with the signal being emitted before your code has
    * connected to them, and {@link Individual}s getting "lost" as a result.
+   *
+   * This function is guaranteed to be idempotent (since version 0.3.0).
+   *
+   * @since 0.1.11
    */
   public async void prepare () throws GLib.Error
     {
-      this.backend_store.load_backends ();
+      /* Once this async function returns, all the {@link Backend}s will have
+       * been prepared (though no {@link PersonaStore}s are guaranteed to be
+       * available yet). This last guarantee is new as of version 0.2.0. */
+
+      lock (this._is_prepared)
+        {
+          if (!this._is_prepared)
+            {
+              yield this._backend_store.load_backends ();
+              this._is_prepared = true;
+              this.notify_property ("is-prepared");
+            }
+        }
     }
 
-  private void backend_available_cb (BackendStore backend_store,
-      Backend backend)
+  /**
+   * Get all matches for a given {@link Individual}.
+   *
+   * @since 0.5.1
+   */
+  public Map<Individual, MatchResult> get_potential_matches
+      (Individual matchee, MatchResult min_threshold = MatchResult.VERY_HIGH)
     {
-      backend.persona_store_added.connect (this.backend_persona_store_added_cb);
-      backend.persona_store_removed.connect (
-          this.backend_persona_store_removed_cb);
+      HashMap<Individual, MatchResult> matches =
+          new HashMap<Individual, MatchResult> ();
+      Folks.PotentialMatch matchObj = new Folks.PotentialMatch ();
 
-      backend.prepare.begin ((obj, result) =>
+      foreach (var i in this._individuals.values)
         {
-          try
+          if (i.id == matchee.id)
+                continue;
+
+          var result = matchObj.potential_match (i, matchee);
+          if (result >= min_threshold)
             {
-              backend.prepare.end (result);
+              matches.set (i, result);
             }
-          catch (GLib.Error e)
+        }
+
+      return matches;
+    }
+
+  /**
+   * Get all combinations between all {@link Individual}s.
+   *
+   * @since 0.5.1
+   */
+  public Map<Individual, Map<Individual, MatchResult>>
+      get_all_potential_matches
+        (MatchResult min_threshold = MatchResult.VERY_HIGH)
+    {
+      HashMap<Individual, HashMap<Individual, MatchResult>> matches =
+        new HashMap<Individual, HashMap<Individual, MatchResult>> ();
+      var individuals = this._individuals.values.to_array ();
+      Folks.PotentialMatch matchObj = new Folks.PotentialMatch ();
+
+      for (var i = 0; i < individuals.length; i++)
+        {
+          var a = individuals[i];
+          var matches_a = matches.get (a);
+          if (matches_a == null)
             {
-              warning ("Error preparing Backend '%s': %s", backend.name,
-                  e.message);
+              matches_a = new HashMap<Individual, MatchResult> ();
+              matches.set (a, matches_a);
             }
-        });
+
+          for (var f = i + 1; f < individuals.length; f++)
+            {
+              var b = individuals[f];
+              var matches_b = matches.get (b);
+              if (matches_b == null)
+                {
+                  matches_b = new HashMap<Individual, MatchResult> ();
+                  matches.set (b, matches_b);
+                }
+
+              var result = matchObj.potential_match (a, b);
+
+              if (result >= min_threshold)
+                {
+                  matches_a.set (b, result);
+                  matches_b.set (a, result);
+                }
+            }
+        }
+
+      return matches;
+    }
+
+  private async void _add_backend (Backend backend)
+    {
+      if (!this._backends.contains (backend))
+        {
+          this._backends.add (backend);
+
+          backend.persona_store_added.connect (
+              this._backend_persona_store_added_cb);
+          backend.persona_store_removed.connect (
+              this._backend_persona_store_removed_cb);
+
+          /* handle the stores that have already been signaled */
+          foreach (var persona_store in backend.persona_stores.values)
+              {
+                this._backend_persona_store_added_cb (backend, persona_store);
+              }
+        }
+    }
+
+  private void _backend_available_cb (BackendStore backend_store,
+      Backend backend)
+    {
+      this._add_backend.begin (backend);
     }
 
-  private void backend_persona_store_added_cb (Backend backend,
+  private void _backend_persona_store_added_cb (Backend backend,
       PersonaStore store)
     {
-      string store_id = this.get_store_full_id (store.type_id, store.id);
+      var store_id = this._get_store_full_id (store.type_id, store.id);
 
-      /* FIXME: We hardcode the key-file backend's singleton PersonaStore as the
-       * only trusted and writeable PersonaStore for now. */
-      if (store.type_id == "key-file")
+      /* We use the configured PersonaStore as the only trusted and writeable
+       * PersonaStore. */
+      if (store.type_id == this._configured_writeable_store_type_id)
         {
           store.is_writeable = true;
           store.trust_level = PersonaStoreTrust.FULL;
-          this.writeable_store = store;
+          this._writeable_store = store;
         }
 
-      this.stores.set (store_id, store);
-      store.personas_changed.connect (this.personas_changed_cb);
-      store.notify["is-writeable"].connect (this.is_writeable_changed_cb);
-      store.notify["trust-level"].connect (this.trust_level_changed_cb);
+      this._stores.set (store_id, store);
+      store.personas_changed.connect (this._personas_changed_cb);
+      store.notify["is-writeable"].connect (this._is_writeable_changed_cb);
+      store.notify["trust-level"].connect (this._trust_level_changed_cb);
 
       store.prepare.begin ((obj, result) =>
         {
@@ -181,71 +458,135 @@ public class Folks.IndividualAggregator : Object
             }
           catch (GLib.Error e)
             {
-              warning ("Error preparing PersonaStore '%s': %s", store_id,
+              /* Translators: the first parameter is a persona store identifier
+               * and the second is an error message. */
+              warning (_("Error preparing persona store '%s': %s"), store_id,
                   e.message);
             }
         });
     }
 
-  private void backend_persona_store_removed_cb (Backend backend,
+  private void _backend_persona_store_removed_cb (Backend backend,
       PersonaStore store)
     {
-      store.personas_changed.disconnect (this.personas_changed_cb);
-      store.notify["trust-level"].disconnect (this.trust_level_changed_cb);
-      store.notify["is-writeable"].disconnect (this.is_writeable_changed_cb);
+      store.personas_changed.disconnect (this._personas_changed_cb);
+      store.notify["trust-level"].disconnect (this._trust_level_changed_cb);
+      store.notify["is-writeable"].disconnect (this._is_writeable_changed_cb);
 
       /* no need to remove this store's personas from all the individuals, since
        * they'll do that themselves (and emit their own 'removed' signal if
        * necessary) */
 
-      if (this.writeable_store == store)
-        this.writeable_store = null;
-      this.stores.unset (this.get_store_full_id (store.type_id, store.id));
+      if (this._writeable_store == store)
+        this._writeable_store = null;
+      this._stores.unset (this._get_store_full_id (store.type_id, store.id));
     }
 
-  private string get_store_full_id (string type_id, string id)
+  private string _get_store_full_id (string type_id, string id)
     {
       return type_id + ":" + id;
     }
 
-  private GLib.List<Individual> add_personas (GLib.List<Persona> added)
+  /* Emit the individuals-changed signal ensuring that null parameters are
+   * turned into empty sets, and both sets passed to signal handlers are
+   * read-only. */
+  private void _emit_individuals_changed (Set<Individual>? added,
+      Set<Individual>? removed,
+      string? message = null,
+      Persona? actor = null,
+      GroupDetails.ChangeReason reason = GroupDetails.ChangeReason.NONE)
     {
-      GLib.List<Individual> added_individuals = new GLib.List<Individual> ();
+      var _added = added;
+      var _removed = removed;
 
-      added.foreach ((p) =>
+      if ((added == null || added.size == 0) &&
+          (removed == null || removed.size == 0))
+        {
+          /* Don't bother emitting it if nothing's changed */
+          return;
+        }
+      else if (added == null)
+        {
+          _added = new HashSet<Individual> ();
+        }
+      else if (removed == null)
+        {
+          _removed = new HashSet<Individual> ();
+        }
+
+      this.individuals_changed (_added.read_only_view, _removed.read_only_view,
+          message, actor, reason);
+    }
+
+  private void _connect_to_individual (Individual individual)
+    {
+      individual.removed.connect (this._individual_removed_cb);
+      this._individuals.set (individual.id, individual);
+    }
+
+  private void _disconnect_from_individual (Individual individual)
+    {
+      this._individuals.unset (individual.id);
+      individual.removed.disconnect (this._individual_removed_cb);
+    }
+
+  private void _add_personas (Set<Persona> added,
+      ref HashSet<Individual> added_individuals,
+      ref HashMap<Individual, Individual> replaced_individuals,
+      ref Individual user)
+    {
+      /* Set of individuals which have been added as a result of the new
+       * personas. These will be returned in added_individuals, but have to be
+       * cached first so that we can ensure that we don't return any given
+       * individual in both added_individuals _and_ replaced_individuals. This
+       * can happen in the case that several of the added personas are linked
+       * together to form one final individual. In that case, a succession of
+       * newly linked individuals will be produced (one for each iteration of
+       * the loop over the added personas); only the *last one* of which should
+       * make its way into added_individuals. The rest should not even make
+       * their way into replaced_individuals, as they've existed only within the
+       * confines of this function call. */
+      HashSet<Individual> almost_added_individuals = new HashSet<Individual> ();
+
+      foreach (var persona in added)
         {
-          unowned Persona persona = (Persona) p;
           PersonaStoreTrust trust_level = persona.store.trust_level;
 
           /* These are the Individuals whose Personas will be linked together
-           * to form the `final_individual`. We keep a list of the Individuals
-           * for fast iteration, but also keep a set to ensure that we don't
-           * get duplicate Individuals in the list.
+           * to form the `final_individual`.
            * Since a given Persona can only be part of one Individual, and the
            * code in Persona._set_personas() ensures that there are no duplicate
            * Personas in a given Individual, ensuring that there are no
-           * duplicate Individuals in `candidate_inds` guarantees that there
-           * will be no duplicate Personas in the `final_individual`. */
-          GLib.List<Individual> candidate_inds = null;
-          HashSet<Individual> candidate_ind_set = new HashSet<Individual> ();
+           * duplicate Individuals in `candidate_inds` (by using a
+           * HashSet) guarantees that there will be no duplicate Personas
+           * in the `final_individual`. */
+          HashSet<Individual> candidate_inds = new HashSet<Individual> ();
 
-          GLib.List<Persona> final_personas = new GLib.List<Persona> ();
+          var final_personas = new HashSet<Persona> ();
           Individual final_individual = null;
 
           debug ("Aggregating persona '%s' on '%s'.", persona.uid, persona.iid);
 
+          /* If the Persona is the user, we *always* want to link it to the
+           * existing this.user. */
+          if (persona.is_user == true && user != null)
+            {
+              debug ("    Found candidate individual '%s' as user.", user.id);
+              candidate_inds.add (user);
+            }
+
           /* If we don't trust the PersonaStore at all, we can't link the
            * Persona to any existing Individual */
           if (trust_level != PersonaStoreTrust.NONE)
             {
-              Individual candidate_ind = this.link_map.lookup (persona.iid);
+              var candidate_ind = this._link_map.lookup (persona.iid);
               if (candidate_ind != null &&
-                  candidate_ind.trust_level != TrustLevel.NONE)
+                  candidate_ind.trust_level != TrustLevel.NONE &&
+                  !candidate_inds.contains (candidate_ind))
                 {
                   debug ("    Found candidate individual '%s' by IID '%s'.",
                       candidate_ind.id, persona.iid);
-                  candidate_inds.prepend (candidate_ind);
-                  candidate_ind_set.add (candidate_ind);
+                  candidate_inds.add (candidate_ind);
                 }
             }
 
@@ -260,60 +601,54 @@ public class Folks.IndividualAggregator : Object
                    * prop_name ends up as NULL. bgo#628336 */
                   unowned string prop_name = foo;
 
+                  /* FIXME: can't be var because of bgo#638208 */
                   unowned ObjectClass pclass = persona.get_class ();
                   if (pclass.find_property (prop_name) == null)
                     {
-                      warning ("Unknown property '%s' in linkable property " +
-                          "list.", prop_name);
+                      warning (
+                          /* Translators: the parameter is a property name. */
+                          _("Unknown property '%s' in linkable property list."),
+                          prop_name);
                       continue;
                     }
 
                   persona.linkable_property_to_links (prop_name, (l) =>
                     {
-                      string prop_linking_value = (string) l;
-                      Individual candidate_ind =
-                          this.link_map.lookup (prop_linking_value);
+                      unowned string prop_linking_value = l;
+                      var candidate_ind =
+                          this._link_map.lookup (prop_linking_value);
 
                       if (candidate_ind != null &&
                           candidate_ind.trust_level != TrustLevel.NONE &&
-                          !candidate_ind_set.contains (candidate_ind))
+                          !candidate_inds.contains (candidate_ind))
                         {
                           debug ("    Found candidate individual '%s' by " +
                               "linkable property '%s' = '%s'.",
                               candidate_ind.id, prop_name, prop_linking_value);
-                          candidate_inds.prepend (candidate_ind);
-                          candidate_ind_set.add (candidate_ind);
+                          candidate_inds.add (candidate_ind);
                         }
                     });
                 }
             }
 
-          /* Ensure the original persona makes it into the final persona */
-          final_personas.prepend (persona);
+          /* Ensure the original persona makes it into the final individual */
+          final_personas.add (persona);
 
-          if (candidate_inds != null)
+          if (candidate_inds.size > 0 && this._linking_enabled == true)
             {
               /* The Persona's IID or linkable properties match one or more
                * linkable fields which are already in the link map, so we link
                * together all the Individuals we found to form a new
                * final_individual. Later, we remove the Personas from the old
                * Individuals so that the Individuals themselves are removed. */
-              candidate_inds.foreach ((i) =>
+              foreach (var individual in candidate_inds)
                 {
-                  unowned Individual individual = (Individual) i;
-
-                  /* FIXME: It would be faster to prepend a reversed copy of
-                   * `individual.personas`, then reverse the entire
-                   * `final_personas` list afterwards, but Vala won't let us.
-                   * We also have to reference each of `individual.personas`
-                   * manually, since copy() doesn't do that for us. */
-                  individual.personas.foreach ((p) =>
-                    {
-                      ((Persona) p).ref ();
-                    });
-
-                  final_personas.concat (individual.personas.copy ());
-                });
+                  final_personas.add_all (individual.personas);
+                }
+            }
+          else if (candidate_inds.size > 0)
+            {
+              debug ("    Linking disabled.");
             }
           else
             {
@@ -322,20 +657,20 @@ public class Folks.IndividualAggregator : Object
 
           /* Create the final linked Individual */
           final_individual = new Individual (final_personas);
-
           debug ("    Created new individual '%s' with personas:",
               final_individual.id);
-          final_personas.foreach ((i) =>
+          foreach (var p in final_personas)
             {
-              unowned Persona final_persona = (Persona) i;
+              var final_persona = (Persona) p;
 
-              debug ("        %s (%s)", final_persona.uid, final_persona.iid);
+              debug ("        %s (is user: %s, IID: %s)", final_persona.uid,
+                  final_persona.is_user ? "yes" : "no", final_persona.iid);
 
               /* Add the Persona to the link map. Its trust level will be
                * reflected in final_individual.trust_level, so other Personas
                * won't be linked against it in error if the trust level is
                * NONE. */
-              this.link_map.replace (final_persona.iid, final_individual);
+              this._link_map.replace (final_persona.iid, final_individual);
 
               /* Only allow linking on non-IID properties of the Persona if we
                * fully trust the PersonaStore it came from. */
@@ -348,182 +683,251 @@ public class Folks.IndividualAggregator : Object
                   foreach (unowned string prop_name in
                       final_persona.linkable_properties)
                     {
+                      /* FIXME: can't be var because of bgo#638208 */
                       unowned ObjectClass pclass = final_persona.get_class ();
                       if (pclass.find_property (prop_name) == null)
                         {
-                          warning ("Unknown property '%s' in linkable " +
-                              "property list.", prop_name);
+                          warning (
+                              /* Translators: the parameter is a property
+                               * name. */
+                              _("Unknown property '%s' in linkable property list."),
+                              prop_name);
                           continue;
                         }
 
                       final_persona.linkable_property_to_links (prop_name,
                           (l) =>
                         {
-                          string prop_linking_value = (string) l;
+                          unowned string prop_linking_value = l;
 
                           debug ("            %s", prop_linking_value);
-                          this.link_map.replace (prop_linking_value,
+                          this._link_map.replace (prop_linking_value,
                               final_individual);
                         });
                     }
                 }
-            });
+            }
 
           /* Remove the old Individuals. This has to be done here, as we need
            * the final_individual. */
-          candidate_inds.foreach ((i) =>
+          foreach (var i in candidate_inds)
             {
-              ((Individual) i).replace (final_individual);
-            });
+              /* If the replaced individual was marked to be added to the
+               * aggregator, unmark it. */
+              if (almost_added_individuals.contains (i) == true)
+                almost_added_individuals.remove (i);
+              else
+                replaced_individuals.set (i, final_individual);
+            }
 
+          /* If the final Individual is the user, set them as such. */
+          if (final_individual.is_user == true)
+            user = final_individual;
+
+          /* Mark the final individual for addition later */
+          almost_added_individuals.add (final_individual);
+        }
+
+      /* Add the set of final individuals which weren't later replaced to the
+       * aggregator. */
+      foreach (var i in almost_added_individuals)
+        {
           /* Add the new Individual to the aggregator */
-          final_individual.removed.connect (this.individual_removed_cb);
-          added_individuals.prepend (final_individual);
-          this.individuals.insert (final_individual.id, final_individual);
-        });
+          added_individuals.add (i);
+          this._connect_to_individual (i);
+        }
+    }
+
+  private void _remove_persona_from_link_map (Persona persona)
+    {
+      this._link_map.remove (persona.iid);
+
+      if (persona.store.trust_level == PersonaStoreTrust.FULL)
+        {
+          debug ("    Removing links to %s:", persona.uid);
+
+          /* Remove maps from the Persona's linkable properties to
+           * Individuals. Add the Individuals to a list of Individuals to be
+           * removed. */
+          foreach (unowned string prop_name in persona.linkable_properties)
+            {
+              /* FIXME: can't be var because of bgo#638208 */
+              unowned ObjectClass pclass = persona.get_class ();
+              if (pclass.find_property (prop_name) == null)
+                {
+                  warning (
+                      /* Translators: the parameter is a property name. */
+                      _("Unknown property '%s' in linkable property list."),
+                      prop_name);
+                  continue;
+                }
 
-      /* FIXME: AAAARGH VALA GO AWAY */
-      foreach (Individual i in added_individuals)
-        i.ref ();
-      return added_individuals.copy ();
+              persona.linkable_property_to_links (prop_name, (linking_value) =>
+                {
+                  debug ("        %s", linking_value);
+                  this._link_map.remove (linking_value);
+                });
+            }
+        }
     }
 
-  private void personas_changed_cb (PersonaStore store,
-      GLib.List<Persona>? added,
-      GLib.List<Persona>? removed,
+  private void _personas_changed_cb (PersonaStore store,
+      Set<Persona> added,
+      Set<Persona> removed,
       string? message,
       Persona? actor,
-      Groups.ChangeReason reason)
+      GroupDetails.ChangeReason reason)
     {
-      GLib.List<Individual> added_individuals = null,
-          removed_individuals = null;
-      GLib.List<Persona> relinked_personas = null;
-      HashSet<Persona> removed_personas = new HashSet<Persona> (direct_hash,
-          direct_equal);
-
-      if (added != null)
-        added_individuals = this.add_personas (added);
+      var added_individuals = new HashSet<Individual> ();
+      var removed_individuals = new HashSet<Individual> ();
+      var replaced_individuals = new HashMap<Individual, Individual> ();
+      var relinked_personas = new HashSet<Persona> ();
+      var removed_personas = new HashSet<Persona> (direct_hash, direct_equal);
+
+      /* We store the value of this.user locally and only update it at the end
+       * of the function to prevent spamming notifications of changes to the
+       * property. */
+      var user = this.user;
+
+      if (added.size > 0)
+        {
+          this._add_personas (added, ref added_individuals,
+              ref replaced_individuals, ref user);
+        }
 
       debug ("Removing Personas:");
 
-      removed.foreach ((p) =>
+      foreach (var persona in removed)
         {
-          unowned Persona persona = (Persona) p;
-          PersonaStoreTrust trust_level = persona.store.trust_level;
-
-          debug ("    %s (%s)", persona.uid, persona.iid);
+          debug ("    %s (is user: %s, IID: %s)", persona.uid,
+              persona.is_user ? "yes" : "no", persona.iid);
 
           /* Build a hash table of the removed Personas so that we can quickly
            * eliminate them from the list of Personas to relink, below. */
           removed_personas.add (persona);
 
-          /* Find the Individual containing the Persona and mark them for
-           * removal (any other Personas they have which aren't being removed
-           * will be re-linked into other Individuals). */
-          Individual ind = this.link_map.lookup (persona.iid);
-          removed_individuals.prepend (ind);
-          this.link_map.remove (persona.iid);
+          /* Find the Individual containing the Persona (if any) and mark them
+           * for removal (any other Personas they have which aren't being
+           * removed will be re-linked into other Individuals). */
+          var ind = this._link_map.lookup (persona.iid);
+          if (ind != null)
+            removed_individuals.add (ind);
 
-          if (trust_level == PersonaStoreTrust.FULL)
-            {
-              debug ("    Removing links:");
-
-              /* Remove maps from the Persona's linkable properties to
-               * Individuals. Add the Individuals to a list of Individuals to be
-               * removed. */
-              foreach (string prop_name in persona.linkable_properties)
-                {
-                  unowned ObjectClass pclass = persona.get_class ();
-                  if (pclass.find_property (prop_name) == null)
-                    {
-                      warning ("Unknown property '%s' in linkable property " +
-                          "list.", prop_name);
-                      continue;
-                    }
-
-                  persona.linkable_property_to_links (prop_name, (l) =>
-                    {
-                      string prop_linking_value = (string) l;
-
-                      debug ("        %s", prop_linking_value);
-                      this.link_map.remove (prop_linking_value);
-                    });
-                }
-            }
-        });
+          /* Remove the Persona's links from the link map */
+          this._remove_persona_from_link_map (persona);
+        }
 
       /* Remove the Individuals which were pointed to by the linkable properties
        * of the removed Personas. We can then re-link the other Personas in
        * those Individuals, since their links may have changed.
        * Note that we remove the Individual from this.individuals, meaning that
-       * individual_removed_cb() ignores this Individual. This allows us to
+       * _individual_removed_cb() ignores this Individual. This allows us to
        * group together the IndividualAggregator.individuals_changed signals
        * for all the removed Individuals. */
       debug ("Removing Individuals due to removed links:");
-      foreach (Individual individual in removed_individuals)
+      foreach (var individual in removed_individuals)
         {
           /* Ensure we don't remove the same Individual twice */
-          if (this.individuals.lookup (individual.id) == null)
+          if (this._individuals.has_key (individual.id) == false)
             continue;
 
           debug ("    %s", individual.id);
 
           /* Build a list of Personas which need relinking. Ensure we don't
            * include any of the Personas which have just been removed. */
-          foreach (unowned Persona p in individual.personas)
+          foreach (var persona in individual.personas)
             {
-              if (removed_personas.contains (p) == false)
-                relinked_personas.prepend (p);
+              if (removed_personas.contains (persona) == true ||
+                  relinked_personas.contains (persona) == true)
+                continue;
+
+              relinked_personas.add (persona);
+
+              /* Remove links to the Persona */
+              this._remove_persona_from_link_map (persona);
             }
 
-          this.individuals.remove (individual.id);
+          if (user == individual)
+            user = null;
+
+          this._disconnect_from_individual (individual);
           individual.personas = null;
         }
 
       debug ("Relinking Personas:");
-      foreach (unowned Persona persona in relinked_personas)
-        debug ("    %s (%s)", persona.uid, persona.iid);
+      foreach (var persona in relinked_personas)
+        {
+          debug ("    %s (is user: %s, IID: %s)", persona.uid,
+              persona.is_user ? "yes" : "no", persona.iid);
+        }
+
+      this._add_personas (relinked_personas, ref added_individuals,
+          ref replaced_individuals, ref user);
+
+      /* Signal the removal of the replaced_individuals at the same time as the
+       * removed_individuals. (The only difference between replaced individuals
+       * and removed ones is that replaced individuals specify a replacement
+       * when they emit their Individual:removed signal. */
+      if (replaced_individuals != null)
+        {
+          MapIterator<Individual, Individual> iter =
+              replaced_individuals.map_iterator ();
+          while (iter.next () == true)
+            removed_individuals.add (iter.get_key ());
+        }
 
-      /* FIXME: Vala is horrible with GLists */
-      added_individuals.concat (this.add_personas (relinked_personas));
+      /* Notify of changes to this.user */
+      this.user = user;
 
       /* Signal the addition of new individuals and removal of old ones to the
        * aggregator */
-      if (added_individuals != null || removed_individuals != null)
+      if (added_individuals.size > 0 || removed_individuals.size > 0)
+        {
+          this._emit_individuals_changed (added_individuals,
+              removed_individuals);
+        }
+
+      /* Signal the replacement of various Individuals as a consequence of
+       * linking. */
+      debug ("Replacing Individuals due to linking:");
+      var iter = replaced_individuals.map_iterator ();
+      while (iter.next () == true)
         {
-          this.individuals_changed (added_individuals, removed_individuals,
-              null, null, 0);
+          iter.get_key ().replace (iter.get_value ());
         }
     }
 
-  private void is_writeable_changed_cb (Object object, ParamSpec pspec)
+  private void _is_writeable_changed_cb (Object object, ParamSpec pspec)
     {
       /* Ensure that we only have one writeable PersonaStore */
-      unowned PersonaStore store = (PersonaStore) object;
-      assert ((store.is_writeable == true && store == this.writeable_store) ||
-          (store.is_writeable == false && store != this.writeable_store));
+      var store = (PersonaStore) object;
+      assert ((store.is_writeable == true && store == this._writeable_store) ||
+          (store.is_writeable == false && store != this._writeable_store));
     }
 
-  private void trust_level_changed_cb (Object object, ParamSpec pspec)
+  private void _trust_level_changed_cb (Object object, ParamSpec pspec)
     {
-      /* FIXME: For the moment, assert that only the key-file backend's
-       * singleton PersonaStore is trusted. */
-      unowned PersonaStore store = (PersonaStore) object;
-      if (store.type_id == "key-file")
+      /* Only our writeable_store can be fully trusted. */
+      var store = (PersonaStore) object;
+      if (this._writeable_store != null &&
+          store.type_id == this._writeable_store.type_id)
         assert (store.trust_level == PersonaStoreTrust.FULL);
       else
         assert (store.trust_level != PersonaStoreTrust.FULL);
     }
 
-  private void individual_removed_cb (Individual i, Individual? replacement)
+  private void _individual_removed_cb (Individual i, Individual? replacement)
     {
+      if (this.user == i)
+        this.user = null;
+
       /* Only signal if the individual is still in this.individuals. This allows
-       * us to group removals together in, e.g., personas_changed_cb(). */
-      if (this.individuals.lookup (i.id) == null)
+       * us to group removals together in, e.g., _personas_changed_cb(). */
+      if (this._individuals.get (i.id) != i)
         return;
 
-      var i_list = new GLib.List<Individual> ();
-      i_list.append (i);
+      var individuals = new HashSet<Individual> ();
+      individuals.add (i);
 
       if (replacement != null)
         {
@@ -535,78 +939,88 @@ public class Folks.IndividualAggregator : Object
           debug ("Individual '%s' removed (not replaced)", i.id);
         }
 
-      this.individuals_changed (null, i_list, null, null, 0);
-      this.individuals.remove (i.id);
+      /* If the individual has 0 personas, we've already signaled removal */
+      if (i.personas.size > 0)
+        {
+          this._emit_individuals_changed (null, individuals);
+        }
+
+      this._disconnect_from_individual (i);
     }
 
   /**
    * Add a new persona in the given {@link PersonaStore} based on the `details`
    * provided.
    *
+   * If the target store is offline, this function will throw
+   * {@link IndividualAggregatorError.STORE_OFFLINE}. It's the responsibility of
+   * the caller to cache details and re-try this function if it wishes to make
+   * offline adds work.
+   *
    * The details hash is a backend-specific mapping of key, value strings.
    * Common keys include:
    *
    *  * contact - service-specific contact ID
+   *  * message - a user-readable message to pass to the persona being added
    *
-   * If `parent` is provided, the new persona will be appended to its ordered
-   * list of personas.
+   * If a {@link Persona} with the given details already exists in the store, no
+   * error will be thrown and this function will return `null`.
    *
    * @param parent an optional {@link Individual} to add the new {@link Persona}
-   * to
-   * @param persona_store_type the {@link PersonaStore.type_id} of the
-   * {@link PersonaStore} to use
-   * @param persona_store_id the {@link PersonaStore.id} of the
-   * {@link PersonaStore} to use
+   * to. This persona will be appended to its ordered list of personas.
+   * @param persona_store the {@link PersonaStore} to add the persona to
    * @param details a key-value map of details to use in creating the new
    * {@link Persona}
+   * @return the new {@link Persona} or `null` if the corresponding
+   * {@link Persona} already existed. If non-`null`, the new {@link Persona}
+   * will also be added to a new or existing {@link Individual} as necessary.
+   *
+   * @since 0.3.5
    */
   public async Persona? add_persona_from_details (Individual? parent,
-      string persona_store_type,
-      string persona_store_id,
+      PersonaStore persona_store,
       HashTable<string, Value?> details) throws IndividualAggregatorError
     {
-      var full_id = this.get_store_full_id (persona_store_type,
-          persona_store_id);
-      var store = this.stores[full_id];
-
-      if (store == null)
-        {
-          throw new IndividualAggregatorError.STORE_NOT_FOUND (
-              "no store known for type ID '%s' and ID '%s'", store.type_id,
-              store.id);
-        }
-
       Persona persona = null;
       try
         {
-          var details_copy = asv_copy (details);
-          persona = yield store.add_persona_from_details (details_copy);
+          var details_copy = this._asv_copy (details);
+          persona = yield persona_store.add_persona_from_details (details_copy);
         }
       catch (PersonaStoreError e)
         {
-          throw new IndividualAggregatorError.ADD_FAILED (
-              "failed to add contact for store type '%s', ID '%s': %s",
-              persona_store_type, persona_store_id, e.message);
+          if (e is PersonaStoreError.STORE_OFFLINE)
+            {
+              throw new IndividualAggregatorError.STORE_OFFLINE (e.message);
+            }
+          else
+            {
+              var full_id = this._get_store_full_id (persona_store.type_id,
+                  persona_store.id);
+
+              throw new IndividualAggregatorError.ADD_FAILED (
+                  /* Translators: the first parameter is a store identifier
+                   * and the second parameter is an error message. */
+                  _("Failed to add contact for persona store ID '%s': %s"),
+                  full_id, e.message);
+            }
         }
 
       if (parent != null && persona != null)
         {
-          var personas = parent.personas.copy ();
-
-          personas.append (persona);
-          parent.personas = personas;
+          parent.personas.add (persona);
         }
 
       return persona;
     }
 
-  private HashTable<string, Value?> asv_copy (HashTable<string, Value?> asv)
+  private HashTable<string, Value?> _asv_copy (HashTable<string, Value?> asv)
     {
       var retval = new HashTable<string, Value?> (str_hash, str_equal);
 
       asv.foreach ((k, v) =>
         {
-          retval.insert ((string) k, (Value?) v);
+          retval.insert ((string) k, v);
         });
 
       return retval;
@@ -617,15 +1031,20 @@ public class Folks.IndividualAggregator : Object
    * backing stores.
    *
    * @param individual the {@link Individual} to remove
+   * @since 0.1.11
    */
   public async void remove_individual (Individual individual) throws GLib.Error
     {
-      /* We have to iterate manually since using foreach() requires a sync
-       * lambda function, meaning we can't yield on the remove_persona() call */
-      unowned GLib.List<unowned Persona> i;
-      for (i = individual.personas; i != null; i = i.next)
+      /* Removing personas changes the persona set so we need to make a copy
+       * first */
+      var personas = new HashSet<Persona> ();
+      foreach (var p in individual.personas)
+        {
+          personas.add (p);
+        }
+
+      foreach (var persona in personas)
         {
-          unowned Persona persona = (Persona) i.data;
           yield persona.store.remove_persona (persona);
         }
     }
@@ -636,115 +1055,185 @@ public class Folks.IndividualAggregator : Object
    * This will leave other personas in the same individual alone.
    *
    * @param persona the {@link Persona} to remove
+   * @since 0.1.11
    */
   public async void remove_persona (Persona persona) throws GLib.Error
     {
       yield persona.store.remove_persona (persona);
     }
 
-  /* FIXME: This should be GLib.List<Persona>, but Vala won't allow it */
-  public async void link_personas (void *_personas)
-      throws GLib.Error
+  /**
+   * Link the given {@link Persona}s together.
+   *
+   * Create links between the given {@link Persona}s so that they form a single
+   * {@link Individual}. The new {@link Individual} will be returned via the
+   * {@link IndividualAggregator.individuals_changed} signal.
+   *
+   * Removal of the {@link Individual}s which the {@link Persona}s were in
+   * before is signalled by {@link IndividualAggregator.individuals_changed} and
+   * {@link Individual.removed}.
+   *
+   * @param personas the {@link Persona}s to be linked
+   * @since 0.5.1
+   */
+  public async void link_personas (Set<Persona> personas)
+      throws IndividualAggregatorError
     {
-      unowned GLib.List<Persona> personas = (GLib.List<Persona>) _personas;
-
-      if (this.writeable_store == null)
+      if (this._writeable_store == null)
         {
           throw new IndividualAggregatorError.NO_WRITEABLE_STORE (
-              "Can't link personas with no writeable store.");
+              _("Can't link personas with no writeable store."));
         }
 
       /* Don't bother linking if it's just one Persona */
-      if (personas.next == null)
+      if (personas.size <= 1)
         return;
 
+      /* Disallow linking if it's disabled */
+      if (this._linking_enabled == false)
+        {
+          debug ("Can't link Personas: linking disabled.");
+          return;
+        }
+
       /* Create a new persona in the writeable store which links together the
        * given personas */
-      /* FIXME: We hardcode this to use the key-file backend for now */
-      assert (this.writeable_store.type_id == "key-file");
-
-      /* `protocols_addrs_list` will be passed to the new Kf.Persona, whereas
-       * `protocols_addrs_set` is used to ensure we don't get duplicate IM
-       * addresses in the ordered set of addresses for each protocol in
-       * `protocols_addrs_list`. It's temporary. */
-      HashTable<string, GenericArray<string>> protocols_addrs_list =
-          new HashTable<string, GenericArray<string>> (str_hash, str_equal);
-      HashTable<string, HashSet<string>> protocols_addrs_set =
-          new HashTable<string, HashSet<string>> (str_hash, str_equal);
+      assert (this._writeable_store.type_id ==
+          this._configured_writeable_store_type_id);
 
-      personas.foreach ((p) =>
-        {
-          unowned Persona persona = (Persona) p;
+      /* `protocols_addrs_set` will be passed to the new Kf.Persona */
+      var protocols_addrs_set = new HashMultiMap<string, string> ();
+      var web_service_addrs_set = new HashMultiMap<string, string> ();
 
-          if (!(persona is IMable))
-            return;
+      /* List of local_ids */
+      var local_ids = new Gee.HashSet<string> ();
 
-          ((IMable) persona).im_addresses.foreach ((k, v) =>
+      foreach (var persona in personas)
+        {
+          if (persona is ImDetails)
             {
-              unowned string protocol = (string) k;
-              unowned GenericArray<string> addresses = (GenericArray<string>) v;
+              ImDetails im_details = (ImDetails) persona;
 
-              GenericArray<string> address_list =
-                  protocols_addrs_list.lookup (protocol);
-              HashSet<string> address_set = protocols_addrs_set.lookup (
-                protocol);
-
-              if (address_list == null || address_set == null)
+              /* protocols_addrs_set = union (all personas' IM addresses) */
+              foreach (var protocol in im_details.im_addresses.get_keys ())
                 {
-                  address_list = new GenericArray<string> ();
-                  address_set = new HashSet<string> ();
+                  var im_addresses = im_details.im_addresses.get (protocol);
 
-                  protocols_addrs_list.insert (protocol, address_list);
-                  protocols_addrs_set.insert (protocol, address_set);
+                  foreach (var im_address in im_addresses)
+                    {
+                      protocols_addrs_set.set (protocol, im_address);
+                    }
                 }
+            }
 
-              addresses.foreach ((a) =>
+          if (persona is WebServiceDetails)
+            {
+              WebServiceDetails ws_details = (WebServiceDetails) persona;
+
+              /* web_service_addrs_set = union (all personas' WS addresses) */
+              foreach (var web_service in
+                  ws_details.web_service_addresses.get_keys ())
                 {
-                  unowned string address = (string) a;
+                  var ws_addresses =
+                      ws_details.web_service_addresses.get (web_service);
 
-                  /* Only add the IM address to the ordered set if it isn't
-                   * already a member. */
-                  if (!address_set.contains (address))
+                  foreach (var ws_address in ws_addresses)
                     {
-                      address_list.add (address);
-                      address_set.add (address);
+                      web_service_addrs_set.set (web_service, ws_address);
                     }
-                });
-            });
-        });
+                }
+            }
 
-      Value addresses_value = Value (typeof (HashTable));
-      addresses_value.set_boxed (protocols_addrs_list);
+          if (persona is LocalIdDetails)
+            {
+              foreach (var id in ((LocalIdDetails) persona).local_ids)
+                {
+                  local_ids.add (id);
+                }
+            }
+        }
 
-      HashTable<string, Value?> details =
-          new HashTable<string, Value?> (str_hash, str_equal);
-      details.insert ("im-addresses", addresses_value);
+      var details = new HashTable<string, Value?> (str_hash, str_equal);
 
-      yield this.add_persona_from_details (null, this.writeable_store.type_id,
-          this.writeable_store.id, details);
+      if (protocols_addrs_set.size > 0)
+        {
+          var im_addresses_value = Value (typeof (MultiMap));
+          im_addresses_value.set_object (protocols_addrs_set);
+          details.insert (PersonaStore.detail_key (PersonaDetail.IM_ADDRESSES),
+              im_addresses_value);
+        }
+
+      if (web_service_addrs_set.size > 0)
+        {
+          var web_service_addresses_value = Value (typeof (MultiMap));
+          web_service_addresses_value.set_object (web_service_addrs_set);
+          details.insert (PersonaStore.detail_key
+              (PersonaDetail.WEB_SERVICE_ADDRESSES),
+              web_service_addresses_value);
+        }
+
+      if (local_ids.size > 0)
+        {
+          var local_ids_value = Value (typeof (Set<string>));
+          local_ids_value.set_object (local_ids);
+          details.insert (
+              Folks.PersonaStore.detail_key (PersonaDetail.LOCAL_IDS),
+              local_ids_value);
+        }
+
+      yield this.add_persona_from_details (null,
+          this._writeable_store, details);
     }
 
+  /**
+   * Unlinks the given {@link Individual} into its constituent {@link Persona}s.
+   *
+   * This completely unlinks the given {@link Individual}, destroying all of
+   * its writeable {@link Persona}s.
+   *
+   * The {@link Individual}'s removal is signalled by
+   * {@link IndividualAggregator.individuals_changed} and
+   * {@link Individual.removed}.
+   *
+   * The {@link Persona}s comprising the {@link Individual} will be re-linked
+   * into one or more new {@link Individual}s, depending on how much linking
+   * data remains (typically only implicit links remain). The addition of these
+   * new {@link Individual}s will be signalled by
+   * {@link IndividualAggregator.individuals_changed}.
+   *
+   * @param individual the {@link Individual} to unlink
+   * @since 0.1.13
+   */
   public async void unlink_individual (Individual individual) throws GLib.Error
     {
-      /* Remove all the Personas from writeable PersonaStores
-       * We have to iterate manually since using foreach() requires a sync
-       * lambda function, meaning we can't yield on the remove_persona() call */
+      if (this._linking_enabled == false)
+        {
+          debug ("Can't unlink Individual '%s': linking disabled.",
+              individual.id);
+          return;
+        }
+
       debug ("Unlinking Individual '%s', deleting Personas:", individual.id);
 
-      /* We have to take a copy of the Persona list before removing the
-       * Personas, as personas_changed_cb() (which is called as a result of
-       * calling writeable_store.remove_persona()) messes around with Persona
+      /* Remove all the Personas from writeable PersonaStores.
+       *
+       * We have to take a copy of the Persona list before removing the
+       * Personas, as _personas_changed_cb() (which is called as a result of
+       * calling _writeable_store.remove_persona()) messes around with Persona
        * lists. */
-      GLib.List<Persona> personas = individual.personas.copy ();
-      foreach (Persona p in personas)
-        p.ref ();
+      var personas = new HashSet<Persona> ();
+      foreach (var p in individual.personas)
+        {
+          personas.add (p);
+        }
 
-      foreach (unowned Persona persona in personas)
+      foreach (var persona in personas)
         {
-          if (persona.store == this.writeable_store)
+          if (persona.store == this._writeable_store)
             {
-              debug ("    %s (%s)", persona.uid, persona.iid);
-              yield this.writeable_store.remove_persona (persona);
+              debug ("    %s (is user: %s, IID: %s)", persona.uid,
+                  persona.is_user ? "yes" : "no", persona.iid);
+              yield this._writeable_store.remove_persona (persona);
             }
         }
     }