Source code

Revision control

Copy as Markdown

Other Tools

/* 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(
);
const { PlacesUtils } = ChromeUtils.importESModule(
);
const { PlacesDBUtils } = ChromeUtils.importESModule(
);
/**
* 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();