|  | // 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 {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<HTMLElement>('#' + id); | 
|  | element!.style.display = visible ? 'block' : 'none'; | 
|  | } | 
|  |  | 
|  | function setButtonEnabled(id: string, enabled: boolean) { | 
|  | const element = document.querySelector<HTMLButtonElement>('#' + id); | 
|  | element!.disabled = !enabled; | 
|  | } | 
|  |  | 
|  | 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 = 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) + '. ' + 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); | 
|  | }); |