summaryrefslogtreecommitdiff
path: root/src/contacts-store.vala
blob: c359a3796e0d5dd05a4329d2cc05c29eee59c597 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
/*
 * Copyright (C) 2011 Alexander Larsson <alexl@redhat.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;

/**
 * The Contacts.Store is the base abstraction that holds all contacts (i.e.
 * {@link Folks.Indidivual}s). Note that it also has a "quiescent" and
 * "prepared" signal, with similar effects to those of a
 * {@link Folks.IndividualAggregator}.
 *
 * Internally, the Store works with 3 list models layered on top of each other:
 *
 * - A base list model which contains all contacts in the
 *   {@link Folks.IndividualAggregator}
 * - A {@link Gtk.SortListModel}, which sorts the base model according to
 *   first name or last name, or whatever user preference
 * - A {@link Gtk.FilterListModel} to filter out contacts using a
 *   {@link Folks.Query}, so a user can filter contacts with the search entry
 */
public class Contacts.Store : GLib.Object {

  public signal void quiescent ();
  public signal void prepared ();

  public IndividualAggregator aggregator { get; private set; }
  public BackendStore backend_store { get { return this.aggregator.backend_store; } }

  private GLib.ListStore _address_books = new GLib.ListStore (typeof (PersonaStore));
  public GLib.ListModel address_books {
    get { return this._address_books; }
  }

  // Base list model
  private GLib.ListStore _base_model = new ListStore (typeof (Individual));
  public GLib.ListModel base_model { get { return this._base_model; } }

  // Sorting list model
  public Gtk.SortListModel sort_model { get; private set; }
  public IndividualSorter sorter { get; private set; }

  // Filtering list model
  public Gtk.FilterListModel filter_model { get; private set; }
  public QueryFilter filter { get; private set; }

  // Selection list model
  public Gtk.SingleSelection selection { get; private set; }

  public Gee.HashMultiMap<string, string> dont_suggest_link;

  private void read_dont_suggest_db () {
    dont_suggest_link.clear ();

    var path = Path.build_filename (Environment.get_user_config_dir (), "gnome-contacts", "dont_suggest.db");
    try {
      string contents;
      FileUtils.get_contents (path, out contents);

      var rows = contents.split ("\n");
      foreach (unowned string r in rows) {
        var ids = r.split (" ");
        if (ids.length == 2) {
          dont_suggest_link.set (ids[0], ids[1]);
        }
      }
    } catch (GLib.Error e) {
      if (e is FileError.NOENT)
        return;

      warning ("error loading no suggestion db: %s\n", e.message);
    }
  }

  private void write_dont_suggest_db () {
    try {
      var dir = Path.build_filename (Environment.get_user_config_dir (), "gnome-contacts");
      DirUtils.create_with_parents (dir, 0700);
      var path = Path.build_filename (dir, "dont_suggest.db");

      var s = new StringBuilder ();
      foreach (var key in dont_suggest_link.get_keys ()) {
        foreach (var value in dont_suggest_link.get (key))
          s.append_printf ("%s %s\n", key, value);
      }
      FileUtils.set_contents (path, s.str, s.len);
    } catch (GLib.Error e) {
      warning ("error writing no suggestion db: %s\n", e.message);
    }
  }

  public bool may_suggest_link (Individual a, Individual b) {
    foreach (var a_persona in a.personas) {
      foreach (var no_link_uid in dont_suggest_link.get (a_persona.uid)) {
        foreach (var b_persona in b.personas) {
          if (b_persona.uid == no_link_uid)
            return false;
        }
      }
    }
    foreach (var b_persona in b.personas) {
      foreach (var no_link_uid in dont_suggest_link.get (b_persona.uid)) {
        foreach (var a_persona in a.personas) {
          if (a_persona.uid == no_link_uid)
            return false;
        }
      }
    }
    return true;
  }

  public void add_no_suggest_link (Individual a, Individual b) {
    var persona1 = a.personas.to_array ()[0];
    var persona2 = b.personas.to_array ()[0];
    dont_suggest_link.set (persona1.uid, persona2.uid);
    write_dont_suggest_db ();
  }

  construct {
    this.dont_suggest_link = new Gee.HashMultiMap<string, string> ();
    read_dont_suggest_db ();

    // Setup the backends
    var backend_store = BackendStore.dup ();
    // FIXME: we should just turn the "backends" property in folks into a
    // GListModel directly
    foreach (var backend in backend_store.enabled_backends.values) {
      foreach (var persona_store in backend.persona_stores.values)
        this._address_books.append (persona_store);
    }
    backend_store.backend_available.connect ((backend) => {
      foreach (var persona_store in backend.persona_stores.values)
        this._address_books.append (persona_store);
    });

    // Setup the individual aggregator
    this.aggregator = IndividualAggregator.dup_with_backend_store (backend_store);
    aggregator.notify["is-quiescent"].connect ((obj, pspec) => {
      // We seem to get this before individuals_changed, so hack around it
      Idle.add( () => {
        this.quiescent ();
        return false;
      });
    });

    aggregator.notify["is-prepared"].connect ((obj, pspec) => {
      Idle.add( () => {
        this.prepared ();
        return false;
      });
    });

    this.aggregator.individuals_changed_detailed.connect (on_individuals_changed_detailed);
    aggregator.prepare.begin ();
  }

  public Store (GLib.Settings settings, Folks.Query query) {
    // Create the sorting, filtering and selection models
    this.sorter = new IndividualSorter (settings);
    this.sort_model = new Gtk.SortListModel (this.base_model, this.sorter);

    this.filter = new QueryFilter (query);
    this.filter_model = new Gtk.FilterListModel (this.sort_model, this.filter);

    this.selection = new Gtk.SingleSelection (this.filter_model);
    this.selection.can_unselect = true;
    this.selection.autoselect = false;
  }

  private void on_individuals_changed_detailed (Gee.MultiMap<Individual?,Individual?> changes) {
    var to_add = new GenericArray<unowned Individual> ();
    var to_remove = new GenericArray<unowned Individual> ();

    foreach (var individual in changes.get_keys ()) {
      if (individual != null)
        to_remove.add (individual);
      foreach (var new_i in changes[individual]) {
        if (new_i != null && !to_add.find (new_i, null))
          to_add.add (new_i);
      }
    }

    debug ("Individuals changed: %d added, %d removed", to_add.length, to_remove.length);

    // Remove old individuals. It's not the most performance way of doing it,
    // but optimizing for it (and making it more comples) makes little sense.
    foreach (unowned var indiv in to_remove) {
      uint pos = 0;
      if (this._base_model.find (indiv, out pos)) {
        this._base_model.remove (pos);
      } else {
        debug ("Tried to remove individual '%s', but could't find it", indiv.display_name);
      }
    }

    // Add new individuals
    for (uint i = 0; i < to_add.length; i++) {
      unowned var indiv = to_add[i];
      if (indiv.personas.size == 0 || Utils.is_ignorable (indiv)) {
        to_add.remove_index_fast (i);
        i--;
      } else {
        // We want to make sure that changes in the Individual triggers changes
        // in the list model if it affects sorting and/or filtering. Atm, the
        // only thing that can lead to this is a change in display name or
        // whether they are marked as favourite.
        indiv.notify.connect ((obj, pspec) => {
          unowned var prop_name = pspec.get_name ();
          if (prop_name != "display-name" && prop_name != "is-favourite")
            return;

          uint pos;
          if (this._base_model.find (obj, out pos)) {
            this._base_model.items_changed (pos, 1, 1);
          }
        });
      }
    }
    this._base_model.splice (this.base_model.get_n_items (), 0, (Object[]) to_add.data);
  }

  public unowned Individual? get_selected_contact () {
    return (Individual) this.selection.get_selected_item ();
  }

  /**
   * A helper method to find a contact based on the given search query, while
   * making sure to take care of (wait for) the "quiescent" property of the
   * IndividualAggregator.
   */
  public async uint find_individual_for_query (Query query) {
    // Wait that the store gets quiescent if it isn't already
    if (!this.aggregator.is_quiescent) {
      ulong signal_id;
      SourceFunc callback = find_individual_for_query.callback;
      signal_id = this.quiescent.connect (() => {
        callback ();
      });
      yield;
      disconnect (signal_id);
    }

    // We search for the closest matching Individual
    uint matched_pos = Gtk.INVALID_LIST_POSITION;
    uint strength = 0;
    for (uint i = 0; i < this.filter_model.get_n_items (); i++) {
      var individual = (Individual) this.filter_model.get_item (i);
      uint this_strength = query.is_match (individual);
      if (this_strength > strength) {
        matched_pos = i;
        strength = this_strength;
      }
    }

    return matched_pos;
  }

  /**
   * A helper method to find a contact based on the given individual id, while
   * making sure to take care of (wait for) the "quiescent" property of the
   * IndividualAggregator.
   */
  public async uint find_individual_for_id (string id) {
    // Wait that the store gets quiescent if it isn't already
    if (!this.aggregator.is_quiescent) {
      ulong signal_id;
      SourceFunc callback = find_individual_for_id.callback;
      signal_id = this.quiescent.connect (() => {
        callback ();
      });
      yield;
      disconnect (signal_id);
    }

    for (uint i = 0; i < this.filter_model.get_n_items (); i++) {
      var individual = (Individual) this.filter_model.get_item (i);
      if (individual.id == id)
        return i;
    }

    return Gtk.INVALID_LIST_POSITION;
  }

  /**
   * Sets the primary address book. This will be used as the primary candidate
   * to store new contacts, and will prioritize personas coming from this store
   * when showing them.
   */
  public void set_primary_address_book (Edsf.PersonaStore e_store) {
    eds_source_registry.set_default_address_book (e_store.source);
    var settings = new GLib.Settings ("org.freedesktop.folks");
    settings.set_string ("primary-store", "eds:%s".printf (e_store.id));
  }

  public bool suggest_link_to (Individual self, Individual other) {
    if (non_linkable (self) || non_linkable (other))
      return false;

    if (!may_suggest_link (self, other))
      return false;

    /* Only connect main contacts with non-mainable contacts.
       non-main contacts can link to any other */
    return !Utils.has_main_persona (self) || !has_mainable_persona (other);
  }

  // These are "regular" address book contacts, i.e. they contain a
  // persona that would be "main" if that persona was the primary store
  private bool has_mainable_persona (Individual individual) {
    foreach (var p in individual.personas) {
      if (p.store.type_id == "eds" &&
          !Utils.persona_is_google_other (p))
        return true;
    }
    return false;
  }

  // We never want to suggest linking to google contacts that
  // are part of "Other Contacts"
  private bool non_linkable (Individual individual) {
    bool all_unlinkable = true;

    foreach (var p in individual.personas) {
      if (!Utils.persona_is_google_other (p))
        all_unlinkable = false;
    }

    return all_unlinkable;
  }
}