Commit da8f8e5f authored by Jonathan Sudiaman's avatar Jonathan Sudiaman
Browse files

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

parent 5f18f085
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -296,6 +296,9 @@ var whitelist = [
  { file: "chrome://browser/content/screenshots/copy.svg" },
  { file: "chrome://browser/content/screenshots/download.svg" },
  { file: "chrome://browser/content/screenshots/download-white.svg" },

  // Bug 1824826 - Implement a view of history in Firefox View
  { file: "resource://gre/modules/PlacesQuery.sys.mjs" },
];

if (AppConstants.NIGHTLY_BUILD && AppConstants.platform != "win") {
+217 −0
Original line number Diff line number Diff line
/* -*- 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,
      });
    }
  }
}
+1 −0
Original line number Diff line number Diff line
@@ -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",
+100 −0
Original line number Diff line number Diff line
/* 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();
});
+1 −0
Original line number Diff line number Diff line
@@ -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]