1
0
Fork 0
firefox/browser/components/places/metadataViewer/interactionsViewer.js
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

674 lines
18 KiB
JavaScript

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