chromium/chrome/browser/resources/browsing_topics/browsing_topics_internals.ts

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/cr_elements/cr_tab_box/cr_tab_box.js';

import {assert} from 'chrome://resources/js/assert.js';
import type {String16} from 'chrome://resources/mojo/mojo/public/mojom/base/string16.mojom-webui.js';
import type {Time, TimeDelta} from 'chrome://resources/mojo/mojo/public/mojom/base/time.mojom-webui.js';

import type {PageHandlerRemote, WebUITopic} from './browsing_topics_internals.mojom-webui.js';
import {PageHandler} from './browsing_topics_internals.mojom-webui.js';

let pageHandler: PageHandlerRemote|null = null;
let hostsClassificationSequenceNumber = 0;

function setElementVisible(id: string, visible: boolean) {
  const element = document.querySelector<HTMLDivElement>('#' + id);
  element!.style.display = visible ? 'block' : 'none';
}

function setButtonEnabled(id: string, enabled: boolean) {
  const element = document.querySelector<HTMLButtonElement>('#' + id);
  element!.disabled = !enabled;
}

function decodeString16(arr: String16) {
  return arr.data.map(ch => String.fromCodePoint(ch)).join('');
}

function formatTimeDuration(totalMicrosecondsBigInt: bigint) {
  if (totalMicrosecondsBigInt > Number.MAX_SAFE_INTEGER) {
    return '+inf';
  }

  const totalMicroseconds = Number(totalMicrosecondsBigInt);

  let totalSeconds = Math.floor(totalMicroseconds / 1000000);

  const days = Math.floor(totalSeconds / 3600 / 24);
  totalSeconds %= 3600 * 24;
  const hours = Math.floor(totalSeconds / 3600);
  totalSeconds %= 3600;
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = Math.floor(totalSeconds % 60);
  return days + 'd-' + hours + 'h-' + minutes + 'm-' + seconds + 's';
}

function formatMojoTime(mojoTime: Time) {
  // The JS Date() is based off of the number of milliseconds since the
  // UNIX epoch (1970-01-01 00::00:00 UTC), while |internalValue| of the
  // base::Time (represented in mojom.Time) represents the number of
  // microseconds since the Windows FILETIME epoch (1601-01-01 00:00:00 UTC).
  // This computes the final JS time by computing the epoch delta and the
  // conversion from microseconds to milliseconds.
  const windowsEpoch = Date.UTC(1601, 0, 1, 0, 0, 0, 0);
  const unixEpoch = Date.UTC(1970, 0, 1, 0, 0, 0, 0);
  // |epochDeltaInMs| equals to base::Time::kTimeTToMicrosecondsOffset.
  const epochDeltaInMs = unixEpoch - windowsEpoch;
  const timeInMs = Number(mojoTime.internalValue) / 1000;

  return (new Date(timeInMs - epochDeltaInMs)).toLocaleString();
}

function getEnabledStatusText(enabled: boolean) {
  return enabled ? 'enabled' : 'disabled';
}

function getRealOrRandomStatusText(isRealTopic: boolean) {
  return isRealTopic ? 'Real' : 'Random';
}

function createContextDomainEntry(domain: string) {
  const entry =
      document
          .querySelector<HTMLTemplateElement>(
              '#context-domain-entry-template')!.content.cloneNode(true);
  (entry as HTMLElement).querySelectorAll('span')[0]!.textContent = domain;
  return entry;
}

function createTopicRow(topic: WebUITopic) {
  const row =
      document.querySelector<HTMLTemplateElement>(
                  '#topic-row-template')!.content.cloneNode(true) as
      HTMLElement;
  const nestedCells = row.querySelectorAll('td');
  nestedCells[0]!.textContent = String(topic.topicId);
  nestedCells[1]!.textContent = decodeString16(topic.topicName);
  nestedCells[2]!.textContent = getRealOrRandomStatusText(topic.isRealTopic);

  topic.observedByDomains.forEach((domain) => {
    row.querySelectorAll('td')[3]!.appendChild(
        createContextDomainEntry(domain));
  });

  return row;
}

function fieldNameFromId(id: string) {
  const tokens = id.split('-');
  let processedFirstToken = false;

  const convertedTokens = tokens.map(token => {
    if (!processedFirstToken) {
      processedFirstToken = true;
      return token;
    }

    if (token === 'div') {
      return '';
    }

    // Capitalize the first letter
    return token.charAt(0).toUpperCase() + token.slice(1);
  });

  return convertedTokens.join('');
}

async function asyncGetBrowsingTopicsConfiguration() {
  assert(pageHandler);
  const response = await pageHandler.getBrowsingTopicsConfiguration();

  const config = response.config;

  // Enabled status fields
  ['browsing-topics-enabled-div',
   'privacy-sandbox-ads-apis-override-enabled-div',
   'override-privacy-sandbox-settings-local-testing-enabled-div',
   'browsing-topics-bypass-ip-is-publicly-routable-check-enabled-div',
   'browsing-topics-document-api-enabled-div',
   'browsing-topics-parameters-enabled-div']
      .forEach(id => {
        const div = document.querySelector<HTMLElement>(`#${id}`);
        assert(div);
        div.textContent! += getEnabledStatusText(
            config[fieldNameFromId(id) as keyof typeof config] as boolean);
      });

  // Number fields
  ['config-version-div', 'number-of-epochs-to-expose-div',
   'number-of-top-topics-per-epoch-div',
   'use-random-topic-probability-percent-div',
   'number-of-epochs-of-observation-data-to-use-for-filtering-div',
   'max-number-of-api-usage-context-domains-to-keep-per-topic-div',
   'max-number-of-api-usage-context-entries-to-load-per-epoch-div',
   'max-number-of-api-usage-context-domains-to-store-per-page-load-div',
   'taxonomy-version-div', 'disabled-topics-list-div']
      .forEach(id => {
        const div = document.querySelector<HTMLElement>(`#${id}`);
        assert(div);
        div.textContent! +=
            (config[fieldNameFromId(id) as keyof typeof config] as number);
      });

  // Time duration fields
  ['time-period-per-epoch-div', 'max-epoch-introduction-delay-div'].forEach(
      id => {
        const div = document.querySelector<HTMLElement>(`#${id}`);
        assert(div);
        div.textContent! += formatTimeDuration(
            (config[fieldNameFromId(id) as keyof typeof config] as TimeDelta)
                .microseconds);
      });
}

async function asyncGetBrowsingTopicsState(calculateNow: boolean) {
  // Clear and hide existing content.
  document.querySelector('#epoch-div-list-wrapper')!.innerHTML =
      window.trustedTypes!.emptyHTML;
  setElementVisible('topics-state-override-status-message-div', false);
  setElementVisible('topics-state-div', false);

  // Disable the buttons to make sure only one request (either Refresh or
  // Calculate Now) can be made at a time.
  setButtonEnabled('refresh-topics-state-button', false);
  setButtonEnabled('calculate-now-button', false);

  assert(pageHandler);
  const response = await pageHandler.getBrowsingTopicsState(calculateNow);

  setButtonEnabled('refresh-topics-state-button', true);
  setButtonEnabled('calculate-now-button', true);

  const result = response.result;

  if (result.overrideStatusMessage) {
    document.querySelector(
                '#topics-state-override-status-message-div')!.textContent =
        result.overrideStatusMessage.toString();
    setElementVisible('topics-state-override-status-message-div', true);
    return;
  }

  setElementVisible('topics-state-div', true);

  document.querySelector('#next-scheduled-calculation-time-div')!.textContent =
      'Next scheduled calculation time: ' +
      formatMojoTime(result.browsingTopicsState!.nextScheduledCalculationTime);

  result.browsingTopicsState!.epochs.forEach((epoch) => {
    const epochDiv =
        document.querySelector<HTMLTemplateElement>(
                    '#epoch-div-template')!.content.cloneNode(true) as
        HTMLElement;

    const nestedDivs = epochDiv.querySelectorAll('div');
    nestedDivs[1]!.textContent += formatMojoTime(epoch.calculationTime);
    nestedDivs[2]!.textContent += epoch.modelVersion;
    nestedDivs[3]!.textContent += epoch.taxonomyVersion;

    epoch.topics.forEach((topic) => {
      epochDiv.querySelectorAll('table')[0]!.appendChild(createTopicRow(topic));
    });

    document.querySelector('#epoch-div-list-wrapper')!.appendChild(epochDiv);
  });
}

function createClassificationResultTopicEntry(topic: string) {
  const entry = document
                    .querySelector<HTMLTemplateElement>(
                        '#classification-result-topic-entry-template')!.content
                    .cloneNode(true);
  (entry as HTMLElement).querySelectorAll('span')[0]!.textContent = topic;
  return entry;
}

function createClassificationResultRow(host: string, topics: WebUITopic[]) {
  const row = document
                  .querySelector<HTMLTemplateElement>(
                      '#classification-result-host-row-template')!.content
                  .cloneNode(true) as HTMLElement;
  const nestedCells = row.querySelectorAll('td');
  nestedCells[0]!.textContent = host;

  topics.forEach((topic) => {
    const topicText =
        String(topic.topicId) + '. ' + decodeString16(topic.topicName);
    nestedCells[1]!.appendChild(
        createClassificationResultTopicEntry(topicText));
  });

  return row;
}

async function asyncClassifyHosts(hosts: string[], sequenceNumber: number) {
  let topicsForHosts = [] as WebUITopic[][];

  if (hosts.length > 0) {
    assert(pageHandler);
    const response = await pageHandler.classifyHosts(hosts);
    topicsForHosts = response.topicsForHosts;
  }

  // Skip this result if a newer classification was initiated before this one
  // finished.
  if (sequenceNumber !== hostsClassificationSequenceNumber) {
    return;
  }

  for (let i = 0; i < hosts.length; i++) {
    const host = hosts[i] as string;
    const topics = topicsForHosts![i] as WebUITopic[];

    document.querySelector('#hosts-classification-result-table')!.appendChild(
        createClassificationResultRow(host, topics));
  }

  setElementVisible('hosts-classification-loader-div', false);
  setElementVisible('hosts-classification-result-table-wrapper', true);
}

function clearHostsClassificationResult() {
  const table = document.querySelector<HTMLTableElement>(
      '#hosts-classification-result-table');
  assert(table);

  while (table.rows[1]) {
    table.deleteRow(1);
  }

  const div = document.querySelector<HTMLElement>(
      '#hosts-classification-input-validation-error');
  div!.innerHTML = window.trustedTypes!.emptyHTML;

  setElementVisible('hosts-classification-loader-div', false);
  setElementVisible('hosts-classification-input-validation-error', false);
  setElementVisible('hosts-classification-result-table-wrapper', false);
}

async function asyncGetModelInfo() {
  assert(pageHandler);
  const response = await pageHandler.getModelInfo();

  setElementVisible('model-info-loader', false);

  const result = response.result;

  if (result.overrideStatusMessage) {
    document.querySelector(
                '#model-info-override-status-message-div')!.textContent =
        result.overrideStatusMessage.toString();
    setElementVisible('model-info-override-status-message-div', true);
    return;
  }

  document.querySelector('#model-version-div')!.textContent +=
      result.modelInfo!.modelVersion;
  document.querySelector('#model-file-path-div')!.textContent +=
      result.modelInfo!.modelFilePath;

  setElementVisible('model-info-div', true);
  setElementVisible('hosts-classification-input-area-div', true);

  document.querySelector(
              '#hosts-classification-button')!.addEventListener('click', () => {
    clearHostsClassificationResult();

    const input =
        document.querySelector<HTMLTextAreaElement>(
                    '#input-hosts-textarea')!.value;
    const hosts = input!.split('\n');

    const preprocessedHosts = [] as string[];
    hosts.forEach((host) => {
      const trimmedHost = host.trim();
      if (trimmedHost === '') {
        return;
      }

      preprocessedHosts.push(trimmedHost);
    });

    const inputValidationErrors = [] as string[];
    preprocessedHosts.forEach((host) => {
      if (host.includes('/')) {
        inputValidationErrors.push(
            'Host \"' + host + '" contains invalid character: "\/"');
      }
    });

    ++hostsClassificationSequenceNumber;

    if (inputValidationErrors.length > 0) {
      const errorsDiv = document.querySelector<HTMLElement>(
          '#hosts-classification-input-validation-error');
      inputValidationErrors.forEach((error) => {
        const errorDiv = document.createElement('div');
        errorDiv.textContent = error;
        errorsDiv!.appendChild(errorDiv);
      });

      setElementVisible('hosts-classification-input-validation-error', true);
    } else {
      setElementVisible('hosts-classification-loader-div', true);
      asyncClassifyHosts(preprocessedHosts, hostsClassificationSequenceNumber);
    }
  });
}

document.addEventListener('DOMContentLoaded', function() {
  // Setup the mojo interface.
  pageHandler = PageHandler.getRemote();

  setElementVisible('topics-state-override-status-message-div', false);
  setElementVisible('topics-state-div', false);
  setElementVisible('model-info-override-status-message-div', false);
  setElementVisible('model-info-div', false);
  setElementVisible('hosts-classification-input-area-div', false);
  setElementVisible('hosts-classification-loader-div', false);
  setElementVisible('hosts-classification-input-validation-error', false);
  setElementVisible('hosts-classification-result-table-wrapper', false);

  asyncGetBrowsingTopicsConfiguration();
  asyncGetModelInfo();

  document.querySelector('#refresh-topics-state-button')!.addEventListener(
      'click', () => {
        asyncGetBrowsingTopicsState(/*calculateNow=*/ false);
      });

  document.querySelector('#calculate-now-button')!.addEventListener(
      'click', () => {
        asyncGetBrowsingTopicsState(/*calculateNow=*/ true);
      });

  asyncGetBrowsingTopicsState(/*calculateNow=*/ false);
});