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