Bug 1816137 - Create async API to query for a list of browser history entries r=mak

Differential Revision: https://phabricator.services.mozilla.com/D171906
This commit is contained in:
Jonathan Sudiaman 2023-04-04 19:07:26 +00:00
Родитель 3076e0405f
Коммит a226fa315c
4 изменённых файлов: 319 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,217 @@
/* -*- 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,
});
}
}
}

Просмотреть файл

@ -68,6 +68,7 @@ if CONFIG["MOZ_PLACES"]:
"PlacesExpiration.sys.mjs",
"PlacesFrecencyRecalculator.sys.mjs",
"PlacesPreviews.sys.mjs",
"PlacesQuery.sys.mjs",
"PlacesSyncUtils.sys.mjs",
"PlacesTransactions.sys.mjs",
"PlacesUtils.sys.mjs",

Просмотреть файл

@ -0,0 +1,100 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const { PlacesQuery } = ChromeUtils.importESModule(
"resource://gre/modules/PlacesQuery.sys.mjs"
);
add_task(async function test_visits_cache_is_updated() {
const placesQuery = new PlacesQuery();
const now = new Date();
info("Insert the first visit.");
await PlacesUtils.history.insert({
url: "https://www.example.com/",
title: "Example Domain",
visits: [{ date: now }],
});
let history = await placesQuery.getHistory();
Assert.equal(history.length, 1);
Assert.equal(history[0].url, "https://www.example.com/");
Assert.equal(history[0].date.getTime(), now.getTime());
Assert.equal(history[0].title, "Example Domain");
info("Insert the next visit.");
let historyUpdated = PromiseUtils.defer();
placesQuery.observeHistory(newHistory => {
history = newHistory;
historyUpdated.resolve();
});
await PlacesUtils.history.insert({
url: "https://example.net/",
visits: [{ date: now }],
});
await historyUpdated.promise;
Assert.equal(history.length, 2);
Assert.equal(
history[0].url,
"https://example.net/",
"The most recent visit should come first."
);
Assert.equal(history[0].date.getTime(), now.getTime());
info("Remove the first visit.");
historyUpdated = PromiseUtils.defer();
await PlacesUtils.history.remove("https://www.example.com/");
await historyUpdated.promise;
Assert.equal(history.length, 1);
Assert.equal(history[0].url, "https://example.net/");
info("Remove all visits.");
historyUpdated = PromiseUtils.defer();
await PlacesUtils.history.clear();
await historyUpdated.promise;
Assert.equal(history.length, 0);
placesQuery.close();
});
add_task(async function test_filter_visits_by_age() {
const placesQuery = new PlacesQuery();
await PlacesUtils.history.insertMany([
{
url: "https://www.example.com/",
visits: [{ date: new Date("2000-01-01T12:00:00") }],
},
{
url: "https://example.net/",
visits: [{ date: new Date() }],
},
]);
const history = await placesQuery.getHistory({ daysOld: 1 });
Assert.equal(history.length, 1, "The older visit should be excluded.");
Assert.equal(history[0].url, "https://example.net/");
await PlacesUtils.history.clear();
placesQuery.close();
});
add_task(async function test_filter_redirecting_visits() {
const placesQuery = new PlacesQuery();
await PlacesUtils.history.insertMany([
{
url: "http://google.com/",
visits: [{ transition: PlacesUtils.history.TRANSITIONS.TYPED }],
},
{
url: "https://www.google.com/",
visits: [
{
transition: PlacesUtils.history.TRANSITIONS.REDIRECT_PERMANENT,
referrer: "http://google.com/",
},
],
},
]);
const history = await placesQuery.getHistory();
Assert.equal(history.length, 1, "Redirecting visits should be excluded.");
Assert.equal(history[0].url, "https://www.google.com/");
await PlacesUtils.history.clear();
placesQuery.close();
});

Просмотреть файл

@ -39,6 +39,7 @@ skip-if = os == "linux" # Bug 821781
[test_1105208.js]
[test_1105866.js]
[test_1606731.js]
[test_PlacesQuery_history.js]
[test_asyncExecuteLegacyQueries.js]
[test_async_transactions.js]
[test_autocomplete_match_fallbackTitle.js]