/* 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} */ 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();