tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }