1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
4
5const EXPORTED_SYMBOLS = ["AddrBookDirectory"];
6
7const { XPCOMUtils } = ChromeUtils.import(
8  "resource://gre/modules/XPCOMUtils.jsm"
9);
10
11XPCOMUtils.defineLazyModuleGetters(this, {
12  AddrBookCard: "resource:///modules/AddrBookCard.jsm",
13  AddrBookMailingList: "resource:///modules/AddrBookMailingList.jsm",
14  compareAddressBooks: "resource:///modules/AddrBookUtils.jsm",
15  newUID: "resource:///modules/AddrBookUtils.jsm",
16  Services: "resource://gre/modules/Services.jsm",
17});
18
19/**
20 * Abstract base class implementing nsIAbDirectory.
21 *
22 * @abstract
23 * @implements {nsIAbDirectory}
24 */
25class AddrBookDirectory {
26  QueryInterface = ChromeUtils.generateQI(["nsIAbDirectory"]);
27
28  constructor() {
29    this._uid = null;
30    this._dirName = null;
31  }
32
33  _initialized = false;
34  init(uri) {
35    if (this._initialized) {
36      throw new Components.Exception(
37        `Directory already initialized: ${uri}`,
38        Cr.NS_ERROR_ALREADY_INITIALIZED
39      );
40    }
41
42    // If this._readOnly is true, the user is prevented from making changes to
43    // the contacts. Subclasses may override this (for example to sync with a
44    // server) by setting this._overrideReadOnly to true, but must clear it
45    // before yielding to another thread (e.g. awaiting a Promise).
46
47    if (this._dirPrefId) {
48      XPCOMUtils.defineLazyPreferenceGetter(
49        this,
50        "_readOnly",
51        `${this.dirPrefId}.readOnly`,
52        false
53      );
54    }
55
56    this._initialized = true;
57  }
58  async cleanUp() {
59    if (!this._initialized) {
60      throw new Components.Exception(
61        "Directory not initialized",
62        Cr.NS_ERROR_NOT_INITIALIZED
63      );
64    }
65  }
66
67  get _prefBranch() {
68    if (this.__prefBranch) {
69      return this.__prefBranch;
70    }
71    if (!this._dirPrefId) {
72      throw Components.Exception("No dirPrefId!", Cr.NS_ERROR_NOT_AVAILABLE);
73    }
74    return (this.__prefBranch = Services.prefs.getBranch(
75      `${this._dirPrefId}.`
76    ));
77  }
78  /** @abstract */
79  get lists() {
80    throw new Components.Exception(
81      `${this.constructor.name} does not implement lists getter.`,
82      Cr.NS_ERROR_NOT_IMPLEMENTED
83    );
84  }
85  /** @abstract */
86  get cards() {
87    throw new Components.Exception(
88      `${this.constructor.name} does not implement cards getter.`,
89      Cr.NS_ERROR_NOT_IMPLEMENTED
90    );
91  }
92
93  getCard(uid) {
94    let card = new AddrBookCard();
95    card.directoryUID = this.UID;
96    card._uid = uid;
97    card._properties = this.loadCardProperties(uid);
98    return card.QueryInterface(Ci.nsIAbCard);
99  }
100  /** @abstract */
101  loadCardProperties(uid) {
102    throw new Components.Exception(
103      `${this.constructor.name} does not implement loadCardProperties.`,
104      Cr.NS_ERROR_NOT_IMPLEMENTED
105    );
106  }
107  /** @abstract */
108  saveCardProperties(card) {
109    throw new Components.Exception(
110      `${this.constructor.name} does not implement saveCardProperties.`,
111      Cr.NS_ERROR_NOT_IMPLEMENTED
112    );
113  }
114  /** @abstract */
115  deleteCard(uid) {
116    throw new Components.Exception(
117      `${this.constructor.name} does not implement deleteCard.`,
118      Cr.NS_ERROR_NOT_IMPLEMENTED
119    );
120  }
121  /** @abstract */
122  saveList(list) {
123    throw new Components.Exception(
124      `${this.constructor.name} does not implement saveList.`,
125      Cr.NS_ERROR_NOT_IMPLEMENTED
126    );
127  }
128  /** @abstract */
129  deleteList(uid) {
130    throw new Components.Exception(
131      `${this.constructor.name} does not implement deleteList.`,
132      Cr.NS_ERROR_NOT_IMPLEMENTED
133    );
134  }
135
136  /* nsIAbDirectory */
137
138  get readOnly() {
139    return this._readOnly;
140  }
141  get isRemote() {
142    return false;
143  }
144  get isSecure() {
145    return false;
146  }
147  get propertiesChromeURI() {
148    return "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml";
149  }
150  get dirPrefId() {
151    return this._dirPrefId;
152  }
153  get dirName() {
154    if (this._dirName === null) {
155      this._dirName = this.getLocalizedStringValue("description", "");
156    }
157    return this._dirName;
158  }
159  set dirName(value) {
160    this.setLocalizedStringValue("description", value);
161    this._dirName = value;
162    Services.obs.notifyObservers(this, "addrbook-directory-updated", "DirName");
163  }
164  get dirType() {
165    return Ci.nsIAbManager.JS_DIRECTORY_TYPE;
166  }
167  get fileName() {
168    return this._fileName;
169  }
170  get UID() {
171    if (!this._uid) {
172      if (this._prefBranch.getPrefType("uid") == Services.prefs.PREF_STRING) {
173        this._uid = this._prefBranch.getStringPref("uid");
174      } else {
175        this._uid = newUID();
176        this._prefBranch.setStringPref("uid", this._uid);
177      }
178    }
179    return this._uid;
180  }
181  get URI() {
182    return this._uri;
183  }
184  get position() {
185    return this._prefBranch.getIntPref("position", 1);
186  }
187  get childNodes() {
188    let lists = Array.from(
189      this.lists.values(),
190      list =>
191        new AddrBookMailingList(
192          list.uid,
193          this,
194          list.name,
195          list.nickName,
196          list.description
197        ).asDirectory
198    );
199    lists.sort(compareAddressBooks);
200    return lists;
201  }
202  get childCards() {
203    let results = Array.from(
204      this.lists.values(),
205      list =>
206        new AddrBookMailingList(
207          list.uid,
208          this,
209          list.name,
210          list.nickName,
211          list.description
212        ).asCard
213    ).concat(Array.from(this.cards.keys(), this.getCard, this));
214
215    return results;
216  }
217  get supportsMailingLists() {
218    return true;
219  }
220
221  search(query, string, listener) {
222    if (!listener) {
223      return;
224    }
225    if (!query) {
226      listener.onSearchFinished(Cr.NS_ERROR_FAILURE, true, null, "");
227      return;
228    }
229    if (query[0] == "?") {
230      query = query.substring(1);
231    }
232
233    let results = Array.from(
234      this.lists.values(),
235      list =>
236        new AddrBookMailingList(
237          list.uid,
238          this,
239          list.name,
240          list.nickName,
241          list.description
242        ).asCard
243    ).concat(Array.from(this.cards.keys(), this.getCard, this));
244
245    // Process the query string into a tree of conditions to match.
246    let lispRegexp = /^\((and|or|not|([^\)]*)(\)+))/;
247    let index = 0;
248    let rootQuery = { children: [], op: "or" };
249    let currentQuery = rootQuery;
250
251    while (true) {
252      let match = lispRegexp.exec(query.substring(index));
253      if (!match) {
254        break;
255      }
256      index += match[0].length;
257
258      if (["and", "or", "not"].includes(match[1])) {
259        // For the opening bracket, step down a level.
260        let child = {
261          parent: currentQuery,
262          children: [],
263          op: match[1],
264        };
265        currentQuery.children.push(child);
266        currentQuery = child;
267      } else {
268        let [name, condition, value] = match[2].split(",");
269        currentQuery.children.push({
270          name,
271          condition,
272          value: decodeURIComponent(value).toLowerCase(),
273        });
274
275        // For each closing bracket except the first, step up a level.
276        for (let i = match[3].length - 1; i > 0; i--) {
277          currentQuery = currentQuery.parent;
278        }
279      }
280    }
281
282    results = results.filter(card => {
283      let properties;
284      if (card.isMailList) {
285        properties = new Map([
286          ["DisplayName", card.displayName],
287          ["NickName", card.getProperty("NickName", "")],
288          ["Notes", card.getProperty("Notes", "")],
289        ]);
290      } else {
291        properties = card._properties;
292      }
293      let matches = b => {
294        if ("condition" in b) {
295          let { name, condition, value } = b;
296          if (name == "IsMailList" && condition == "=") {
297            return card.isMailList == (value == "true");
298          }
299          let cardValue = properties.get(name);
300          if (!cardValue) {
301            return condition == "!ex";
302          }
303          if (condition == "ex") {
304            return true;
305          }
306
307          cardValue = cardValue.toLowerCase();
308          switch (condition) {
309            case "=":
310              return cardValue == value;
311            case "!=":
312              return cardValue != value;
313            case "lt":
314              return cardValue < value;
315            case "gt":
316              return cardValue > value;
317            case "bw":
318              return cardValue.startsWith(value);
319            case "ew":
320              return cardValue.endsWith(value);
321            case "c":
322              return cardValue.includes(value);
323            case "!c":
324              return !cardValue.includes(value);
325            case "~=":
326            case "regex":
327            default:
328              return false;
329          }
330        }
331        if (b.op == "or") {
332          return b.children.some(bb => matches(bb));
333        }
334        if (b.op == "and") {
335          return b.children.every(bb => matches(bb));
336        }
337        if (b.op == "not") {
338          return !matches(b.children[0]);
339        }
340        return false;
341      };
342
343      return matches(rootQuery);
344    }, this);
345
346    for (let card of results) {
347      listener.onSearchFoundCard(card);
348    }
349    listener.onSearchFinished(Cr.NS_OK, true, null, "");
350  }
351  generateName(generateFormat, bundle) {
352    return this.dirName;
353  }
354  cardForEmailAddress(emailAddress) {
355    return (
356      this.getCardFromProperty("PrimaryEmail", emailAddress, false) ||
357      this.getCardFromProperty("SecondEmail", emailAddress, false)
358    );
359  }
360  /** @abstract */
361  getCardFromProperty(property, value, caseSensitive) {
362    throw new Components.Exception(
363      `${this.constructor.name} does not implement getCardFromProperty.`,
364      Cr.NS_ERROR_NOT_IMPLEMENTED
365    );
366  }
367  /** @abstract */
368  getCardsFromProperty(property, value, caseSensitive) {
369    throw new Components.Exception(
370      `${this.constructor.name} does not implement getCardsFromProperty.`,
371      Cr.NS_ERROR_NOT_IMPLEMENTED
372    );
373  }
374  getMailListFromName(name) {
375    for (let list of this.lists.values()) {
376      if (list.name.toLowerCase() == name.toLowerCase()) {
377        return new AddrBookMailingList(
378          list.uid,
379          this,
380          list.name,
381          list.nickName,
382          list.description
383        ).asDirectory;
384      }
385    }
386    return null;
387  }
388  deleteDirectory(directory) {
389    if (this._readOnly) {
390      throw new Components.Exception(
391        "Directory is read-only",
392        Cr.NS_ERROR_FAILURE
393      );
394    }
395
396    let list = this.lists.get(directory.UID);
397    list = new AddrBookMailingList(
398      list.uid,
399      this,
400      list.name,
401      list.nickName,
402      list.description
403    );
404
405    this.deleteList(directory.UID);
406
407    Services.obs.notifyObservers(
408      list.asDirectory,
409      "addrbook-list-deleted",
410      this.UID
411    );
412  }
413  hasCard(card) {
414    return this.lists.has(card.UID) || this.cards.has(card.UID);
415  }
416  hasDirectory(dir) {
417    return this.lists.has(dir.UID);
418  }
419  hasMailListWithName(name) {
420    return this.getMailListFromName(name) != null;
421  }
422  addCard(card) {
423    return this.dropCard(card, false);
424  }
425  modifyCard(card) {
426    if (this._readOnly && !this._overrideReadOnly) {
427      throw new Components.Exception(
428        "Directory is read-only",
429        Cr.NS_ERROR_FAILURE
430      );
431    }
432
433    let oldProperties = this.loadCardProperties(card.UID);
434    let changedProperties = new Set(oldProperties.keys());
435
436    for (let { name, value } of card.properties) {
437      if (!oldProperties.has(name) && ![null, undefined, ""].includes(value)) {
438        changedProperties.add(name);
439      } else if (oldProperties.get(name) == value) {
440        changedProperties.delete(name);
441      }
442    }
443    changedProperties.delete("LastModifiedDate");
444
445    this.saveCardProperties(card);
446
447    if (changedProperties.size == 0) {
448      return;
449    }
450
451    // Send the card as it is in this directory, not as passed to this function.
452    card = this.getCard(card.UID);
453    Services.obs.notifyObservers(card, "addrbook-contact-updated", this.UID);
454
455    let data = {};
456
457    for (let name of changedProperties) {
458      data[name] = {
459        oldValue: oldProperties.get(name) || null,
460        newValue: card.getProperty(name, null),
461      };
462    }
463
464    Services.obs.notifyObservers(
465      card,
466      "addrbook-contact-properties-updated",
467      JSON.stringify(data)
468    );
469  }
470  deleteCards(cards) {
471    if (this._readOnly && !this._overrideReadOnly) {
472      throw new Components.Exception(
473        "Directory is read-only",
474        Cr.NS_ERROR_FAILURE
475      );
476    }
477
478    if (cards === null) {
479      throw Components.Exception("", Cr.NS_ERROR_INVALID_POINTER);
480    }
481
482    for (let card of cards) {
483      this.deleteCard(card.UID);
484      if (this.hasOwnProperty("cards")) {
485        this.cards.delete(card.UID);
486      }
487    }
488
489    for (let card of cards) {
490      Services.obs.notifyObservers(card, "addrbook-contact-deleted", this.UID);
491      card.directoryUID = null;
492    }
493
494    // We could just delete all non-existent cards from list_cards, but a
495    // notification should be fired for each one. Let the list handle that.
496    for (let list of this.childNodes) {
497      list.deleteCards(cards);
498    }
499  }
500  dropCard(card, needToCopyCard) {
501    if (this._readOnly && !this._overrideReadOnly) {
502      throw new Components.Exception(
503        "Directory is read-only",
504        Cr.NS_ERROR_FAILURE
505      );
506    }
507
508    if (!card.UID) {
509      throw new Error("Card must have a UID to be added to this directory.");
510    }
511
512    let newCard = new AddrBookCard();
513    newCard.directoryUID = this.UID;
514    newCard._uid = needToCopyCard ? newUID() : card.UID;
515
516    if (this.hasOwnProperty("cards")) {
517      this.cards.set(newCard._uid, new Map());
518    }
519
520    for (let { name, value } of card.properties) {
521      if (
522        [
523          "DbRowID",
524          "LowercasePrimaryEmail",
525          "LowercaseSecondEmail",
526          "RecordKey",
527          "UID",
528        ].includes(name)
529      ) {
530        // These properties are either stored elsewhere (DbRowID, UID), or no
531        // longer needed. Don't store them.
532        continue;
533      }
534      if (card.directoryUID && ["_etag", "_href"].includes(name)) {
535        // These properties belong to a different directory. Don't keep them.
536        continue;
537      }
538      newCard.setProperty(name, value);
539    }
540    this.saveCardProperties(newCard);
541
542    Services.obs.notifyObservers(newCard, "addrbook-contact-created", this.UID);
543
544    return newCard;
545  }
546  useForAutocomplete(identityKey) {
547    return (
548      Services.prefs.getBoolPref("mail.enable_autocomplete") &&
549      this.getBoolValue("enable_autocomplete", true)
550    );
551  }
552  addMailList(list) {
553    if (this._readOnly) {
554      throw new Components.Exception(
555        "Directory is read-only",
556        Cr.NS_ERROR_FAILURE
557      );
558    }
559
560    if (!list.isMailList) {
561      throw Components.Exception(
562        "Can't add; not a mail list",
563        Cr.NS_ERROR_UNEXPECTED
564      );
565    }
566
567    // Check if the new name is empty.
568    if (!list.dirName) {
569      throw new Components.Exception(
570        `Mail list name must be set; list.dirName=${list.dirName}`,
571        Cr.NS_ERROR_ILLEGAL_VALUE
572      );
573    }
574
575    // Check if the new name contains 2 spaces.
576    if (list.dirName.match("  ")) {
577      throw new Components.Exception(
578        `Invalid mail list name: ${list.dirName}`,
579        Cr.NS_ERROR_ILLEGAL_VALUE
580      );
581    }
582
583    // Check if the new name contains the following special characters.
584    for (let char of ',;"<>') {
585      if (list.dirName.includes(char)) {
586        throw new Components.Exception(
587          `Invalid mail list name: ${list.dirName}`,
588          Cr.NS_ERROR_ILLEGAL_VALUE
589        );
590      }
591    }
592
593    let newList = new AddrBookMailingList(
594      newUID(),
595      this,
596      list.dirName || "",
597      list.listNickName || "",
598      list.description || ""
599    );
600    this.saveList(newList);
601
602    let newListDirectory = newList.asDirectory;
603    Services.obs.notifyObservers(
604      newListDirectory,
605      "addrbook-list-created",
606      this.UID
607    );
608    return newListDirectory;
609  }
610  editMailListToDatabase(listCard) {
611    // Deliberately not implemented, this isn't a mailing list.
612    throw Components.Exception(
613      "editMailListToDatabase not relevant here",
614      Cr.NS_ERROR_NOT_IMPLEMENTED
615    );
616  }
617  copyMailList(srcList) {
618    // Deliberately not implemented, this isn't a mailing list.
619    throw Components.Exception(
620      "copyMailList not relevant here",
621      Cr.NS_ERROR_NOT_IMPLEMENTED
622    );
623  }
624  getIntValue(name, defaultValue) {
625    return this._prefBranch
626      ? this._prefBranch.getIntPref(name, defaultValue)
627      : defaultValue;
628  }
629  getBoolValue(name, defaultValue) {
630    return this._prefBranch
631      ? this._prefBranch.getBoolPref(name, defaultValue)
632      : defaultValue;
633  }
634  getStringValue(name, defaultValue) {
635    return this._prefBranch
636      ? this._prefBranch.getStringPref(name, defaultValue)
637      : defaultValue;
638  }
639  getLocalizedStringValue(name, defaultValue) {
640    if (!this._prefBranch) {
641      return defaultValue;
642    }
643    if (this._prefBranch.getPrefType(name) == Ci.nsIPrefBranch.PREF_INVALID) {
644      return defaultValue;
645    }
646    try {
647      return this._prefBranch.getComplexValue(name, Ci.nsIPrefLocalizedString)
648        .data;
649    } catch (e) {
650      // getComplexValue doesn't work with autoconfig.
651      return this._prefBranch.getStringPref(name);
652    }
653  }
654  setIntValue(name, value) {
655    this._prefBranch.setIntPref(name, value);
656  }
657  setBoolValue(name, value) {
658    this._prefBranch.setBoolPref(name, value);
659  }
660  setStringValue(name, value) {
661    this._prefBranch.setStringPref(name, value);
662  }
663  setLocalizedStringValue(name, value) {
664    let valueLocal = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(
665      Ci.nsIPrefLocalizedString
666    );
667    valueLocal.data = value;
668    this._prefBranch.setComplexValue(
669      name,
670      Ci.nsIPrefLocalizedString,
671      valueLocal
672    );
673  }
674}
675