summaryrefslogtreecommitdiffstats
path: root/browser/components/places/metadataViewer/interactionsViewer.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/places/metadataViewer/interactionsViewer.js')
-rw-r--r--browser/components/places/metadataViewer/interactionsViewer.js427
1 files changed, 427 insertions, 0 deletions
diff --git a/browser/components/places/metadataViewer/interactionsViewer.js b/browser/components/places/metadataViewer/interactionsViewer.js
new file mode 100644
index 0000000000..a6a3a0e4c1
--- /dev/null
+++ b/browser/components/places/metadataViewer/interactionsViewer.js
@@ -0,0 +1,427 @@
+/* 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/. */
+
+/* eslint-env module */
+
+const { Interactions } = ChromeUtils.importESModule(
+ "resource:///modules/Interactions.sys.mjs"
+);
+const { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+const { PlacesDBUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesDBUtils.sys.mjs"
+);
+
+/**
+ * Base class for the table display. Handles table layout and updates.
+ */
+class TableViewer {
+ /**
+ * Maximum number of rows to display by default.
+ *
+ * @type {number}
+ */
+ maxRows = 100;
+
+ /**
+ * The number of rows that we last filled in on the table. This allows
+ * tracking to know when to clear unused rows.
+ *
+ * @type {number}
+ */
+ #lastFilledRows = 0;
+
+ /**
+ * A map of columns that are displayed by default. This is set by sub-classes.
+ *
+ * - The key is the column name in the database.
+ * - The header is the column header on the table.
+ * - The modifier is a function to modify the returned value from the database
+ * for display.
+ * - includeTitle determines if the title attribute should be set on that
+ * column, for tooltips, e.g. if an element is likely to overflow.
+ *
+ * @type {Map<string, object>}
+ */
+ columnMap;
+
+ /**
+ * A reference for the current interval timer, if any.
+ *
+ * @type {number}
+ */
+ #timer;
+
+ /**
+ * Starts the display of the table. Setting up the table display and doing
+ * an initial output. Also starts the interval timer.
+ */
+ async start() {
+ this.setupUI();
+ await this.updateDisplay();
+ this.#timer = setInterval(this.updateDisplay.bind(this), 10000);
+ }
+
+ /**
+ * Pauses updates for this table, use start() to re-start.
+ */
+ pause() {
+ if (this.#timer) {
+ clearInterval(this.#timer);
+ this.#timer = null;
+ }
+ }
+
+ /**
+ * Creates the initial table layout and sets the styles to match the number
+ * of columns.
+ */
+ setupUI() {
+ document.getElementById("title").textContent = this.title;
+
+ let viewer = document.getElementById("tableViewer");
+ viewer.textContent = "";
+
+ // Set up the table styles.
+ let existingStyle = document.getElementById("tableStyle");
+ let numColumns = this.columnMap.size;
+ let styleText = `
+#tableViewer {
+ display: grid;
+ grid-template-columns: ${this.cssGridTemplateColumns}
+}
+
+/* Sets the first row of elements to bold. The number is the number of columns */
+#tableViewer > div:nth-child(-n+${numColumns}) {
+ font-weight: bold;
+ white-space: break-spaces;
+}
+
+/* Highlights every other row to make visual scanning of the table easier.
+ The numbers need to be adapted if the number of columns changes. */
+`;
+ for (let i = numColumns + 1; i <= numColumns * 2 - 1; i++) {
+ styleText += `#tableViewer > div:nth-child(${numColumns}n+${i}):nth-child(${
+ numColumns * 2
+ }n+${i}),\n`;
+ }
+ styleText += `#tableViewer > div:nth-child(${numColumns}n+${
+ numColumns * 2
+ }):nth-child(${numColumns * 2}n+${numColumns * 2})\n
+{
+ background: var(--in-content-box-background-odd);
+}`;
+ existingStyle.innerText = styleText;
+
+ // Now set up the table itself with empty cells, this avoids having to
+ // create and delete rows all the time.
+ let tableBody = document.createDocumentFragment();
+ let header = document.createDocumentFragment();
+ for (let details of this.columnMap.values()) {
+ let columnDiv = document.createElement("div");
+ columnDiv.textContent = details.header;
+ header.appendChild(columnDiv);
+ }
+ tableBody.appendChild(header);
+
+ for (let i = 0; i < this.maxRows; i++) {
+ let row = document.createDocumentFragment();
+ for (let j = 0; j < this.columnMap.size; j++) {
+ row.appendChild(document.createElement("div"));
+ }
+ tableBody.appendChild(row);
+ }
+ viewer.appendChild(tableBody);
+
+ let limit = document.getElementById("tableLimit");
+ limit.textContent = `Maximum rows displayed: ${this.maxRows}.`;
+
+ this.#lastFilledRows = 0;
+ }
+
+ /**
+ * Displays the provided data in the table.
+ *
+ * @param {object[]} rows
+ * An array of rows to display. The rows are objects with the values for
+ * the rows being the keys of the columnMap.
+ */
+ displayData(rows) {
+ if (gCurrentHandler != this) {
+ /* Data is no more relevant for the current view. */
+ return;
+ }
+ let viewer = document.getElementById("tableViewer");
+ let index = this.columnMap.size;
+ for (let row of rows) {
+ for (let [column, details] of this.columnMap.entries()) {
+ let value = row[column];
+
+ if (details.includeTitle) {
+ viewer.children[index].setAttribute("title", value);
+ }
+
+ viewer.children[index].textContent = details.modifier
+ ? details.modifier(value)
+ : value;
+
+ index++;
+ }
+ }
+ let numRows = rows.length;
+ if (numRows < this.#lastFilledRows) {
+ for (let r = numRows; r < this.#lastFilledRows; r++) {
+ for (let c = 0; c < this.columnMap.size; c++) {
+ viewer.children[index].textContent = "";
+ viewer.children[index].removeAttribute("title");
+ index++;
+ }
+ }
+ }
+ this.#lastFilledRows = numRows;
+ }
+}
+
+/**
+ * Viewer definition for the page metadata.
+ */
+const metadataHandler = new (class extends TableViewer {
+ title = "Interactions";
+ cssGridTemplateColumns =
+ "max-content fit-content(100%) repeat(6, min-content) fit-content(100%);";
+
+ /**
+ * @see TableViewer.columnMap
+ */
+ columnMap = new Map([
+ ["id", { header: "ID" }],
+ ["url", { header: "URL", includeTitle: true }],
+ [
+ "updated_at",
+ {
+ header: "Updated",
+ modifier: updatedAt => new Date(updatedAt).toLocaleString(),
+ },
+ ],
+ [
+ "total_view_time",
+ {
+ header: "View Time (s)",
+ modifier: totalViewTime => (totalViewTime / 1000).toFixed(2),
+ },
+ ],
+ [
+ "typing_time",
+ {
+ header: "Typing Time (s)",
+ modifier: typingTime => (typingTime / 1000).toFixed(2),
+ },
+ ],
+ ["key_presses", { header: "Key Presses" }],
+ [
+ "scrolling_time",
+ {
+ header: "Scroll Time (s)",
+ modifier: scrollingTime => (scrollingTime / 1000).toFixed(2),
+ },
+ ],
+ ["scrolling_distance", { header: "Scroll Distance (pixels)" }],
+ ["referrer", { header: "Referrer", includeTitle: true }],
+ ]);
+
+ /**
+ * A reference to the database connection.
+ *
+ * @type {mozIStorageConnection}
+ */
+ #db = null;
+
+ async #getRows(query, columns = [...this.columnMap.keys()]) {
+ if (!this.#db) {
+ this.#db = await PlacesUtils.promiseDBConnection();
+ }
+ let rows = await this.#db.executeCached(query);
+ return rows.map(r => {
+ let result = {};
+ for (let column of columns) {
+ result[column] = r.getResultByName(column);
+ }
+ return result;
+ });
+ }
+
+ /**
+ * Loads the current metadata from the database and updates the display.
+ */
+ async updateDisplay() {
+ let rows = await this.#getRows(
+ `SELECT m.id AS id, h.url AS url, updated_at, total_view_time,
+ typing_time, key_presses, scrolling_time, scrolling_distance, h2.url as referrer
+ FROM moz_places_metadata m
+ JOIN moz_places h ON h.id = m.place_id
+ LEFT JOIN moz_places h2 ON h2.id = m.referrer_place_id
+ ORDER BY updated_at DESC
+ LIMIT ${this.maxRows}`
+ );
+ this.displayData(rows);
+ }
+
+ export() {
+ // Export all data. We only export place_id and not url so users can share their exports
+ // without revealing the sites they have been visiting.
+ return this.#getRows(
+ `SELECT
+ m.id,
+ m.place_id,
+ m.referrer_place_id,
+ h.origin_id,
+ m.updated_at,
+ m.total_view_time,
+ h.visit_count,
+ h.frecency,
+ m.typing_time,
+ m.key_presses,
+ m.scrolling_time,
+ m.scrolling_distance,
+ vall.visit_dates,
+ vall.visit_types
+ FROM moz_places_metadata m
+ JOIN moz_places h ON h.id = m.place_id
+ JOIN
+ (SELECT
+ place_id,
+ group_concat(visit_date, ',') AS visit_dates,
+ group_concat(visit_type, ',') AS visit_types
+ FROM moz_historyvisits
+ GROUP BY place_id
+ ORDER BY visit_date DESC
+ ) vall ON vall.place_id = m.place_id
+ ORDER BY m.place_id DESC
+ `,
+ [
+ "id",
+ "place_id",
+ "referrer_place_id",
+ "origin_id",
+ "updated_at",
+ "total_view_time",
+ "visit_count",
+ "frecency",
+ "typing_time",
+ "key_presses",
+ "scrolling_time",
+ "scrolling_distance",
+ "visit_dates",
+ "visit_types",
+ ]
+ );
+ }
+})();
+
+/**
+ * Viewer definition for the Places database stats.
+ */
+const placesStatsHandler = new (class extends TableViewer {
+ title = "Places Database Statistics";
+ cssGridTemplateColumns = "fit-content(100%) repeat(5, max-content);";
+
+ /**
+ * @see TableViewer.columnMap
+ */
+ columnMap = new Map([
+ ["entity", { header: "Entity" }],
+ ["count", { header: "Count" }],
+ [
+ "sizeBytes",
+ {
+ header: "Size (KiB)",
+ modifier: c => c / 1024,
+ },
+ ],
+ [
+ "sizePerc",
+ {
+ header: "Size (Perc.)",
+ },
+ ],
+ [
+ "efficiencyPerc",
+ {
+ header: "Space Eff. (Perc.)",
+ },
+ ],
+ [
+ "sequentialityPerc",
+ {
+ header: "Sequentiality (Perc.)",
+ },
+ ],
+ ]);
+
+ /**
+ * Loads the current metadata from the database and updates the display.
+ */
+ async updateDisplay() {
+ let data = await PlacesDBUtils.getEntitiesStatsAndCounts();
+ this.displayData(data);
+ }
+})();
+
+function checkPrefs() {
+ if (
+ !Services.prefs.getBoolPref("browser.places.interactions.enabled", false)
+ ) {
+ let warning = document.getElementById("enabledWarning");
+ warning.hidden = false;
+ }
+}
+
+function show(selectedButton) {
+ let currentButton = document.querySelector(".category.selected");
+ if (currentButton == selectedButton) {
+ return;
+ }
+
+ gCurrentHandler.pause();
+ currentButton.classList.remove("selected");
+ selectedButton.classList.add("selected");
+ switch (selectedButton.getAttribute("value")) {
+ case "metadata":
+ (gCurrentHandler = metadataHandler).start();
+ metadataHandler.start();
+ break;
+ case "places-stats":
+ (gCurrentHandler = placesStatsHandler).start();
+ break;
+ }
+}
+
+function setupListeners() {
+ let menu = document.getElementById("categories");
+ menu.addEventListener("click", e => {
+ if (e.target && e.target.parentNode == menu) {
+ show(e.target);
+ }
+ });
+ document.getElementById("export").addEventListener("click", async e => {
+ e.preventDefault();
+ const data = await metadataHandler.export();
+
+ const blob = new Blob([JSON.stringify(data)], {
+ type: "text/json;charset=utf-8",
+ });
+ const a = document.createElement("a");
+ a.setAttribute("download", `places-${Date.now()}.json`);
+ a.setAttribute("href", window.URL.createObjectURL(blob));
+ a.click();
+ a.remove();
+ });
+}
+
+checkPrefs();
+// Set the initial handler here.
+let gCurrentHandler = metadataHandler;
+gCurrentHandler.start().catch(console.error);
+setupListeners();