218 lines
6.7 KiB
JavaScript
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,
|
|
});
|
|
}
|
|
}
|
|
}
|