diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/places/History.sys.mjs | 1741 |
1 files changed, 1741 insertions, 0 deletions
diff --git a/toolkit/components/places/History.sys.mjs b/toolkit/components/places/History.sys.mjs new file mode 100644 index 0000000000..3f43ca5a53 --- /dev/null +++ b/toolkit/components/places/History.sys.mjs @@ -0,0 +1,1741 @@ +/* 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/. */ + +/** + * 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. + * - description: (string) + * The description of the page, if any. + * - previewImageURL: (URL) + * or (nsIURI) + * or (string) + * The preview image URL of 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. + * - annotations: (Map) + * A map containing key/value pairs of the annotations 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 PlacesObservers. To listen to such + * notifications you must register using + * PlacesObservers `addListener` and `removeListener` methods. + * @see PlacesObservers + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "asyncHistory", + "@mozilla.org/browser/history;1", + "mozIAsyncHistory" +); + +/** + * Whenever we update 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 ONRESULT_CHUNK_SIZE = 300; + +// This constant determines the maximum number of remove pages before we cycle. +const REMOVE_PAGES_CHUNKLEN = 300; + +export var History = Object.freeze({ + ANNOTATION_EXPIRE_NEVER: 4, + // Constants for the type of annotation. + ANNOTATION_TYPE_STRING: 3, + ANNOTATION_TYPE_INT64: 5, + + /** + * Fetch the available information for one page. + * + * @param {URL|nsIURI|string} guidOrURI: (string) or (URL, nsIURI or href) + * Either the full URI of the page or the GUID of the page. + * @param {object} [options] + * An optional object whose properties describe options: + * - `includeVisits` (boolean) set this to true if `visits` in the + * PageInfo needs to contain VisitInfo in a reverse chronological order. + * By default, `visits` is undefined inside the returned `PageInfo`. + * - `includeMeta` (boolean) set this to true to fetch page meta fields, + * i.e. `description`, `site_name` and `preview_image_url`. + * - `includeAnnotations` (boolean) set this to true to fetch any + * annotations that are associated with 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 the VisitInfo objects returned while fetching visits do not + * contain the property `referrer`. + * TODO: Add `referrer` to VisitInfo. See Bug #1365913. + * @note the visits returned will not contain `TRANSITION_EMBED` visits. + * + * @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(guidOrURI, options = {}) { + // First, normalize to guid or string, and throw if not possible + guidOrURI = lazy.PlacesUtils.normalizeToURLOrGUID(guidOrURI); + + // See if options exists and make sense + if (!options || typeof options !== "object") { + throw new TypeError("options should be an object and not null"); + } + + let hasIncludeVisits = "includeVisits" in options; + if (hasIncludeVisits && typeof options.includeVisits !== "boolean") { + throw new TypeError("includeVisits should be a boolean if exists"); + } + + let hasIncludeMeta = "includeMeta" in options; + if (hasIncludeMeta && typeof options.includeMeta !== "boolean") { + throw new TypeError("includeMeta should be a boolean if exists"); + } + + let hasIncludeAnnotations = "includeAnnotations" in options; + if ( + hasIncludeAnnotations && + typeof options.includeAnnotations !== "boolean" + ) { + throw new TypeError("includeAnnotations should be a boolean if exists"); + } + + return lazy.PlacesUtils.promiseDBConnection().then(db => + fetch(db, guidOrURI, options) + ); + }, + + /** + * Fetches all pages which have one or more of the specified annotations. + * + * @param annotations: An array of strings containing the annotation names to + * find. + * @return (Promise) + * A promise resolved once the operation is complete. + * @resolves (Map) + * A Map containing the annotations, pages and their contents, e.g. + * Map("anno1" => [{page, content}, {page, content}]), "anno2" => ....); + * @rejects (Error) XXX + * Rejects if the insert was unsuccessful. + */ + fetchAnnotatedPages(annotations) { + // See if options exists and make sense + if (!annotations || !Array.isArray(annotations)) { + throw new TypeError("annotations should be an Array and not null"); + } + if (annotations.some(name => typeof name !== "string")) { + throw new TypeError("all annotation values should be strings"); + } + + return lazy.PlacesUtils.promiseDBConnection().then(db => + fetchAnnotatedPages(db, annotations) + ); + }, + + /** + * Fetch multiple pages. + * + * @param {string[]|nsIURI[]|URL[]} guidOrURIs: Array of href or URLs to fetch. + * @returns {Promise} + * A promise resolved once the operation is complete. + * @resolves {Map<string, string>} Map of PageInfos, keyed by the input info, + * either guid or href. We don't use nsIURI or URL as keys to avoid + * complexity, in all the cases the caller knows which objects is handling, + * and can unwrap them. Unknown input pages will have no entry in the Map. + * @throws (Error) + * If input is invalid, for example not a valid GUID or URL. + */ + fetchMany(guidOrURIs) { + if (!Array.isArray(guidOrURIs)) { + throw new TypeError("Input is not an array"); + } + // First, normalize to guid or URL, throw if not possible + guidOrURIs = guidOrURIs.map(v => lazy.PlacesUtils.normalizeToURLOrGUID(v)); + return lazy.PlacesUtils.promiseDBConnection().then(db => + fetchMany(db, guidOrURIs) + ); + }, + + /** + * Adds a number of visits for a single page. + * + * Any change may be observed through PlacesObservers. + * + * @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 (@see nsNavHistory::CanAddURI). + * @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(pageInfo) { + let info = lazy.PlacesUtils.validatePageInfo(pageInfo); + + return lazy.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 PlacesObservers. + * + * @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 (@see nsNavHistory::CanAddURI). + * @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(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 = lazy.PlacesUtils.validatePageInfo(pageInfo); + infos.push(info); + } + + return lazy.PlacesUtils.withConnectionWrapper( + "History.jsm: insertMany", + db => insertMany(db, infos, onResult, onError) + ); + }, + + /** + * Remove pages from the database. + * + * Any change may be observed through PlacesObservers. + * + * + * @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(pages, onResult = null) { + // Normalize and type-check arguments + if (Array.isArray(pages)) { + if (!pages.length) { + 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 = lazy.PlacesUtils.normalizeToURLOrGUID(page); + if (typeof normalized === "string") { + guids.push(normalized); + } else { + urls.push(normalized.href); + } + } + + // 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 (async function () { + let removedPages = false; + let count = 0; + while (guids.length || urls.length) { + if (count && count % 2 == 0) { + // Every few cycles, yield time back to the main + // thread to avoid jank. + await Promise.resolve(); + } + count++; + let guidsSlice = guids.splice(0, REMOVE_PAGES_CHUNKLEN); + let urlsSlice = []; + if (guidsSlice.length < REMOVE_PAGES_CHUNKLEN) { + urlsSlice = urls.splice(0, REMOVE_PAGES_CHUNKLEN - guidsSlice.length); + } + + let pages = { guids: guidsSlice, urls: urlsSlice }; + + let result = await lazy.PlacesUtils.withConnectionWrapper( + "History.jsm: remove", + db => remove(db, pages, onResult) + ); + + removedPages = removedPages || result; + } + return removedPages; + })(); + }, + + /** + * Remove visits matching specific characteristics. + * + * Any change may be observed through PlacesObservers. + * + * @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 + * - transition: (Integer) + * The type of the transition (see TRANSITIONS below) + * 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(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; + let hasTransition = "transition" in filter; + if (hasBeginDate) { + this.ensureDate(filter.beginDate); + } + if (hasEndDate) { + this.ensureDate(filter.endDate); + } + if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) { + throw new TypeError("`beginDate` should be at least as old as `endDate`"); + } + if (hasTransition && !this.isValidTransition(filter.transition)) { + throw new TypeError("`transition` should be valid"); + } + if ( + !hasBeginDate && + !hasEndDate && + !hasURL && + !hasLimit && + !hasTransition + ) { + throw new TypeError("Expected a non-empty filter"); + } + + if ( + hasURL && + !URL.isInstance(filter.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 lazy.PlacesUtils.withConnectionWrapper( + "History.jsm: removeVisitsByFilter", + db => removeVisitsByFilter(db, filter, onResult) + ); + }, + + /** + * Remove pages from the database based on a filter. + * + * Any change may be observed through PlacesObservers + * + * + * @param filter: An object containing a non empty subset of the following + * properties: + * - host: (string) + * Hostname with or without subhost. Examples: + * "mozilla.org" removes pages from mozilla.org but not its subdomains + * ".mozilla.org" removes pages from mozilla.org and its subdomains + * "." removes local files + * - beginDate: (Date) + * The first time the page was visited (inclusive) + * - endDate: (Date) + * The last time the page was visited (inclusive) + * @param [optional] onResult: (function(PageInfo)) + * A callback invoked for each page found. + * + * @note This removes pages with at least one visit inside the timeframe. + * Any visits outside the timeframe will also be removed with the page. + * @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 `filter` does not have the expected type, in particular + * if the `object` is empty, or its components do not satisfy the + * criteria given above + */ + removeByFilter(filter, onResult) { + if (!filter || typeof filter !== "object") { + throw new TypeError("Expected a filter object"); + } + + let hasHost = filter.host; + if (hasHost) { + if (typeof filter.host !== "string") { + throw new TypeError("`host` should be a string"); + } + filter.host = filter.host.toLowerCase(); + if (filter.host.length > 1 && filter.host.lastIndexOf(".") == 0) { + // The input contains only an initial period, thus it may be a + // wildcarded local host, like ".localhost". Ideally the consumer should + // pass just "localhost", because there is no concept of subhosts for + // it, but we are being more lenient to allow for simpler input. + // Anyway, in this case we remove the wildcard to avoid clearing too + // much if the consumer wrongly passes in things like ".com". + filter.host = filter.host.slice(1); + } + } + + let hasBeginDate = "beginDate" in filter; + if (hasBeginDate) { + this.ensureDate(filter.beginDate); + } + + let hasEndDate = "endDate" in filter; + if (hasEndDate) { + this.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 && !hasHost) { + throw new TypeError("Expected a non-empty filter"); + } + + // Check the host format. + // Either it has no dots, or has multiple dots, or it's a single dot char. + if ( + hasHost && + (!/^(\.?([.a-z0-9-]+\.[a-z0-9-]+)?|[a-z0-9-]+)$/.test(filter.host) || + filter.host.includes("..")) + ) { + throw new TypeError( + "Expected well formed hostname string for `host` with atmost 1 wildcard." + ); + } + + if (onResult && typeof onResult != "function") { + throw new TypeError("Invalid function: " + onResult); + } + + return lazy.PlacesUtils.withConnectionWrapper( + "History.jsm: removeByFilter", + db => removeByFilter(db, filter, onResult) + ); + }, + + /** + * Determine if a page has been visited. + * + * @param guidOrURI: (string) or (URL, nsIURI or href) + * Either 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 `guidOrURI` has an unexpected type or if a string provided + * is neither not a valid GUID nor a valid URI. + */ + hasVisits(guidOrURI) { + // Quick fallback to the cpp version. + if (guidOrURI instanceof Ci.nsIURI) { + return new Promise(resolve => { + lazy.asyncHistory.isURIVisited(guidOrURI, (aURI, aIsVisited) => { + resolve(aIsVisited); + }); + }); + } + + guidOrURI = lazy.PlacesUtils.normalizeToURLOrGUID(guidOrURI); + let isGuid = typeof guidOrURI == "string"; + let sqlFragment = isGuid + ? "guid = :val" + : "url_hash = hash(:val) AND url = :val "; + + return lazy.PlacesUtils.promiseDBConnection().then(async db => { + let rows = await db.executeCached( + `SELECT 1 FROM moz_places + WHERE ${sqlFragment} + AND last_visit_date NOTNULL`, + { val: isGuid ? guidOrURI : guidOrURI.href } + ); + return !!rows.length; + }); + }, + + /** + * Clear all history. + * + * @return (Promise) + * A promise resolved once the operation is complete. + */ + clear() { + return lazy.PlacesUtils.withConnectionWrapper("History.jsm: clear", clear); + }, + + /** + * Is a value a valid transition type? + * + * @param transition: (String) + * @return (Boolean) + */ + isValidTransition(transition) { + return Object.values(History.TRANSITIONS).includes(transition); + }, + + /** + * Throw if an object is not a Date object. + */ + ensureDate(arg) { + if ( + !arg || + typeof arg != "object" || + arg.constructor.name != "Date" || + isNaN(arg) + ) { + throw new TypeError("Expected a valid Date, got " + arg); + } + }, + + /** + * Update information for a page. + * + * Currently, it supports updating the description, preview image URL and annotations + * for a page, any other fields will be ignored. + * + * Note that this function will ignore the update if the target page has not + * yet been stored in the database. `History.fetch` could be used to check + * whether the page and its meta information exist or not. Beware that + * fetch&update might fail as they are not executed in a single transaction. + * + * @param pageInfo: (PageInfo) + * pageInfo must contain a URL of the target page. It will be ignored + * if a valid page `guid` is also provided. + * + * If a property `description` is provided, the description of the + * page is updated. Note that: + * 1). An empty string or null `description` will clear the existing + * value in the database. + * 2). Descriptions longer than DB_DESCRIPTION_LENGTH_MAX will be + * truncated. + * + * If a property `siteName` is provided, the site name of the + * page is updated. Note that: + * 1). An empty string or null `siteName` will clear the existing + * value in the database. + * 2). Descriptions longer than DB_SITENAME_LENGTH_MAX will be + * truncated. + * + * If a property `previewImageURL` is provided, the preview image + * URL of the page is updated. Note that: + * 1). A null `previewImageURL` will clear the existing value in the + * database. + * 2). It throws if its length is greater than DB_URL_LENGTH_MAX + * defined in PlacesUtils.jsm. + * + * If a property `annotations` is provided, the annotations will be + * updated. Note that: + * 1). It should be a Map containing key/value pairs to be updated. + * 2). If the value is falsy, the annotation will be removed. + * 3). If the value is non-falsy, the annotation will be added or updated. + * For `annotations` the keys must all be strings, the values should be + * Boolean, Number or Strings. null and undefined are supported as falsy values. + * + * @return (Promise) + * A promise resolved once the update is complete. + * @rejects (Error) + * Rejects if the update was unsuccessful. + * + * @throws (Error) + * If `pageInfo` has an unexpected type. + * @throws (Error) + * If `pageInfo` has an invalid `url` or an invalid `guid`. + * @throws (Error) + * If `pageInfo` has neither `description` nor `previewImageURL`. + * @throws (Error) + * If the length of `pageInfo.previewImageURL` is greater than + * DB_URL_LENGTH_MAX defined in PlacesUtils.jsm. + */ + update(pageInfo) { + let info = lazy.PlacesUtils.validatePageInfo(pageInfo, false); + + if ( + info.description === undefined && + info.siteName === undefined && + info.previewImageURL === undefined && + info.annotations === undefined + ) { + throw new TypeError( + "pageInfo object must at least have either a description, siteName, previewImageURL or annotations property." + ); + } + + return lazy.PlacesUtils.withConnectionWrapper("History.jsm: update", db => + update(db, info) + ); + }, + + /** + * 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, + }, +}); + +/** + * Convert a PageInfo object into the format expected by updatePlaces. + * + * Note: this assumes that the PageInfo object has already been validated + * via PlacesUtils.validatePageInfo. + * + * @param pageInfo: (PageInfo) + * @return (info) + */ +function convertForUpdatePlaces(pageInfo) { + let info = { + guid: pageInfo.guid, + uri: lazy.PlacesUtils.toURI(pageInfo.url), + title: pageInfo.title, + visits: [], + }; + + for (let inVisit of pageInfo.visits) { + let visit = { + visitDate: lazy.PlacesUtils.toPRTime(inVisit.date), + transitionType: inVisit.transition, + referrerURI: inVisit.referrer + ? lazy.PlacesUtils.toURI(inVisit.referrer) + : undefined, + }; + info.visits.push(visit); + } + return info; +} + +// Inner implementation of History.clear(). +var clear = async function (db) { + await db.executeTransaction(async function () { + // Since all metadata must be removed, remove it before pages, to save on + // foreign key delete cascading. + await db.execute("DELETE FROM moz_places_metadata"); + + // Remove all non-bookmarked places entries first, this will speed up the + // triggers work. + await db.execute(`DELETE FROM moz_places WHERE foreign_count = 0`); + await db.execute(`DELETE FROM moz_updateoriginsdelete_temp`); + + // Expire orphan icons. + await db.executeCached(`DELETE FROM moz_pages_w_icons + WHERE page_url_hash NOT IN (SELECT url_hash FROM moz_places)`); + await removeOrphanIcons(db); + + // Expire annotations. + await db.execute(`DELETE FROM moz_annos WHERE NOT EXISTS ( + SELECT 1 FROM moz_places WHERE id = place_id + )`); + + // Expire inputhistory. + await db.execute(`DELETE FROM moz_inputhistory WHERE place_id IN ( + SELECT i.place_id FROM moz_inputhistory i + LEFT JOIN moz_places h ON h.id = i.place_id + WHERE h.id IS NULL)`); + + // Remove all history. + await db.execute("DELETE FROM moz_historyvisits"); + }); + + PlacesObservers.notifyListeners([new PlacesHistoryCleared()]); +}; + +/** + * 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 = async function (db, pages) { + let pagesToRemove = pages.filter(p => !p.hasForeign && !p.hasVisits); + if (!pagesToRemove.length) { + return; + } + + // Note, we are already in a transaction, since callers create it. + // Check relations regardless, to avoid creating orphans in case of + // async race conditions. + for (let chunk of lazy.PlacesUtils.chunkArray( + pagesToRemove, + db.variableLimit + )) { + let idsToRemove = chunk.map(p => p.id); + await db.execute( + `DELETE FROM moz_places + WHERE id IN ( ${lazy.PlacesUtils.sqlBindPlaceholders(idsToRemove)} ) + AND foreign_count = 0 AND last_visit_date ISNULL`, + idsToRemove + ); + + // Expire orphans. + let hashesToRemove = chunk.map(p => p.hash); + await db.executeCached( + `DELETE FROM moz_pages_w_icons + WHERE page_url_hash IN (${lazy.PlacesUtils.sqlBindPlaceholders( + hashesToRemove + )})`, + hashesToRemove + ); + + await db.execute( + `DELETE FROM moz_annos + WHERE place_id IN ( ${lazy.PlacesUtils.sqlBindPlaceholders( + idsToRemove + )} )`, + idsToRemove + ); + await db.execute( + `DELETE FROM moz_inputhistory + WHERE place_id IN ( ${lazy.PlacesUtils.sqlBindPlaceholders( + idsToRemove + )} )`, + idsToRemove + ); + } + // Hosts accumulated during the places delete are updated through a trigger + // (see nsPlacesTriggers.h). + await db.executeCached(`DELETE FROM moz_updateoriginsdelete_temp`); + + await removeOrphanIcons(db); +}; + +/** + * Remove icons whose origin is not in moz_origins, unless referenced. + * @param db: (Sqlite connection) + * The database. + */ +function removeOrphanIcons(db) { + return db.executeCached(` + DELETE FROM moz_icons WHERE id IN ( + SELECT id FROM moz_icons WHERE root = 0 + UNION ALL + SELECT id FROM moz_icons + WHERE root = 1 + AND get_host_and_port(icon_url) NOT IN (SELECT host FROM moz_origins) + AND fixup_url(get_host_and_port(icon_url)) NOT IN (SELECT host FROM moz_origins) + EXCEPT + SELECT icon_id FROM moz_icons_to_pages + )`); +} + +/** + * 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. + * @param transitionType: (Number) + * Set to a valid TRANSITIONS value to indicate all transitions of a + * certain type have been removed, otherwise defaults to 0 (unknown value). + * @return (Promise) + */ +var notifyCleanup = async function (db, pages, transitionType = 0) { + const notifications = []; + + for (let page of pages) { + const isRemovedFromStore = !page.hasVisits && !page.hasForeign; + notifications.push( + new PlacesVisitRemoved({ + url: page.url.href, + pageGuid: page.guid, + reason: PlacesVisitRemoved.REASON_DELETED, + transitionType, + isRemovedFromStore, + isPartialVisistsRemoval: !isRemovedFromStore && page.hasVisits > 0, + }) + ); + } + + PlacesObservers.notifyListeners(notifications); +}; + +/** + * 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 = 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. + await Promise.resolve(); + } + } +}; + +// Inner implementation of History.fetch. +var fetch = async function (db, guidOrURL, options) { + let whereClauseFragment = ""; + let params = {}; + if (URL.isInstance(guidOrURL)) { + whereClauseFragment = "WHERE h.url_hash = hash(:url) AND h.url = :url"; + params.url = guidOrURL.href; + } else { + whereClauseFragment = "WHERE h.guid = :guid"; + params.guid = guidOrURL; + } + + let visitSelectionFragment = ""; + let joinFragment = ""; + let visitOrderFragment = ""; + if (options.includeVisits) { + visitSelectionFragment = ", v.visit_date, v.visit_type"; + joinFragment = "JOIN moz_historyvisits v ON h.id = v.place_id"; + visitOrderFragment = "ORDER BY v.visit_date DESC"; + } + + let pageMetaSelectionFragment = ""; + if (options.includeMeta) { + pageMetaSelectionFragment = ", description, site_name, preview_image_url"; + } + + let query = `SELECT h.id, guid, url, title, frecency + ${pageMetaSelectionFragment} ${visitSelectionFragment} + FROM moz_places h ${joinFragment} + ${whereClauseFragment} + ${visitOrderFragment}`; + let pageInfo = null; + let placeId = null; + await db.executeCached(query, params, row => { + if (pageInfo === null) { + // This means we're on the first row, so we need to get all the page info. + pageInfo = { + guid: row.getResultByName("guid"), + url: new URL(row.getResultByName("url")), + frecency: row.getResultByName("frecency"), + title: row.getResultByName("title") || "", + }; + placeId = row.getResultByName("id"); + } + if (options.includeMeta) { + pageInfo.description = row.getResultByName("description") || ""; + pageInfo.siteName = row.getResultByName("site_name") || ""; + let previewImageURL = row.getResultByName("preview_image_url"); + pageInfo.previewImageURL = previewImageURL + ? new URL(previewImageURL) + : null; + } + if (options.includeVisits) { + // On every row (not just the first), we need to collect visit data. + if (!("visits" in pageInfo)) { + pageInfo.visits = []; + } + let date = lazy.PlacesUtils.toDate(row.getResultByName("visit_date")); + let transition = row.getResultByName("visit_type"); + + // TODO: Bug #1365913 add referrer URL to the `VisitInfo` data as well. + pageInfo.visits.push({ date, transition }); + } + }); + + // Only try to get annotations if requested, and if there's an actual page found. + if (pageInfo && options.includeAnnotations) { + let rows = await db.executeCached( + ` + SELECT n.name, a.content FROM moz_anno_attributes n + JOIN moz_annos a ON n.id = a.anno_attribute_id + WHERE a.place_id = :placeId + `, + { placeId } + ); + + pageInfo.annotations = new Map( + rows.map(row => [ + row.getResultByName("name"), + row.getResultByName("content"), + ]) + ); + } + return pageInfo; +}; + +// Inner implementation of History.fetchAnnotatedPages. +var fetchAnnotatedPages = async function (db, annotations) { + let result = new Map(); + let rows = await db.execute( + ` + SELECT n.name, h.url, a.content FROM moz_anno_attributes n + JOIN moz_annos a ON n.id = a.anno_attribute_id + JOIN moz_places h ON h.id = a.place_id + WHERE n.name IN (${new Array(annotations.length).fill("?").join(",")}) + `, + annotations + ); + + for (let row of rows) { + let uri; + try { + uri = new URL(row.getResultByName("url")); + } catch (ex) { + console.error("Invalid URL read from database in fetchAnnotatedPages"); + continue; + } + + let anno = { + uri, + content: row.getResultByName("content"), + }; + let annoName = row.getResultByName("name"); + let pageAnnos = result.get(annoName); + if (!pageAnnos) { + pageAnnos = []; + result.set(annoName, pageAnnos); + } + pageAnnos.push(anno); + } + + return result; +}; + +// Inner implementation of History.fetchMany. +var fetchMany = async function (db, guidOrURLs) { + let resultsMap = new Map(); + for (let chunk of lazy.PlacesUtils.chunkArray(guidOrURLs, db.variableLimit)) { + let urls = []; + let guids = []; + for (let v of chunk) { + if (URL.isInstance(v)) { + urls.push(v); + } else { + guids.push(v); + } + } + let wheres = []; + let params = []; + if (urls.length) { + wheres.push(` + ( + url_hash IN(${lazy.PlacesUtils.sqlBindPlaceholders( + urls, + "hash(", + ")" + )}) AND + url IN(${lazy.PlacesUtils.sqlBindPlaceholders(urls)}) + )`); + let hrefs = urls.map(u => u.href); + params = [...params, ...hrefs, ...hrefs]; + } + if (guids.length) { + wheres.push(`guid IN(${lazy.PlacesUtils.sqlBindPlaceholders(guids)})`); + params = [...params, ...guids]; + } + + let rows = await db.executeCached( + ` + SELECT h.id, guid, url, title, frecency + FROM moz_places h + WHERE ${wheres.join(" OR ")} + `, + params + ); + for (let row of rows) { + let pageInfo = { + guid: row.getResultByName("guid"), + url: new URL(row.getResultByName("url")), + frecency: row.getResultByName("frecency"), + title: row.getResultByName("title") || "", + }; + if (guidOrURLs.includes(pageInfo.guid)) { + resultsMap.set(pageInfo.guid, pageInfo); + } else { + resultsMap.set(pageInfo.url.href, pageInfo); + } + } + } + return resultsMap; +}; + +// Inner implementation of History.removeVisitsByFilter. +var removeVisitsByFilter = 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 = {}; + let transition = -1; + 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); + } + if ("transition" in filter) { + conditions.push("v.visit_type = :transition"); + args.transition = filter.transition; + transition = filter.transition; + } + + 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; + + await 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"), + }); + } + } + ); + + if (!visitsToRemove.length) { + // Nothing to do + return false; + } + + let pages = []; + await db.executeTransaction(async function () { + // 2. Remove all offending visits. + for (let chunk of lazy.PlacesUtils.chunkArray( + visitsToRemove, + db.variableLimit + )) { + await db.execute( + `DELETE FROM moz_historyvisits + WHERE id IN (${lazy.PlacesUtils.sqlBindPlaceholders(chunk)})`, + chunk + ); + } + + // 3. Find out which pages have been orphaned + for (let chunk of lazy.PlacesUtils.chunkArray( + [...pagesToInspect], + db.variableLimit + )) { + await db.execute( + `SELECT id, url, url_hash, guid, + (foreign_count != 0) AS has_foreign, + (last_visit_date NOTNULL) as has_visits + FROM moz_places + WHERE id IN (${lazy.PlacesUtils.sqlBindPlaceholders(chunk)})`, + chunk, + 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")), + hash: row.getResultByName("url_hash"), + }; + pages.push(page); + } + ); + } + + // 4. Clean up and notify + await cleanupPages(db, pages); + }); + + notifyCleanup(db, pages, transition); + notifyOnResult(onResultData, onResult); // don't wait + + return !!visitsToRemove.length; +}; + +// Inner implementation of History.removeByFilter +var removeByFilter = async function (db, filter, onResult = null) { + // 1. Create fragment for date filtration + let dateFilterSQLFragment = ""; + let conditions = []; + let params = {}; + if ("beginDate" in filter) { + conditions.push("v.visit_date >= :begin"); + params.begin = lazy.PlacesUtils.toPRTime(filter.beginDate); + } + if ("endDate" in filter) { + conditions.push("v.visit_date <= :end"); + params.end = lazy.PlacesUtils.toPRTime(filter.endDate); + } + + if (conditions.length !== 0) { + dateFilterSQLFragment = `EXISTS + (SELECT id FROM moz_historyvisits v WHERE v.place_id = h.id AND + ${conditions.join(" AND ")} + LIMIT 1)`; + } + + // 2. Create fragment for host and subhost filtering + let hostFilterSQLFragment = ""; + if (filter.host) { + // There are four cases that we need to consider: + // mozilla.org, .mozilla.org, localhost, and local files + let revHost = filter.host.split("").reverse().join(""); + if (filter.host == ".") { + // Local files. + hostFilterSQLFragment = `h.rev_host = :revHost`; + } else if (filter.host.startsWith(".")) { + // Remove the subhost wildcard. + revHost = revHost.slice(0, -1); + hostFilterSQLFragment = `h.rev_host between :revHost || "." and :revHost || "/"`; + } else { + // This covers non-wildcarded hosts (e.g.: mozilla.org, localhost) + hostFilterSQLFragment = `h.rev_host = :revHost || "."`; + } + params.revHost = revHost; + } + + // 3. Find out what needs to be removed + let fragmentArray = [hostFilterSQLFragment, dateFilterSQLFragment]; + let query = `SELECT h.id, url, url_hash, rev_host, guid, title, frecency, foreign_count + FROM moz_places h WHERE + (${fragmentArray.filter(f => f !== "").join(") AND (")})`; + let onResultData = onResult ? [] : null; + let pages = []; + let hasPagesToRemove = false; + + await db.executeCached(query, params, 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), + hash: row.getResultByName("url_hash"), + }; + pages.push(page); + if (onResult) { + onResultData.push({ + guid, + title: row.getResultByName("title"), + frecency: row.getResultByName("frecency"), + url: new URL(url), + }); + } + }); + + if (pages.length === 0) { + // Nothing to do + return false; + } + + await db.executeTransaction(async function () { + // 4. Actually remove visits + let pageIds = pages.map(p => p.id); + for (let chunk of lazy.PlacesUtils.chunkArray(pageIds, db.variableLimit)) { + await db.execute( + `DELETE FROM moz_historyvisits + WHERE place_id IN(${lazy.PlacesUtils.sqlBindPlaceholders(chunk)})`, + chunk + ); + } + // 5. Clean up and notify + await cleanupPages(db, pages); + }); + + notifyCleanup(db, pages); + notifyOnResult(onResultData, onResult); + + return hasPagesToRemove; +}; + +// Inner implementation of History.remove. +var remove = async function (db, { guids, urls }, onResult = null) { + // 1. Find out what needs to be removed + let onResultData = onResult ? [] : null; + let pages = []; + let hasPagesToRemove = false; + function onRow(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), + hash: row.getResultByName("url_hash"), + }; + pages.push(page); + if (onResult) { + onResultData.push({ + guid, + title: row.getResultByName("title"), + frecency: row.getResultByName("frecency"), + url: new URL(url), + }); + } + } + for (let chunk of lazy.PlacesUtils.chunkArray(guids, db.variableLimit)) { + let query = `SELECT id, url, url_hash, guid, foreign_count, title, frecency + FROM moz_places + WHERE guid IN (${lazy.PlacesUtils.sqlBindPlaceholders(guids)}) + `; + await db.execute(query, chunk, onRow); + } + for (let chunk of lazy.PlacesUtils.chunkArray(urls, db.variableLimit)) { + // Make an array of variables like `["?1", "?2", ...]`, up to the length of + // the chunk. This lets us bind each URL once, reusing the binding for the + // `url_hash IN (...)` and `url IN (...)` clauses. We add 1 because indexed + // parameters start at 1, not 0. + let variables = Array.from( + { length: chunk.length }, + (_, i) => "?" + (i + 1) + ); + let query = `SELECT id, url, url_hash, guid, foreign_count, title, frecency + FROM moz_places + WHERE url_hash IN (${variables.map(v => `hash(${v})`).join(",")}) AND + url IN (${variables.join(",")}) + `; + await db.execute(query, chunk, onRow); + } + + if (!pages.length) { + // Nothing to do + return false; + } + + await db.executeTransaction(async function () { + // 2. Remove all visits to these pages. + let pageIds = pages.map(p => p.id); + for (let chunk of lazy.PlacesUtils.chunkArray(pageIds, db.variableLimit)) { + await db.execute( + `DELETE FROM moz_historyvisits + WHERE place_id IN (${lazy.PlacesUtils.sqlBindPlaceholders(chunk)})`, + chunk + ); + } + + // 3. Clean up and notify + await cleanupPages(db, pages); + }); + + notifyCleanup(db, pages); + notifyOnResult(onResultData, onResult); // don't wait + + 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; + pageInfo.title = updateInfo.title; + if (!pageInfo.url) { + pageInfo.url = URL.fromURI(updateInfo.uri); + pageInfo.title = updateInfo.title; + pageInfo.placeId = updateInfo.placeId; + pageInfo.visits = updateInfo.visits.map(visit => { + return { + visitId: visit.visitId, + date: lazy.PlacesUtils.toDate(visit.visitDate), + transition: visit.transitionType, + referrer: visit.referrerURI ? URL.fromURI(visit.referrerURI) : null, + }; + }); + } + return pageInfo; +} + +// Inner implementation of History.insert. +var insert = function (db, pageInfo) { + let info = convertForUpdatePlaces(pageInfo); + + return new Promise((resolve, reject) => { + lazy.asyncHistory.updatePlaces(info, { + handleError: error => { + reject(error); + }, + handleResult: result => { + pageInfo = mergeUpdateInfoIntoPageInfo(result, pageInfo); + }, + handleCompletion: () => { + resolve(pageInfo); + }, + }); + }); +}; + +// Inner implementation of History.insertMany. +var insertMany = 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) => { + lazy.asyncHistory.updatePlaces(infos, { + handleError: (resultCode, result) => { + let pageInfo = mergeUpdateInfoIntoPageInfo(result); + onErrorData.push(pageInfo); + }, + handleResult: result => { + let pageInfo = mergeUpdateInfoIntoPageInfo(result); + onResultData.push(pageInfo); + }, + ignoreErrors: !onError, + ignoreResults: !onResult, + handleCompletion: updatedCount => { + notifyOnResult(onResultData, onResult); + notifyOnResult(onErrorData, onError); + if (updatedCount > 0) { + resolve(); + } else { + reject({ message: "No items were added to history." }); + } + }, + }); + }); +}; + +// Inner implementation of History.update. +var update = async function (db, pageInfo) { + // Check for page existence first; we can skip most of the work if it doesn't + // exist and anyway we'll need the place id multiple times later. + // Prefer GUID over url if it's present. + let id; + if (typeof pageInfo.guid === "string") { + let rows = await db.executeCached( + "SELECT id FROM moz_places WHERE guid = :guid", + { guid: pageInfo.guid } + ); + id = rows.length ? rows[0].getResultByName("id") : null; + } else { + let rows = await db.executeCached( + "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url", + { url: pageInfo.url.href } + ); + id = rows.length ? rows[0].getResultByName("id") : null; + } + if (!id) { + return; + } + + let updateFragments = []; + let params = {}; + if ("description" in pageInfo) { + updateFragments.push("description"); + params.description = pageInfo.description; + } + if ("siteName" in pageInfo) { + updateFragments.push("site_name"); + params.site_name = pageInfo.siteName; + } + if ("previewImageURL" in pageInfo) { + updateFragments.push("preview_image_url"); + params.preview_image_url = pageInfo.previewImageURL + ? pageInfo.previewImageURL.href + : null; + } + if (updateFragments.length) { + // Since this data may be written at every visit and is textual, avoid + // overwriting the existing record if it didn't change. + await db.execute( + ` + UPDATE moz_places + SET ${updateFragments.map(v => `${v} = :${v}`).join(", ")} + WHERE id = :id + AND (${updateFragments + .map(v => `IFNULL(${v}, '') <> IFNULL(:${v}, '')`) + .join(" OR ")}) + `, + { id, ...params } + ); + } + + if (pageInfo.annotations) { + let annosToRemove = []; + let annosToUpdate = []; + + for (let anno of pageInfo.annotations) { + anno[1] ? annosToUpdate.push(anno[0]) : annosToRemove.push(anno[0]); + } + + await db.executeTransaction(async function () { + if (annosToUpdate.length) { + await db.execute( + ` + INSERT OR IGNORE INTO moz_anno_attributes (name) + VALUES ${Array.from(annosToUpdate.keys()) + .map(k => `(:${k})`) + .join(", ")} + `, + Object.assign({}, annosToUpdate) + ); + + for (let anno of annosToUpdate) { + let content = pageInfo.annotations.get(anno); + // TODO: We only really need to save the type whilst we still support + // accessing page annotations via the annotation service. + let type = + typeof content == "string" + ? History.ANNOTATION_TYPE_STRING + : History.ANNOTATION_TYPE_INT64; + let date = lazy.PlacesUtils.toPRTime(new Date()); + + // This will replace the id every time an annotation is updated. This is + // not currently an issue as we're not joining on the id field. + await db.execute( + ` + INSERT OR REPLACE INTO moz_annos + (place_id, anno_attribute_id, content, flags, + expiration, type, dateAdded, lastModified) + VALUES (:id, + (SELECT id FROM moz_anno_attributes WHERE name = :anno_name), + :content, 0, :expiration, :type, :date_added, + :last_modified) + `, + { + id, + anno_name: anno, + content, + expiration: History.ANNOTATION_EXPIRE_NEVER, + type, + // The date fields are unused, so we just set them both to the latest. + date_added: date, + last_modified: date, + } + ); + } + } + + for (let anno of annosToRemove) { + // We don't remove anything from the moz_anno_attributes table. If we + // delete the last item of a given name, that item really should go away. + // It will be cleaned up by expiration. + await db.execute( + ` + DELETE FROM moz_annos + WHERE place_id = :id + AND anno_attribute_id = + (SELECT id FROM moz_anno_attributes WHERE name = :anno_name) + `, + { id, anno_name: anno } + ); + } + }); + } +}; |