summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/PlacesQuery.sys.mjs
blob: 8c3fd0372fb46a7e89196d5eddf6e806159e83ba (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
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,
      });
    }
  }
}