interactionsViewer.js (18188B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /* eslint-env module */ 6 7 const { AppConstants } = ChromeUtils.importESModule( 8 "resource://gre/modules/AppConstants.sys.mjs" 9 ); 10 11 const { Interactions } = ChromeUtils.importESModule( 12 "moz-src:///browser/components/places/Interactions.sys.mjs" 13 ); 14 const { PlacesUtils } = ChromeUtils.importESModule( 15 "resource://gre/modules/PlacesUtils.sys.mjs" 16 ); 17 const { PlacesDBUtils } = ChromeUtils.importESModule( 18 "resource://gre/modules/PlacesDBUtils.sys.mjs" 19 ); 20 21 const lazy = {}; 22 23 ChromeUtils.defineLazyGetter(lazy, "PlacesFrecencyRecalculator", () => { 24 return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( 25 Ci.nsIObserver 26 ).wrappedJSObject; 27 }); 28 29 /** 30 * Methods of sorting. 31 * 32 * @readonly 33 * @enum {SortingType} 34 */ 35 const SortingType = { 36 ASCENDING: "ASC", 37 DESCENDING: "DESC", 38 }; 39 40 /** 41 * How to sort a table of values. 42 * 43 * @typedef SortSetting 44 * 45 * @property {string} column 46 * Which column the table should be sorted by. 47 * @property {SortingType} order 48 * How order the sorting. 49 */ 50 51 /** 52 * Base class for the table display. Handles table layout and updates. 53 */ 54 class TableViewer { 55 /** 56 * Maximum number of rows to display by default. 57 * 58 * @type {number} 59 */ 60 maxRows = 100; 61 62 /** 63 * The number of rows that we last filled in on the table. This allows 64 * tracking to know when to clear unused rows. 65 * 66 * @type {number} 67 */ 68 #lastFilledRows = 0; 69 70 /** 71 * A map of columns that are displayed by default. This is set by sub-classes. 72 * 73 * - The key is the column name in the database. 74 * - The header is the column header on the table. 75 * - The modifier is a function to modify the returned value from the database 76 * for display. 77 * - includeTitle determines if the title attribute should be set on that 78 * column, for tooltips, e.g. if an element is likely to overflow. 79 * 80 * @type {Map<string, object>} 81 */ 82 columnMap; 83 84 /** 85 * A reference for the current interval timer, if any. 86 * 87 * @type {number} 88 */ 89 #timer; 90 91 /** 92 * How the table should be sorted. If not provided, the view will not allow 93 * sorting and default to the initial way the rows were pulled from the data 94 * source. 95 * 96 * @type {SortSetting} 97 */ 98 sortSetting = null; 99 100 /** 101 * Starts the display of the table. Setting up the table display and doing 102 * an initial output. Also starts the interval timer. 103 */ 104 async start() { 105 this.setupUI(); 106 await this.updateDisplay(); 107 this.#timer = setInterval(this.updateDisplay.bind(this), 10000); 108 } 109 110 /** 111 * Pauses updates for this table, use start() to re-start. 112 */ 113 pause() { 114 if (this.#timer) { 115 clearInterval(this.#timer); 116 this.#timer = null; 117 } 118 } 119 120 /** 121 * Creates the initial table layout and sets the styles to match the number 122 * of columns. 123 */ 124 setupUI() { 125 document.getElementById("title").textContent = this.title; 126 127 let viewer = document.getElementById("tableViewer"); 128 viewer.textContent = ""; 129 130 // Set up the table styles. 131 let existingStyle = document.getElementById("tableStyle"); 132 let numColumns = this.columnMap.size; 133 let styleText = ` 134 #tableViewer { 135 display: grid; 136 grid-template-columns: ${this.cssGridTemplateColumns} 137 } 138 139 /* Sets the first row of elements to bold. The number is the number of columns */ 140 #tableViewer > div:nth-child(-n+${numColumns}) { 141 font-weight: bold; 142 white-space: break-spaces; 143 } 144 145 /* Highlights every other row to make visual scanning of the table easier. 146 The numbers need to be adapted if the number of columns changes. */ 147 `; 148 for (let i = numColumns + 1; i <= numColumns * 2 - 1; i++) { 149 styleText += `#tableViewer > div:nth-child(${numColumns}n+${i}):nth-child(${ 150 numColumns * 2 151 }n+${i}),\n`; 152 } 153 styleText += `#tableViewer > div:nth-child(${numColumns}n+${ 154 numColumns * 2 155 }):nth-child(${numColumns * 2}n+${numColumns * 2})\n 156 { 157 background: var(--table-row-background-color-alternate); 158 }`; 159 existingStyle.innerText = styleText; 160 161 // Now set up the table itself with empty cells, this avoids having to 162 // create and delete rows all the time. 163 let tableBody = document.createDocumentFragment(); 164 let header = document.createDocumentFragment(); 165 for (let [key, details] of this.columnMap.entries()) { 166 let columnDiv = document.createElement("div"); 167 columnDiv.classList.add("column-title"); 168 columnDiv.setAttribute("data-column-title", key); 169 columnDiv.textContent = details.header; 170 header.appendChild(columnDiv); 171 } 172 tableBody.appendChild(header); 173 174 for (let i = 0; i < this.maxRows; i++) { 175 let row = document.createDocumentFragment(); 176 for (let j = 0; j < this.columnMap.size; j++) { 177 row.appendChild(document.createElement("div")); 178 } 179 tableBody.appendChild(row); 180 } 181 viewer.appendChild(tableBody); 182 183 let limit = document.getElementById("tableLimit"); 184 limit.textContent = `Maximum rows displayed: ${this.maxRows}.`; 185 186 this.#lastFilledRows = 0; 187 } 188 189 /** 190 * Displays the provided data in the table. 191 * 192 * @param {object[]} rows 193 * An array of rows to display. The rows are objects with the values for 194 * the rows being the keys of the columnMap. 195 */ 196 displayData(rows) { 197 if (gCurrentHandler != this) { 198 /* Data is no more relevant for the current view. */ 199 return; 200 } 201 let viewer = document.getElementById("tableViewer"); 202 let index = this.columnMap.size; 203 for (let row of rows) { 204 for (let [column, details] of this.columnMap.entries()) { 205 let value = row[column]; 206 207 if (details.includeTitle) { 208 viewer.children[index].setAttribute("title", value); 209 } 210 211 viewer.children[index].textContent = details.modifier 212 ? details.modifier(value) 213 : value; 214 215 index++; 216 } 217 } 218 let numRows = rows.length; 219 if (numRows < this.#lastFilledRows) { 220 for (let r = numRows; r < this.#lastFilledRows; r++) { 221 for (let c = 0; c < this.columnMap.size; c++) { 222 viewer.children[index].textContent = ""; 223 viewer.children[index].removeAttribute("title"); 224 index++; 225 } 226 } 227 } 228 this.#lastFilledRows = numRows; 229 230 this.updateDisplayedSort(); 231 } 232 233 updateDisplayedSort() { 234 if (this.sortable) { 235 let viewer = document.getElementById("tableViewer"); 236 let element = viewer.querySelector( 237 `[data-column-title="${this.sortSetting.column}"]` 238 ); 239 let symbolHolder = document.getElementById("column-title-sort-indicator"); 240 if (!symbolHolder) { 241 symbolHolder = document.createElement("span"); 242 symbolHolder.style.marginLeft = "5px"; 243 // Let the column header receive the click. 244 symbolHolder.style.pointerEvents = "none"; 245 symbolHolder.id = "column-title-sort-indicator"; 246 } 247 element.appendChild(symbolHolder); 248 symbolHolder.textContent = 249 this.sortSetting.order == SortingType.DESCENDING 250 ? "\u2B07\uFE0F" 251 : "\u2B06\uFE0F"; 252 } 253 } 254 255 changeSort(column) { 256 if (this.sortSetting.column == column) { 257 this.sortSetting.order = 258 this.sortSetting.order == SortingType.DESCENDING 259 ? SortingType.ASCENDING 260 : SortingType.DESCENDING; 261 } else { 262 this.sortSetting = { column, order: SortingType.DESCENDING }; 263 } 264 } 265 266 get sortable() { 267 return !!this.sortSetting; 268 } 269 } 270 271 /** 272 * Viewer definition for the page metadata. 273 */ 274 const metadataHandler = new (class extends TableViewer { 275 title = "Interactions"; 276 cssGridTemplateColumns = 277 "max-content fit-content(100%) repeat(6, min-content) fit-content(100%);"; 278 279 /** 280 * @see TableViewer.columnMap 281 */ 282 columnMap = new Map([ 283 ["id", { header: "ID" }], 284 ["url", { header: "URL", includeTitle: true }], 285 [ 286 "updated_at", 287 { 288 header: "Updated", 289 modifier: updatedAt => new Date(updatedAt).toLocaleString(), 290 }, 291 ], 292 [ 293 "total_view_time", 294 { 295 header: "View Time (s)", 296 modifier: totalViewTime => (totalViewTime / 1000).toFixed(2), 297 }, 298 ], 299 [ 300 "typing_time", 301 { 302 header: "Typing Time (s)", 303 modifier: typingTime => (typingTime / 1000).toFixed(2), 304 }, 305 ], 306 ["key_presses", { header: "Key Presses" }], 307 [ 308 "scrolling_time", 309 { 310 header: "Scroll Time (s)", 311 modifier: scrollingTime => (scrollingTime / 1000).toFixed(2), 312 }, 313 ], 314 ["scrolling_distance", { header: "Scroll Distance (pixels)" }], 315 ["referrer", { header: "Referrer", includeTitle: true }], 316 ]); 317 318 sortSetting = { column: "updated_at", order: SortingType.DESCENDING }; 319 320 /** 321 * A reference to the database connection. 322 * 323 * @type {mozIStorageConnection} 324 */ 325 #db = null; 326 327 async #getRows(query, columns = [...this.columnMap.keys()]) { 328 if (!this.#db) { 329 this.#db = await PlacesUtils.promiseDBConnection(); 330 } 331 let rows = await this.#db.executeCached(query); 332 return rows.map(r => { 333 let result = {}; 334 for (let column of columns) { 335 result[column] = r.getResultByName(column); 336 } 337 return result; 338 }); 339 } 340 341 /** 342 * Loads the current metadata from the database and updates the display. 343 */ 344 async updateDisplay() { 345 let rows = await this.#getRows( 346 `SELECT m.id AS id, h.url AS url, updated_at, total_view_time, 347 typing_time, key_presses, scrolling_time, scrolling_distance, h2.url as referrer 348 FROM moz_places_metadata m 349 JOIN moz_places h ON h.id = m.place_id 350 LEFT JOIN moz_places h2 ON h2.id = m.referrer_place_id 351 ORDER BY ${this.sortSetting.column} ${this.sortSetting.order} 352 LIMIT ${this.maxRows}` 353 ); 354 this.displayData(rows); 355 } 356 357 export(includeUrlAndTitle = false) { 358 return this.#getRows( 359 `SELECT 360 m.id, 361 ${includeUrlAndTitle ? "h.title," : ""} 362 ${includeUrlAndTitle ? "h.url" : "m.place_id"}, 363 m.updated_at, 364 h.frecency, 365 m.total_view_time, 366 m.typing_time, 367 m.key_presses, 368 m.scrolling_time, 369 m.scrolling_distance, 370 ${includeUrlAndTitle ? "r.url AS referrer_url" : "m.referrer_place_id"}, 371 ${includeUrlAndTitle ? "o.host" : "h.origin_id"}, 372 h.visit_count, 373 vall.visit_dates, 374 vall.visit_types 375 FROM moz_places_metadata m 376 JOIN moz_places h ON h.id = m.place_id 377 JOIN 378 (SELECT 379 place_id, 380 group_concat(visit_date, ',') AS visit_dates, 381 group_concat(visit_type, ',') AS visit_types 382 FROM moz_historyvisits 383 GROUP BY place_id 384 ORDER BY visit_date DESC 385 ) vall ON vall.place_id = m.place_id 386 JOIN moz_origins o ON h.origin_id = o.id 387 LEFT JOIN moz_places r ON m.referrer_place_id = r.id 388 389 ORDER BY m.place_id DESC 390 `, 391 [ 392 "id", 393 ...(includeUrlAndTitle ? ["title"] : []), 394 includeUrlAndTitle ? "url" : "place_id", 395 "updated_at", 396 "frecency", 397 "total_view_time", 398 "typing_time", 399 "key_presses", 400 "scrolling_time", 401 "scrolling_distance", 402 includeUrlAndTitle ? "referrer_url" : "referrer_place_id", 403 includeUrlAndTitle ? "host" : "origin_id", 404 "visit_count", 405 "visit_dates", 406 "visit_types", 407 ] 408 ); 409 } 410 })(); 411 412 /** 413 * Viewer definition for the Places database stats. 414 */ 415 const placesStatsHandler = new (class extends TableViewer { 416 title = "Places Database Statistics"; 417 cssGridTemplateColumns = "fit-content(100%) repeat(5, max-content);"; 418 419 /** 420 * @see TableViewer.columnMap 421 */ 422 columnMap = new Map([ 423 ["entity", { header: "Entity" }], 424 ["count", { header: "Count" }], 425 [ 426 "sizeBytes", 427 { 428 header: "Size (KiB)", 429 modifier: c => c / 1024, 430 }, 431 ], 432 [ 433 "sizePerc", 434 { 435 header: "Size (Perc.)", 436 }, 437 ], 438 [ 439 "efficiencyPerc", 440 { 441 header: "Space Eff. (Perc.)", 442 }, 443 ], 444 [ 445 "sequentialityPerc", 446 { 447 header: "Sequentiality (Perc.)", 448 }, 449 ], 450 ]); 451 452 /** 453 * Loads the current metadata from the database and updates the display. 454 */ 455 async updateDisplay() { 456 let data = await PlacesDBUtils.getEntitiesStatsAndCounts(); 457 this.displayData(data); 458 } 459 })(); 460 461 /** 462 * Places database with frecency scores. 463 */ 464 const placesViewerHandler = new (class extends TableViewer { 465 title = "Places Viewer"; 466 cssGridTemplateColumns = "fit-content(100%) repeat(6, min-content);"; 467 #db = null; 468 #maxRows = 100; 469 470 /** 471 * @see TableViewer.columnMap 472 */ 473 columnMap = new Map([ 474 ["url", { header: "URL" }], 475 ["title", { header: "Title" }], 476 [ 477 "last_visit_date", 478 { 479 header: "Last Visit Date", 480 modifier: lastVisitDate => 481 new Date(lastVisitDate / 1000).toLocaleString(), 482 }, 483 ], 484 ["frecency", { header: "Frecency" }], 485 [ 486 "recalc_frecency", 487 { 488 header: "Recalc Frecency", 489 }, 490 ], 491 [ 492 "alt_frecency", 493 { 494 header: "Alt Frecency", 495 }, 496 ], 497 [ 498 "recalc_alt_frecency", 499 { 500 header: "Recalc Alt Frecency", 501 }, 502 ], 503 ]); 504 505 sortSetting = { column: "last_visit_date", order: SortingType.DESCENDING }; 506 507 async #getRows(query, columns = [...this.columnMap.keys()]) { 508 if (!this.#db) { 509 this.#db = await PlacesUtils.promiseDBConnection(); 510 } 511 let rows = await this.#db.executeCached(query); 512 return rows.map(r => { 513 let result = {}; 514 for (let column of columns) { 515 result[column] = r.getResultByName(column); 516 } 517 return result; 518 }); 519 } 520 521 /** 522 * Loads the current metadata from the database and updates the display. 523 */ 524 async updateDisplay() { 525 let rows = await this.#getRows( 526 ` 527 SELECT 528 url, 529 title, 530 last_visit_date, 531 frecency, 532 recalc_frecency, 533 alt_frecency, 534 recalc_alt_frecency 535 FROM moz_places 536 ORDER BY ${this.sortSetting.column} ${this.sortSetting.order} 537 LIMIT ${this.#maxRows}` 538 ); 539 this.displayData(rows); 540 } 541 })(); 542 543 function checkPrefs() { 544 if ( 545 !Services.prefs.getBoolPref("browser.places.interactions.enabled", false) 546 ) { 547 let warning = document.getElementById("enabledWarning"); 548 warning.hidden = false; 549 } 550 } 551 552 function show(selectedButton) { 553 let currentButton = document.querySelector(".category.selected"); 554 if (currentButton == selectedButton) { 555 return; 556 } 557 558 gCurrentHandler.pause(); 559 currentButton.classList.remove("selected"); 560 selectedButton.classList.add("selected"); 561 switch (selectedButton.getAttribute("value")) { 562 case "metadata": 563 (gCurrentHandler = metadataHandler).start(); 564 metadataHandler.start(); 565 break; 566 case "places-stats": 567 (gCurrentHandler = placesStatsHandler).start(); 568 break; 569 case "places-viewer": 570 (gCurrentHandler = placesViewerHandler).start(); 571 break; 572 } 573 } 574 575 function createObjectURL(data, type) { 576 // Downloading the Blob will throw errors in debug mode because the 577 // principal is system and nsUrlClassifierDBService::lookup does not expect 578 // a caller from this principal. Thus, we use the null principal. However, in 579 // non-debug mode we'd rather not run eval and use the Javascript API. 580 if (AppConstants.DEBUG) { 581 let escapedData = data.replaceAll("'", "\\'").replaceAll("\n", "\\n"); 582 let sb = new Cu.Sandbox(null, { wantGlobalProperties: ["Blob", "URL"] }); 583 return Cu.evalInSandbox( 584 `URL.createObjectURL(new Blob(['${escapedData}'], {type: '${type}'}))`, 585 sb, 586 "", 587 null, 588 0, 589 false 590 ); 591 } 592 let blob = new Blob([data], { 593 type, 594 }); 595 return window.URL.createObjectURL(blob); 596 } 597 598 function downloadFile(data, blobType, fileType) { 599 const a = document.createElement("a"); 600 a.setAttribute("download", `places-${Date.now()}.${fileType}`); 601 a.setAttribute("href", createObjectURL(data, blobType)); 602 a.click(); 603 a.remove(); 604 } 605 606 async function getData() { 607 let includeUrlAndTitle = 608 document.getElementById("include-place-data").checked; 609 return await metadataHandler.export(includeUrlAndTitle); 610 } 611 612 function setupListeners() { 613 let menu = document.getElementById("categories"); 614 menu.addEventListener("click", e => { 615 if (e.target && e.target.parentNode == menu) { 616 show(e.target); 617 } 618 }); 619 620 document.getElementById("export-json").addEventListener("click", async e => { 621 e.preventDefault(); 622 const data = await getData(); 623 downloadFile(JSON.stringify(data), "text/json;charset=utf-8", "json"); 624 }); 625 626 document.getElementById("export-csv").addEventListener("click", async e => { 627 e.preventDefault(); 628 const data = await getData(); 629 630 // Convert Javascript to CSV string. 631 let headers = Object.keys(data.at(0)); 632 let rows = [ 633 headers.join(","), 634 ...data.map(obj => 635 headers.map(field => JSON.stringify(obj[field] ?? "")).join(",") 636 ), 637 ]; 638 rows = rows.join("\n"); 639 640 downloadFile(rows, "text/csv", "csv"); 641 }); 642 643 // Allow users to force frecency to update instead of waiting for an idle 644 // event. 645 document 646 .getElementById("recalc-alt-frecency") 647 .addEventListener("click", async e => { 648 e.preventDefault(); 649 lazy.PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); 650 }); 651 652 document.getElementById("tableViewer").addEventListener("click", e => { 653 if (gCurrentHandler.sortable && e.target.dataset.columnTitle) { 654 gCurrentHandler.changeSort(e.target.dataset.columnTitle); 655 gCurrentHandler.updateDisplay(); 656 } 657 }); 658 } 659 660 let gCurrentHandler; 661 if ( 662 Services.prefs.getBoolPref( 663 "browser.places.interactions.viewer.enabled", 664 false 665 ) 666 ) { 667 document.body.classList.remove("hidden"); 668 669 checkPrefs(); 670 // Set the initial handler here. 671 gCurrentHandler = metadataHandler; 672 gCurrentHandler.start().catch(console.error); 673 setupListeners(); 674 }