/* 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 { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); 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" ); const lazy = {}; ChromeUtils.defineLazyGetter(lazy, "PlacesFrecencyRecalculator", () => { return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( Ci.nsIObserver ).wrappedJSObject; }); /** * Methods of sorting. * * @readonly * @enum {SortingType} */ const SortingType = { ASCENDING: "ASC", DESCENDING: "DESC", }; /** * How to sort a table of values. * * @typedef SortSetting * * @property {string} column * Which column the table should be sorted by. * @property {SortingType} order * How order the sorting. */ /** * 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; /** * How the table should be sorted. If not provided, the view will not allow * sorting and default to the initial way the rows were pulled from the data * source. * * @type {SortSetting} */ sortSetting = null; /** * 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(--table-row-background-color-alternate); }`; 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 [key, details] of this.columnMap.entries()) { let columnDiv = document.createElement("div"); columnDiv.classList.add("column-title"); columnDiv.setAttribute("data-column-title", key); 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; this.updateDisplayedSort(); } updateDisplayedSort() { if (this.sortable) { let viewer = document.getElementById("tableViewer"); let element = viewer.querySelector( `[data-column-title="${this.sortSetting.column}"]` ); let symbolHolder = document.getElementById("column-title-sort-indicator"); if (!symbolHolder) { symbolHolder = document.createElement("span"); symbolHolder.style.marginLeft = "5px"; // Let the column header receive the click. symbolHolder.style.pointerEvents = "none"; symbolHolder.id = "column-title-sort-indicator"; } element.appendChild(symbolHolder); symbolHolder.textContent = this.sortSetting.order == SortingType.DESCENDING ? "\u2B07\uFE0F" : "\u2B06\uFE0F"; } } changeSort(column) { if (this.sortSetting.column == column) { this.sortSetting.order = this.sortSetting.order == SortingType.DESCENDING ? SortingType.ASCENDING : SortingType.DESCENDING; } else { this.sortSetting = { column, order: SortingType.DESCENDING }; } } get sortable() { return !!this.sortSetting; } } /** * 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 }], ]); sortSetting = { column: "updated_at", order: SortingType.DESCENDING }; /** * 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 ${this.sortSetting.column} ${this.sortSetting.order} LIMIT ${this.maxRows}` ); this.displayData(rows); } export(includeUrlAndTitle = false) { return this.#getRows( `SELECT m.id, ${includeUrlAndTitle ? "h.title," : ""} ${includeUrlAndTitle ? "h.url" : "m.place_id"}, m.updated_at, h.frecency, m.total_view_time, m.typing_time, m.key_presses, m.scrolling_time, m.scrolling_distance, ${includeUrlAndTitle ? "r.url AS referrer_url" : "m.referrer_place_id"}, ${includeUrlAndTitle ? "o.host" : "h.origin_id"}, h.visit_count, 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 JOIN moz_origins o ON h.origin_id = o.id LEFT JOIN moz_places r ON m.referrer_place_id = r.id ORDER BY m.place_id DESC `, [ "id", ...(includeUrlAndTitle ? ["title"] : []), includeUrlAndTitle ? "url" : "place_id", "updated_at", "frecency", "total_view_time", "typing_time", "key_presses", "scrolling_time", "scrolling_distance", includeUrlAndTitle ? "referrer_url" : "referrer_place_id", includeUrlAndTitle ? "host" : "origin_id", "visit_count", "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); } })(); /** * Places database with frecency scores. */ const placesViewerHandler = new (class extends TableViewer { title = "Places Viewer"; cssGridTemplateColumns = "fit-content(100%) repeat(6, min-content);"; #db = null; #maxRows = 100; /** * @see TableViewer.columnMap */ columnMap = new Map([ ["url", { header: "URL" }], ["title", { header: "Title" }], [ "last_visit_date", { header: "Last Visit Date", modifier: lastVisitDate => new Date(lastVisitDate / 1000).toLocaleString(), }, ], ["frecency", { header: "Frecency" }], [ "recalc_frecency", { header: "Recalc Frecency", }, ], [ "alt_frecency", { header: "Alt Frecency", }, ], [ "recalc_alt_frecency", { header: "Recalc Alt Frecency", }, ], ]); sortSetting = { column: "last_visit_date", order: SortingType.DESCENDING }; 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 url, title, last_visit_date, frecency, recalc_frecency, alt_frecency, recalc_alt_frecency FROM moz_places ORDER BY ${this.sortSetting.column} ${this.sortSetting.order} LIMIT ${this.#maxRows}` ); this.displayData(rows); } })(); 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; case "places-viewer": (gCurrentHandler = placesViewerHandler).start(); break; } } function createObjectURL(data, type) { // Downloading the Blob will throw errors in debug mode because the // principal is system and nsUrlClassifierDBService::lookup does not expect // a caller from this principal. Thus, we use the null principal. However, in // non-debug mode we'd rather not run eval and use the Javascript API. if (AppConstants.DEBUG) { let escapedData = data.replaceAll("'", "\\'").replaceAll("\n", "\\n"); let sb = new Cu.Sandbox(null, { wantGlobalProperties: ["Blob", "URL"] }); return Cu.evalInSandbox( `URL.createObjectURL(new Blob(['${escapedData}'], {type: '${type}'}))`, sb, "", null, 0, false ); } let blob = new Blob([data], { type, }); return window.URL.createObjectURL(blob); } function downloadFile(data, blobType, fileType) { const a = document.createElement("a"); a.setAttribute("download", `places-${Date.now()}.${fileType}`); a.setAttribute("href", createObjectURL(data, blobType)); a.click(); a.remove(); } async function getData() { let includeUrlAndTitle = document.getElementById("include-place-data").checked; return await metadataHandler.export(includeUrlAndTitle); } function setupListeners() { let menu = document.getElementById("categories"); menu.addEventListener("click", e => { if (e.target && e.target.parentNode == menu) { show(e.target); } }); document.getElementById("export-json").addEventListener("click", async e => { e.preventDefault(); const data = await getData(); downloadFile(JSON.stringify(data), "text/json;charset=utf-8", "json"); }); document.getElementById("export-csv").addEventListener("click", async e => { e.preventDefault(); const data = await getData(); // Convert Javascript to CSV string. let headers = Object.keys(data.at(0)); let rows = [ headers.join(","), ...data.map(obj => headers.map(field => JSON.stringify(obj[field] ?? "")).join(",") ), ]; rows = rows.join("\n"); downloadFile(rows, "text/csv", "csv"); }); // Allow users to force frecency to update instead of waiting for an idle // event. document .getElementById("recalc-alt-frecency") .addEventListener("click", async e => { e.preventDefault(); lazy.PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); }); document.getElementById("tableViewer").addEventListener("click", e => { if (gCurrentHandler.sortable && e.target.dataset.columnTitle) { gCurrentHandler.changeSort(e.target.dataset.columnTitle); gCurrentHandler.updateDisplay(); } }); } let gCurrentHandler; if ( Services.prefs.getBoolPref( "browser.places.interactions.viewer.enabled", false ) ) { document.body.classList.remove("hidden"); checkPrefs(); // Set the initial handler here. gCurrentHandler = metadataHandler; gCurrentHandler.start().catch(console.error); setupListeners(); }