Files
tubestation/toolkit/components/places/PlacesQuery.sys.mjs

218 lines
6.7 KiB
JavaScript

/* -*- 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,
});
}
}
}