diff options
Diffstat (limited to 'toolkit/components/places/History.jsm')
-rw-r--r-- | toolkit/components/places/History.jsm | 1049 |
1 files changed, 1049 insertions, 0 deletions
diff --git a/toolkit/components/places/History.jsm b/toolkit/components/places/History.jsm new file mode 100644 index 000000000..59c24fcc6 --- /dev/null +++ b/toolkit/components/places/History.jsm @@ -0,0 +1,1049 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Asynchronous API for managing history. + * + * + * The API makes use of `PageInfo` and `VisitInfo` objects, defined as follows. + * + * A `PageInfo` object is any object that contains A SUBSET of the + * following properties: + * - guid: (string) + * The globally unique id of the page. + * - url: (URL) + * or (nsIURI) + * or (string) + * The full URI of the page. Note that `PageInfo` values passed as + * argument may hold `nsIURI` or `string` values for property `url`, + * but `PageInfo` objects returned by this module always hold `URL` + * values. + * - title: (string) + * The title associated with the page, if any. + * - frecency: (number) + * The frecency of the page, if any. + * See https://developer.mozilla.org/en-US/docs/Mozilla/Tech/Places/Frecency_algorithm + * Note that this property may not be used to change the actualy frecency + * score of a page, only to retrieve it. In other words, any `frecency` field + * passed as argument to a function of this API will be ignored. + * - visits: (Array<VisitInfo>) + * All the visits for this page, if any. + * + * See the documentation of individual methods to find out which properties + * are required for `PageInfo` arguments or returned for `PageInfo` results. + * + * A `VisitInfo` object is any object that contains A SUBSET of the following + * properties: + * - date: (Date) + * The time the visit occurred. + * - transition: (number) + * How the user reached the page. See constants `TRANSITIONS.*` + * for the possible transition types. + * - referrer: (URL) + * or (nsIURI) + * or (string) + * The referring URI of this visit. Note that `VisitInfo` passed + * as argument may hold `nsIURI` or `string` values for property `referrer`, + * but `VisitInfo` objects returned by this module always hold `URL` + * values. + * See the documentation of individual methods to find out which properties + * are required for `VisitInfo` arguments or returned for `VisitInfo` results. + * + * + * + * Each successful operation notifies through the nsINavHistoryObserver + * interface. To listen to such notifications you must register using + * nsINavHistoryService `addObserver` and `removeObserver` methods. + * @see nsINavHistoryObserver + */ + +this.EXPORTED_SYMBOLS = [ "History" ]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +Cu.importGlobalProperties(["URL"]); + +/** + * Whenever we update or remove numerous pages, it is preferable + * to yield time to the main thread every so often to avoid janking. + * These constants determine the maximal number of notifications we + * may emit before we yield. + */ +const NOTIFICATION_CHUNK_SIZE = 300; +const ONRESULT_CHUNK_SIZE = 300; + +// Timers resolution is not always good, it can have a 16ms precision on Win. +const TIMERS_RESOLUTION_SKEW_MS = 16; + +/** + * Sends a bookmarks notification through the given observers. + * + * @param observers + * array of nsINavBookmarkObserver objects. + * @param notification + * the notification name. + * @param args + * array of arguments to pass to the notification. + */ +function notify(observers, notification, args = []) { + for (let observer of observers) { + try { + observer[notification](...args); + } catch (ex) {} + } +} + +this.History = Object.freeze({ + /** + * Fetch the available information for one page. + * + * @param guidOrURI: (URL or nsIURI) + * The full URI of the page. + * or (string) + * Either the full URI of the page or the GUID of the page. + * + * @return (Promise) + * A promise resolved once the operation is complete. + * @resolves (PageInfo | null) If the page could be found, the information + * on that page. Note that this `PageInfo` does NOT contain the visit + * data (i.e. `visits` is `undefined`). + * + * @throws (Error) + * 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 number of visits for a single page. + * + * Any change may be observed through nsINavHistoryObserver + * + * @param pageInfo: (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. + * + * @return (Promise) + * 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 `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 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 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` has an invalid `transition`. + */ + 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 + * + * + * @param page: (URL or nsIURI) + * The full URI of the page. + * or (string) + * Either the full URI of the page or the GUID of the page. + * or (Array<URL|nsIURI|string>) + * An array of the above, to batch requests. + * @param onResult: (function(PageInfo)) + * A callback invoked for each page found. + * + * @return (Promise) + * A promise resolved once the operation is complete. + * @resolve (bool) + * `true` if at least one page was removed, `false` otherwise. + * @throws (TypeError) + * If `pages` has an unexpected type or if a string provided + * is neither a valid GUID nor a valid URI or if `pages` + * is an empty array. + */ + remove: function (pages, onResult = null) { + // Normalize and type-check arguments + if (Array.isArray(pages)) { + if (pages.length == 0) { + throw new TypeError("Expected at least one page"); + } + } else { + pages = [pages]; + } + + let guids = []; + let urls = []; + for (let page of pages) { + // Normalize to URL or GUID, or throw if `page` cannot + // be normalized. + let normalized = normalizeToURLOrGUID(page); + if (typeof normalized === "string") { + guids.push(normalized); + } else { + urls.push(normalized.href); + } + } + let normalizedPages = {guids: guids, urls: urls}; + + // At this stage, we know that either `guids` is not-empty + // or `urls` is not-empty. + + if (onResult && typeof onResult != "function") { + throw new TypeError("Invalid function: " + onResult); + } + + return PlacesUtils.withConnectionWrapper("History.jsm: remove", + db => remove(db, normalizedPages, onResult)); + }, + + /** + * Remove visits matching specific characteristics. + * + * Any change may be observed through nsINavHistoryObserver. + * + * @param filter: (object) + * The `object` may contain some of the following + * properties: + * - beginDate: (Date) Remove visits that have + * been added since this date (inclusive). + * - endDate: (Date) Remove visits that have + * been added before this date (inclusive). + * - limit: (Number) Limit the number of visits + * we remove to this number + * - url: (URL) Only remove visits to this URL + * If both `beginDate` and `endDate` are specified, + * visits between `beginDate` (inclusive) and `end` + * (inclusive) are removed. + * + * @param onResult: (function(VisitInfo), [optional]) + * A callback invoked for each visit found and removed. + * Note that the referrer property of `VisitInfo` + * is NOT populated. + * + * @return (Promise) + * @resolve (bool) + * `true` if at least one visit was removed, `false` + * otherwise. + * @throws (TypeError) + * If `filter` does not have the expected type, in + * particular if the `object` is empty. + */ + removeVisitsByFilter: function(filter, onResult = null) { + if (!filter || typeof filter != "object") { + throw new TypeError("Expected a filter"); + } + + let hasBeginDate = "beginDate" in filter; + let hasEndDate = "endDate" in filter; + let hasURL = "url" in filter; + let hasLimit = "limit" in filter; + if (hasBeginDate) { + ensureDate(filter.beginDate); + } + if (hasEndDate) { + ensureDate(filter.endDate); + } + if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) { + throw new TypeError("`beginDate` should be at least as old as `endDate`"); + } + if (!hasBeginDate && !hasEndDate && !hasURL && !hasLimit) { + throw new TypeError("Expected a non-empty filter"); + } + + if (hasURL && !(filter.url instanceof URL) && typeof filter.url != "string" && + !(filter.url instanceof Ci.nsIURI)) { + throw new TypeError("Expected a valid URL for `url`"); + } + + if (hasLimit && + (typeof filter.limit != "number" || + filter.limit <= 0 || + !Number.isInteger(filter.limit))) { + throw new TypeError("Expected a non-zero positive integer as a limit"); + } + + if (onResult && typeof onResult != "function") { + throw new TypeError("Invalid function: " + onResult); + } + + return PlacesUtils.withConnectionWrapper("History.jsm: removeVisitsByFilter", + db => removeVisitsByFilter(db, filter, onResult) + ); + }, + + /** + * Determine if a page has been visited. + * + * @param pages: (URL or nsIURI) + * The full URI of the page. + * or (string) + * The full URI of the page or the GUID of the page. + * + * @return (Promise) + * A promise resolved once the operation is complete. + * @resolve (bool) + * `true` if the page has been visited, `false` otherwise. + * @throws (Error) + * If `pages` has an unexpected type or if a string provided + * is neither not a valid GUID nor a valid URI. + */ + hasVisits: function(page, onResult) { + throw new Error("Method not implemented"); + }, + + /** + * Clear all history. + * + * @return (Promise) + * A promise resolved once the operation is complete. + */ + clear() { + return PlacesUtils.withConnectionWrapper("History.jsm: clear", + clear + ); + }, + + /** + * Possible values for the `transition` property of `VisitInfo` + * objects. + */ + + TRANSITIONS: { + /** + * The user followed a link and got a new toplevel window. + */ + LINK: Ci.nsINavHistoryService.TRANSITION_LINK, + + /** + * The user typed the page's URL in the URL bar or selected it from + * URL bar autocomplete results, clicked on it from a history query + * (from the History sidebar, History menu, or history query in the + * personal toolbar or Places organizer. + */ + TYPED: Ci.nsINavHistoryService.TRANSITION_TYPED, + + /** + * The user followed a bookmark to get to the page. + */ + BOOKMARK: Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + + /** + * Some inner content is loaded. This is true of all images on a + * page, and the contents of the iframe. It is also true of any + * content in a frame if the user did not explicitly follow a link + * to get there. + */ + EMBED: Ci.nsINavHistoryService.TRANSITION_EMBED, + + /** + * Set when the transition was a permanent redirect. + */ + REDIRECT_PERMANENT: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT, + + /** + * Set when the transition was a temporary redirect. + */ + REDIRECT_TEMPORARY: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY, + + /** + * Set when the transition is a download. + */ + DOWNLOAD: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + + /** + * The user followed a link and got a visit in a frame. + */ + FRAMED_LINK: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK, + + /** + * The user reloaded a page. + */ + RELOAD: Ci.nsINavHistoryService.TRANSITION_RELOAD, + }, +}); + +/** + * 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") { + 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.TRANSITIONS.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() + TIMERS_RESOLUTION_SKEW_MS)) { + 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 Object.values(History.TRANSITIONS).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 (PlacesUtils.isValidGuid(key)) { + return key; + } + return new URL(key); + } + if (key instanceof URL) { + return key; + } + if (key instanceof Ci.nsIURI) { + return new URL(key.spec); + } + throw new TypeError("Invalid url or guid: " + key); +} + +/** + * Throw if an object is not a Date object. + */ +function ensureDate(arg) { + if (!arg || typeof arg != "object" || arg.constructor.name != "Date") { + throw new TypeError("Expected a Date, got " + arg); + } +} + +/** + * Convert a list of strings or numbers to its SQL + * representation as a string. + */ +function sqlList(list) { + return list.map(JSON.stringify).join(); +} + +/** + * Invalidate and recompute the frecency of a list of pages, + * informing frecency observers. + * + * @param db: (Sqlite connection) + * @param idList: (Array) + * The `moz_places` identifiers for the places to invalidate. + * @return (Promise) + */ +var invalidateFrecencies = Task.async(function*(db, idList) { + if (idList.length == 0) { + return; + } + let ids = sqlList(idList); + yield db.execute( + `UPDATE moz_places + SET frecency = NOTIFY_FRECENCY( + CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date + ) WHERE id in (${ ids })` + ); + yield db.execute( + `UPDATE moz_places + SET hidden = 0 + WHERE id in (${ ids }) + AND frecency <> 0` + ); +}); + +// Inner implementation of History.clear(). +var clear = Task.async(function* (db) { + // Remove all history. + yield db.execute("DELETE FROM moz_historyvisits"); + + // Clear the registered embed visits. + PlacesUtils.history.clearEmbedVisits(); + + // Expiration will take care of orphans. + let observers = PlacesUtils.history.getObservers(); + notify(observers, "onClearHistory"); + + // Invalidate frecencies for the remaining places. This must happen + // after the notification to ensure it runs enqueued to expiration. + yield db.execute( + `UPDATE moz_places SET frecency = + (CASE + WHEN url_hash BETWEEN hash("place", "prefix_lo") AND + hash("place", "prefix_hi") + THEN 0 + ELSE -1 + END) + WHERE frecency > 0`); + + // Notify frecency change observers. + notify(observers, "onManyFrecenciesChanged"); +}); + +/** + * Clean up pages whose history has been modified, by either + * removing them entirely (if they are marked for removal, + * typically because all visits have been removed and there + * are no more foreign keys such as bookmarks) or updating + * their frecency (otherwise). + * + * @param db: (Sqlite connection) + * The database. + * @param pages: (Array of objects) + * Pages that have been touched and that need cleaning up. + * Each object should have the following properties: + * - id: (number) The `moz_places` identifier for the place. + * - hasVisits: (boolean) If `true`, there remains at least one + * visit to this page, so the page should be kept and its + * frecency updated. + * - hasForeign: (boolean) If `true`, the page has at least + * one foreign reference (i.e. a bookmark), so the page should + * be kept and its frecency updated. + * @return (Promise) + */ +var cleanupPages = Task.async(function*(db, pages) { + yield invalidateFrecencies(db, pages.filter(p => p.hasForeign || p.hasVisits).map(p => p.id)); + + let pageIdsToRemove = pages.filter(p => !p.hasForeign && !p.hasVisits).map(p => p.id); + if (pageIdsToRemove.length > 0) { + let idsList = sqlList(pageIdsToRemove); + // Note, we are already in a transaction, since callers create it. + // Check relations regardless, to avoid creating orphans in case of + // async race conditions. + yield db.execute(`DELETE FROM moz_places WHERE id IN ( ${ idsList } ) + AND foreign_count = 0 AND last_visit_date ISNULL`); + // Hosts accumulated during the places delete are updated through a trigger + // (see nsPlacesTriggers.h). + yield db.executeCached(`DELETE FROM moz_updatehosts_temp`); + + // Expire orphans. + yield db.executeCached(` + DELETE FROM moz_favicons WHERE NOT EXISTS + (SELECT 1 FROM moz_places WHERE favicon_id = moz_favicons.id)`); + yield db.execute(`DELETE FROM moz_annos + WHERE place_id IN ( ${ idsList } )`); + yield db.execute(`DELETE FROM moz_inputhistory + WHERE place_id IN ( ${ idsList } )`); + } +}); + +/** + * Notify observers that pages have been removed/updated. + * + * @param db: (Sqlite connection) + * The database. + * @param pages: (Array of objects) + * Pages that have been touched and that need cleaning up. + * Each object should have the following properties: + * - id: (number) The `moz_places` identifier for the place. + * - hasVisits: (boolean) If `true`, there remains at least one + * visit to this page, so the page should be kept and its + * frecency updated. + * - hasForeign: (boolean) If `true`, the page has at least + * one foreign reference (i.e. a bookmark), so the page should + * be kept and its frecency updated. + * @return (Promise) + */ +var notifyCleanup = Task.async(function*(db, pages) { + let notifiedCount = 0; + let observers = PlacesUtils.history.getObservers(); + + let reason = Ci.nsINavHistoryObserver.REASON_DELETED; + + for (let page of pages) { + let uri = NetUtil.newURI(page.url.href); + let guid = page.guid; + if (page.hasVisits) { + // For the moment, we do not have the necessary observer API + // to notify when we remove a subset of visits, see bug 937560. + continue; + } + if (page.hasForeign) { + // We have removed all visits, but the page is still alive, e.g. + // because of a bookmark. + notify(observers, "onDeleteVisits", + [uri, /* last visit*/0, guid, reason, -1]); + } else { + // The page has been entirely removed. + notify(observers, "onDeleteURI", + [uri, guid, reason]); + } + if (++notifiedCount % NOTIFICATION_CHUNK_SIZE == 0) { + // Every few notifications, yield time back to the main + // thread to avoid jank. + yield Promise.resolve(); + } + } +}); + +/** + * Notify an `onResult` callback of a set of operations + * that just took place. + * + * @param data: (Array) + * The data to send to the callback. + * @param onResult: (function [optional]) + * If provided, call `onResult` with `data[0]`, `data[1]`, etc. + * Otherwise, do nothing. + */ +var notifyOnResult = Task.async(function*(data, onResult) { + if (!onResult) { + return; + } + let notifiedCount = 0; + for (let info of data) { + try { + onResult(info); + } catch (ex) { + // Errors should be reported but should not stop the operation. + Promise.reject(ex); + } + if (++notifiedCount % ONRESULT_CHUNK_SIZE == 0) { + // Every few notifications, yield time back to the main + // thread to avoid jank. + yield Promise.resolve(); + } + } +}); + +// Inner implementation of History.removeVisitsByFilter. +var removeVisitsByFilter = Task.async(function*(db, filter, onResult = null) { + // 1. Determine visits that took place during the interval. Note + // that the database uses microseconds, while JS uses milliseconds, + // so we need to *1000 one way and /1000 the other way. + let conditions = []; + let args = {}; + if ("beginDate" in filter) { + conditions.push("v.visit_date >= :begin * 1000"); + args.begin = Number(filter.beginDate); + } + if ("endDate" in filter) { + conditions.push("v.visit_date <= :end * 1000"); + args.end = Number(filter.endDate); + } + if ("limit" in filter) { + args.limit = Number(filter.limit); + } + + let optionalJoin = ""; + if ("url" in filter) { + let url = filter.url; + if (url instanceof Ci.nsIURI) { + url = filter.url.spec; + } else { + url = new URL(url).href; + } + optionalJoin = `JOIN moz_places h ON h.id = v.place_id`; + conditions.push("h.url_hash = hash(:url)", "h.url = :url"); + args.url = url; + } + + + let visitsToRemove = []; + let pagesToInspect = new Set(); + let onResultData = onResult ? [] : null; + + yield db.executeCached( + `SELECT v.id, place_id, visit_date / 1000 AS date, visit_type FROM moz_historyvisits v + ${optionalJoin} + WHERE ${ conditions.join(" AND ") }${ args.limit ? " LIMIT :limit" : "" }`, + args, + row => { + let id = row.getResultByName("id"); + let place_id = row.getResultByName("place_id"); + visitsToRemove.push(id); + pagesToInspect.add(place_id); + + if (onResult) { + onResultData.push({ + date: new Date(row.getResultByName("date")), + transition: row.getResultByName("visit_type") + }); + } + } + ); + + try { + if (visitsToRemove.length == 0) { + // Nothing to do + return false; + } + + let pages = []; + yield db.executeTransaction(function*() { + // 2. Remove all offending visits. + yield db.execute(`DELETE FROM moz_historyvisits + WHERE id IN (${ sqlList(visitsToRemove) } )`); + + // 3. Find out which pages have been orphaned + yield db.execute( + `SELECT id, url, guid, + (foreign_count != 0) AS has_foreign, + (last_visit_date NOTNULL) as has_visits + FROM moz_places + WHERE id IN (${ sqlList([...pagesToInspect]) })`, + null, + row => { + let page = { + id: row.getResultByName("id"), + guid: row.getResultByName("guid"), + hasForeign: row.getResultByName("has_foreign"), + hasVisits: row.getResultByName("has_visits"), + url: new URL(row.getResultByName("url")), + }; + pages.push(page); + }); + + // 4. Clean up and notify + yield cleanupPages(db, pages); + }); + + notifyCleanup(db, pages); + notifyOnResult(onResultData, onResult); // don't wait + } finally { + // Ensure we cleanup embed visits, even if we bailed out early. + PlacesUtils.history.clearEmbedVisits(); + } + + return visitsToRemove.length != 0; +}); + + +// Inner implementation of History.remove. +var remove = Task.async(function*(db, {guids, urls}, onResult = null) { + // 1. Find out what needs to be removed + let query = + `SELECT id, url, guid, foreign_count, title, frecency + FROM moz_places + WHERE guid IN (${ sqlList(guids) }) + OR (url_hash IN (${ urls.map(u => "hash(" + JSON.stringify(u) + ")").join(",") }) + AND url IN (${ sqlList(urls) })) + `; + + let onResultData = onResult ? [] : null; + let pages = []; + let hasPagesToRemove = false; + yield db.execute(query, null, Task.async(function*(row) { + let hasForeign = row.getResultByName("foreign_count") != 0; + if (!hasForeign) { + hasPagesToRemove = true; + } + let id = row.getResultByName("id"); + let guid = row.getResultByName("guid"); + let url = row.getResultByName("url"); + let page = { + id, + guid, + hasForeign, + hasVisits: false, + url: new URL(url), + }; + pages.push(page); + if (onResult) { + onResultData.push({ + guid: guid, + title: row.getResultByName("title"), + frecency: row.getResultByName("frecency"), + url: new URL(url) + }); + } + })); + + try { + if (pages.length == 0) { + // Nothing to do + return false; + } + + yield db.executeTransaction(function*() { + // 2. Remove all visits to these pages. + yield db.execute(`DELETE FROM moz_historyvisits + WHERE place_id IN (${ sqlList(pages.map(p => p.id)) }) + `); + + // 3. Clean up and notify + yield cleanupPages(db, pages); + }); + + notifyCleanup(db, pages); + 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."}) + } + } + }); + }); +}); |