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