chromium/ios/tools/documents_statistics_viewer/viewer.ts

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

// Returns a display string given the date & time specified in dateString.
// Example format: 2023-04-30T14:20:10
function getDateTimeDisplayString(dateString: string|undefined): string {
  if (!dateString || dateString.length == 0) {
    return '';
  }
  const date = new Date(dateString);
  return date.toLocaleString(
      'default', {year: 'numeric', day: 'numeric', month: 'short'});
}

// Returns a string representation of the size sizeInBytes.
function getSizeDisplayString(sizeInBytes: number|undefined): string {
  if (!sizeInBytes || sizeInBytes == 0) {
    return '0 B';
  }
  if (sizeInBytes < 1024) {
    return sizeInBytes.toFixed() + ' B';
  }
  if (sizeInBytes < (1024 * 1024)) {
    return (sizeInBytes / 1024).toFixed(1) + ' KB';
  }
  if (sizeInBytes < (1024 * 1024 * 1024)) {
    return (sizeInBytes / 1024 / 1024).toFixed(1) + ' MB';
  }

  return (sizeInBytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
}

// A set of common audio file extensions.
const AUDIO_FORMATS =
    new Set(['AAC', 'AIFF', 'ALAC', 'DSD', 'FLAC', 'MP3', 'OGG', 'WAV']);
// A set of common image file extensions.
const IMAGE_FORMATS =
    new Set(['BMP', 'GIF', 'JPEG', 'JPG', 'PNG', 'TIF', 'TIFF']);
// A set of common video file extensions.
const VIDEO_FORMATS = new Set([
  'AVCHD', 'AVI', 'FLV', 'M4P', 'M4V', 'MOV', 'MP2', 'MP4', 'MPE', 'MPEG',
  'MPG', 'MPV', 'OGG', 'QT', 'SWF', 'WEBM', 'WMV'
]);

// Returns an icon (as a single emoji item) based on the given `filename`'s
// extension.
function iconForFilename(filename: string): string {
  let extension = filename.split('.').pop();
  if (extension) {
    extension = extension.toUpperCase();
  }

  if (!extension) {
    return '📄';
  }

  if (extension == 'PDF') {
    return '📋';
  }

  if (AUDIO_FORMATS.has(extension)) {
    return '🎶';
  }

  if (IMAGE_FORMATS.has(extension)) {
    return '📷';
  }

  if (VIDEO_FORMATS.has(extension)) {
    return '📹';
  }

  return '📄';
}

declare interface Item {
  name: string;
  size?: number;

  accessed?: string;
  created?: string;
  modified?: string;

  excludedFromBackups?: boolean;

  contents?: Item[]
}

// Returns a sorted list of the given `items` based on the value of `sorting`.
function sortItems(items: Array<Item>, sorting: string): Array<Item> {
  const sortedItems = items;
  // return items.toSorted((a: Item, b: Item) => {
  sortedItems.sort((a: Item, b: Item) => {
    switch (sorting) {
      case 'nameAsc':
        return a.name.localeCompare(b.name);
      case 'nameDesc':
        return b.name.localeCompare(a.name);
      case 'sizeAsc':
        if (!a.size) {
          return -1;
        }
        if (!b.size) {
          return 1;
        }
        if (a.size < b.size) {
          return -1;
        } else if (a.size == b.size) {
          return 0;
        }
        return 1;
      case 'sizeDesc':
        if (!b.size) {
          return -1;
        }
        if (!a.size) {
          return 1;
        }
        if (b.size < a.size) {
          return -1;
        } else if (a.size == b.size) {
          return 0;
        }
        return 1;
      case 'accessedAsc':
        if (!b.accessed) {
          return -1;
        }
        if (!a.accessed) {
          return 1;
        }
        return b.accessed.localeCompare(a.accessed);
      case 'accessedDesc':
        if (!a.accessed) {
          return -1;
        }
        if (!b.accessed) {
          return 1;
        }
        return a.accessed.localeCompare(b.accessed);
    }
    return 0;
  });
  return sortedItems;
}

let collapsedDirectoryPaths: Set<string> = new Set();

// Updates the expanded/collapsed state of directory contents and updates
// directory icons to be in the correct open/closed state.
function refreshExpandedState(): void {
  const contents = document.getElementById('contents')!;
  for (const row of contents.querySelectorAll('.item_row')) {
    if (row.hasAttribute('path')) {
      const rowPath = row.getAttribute('path')!;

      if (row.classList.contains('directory')) {
        const itemIcon = row.querySelector('.item_icon') as HTMLElement;
        if (collapsedDirectoryPaths.has(rowPath)) {
          itemIcon.innerText = '📁';
        } else {
          itemIcon.innerText = '📂';
        }
      }

      let collapsed = false;
      for (const collapsedPath of collapsedDirectoryPaths) {
        if (rowPath.startsWith(collapsedPath + '/')) {
          collapsed = true;
          break;
        }
      }

      if (collapsed) {
        (row as HTMLElement).style.display = 'none';
      } else {
        (row as HTMLElement).style.display = 'flex';
      }
    }
  }
}

// Creates row items for `root` and all children, recursively.
function createEntryRowForRoot(root: Item, level = 0, parentPath = ''): void {
  const path = parentPath + '/' + root.name;

  let currentRootIncludesThisRow = true;
  if (window.location.hash) {
    const rootPath = decodeURIComponent(window.location.hash.substring(1))
    currentRootIncludesThisRow = path.indexOf(rootPath) == 0;
  }

  let nextLevel = level;
  if (currentRootIncludesThisRow &&
      // No search terms or this item matches the search terms.
      (!searchTerms ||
       root.name.toUpperCase().indexOf(searchTerms.toUpperCase()) >= 0)) {
    nextLevel = nextLevel + 1;

    const itemRow = document.createElement('div');
    itemRow.setAttribute('path', path);
    if (root.contents) {
      itemRow.classList.add('directory');
    }
    itemRow.classList.add('item_row');

    const itemInset = document.createElement('span');
    itemInset.classList.add('item_spacing');
    itemInset.style.width = (25 * level) + 'px';
    itemRow.appendChild(itemInset);

    const itemIcon = document.createElement('span');
    itemIcon.classList.add('item_icon');
    if (!root.contents) {
      itemIcon.innerText = iconForFilename(root.name);
    }
    itemRow.appendChild(itemIcon);

    const itemName = document.createElement('span');
    itemName.classList.add('item_name');
    let backupIcon = '<span class="backed_up_cloud">☁️</span>';
    if (root.excludedFromBackups) {
      backupIcon = '';
    }
    let makeDirRootLink = '';
    if (root.contents && level > 0) {
      makeDirRootLink =
          '<a class="arrow-up" href="#' + encodeURIComponent(path) + '">⬆️</a>';
    }
    itemName.innerHTML = '' + root.name + backupIcon + makeDirRootLink;
    itemRow.appendChild(itemName);

    const itemSize = document.createElement('span');
    itemSize.classList.add('item_size');
    itemSize.innerText = getSizeDisplayString(root.size);
    itemRow.appendChild(itemSize);

    const itemAccessed = document.createElement('span');
    itemAccessed.classList.add('item_accessed');
    itemAccessed.innerText = getDateTimeDisplayString(root.accessed);
    itemRow.appendChild(itemAccessed);

    const itemCreated = document.createElement('span');
    itemCreated.classList.add('item_created');
    itemCreated.innerText = getDateTimeDisplayString(root.created);
    itemRow.appendChild(itemCreated);

    const itemModified = document.createElement('span');
    itemModified.classList.add('item_modified');
    itemModified.innerText = getDateTimeDisplayString(root.modified);
    itemRow.appendChild(itemModified);

    if (parentPath.split('/').length % 2 == 1) {
      itemName.classList.add('grey_bg');
      itemSize.classList.add('grey_bg');
      itemAccessed.classList.add('grey_bg');
      itemCreated.classList.add('grey_bg');
      itemModified.classList.add('grey_bg');
    }

    const contents = document.getElementById('contents')!;
    contents.appendChild(itemRow);

    if (root.contents) {
      itemRow.addEventListener('click', function(event) {
        if (!event.target || !(event.target instanceof Element) ||
            event.target.classList.contains('arrow-up')) {
          // Don't change expansion state on arrow click.
          return;
        }

        if (collapsedDirectoryPaths.has(path)) {
          // Expand previously collapsed directory.
          collapsedDirectoryPaths.delete(path);
        } else {
          // Collapse previously expanded directory.
          collapsedDirectoryPaths.add(path);
        }

        refreshExpandedState();
      });
    }
  }
  if (root.contents) {
    let sorting = 'nameAsc';
    const sortDropdown = document.getElementById('sorting');
    if (sortDropdown && sortDropdown instanceof HTMLSelectElement) {
      sorting = sortDropdown.value;
    }
    const sortedItems = sortItems(root.contents, sorting);
    for (const item of sortedItems) {
      createEntryRowForRoot(item, nextLevel, path);
    }
  }
}

let allStatistics: Item|null = null;
let searchTerms: string|null = null;
let rootPath: string|null = null;
// Reloads the displayed items, taking into account collapsed directories,
// `searchTerms`, and the chosen sorting.
function reloadStatistics(): void {
  const contents = document.getElementById('contents')!;
  for (const row of contents.querySelectorAll('div:not(.header_row)')) {
    contents.removeChild(row);
  }

  if (window.location.hash) {
    rootPath = decodeURIComponent(window.location.hash.substring(1))
    document.getElementById('root_path')!.innerText = rootPath;

    let one_up_location = '';
    if (rootPath.includes('/')) {
      one_up_location =
          encodeURIComponent(rootPath.substring(0, rootPath.lastIndexOf('/')));
    }

    document.getElementById('nav_up')!.setAttribute(
        'onclick', 'window.location.hash=\'#' + one_up_location + '\'');
  } else {
    document.getElementById('root_path')!.innerText = '/';
  }

  if (!allStatistics) {
    return;
  }
  createEntryRowForRoot(allStatistics);
  refreshExpandedState();
}

// Recursively marks all directories in items as collapsed
function collapseDirectories(items: Array<Item>, parentPath = ''): void {
  if (!items || items.length == 0) {
    return;
  }
  for (const item of items) {
    const path = parentPath + '/' + item.name;
    if (item.contents) {
      let currentRootIncludesThisItemAsChild = true;
      if (window.location.hash) {
        const rootPath = decodeURIComponent(window.location.hash.substring(1))
        if (path == rootPath) {
          // Don't collapse the top level item.
          currentRootIncludesThisItemAsChild = false;
        }
        else {
          currentRootIncludesThisItemAsChild = path.indexOf(rootPath) == 0;
        }
      }

      if (currentRootIncludesThisItemAsChild) {
        collapsedDirectoryPaths.add(path);
      }
      collapseDirectories(item.contents, path);
    }
  }
}

// Marks every directory as collapsed and refreshes the UI.
function collapseAllDirectories(): void {
  if (!allStatistics) {
    return;
  }

  collapsedDirectoryPaths.clear();
  collapseDirectories([allStatistics]);
  refreshExpandedState();
}

// Marks every directory as expanded and refreshes the UI.
function expandAllDirectories(): void {
  collapsedDirectoryPaths.clear();
  refreshExpandedState();
}

// Triggered when the user chose a data file. Reads the file contents and loads
// the contents.
function fileSelected(file: File): void {
  // Clear file selection listeners
  const reportSelector = document.getElementById('report_file_input')!;
  reportSelector.removeEventListener('change', fileInputValueChanged);

  const dropArea = document.getElementById('drop_target')!;
  dropArea.removeEventListener('dragover', dragoverEvent);
  dropArea.removeEventListener('drop', dropEvent);

  document.getElementById('report_upload')!.hidden = true;
  document.getElementById('loading')!.hidden = false;

  document.getElementById('local_file')!.innerText = file.name;

  const fileReader = new FileReader();
  fileReader.addEventListener('load', () => {
    const statistics = JSON.parse(fileReader.result as string);
    document.getElementById('loading')!.hidden = true;
    document.getElementById('viewer')!.hidden = false;

    allStatistics = statistics;
    reloadStatistics();
  });
  fileReader.readAsText(file);
}

function fileInputValueChanged(event: Event) {
  if (!event.target || !(event.target instanceof HTMLInputElement)) {
    return;
  }
  const fileList = event.target.files;
  if (fileList && fileList.length > 0) {
    fileSelected(fileList[0] as File);
  }
}

function dragoverEvent(event: DragEvent) {
  event.stopPropagation();
  event.preventDefault();
  if (!event.dataTransfer) {
    return;
  }
  // Style the drag-and-drop as a "copy file" operation.
  event.dataTransfer.dropEffect = 'copy';
}

function dropEvent(event: DragEvent) {
  event.stopPropagation();
  event.preventDefault();
  if (!event.dataTransfer) {
    return;
  }
  const fileList = event.dataTransfer.files;
  if (fileList && fileList.length > 0) {
    fileSelected(fileList[0] as File);
  }
}

function searchBarTextChanged(event: Event) {
  if (!event.target || !(event.target instanceof HTMLInputElement)) {
    return;
  }

  searchTerms = event.target.value;
  reloadStatistics();
}

document.addEventListener('DOMContentLoaded', function() {
  const reportSelector = document.getElementById('report_file_input')!;
  reportSelector.addEventListener('change', fileInputValueChanged);

  const dropArea = document.getElementById('drop_target')!;
  dropArea.addEventListener('dragover', dragoverEvent);
  dropArea.addEventListener('drop', dropEvent);

  const searchbar = document.getElementById('searchbar')!;
  searchbar.addEventListener('input', searchBarTextChanged);
  window.addEventListener('hashchange', reloadStatistics);

  const sortDropdown = document.getElementById('sorting')!;
  sortDropdown.addEventListener('change', reloadStatistics);
});