--- 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]