/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BinarySearch: "resource://gre/modules/BinarySearch.sys.mjs", BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", }); const BULK_PLACES_EVENTS_THRESHOLD = 50; const OBSERVER_DEBOUNCE_RATE_MS = 500; const OBSERVER_DEBOUNCE_TIMEOUT_MS = 5000; /** * An object that contains details of a page visit. * * @typedef {object} HistoryVisit * * @property {Date} date * When this page was visited. * @property {string} title * The page's title. * @property {string} url * The page's URL. * @property {string} guid * The page's GUID. */ /** * Cache key type depends on how visits are currently being grouped. * * By date: number - The start of day timestamp of the visit. * By site: string - The domain name of the visit. * * @typedef {number | string} CacheKey */ /** * Sorting by date or site, cache is stored as: (Date/Site) => List of Visits. * Sorting by date and site, cache is stored as: (Date) => (Site) => List of Visits. * Sorting by last visited, cache is stored as: List of Visits. * * @typedef {Map | Map> | HistoryVisit[]} CachedHistory */ /** * Types returnable from the observer. * * @typedef {PlacesVisitRemoved | PlacesVisit | PlacesHistoryCleared | PlacesVisitTitle} PlacesEventObserved */ /** * Queries the places database using an async read only connection. Maintains * an internal cache of query results which is live-updated by adding listeners * to `PlacesObservers`. When the results are no longer needed, call `close` to * remove the listeners. */ export class PlacesQuery { /** @type {HistoryCache} */ #cache = null; /** @type {object} */ cachedHistoryOptions = null; /** @type {function(PlacesEventObserved[]): any} */ #historyListener = null; /** @type {function(CachedHistory): any} */ #historyListenerCallback = null; /** @type {DeferredTask} */ #historyObserverTask = null; /** * Indicates whether this query is closed. When closed, caches should not be * populated, and observers should not be instantiated. It can be reopened by * calling `initializeCache()`. * * @type {boolean} */ #isClosed = false; #searchInProgress = false; get cachedHistory() { return this.#cache?.data ?? null; } /** * Get a snapshot of history visits at this moment. * * @param {object} [options] * Options to apply to the database query. * @param {number} [options.daysOld] * The maximum number of days to go back in history. * @param {number} [options.limit] * The maximum number of visits to return. * @param {Values} [options.sortBy] * The sorting order of history visits. See SORT_BY. * @returns {Promise} * History visits obtained from the database query. */ async getHistory({ daysOld = 60, limit, sortBy = SORT_BY.DATE } = {}) { const options = { daysOld, limit, sortBy }; const cacheInvalid = this.cachedHistory == null || !lazy.ObjectUtils.deepEqual(options, this.cachedHistoryOptions); if (cacheInvalid) { this.initializeCache(options); await this.fetchHistory(); } if (!this.#historyListener && !this.#isClosed) { this.#initHistoryListener(); } return this.cachedHistory; } /** * Clear existing cache and store options for the new query. * * @param {object} options * The database query options. */ initializeCache(options = this.cachedHistoryOptions) { this.#cache = new HistoryCache(options.sortBy, this); this.cachedHistoryOptions = options; this.#isClosed = false; } /** * Run the database query and populate the history cache. */ async fetchHistory() { const { daysOld, limit, sortBy } = this.cachedHistoryOptions; const db = await lazy.PlacesUtils.promiseDBConnection(); let groupBy; switch (sortBy) { case SORT_BY.DATE: case SORT_BY.DATESITE: groupBy = "url, date(visit_date / 1000000, 'unixepoch', 'localtime')"; break; case SORT_BY.SITE: case SORT_BY.LAST_VISITED: groupBy = "url"; break; } const whereClause = daysOld == Infinity ? "" : `WHERE visit_date >= (strftime('%s','now','localtime','start of day','-${Number( daysOld )} days','utc') * 1000000)`; const sql = `SELECT MAX(visit_date) as visit_date, title, url, guid FROM moz_historyvisits v JOIN moz_places h ON v.place_id = h.id AND hidden = 0 ${whereClause} GROUP BY ${groupBy} ORDER BY visit_date DESC LIMIT ${limit > 0 ? limit : -1}`; const rows = await db.executeCached(sql); if (this.#isClosed) { // Do not cache visits if this instance is closed already. return; } for (const row of rows) { const visit = this.formatRowAsVisit(row); this.#cache.append(visit); } } /** * Search the database for visits matching a search query. This does not * affect internal caches, and observers will not be notified of search * results obtained from this query. * * @param {string} query * The search query. * @param {number} [limit] * The maximum number of visits to return. * @returns {Promise} * The matching visits. */ async searchHistory(query, limit) { const { sortBy } = this.cachedHistoryOptions; const db = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); let orderBy; switch (sortBy) { case SORT_BY.DATE: orderBy = "visit_date DESC"; break; case SORT_BY.SITE: orderBy = "url"; break; } const sql = `SELECT MAX(visit_date) as visit_date, title, url, guid FROM moz_historyvisits v JOIN moz_places h ON v.place_id = h.id WHERE AUTOCOMPLETE_MATCH(:query, url, title, NULL, 1, 1, 1, 1, :matchBehavior, :searchBehavior, NULL) AND hidden = 0 GROUP BY url ORDER BY ${orderBy} LIMIT ${limit > 0 ? limit : -1}`; if (this.#searchInProgress) { db.interrupt(); } try { this.#searchInProgress = true; const rows = await db.executeCached(sql, { query, matchBehavior: Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED, searchBehavior: Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY, }); return rows.map(row => this.formatRowAsVisit(row)); } finally { this.#searchInProgress = false; } } /** * Observe changes to the visits table. When changes are made, the callback * is given the new list of visits. Only one callback can be active at a time * (per instance). If one already exists, it will be replaced. * * @param {function(CachedHistory): any} callback * The function to call when changes are made. */ observeHistory(callback) { this.#historyListenerCallback = callback; } /** * Close this query. Caches are cleared and listeners are removed. */ close() { this.#isClosed = true; this.#cache = null; this.cachedHistoryOptions = null; if (this.#historyListener) { PlacesObservers.removeListener( [ "page-removed", "page-visited", "history-cleared", "page-title-changed", ], this.#historyListener ); } this.#historyListener = null; this.#historyListenerCallback = null; if (this.#historyObserverTask && !this.#historyObserverTask.isFinalized) { this.#historyObserverTask.disarm(); this.#historyObserverTask.finalize(); } } /** * Listen for changes to the visits table and update caches accordingly. */ #initHistoryListener() { this.#historyObserverTask = new lazy.DeferredTask( async () => { if (typeof this.#historyListenerCallback === "function") { const history = await this.getHistory(this.cachedHistoryOptions); this.#historyListenerCallback(history); } }, OBSERVER_DEBOUNCE_RATE_MS, OBSERVER_DEBOUNCE_TIMEOUT_MS ); this.#historyListener = async events => { if ( events.length >= BULK_PLACES_EVENTS_THRESHOLD || events.some(({ type }) => type === "page-removed") ) { // Accounting for cascading deletes, or handling places events in bulk, // can be expensive. In this case, we invalidate the cache once rather // than handling each event individually. this.#cache = null; } else if (this.cachedHistory != null) { for (const event of events) { switch (event.type) { case "page-visited": this.handlePageVisited(/** @type {PlacesVisit} */ (event)); break; case "history-cleared": this.initializeCache(); break; case "page-title-changed": this.handlePageTitleChanged( /** @type {PlacesVisitTitle} */ (event) ); break; } } } this.#historyObserverTask.arm(); }; PlacesObservers.addListener( ["page-removed", "page-visited", "history-cleared", "page-title-changed"], this.#historyListener ); } /** * Handle a page visited event. * * @param {PlacesVisit} event * The event. * @returns {HistoryVisit} * The visit that was inserted, or `null` if no visit was inserted. */ handlePageVisited(event) { if (event.hidden) { return null; } const visit = this.formatEventAsVisit(event); this.#cache.insertSorted(visit); return visit; } /** * Handle a page title changed event. * * @param {PlacesVisitTitle} event * The event. */ handlePageTitleChanged(event) { this.#cache.updateTitle(event.url, event.title); } /** * Get timestamp from a date by only considering its year, month, and date * (so that it can be used as a date-based key). * * @param {Date} date * The date to truncate. * @returns {number} * The corresponding timestamp. */ getStartOfDayTimestamp(date) { return new Date( date.getFullYear(), date.getMonth(), date.getDate() ).getTime(); } /** * Get timestamp from a date by only considering its year and month (so that * it can be used as a month-based key). * * @param {Date} date * The date to truncate. * @returns {number} * The corresponding timestamp. */ getStartOfMonthTimestamp(date) { return new Date(date.getFullYear(), date.getMonth()).getTime(); } /** * Format a database row as a history visit. * * @param {mozIStorageRow} row * The row to format. * @returns {HistoryVisit} * The resulting history visit. */ formatRowAsVisit(row) { return { date: lazy.PlacesUtils.toDate(row.getResultByName("visit_date")), // @ts-expect-error - Bug 1966462 title: row.getResultByName("title"), // @ts-expect-error - Bug 1966462 url: row.getResultByName("url"), // @ts-expect-error - Bug 1966462 guid: row.getResultByName("guid"), }; } /** * Format a page visited event as a history visit. * * @param {PlacesVisit} event * The event to format. * @returns {HistoryVisit} * The resulting history visit. */ formatEventAsVisit(event) { return { date: new Date(event.visitTime), title: event.lastKnownTitle, url: event.url, guid: event.pageGuid, }; } } const SORT_BY = Object.freeze({ /** * Group visits by calendar date. * * Cache structure: `Map` * - Key: Timestamp for visit date. * - Value: Array of visits that occured on that day, sorted newest first. */ DATE: "date", /** * Group visits first by date, then by website. * * Cache structure: `Map>` * - Outer key: Timestamp for visit date. * - Inner key: Formatted domain name (e.g. "example.com") * - Value: Array of visits to that site on that day, sorted newest first. */ DATESITE: "datesite", /** * Flat chronological list of all visits, ungrouped. * * Cache structure: `HistoryVisit[]` * - An array of all visits sorted by newest first. * - No grouping. */ LAST_VISITED: "lastvisited", /** * Group visits by website/domain. * * Cache structure: `Map` * - Key: Formatted domain name (e.g. "example.com", excludes "www.") * - Value: Array of visits to that site, sorted newest first. */ SITE: "site", }); /** * Cache storage and operations for history visits. * * Maintains two data structures: * - A primary cache organized by the sorting strategy. * - A secondary URL-indexed cache for fast duplicate detection and title * updates. */ class HistoryCache { /** @type {CachedHistory} */ #cache; /** @type {Map>} */ #urlCache = new Map(); /** @type {string} */ #sortBy; /** @type {PlacesQuery} */ #placesQuery; /** * * @param {string} sortBy * The sorting strategy. See SORT_BY. * @param {PlacesQuery} placesQuery * The PlacesQuery instance, required for date key generation. */ constructor(sortBy, placesQuery) { this.#sortBy = sortBy; this.#placesQuery = placesQuery; this.#cache = sortBy === SORT_BY.LAST_VISITED ? [] : new Map(); } /** * Required for iteration by consumers. * * @returns {CachedHistory} */ get data() { return this.#cache; } /** * Appends a visit to the end of its container. * * @param {HistoryVisit} visit * The visit to append. */ append(visit) { let container = this.#getContainerForVisit(visit); container.push(visit); this.#addUrlToCache(visit); } /** * Insert a visit into the correct position maintaining sorted order. * Handles duplicate detection and removal of older visits for the same URL. * * @param {HistoryVisit} visit * The visit to insert. * @returns {boolean} * true if the visit was inserted, false if it was rejected as a duplicate. */ insertSorted(visit) { let container = this.#getContainerForVisit(visit); if (!this.#handleDuplicate(visit, container)) { return false; } this.#insertSortedIntoContainer(visit, container); this.#addUrlToCache(visit); return true; } /** * Update the title for all cached visits matching the given URL. * * @param {string} url * The URL to update. * @param {string} title * The new title. */ updateTitle(url, title) { let visits = this.#urlCache.get(url); if (visits) { for (let visit of visits) { visit.title = title; } } } /** * Get the container array where a visit should be stored based on the sorting * strategy. * * @param {HistoryVisit} visit * The visit to find a container for. * @returns {HistoryVisit[]} * The container array where this visit should be stored. * @throws {Error} * If an unknown sortBy option is provided. */ #getContainerForVisit(visit) { switch (this.#sortBy) { case SORT_BY.LAST_VISITED: { return /**@type {HistoryVisit[]} */ (this.#cache); } case SORT_BY.DATE: { let dateKey = this.#placesQuery.getStartOfDayTimestamp(visit.date); return this.#getOrCreateContainer( /**@type {Map} */ (this.#cache), dateKey ); } case SORT_BY.SITE: { let siteKey = this.#getSiteKey(visit.url); return this.#getOrCreateContainer( /**@type {Map} */ (this.#cache), siteKey ); } case SORT_BY.DATESITE: { let dateKey = this.#placesQuery.getStartOfDayTimestamp(visit.date); let siteKey = this.#getSiteKey(visit.url); let typedCache = /**@type {Map>} */ ( this.#cache ); if (!typedCache.has(dateKey)) { typedCache.set(dateKey, new Map()); } let dateContainer = typedCache.get(dateKey); return this.#getOrCreateContainer(dateContainer, siteKey); } default: throw new Error(`Unknown sortBy option: ${this.#sortBy}`); } } /** * Get an existing container from cache or create a new one if it doesn't * exist. * * @param {Map} map * The map to search. * @param {CacheKey} key * The key to look up or create a container for. * @returns {HistoryVisit[]} * The existing or newly created container array. */ #getOrCreateContainer(map, key) { let container = map.get(key); if (!container) { container = []; map.set(key, container); } return container; } /** * Extract a site key from a URL for grouping purposes. * * @param {string} url * The URL to extract a site key from. * @returns {string} * The site key for grouping, or empty string if the URL is invalid or uses * unsupported protocols. */ #getSiteKey(url) { let protocol = URL.parse(url)?.protocol; // It could be worth caching the site key string to avoid recomputing it // multiple times for the same url. return protocol == "http:" || protocol == "https:" ? lazy.BrowserUtils.formatURIStringForDisplay(url) : ""; } /** * Add a visit to the URL-indexed cache. * * @param {HistoryVisit} visit * The visit to add. */ #addUrlToCache(visit) { let existing = this.#urlCache.get(visit.url); if (existing) { existing.add(visit); } else { this.#urlCache.set(visit.url, new Set([visit])); } } /** * Handle duplicate detection and removal. If an older visit for the same * URL exists in the container, it is removed. If a newer visit exists, * the new visit is rejected. * * @param {HistoryVisit} visit * The visit to check. * @param {HistoryVisit[]} container * The container to check within. */ #handleDuplicate(visit, container) { let existingVisitsForUrl = this.#urlCache.get(visit.url); if (!existingVisitsForUrl) { return true; } for (let existingVisit of existingVisitsForUrl) { if (container.includes(existingVisit)) { if (existingVisit.date.getTime() >= visit.date.getTime()) { return false; } container.splice(container.indexOf(existingVisit), 1); existingVisitsForUrl.delete(existingVisit); break; } } return true; } /** * Insert a visit into a container while maintaining descending chronological * order. * * @param {HistoryVisit} visit * The visit to insert. * @param {HistoryVisit[]} container * The container to insert into. */ #insertSortedIntoContainer(visit, container) { let insertionPoint = 0; if (visit.date.getTime() < container[0]?.date.getTime()) { insertionPoint = lazy.BinarySearch.insertionIndexOf( (a, b) => b.date.getTime() - a.date.getTime(), container, visit ); } container.splice(insertionPoint, 0, visit); } }