chromium/ash/webui/common/resources/sea_pen/sea_pen_utils.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.

import {assert} from 'chrome://resources/js/assert.js';
import {FilePath} from 'chrome://resources/mojo/mojo/public/mojom/base/file_path.mojom-webui.js';
import {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';

import {getSeaPenTemplates, parseTemplateText, QUERY, Query, SeaPenImageId, SeaPenOption, SeaPenTemplate} from './constants.js';
import {SeaPenQuery} from './sea_pen.mojom-webui.js';
import {SeaPenTemplateChip, SeaPenTemplateId} from './sea_pen_generated.mojom-webui.js';

// Returns true if `maybeDataUrl` is a Url that contains a base64 encoded image.
export function isImageDataUrl(maybeDataUrl: unknown): maybeDataUrl is Url {
  return !!maybeDataUrl && typeof maybeDataUrl === 'object' &&
      'url' in maybeDataUrl && typeof maybeDataUrl.url === 'string' &&
      (maybeDataUrl.url.startsWith('data:image/png;base64') ||
       maybeDataUrl.url.startsWith('data:image/jpeg;base64'));
}

// SeaPenImageId must always be a positive
export function isSeaPenImageId(maybeSeaPenImageId: unknown):
    maybeSeaPenImageId is SeaPenImageId {
  return typeof maybeSeaPenImageId === 'number' &&
      Number.isInteger(maybeSeaPenImageId) && maybeSeaPenImageId >= 0;
}

// Returns true if `maybeArray` is an array with at least one item.
export function isNonEmptyArray(maybeArray: unknown): maybeArray is unknown[] {
  return Array.isArray(maybeArray) && maybeArray.length > 0;
}

// Returns true is `obj` is a FilePath with a non-empty path.
export function isNonEmptyFilePath(obj: unknown): obj is FilePath {
  return !!obj && typeof obj === 'object' && 'path' in obj &&
      typeof obj.path === 'string' && !!obj.path;
}

/**
 * Returns a random number between [0, max).
 */
function getRandomInt(max: number) {
  return Math.floor(Math.random() * max);
}

function isChip(word: string): boolean {
  return !!word && word.startsWith('<') && word.endsWith('>');
}

function toChip(word: string): SeaPenTemplateChip {
  return parseInt(word.slice(1, -1)) as SeaPenTemplateChip;
}

/**
 * Returns the default mapping of chip to option for the template.
 * Randomly picks the option if `random` is true.
 */
export function getDefaultOptions(template: SeaPenTemplate, random = false):
    Map<SeaPenTemplateChip, SeaPenOption> {
  const selectedOptions = new Map<SeaPenTemplateChip, SeaPenOption>();
  template.options.forEach((options, chip) => {
    if (isNonEmptyArray(options)) {
      let option = options[0];
      if (random) {
        option = options[getRandomInt(options.length)];
      }
      selectedOptions.set(chip, option);
    } else {
      console.warn('empty options for', template.id);
    }
  });
  return selectedOptions;
}

/**
 * A template token that is a chip.
 */
export interface ChipToken {
  // The translated string displayed on the UI.
  translation: string;
  // The identifier of the chip.
  id: SeaPenTemplateChip;
}

/**
 * A tokenized unit of the `SeaPenTemplate`. Used to render the prompt on the UI
 */
export type TemplateToken = string|ChipToken;

/**
 * Separates a template into tokens that can be displayed on the UI.
 */
export function getTemplateTokens(
    template: SeaPenTemplate,
    selectedOptions: Map<SeaPenTemplateChip, SeaPenOption>): TemplateToken[] {
  const strs = parseTemplateText(template.text);
  return strs.map(str => {
    if (isChip(str)) {
      const templateChip = toChip(str);
      return {
        translation: selectedOptions.get(templateChip)?.translation || '',
        id: templateChip,
      };
    } else {
      return str;
    }
  });
}

/**
 * Get the selected template options map from the options information in
 * SeaPenQuery `query` and SeaPenTemplate `template`.
 */
export function getSelectedOptionsFromQuery(
    query: SeaPenQuery|null,
    template: SeaPenTemplate): Map<SeaPenTemplateChip, SeaPenOption>|null {
  if (!query || query.textQuery) {
    return null;
  }

  const templateId = query.templateQuery?.id;
  assert(templateId === template.id, 'template id should match');

  // Update the selected options to match with current Sea Pen query.
  const options = query.templateQuery?.options;
  const newSelectedOptions = new Map<SeaPenTemplateChip, SeaPenOption>();
  for (const [key, value] of Object.entries(options ?? new Map())) {
    const chip = parseInt(key) as SeaPenTemplateChip;
    const chipOptions = template.options.get(chip);
    const selectedChipOption =
        chipOptions?.find((option) => option.value === value);
    if (selectedChipOption) {
      newSelectedOptions.set(chip, selectedChipOption);
    }
  }
  return newSelectedOptions;
}

/**
 * Checks whether a Sea Pen query is active. Freeform query is active by
 * default. Template query should have active template and chip options.
 */
export function isActiveSeaPenQuery(query: SeaPenQuery|undefined): boolean {
  if (!query) {
    return false;
  }

  if (query.textQuery) {
    return true;
  }

  const template = getSeaPenTemplates().find(
      (seaPenTemplate) => seaPenTemplate.id === query.templateQuery?.id);
  const options = query.templateQuery?.options;
  if (!template || !options) {
    return false;
  }

  const isActive = Object.entries(options).every(([key, value]) => {
    const chip = parseInt(key) as SeaPenTemplateChip;
    const activeOptions = template.options.get(chip);
    return !!activeOptions && activeOptions.some(opt => opt.value === value);
  });
  return isActive;
}

/**
 * Get the user visible query from SeaPenQuery `query`. Empty string if the
 * query is null or invalid.
 */
export function getUserVisibleQuery(query: SeaPenQuery): string {
  if (!query) {
    return '';
  }
  if (query.textQuery) {
    return query.textQuery;
  }
  if (query.templateQuery) {
    return query.templateQuery.userVisibleQuery?.text ?? '';
  }
  return '';
}

/**
 * Convert Sea Pen template id in string type to SeaPenTemplateId/Query type.
 */
export function getTemplateIdFromString(templateId: string): SeaPenTemplateId|
    Query {
  if (templateId === QUERY) {
    return QUERY;
  }
  return parseInt(templateId) as SeaPenTemplateId;
}

/**
 * Checks whether the origin of the URL from Personalization App.
 */
export function isPersonalizationApp(): boolean {
  return window.location.origin === 'chrome://personalization';
}

/** Returns true if this event is a user action to select an item. */
export function isSelectionEvent(event: Event): boolean {
  return (event instanceof MouseEvent && event.type === 'click') ||
      (event instanceof KeyboardEvent && event.key === 'Enter');
}

/**
 * Fisher-Yates Shuffle
 */
export function shuffle<T>(array: T[]): T[] {
  const copy = [...array];
  for (let i = copy.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [copy[i], copy[j]] = [copy[j], copy[i]];
  }
  return copy;
}

/**
 * Checks whether the two arrays contain the same elements. Uses strict equals
 * comparison on each member of the arrays.
 */
export function isArrayEqual<T>(arr1: T[], arr2: T[]): boolean {
  return arr1.length === arr2.length &&
      arr1.every((value, index) => value === arr2[index]);
}