diff options
Diffstat (limited to 'toolkit/components/places/PlacesQuery.sys.mjs')
-rw-r--r-- | toolkit/components/places/PlacesQuery.sys.mjs | 210 |
1 files changed, 210 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesQuery.sys.mjs b/toolkit/components/places/PlacesQuery.sys.mjs new file mode 100644 index 0000000000..8c3fd0372f --- /dev/null +++ b/toolkit/components/places/PlacesQuery.sys.mjs @@ -0,0 +1,210 @@ +/* -*- 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + requestIdleCallback: "resource://gre/modules/Timer.sys.mjs", +}); +XPCOMUtils.defineLazyModuleGetters(lazy, { + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +function isRedirectType(visitType) { + const { TRANSITIONS } = lazy.PlacesUtils.history; + return ( + visitType === TRANSITIONS.REDIRECT_PERMANENT || + visitType === TRANSITIONS.REDIRECT_TEMPORARY + ); +} + +const BULK_PLACES_EVENTS_THRESHOLD = 50; + +/** + * An object that contains details of a page visit. + * + * @typedef {object} HistoryVisit + * + * @property {Date} date + * When this page was visited. + * @property {number} id + * Visit ID from the database. + * @property {string} title + * The page's title. + * @property {string} url + * The page's URL. + */ + +/** + * 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 HistoryVisit[] */ + #cachedHistory = null; + /** @type object */ + #cachedHistoryOptions = null; + /** @type function(PlacesEvent[]) */ + #historyListener = null; + /** @type function(HistoryVisit[]) */ + #historyListenerCallback = 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. + * @returns {HistoryVisit[]} + * History visits obtained from the database query. + */ + async getHistory({ daysOld = 60 } = {}) { + const options = { daysOld }; + const cacheInvalid = + this.#cachedHistory == null || + !lazy.ObjectUtils.deepEqual(options, this.#cachedHistoryOptions); + if (cacheInvalid) { + this.#cachedHistory = []; + this.#cachedHistoryOptions = options; + const db = await lazy.PlacesUtils.promiseDBConnection(); + const sql = `SELECT v.id, visit_date, title, url, visit_type, from_visit, hidden + FROM moz_historyvisits v + JOIN moz_places h + ON v.place_id = h.id + WHERE visit_date >= (strftime('%s','now','localtime','start of day','-${Number( + daysOld + )} days','utc') * 1000000) + ORDER BY visit_date DESC`; + const rows = await db.executeCached(sql); + let lastUrl; // Avoid listing consecutive visits to the same URL. + let lastRedirectFromVisitId; // Avoid listing redirecting visits. + for (const row of rows) { + const [id, visitDate, title, url, visitType, fromVisit, hidden] = + Array.from({ length: row.numEntries }, (_, i) => + row.getResultByIndex(i) + ); + if (isRedirectType(visitType) && fromVisit > 0) { + lastRedirectFromVisitId = fromVisit; + } + if (!hidden && url !== lastUrl && id !== lastRedirectFromVisitId) { + this.#cachedHistory.push({ + date: lazy.PlacesUtils.toDate(visitDate), + id, + title, + url, + }); + lastUrl = url; + } + } + } + if (!this.#historyListener) { + this.#initHistoryListener(); + } + return this.#cachedHistory; + } + + /** + * 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(HistoryVisit[])} 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.#cachedHistory = null; + this.#cachedHistoryOptions = null; + PlacesObservers.removeListener( + ["page-removed", "page-visited", "history-cleared", "page-title-changed"], + this.#historyListener + ); + this.#historyListener = null; + this.#historyListenerCallback = null; + } + + /** + * Listen for changes to the visits table and update caches accordingly. + */ + #initHistoryListener() { + 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.#cachedHistory = null; + } else if (this.#cachedHistory != null) { + for (const event of events) { + switch (event.type) { + case "page-visited": + await this.#handlePageVisited(event); + break; + case "history-cleared": + this.#cachedHistory = []; + break; + case "page-title-changed": + this.#cachedHistory + .filter(({ url }) => url === event.url) + .forEach(visit => (visit.title = event.title)); + break; + } + } + } + if (typeof this.#historyListenerCallback === "function") { + lazy.requestIdleCallback(async () => { + const history = await this.getHistory(this.#cachedHistoryOptions); + this.#historyListenerCallback(history); + }); + } + }; + PlacesObservers.addListener( + ["page-removed", "page-visited", "history-cleared", "page-title-changed"], + this.#historyListener + ); + } + + /** + * Handle a page visited event. + * + * @param {PlacesEvent} event + * The event. + */ + async #handlePageVisited(event) { + const lastVisit = this.#cachedHistory[0]; + if ( + lastVisit != null && + (event.url === lastVisit.url || + (isRedirectType(event.transitionType) && + event.referringVisitId === lastVisit.id)) + ) { + // Remove the last visit if it duplicates this visit's URL, or if it + // redirects to this visit. + this.#cachedHistory.shift(); + } + if (!event.hidden) { + this.#cachedHistory.unshift({ + date: new Date(event.visitTime), + id: event.visitId, + title: event.lastKnownTitle, + url: event.url, + }); + } + } +} |