chromium/components/sync/service/resources/data.ts

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

import {assert} from 'chrome://resources/js/assert.js';
import type {WebUiListener} from 'chrome://resources/js/cr.js';
import {addWebUiListener, removeWebUiListener} from 'chrome://resources/js/cr.js';

import {aboutInfo} from './about.js';
import type {SyncNode, SyncNodeMap} from './chrome_sync.js';
import {getAllNodes, requestIncludeSpecificsInitialState, requestListOfTypes} from './chrome_sync.js';
import {log} from './sync_log.js';

const dumpToTextButton = document.querySelector<HTMLElement>('#dump-to-text');
assert(dumpToTextButton);
const dataDump = document.querySelector<HTMLElement>('#data-dump');
assert(dataDump);
dumpToTextButton.addEventListener('click', function() {
  // TODO(akalin): Add info like Chrome version, OS, date dumped, etc.

  let data = '';
  data += '======\n';
  data += 'Status\n';
  data += '======\n';
  data += JSON.stringify(aboutInfo, null, 2);
  data += '\n';
  data += '\n';

  data += '===\n';
  data += 'Log\n';
  data += '===\n';
  data += JSON.stringify(log.entries, null, 2);
  data += '\n';

  dataDump.textContent = data;
});

const allFields = [
  'ID',
  'IS_UNSYNCED',
  'IS_UNAPPLIED_UPDATE',
  'BASE_VERSION',
  'BASE_VERSION_TIME',
  'SERVER_VERSION',
  'SERVER_VERSION_TIME',
  'PARENT_ID',
  'SERVER_PARENT_ID',
  'IS_DEL',
  'SERVER_IS_DEL',
  'dataType',
  'SERVER_SPECIFICS',
  'SPECIFICS',
];

function versionToDateString(version: string) {
  // TODO(mmontgomery): ugly? Hacky? Is there a better way?
  const epochLength = Date.now().toString().length;
  const epochTime = parseInt(version.slice(0, epochLength), 10);
  const date = new Date(epochTime);
  return date.toString();
}

/**
 * @param node A JavaScript represenation of a sync entity.
 * @return A string representation of the sync entity.
 */
function serializeNode(node: SyncNode): string[] {
  const includeSpecifics =
      document.querySelector<HTMLInputElement>('#include-specifics');
  assert(includeSpecifics);
  return allFields.map(function(field) {
    let fieldVal;
    if (field === 'SERVER_VERSION_TIME') {
      const version = node['SERVER_VERSION'];
      if (version != null) {
        fieldVal = versionToDateString(version);
      }
    }
    if (field === 'BASE_VERSION_TIME') {
      const version = node['BASE_VERSION'];
      if (version != null) {
        fieldVal = versionToDateString(version);
      }
    } else if (
        (field === 'SERVER_SPECIFICS' || field === 'SPECIFICS') &&
        (!includeSpecifics.checked)) {
      fieldVal = 'REDACTED';
    } else if (
        (field === 'SERVER_SPECIFICS' || field === 'SPECIFICS') &&
        includeSpecifics.checked) {
      fieldVal = JSON.stringify(node[field]);
    } else {
      fieldVal = (node as unknown as {[key: string]: string})[field];
    }
    return fieldVal || '';
  });
}

/**
 * @param type The name of a sync data type.
 * @return True if the type's checkbox is selected.
 */
function isSelectedDatatype(type: string): boolean {
  const typeCheckbox = document.querySelector<HTMLInputElement>(`#${type}`);
  // Some types, such as 'Top level folder', appear in the list of nodes
  // but not in the list of selectable items.
  if (typeCheckbox == null) {
    return false;
  }
  return typeCheckbox.checked;
}

function makeBlobUrl(data: string): string {
  const textBlob = new Blob([data], {type: 'octet/stream'});
  const blobUrl = window.URL.createObjectURL(textBlob);
  return blobUrl;
}

function makeDownloadName() {
  // Format is sync-data-dump-$epoch-$year-$month-$day-$OS.csv.
  const now = new Date();
  const friendlyDate =
      [now.getFullYear(), now.getMonth() + 1, now.getDate()].join('-');
  const name = [
    'sync-data-dump',
    friendlyDate,
    Date.now(),
    navigator.platform,
  ].join('-');
  return [name, 'csv'].join('.');
}

function makeDateUserAgentHeader() {
  const now = new Date();
  const userAgent = window.navigator.userAgent;
  const dateUaHeader = [now.toISOString(), userAgent].join(',');
  return dateUaHeader;
}

/**
 * Builds a summary of current state and exports it as a downloaded file.
 *
 * @param nodesMap Summary of local state by data type.
 */
function triggerDataDownload(nodesMap: SyncNodeMap) {
  // Prepend a header with ISO date and useragent.
  const output = [makeDateUserAgentHeader()];
  output.push('=====');

  const aboutInfoString = JSON.stringify(aboutInfo, null, 2);
  output.push(aboutInfoString);

  // Filter out non-selected types.
  const selectedTypesNodes = nodesMap.filter(function(x) {
    return isSelectedDatatype(x.type);
  });

  // Serialize the remaining nodes and add them to the output.
  selectedTypesNodes.forEach(function(typeNodes) {
    output.push('=====');
    output.push(typeNodes.nodes.map(serializeNode).join('\n'));
  });

  const outputString = output.join('\n');

  const anchor =
      document.querySelector<HTMLAnchorElement>('#dump-to-file-anchor');
  assert(anchor);
  anchor.href = makeBlobUrl(outputString);
  anchor.download = makeDownloadName();
  anchor.click();
}

function createTypesCheckboxes(types: string[]) {
  const containerElt =
      document.querySelector<HTMLElement>('#node-type-checkboxes');
  assert(containerElt);

  types.map(function(type: string) {
    const div = document.createElement('div');

    const checkbox = document.createElement('input');
    checkbox.id = type;
    checkbox.type = 'checkbox';
    checkbox.checked = true;
    div.appendChild(checkbox);

    const label = document.createElement('label');
    // Assigning to label.for doesn't work.
    label.setAttribute('for', type);
    label.innerText = type;
    div.appendChild(label);

    containerElt.appendChild(div);
  });
}

let listOfTypesListener: WebUiListener|null = null;

function onReceivedListOfTypes(response: {types: string[]}) {
  const types = response.types;
  types.sort();
  createTypesCheckboxes(types);
  assert(listOfTypesListener);
  removeWebUiListener(listOfTypesListener);
}

function onReceivedIncludeSpecificsInitialState(
    response: {includeSpecifics: boolean}) {
  const captureSpecifics =
      document.querySelector<HTMLInputElement>('#capture-specifics');
  assert(captureSpecifics);
  captureSpecifics.checked = response.includeSpecifics;
}

document.addEventListener('DOMContentLoaded', function() {
  listOfTypesListener =
      addWebUiListener('onReceivedListOfTypes', onReceivedListOfTypes);
  requestListOfTypes();

  addWebUiListener(
      'onReceivedIncludeSpecificsInitialState',
      onReceivedIncludeSpecificsInitialState);
  requestIncludeSpecificsInitialState();
});

const dumpToFileLink = document.querySelector<HTMLElement>('#dump-to-file');
assert(dumpToFileLink);
dumpToFileLink.addEventListener('click', function() {
  getAllNodes(triggerDataDownload);
});