Bug 1265836 - Part 3: Implement insert and insertMany in History.jsm, r?mak draft
authorBob Silverberg <bsilverberg@mozilla.com>
Fri, 13 May 2016 11:09:06 -0400
changeset 374250 02ab9d5a8fe84552ae2a7ea34c89dc116737dfc4
parent 374249 0f337f8959c36d86eb910a1bff4d776819afe223
child 374251 bc58756420ecbeea1631664eb411e26e2862958f
push id19960
push userbmo:bob.silverberg@gmail.com
push dateThu, 02 Jun 2016 02:42:32 +0000
reviewersmak
bugs1265836
milestone49.0a1
Bug 1265836 - Part 3: Implement insert and insertMany in History.jsm, r?mak MozReview-Commit-ID: GmXVDPuULtq
toolkit/components/places/Bookmarks.jsm
toolkit/components/places/History.jsm
toolkit/components/places/PlacesUtils.jsm
toolkit/components/places/tests/history/test_insert.js
toolkit/components/places/tests/history/xpcshell.ini
--- a/toolkit/components/places/Bookmarks.jsm
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -171,29 +171,29 @@ var Bookmarks = Object.freeze({
       }
 
       let item = yield insertBookmark(insertInfo, parent);
 
       // Notify onItemAdded to listeners.
       let observers = PlacesUtils.bookmarks.getObservers();
       // We need the itemId to notify, though once the switch to guids is
       // complete we may stop using it.
-      let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
+      let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
       let itemId = yield PlacesUtils.promiseItemId(item.guid);
       notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
                                          item.type, uri, item.title || null,
-                                         toPRTime(item.dateAdded), item.guid,
+                                         PlacesUtils.toPRTime(item.dateAdded), item.guid,
                                          item.parentGuid ]);
 
       // If it's a tag, notify OnItemChanged to all bookmarks for this URL.
       let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
       if (isTagging) {
         for (let entry of (yield fetchBookmarksByURL(item))) {
           notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
-                                               toPRTime(entry.lastModified),
+                                               PlacesUtils.toPRTime(entry.lastModified),
                                                entry.type, entry._parentId,
                                                entry.guid, entry.parentGuid,
                                                "" ]);
         }
       }
 
       // Remove non-enumerable properties.
       return Object.assign({}, item);
@@ -318,36 +318,36 @@ var Bookmarks = Object.freeze({
         let observers = PlacesUtils.bookmarks.getObservers();
         // For lastModified, we only care about the original input, since we
         // should not notify implciit lastModified changes.
         if (info.hasOwnProperty("lastModified") &&
             updateInfo.hasOwnProperty("lastModified") &&
             item.lastModified != updatedItem.lastModified) {
           notify(observers, "onItemChanged", [ updatedItem._id, "lastModified",
                                                false,
-                                               `${toPRTime(updatedItem.lastModified)}`,
-                                               toPRTime(updatedItem.lastModified),
+                                               `${PlacesUtils.toPRTime(updatedItem.lastModified)}`,
+                                               PlacesUtils.toPRTime(updatedItem.lastModified),
                                                updatedItem.type,
                                                updatedItem._parentId,
                                                updatedItem.guid,
                                                updatedItem.parentGuid, "" ]);
         }
         if (updateInfo.hasOwnProperty("title")) {
           notify(observers, "onItemChanged", [ updatedItem._id, "title",
                                                false, updatedItem.title,
-                                               toPRTime(updatedItem.lastModified),
+                                               PlacesUtils.toPRTime(updatedItem.lastModified),
                                                updatedItem.type,
                                                updatedItem._parentId,
                                                updatedItem.guid,
                                                updatedItem.parentGuid, "" ]);
         }
         if (updateInfo.hasOwnProperty("url")) {
           notify(observers, "onItemChanged", [ updatedItem._id, "uri",
                                                false, updatedItem.url.href,
-                                               toPRTime(updatedItem.lastModified),
+                                               PlacesUtils.toPRTime(updatedItem.lastModified),
                                                updatedItem.type,
                                                updatedItem._parentId,
                                                updatedItem.guid,
                                                updatedItem.parentGuid,
                                                item.url.href ]);
         }
         // If the item was moved, notify onItemMoved.
         if (item.parentGuid != updatedItem.parentGuid ||
@@ -403,26 +403,26 @@ var Bookmarks = Object.freeze({
       let item = yield fetchBookmark(removeInfo);
       if (!item)
         throw new Error("No bookmarks found for the provided GUID.");
 
       item = yield removeBookmark(item, options);
 
       // Notify onItemRemoved to listeners.
       let observers = PlacesUtils.bookmarks.getObservers();
-      let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
+      let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
       notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
                                            item.type, uri, item.guid,
                                            item.parentGuid ]);
 
       let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
       if (isUntagging) {
         for (let entry of (yield fetchBookmarksByURL(item))) {
           notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
-                                               toPRTime(entry.lastModified),
+                                               PlacesUtils.toPRTime(entry.lastModified),
                                                entry.type, entry._parentId,
                                                entry.guid, entry.parentGuid,
                                                "" ]);
         }
       }
 
       // Remove non-enumerable properties.
       return Object.assign({}, item);
@@ -437,17 +437,17 @@ var Bookmarks = Object.freeze({
    * @return {Promise} resolved when the removal is complete.
    * @resolves once the removal is complete.
    */
   eraseEverything: function() {
     return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything",
       db => db.executeTransaction(function* () {
         const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid];
         yield removeFoldersContents(db, folderGuids);
-        const time = toPRTime(new Date());
+        const time = PlacesUtils.toPRTime(new Date());
         for (let folderGuid of folderGuids) {
           yield db.executeCached(
             `UPDATE moz_bookmarks SET lastModified = :time
              WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
             `, { folderGuid, time });
         }
       }.bind(this))
     );
@@ -764,17 +764,17 @@ function notify(observers, notification,
 // Update implementation.
 
 function updateBookmark(info, item, newParent) {
   return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
     Task.async(function*(db) {
 
     let tuples = new Map();
     if (info.hasOwnProperty("lastModified"))
-      tuples.set("lastModified", { value: toPRTime(info.lastModified) });
+      tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) });
     if (info.hasOwnProperty("title"))
       tuples.set("title", { value: info.title });
 
     yield db.executeTransaction(function* () {
       if (info.hasOwnProperty("url")) {
         // Ensure a page exists in moz_places for this URL.
         yield db.executeCached(
           `INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
@@ -881,18 +881,18 @@ function insertBookmark(item, parent) {
       // Insert the bookmark into the database.
       yield db.executeCached(
         `INSERT INTO moz_bookmarks (fk, type, parent, position, title,
                                     dateAdded, lastModified, guid)
          VALUES ((SELECT id FROM moz_places WHERE url = :url), :type, :parent,
                  :index, :title, :date_added, :last_modified, :guid)
         `, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
              type: item.type, parent: parent._id, index: item.index,
-             title: item.title, date_added: toPRTime(item.dateAdded),
-             last_modified: toPRTime(item.lastModified), guid: item.guid });
+             title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded),
+             last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid });
 
       yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
     });
 
     // If not a tag recalculate frecency...
     let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
     if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
       // ...though we don't wait for the calculation.
@@ -1235,63 +1235,30 @@ function removeSameValueProperties(dest,
         remove = dest[prop] == src[prop];
     }
     if (remove && prop != "guid")
       delete dest[prop];
   }
 }
 
 /**
- * Converts an URL object to an nsIURI.
- *
- * @param url
- *        the URL object to convert.
- * @return nsIURI for the given URL.
- */
-function toURI(url) {
-  return NetUtil.newURI(url.href);
-}
-
-/**
- * Convert a Date object to a PRTime (microseconds).
- *
- * @param date
- *        the Date object to convert.
- * @return microseconds from the epoch.
- */
-function toPRTime(date) {
-  return date * 1000;
-}
-
-/**
- * Convert a PRTime to a Date object.
- *
- * @param time
- *        microseconds from the epoch.
- * @return a Date object.
- */
-function toDate(time) {
-  return new Date(parseInt(time / 1000));
-}
-
-/**
  * Convert an array of mozIStorageRow objects to an array of bookmark objects.
  *
  * @param rows
  *        the array of mozIStorageRow objects.
  * @return an array of bookmark objects.
  */
 function rowsToItemsArray(rows) {
   return rows.map(row => {
     let item = {};
     for (let prop of ["guid", "index", "type"]) {
       item[prop] = row.getResultByName(prop);
     }
     for (let prop of ["dateAdded", "lastModified"]) {
-      item[prop] = toDate(row.getResultByName(prop));
+      item[prop] = PlacesUtils.toDate(row.getResultByName(prop));
     }
     for (let prop of ["title", "parentGuid", "url" ]) {
       let val = row.getResultByName(prop);
       if (val)
         item[prop] = prop === "url" ? new URL(val) : val;
     }
     for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
       let val = row.getResultByName(prop);
@@ -1327,17 +1294,17 @@ function simpleValidateFunc(boolValidate
 
 /**
  * List of validators, one per each known property.
  * Validators must throw if the property value is invalid and return a fixed up
  * version of the value, if needed.
  */
 const VALIDATORS = Object.freeze({
   guid: simpleValidateFunc(v => typeof(v) == "string" &&
-                                /^[a-zA-Z0-9\-_]{12}$/.test(v)),
+                                PlacesUtils.isValidGuid(v)),
   parentGuid: simpleValidateFunc(v => typeof(v) == "string" &&
                                       /^[a-zA-Z0-9\-_]{12}$/.test(v)),
   index: simpleValidateFunc(v => Number.isInteger(v) &&
                                  v >= Bookmarks.DEFAULT_INDEX),
   dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
   lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
   type: simpleValidateFunc(v => Number.isInteger(v) &&
                                 [ Bookmarks.TYPE_BOOKMARK
@@ -1510,17 +1477,17 @@ var setAncestorsLastModified = Task.asyn
        UNION ALL
        SELECT parent FROM moz_bookmarks
        JOIN ancestors ON id = aid
        WHERE type = :type
      )
      UPDATE moz_bookmarks SET lastModified = :time
      WHERE id IN ancestors
     `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
-         time: toPRTime(time) });
+         time: PlacesUtils.toPRTime(time) });
 });
 
 /**
  * Remove all descendants of one or more bookmark folders.
  *
  * @param db
  *        the Sqlite.jsm connection handle.
  * @param folderGuids
@@ -1575,25 +1542,25 @@ Task.async(function* (db, folderGuids) {
   // Send onItemRemoved notifications to listeners.
   // TODO (Bug 1087580): for the case of eraseEverything, this should send a
   // single clear bookmarks notification rather than notifying for each
   // bookmark.
 
   // Notify listeners in reverse order to serve children before parents.
   let observers = PlacesUtils.bookmarks.getObservers();
   for (let item of itemsRemoved.reverse()) {
-    let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
+    let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
     notify(observers, "onItemRemoved", [ item._id, item._parentId,
                                          item.index, item.type, uri,
                                          item.guid, item.parentGuid ]);
 
     let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
     if (isUntagging) {
       for (let entry of (yield fetchBookmarksByURL(item))) {
         notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
-                                             toPRTime(entry.lastModified),
+                                             PlacesUtils.toPRTime(entry.lastModified),
                                              entry.type, entry._parentId,
                                              entry.guid, entry.parentGuid,
                                              "" ]);
       }
     }
   }
 });
--- a/toolkit/components/places/History.jsm
+++ b/toolkit/components/places/History.jsm
@@ -127,71 +127,136 @@ this.History = Object.freeze({
    *      If `guidOrURI` does not have the expected type or if it is a string
    *      that may be parsed neither as a valid URL nor as a valid GUID.
    */
   fetch: function (guidOrURI) {
     throw new Error("Method not implemented");
   },
 
   /**
-   * Adds a set of visits for one or more page.
+   * Adds a number of visits for a single page.
    *
    * Any change may be observed through nsINavHistoryObserver
    *
-   * @note This function recomputes the frecency of the page automatically,
-   * regardless of the value of property `frecency` passed as argument.
-   * @note If there is no entry for the page, the entry is created.
-   *
-   * @param infos: (PageInfo)
+   * @param pageInfo: (PageInfo)
    *      Information on a page. This `PageInfo` MUST contain
-   *        - either a property `guid` or a property `url`, as specified
-   *          by the definition of `PageInfo`;
+   *        - a property `url`, as specified by the definition of `PageInfo`.
    *        - a property `visits`, as specified by the definition of
    *          `PageInfo`, which MUST contain at least one visit.
    *      If a property `title` is provided, the title of the page
    *      is updated.
-   *      If the `visitDate` of a visit is not provided, it defaults
+   *      If the `date` of a visit is not provided, it defaults
    *      to now.
-   *            or (Array<PageInfo>)
-   *      An array of the above, to batch requests.
-   * @param onResult: (function(PageInfo), [optional])
-   *      A callback invoked for each page, with the updated
-   *      information on that page. Note that this `PageInfo`
-   *      does NOT contain the visit data (i.e. `visits` is
-   *      `undefined`).
+   *      If the `transition` of a visit is not provided, it defaults to
+   *      TRANSITION_LINK.
    *
    * @return (Promise)
-   *      A promise resolved once the operation is complete, including
-   *      all calls to `onResult`.
-   * @resolves (bool)
-   *      `true` if at least one page entry was created, `false` otherwise
-   *       (i.e. if page entries were updated but not created).
+   *      A promise resolved once the operation is complete.
+   * @resolves (PageInfo)
+   *      A PageInfo object populated with data after the insert is complete.
+   * @rejects (Error)
+   *      Rejects if the insert was unsuccessful.
    *
    * @throws (Error)
    *      If the `url` specified was for a protocol that should not be
    *      stored (e.g. "chrome:", "mailbox:", "about:", "imap:", "news:",
    *      "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:",
    *      "javascript:", "blob:").
    * @throws (Error)
-   *      If `infos` has an unexpected type.
+   *      If `pageInfo` has an unexpected type.
+   * @throws (Error)
+   *      If `pageInfo` does not have a `url`.
+   * @throws (Error)
+   *      If `pageInfo` does not have a `visits` property or if the
+   *      value of `visits` is ill-typed or is an empty array.
+   * @throws (Error)
+   *      If an element of `visits` has an invalid `date`.
    * @throws (Error)
-   *      If a `PageInfo` has neither `guid` nor `url`.
+   *      If an element of `visits` has an invalid `transition`.
+   */
+  insert: function (pageInfo) {
+    if (typeof pageInfo != "object" || !pageInfo) {
+      throw new TypeError("pageInfo must be an object");
+    }
+
+    let info = validatePageInfo(pageInfo);
+
+    return PlacesUtils.withConnectionWrapper("History.jsm: insert",
+      db => insert(db, info));
+  },
+
+  /**
+   * Adds a number of visits for a number of pages.
+   *
+   * Any change may be observed through nsINavHistoryObserver
+   *
+   * @param pageInfos: (Array<PageInfo>)
+   *      Information on a page. This `PageInfo` MUST contain
+   *        - a property `url`, as specified by the definition of `PageInfo`.
+   *        - a property `visits`, as specified by the definition of
+   *          `PageInfo`, which MUST contain at least one visit.
+   *      If a property `title` is provided, the title of the page
+   *      is updated.
+   *      If the `date` of a visit is not provided, it defaults
+   *      to now.
+   *      If the `transition` of a visit is not provided, it defaults to
+   *      TRANSITION_LINK.
+   * @param onResult: (function(PageInfo))
+   *      A callback invoked for each page inserted.
+   * @param onError: (function(PageInfo))
+   *      A callback invoked for each page which generated an error
+   *      when an insert was attempted.
+   *
+   * @return (Promise)
+   *      A promise resolved once the operation is complete.
+   * @resolves (null)
+   * @rejects (Error)
+   *      Rejects if all of the inserts were unsuccessful.
+   *
    * @throws (Error)
-   *      If a `guid` property provided is not a valid GUID.
+   *      If the `url` specified was for a protocol that should not be
+   *      stored (e.g. "chrome:", "mailbox:", "about:", "imap:", "news:",
+   *      "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:",
+   *      "javascript:", "blob:").
+   * @throws (Error)
+   *      If `pageInfos` has an unexpected type.
+   * @throws (Error)
+   *      If a `pageInfo` does not have a `url`.
    * @throws (Error)
    *      If a `PageInfo` does not have a `visits` property or if the
    *      value of `visits` is ill-typed or is an empty array.
    * @throws (Error)
    *      If an element of `visits` has an invalid `date`.
    * @throws (Error)
-   *      If an element of `visits` is missing `transition` or if
-   *      the value of `transition` is invalid.
+   *      If an element of `visits` has an invalid `transition`.
    */
-  update: function (infos, onResult) {
-    throw new Error("Method not implemented");
+  insertMany: function (pageInfos, onResult, onError) {
+    let infos = [];
+
+    if (!Array.isArray(pageInfos)) {
+      throw new TypeError("pageInfos must be an array");
+    }
+    if (!pageInfos.length) {
+      throw new TypeError("pageInfos may not be an empty array");
+    }
+
+    if (onResult && typeof onResult != "function") {
+      throw new TypeError(`onResult: ${onResult} is not a valid function`);
+    }
+    if (onError && typeof onError != "function") {
+      throw new TypeError(`onError: ${onError} is not a valid function`);
+    }
+
+    for (let pageInfo of pageInfos) {
+      let info = validatePageInfo(pageInfo);
+      infos.push(info);
+    }
+
+    return PlacesUtils.withConnectionWrapper("History.jsm: insertMany",
+      db => insertMany(db, infos, onResult, onError));
   },
 
   /**
    * Remove pages from the database.
    *
    * Any change may be observed through nsINavHistoryObserver
    *
    *
@@ -385,29 +450,126 @@ this.History = Object.freeze({
   TRANSITION_DOWNLOAD: Ci.nsINavHistoryService.TRANSITION_REDIRECT_DOWNLOAD,
 
   /**
    * The user followed a link and got a visit in a frame.
    */
   TRANSITION_FRAMED_LINK: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
 });
 
+/**
+ * Validate an input PageInfo object, returning a valid PageInfo object.
+ *
+ * @param pageInfo: (PageInfo)
+ * @return (PageInfo)
+ */
+function validatePageInfo(pageInfo) {
+  let info = {
+    visits: [],
+  };
+
+  if (!pageInfo.url) {
+    throw new TypeError("PageInfo object must have a url property");
+  }
+
+  info.url = normalizeToURLOrGUID(pageInfo.url);
+
+  if (typeof pageInfo.title === "string" && pageInfo.title.length) {
+    info.title = pageInfo.title;
+  } else if (pageInfo.title != null && pageInfo.title != undefined) {
+    throw new TypeError(`title property of PageInfo object: ${pageInfo.title} must be a string if provided`);
+  }
+
+  if (!pageInfo.visits || !Array.isArray(pageInfo.visits) || !pageInfo.visits.length) {
+    throw new TypeError("PageInfo object must have an array of visits");
+  }
+  for (let inVisit of pageInfo.visits) {
+    let visit = {
+      date: new Date(),
+      transition: inVisit.transition || History.TRANSITION_LINK,
+    };
+
+    if (!isValidTransitionType(visit.transition)) {
+      throw new TypeError(`transition: ${visit.transition} is not a valid transition type`);
+    }
+
+    if (inVisit.date) {
+      ensureDate(inVisit.date);
+      if (inVisit.date > Date.now()) {
+        throw new TypeError(`date: ${inVisit.date} cannot be a future date`);
+      }
+      visit.date = inVisit.date;
+    }
+
+    if (inVisit.referrer) {
+      visit.referrer = normalizeToURLOrGUID(inVisit.referrer);
+    }
+    info.visits.push(visit);
+  }
+  return info;
+}
+
+/**
+ * Convert a PageInfo object into the format expected by updatePlaces.
+ *
+ * Note: this assumes that the PageInfo object has already been validated
+ * via validatePageInfo.
+ *
+ * @param pageInfo: (PageInfo)
+ * @return (info)
+ */
+function convertForUpdatePlaces(pageInfo) {
+  let info = {
+    uri: PlacesUtils.toURI(pageInfo.url),
+    title: pageInfo.title,
+    visits: [],
+  };
+
+  for (let inVisit of pageInfo.visits) {
+    let visit = {
+      visitDate: PlacesUtils.toPRTime(inVisit.date),
+      transitionType: inVisit.transition,
+      referrerURI: (inVisit.referrer) ? PlacesUtils.toURI(inVisit.referrer) : undefined,
+    };
+    info.visits.push(visit);
+  }
+  return info;
+}
+
+/**
+ * Is a value a valid transition type?
+ *
+ * @param transitionType: (String)
+ * @return (Boolean)
+ */
+function isValidTransitionType(transitionType) {
+  return [
+    History.TRANSITION_LINK,
+    History.TRANSITION_TYPED,
+    History.TRANSITION_BOOKMARK,
+    History.TRANSITION_EMBED,
+    History.TRANSITION_REDIRECT_PERMANENT,
+    History.TRANSITION_REDIRECT_TEMPORARY,
+    History.TRANSITION_DOWNLOAD,
+    History.TRANSITION_FRAMED_LINK
+  ].includes(transitionType);
+}
 
 /**
  * Normalize a key to either a string (if it is a valid GUID) or an
  * instance of `URL` (if it is a `URL`, `nsIURI`, or a string
  * representing a valid url).
  *
  * @throws (TypeError)
  *         If the key is neither a valid guid nor a valid url.
  */
 function normalizeToURLOrGUID(key) {
   if (typeof key === "string") {
     // A string may be a URL or a guid
-    if (/^[a-zA-Z0-9\-_]{12}$/.test(key)) {
+    if (PlacesUtils.isValidGuid(key)) {
       return key;
     }
     return new URL(key);
   }
   if (key instanceof URL) {
     return key;
   }
   if (key instanceof Ci.nsIURI) {
@@ -762,8 +924,92 @@ var remove = Task.async(function*(db, {g
     notifyOnResult(onResultData, onResult); // don't wait
   } finally {
     // Ensure we cleanup embed visits, even if we bailed out early.
     PlacesUtils.history.clearEmbedVisits();
   }
 
   return hasPagesToRemove;
 });
+
+/**
+ * Merges an updateInfo object, as returned by asyncHistory.updatePlaces
+ * into a PageInfo object as defined in this file.
+ *
+ * @param updateInfo: (Object)
+ *      An object that represents a page that is generated by
+ *      asyncHistory.updatePlaces.
+ * @param pageInfo: (PageInfo)
+ *      An PageInfo object into which to merge the data from updateInfo.
+ *      Defaults to an empty object so that this method can be used
+ *      to simply convert an updateInfo object into a PageInfo object.
+ *
+ * @return (PageInfo)
+ *      A PageInfo object populated with data from updateInfo.
+ */
+function mergeUpdateInfoIntoPageInfo(updateInfo, pageInfo={}) {
+  pageInfo.guid = updateInfo.guid;
+  if (!pageInfo.url) {
+    pageInfo.url = new URL(updateInfo.uri.spec);
+    pageInfo.title = updateInfo.title;
+    pageInfo.visits = updateInfo.visits.map(visit => {
+      return {
+        date: PlacesUtils.toDate(visit.visitDate),
+        transition: visit.transitionType,
+        referrer: (visit.referrerURI) ? new URL(visit.referrerURI.spec) : null
+      }
+    });
+  }
+  return pageInfo;
+}
+
+// Inner implementation of History.insert.
+var insert = Task.async(function*(db, pageInfo) {
+  let info = convertForUpdatePlaces(pageInfo);
+
+  return new Promise((resolve, reject) => {
+    PlacesUtils.asyncHistory.updatePlaces(info, {
+      handleError: error => {
+        reject(error);
+      },
+      handleResult: result => {
+        pageInfo = mergeUpdateInfoIntoPageInfo(result, pageInfo);
+      },
+      handleCompletion: () => {
+        resolve(pageInfo);
+      }
+    });
+  });
+});
+
+// Inner implementation of History.insertMany.
+var insertMany = Task.async(function*(db, pageInfos, onResult, onError) {
+  let infos = [];
+  let onResultData = [];
+  let onErrorData = [];
+
+  for (let pageInfo of pageInfos) {
+    let info = convertForUpdatePlaces(pageInfo);
+    infos.push(info);
+  }
+
+  return new Promise((resolve, reject) => {
+    PlacesUtils.asyncHistory.updatePlaces(infos, {
+      handleError: (resultCode, result) => {
+        let pageInfo = mergeUpdateInfoIntoPageInfo(result);
+        onErrorData.push(pageInfo);
+      },
+      handleResult: result => {
+        let pageInfo = mergeUpdateInfoIntoPageInfo(result);
+        onResultData.push(pageInfo);
+      },
+      handleCompletion: () => {
+        notifyOnResult(onResultData, onResult);
+        notifyOnResult(onErrorData, onError);
+        if (onResultData.length) {
+          resolve();
+        } else {
+          reject({message: "No items were added to history."})
+        }
+      }
+    });
+  });
+});
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -256,16 +256,39 @@ this.PlacesUtils = {
    *          The string spec of the URI
    * @returns A URI object for the spec.
    */
   _uri: function PU__uri(aSpec) {
     return NetUtil.newURI(aSpec);
   },
 
   /**
+   * Is a string a valid GUID?
+   *
+   * @param guid: (String)
+   * @return (Boolean)
+   */
+  isValidGuid(guid) {
+    return (/^[a-zA-Z0-9\-_]{12}$/.test(guid));
+  },
+
+  /**
+   * Converts a string or n URL object to an nsIURI.
+   *
+   * @param url (URL) or (String)
+   *        the URL to convert.
+   * @return nsIURI for the given URL.
+   */
+  toURI(url) {
+    url = (url instanceof URL) ? url.href : url;
+
+    return NetUtil.newURI(url);
+  },
+
+  /**
    * Convert a Date object to a PRTime (microseconds).
    *
    * @param date
    *        the Date object to convert.
    * @return microseconds from the epoch.
    */
   toPRTime(date) {
     return date * 1000;
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_insert.js
@@ -0,0 +1,264 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.insert` and `History.insertMany`, as implemented in History.jsm
+
+"use strict";
+
+add_task(function* test_insert_error_cases() {
+  const TEST_URL = "http://mozilla.com";
+
+  let validPageInfo = {
+    url: TEST_URL,
+    visits: [
+      {transition: TRANSITION_LINK}
+    ]
+  };
+
+  Assert.throws(
+    () =>  PlacesUtils.history.insert(),
+    /TypeError: pageInfo must be an object/,
+    "passing a null into History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert(1),
+    /TypeError: pageInfo must be an object/,
+    "passing a non object into History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({}),
+    /TypeError: PageInfo object must have a url property/,
+    "passing an object without a url to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: 123}),
+    /TypeError: Invalid url or guid: 123/,
+    "passing an object with an invalid url to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: TEST_URL}),
+    /TypeError: PageInfo object must have an array of visits/,
+    "passing an object without a visits property to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: TEST_URL, visits: 1}),
+    /TypeError: PageInfo object must have an array of visits/,
+    "passing an object with a non-array visits property to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({url: TEST_URL, visits: []}),
+    /TypeError: PageInfo object must have an array of visits/,
+    "passing an object with an empty array as the visits property to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {
+          transition: TRANSITION_LINK,
+          date: "a"
+        }
+      ]}),
+    /TypeError: Expected a Date, got a/,
+    "passing a visit object with an invalid date to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {
+          transition: TRANSITION_LINK
+        },
+        {
+          transition: TRANSITION_LINK,
+          date: "a"
+        }
+      ]}),
+    /TypeError: Expected a Date, got a/,
+    "passing a second visit object with an invalid date to History.insert should throw a TypeError"
+  );
+  let futureDate = new Date();
+  futureDate.setDate(futureDate.getDate() + 1);
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {
+          transition: TRANSITION_LINK,
+          date: futureDate,
+        }
+      ]}),
+    `TypeError: date: ${futureDate} is not a valid date`,
+    "passing a visit object with a future date to History.insert should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insert({
+      url: TEST_URL,
+      visits: [
+        {transition: "a"}
+      ]}),
+    /TypeError: transition: a is not a valid transition type/,
+    "passing a visit object with an invalid transition to History.insert should throw a TypeError"
+  );
+});
+
+add_task(function* test_history_insert() {
+  const TEST_URL = "http://mozilla.com/";
+
+  let inserter = Task.async(function*(name, filter, referrer, date, transition) {
+    do_print(name);
+    do_print(`filter: ${filter}, referrer: ${referrer}, date: ${date}, transition: ${transition}`);
+
+    let uri = NetUtil.newURI(TEST_URL + Math.random());
+    let title = "Visit " + Math.random();
+
+    let pageInfo = {
+      title,
+      visits: [
+        {transition: transition, referrer: referrer, date: date,}
+      ]
+    };
+
+    pageInfo.url = yield filter(uri);
+
+    let result = yield PlacesUtils.history.insert(pageInfo);
+
+    Assert.ok(PlacesUtils.isValidGuid(result.guid), "guid for pageInfo object is valid");
+    Assert.equal(uri.spec, result.url.href, "url is correct for pageInfo object");
+    Assert.equal(title, result.title, "title is correct for pageInfo object");
+    Assert.equal(TRANSITION_LINK, result.visits[0].transition, "transition is correct for pageInfo object");
+    if (referrer) {
+      Assert.equal(referrer, result.visits[0].referrer.href, "url of referrer for visit is correct");
+    } else {
+      Assert.equal(null, result.visits[0].referrer, "url of referrer for visit is correct");
+    }
+    if (date) {
+      Assert.equal(Number(date),
+                   Number(result.visits[0].date),
+                   "date of visit is correct");
+    }
+
+    Assert.ok(yield PlacesTestUtils.isPageInDB(uri), "Page was added");
+    Assert.ok(yield PlacesTestUtils.visitsInDB(uri), "Visit was added");
+  });
+
+  try {
+    for (let referrer of [TEST_URL, null]) {
+      for (let date of [new Date(), null]) {
+        for (let transition of [TRANSITION_LINK, null]) {
+          yield inserter("Testing History.insert() with an nsIURI", x => x, referrer, date, transition);
+          yield inserter("Testing History.insert() with a string url", x => x.spec, referrer, date, transition);
+          yield inserter("Testing History.insert() with a URL object", x => new URL(x.spec), referrer, date, transition);
+        }
+      }
+    }
+  } finally {
+    yield PlacesTestUtils.clearHistory();
+  }
+});
+
+add_task(function* test_insert_multiple_error_cases() {
+  let validPageInfo = {
+    url: "http://mozilla.com",
+    visits: [
+      {transition: TRANSITION_LINK}
+    ]
+  };
+
+  Assert.throws(
+    () =>  PlacesUtils.history.insertMany(),
+    /TypeError: pageInfos must be an array/,
+    "passing a null into History.insertMany should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insertMany([]),
+    /TypeError: pageInfos may not be an empty array/,
+    "passing an empty array into History.insertMany should throw a TypeError"
+  );
+  Assert.throws(
+    () =>  PlacesUtils.history.insertMany([validPageInfo, {}]),
+    /TypeError: PageInfo object must have a url property/,
+    "passing a second invalid PageInfo object to History.insertMany should throw a TypeError"
+  );
+});
+
+add_task(function* test_history_insertMany() {
+  const BAD_URLS = ["about:config", "chrome://browser/content/browser.xul"];
+  const GOOD_URLS = [1, 2, 3].map(x => {return `http://mozilla.com/${x}`;});
+
+  let makePageInfos = Task.async(function*(urls, filter = x => x) {
+    let pageInfos = [];
+    for (let url of urls) {
+      let uri = NetUtil.newURI(url);
+
+      let pageInfo = {
+        title: `Visit to ${url}`,
+        visits: [
+          {transition: TRANSITION_LINK}
+        ]
+      };
+
+      pageInfo.url = yield filter(uri);
+      pageInfos.push(pageInfo);
+    }
+    return pageInfos;
+  });
+
+  let inserter = Task.async(function*(name, filter, useCallbacks) {
+    do_print(name);
+    do_print(`filter: ${filter}`);
+    do_print(`useCallbacks: ${useCallbacks}`);
+    yield PlacesTestUtils.clearHistory();
+
+    let result;
+    let allUrls = GOOD_URLS.concat(BAD_URLS);
+    let pageInfos = yield makePageInfos(allUrls, filter);
+
+    if (useCallbacks) {
+      let onResultUrls = [];
+      let onErrorUrls = [];
+      result = yield PlacesUtils.history.insertMany(pageInfos, pageInfo => {
+        let url = pageInfo.url.href;
+        Assert.ok(GOOD_URLS.includes(url), "onResult callback called for correct url");
+        onResultUrls.push(url);
+        Assert.equal(`Visit to ${url}`, pageInfo.title, "onResult callback provides the correct title");
+        Assert.ok(PlacesUtils.isValidGuid(pageInfo.guid), "onResult callback provides a valid guid");
+      }, pageInfo => {
+        let url = pageInfo.url.href;
+        Assert.ok(BAD_URLS.includes(url), "onError callback called for correct uri");
+        onErrorUrls.push(url);
+        Assert.equal(undefined, pageInfo.title, "onError callback provides the correct title");
+        Assert.equal(undefined, pageInfo.guid, "onError callback provides the expected guid");
+      });
+      Assert.equal(GOOD_URLS.sort().toString(), onResultUrls.sort().toString(), "onResult callback was called for each good url");
+      Assert.equal(BAD_URLS.sort().toString(), onErrorUrls.sort().toString(), "onError callback was called for each bad url");
+    } else {
+      result = yield PlacesUtils.history.insertMany(pageInfos);
+    }
+
+    Assert.equal(undefined, result, "insertMany returned undefined");
+
+    for (let url of allUrls) {
+      let expected = GOOD_URLS.includes(url);
+      Assert.equal(expected, yield PlacesTestUtils.isPageInDB(url), `isPageInDB for ${url} is ${expected}`);
+      Assert.equal(expected, yield PlacesTestUtils.visitsInDB(url), `visitsInDB for ${url} is ${expected}`);
+    }
+  });
+
+  try {
+    for (let useCallbacks of [false, true]) {
+      yield inserter("Testing History.insertMany() with an nsIURI", x => x, useCallbacks);
+      yield inserter("Testing History.insertMany() with a string url", x => x.spec, useCallbacks);
+      yield inserter("Testing History.insertMany() with a URL object", x => new URL(x.spec), useCallbacks);
+    }
+    // Test rejection when no items added
+    let pageInfos = yield makePageInfos(BAD_URLS);
+    PlacesUtils.history.insertMany(pageInfos).then(() => {
+      Assert.ok(false, "History.insertMany rejected promise with all bad URLs");
+    }, error => {
+      Assert.equal("No items were added to history.", error.message, "History.insertMany rejected promise with all bad URLs");
+    });
+  } finally {
+    yield PlacesTestUtils.clearHistory();
+  }
+});
--- a/toolkit/components/places/tests/history/xpcshell.ini
+++ b/toolkit/components/places/tests/history/xpcshell.ini
@@ -1,7 +1,8 @@
 [DEFAULT]
 head = head_history.js
 tail =
 
+[test_insert.js]
 [test_remove.js]
 [test_removeVisits.js]
 [test_removeVisitsByFilter.js]