1/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
2/* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6var EXPORTED_SYMBOLS = ["PlacesUtils"];
7
8const { XPCOMUtils } = ChromeUtils.import(
9  "resource://gre/modules/XPCOMUtils.jsm"
10);
11const { AppConstants } = ChromeUtils.import(
12  "resource://gre/modules/AppConstants.jsm"
13);
14
15XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
16
17XPCOMUtils.defineLazyModuleGetters(this, {
18  Services: "resource://gre/modules/Services.jsm",
19  NetUtil: "resource://gre/modules/NetUtil.jsm",
20  Sqlite: "resource://gre/modules/Sqlite.jsm",
21  Bookmarks: "resource://gre/modules/Bookmarks.jsm",
22  History: "resource://gre/modules/History.jsm",
23  PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.jsm",
24  PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
25});
26
27XPCOMUtils.defineLazyGetter(this, "MOZ_ACTION_REGEX", () => {
28  return /^moz-action:([^,]+),(.*)$/;
29});
30
31XPCOMUtils.defineLazyGetter(this, "gCryptoHash", () => {
32  return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
33});
34
35// On Mac OSX, the transferable system converts "\r\n" to "\n\n", where
36// we really just want "\n". On other platforms, the transferable system
37// converts "\r\n" to "\n".
38const NEWLINE = AppConstants.platform == "macosx" ? "\n" : "\r\n";
39
40// Timers resolution is not always good, it can have a 16ms precision on Win.
41const TIMERS_RESOLUTION_SKEW_MS = 16;
42
43function QI_node(aNode, aIID) {
44  try {
45    return aNode.QueryInterface(aIID);
46  } catch (ex) {}
47  return null;
48}
49function asContainer(aNode) {
50  return QI_node(aNode, Ci.nsINavHistoryContainerResultNode);
51}
52function asQuery(aNode) {
53  return QI_node(aNode, Ci.nsINavHistoryQueryResultNode);
54}
55
56/**
57 * Sends a bookmarks notification through the given observers.
58 *
59 * @param observers
60 *        array of nsINavBookmarkObserver objects.
61 * @param notification
62 *        the notification name.
63 * @param args
64 *        array of arguments to pass to the notification.
65 */
66function notify(observers, notification, args) {
67  for (let observer of observers) {
68    try {
69      observer[notification](...args);
70    } catch (ex) {}
71  }
72}
73
74/**
75 * Sends a keyword change notification.
76 *
77 * @param url
78 *        the url to notify about.
79 * @param keyword
80 *        The keyword to notify, or empty string if a keyword was removed.
81 */
82async function notifyKeywordChange(url, keyword, source) {
83  // Notify bookmarks about the removal.
84  let bookmarks = [];
85  await PlacesUtils.bookmarks.fetch({ url }, b => bookmarks.push(b));
86  for (let bookmark of bookmarks) {
87    let ids = await PlacesUtils.promiseManyItemIds([
88      bookmark.guid,
89      bookmark.parentGuid,
90    ]);
91    bookmark.id = ids.get(bookmark.guid);
92    bookmark.parentId = ids.get(bookmark.parentGuid);
93  }
94  let observers = PlacesUtils.bookmarks.getObservers();
95  for (let bookmark of bookmarks) {
96    notify(observers, "onItemChanged", [
97      bookmark.id,
98      "keyword",
99      false,
100      keyword,
101      bookmark.lastModified * 1000,
102      bookmark.type,
103      bookmark.parentId,
104      bookmark.guid,
105      bookmark.parentGuid,
106      "",
107      source,
108    ]);
109  }
110}
111
112/**
113 * Serializes the given node in JSON format.
114 *
115 * @param aNode
116 *        An nsINavHistoryResultNode
117 */
118function serializeNode(aNode) {
119  let data = {};
120
121  data.title = aNode.title;
122  // The id is no longer used for copying within the same instance/session of
123  // Firefox as of at least 61. However, we keep the id for now to maintain
124  // backwards compat of drag and drop with older Firefox versions.
125  data.id = aNode.itemId;
126  data.itemGuid = aNode.bookmarkGuid;
127  // Add an instanceId so we can tell which instance of an FF session the data
128  // is coming from.
129  data.instanceId = PlacesUtils.instanceId;
130
131  let guid = aNode.bookmarkGuid;
132
133  // Some nodes, e.g. the unfiled/menu/toolbar ones can have a virtual guid, so
134  // we ignore any that are a folder shortcut. These will be handled below.
135  if (
136    guid &&
137    !PlacesUtils.bookmarks.isVirtualRootItem(guid) &&
138    !PlacesUtils.isVirtualLeftPaneItem(guid)
139  ) {
140    if (aNode.parent) {
141      data.parent = aNode.parent.itemId;
142      data.parentGuid = aNode.parent.bookmarkGuid;
143    }
144
145    data.dateAdded = aNode.dateAdded;
146    data.lastModified = aNode.lastModified;
147  }
148
149  if (PlacesUtils.nodeIsURI(aNode)) {
150    // Check for url validity.
151    new URL(aNode.uri);
152    data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
153    data.uri = aNode.uri;
154    if (aNode.tags) {
155      data.tags = aNode.tags;
156    }
157  } else if (PlacesUtils.nodeIsFolder(aNode)) {
158    if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) {
159      data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
160      data.uri = aNode.uri;
161      data.concreteId = PlacesUtils.getConcreteItemId(aNode);
162      data.concreteGuid = PlacesUtils.getConcreteItemGuid(aNode);
163    } else {
164      data.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER;
165    }
166  } else if (PlacesUtils.nodeIsQuery(aNode)) {
167    data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
168    data.uri = aNode.uri;
169  } else if (PlacesUtils.nodeIsSeparator(aNode)) {
170    data.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR;
171  }
172
173  return JSON.stringify(data);
174}
175
176// Imposed to limit database size.
177const DB_URL_LENGTH_MAX = 65536;
178const DB_TITLE_LENGTH_MAX = 4096;
179const DB_DESCRIPTION_LENGTH_MAX = 256;
180const DB_SITENAME_LENGTH_MAX = 50;
181
182/**
183 * Executes a boolean validate function, throwing if it returns false.
184 *
185 * @param boolValidateFn
186 *        A boolean validate function.
187 * @return the input value.
188 * @throws if input doesn't pass the validate function.
189 */
190function simpleValidateFunc(boolValidateFn) {
191  return (v, input) => {
192    if (!boolValidateFn(v, input)) {
193      throw new Error("Invalid value");
194    }
195    return v;
196  };
197}
198
199/**
200 * List of bookmark object validators, one per each known property.
201 * Validators must throw if the property value is invalid and return a fixed up
202 * version of the value, if needed.
203 */
204const BOOKMARK_VALIDATORS = Object.freeze({
205  guid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)),
206  parentGuid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)),
207  guidPrefix: simpleValidateFunc(v => PlacesUtils.isValidGuidPrefix(v)),
208  index: simpleValidateFunc(
209    v => Number.isInteger(v) && v >= PlacesUtils.bookmarks.DEFAULT_INDEX
210  ),
211  dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
212  lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
213  type: simpleValidateFunc(
214    v =>
215      Number.isInteger(v) &&
216      [
217        PlacesUtils.bookmarks.TYPE_BOOKMARK,
218        PlacesUtils.bookmarks.TYPE_FOLDER,
219        PlacesUtils.bookmarks.TYPE_SEPARATOR,
220      ].includes(v)
221  ),
222  title: v => {
223    if (v === null) {
224      return "";
225    }
226    if (typeof v == "string") {
227      return v.slice(0, DB_TITLE_LENGTH_MAX);
228    }
229    throw new Error("Invalid title");
230  },
231  url: v => {
232    simpleValidateFunc(
233      val =>
234        (typeof val == "string" && val.length <= DB_URL_LENGTH_MAX) ||
235        (val instanceof Ci.nsIURI && val.spec.length <= DB_URL_LENGTH_MAX) ||
236        (val instanceof URL && val.href.length <= DB_URL_LENGTH_MAX)
237    ).call(this, v);
238    if (typeof v === "string") {
239      return new URL(v);
240    }
241    if (v instanceof Ci.nsIURI) {
242      return new URL(v.spec);
243    }
244    return v;
245  },
246  source: simpleValidateFunc(
247    v =>
248      Number.isInteger(v) &&
249      Object.values(PlacesUtils.bookmarks.SOURCES).includes(v)
250  ),
251  keyword: simpleValidateFunc(v => typeof v == "string" && v.length),
252  charset: simpleValidateFunc(v => typeof v == "string" && v.length),
253  postData: simpleValidateFunc(v => typeof v == "string" && v.length),
254  tags: simpleValidateFunc(
255    v =>
256      Array.isArray(v) &&
257      v.length &&
258      v.every(item => item && typeof item == "string")
259  ),
260});
261
262// Sync bookmark records can contain additional properties.
263const SYNC_BOOKMARK_VALIDATORS = Object.freeze({
264  // Sync uses Places GUIDs for all records except roots.
265  recordId: simpleValidateFunc(
266    v =>
267      typeof v == "string" &&
268      (PlacesSyncUtils.bookmarks.ROOTS.includes(v) ||
269        PlacesUtils.isValidGuid(v))
270  ),
271  parentRecordId: v => SYNC_BOOKMARK_VALIDATORS.recordId(v),
272  // Sync uses kinds instead of types.
273  kind: simpleValidateFunc(
274    v =>
275      typeof v == "string" &&
276      Object.values(PlacesSyncUtils.bookmarks.KINDS).includes(v)
277  ),
278  query: simpleValidateFunc(v => v === null || (typeof v == "string" && v)),
279  folder: simpleValidateFunc(
280    v =>
281      typeof v == "string" &&
282      v &&
283      v.length <= PlacesUtils.bookmarks.MAX_TAG_LENGTH
284  ),
285  tags: v => {
286    if (v === null) {
287      return [];
288    }
289    if (!Array.isArray(v)) {
290      throw new Error("Invalid tag array");
291    }
292    for (let tag of v) {
293      if (
294        typeof tag != "string" ||
295        !tag ||
296        tag.length > PlacesUtils.bookmarks.MAX_TAG_LENGTH
297      ) {
298        throw new Error(`Invalid tag: ${tag}`);
299      }
300    }
301    return v;
302  },
303  keyword: simpleValidateFunc(v => v === null || typeof v == "string"),
304  dateAdded: simpleValidateFunc(
305    v =>
306      typeof v === "number" &&
307      v > PlacesSyncUtils.bookmarks.EARLIEST_BOOKMARK_TIMESTAMP
308  ),
309  feed: v => (v === null ? v : BOOKMARK_VALIDATORS.url(v)),
310  site: v => (v === null ? v : BOOKMARK_VALIDATORS.url(v)),
311  title: BOOKMARK_VALIDATORS.title,
312  url: BOOKMARK_VALIDATORS.url,
313});
314
315// Sync change records are passed between `PlacesSyncUtils` and the Sync
316// bookmarks engine, and are used to update an item's sync status and change
317// counter at the end of a sync.
318const SYNC_CHANGE_RECORD_VALIDATORS = Object.freeze({
319  modified: simpleValidateFunc(v => typeof v == "number" && v >= 0),
320  counter: simpleValidateFunc(v => typeof v == "number" && v >= 0),
321  status: simpleValidateFunc(
322    v =>
323      typeof v == "number" &&
324      Object.values(PlacesUtils.bookmarks.SYNC_STATUS).includes(v)
325  ),
326  tombstone: simpleValidateFunc(v => v === true || v === false),
327  synced: simpleValidateFunc(v => v === true || v === false),
328});
329/**
330 * List PageInfo bookmark object validators.
331 */
332const PAGEINFO_VALIDATORS = Object.freeze({
333  guid: BOOKMARK_VALIDATORS.guid,
334  url: BOOKMARK_VALIDATORS.url,
335  title: v => {
336    if (v == null || v == undefined) {
337      return undefined;
338    } else if (typeof v === "string") {
339      return v;
340    }
341    throw new TypeError(
342      `title property of PageInfo object: ${v} must be a string if provided`
343    );
344  },
345  previewImageURL: v => {
346    if (!v) {
347      return null;
348    }
349    return BOOKMARK_VALIDATORS.url(v);
350  },
351  description: v => {
352    if (typeof v === "string" || v === null) {
353      return v ? v.slice(0, DB_DESCRIPTION_LENGTH_MAX) : null;
354    }
355    throw new TypeError(
356      `description property of pageInfo object: ${v} must be either a string or null if provided`
357    );
358  },
359  siteName: v => {
360    if (typeof v === "string" || v === null) {
361      return v ? v.slice(0, DB_SITENAME_LENGTH_MAX) : null;
362    }
363    throw new TypeError(
364      `siteName property of pageInfo object: ${v} must be either a string or null if provided`
365    );
366  },
367  annotations: v => {
368    if (typeof v != "object" || v.constructor.name != "Map") {
369      throw new TypeError("annotations must be a Map");
370    }
371
372    if (v.size == 0) {
373      throw new TypeError("there must be at least one annotation");
374    }
375
376    for (let [key, value] of v.entries()) {
377      if (typeof key != "string") {
378        throw new TypeError("all annotation keys must be strings");
379      }
380      if (
381        typeof value != "string" &&
382        typeof value != "number" &&
383        typeof value != "boolean" &&
384        value !== null &&
385        value !== undefined
386      ) {
387        throw new TypeError(
388          "all annotation values must be Boolean, Numbers or Strings"
389        );
390      }
391    }
392    return v;
393  },
394  visits: v => {
395    if (!Array.isArray(v) || !v.length) {
396      throw new TypeError("PageInfo object must have an array of visits");
397    }
398    let visits = [];
399    for (let inVisit of v) {
400      let visit = {
401        date: new Date(),
402        transition: inVisit.transition || History.TRANSITIONS.LINK,
403      };
404
405      if (!PlacesUtils.history.isValidTransition(visit.transition)) {
406        throw new TypeError(
407          `transition: ${visit.transition} is not a valid transition type`
408        );
409      }
410
411      if (inVisit.date) {
412        PlacesUtils.history.ensureDate(inVisit.date);
413        if (inVisit.date > Date.now() + TIMERS_RESOLUTION_SKEW_MS) {
414          throw new TypeError(`date: ${inVisit.date} cannot be a future date`);
415        }
416        visit.date = inVisit.date;
417      }
418
419      if (inVisit.referrer) {
420        visit.referrer = PlacesUtils.normalizeToURLOrGUID(inVisit.referrer);
421      }
422      visits.push(visit);
423    }
424    return visits;
425  },
426});
427
428var PlacesUtils = {
429  // Place entries that are containers, e.g. bookmark folders or queries.
430  TYPE_X_MOZ_PLACE_CONTAINER: "text/x-moz-place-container",
431  // Place entries that are bookmark separators.
432  TYPE_X_MOZ_PLACE_SEPARATOR: "text/x-moz-place-separator",
433  // Place entries that are not containers or separators
434  TYPE_X_MOZ_PLACE: "text/x-moz-place",
435  // Place entries in shortcut url format (url\ntitle)
436  TYPE_X_MOZ_URL: "text/x-moz-url",
437  // Place entries formatted as HTML anchors
438  TYPE_HTML: "text/html",
439  // Place entries as raw URL text
440  TYPE_UNICODE: "text/unicode",
441  // Used to track the action that populated the clipboard.
442  TYPE_X_MOZ_PLACE_ACTION: "text/x-moz-place-action",
443
444  // Deprecated: Remaining only for supporting migration of old livemarks.
445  LMANNO_FEEDURI: "livemark/feedURI",
446  LMANNO_SITEURI: "livemark/siteURI",
447  CHARSET_ANNO: "URIProperties/characterSet",
448  // Deprecated: This is only used for supporting import from older datasets.
449  MOBILE_ROOT_ANNO: "mobile/bookmarksRoot",
450
451  TOPIC_SHUTDOWN: "places-shutdown",
452  TOPIC_INIT_COMPLETE: "places-init-complete",
453  TOPIC_DATABASE_LOCKED: "places-database-locked",
454  TOPIC_EXPIRATION_FINISHED: "places-expiration-finished",
455  TOPIC_FAVICONS_EXPIRED: "places-favicons-expired",
456  TOPIC_VACUUM_STARTING: "places-vacuum-starting",
457  TOPIC_BOOKMARKS_RESTORE_BEGIN: "bookmarks-restore-begin",
458  TOPIC_BOOKMARKS_RESTORE_SUCCESS: "bookmarks-restore-success",
459  TOPIC_BOOKMARKS_RESTORE_FAILED: "bookmarks-restore-failed",
460
461  observers: PlacesObservers,
462
463  /**
464   * GUIDs associated with virtual queries that are used for displaying the
465   * top-level folders in the left pane.
466   */
467  virtualAllBookmarksGuid: "allbms_____v",
468  virtualHistoryGuid: "history____v",
469  virtualDownloadsGuid: "downloads__v",
470  virtualTagsGuid: "tags_______v",
471
472  /**
473   * Checks if a guid is a virtual left-pane root.
474   *
475   * @param {String} guid The guid of the item to look for.
476   * @returns {Boolean} true if guid is a virtual root, false otherwise.
477   */
478  isVirtualLeftPaneItem(guid) {
479    return (
480      guid == PlacesUtils.virtualAllBookmarksGuid ||
481      guid == PlacesUtils.virtualHistoryGuid ||
482      guid == PlacesUtils.virtualDownloadsGuid ||
483      guid == PlacesUtils.virtualTagsGuid
484    );
485  },
486
487  asContainer: aNode => asContainer(aNode),
488  asQuery: aNode => asQuery(aNode),
489
490  endl: NEWLINE,
491
492  /**
493   * Is a string a valid GUID?
494   *
495   * @param guid: (String)
496   * @return (Boolean)
497   */
498  isValidGuid(guid) {
499    return typeof guid == "string" && guid && /^[a-zA-Z0-9\-_]{12}$/.test(guid);
500  },
501
502  /**
503   * Is a string a valid GUID prefix?
504   *
505   * @param guidPrefix: (String)
506   * @return (Boolean)
507   */
508  isValidGuidPrefix(guidPrefix) {
509    return (
510      typeof guidPrefix == "string" &&
511      guidPrefix &&
512      /^[a-zA-Z0-9\-_]{1,11}$/.test(guidPrefix)
513    );
514  },
515
516  /**
517   * Generates a random GUID and replace its beginning with the given
518   * prefix. We do this instead of just prepending the prefix to keep
519   * the correct character length.
520   *
521   * @param prefix: (String)
522   * @return (String)
523   */
524  generateGuidWithPrefix(prefix) {
525    return prefix + this.history.makeGuid().substring(prefix.length);
526  },
527
528  /**
529   * Converts a string or n URL object to an nsIURI.
530   *
531   * @param url (URL) or (String)
532   *        the URL to convert.
533   * @return nsIURI for the given URL.
534   */
535  toURI(url) {
536    url = url instanceof URL ? url.href : url;
537
538    return NetUtil.newURI(url);
539  },
540
541  /**
542   * Convert a Date object to a PRTime (microseconds).
543   *
544   * @param date
545   *        the Date object to convert.
546   * @return microseconds from the epoch.
547   */
548  toPRTime(date) {
549    if (typeof date != "number" && date.constructor.name != "Date") {
550      throw new Error("Invalid value passed to toPRTime");
551    }
552    return date * 1000;
553  },
554
555  /**
556   * Convert a PRTime to a Date object.
557   *
558   * @param time
559   *        microseconds from the epoch.
560   * @return a Date object.
561   */
562  toDate(time) {
563    if (typeof time != "number") {
564      throw new Error("Invalid value passed to toDate");
565    }
566    return new Date(parseInt(time / 1000));
567  },
568
569  /**
570   * Wraps a string in a nsISupportsString wrapper.
571   * @param   aString
572   *          The string to wrap.
573   * @returns A nsISupportsString object containing a string.
574   */
575  toISupportsString: function PU_toISupportsString(aString) {
576    let s = Cc["@mozilla.org/supports-string;1"].createInstance(
577      Ci.nsISupportsString
578    );
579    s.data = aString;
580    return s;
581  },
582
583  getFormattedString: function PU_getFormattedString(key, params) {
584    return bundle.formatStringFromName(key, params);
585  },
586
587  getString: function PU_getString(key) {
588    return bundle.GetStringFromName(key);
589  },
590
591  /**
592   * Parses a moz-action URL and returns its parts.
593   *
594   * @param url A moz-action URI.
595   * @note URL is in the format moz-action:ACTION,JSON_ENCODED_PARAMS
596   */
597  parseActionUrl(url) {
598    if (url instanceof Ci.nsIURI) {
599      url = url.spec;
600    } else if (url instanceof URL) {
601      url = url.href;
602    }
603    // Faster bailout.
604    if (!url.startsWith("moz-action:")) {
605      return null;
606    }
607
608    try {
609      let [, type, params] = url.match(MOZ_ACTION_REGEX);
610      let action = {
611        type,
612        params: JSON.parse(params),
613      };
614      for (let key in action.params) {
615        action.params[key] = decodeURIComponent(action.params[key]);
616      }
617      return action;
618    } catch (ex) {
619      Cu.reportError(`Invalid action url "${url}"`);
620      return null;
621    }
622  },
623
624  /**
625   * Determines if a folder is generated from a query.
626   * @param aNode a result true.
627   * @returns true if the node is a folder generated from a query.
628   */
629  isQueryGeneratedFolder(node) {
630    if (!node.parent) {
631      return false;
632    }
633    return this.nodeIsFolder(node) && this.nodeIsQuery(node.parent);
634  },
635
636  /**
637   * Determines whether or not a ResultNode is a Bookmark folder.
638   * @param   aNode
639   *          A result node
640   * @returns true if the node is a Bookmark folder, false otherwise
641   */
642  nodeIsFolder: function PU_nodeIsFolder(aNode) {
643    return (
644      aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER ||
645      aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT
646    );
647  },
648
649  /**
650   * Determines whether or not a ResultNode represents a bookmarked URI.
651   * @param   aNode
652   *          A result node
653   * @returns true if the node represents a bookmarked URI, false otherwise
654   */
655  nodeIsBookmark: function PU_nodeIsBookmark(aNode) {
656    return (
657      aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI &&
658      aNode.itemId != -1
659    );
660  },
661
662  /**
663   * Determines whether or not a ResultNode is a Bookmark separator.
664   * @param   aNode
665   *          A result node
666   * @returns true if the node is a Bookmark separator, false otherwise
667   */
668  nodeIsSeparator: function PU_nodeIsSeparator(aNode) {
669    return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR;
670  },
671
672  /**
673   * Determines whether or not a ResultNode is a URL item.
674   * @param   aNode
675   *          A result node
676   * @returns true if the node is a URL item, false otherwise
677   */
678  nodeIsURI: function PU_nodeIsURI(aNode) {
679    return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
680  },
681
682  /**
683   * Determines whether or not a ResultNode is a Query item.
684   * @param   aNode
685   *          A result node
686   * @returns true if the node is a Query item, false otherwise
687   */
688  nodeIsQuery: function PU_nodeIsQuery(aNode) {
689    return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY;
690  },
691
692  /**
693   * Generator for a node's ancestors.
694   * @param aNode
695   *        A result node
696   */
697  nodeAncestors: function* PU_nodeAncestors(aNode) {
698    let node = aNode.parent;
699    while (node) {
700      yield node;
701      node = node.parent;
702    }
703  },
704
705  /**
706   * Checks validity of an object, filling up default values for optional
707   * properties.
708   *
709   * @param {string} name
710   *        The operation name. This is included in the error message if
711   *        validation fails.
712   * @param validators (object)
713   *        An object containing input validators. Keys should be field names;
714   *        values should be validation functions.
715   * @param props (object)
716   *        The object to validate.
717   * @param behavior (object) [optional]
718   *        Object defining special behavior for some of the properties.
719   *        The following behaviors may be optionally set:
720   *         - required: this property is required.
721   *         - replaceWith: this property will be overwritten with the value
722   *                        provided
723   *         - requiredIf: if the provided condition is satisfied, then this
724   *                       property is required.
725   *         - validIf: if the provided condition is not satisfied, then this
726   *                    property is invalid.
727   *         - defaultValue: an undefined property should default to this value.
728   *         - fixup: a function invoked when validation fails, takes the input
729   *                  object as argument and must fix the property.
730   *
731   * @return a validated and normalized item.
732   * @throws if the object contains invalid data.
733   * @note any unknown properties are pass-through.
734   */
735  validateItemProperties(name, validators, props, behavior = {}) {
736    if (typeof props != "object" || !props) {
737      throw new Error(`${name}: Input should be a valid object`);
738    }
739    // Make a shallow copy of `props` to avoid mutating the original object
740    // when filling in defaults.
741    let input = Object.assign({}, props);
742    let normalizedInput = {};
743    let required = new Set();
744    for (let prop in behavior) {
745      if (
746        behavior[prop].hasOwnProperty("required") &&
747        behavior[prop].required
748      ) {
749        required.add(prop);
750      }
751      if (
752        behavior[prop].hasOwnProperty("requiredIf") &&
753        behavior[prop].requiredIf(input)
754      ) {
755        required.add(prop);
756      }
757      if (
758        behavior[prop].hasOwnProperty("validIf") &&
759        input[prop] !== undefined &&
760        !behavior[prop].validIf(input)
761      ) {
762        if (behavior[prop].hasOwnProperty("fixup")) {
763          behavior[prop].fixup(input);
764        } else {
765          throw new Error(
766            `${name}: Invalid value for property '${prop}': ${JSON.stringify(
767              input[prop]
768            )}`
769          );
770        }
771      }
772      if (
773        behavior[prop].hasOwnProperty("defaultValue") &&
774        input[prop] === undefined
775      ) {
776        input[prop] = behavior[prop].defaultValue;
777      }
778      if (behavior[prop].hasOwnProperty("replaceWith")) {
779        input[prop] = behavior[prop].replaceWith;
780      }
781    }
782
783    for (let prop in input) {
784      if (required.has(prop)) {
785        required.delete(prop);
786      } else if (input[prop] === undefined) {
787        // Skip undefined properties that are not required.
788        continue;
789      }
790      if (validators.hasOwnProperty(prop)) {
791        try {
792          normalizedInput[prop] = validators[prop](input[prop], input);
793        } catch (ex) {
794          if (
795            behavior.hasOwnProperty(prop) &&
796            behavior[prop].hasOwnProperty("fixup")
797          ) {
798            behavior[prop].fixup(input);
799            normalizedInput[prop] = input[prop];
800          } else {
801            throw new Error(
802              `${name}: Invalid value for property '${prop}': ${JSON.stringify(
803                input[prop]
804              )}`
805            );
806          }
807        }
808      }
809    }
810    if (required.size > 0) {
811      throw new Error(
812        `${name}: The following properties were expected: ${[...required].join(
813          ", "
814        )}`
815      );
816    }
817    return normalizedInput;
818  },
819
820  BOOKMARK_VALIDATORS,
821  PAGEINFO_VALIDATORS,
822  SYNC_BOOKMARK_VALIDATORS,
823  SYNC_CHANGE_RECORD_VALIDATORS,
824
825  QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
826
827  _shutdownFunctions: [],
828  registerShutdownFunction: function PU_registerShutdownFunction(aFunc) {
829    // If this is the first registered function, add the shutdown observer.
830    if (!this._shutdownFunctions.length) {
831      Services.obs.addObserver(this, this.TOPIC_SHUTDOWN);
832    }
833    this._shutdownFunctions.push(aFunc);
834  },
835
836  // nsIObserver
837  observe: function PU_observe(aSubject, aTopic, aData) {
838    switch (aTopic) {
839      case this.TOPIC_SHUTDOWN:
840        Services.obs.removeObserver(this, this.TOPIC_SHUTDOWN);
841        while (this._shutdownFunctions.length) {
842          this._shutdownFunctions.shift().apply(this);
843        }
844        break;
845    }
846  },
847
848  /**
849   * Determines whether or not a ResultNode is a host container.
850   * @param   aNode
851   *          A result node
852   * @returns true if the node is a host container, false otherwise
853   */
854  nodeIsHost: function PU_nodeIsHost(aNode) {
855    return (
856      aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
857      aNode.parent &&
858      asQuery(aNode.parent).queryOptions.resultType ==
859        Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY
860    );
861  },
862
863  /**
864   * Determines whether or not a ResultNode is a day container.
865   * @param   node
866   *          A NavHistoryResultNode
867   * @returns true if the node is a day container, false otherwise
868   */
869  nodeIsDay: function PU_nodeIsDay(aNode) {
870    var resultType;
871    return (
872      aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
873      aNode.parent &&
874      ((resultType = asQuery(aNode.parent).queryOptions.resultType) ==
875        Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
876        resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY)
877    );
878  },
879
880  /**
881   * Determines whether or not a result-node is a tag container.
882   * @param   aNode
883   *          A result-node
884   * @returns true if the node is a tag container, false otherwise
885   */
886  nodeIsTagQuery: function PU_nodeIsTagQuery(aNode) {
887    if (aNode.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) {
888      return false;
889    }
890    // Direct child of RESULTS_AS_TAGS_ROOT.
891    let parent = aNode.parent;
892    if (
893      parent &&
894      PlacesUtils.asQuery(parent).queryOptions.resultType ==
895        Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT
896    ) {
897      return true;
898    }
899    // We must also support the right pane of the Library, when the tag query
900    // is the root node. Unfortunately this is also valid for any tag query
901    // selected in the left pane that is not a direct child of RESULTS_AS_TAGS_ROOT.
902    if (
903      !parent &&
904      aNode == aNode.parentResult.root &&
905      PlacesUtils.asQuery(aNode).query.tags.length == 1
906    ) {
907      return true;
908    }
909    return false;
910  },
911
912  /**
913   * Determines whether or not a ResultNode is a container.
914   * @param   aNode
915   *          A result node
916   * @returns true if the node is a container item, false otherwise
917   */
918  containerTypes: [
919    Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
920    Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT,
921    Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY,
922  ],
923  nodeIsContainer: function PU_nodeIsContainer(aNode) {
924    return this.containerTypes.includes(aNode.type);
925  },
926
927  /**
928   * Determines whether or not a ResultNode is an history related container.
929   * @param   node
930   *          A result node
931   * @returns true if the node is an history related container, false otherwise
932   */
933  nodeIsHistoryContainer: function PU_nodeIsHistoryContainer(aNode) {
934    var resultType;
935    return (
936      this.nodeIsQuery(aNode) &&
937      ((resultType = asQuery(aNode).queryOptions.resultType) ==
938        Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY ||
939        resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
940        resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY ||
941        this.nodeIsDay(aNode) ||
942        this.nodeIsHost(aNode))
943    );
944  },
945
946  /**
947   * Gets the concrete item-id for the given node. Generally, this is just
948   * node.itemId, but for folder-shortcuts that's node.folderItemId.
949   */
950  getConcreteItemId: function PU_getConcreteItemId(aNode) {
951    return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT
952      ? asQuery(aNode).folderItemId
953      : aNode.itemId;
954  },
955
956  /**
957   * Gets the concrete item-guid for the given node. For everything but folder
958   * shortcuts, this is just node.bookmarkGuid.  For folder shortcuts, this is
959   * node.targetFolderGuid (see nsINavHistoryService.idl for the semantics).
960   *
961   * @param aNode
962   *        a result node.
963   * @return the concrete item-guid for aNode.
964   */
965  getConcreteItemGuid(aNode) {
966    if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) {
967      return asQuery(aNode).targetFolderGuid;
968    }
969    return aNode.bookmarkGuid;
970  },
971
972  /**
973   * Reverse a host based on the moz_places algorithm, that is reverse the host
974   * string and add a trailing period.  For example "google.com" becomes
975   * "moc.elgoog.".
976   *
977   * @param url
978   *        the URL to generate a rev host for.
979   * @return the reversed host string.
980   */
981  getReversedHost(url) {
982    return (
983      url.host
984        .split("")
985        .reverse()
986        .join("") + "."
987    );
988  },
989
990  /**
991   * String-wraps a result node according to the rules of the specified
992   * content type for copy or move operations.
993   *
994   * @param   aNode
995   *          The Result node to wrap (serialize)
996   * @param   aType
997   *          The content type to serialize as
998   * @return  A string serialization of the node
999   */
1000  wrapNode(aNode, aType) {
1001    // when wrapping a node, we want all the items, even if the original
1002    // query options are excluding them.
1003    // This can happen when copying from the left hand pane of the bookmarks
1004    // organizer.
1005    // @return [node, shouldClose]
1006    function gatherDataFromNode(node, gatherDataFunc) {
1007      if (
1008        PlacesUtils.nodeIsFolder(node) &&
1009        node.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT &&
1010        asQuery(node).queryOptions.excludeItems
1011      ) {
1012        let folderRoot = PlacesUtils.getFolderContents(
1013          node.bookmarkGuid,
1014          false,
1015          true
1016        ).root;
1017        try {
1018          return gatherDataFunc(folderRoot);
1019        } finally {
1020          folderRoot.containerOpen = false;
1021        }
1022      }
1023      // If we didn't create our own query, do not alter the node's state.
1024      return gatherDataFunc(node);
1025    }
1026
1027    function gatherDataHtml(node) {
1028      let htmlEscape = s =>
1029        s
1030          .replace(/&/g, "&amp;")
1031          .replace(/>/g, "&gt;")
1032          .replace(/</g, "&lt;")
1033          .replace(/"/g, "&quot;")
1034          .replace(/'/g, "&apos;");
1035
1036      // escape out potential HTML in the title
1037      let escapedTitle = node.title ? htmlEscape(node.title) : "";
1038
1039      if (PlacesUtils.nodeIsContainer(node)) {
1040        asContainer(node);
1041        let wasOpen = node.containerOpen;
1042        if (!wasOpen) {
1043          node.containerOpen = true;
1044        }
1045
1046        let childString = "<DL><DT>" + escapedTitle + "</DT>" + NEWLINE;
1047        let cc = node.childCount;
1048        for (let i = 0; i < cc; ++i) {
1049          childString +=
1050            "<DD>" +
1051            NEWLINE +
1052            gatherDataHtml(node.getChild(i)) +
1053            "</DD>" +
1054            NEWLINE;
1055        }
1056        node.containerOpen = wasOpen;
1057        return childString + "</DL>" + NEWLINE;
1058      }
1059      if (PlacesUtils.nodeIsURI(node)) {
1060        return `<A HREF="${node.uri}">${escapedTitle}</A>${NEWLINE}`;
1061      }
1062      if (PlacesUtils.nodeIsSeparator(node)) {
1063        return "<HR>" + NEWLINE;
1064      }
1065      return "";
1066    }
1067
1068    function gatherDataText(node) {
1069      if (PlacesUtils.nodeIsContainer(node)) {
1070        asContainer(node);
1071        let wasOpen = node.containerOpen;
1072        if (!wasOpen) {
1073          node.containerOpen = true;
1074        }
1075
1076        let childString = node.title + NEWLINE;
1077        let cc = node.childCount;
1078        for (let i = 0; i < cc; ++i) {
1079          let child = node.getChild(i);
1080          let suffix = i < cc - 1 ? NEWLINE : "";
1081          childString += gatherDataText(child) + suffix;
1082        }
1083        node.containerOpen = wasOpen;
1084        return childString;
1085      }
1086      if (PlacesUtils.nodeIsURI(node)) {
1087        return node.uri;
1088      }
1089      if (PlacesUtils.nodeIsSeparator(node)) {
1090        return "--------------------";
1091      }
1092      return "";
1093    }
1094
1095    switch (aType) {
1096      case this.TYPE_X_MOZ_PLACE:
1097      case this.TYPE_X_MOZ_PLACE_SEPARATOR:
1098      case this.TYPE_X_MOZ_PLACE_CONTAINER: {
1099        // Serialize the node to JSON.
1100        return serializeNode(aNode);
1101      }
1102      case this.TYPE_X_MOZ_URL: {
1103        if (PlacesUtils.nodeIsURI(aNode)) {
1104          return aNode.uri + NEWLINE + aNode.title;
1105        }
1106        if (PlacesUtils.nodeIsContainer(aNode)) {
1107          return PlacesUtils.getURLsForContainerNode(aNode)
1108            .map(item => item.uri + "\n" + item.title)
1109            .join("\n");
1110        }
1111        return "";
1112      }
1113      case this.TYPE_HTML: {
1114        return gatherDataFromNode(aNode, gatherDataHtml);
1115      }
1116    }
1117
1118    // Otherwise, we wrap as TYPE_UNICODE.
1119    return gatherDataFromNode(aNode, gatherDataText);
1120  },
1121
1122  /**
1123   * Unwraps data from the Clipboard or the current Drag Session.
1124   * @param   blob
1125   *          A blob (string) of data, in some format we potentially know how
1126   *          to parse.
1127   * @param   type
1128   *          The content type of the blob.
1129   * @returns An array of objects representing each item contained by the source.
1130   * @throws if the blob contains invalid data.
1131   */
1132  unwrapNodes: function PU_unwrapNodes(blob, type) {
1133    // We split on "\n"  because the transferable system converts "\r\n" to "\n"
1134    var nodes = [];
1135    switch (type) {
1136      case this.TYPE_X_MOZ_PLACE:
1137      case this.TYPE_X_MOZ_PLACE_SEPARATOR:
1138      case this.TYPE_X_MOZ_PLACE_CONTAINER:
1139        nodes = JSON.parse("[" + blob + "]");
1140        break;
1141      case this.TYPE_X_MOZ_URL: {
1142        let parts = blob.split("\n");
1143        // data in this type has 2 parts per entry, so if there are fewer
1144        // than 2 parts left, the blob is malformed and we should stop
1145        // but drag and drop of files from the shell has parts.length = 1
1146        if (parts.length != 1 && parts.length % 2) {
1147          break;
1148        }
1149        for (let i = 0; i < parts.length; i = i + 2) {
1150          let uriString = parts[i];
1151          let titleString = "";
1152          if (parts.length > i + 1) {
1153            titleString = parts[i + 1];
1154          } else {
1155            // for drag and drop of files, try to use the leafName as title
1156            try {
1157              titleString = Services.io
1158                .newURI(uriString)
1159                .QueryInterface(Ci.nsIURL).fileName;
1160            } catch (ex) {}
1161          }
1162          // note:  Services.io.newURI() will throw if uriString is not a valid URI
1163          let uri = Services.io.newURI(uriString);
1164          if (Services.io.newURI(uriString) && uri.scheme != "place") {
1165            nodes.push({
1166              uri: uriString,
1167              title: titleString ? titleString : uriString,
1168              type: this.TYPE_X_MOZ_URL,
1169            });
1170          }
1171        }
1172        break;
1173      }
1174      case this.TYPE_UNICODE: {
1175        let parts = blob.split("\n");
1176        for (let i = 0; i < parts.length; i++) {
1177          let uriString = parts[i];
1178          // text/uri-list is converted to TYPE_UNICODE but it could contain
1179          // comments line prepended by #, we should skip them, as well as
1180          // empty uris.
1181          if (uriString.substr(0, 1) == "\x23" || uriString == "") {
1182            continue;
1183          }
1184          // note: Services.io.newURI) will throw if uriString is not a valid URI
1185          let uri = Services.io.newURI(uriString);
1186          if (uri.scheme != "place") {
1187            nodes.push({
1188              uri: uriString,
1189              title: uriString,
1190              type: this.TYPE_X_MOZ_URL,
1191            });
1192          }
1193        }
1194        break;
1195      }
1196      default:
1197        throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
1198    }
1199    return nodes;
1200  },
1201
1202  /**
1203   * Validate an input PageInfo object, returning a valid PageInfo object.
1204   *
1205   * @param pageInfo: (PageInfo)
1206   * @return (PageInfo)
1207   */
1208  validatePageInfo(pageInfo, validateVisits = true) {
1209    return this.validateItemProperties(
1210      "PageInfo",
1211      PAGEINFO_VALIDATORS,
1212      pageInfo,
1213      {
1214        url: { requiredIf: b => !b.guid },
1215        guid: { requiredIf: b => !b.url },
1216        visits: { requiredIf: b => validateVisits },
1217      }
1218    );
1219  },
1220  /**
1221   * Normalize a key to either a string (if it is a valid GUID) or an
1222   * instance of `URL` (if it is a `URL`, `nsIURI`, or a string
1223   * representing a valid url).
1224   *
1225   * @throws (TypeError)
1226   *         If the key is neither a valid guid nor a valid url.
1227   */
1228  normalizeToURLOrGUID(key) {
1229    if (typeof key === "string") {
1230      // A string may be a URL or a guid
1231      if (this.isValidGuid(key)) {
1232        return key;
1233      }
1234      return new URL(key);
1235    }
1236    if (key instanceof URL) {
1237      return key;
1238    }
1239    if (key instanceof Ci.nsIURI) {
1240      return new URL(key.spec);
1241    }
1242    throw new TypeError("Invalid url or guid: " + key);
1243  },
1244
1245  /**
1246   * Generates a nsINavHistoryResult for the contents of a folder.
1247   * @param   aFolderGuid
1248   *          The folder to open
1249   * @param   [optional] excludeItems
1250   *          True to hide all items (individual bookmarks). This is used on
1251   *          the left places pane so you just get a folder hierarchy.
1252   * @param   [optional] expandQueries
1253   *          True to make query items expand as new containers. For managing,
1254   *          you want this to be false, for menus and such, you want this to
1255   *          be true.
1256   * @returns A nsINavHistoryResult containing the contents of the
1257   *          folder. The result.root is guaranteed to be open.
1258   */
1259  getFolderContents(aFolderGuid, aExcludeItems, aExpandQueries) {
1260    if (!this.isValidGuid(aFolderGuid)) {
1261      throw new Error("aFolderGuid should be a valid GUID.");
1262    }
1263    var query = this.history.getNewQuery();
1264    query.setParents([aFolderGuid]);
1265    var options = this.history.getNewQueryOptions();
1266    options.excludeItems = aExcludeItems;
1267    options.expandQueries = aExpandQueries;
1268
1269    var result = this.history.executeQuery(query, options);
1270    result.root.containerOpen = true;
1271    return result;
1272  },
1273
1274  // Identifier getters for special folders.
1275  // You should use these everywhere PlacesUtils is available to avoid XPCOM
1276  // traversal just to get roots' ids.
1277  get placesRootId() {
1278    delete this.placesRootId;
1279    return (this.placesRootId = this.bookmarks.placesRoot);
1280  },
1281
1282  get bookmarksMenuFolderId() {
1283    delete this.bookmarksMenuFolderId;
1284    return (this.bookmarksMenuFolderId = this.bookmarks.bookmarksMenuFolder);
1285  },
1286
1287  get toolbarFolderId() {
1288    delete this.toolbarFolderId;
1289    return (this.toolbarFolderId = this.bookmarks.toolbarFolder);
1290  },
1291
1292  get tagsFolderId() {
1293    delete this.tagsFolderId;
1294    return (this.tagsFolderId = this.bookmarks.tagsFolder);
1295  },
1296
1297  /**
1298   * Checks if item is a root.
1299   *
1300   * @param {String} guid The guid of the item to look for.
1301   * @returns {Boolean} true if guid is a root, false otherwise.
1302   */
1303  isRootItem(guid) {
1304    return (
1305      guid == PlacesUtils.bookmarks.menuGuid ||
1306      guid == PlacesUtils.bookmarks.toolbarGuid ||
1307      guid == PlacesUtils.bookmarks.unfiledGuid ||
1308      guid == PlacesUtils.bookmarks.tagsGuid ||
1309      guid == PlacesUtils.bookmarks.rootGuid ||
1310      guid == PlacesUtils.bookmarks.mobileGuid
1311    );
1312  },
1313
1314  /**
1315   * Returns a nsNavHistoryContainerResultNode with forced excludeItems and
1316   * expandQueries.
1317   * @param   aNode
1318   *          The node to convert
1319   * @param   [optional] excludeItems
1320   *          True to hide all items (individual bookmarks). This is used on
1321   *          the left places pane so you just get a folder hierarchy.
1322   * @param   [optional] expandQueries
1323   *          True to make query items expand as new containers. For managing,
1324   *          you want this to be false, for menus and such, you want this to
1325   *          be true.
1326   * @returns A nsINavHistoryContainerResultNode containing the unfiltered
1327   *          contents of the container.
1328   * @note    The returned container node could be open or closed, we don't
1329   *          guarantee its status.
1330   */
1331  getContainerNodeWithOptions: function PU_getContainerNodeWithOptions(
1332    aNode,
1333    aExcludeItems,
1334    aExpandQueries
1335  ) {
1336    if (!this.nodeIsContainer(aNode)) {
1337      throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
1338    }
1339
1340    // excludeItems is inherited by child containers in an excludeItems view.
1341    var excludeItems =
1342      asQuery(aNode).queryOptions.excludeItems ||
1343      asQuery(aNode.parentResult.root).queryOptions.excludeItems;
1344    // expandQueries is inherited by child containers in an expandQueries view.
1345    var expandQueries =
1346      asQuery(aNode).queryOptions.expandQueries &&
1347      asQuery(aNode.parentResult.root).queryOptions.expandQueries;
1348
1349    // If our options are exactly what we expect, directly return the node.
1350    if (excludeItems == aExcludeItems && expandQueries == aExpandQueries) {
1351      return aNode;
1352    }
1353
1354    // Otherwise, get contents manually.
1355    var query = {},
1356      options = {};
1357    this.history.queryStringToQuery(aNode.uri, query, options);
1358    options.value.excludeItems = aExcludeItems;
1359    options.value.expandQueries = aExpandQueries;
1360    return this.history.executeQuery(query.value, options.value).root;
1361  },
1362
1363  /**
1364   * Returns true if a container has uri nodes in its first level.
1365   * Has better performance than (getURLsForContainerNode(node).length > 0).
1366   * @param aNode
1367   *        The container node to search through.
1368   * @returns true if the node contains uri nodes, false otherwise.
1369   */
1370  hasChildURIs: function PU_hasChildURIs(aNode) {
1371    if (!this.nodeIsContainer(aNode)) {
1372      return false;
1373    }
1374
1375    let root = this.getContainerNodeWithOptions(aNode, false, true);
1376    let result = root.parentResult;
1377    let didSuppressNotifications = false;
1378    let wasOpen = root.containerOpen;
1379    if (!wasOpen) {
1380      didSuppressNotifications = result.suppressNotifications;
1381      if (!didSuppressNotifications) {
1382        result.suppressNotifications = true;
1383      }
1384
1385      root.containerOpen = true;
1386    }
1387
1388    let found = false;
1389    for (let i = 0; i < root.childCount && !found; i++) {
1390      let child = root.getChild(i);
1391      if (this.nodeIsURI(child)) {
1392        found = true;
1393      }
1394    }
1395
1396    if (!wasOpen) {
1397      root.containerOpen = false;
1398      if (!didSuppressNotifications) {
1399        result.suppressNotifications = false;
1400      }
1401    }
1402    return found;
1403  },
1404
1405  getChildCountForFolder(guid) {
1406    let folder = PlacesUtils.getFolderContents(guid).root;
1407    let childCount = folder.childCount;
1408    folder.containerOpen = false;
1409    return childCount;
1410  },
1411
1412  /**
1413   * Returns an array containing all the uris in the first level of the
1414   * passed in container.
1415   * If you only need to know if the node contains uris, use hasChildURIs.
1416   * @param aNode
1417   *        The container node to search through
1418   * @returns array of uris in the first level of the container.
1419   */
1420  getURLsForContainerNode: function PU_getURLsForContainerNode(aNode) {
1421    let urls = [];
1422    if (!this.nodeIsContainer(aNode)) {
1423      return urls;
1424    }
1425
1426    let root = this.getContainerNodeWithOptions(aNode, false, true);
1427    let result = root.parentResult;
1428    let wasOpen = root.containerOpen;
1429    let didSuppressNotifications = false;
1430    if (!wasOpen) {
1431      didSuppressNotifications = result.suppressNotifications;
1432      if (!didSuppressNotifications) {
1433        result.suppressNotifications = true;
1434      }
1435
1436      root.containerOpen = true;
1437    }
1438
1439    for (let i = 0; i < root.childCount; ++i) {
1440      let child = root.getChild(i);
1441      if (this.nodeIsURI(child)) {
1442        urls.push({
1443          uri: child.uri,
1444          isBookmark: this.nodeIsBookmark(child),
1445          title: child.title,
1446        });
1447      }
1448    }
1449
1450    if (!wasOpen) {
1451      root.containerOpen = false;
1452      if (!didSuppressNotifications) {
1453        result.suppressNotifications = false;
1454      }
1455    }
1456    return urls;
1457  },
1458
1459  /**
1460   * Gets a shared Sqlite.jsm readonly connection to the Places database,
1461   * usable only for SELECT queries.
1462   *
1463   * This is intended to be used mostly internally, components outside of
1464   * Places should, when possible, use API calls and file bugs to get proper
1465   * APIs, where they are missing.
1466   * Keep in mind the Places DB schema is by no means frozen or even stable.
1467   * Your custom queries can - and will - break overtime.
1468   *
1469   * Example:
1470   * let db = await PlacesUtils.promiseDBConnection();
1471   * let rows = await db.executeCached(sql, params);
1472   */
1473  promiseDBConnection: () => gAsyncDBConnPromised,
1474
1475  /**
1476   * This is pretty much the same as promiseDBConnection, but with a larger
1477   * page cache, useful for consumers doing large table scans, like the urlbar.
1478   * @see promiseDBConnection
1479   */
1480  promiseLargeCacheDBConnection: () => gAsyncDBLargeCacheConnPromised,
1481  get largeCacheDBConnDeferred() {
1482    return gAsyncDBLargeCacheConnDeferred;
1483  },
1484
1485  /**
1486   * Returns a Sqlite.jsm wrapper for the main Places connection. Most callers
1487   * should prefer `withConnectionWrapper`, which ensures that all database
1488   * operations finish before the connection is closed.
1489   */
1490  promiseUnsafeWritableDBConnection: () => gAsyncDBWrapperPromised,
1491
1492  /**
1493   * Performs a read/write operation on the Places database through a Sqlite.jsm
1494   * wrapped connection to the Places database.
1495   *
1496   * This is intended to be used only by Places itself, always use APIs if you
1497   * need to modify the Places database. Use promiseDBConnection if you need to
1498   * SELECT from the database and there's no covering API.
1499   * Keep in mind the Places DB schema is by no means frozen or even stable.
1500   * Your custom queries can - and will - break overtime.
1501   *
1502   * As all operations on the Places database are asynchronous, if shutdown
1503   * is initiated while an operation is pending, this could cause dataloss.
1504   * Using `withConnectionWrapper` ensures that shutdown waits until all
1505   * operations are complete before proceeding.
1506   *
1507   * Example:
1508   * await withConnectionWrapper("Bookmarks: Remove a bookmark", Task.async(function*(db) {
1509   *    // Proceed with the db, asynchronously.
1510   *    // Shutdown will not interrupt operations that take place here.
1511   * }));
1512   *
1513   * @param {string} name The name of the operation. Used for debugging, logging
1514   *   and crash reporting.
1515   * @param {function(db)} task A function that takes as argument a Sqlite.jsm
1516   *   connection and returns a Promise. Shutdown is guaranteed to not interrupt
1517   *   execution of `task`.
1518   */
1519  async withConnectionWrapper(name, task) {
1520    if (!name) {
1521      throw new TypeError("Expecting a user-readable name");
1522    }
1523    let db = await gAsyncDBWrapperPromised;
1524    return db.executeBeforeShutdown(name, task);
1525  },
1526
1527  /**
1528   * Gets favicon data for a given page url.
1529   *
1530   * @param {string | URL | nsIURI} aPageUrl
1531   *   url of the page to look favicon for.
1532   * @param {number} preferredWidth
1533   *   The preferred width of the favicon in pixels. The default value of 0
1534   *   returns the largest icon available.
1535   * @resolves to an object representing a favicon entry, having the following
1536   *           properties: { uri, dataLen, data, mimeType }
1537   * @rejects JavaScript exception if the given url has no associated favicon.
1538   */
1539  promiseFaviconData(aPageUrl, preferredWidth = 0) {
1540    return new Promise((resolve, reject) => {
1541      if (!(aPageUrl instanceof Ci.nsIURI)) {
1542        aPageUrl = PlacesUtils.toURI(aPageUrl);
1543      }
1544      PlacesUtils.favicons.getFaviconDataForPage(
1545        aPageUrl,
1546        function(uri, dataLen, data, mimeType, size) {
1547          if (uri) {
1548            resolve({ uri, dataLen, data, mimeType, size });
1549          } else {
1550            reject();
1551          }
1552        },
1553        preferredWidth
1554      );
1555    });
1556  },
1557
1558  /**
1559   * Returns the passed URL with a #size ref for the specified size and
1560   * devicePixelRatio.
1561   *
1562   * @param window
1563   *        The window where the icon will appear.
1564   * @param href
1565   *        The string href we should add the ref to.
1566   * @param size
1567   *        The target image size
1568   * @return The URL with the fragment at the end, in the same formar as input.
1569   */
1570  urlWithSizeRef(window, href, size) {
1571    return (
1572      href +
1573      (href.includes("#") ? "&" : "#") +
1574      "size=" +
1575      Math.round(size) * window.devicePixelRatio
1576    );
1577  },
1578
1579  /**
1580   * Get the unique id for an item (a bookmark, a folder or a separator) given
1581   * its item id.
1582   *
1583   * @param aItemId
1584   *        an item id
1585   * @return {Promise}
1586   * @resolves to the GUID.
1587   * @rejects if aItemId is invalid.
1588   */
1589  promiseItemGuid(aItemId) {
1590    return GuidHelper.getItemGuid(aItemId);
1591  },
1592
1593  /**
1594   * Get the item id for an item (a bookmark, a folder or a separator) given
1595   * its unique id.
1596   *
1597   * @param aGuid
1598   *        an item GUID
1599   * @return {Promise}
1600   * @resolves to the item id.
1601   * @rejects if there's no item for the given GUID.
1602   */
1603  promiseItemId(aGuid) {
1604    return GuidHelper.getItemId(aGuid);
1605  },
1606
1607  /**
1608   * Get the item ids for multiple items (a bookmark, a folder or a separator)
1609   * given the unique ids for each item.
1610   *
1611   * @param {Array} aGuids An array of item GUIDs.
1612   * @return {Promise}
1613   * @resolves to a Map of item ids.
1614   * @rejects if not all of the GUIDs could be found.
1615   */
1616  promiseManyItemIds(aGuids) {
1617    return GuidHelper.getManyItemIds(aGuids);
1618  },
1619
1620  /**
1621   * Invalidate the GUID cache for the given itemId.
1622   *
1623   * @param aItemId
1624   *        an item id
1625   */
1626  invalidateCachedGuidFor(aItemId) {
1627    GuidHelper.invalidateCacheForItemId(aItemId);
1628  },
1629
1630  /**
1631   * Invalidates the entire GUID cache.
1632   */
1633  invalidateCachedGuids() {
1634    GuidHelper.invalidateCache();
1635  },
1636
1637  /**
1638   * Asynchronously retrieve a JS-object representation of a places bookmarks
1639   * item (a bookmark, a folder, or a separator) along with all of its
1640   * descendants.
1641   *
1642   * @param [optional] aItemGuid
1643   *        the (topmost) item to be queried.  If it's not passed, the places
1644   *        root is queried: that is, you get a representation of the entire
1645   *        bookmarks hierarchy.
1646   * @param [optional] aOptions
1647   *        Options for customizing the query behavior, in the form of a JS
1648   *        object with any of the following properties:
1649   *         - excludeItemsCallback: a function for excluding items, along with
1650   *           their descendants.  Given an item object (that has everything set
1651   *           apart its potential children data), it should return true if the
1652   *           item should be excluded.  Once an item is excluded, the function
1653   *           isn't called for any of its descendants.  This isn't called for
1654   *           the root item.
1655   *           WARNING: since the function may be called for each item, using
1656   *           this option can slow down the process significantly if the
1657   *           callback does anything that's not relatively trivial.  It is
1658   *           highly recommended to avoid any synchronous I/O or DB queries.
1659   *        - includeItemIds: opt-in to include the deprecated id property.
1660   *          Use it if you must. It'll be removed once the switch to GUIDs is
1661   *          complete.
1662   *
1663   * @return {Promise}
1664   * @resolves to a JS object that represents either a single item or a
1665   * bookmarks tree.  Each node in the tree has the following properties set:
1666   *  - guid (string): the item's GUID (same as aItemGuid for the top item).
1667   *  - [deprecated] id (number): the item's id. This is only if
1668   *    aOptions.includeItemIds is set.
1669   *  - type (string):  the item's type.  @see PlacesUtils.TYPE_X_*
1670   *  - typeCode (number):  the item's type in numeric format.
1671   *    @see PlacesUtils.bookmarks.TYPE_*
1672   *  - title (string): the item's title. If it has no title, this property
1673   *    isn't set.
1674   *  - dateAdded (number, microseconds from the epoch): the date-added value of
1675   *    the item.
1676   *  - lastModified (number, microseconds from the epoch): the last-modified
1677   *    value of the item.
1678   *  - index: the item's index under it's parent.
1679   *
1680   * The root object (i.e. the one for aItemGuid) also has the following
1681   * properties set:
1682   *  - parentGuid (string): the GUID of the root's parent.  This isn't set if
1683   *    the root item is the places root.
1684   *  - itemsCount (number, not enumerable): the number of items, including the
1685   *    root item itself, which are represented in the resolved object.
1686   *
1687   * Bookmark items also have the following properties:
1688   *  - uri (string): the item's url.
1689   *  - tags (string): csv string of the bookmark's tags.
1690   *  - charset (string): the last known charset of the bookmark.
1691   *  - keyword (string): the bookmark's keyword (unset if none).
1692   *  - postData (string): the bookmark's keyword postData (unset if none).
1693   *  - iconuri (string): the bookmark's favicon url.
1694   * The last four properties are not set at all if they're irrelevant (e.g.
1695   * |charset| is not set if no charset was previously set for the bookmark
1696   * url).
1697   *
1698   * Folders may also have the following properties:
1699   *  - children (array): the folder's children information, each of them
1700   *    having the same set of properties as above.
1701   *
1702   * @rejects if the query failed for any reason.
1703   * @note if aItemGuid points to a non-existent item, the returned promise is
1704   * resolved to null.
1705   */
1706  async promiseBookmarksTree(aItemGuid = "", aOptions = {}) {
1707    let createItemInfoObject = async function(aRow, aIncludeParentGuid) {
1708      let item = {};
1709      let copyProps = (...props) => {
1710        for (let prop of props) {
1711          let val = aRow.getResultByName(prop);
1712          if (val !== null) {
1713            item[prop] = val;
1714          }
1715        }
1716      };
1717      copyProps("guid", "title", "index", "dateAdded", "lastModified");
1718      if (aIncludeParentGuid) {
1719        copyProps("parentGuid");
1720      }
1721
1722      let itemId = aRow.getResultByName("id");
1723      if (aOptions.includeItemIds) {
1724        item.id = itemId;
1725      }
1726
1727      // Cache it for promiseItemId consumers regardless.
1728      GuidHelper.updateCache(itemId, item.guid);
1729
1730      let type = aRow.getResultByName("type");
1731      item.typeCode = type;
1732      if (type == Ci.nsINavBookmarksService.TYPE_BOOKMARK) {
1733        copyProps("charset", "tags", "iconuri");
1734      }
1735
1736      switch (type) {
1737        case PlacesUtils.bookmarks.TYPE_BOOKMARK:
1738          item.type = PlacesUtils.TYPE_X_MOZ_PLACE;
1739          // If this throws due to an invalid url, the item will be skipped.
1740          try {
1741            item.uri = NetUtil.newURI(aRow.getResultByName("url")).spec;
1742          } catch (ex) {
1743            let error = new Error("Invalid bookmark URL");
1744            error.becauseInvalidURL = true;
1745            throw error;
1746          }
1747          // Keywords are cached, so this should be decently fast.
1748          let entry = await PlacesUtils.keywords.fetch({ url: item.uri });
1749          if (entry) {
1750            item.keyword = entry.keyword;
1751            item.postData = entry.postData;
1752          }
1753          break;
1754        case PlacesUtils.bookmarks.TYPE_FOLDER:
1755          item.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER;
1756          // Mark root folders.
1757          if (item.guid == PlacesUtils.bookmarks.rootGuid) {
1758            item.root = "placesRoot";
1759          } else if (item.guid == PlacesUtils.bookmarks.menuGuid) {
1760            item.root = "bookmarksMenuFolder";
1761          } else if (item.guid == PlacesUtils.bookmarks.unfiledGuid) {
1762            item.root = "unfiledBookmarksFolder";
1763          } else if (item.guid == PlacesUtils.bookmarks.toolbarGuid) {
1764            item.root = "toolbarFolder";
1765          } else if (item.guid == PlacesUtils.bookmarks.mobileGuid) {
1766            item.root = "mobileFolder";
1767          }
1768          break;
1769        case PlacesUtils.bookmarks.TYPE_SEPARATOR:
1770          item.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR;
1771          break;
1772        default:
1773          Cu.reportError(`Unexpected bookmark type ${type}`);
1774          break;
1775      }
1776      return item;
1777    };
1778
1779    const QUERY_STR = `/* do not warn (bug no): cannot use an index */
1780       WITH RECURSIVE
1781       descendants(fk, level, type, id, guid, parent, parentGuid, position,
1782                   title, dateAdded, lastModified) AS (
1783         SELECT b1.fk, 0, b1.type, b1.id, b1.guid, b1.parent,
1784                (SELECT guid FROM moz_bookmarks WHERE id = b1.parent),
1785                b1.position, b1.title, b1.dateAdded, b1.lastModified
1786         FROM moz_bookmarks b1 WHERE b1.guid=:item_guid
1787         UNION ALL
1788         SELECT b2.fk, level + 1, b2.type, b2.id, b2.guid, b2.parent,
1789                descendants.guid, b2.position, b2.title, b2.dateAdded,
1790                b2.lastModified
1791         FROM moz_bookmarks b2
1792         JOIN descendants ON b2.parent = descendants.id AND b2.id <> :tags_folder)
1793       SELECT d.level, d.id, d.guid, d.parent, d.parentGuid, d.type,
1794              d.position AS [index], IFNULL(d.title, '') AS title, d.dateAdded,
1795              d.lastModified, h.url, (SELECT icon_url FROM moz_icons i
1796                      JOIN moz_icons_to_pages ON icon_id = i.id
1797                      JOIN moz_pages_w_icons pi ON page_id = pi.id
1798                      WHERE pi.page_url_hash = hash(h.url) AND pi.page_url = h.url
1799                      ORDER BY width DESC LIMIT 1) AS iconuri,
1800              (SELECT GROUP_CONCAT(t.title, ',')
1801               FROM moz_bookmarks b2
1802               JOIN moz_bookmarks t ON t.id = +b2.parent AND t.parent = :tags_folder
1803               WHERE b2.fk = h.id
1804              ) AS tags,
1805              (SELECT a.content FROM moz_annos a
1806               JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id
1807               WHERE place_id = h.id AND n.name = :charset_anno
1808              ) AS charset
1809       FROM descendants d
1810       LEFT JOIN moz_bookmarks b3 ON b3.id = d.parent
1811       LEFT JOIN moz_places h ON h.id = d.fk
1812       ORDER BY d.level, d.parent, d.position`;
1813
1814    if (!aItemGuid) {
1815      aItemGuid = this.bookmarks.rootGuid;
1816    }
1817
1818    let hasExcludeItemsCallback = aOptions.hasOwnProperty(
1819      "excludeItemsCallback"
1820    );
1821    let excludedParents = new Set();
1822    let shouldExcludeItem = (aItem, aParentGuid) => {
1823      let exclude =
1824        excludedParents.has(aParentGuid) ||
1825        aOptions.excludeItemsCallback(aItem);
1826      if (exclude) {
1827        if (aItem.type == this.TYPE_X_MOZ_PLACE_CONTAINER) {
1828          excludedParents.add(aItem.guid);
1829        }
1830      }
1831      return exclude;
1832    };
1833
1834    let rootItem = null;
1835    let parentsMap = new Map();
1836    let conn = await this.promiseDBConnection();
1837    let rows = await conn.executeCached(QUERY_STR, {
1838      tags_folder: PlacesUtils.tagsFolderId,
1839      charset_anno: PlacesUtils.CHARSET_ANNO,
1840      item_guid: aItemGuid,
1841    });
1842    let yieldCounter = 0;
1843    for (let row of rows) {
1844      let item;
1845      if (!rootItem) {
1846        try {
1847          // This is the first row.
1848          rootItem = item = await createItemInfoObject(row, true);
1849          Object.defineProperty(rootItem, "itemsCount", {
1850            value: 1,
1851            writable: true,
1852            enumerable: false,
1853            configurable: false,
1854          });
1855        } catch (ex) {
1856          Cu.reportError("Failed to fetch the data for the root item");
1857          throw ex;
1858        }
1859      } else {
1860        try {
1861          // Our query guarantees that we always visit parents ahead of their
1862          // children.
1863          item = await createItemInfoObject(row, false);
1864          let parentGuid = row.getResultByName("parentGuid");
1865          if (hasExcludeItemsCallback && shouldExcludeItem(item, parentGuid)) {
1866            continue;
1867          }
1868
1869          let parentItem = parentsMap.get(parentGuid);
1870          if ("children" in parentItem) {
1871            parentItem.children.push(item);
1872          } else {
1873            parentItem.children = [item];
1874          }
1875
1876          rootItem.itemsCount++;
1877        } catch (ex) {
1878          // This is a bogus child, report and skip it.
1879          Cu.reportError("Failed to fetch the data for an item " + ex);
1880          continue;
1881        }
1882      }
1883
1884      if (item.type == this.TYPE_X_MOZ_PLACE_CONTAINER) {
1885        parentsMap.set(item.guid, item);
1886      }
1887
1888      // With many bookmarks we end up stealing the CPU - even with yielding!
1889      // So we let everyone else have a go every few items (bug 1186714).
1890      if (++yieldCounter % 50 == 0) {
1891        await new Promise(resolve => {
1892          Services.tm.dispatchToMainThread(resolve);
1893        });
1894      }
1895    }
1896
1897    return rootItem;
1898  },
1899
1900  /**
1901   * Returns a generator that iterates over `array` and yields slices of no
1902   * more than `chunkLength` elements at a time.
1903   *
1904   * @param  {Array} array An array containing zero or more elements.
1905   * @param  {number} chunkLength The maximum number of elements in each chunk.
1906   * @yields {Array} A chunk of the array.
1907   * @throws if `chunkLength` is negative or not an integer.
1908   */
1909  *chunkArray(array, chunkLength) {
1910    if (chunkLength <= 0 || !Number.isInteger(chunkLength)) {
1911      throw new TypeError("Chunk length must be a positive integer");
1912    }
1913    if (!array.length) {
1914      return;
1915    }
1916    if (array.length <= chunkLength) {
1917      yield array;
1918      return;
1919    }
1920    let startIndex = 0;
1921    while (startIndex < array.length) {
1922      yield array.slice(startIndex, (startIndex += chunkLength));
1923    }
1924  },
1925
1926  /**
1927   * Run some text through md5 and return the hash.
1928   * @param {string} data The string to hash.
1929   * @param {string} [format] Which format of the hash to return:
1930   *   - "ascii" for ascii format.
1931   *   - "hex" for hex format.
1932   * @returns {string} md5 hash of the input string in the required format.
1933   */
1934  md5(data, { format = "ascii" } = {}) {
1935    gCryptoHash.init(gCryptoHash.MD5);
1936
1937    // Convert the data to a byte array for hashing
1938    gCryptoHash.update(
1939      data.split("").map(c => c.charCodeAt(0)),
1940      data.length
1941    );
1942    switch (format) {
1943      case "hex":
1944        let hash = gCryptoHash.finish(false);
1945        return Array.from(hash, (c, i) =>
1946          hash
1947            .charCodeAt(i)
1948            .toString(16)
1949            .padStart(2, "0")
1950        ).join("");
1951      case "ascii":
1952      default:
1953        return gCryptoHash.finish(true);
1954    }
1955  },
1956};
1957
1958XPCOMUtils.defineLazyGetter(PlacesUtils, "history", function() {
1959  let hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService(
1960    Ci.nsINavHistoryService
1961  );
1962  return Object.freeze(
1963    new Proxy(hs, {
1964      get(target, name) {
1965        let property, object;
1966        if (name in target) {
1967          property = target[name];
1968          object = target;
1969        } else {
1970          property = History[name];
1971          object = History;
1972        }
1973        if (typeof property == "function") {
1974          return property.bind(object);
1975        }
1976        return property;
1977      },
1978    })
1979  );
1980});
1981
1982XPCOMUtils.defineLazyServiceGetter(
1983  PlacesUtils,
1984  "favicons",
1985  "@mozilla.org/browser/favicon-service;1",
1986  "nsIFaviconService"
1987);
1988
1989XPCOMUtils.defineLazyServiceGetter(
1990  this,
1991  "bmsvc",
1992  "@mozilla.org/browser/nav-bookmarks-service;1",
1993  "nsINavBookmarksService"
1994);
1995XPCOMUtils.defineLazyGetter(PlacesUtils, "bookmarks", () => {
1996  return Object.freeze(
1997    new Proxy(Bookmarks, {
1998      get: (target, name) =>
1999        Bookmarks.hasOwnProperty(name) ? Bookmarks[name] : bmsvc[name],
2000    })
2001  );
2002});
2003
2004XPCOMUtils.defineLazyServiceGetter(
2005  PlacesUtils,
2006  "tagging",
2007  "@mozilla.org/browser/tagging-service;1",
2008  "nsITaggingService"
2009);
2010
2011XPCOMUtils.defineLazyGetter(this, "bundle", function() {
2012  const PLACES_STRING_BUNDLE_URI = "chrome://places/locale/places.properties";
2013  return Services.strings.createBundle(PLACES_STRING_BUNDLE_URI);
2014});
2015
2016// This is just used as a reasonably-random value for copy & paste / drag operations.
2017XPCOMUtils.defineLazyGetter(PlacesUtils, "instanceId", () => {
2018  return PlacesUtils.history.makeGuid();
2019});
2020
2021/**
2022 * Setup internal databases for closing properly during shutdown.
2023 *
2024 * 1. Places initiates shutdown.
2025 * 2. Before places can move to the step where it closes the low-level connection,
2026 *   we need to make sure that we have closed `conn`.
2027 * 3. Before we can close `conn`, we need to make sure that all external clients
2028 *   have stopped using `conn`.
2029 * 4. Before we can close Sqlite, we need to close `conn`.
2030 */
2031function setupDbForShutdown(conn, name) {
2032  try {
2033    let state = "0. Not started.";
2034    let promiseClosed = new Promise((resolve, reject) => {
2035      // The service initiates shutdown.
2036      // Before it can safely close its connection, we need to make sure
2037      // that we have closed the high-level connection.
2038      try {
2039        PlacesUtils.history.connectionShutdownClient.jsclient.addBlocker(
2040          `${name} closing as part of Places shutdown`,
2041          async function() {
2042            state = "1. Service has initiated shutdown";
2043
2044            // At this stage, all external clients have finished using the
2045            // database. We just need to close the high-level connection.
2046            try {
2047              await conn.close();
2048              state = "2. Closed Sqlite.jsm connection.";
2049              resolve();
2050            } catch (ex) {
2051              state = "2. Failed to closed Sqlite.jsm connection: " + ex;
2052              reject(ex);
2053            }
2054          },
2055          () => state
2056        );
2057      } catch (ex) {
2058        // It's too late to block shutdown, just close the connection.
2059        conn.close();
2060        reject(ex);
2061      }
2062    }).catch(Cu.reportError);
2063
2064    // Make sure that Sqlite.jsm doesn't close until we are done
2065    // with the high-level connection.
2066    Sqlite.shutdown.addBlocker(
2067      `${name} must be closed before Sqlite.jsm`,
2068      () => promiseClosed,
2069      () => state
2070    );
2071  } catch (ex) {
2072    // It's too late to block shutdown, just close the connection.
2073    conn.close();
2074    throw ex;
2075  }
2076}
2077
2078XPCOMUtils.defineLazyGetter(this, "gAsyncDBConnPromised", () =>
2079  Sqlite.cloneStorageConnection({
2080    connection: PlacesUtils.history.DBConnection,
2081    readOnly: true,
2082  })
2083    .then(conn => {
2084      setupDbForShutdown(conn, "PlacesUtils read-only connection");
2085      return conn;
2086    })
2087    .catch(Cu.reportError)
2088);
2089
2090XPCOMUtils.defineLazyGetter(this, "gAsyncDBWrapperPromised", () =>
2091  Sqlite.wrapStorageConnection({
2092    connection: PlacesUtils.history.DBConnection,
2093  })
2094    .then(conn => {
2095      setupDbForShutdown(conn, "PlacesUtils wrapped connection");
2096      return conn;
2097    })
2098    .catch(Cu.reportError)
2099);
2100
2101var gAsyncDBLargeCacheConnDeferred = PromiseUtils.defer();
2102XPCOMUtils.defineLazyGetter(this, "gAsyncDBLargeCacheConnPromised", () =>
2103  Sqlite.cloneStorageConnection({
2104    connection: PlacesUtils.history.DBConnection,
2105    readOnly: true,
2106  })
2107    .then(async conn => {
2108      setupDbForShutdown(conn, "PlacesUtils large cache read-only connection");
2109      // Components like the urlbar often fallback to a table scan due to lack
2110      // of full text indices.  A larger cache helps reducing IO and improves
2111      // performance. This value is expected to be larger than the default
2112      // mozStorage value defined as MAX_CACHE_SIZE_BYTES in
2113      // storage/mozStorageConnection.cpp.
2114      await conn.execute("PRAGMA cache_size = -6144"); // 6MiB
2115      // These should be kept in sync with nsPlacesTables.h.
2116      await conn.execute(`
2117        CREATE TEMP TABLE IF NOT EXISTS moz_openpages_temp (
2118          url TEXT,
2119          userContextId INTEGER,
2120          open_count INTEGER,
2121          PRIMARY KEY (url, userContextId)
2122        )`);
2123      await conn.execute(`
2124        CREATE TEMP TRIGGER IF NOT EXISTS moz_openpages_temp_afterupdate_trigger
2125        AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW
2126        WHEN NEW.open_count = 0
2127        BEGIN
2128          DELETE FROM moz_openpages_temp
2129          WHERE url = NEW.url
2130            AND userContextId = NEW.userContextId;
2131        END`);
2132      gAsyncDBLargeCacheConnDeferred.resolve(conn);
2133      return conn;
2134    })
2135    .catch(Cu.reportError)
2136);
2137
2138/**
2139 * The metadata API allows consumers to store simple key-value metadata in
2140 * Places. Keys are strings, values can be any type that SQLite supports:
2141 * numbers (integers and doubles), Booleans, strings, and blobs. Values are
2142 * cached in memory for faster lookups.
2143 *
2144 * Since some consumers set metadata as part of an existing operation or active
2145 * transaction, the API also exposes a `*withConnection` variant for each
2146 * method that takes an open database connection.
2147 */
2148PlacesUtils.metadata = {
2149  cache: new Map(),
2150  jsonPrefix: "data:application/json;base64,",
2151
2152  /**
2153   * Returns the value associated with a metadata key.
2154   *
2155   * @param  {String} key
2156   *         The metadata key to look up.
2157   * @param  {String|Object|Array} defaultValue
2158   *         Optional. The default value to return if the value is not present,
2159   *         or cannot be parsed.
2160   * @resolves {*}
2161   *         The value associated with the key, or the defaultValue if there is one.
2162   * @rejects
2163   *         Rejected if the value is not found or it cannot be parsed
2164   *         and there is no defaultValue.
2165   */
2166  get(key, defaultValue) {
2167    return PlacesUtils.withConnectionWrapper("PlacesUtils.metadata.get", db =>
2168      this.getWithConnection(db, key, defaultValue)
2169    );
2170  },
2171
2172  /**
2173   * Sets the value for a metadata key.
2174   *
2175   * @param {String} key
2176   *        The metadata key to update.
2177   * @param {*}
2178   *        The value to associate with the key.
2179   */
2180  set(key, value) {
2181    return PlacesUtils.withConnectionWrapper("PlacesUtils.metadata.set", db =>
2182      this.setWithConnection(db, key, value)
2183    );
2184  },
2185
2186  /**
2187   * Removes the values for the given metadata keys.
2188   *
2189   * @param {String...}
2190   *        One or more metadata keys to remove.
2191   */
2192  delete(...keys) {
2193    return PlacesUtils.withConnectionWrapper(
2194      "PlacesUtils.metadata.delete",
2195      db => this.deleteWithConnection(db, ...keys)
2196    );
2197  },
2198
2199  async getWithConnection(db, key, defaultValue) {
2200    key = this.canonicalizeKey(key);
2201    if (this.cache.has(key)) {
2202      return this.cache.get(key);
2203    }
2204    let rows = await db.executeCached(
2205      `
2206      SELECT value FROM moz_meta WHERE key = :key`,
2207      { key }
2208    );
2209    let value = null;
2210    if (rows.length) {
2211      let row = rows[0];
2212      let rawValue = row.getResultByName("value");
2213      // Convert blobs back to `Uint8Array`s.
2214      if (row.getTypeOfIndex(0) == row.VALUE_TYPE_BLOB) {
2215        value = new Uint8Array(rawValue);
2216      } else if (
2217        typeof rawValue == "string" &&
2218        rawValue.startsWith(this.jsonPrefix)
2219      ) {
2220        try {
2221          value = JSON.parse(
2222            this._base64Decode(rawValue.substr(this.jsonPrefix.length))
2223          );
2224        } catch (ex) {
2225          if (defaultValue !== undefined) {
2226            // We must create a new array in the local scope to avoid a memory
2227            // leak due to the array global object.
2228            value = Cu.cloneInto(defaultValue, {});
2229          } else {
2230            throw ex;
2231          }
2232        }
2233      } else {
2234        value = rawValue;
2235      }
2236    } else if (defaultValue !== undefined) {
2237      // We must create a new array in the local scope to avoid a memory leak due
2238      // to the array global object.
2239      value = Cu.cloneInto(defaultValue, {});
2240    } else {
2241      throw new Error(`No data stored for key ${key}`);
2242    }
2243    this.cache.set(key, value);
2244    return value;
2245  },
2246
2247  async setWithConnection(db, key, value) {
2248    if (value === null) {
2249      await this.deleteWithConnection(db, key);
2250      return;
2251    }
2252
2253    let cacheValue = value;
2254    if (
2255      typeof value == "object" &&
2256      ChromeUtils.getClassName(value) != "Uint8Array"
2257    ) {
2258      value = this.jsonPrefix + this._base64Encode(JSON.stringify(value));
2259    }
2260
2261    key = this.canonicalizeKey(key);
2262    await db.executeCached(
2263      `
2264      REPLACE INTO moz_meta (key, value)
2265      VALUES (:key, :value)`,
2266      { key, value }
2267    );
2268    this.cache.set(key, cacheValue);
2269  },
2270
2271  async deleteWithConnection(db, ...keys) {
2272    keys = keys.map(this.canonicalizeKey);
2273    if (!keys.length) {
2274      return;
2275    }
2276    await db.execute(
2277      `
2278      DELETE FROM moz_meta
2279      WHERE key IN (${new Array(keys.length).fill("?").join(",")})`,
2280      keys
2281    );
2282    for (let key of keys) {
2283      this.cache.delete(key);
2284    }
2285  },
2286
2287  canonicalizeKey(key) {
2288    if (typeof key != "string" || !/^[a-zA-Z0-9\/]+$/.test(key)) {
2289      throw new TypeError("Invalid metadata key: " + key);
2290    }
2291    return key.toLowerCase();
2292  },
2293
2294  _base64Encode(str) {
2295    return ChromeUtils.base64URLEncode(new TextEncoder("utf-8").encode(str), {
2296      pad: true,
2297    });
2298  },
2299
2300  _base64Decode(str) {
2301    return new TextDecoder("utf-8").decode(
2302      ChromeUtils.base64URLDecode(str, { padding: "require" })
2303    );
2304  },
2305};
2306
2307/**
2308 * Keywords management API.
2309 * Sooner or later these keywords will merge with search aliases, this is an
2310 * interim API that should then be replaced by a unified one.
2311 * Keywords are associated with URLs and can have POST data.
2312 * The relations between URLs and keywords are the following:
2313 *  - 1 keyword can only point to 1 URL
2314 *  - 1 URL can have multiple keywords, iff they differ by POST data (included the empty one).
2315 */
2316PlacesUtils.keywords = {
2317  /**
2318   * Fetches a keyword entry based on keyword or URL.
2319   *
2320   * @param keywordOrEntry
2321   *        Either the keyword to fetch or an entry providing keyword
2322   *        or url property to find keywords for.  If both properties are set,
2323   *        this returns their intersection.
2324   * @param onResult [optional]
2325   *        Callback invoked for each found entry.
2326   * @return {Promise}
2327   * @resolves to an object in the form: { keyword, url, postData },
2328   *           or null if a keyword entry was not found.
2329   */
2330  fetch(keywordOrEntry, onResult = null) {
2331    if (typeof keywordOrEntry == "string") {
2332      keywordOrEntry = { keyword: keywordOrEntry };
2333    }
2334
2335    if (
2336      keywordOrEntry === null ||
2337      typeof keywordOrEntry != "object" ||
2338      ("keyword" in keywordOrEntry && typeof keywordOrEntry.keyword != "string")
2339    ) {
2340      throw new Error("Invalid keyword");
2341    }
2342
2343    let hasKeyword = "keyword" in keywordOrEntry;
2344    let hasUrl = "url" in keywordOrEntry;
2345
2346    if (!hasKeyword && !hasUrl) {
2347      throw new Error("At least keyword or url must be provided");
2348    }
2349    if (onResult && typeof onResult != "function") {
2350      throw new Error("onResult callback must be a valid function");
2351    }
2352
2353    if (hasUrl) {
2354      try {
2355        keywordOrEntry.url = BOOKMARK_VALIDATORS.url(keywordOrEntry.url);
2356      } catch (ex) {
2357        throw new Error(keywordOrEntry.url + " is not a valid URL");
2358      }
2359    }
2360    if (hasKeyword) {
2361      keywordOrEntry.keyword = keywordOrEntry.keyword.trim().toLowerCase();
2362    }
2363
2364    let safeOnResult = entry => {
2365      if (onResult) {
2366        try {
2367          onResult(entry);
2368        } catch (ex) {
2369          Cu.reportError(ex);
2370        }
2371      }
2372    };
2373
2374    return promiseKeywordsCache().then(cache => {
2375      let entries = [];
2376      if (hasKeyword) {
2377        let entry = cache.get(keywordOrEntry.keyword);
2378        if (entry) {
2379          entries.push(entry);
2380        }
2381      }
2382      if (hasUrl) {
2383        for (let entry of cache.values()) {
2384          if (entry.url.href == keywordOrEntry.url.href) {
2385            entries.push(entry);
2386          }
2387        }
2388      }
2389
2390      entries = entries.filter(e => {
2391        return (
2392          (!hasUrl || e.url.href == keywordOrEntry.url.href) &&
2393          (!hasKeyword || e.keyword == keywordOrEntry.keyword)
2394        );
2395      });
2396
2397      entries.forEach(safeOnResult);
2398      return entries.length ? entries[0] : null;
2399    });
2400  },
2401
2402  /**
2403   * Adds a new keyword and postData for the given URL.
2404   *
2405   * @param keywordEntry
2406   *        An object describing the keyword to insert, in the form:
2407   *        {
2408   *          keyword: non-empty string,
2409   *          url: URL or href to associate to the keyword,
2410   *          postData: optional POST data to associate to the keyword
2411   *          source: The change source, forwarded to all bookmark observers.
2412   *            Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
2413   *        }
2414   * @note Do not define a postData property if there isn't any POST data.
2415   *       Defining an empty string for POST data is equivalent to not having it.
2416   * @resolves when the addition is complete.
2417   */
2418  insert(keywordEntry) {
2419    if (!keywordEntry || typeof keywordEntry != "object") {
2420      throw new Error("Input should be a valid object");
2421    }
2422
2423    if (
2424      !("keyword" in keywordEntry) ||
2425      !keywordEntry.keyword ||
2426      typeof keywordEntry.keyword != "string"
2427    ) {
2428      throw new Error("Invalid keyword");
2429    }
2430    if (
2431      "postData" in keywordEntry &&
2432      keywordEntry.postData &&
2433      typeof keywordEntry.postData != "string"
2434    ) {
2435      throw new Error("Invalid POST data");
2436    }
2437    if (!("url" in keywordEntry)) {
2438      throw new Error("undefined is not a valid URL");
2439    }
2440
2441    if (!("source" in keywordEntry)) {
2442      keywordEntry.source = PlacesUtils.bookmarks.SOURCES.DEFAULT;
2443    }
2444    let { keyword, url, source } = keywordEntry;
2445    keyword = keyword.trim().toLowerCase();
2446    let postData = keywordEntry.postData || "";
2447    // This also checks href for validity
2448    try {
2449      url = BOOKMARK_VALIDATORS.url(url);
2450    } catch (ex) {
2451      throw new Error(url + " is not a valid URL");
2452    }
2453
2454    return PlacesUtils.withConnectionWrapper(
2455      "PlacesUtils.keywords.insert",
2456      async db => {
2457        let cache = await promiseKeywordsCache();
2458
2459        // Trying to set the same keyword is a no-op.
2460        let oldEntry = cache.get(keyword);
2461        if (
2462          oldEntry &&
2463          oldEntry.url.href == url.href &&
2464          (oldEntry.postData || "") == postData
2465        ) {
2466          return;
2467        }
2468
2469        // A keyword can only be associated to a single page.
2470        // If another page is using the new keyword, we must update the keyword
2471        // entry.
2472        // Note we cannot use INSERT OR REPLACE cause it wouldn't invoke the delete
2473        // trigger.
2474        if (oldEntry) {
2475          await db.executeCached(
2476            `UPDATE moz_keywords
2477             SET place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
2478                 post_data = :post_data
2479             WHERE keyword = :keyword
2480            `,
2481            { url: url.href, keyword, post_data: postData }
2482          );
2483          await notifyKeywordChange(oldEntry.url.href, "", source);
2484        } else {
2485          // An entry for the given page could be missing, in such a case we need to
2486          // create it.  The IGNORE conflict can trigger on `guid`.
2487          await db.executeTransaction(async () => {
2488            await db.executeCached(
2489              `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid)
2490               VALUES (:url, hash(:url), :rev_host, 0, :frecency,
2491                       IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
2492                              GENERATE_GUID()))
2493              `,
2494              {
2495                url: url.href,
2496                rev_host: PlacesUtils.getReversedHost(url),
2497                frecency: url.protocol == "place:" ? 0 : -1,
2498              }
2499            );
2500            await db.executeCached("DELETE FROM moz_updateoriginsinsert_temp");
2501
2502            // A new keyword could be assigned to an url that already has one,
2503            // then we must replace the old keyword with the new one.
2504            let oldKeywords = [];
2505            for (let entry of cache.values()) {
2506              if (
2507                entry.url.href == url.href &&
2508                (entry.postData || "") == postData
2509              ) {
2510                oldKeywords.push(entry.keyword);
2511              }
2512            }
2513            if (oldKeywords.length) {
2514              for (let oldKeyword of oldKeywords) {
2515                await db.executeCached(
2516                  `DELETE FROM moz_keywords WHERE keyword = :oldKeyword`,
2517                  { oldKeyword }
2518                );
2519                cache.delete(oldKeyword);
2520              }
2521            }
2522
2523            await db.executeCached(
2524              `INSERT INTO moz_keywords (keyword, place_id, post_data)
2525               VALUES (:keyword, (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :post_data)
2526              `,
2527              { url: url.href, keyword, post_data: postData }
2528            );
2529
2530            await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
2531              db,
2532              url,
2533              PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source)
2534            );
2535          });
2536        }
2537
2538        cache.set(keyword, { keyword, url, postData: postData || null });
2539
2540        // In any case, notify about the new keyword.
2541        await notifyKeywordChange(url.href, keyword, source);
2542      }
2543    );
2544  },
2545
2546  /**
2547   * Removes a keyword.
2548   *
2549   * @param keyword
2550   *        The keyword to remove.
2551   * @return {Promise}
2552   * @resolves when the removal is complete.
2553   */
2554  remove(keywordOrEntry) {
2555    if (typeof keywordOrEntry == "string") {
2556      keywordOrEntry = {
2557        keyword: keywordOrEntry,
2558        source: Ci.nsINavBookmarksService.SOURCE_DEFAULT,
2559      };
2560    }
2561
2562    if (
2563      keywordOrEntry === null ||
2564      typeof keywordOrEntry != "object" ||
2565      !keywordOrEntry.keyword ||
2566      typeof keywordOrEntry.keyword != "string"
2567    ) {
2568      throw new Error("Invalid keyword");
2569    }
2570
2571    let {
2572      keyword,
2573      source = Ci.nsINavBookmarksService.SOURCE_DEFAULT,
2574    } = keywordOrEntry;
2575    keyword = keywordOrEntry.keyword.trim().toLowerCase();
2576    return PlacesUtils.withConnectionWrapper(
2577      "PlacesUtils.keywords.remove",
2578      async db => {
2579        let cache = await promiseKeywordsCache();
2580        if (!cache.has(keyword)) {
2581          return;
2582        }
2583        let { url } = cache.get(keyword);
2584        cache.delete(keyword);
2585
2586        await db.executeTransaction(async function() {
2587          await db.execute(
2588            `DELETE FROM moz_keywords WHERE keyword = :keyword`,
2589            { keyword }
2590          );
2591
2592          await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
2593            db,
2594            url,
2595            PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source)
2596          );
2597        });
2598
2599        // Notify bookmarks about the removal.
2600        await notifyKeywordChange(url.href, "", source);
2601      }
2602    );
2603  },
2604
2605  /**
2606   * Moves all (keyword, POST data) pairs from one URL to another, and fires
2607   * observer notifications for all affected bookmarks. If the destination URL
2608   * already has keywords, they will be removed and replaced with the source
2609   * URL's keywords.
2610   *
2611   * @param oldURL
2612   *        The source URL.
2613   * @param newURL
2614   *        The destination URL.
2615   * @param source
2616   *        The change source, forwarded to all bookmark observers.
2617   * @return {Promise}
2618   * @resolves when all keywords have been moved to the destination URL.
2619   */
2620  reassign(oldURL, newURL, source = PlacesUtils.bookmarks.SOURCES.DEFAULT) {
2621    try {
2622      oldURL = BOOKMARK_VALIDATORS.url(oldURL);
2623    } catch (ex) {
2624      throw new Error(oldURL + " is not a valid source URL");
2625    }
2626    try {
2627      newURL = BOOKMARK_VALIDATORS.url(newURL);
2628    } catch (ex) {
2629      throw new Error(oldURL + " is not a valid destination URL");
2630    }
2631    return PlacesUtils.withConnectionWrapper(
2632      "PlacesUtils.keywords.reassign",
2633      async function(db) {
2634        let keywordsToReassign = [];
2635        let keywordsToRemove = [];
2636        let cache = await promiseKeywordsCache();
2637        for (let [keyword, entry] of cache) {
2638          if (entry.url.href == oldURL.href) {
2639            keywordsToReassign.push(keyword);
2640          }
2641          if (entry.url.href == newURL.href) {
2642            keywordsToRemove.push(keyword);
2643          }
2644        }
2645        if (!keywordsToReassign.length) {
2646          return;
2647        }
2648
2649        await db.executeTransaction(async function() {
2650          // Remove existing keywords from the new URL.
2651          await db.executeCached(
2652            `DELETE FROM moz_keywords WHERE keyword = :keyword`,
2653            keywordsToRemove.map(keyword => ({ keyword }))
2654          );
2655
2656          // Move keywords from the old URL to the new URL.
2657          await db.executeCached(
2658            `
2659          UPDATE moz_keywords SET
2660            place_id = (SELECT id FROM moz_places
2661                        WHERE url_hash = hash(:newURL) AND
2662                              url = :newURL)
2663          WHERE place_id = (SELECT id FROM moz_places
2664                            WHERE url_hash = hash(:oldURL) AND
2665                                  url = :oldURL)`,
2666            { newURL: newURL.href, oldURL: oldURL.href }
2667          );
2668        });
2669        for (let keyword of keywordsToReassign) {
2670          let entry = cache.get(keyword);
2671          entry.url = newURL;
2672        }
2673        for (let keyword of keywordsToRemove) {
2674          cache.delete(keyword);
2675        }
2676
2677        if (keywordsToReassign.length) {
2678          // If we moved any keywords, notify that we removed all keywords from
2679          // the old and new URLs, then notify for each moved keyword.
2680          await notifyKeywordChange(oldURL, "", source);
2681          await notifyKeywordChange(newURL, "", source);
2682          for (let keyword of keywordsToReassign) {
2683            await notifyKeywordChange(newURL, keyword, source);
2684          }
2685        } else if (keywordsToRemove.length) {
2686          // If the old URL didn't have any keywords, but the new URL did, just
2687          // notify that we removed all keywords from the new URL.
2688          await notifyKeywordChange(oldURL, "", source);
2689        }
2690      }
2691    );
2692  },
2693
2694  /**
2695   * Removes all orphaned keywords from the given URLs. Orphaned keywords are
2696   * associated with URLs that are no longer bookmarked. If a given URL is still
2697   * bookmarked, its keywords will not be removed.
2698   *
2699   * @param urls
2700   *        A list of URLs to check for orphaned keywords.
2701   * @return {Promise}
2702   * @resolves when all keywords have been removed from URLs that are no longer
2703   *           bookmarked.
2704   */
2705  removeFromURLsIfNotBookmarked(urls) {
2706    let hrefs = new Set();
2707    for (let url of urls) {
2708      try {
2709        url = BOOKMARK_VALIDATORS.url(url);
2710      } catch (ex) {
2711        throw new Error(url + " is not a valid URL");
2712      }
2713      hrefs.add(url.href);
2714    }
2715    return PlacesUtils.withConnectionWrapper(
2716      "PlacesUtils.keywords.removeFromURLsIfNotBookmarked",
2717      async function(db) {
2718        let keywordsByHref = new Map();
2719        let cache = await promiseKeywordsCache();
2720        for (let [keyword, entry] of cache) {
2721          let href = entry.url.href;
2722          if (!hrefs.has(href)) {
2723            continue;
2724          }
2725          if (!keywordsByHref.has(href)) {
2726            keywordsByHref.set(href, [keyword]);
2727            continue;
2728          }
2729          let existingKeywords = keywordsByHref.get(href);
2730          existingKeywords.push(keyword);
2731        }
2732        if (!keywordsByHref.size) {
2733          return;
2734        }
2735
2736        let placeInfosToRemove = [];
2737        let rows = await db.execute(
2738          `
2739          SELECT h.id, h.url
2740          FROM moz_places h
2741          JOIN moz_keywords k ON k.place_id = h.id
2742          GROUP BY h.id
2743          HAVING h.foreign_count = count(*) +
2744            (SELECT count(*)
2745             FROM moz_bookmarks b
2746             JOIN moz_bookmarks p ON b.parent = p.id
2747             WHERE p.parent = :tags_root AND b.fk = h.id)
2748          `,
2749          { tags_root: PlacesUtils.tagsFolderId }
2750        );
2751        for (let row of rows) {
2752          placeInfosToRemove.push({
2753            placeId: row.getResultByName("id"),
2754            href: row.getResultByName("url"),
2755          });
2756        }
2757        if (!placeInfosToRemove.length) {
2758          return;
2759        }
2760
2761        await db.execute(
2762          `DELETE FROM moz_keywords WHERE place_id IN (${Array.from(
2763            placeInfosToRemove.map(info => info.placeId)
2764          ).join()})`
2765        );
2766        for (let { href } of placeInfosToRemove) {
2767          let keywords = keywordsByHref.get(href);
2768          for (let keyword of keywords) {
2769            cache.delete(keyword);
2770          }
2771        }
2772      }
2773    );
2774  },
2775
2776  /**
2777   * Removes all keywords from all URLs.
2778   *
2779   * @return {Promise}
2780   * @resolves when all keywords have been removed.
2781   */
2782  eraseEverything() {
2783    return PlacesUtils.withConnectionWrapper(
2784      "PlacesUtils.keywords.eraseEverything",
2785      async function(db) {
2786        let cache = await promiseKeywordsCache();
2787        if (!cache.size) {
2788          return;
2789        }
2790        await db.executeCached(`DELETE FROM moz_keywords`);
2791        cache.clear();
2792      }
2793    );
2794  },
2795
2796  /**
2797   * Invalidates the keywords cache, leaving all existing keywords in place.
2798   * The cache will be repopulated on the next `PlacesUtils.keywords.*` call.
2799   *
2800   * @return {Promise}
2801   * @resolves when the cache has been cleared.
2802   */
2803  invalidateCachedKeywords() {
2804    gKeywordsCachePromise = gKeywordsCachePromise.then(_ => null);
2805    return gKeywordsCachePromise;
2806  },
2807};
2808
2809var gKeywordsCachePromise = Promise.resolve();
2810
2811function promiseKeywordsCache() {
2812  let promise = gKeywordsCachePromise.then(function(cache) {
2813    if (cache) {
2814      return cache;
2815    }
2816    return PlacesUtils.withConnectionWrapper(
2817      "PlacesUtils: promiseKeywordsCache",
2818      async db => {
2819        let cache = new Map();
2820        let rows = await db.execute(
2821          `SELECT keyword, url, post_data
2822           FROM moz_keywords k
2823           JOIN moz_places h ON h.id = k.place_id
2824          `
2825        );
2826        let brokenKeywords = [];
2827        for (let row of rows) {
2828          let keyword = row.getResultByName("keyword");
2829          try {
2830            let entry = {
2831              keyword,
2832              url: new URL(row.getResultByName("url")),
2833              postData: row.getResultByName("post_data") || null,
2834            };
2835            cache.set(keyword, entry);
2836          } catch (ex) {
2837            // The url is invalid, don't load the keyword and remove it, or it
2838            // would break the whole keywords API.
2839            brokenKeywords.push(keyword);
2840          }
2841        }
2842        if (brokenKeywords.length) {
2843          await db.execute(
2844            `DELETE FROM moz_keywords
2845             WHERE keyword IN (${brokenKeywords.map(JSON.stringify).join(",")})
2846            `
2847          );
2848        }
2849        return cache;
2850      }
2851    );
2852  });
2853  gKeywordsCachePromise = promise.catch(_ => {});
2854  return promise;
2855}
2856
2857// Sometime soon, likely as part of the transition to mozIAsyncBookmarks,
2858// itemIds will be deprecated in favour of GUIDs, which play much better
2859// with multiple undo/redo operations.  Because these GUIDs are already stored,
2860// and because we don't want to revise the transactions API once more when this
2861// happens, transactions are set to work with GUIDs exclusively, in the sense
2862// that they may never expose itemIds, nor do they accept them as input.
2863// More importantly, transactions which add or remove items guarantee to
2864// restore the GUIDs on undo/redo, so that the following transactions that may
2865// done or undo can assume the items they're interested in are stil accessible
2866// through the same GUID.
2867// The current bookmarks API, however, doesn't expose the necessary means for
2868// working with GUIDs.  So, until it does, this helper object accesses the
2869// Places database directly in order to switch between GUIDs and itemIds, and
2870// "restore" GUIDs on items re-created items.
2871var GuidHelper = {
2872  // Cache for GUID<->itemId paris.
2873  guidsForIds: new Map(),
2874  idsForGuids: new Map(),
2875
2876  async getItemId(aGuid) {
2877    let cached = this.idsForGuids.get(aGuid);
2878    if (cached !== undefined) {
2879      return cached;
2880    }
2881
2882    let itemId = await PlacesUtils.withConnectionWrapper(
2883      "GuidHelper.getItemId",
2884      async function(db) {
2885        let rows = await db.executeCached(
2886          "SELECT b.id, b.guid from moz_bookmarks b WHERE b.guid = :guid LIMIT 1",
2887          { guid: aGuid }
2888        );
2889        if (!rows.length) {
2890          throw new Error("no item found for the given GUID");
2891        }
2892
2893        return rows[0].getResultByName("id");
2894      }
2895    );
2896
2897    this.updateCache(itemId, aGuid);
2898    return itemId;
2899  },
2900
2901  async getManyItemIds(aGuids) {
2902    let uncachedGuids = aGuids.filter(guid => !this.idsForGuids.has(guid));
2903    if (uncachedGuids.length) {
2904      await PlacesUtils.withConnectionWrapper(
2905        "GuidHelper.getItemId",
2906        async db => {
2907          while (uncachedGuids.length) {
2908            let chunk = uncachedGuids.splice(0, 100);
2909            let rows = await db.executeCached(
2910              `SELECT b.id, b.guid from moz_bookmarks b WHERE
2911             b.guid IN (${"?,".repeat(chunk.length - 1) + "?"})
2912             LIMIT ${chunk.length}`,
2913              chunk
2914            );
2915            if (rows.length < chunk.length) {
2916              throw new Error("Not all items were found!");
2917            }
2918            for (let row of rows) {
2919              this.updateCache(
2920                row.getResultByIndex(0),
2921                row.getResultByIndex(1)
2922              );
2923            }
2924          }
2925        }
2926      );
2927    }
2928    return new Map(aGuids.map(guid => [guid, this.idsForGuids.get(guid)]));
2929  },
2930
2931  async getItemGuid(aItemId) {
2932    let cached = this.guidsForIds.get(aItemId);
2933    if (cached !== undefined) {
2934      return cached;
2935    }
2936
2937    let guid = await PlacesUtils.withConnectionWrapper(
2938      "GuidHelper.getItemGuid",
2939      async function(db) {
2940        let rows = await db.executeCached(
2941          "SELECT b.id, b.guid from moz_bookmarks b WHERE b.id = :id LIMIT 1",
2942          { id: aItemId }
2943        );
2944        if (!rows.length) {
2945          throw new Error("no item found for the given itemId");
2946        }
2947
2948        return rows[0].getResultByName("guid");
2949      }
2950    );
2951
2952    this.updateCache(aItemId, guid);
2953    return guid;
2954  },
2955
2956  /**
2957   * Updates the cache.
2958   *
2959   * @note This is the only place where the cache should be populated,
2960   *       invalidation relies on both Maps being populated at the same time.
2961   */
2962  updateCache(aItemId, aGuid) {
2963    if (typeof aItemId != "number" || aItemId <= 0) {
2964      throw new Error(
2965        "Trying to update the GUIDs cache with an invalid itemId"
2966      );
2967    }
2968    if (!PlacesUtils.isValidGuid(aGuid)) {
2969      throw new Error("Trying to update the GUIDs cache with an invalid GUID");
2970    }
2971    this.ensureObservingRemovedItems();
2972    this.guidsForIds.set(aItemId, aGuid);
2973    this.idsForGuids.set(aGuid, aItemId);
2974  },
2975
2976  invalidateCacheForItemId(aItemId) {
2977    let guid = this.guidsForIds.get(aItemId);
2978    this.guidsForIds.delete(aItemId);
2979    this.idsForGuids.delete(guid);
2980  },
2981
2982  invalidateCache() {
2983    this.guidsForIds.clear();
2984    this.idsForGuids.clear();
2985  },
2986
2987  ensureObservingRemovedItems() {
2988    if (this.addListeners) {
2989      return;
2990    }
2991    /**
2992     * This observers serves two purposes:
2993     * (1) Invalidate cached id<->GUID paris on when items are removed.
2994     * (2) Cache GUIDs given us free of charge by onItemAdded/onItemRemoved.
2995     *      So, for exmaple, when the NewBookmark needs the new GUID, we already
2996     *      have it cached.
2997     */
2998    let listener = events => {
2999      for (let event of events) {
3000        switch (event.type) {
3001          case "bookmark-added":
3002            this.updateCache(event.id, event.guid);
3003            this.updateCache(event.parentId, event.parentGuid);
3004            break;
3005          case "bookmark-removed":
3006            this.guidsForIds.delete(event.id);
3007            this.idsForGuids.delete(event.guid);
3008            this.updateCache(event.parentId, event.parentGuid);
3009            break;
3010        }
3011      }
3012    };
3013
3014    this.addListeners = true;
3015    PlacesUtils.observers.addListener(
3016      ["bookmark-added", "bookmark-removed"],
3017      listener
3018    );
3019    PlacesUtils.registerShutdownFunction(() => {
3020      PlacesUtils.observers.removeListener(
3021        ["bookmark-added", "bookmark-removed"],
3022        listener
3023      );
3024    });
3025  },
3026};
3027