chromium/chrome/browser/resources/chromeos/accessibility/accessibility_common/dictation/parse/simple_parse_strategy.js

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

/**
 * @fileoverview Defines a simple strategy for parsing text and converting
 * it into a Macro.
 */

import {InputController} from '/common/action_fulfillment/input_controller.js';
import {DeletePrevSentMacro} from '/common/action_fulfillment/macros/delete_prev_sent_macro.js';
import {InputTextViewMacro, NewLineMacro} from '/common/action_fulfillment/macros/input_text_view_macro.js';
import {Macro} from '/common/action_fulfillment/macros/macro.js';
import {MacroName} from '/common/action_fulfillment/macros/macro_names.js';
import {NavNextSentMacro, NavPrevSentMacro} from '/common/action_fulfillment/macros/nav_sent_macro.js';
import {RepeatMacro} from '/common/action_fulfillment/macros/repeat_macro.js';
import * as RepeatableKeyPress from '/common/action_fulfillment/macros/repeatable_key_press_macro.js';
import {SmartDeletePhraseMacro} from '/common/action_fulfillment/macros/smart_delete_phrase_macro.js';
import {SmartInsertBeforeMacro} from '/common/action_fulfillment/macros/smart_insert_before_macro.js';
import {SmartReplacePhraseMacro} from '/common/action_fulfillment/macros/smart_replace_phrase_macro.js';
import {SmartSelectBetweenMacro} from '/common/action_fulfillment/macros/smart_select_between_macro.js';
import {ToggleDictationMacro} from '/common/action_fulfillment/macros/toggle_dictation_macro.js';

import {LocaleInfo} from '../locale_info.js';
import {ListCommandsMacro} from '../macros/list_commands_macro.js';

import {ParseStrategy} from './parse_strategy.js';

/**
 * @typedef {{
 *   messageId: string,
 *   build: !Function,
 * }}
 */
let MacroData;

/**
 * SimpleMacroFactory converts spoken strings into Macros using string matching.
 */
class SimpleMacroFactory {
  /**
   * @param {!MacroName} macroName
   * @param {!InputController} inputController
   */
  constructor(macroName, inputController) {
    if (!SimpleMacroFactory.getData_()[macroName]) {
      throw new Error(
          'Macro is not supported by SimpleMacroFactory: ' + macroName);
    }

    /** @private {!MacroName} */
    this.macroName_ = macroName;
    /** @private {!InputController} */
    this.inputController_ = inputController;

    /** @private {RegExp} */
    this.commandRegex_ = null;
    this.initializeCommandRegex_(this.macroName_);
  }

  /**
   * Builds a RegExp that can be used to parse a command. For example, the
   * SmartReplacePhraseMacro can be parsed with the pattern:
   * /replace (.*) with (.*)/i.
   * @param {!MacroName} macroName
   * @private
   */
  initializeCommandRegex_(macroName) {
    const matchAnythingPattern = '(.*)';
    const args = [];
    switch (macroName) {
      case MacroName.INPUT_TEXT_VIEW:
      case MacroName.SMART_DELETE_PHRASE:
        args.push(matchAnythingPattern);
        break;
      case MacroName.SMART_REPLACE_PHRASE:
      case MacroName.SMART_INSERT_BEFORE:
      case MacroName.SMART_SELECT_BTWN_INCL:
        args.push(matchAnythingPattern, matchAnythingPattern);
        break;
    }
    const message = chrome.i18n.getMessage(
        SimpleMacroFactory.getData_()[macroName].messageId, args);
    const pattern = `^${message}$`;
    if (LocaleInfo.considerSpaces()) {
      this.commandRegex_ = new RegExp(pattern, 'i');
    } else {
      // A regex to be used if the Dictation language doesn't use spaces e.g.
      // Japanese.
      this.commandRegex_ = new RegExp(pattern.replace(/\s+/g, ''), 'i');
    }
  }

  /**
   * @param {string} text
   * @return {Macro|null}
   */
  createMacro(text) {
    // Check whether `text` matches `this.commandRegex_`, ignoring case and
    // whitespace.
    text = text.trim().toLowerCase();
    if (!this.commandRegex_.test(text)) {
      return null;
    }

    const initialArgs = [];
    switch (this.macroName_) {
      case MacroName.COPY_SELECTED_TEXT:
      case MacroName.CUT_SELECTED_TEXT:
      case MacroName.DELETE_ALL_TEXT:
      case MacroName.DELETE_PREV_CHAR:
      case MacroName.DELETE_PREV_SENT:
      case MacroName.DELETE_PREV_WORD:
      case MacroName.NAV_END_TEXT:
      case MacroName.NAV_NEXT_CHAR:
      case MacroName.NAV_NEXT_LINE:
      case MacroName.NAV_NEXT_SENT:
      case MacroName.NAV_NEXT_WORD:
      case MacroName.NAV_START_TEXT:
      case MacroName.NAV_PREV_CHAR:
      case MacroName.NAV_PREV_LINE:
      case MacroName.NAV_PREV_SENT:
      case MacroName.NAV_PREV_WORD:
      case MacroName.NEW_LINE:
      case MacroName.SELECT_ALL_TEXT:
      case MacroName.SELECT_NEXT_CHAR:
      case MacroName.SELECT_NEXT_WORD:
      case MacroName.SELECT_PREV_CHAR:
      case MacroName.SELECT_PREV_WORD:
      case MacroName.SMART_DELETE_PHRASE:
      case MacroName.SMART_INSERT_BEFORE:
      case MacroName.SMART_REPLACE_PHRASE:
      case MacroName.SMART_SELECT_BTWN_INCL:
      case MacroName.UNSELECT_TEXT:
        initialArgs.push(this.inputController_);
        break;
    }

    const result = this.commandRegex_.exec(text);
    // `result[0]` contains the entire matched text, while all subsequent
    // indices contain text matched by each /(.*)/. We're only interested in
    // text matched by /(.*)/, so ignore `result[0]`.
    const extractedArgs = result.slice(1);
    const finalArgs = initialArgs.concat(extractedArgs);
    const data = SimpleMacroFactory.getData_();
    const macro = new data[this.macroName_].build(...finalArgs);
    if (macro.isSmart() && !LocaleInfo.allowSmartEditing()) {
      return null;
    }

    return macro;
  }

  /**
   * Returns data that is used to create a macro. `messageId` is used to
   * retrieve the macro's command string and `build` is used to construct the
   * macro.
   * @return {Object<MacroName, MacroData>}
   * @private
   */
  static getData_() {
    return {
      [MacroName.DELETE_PREV_CHAR]: {
        messageId: 'dictation_command_delete_prev_char',
        build: RepeatableKeyPress.DeletePreviousCharacterMacro,
      },
      [MacroName.NAV_PREV_CHAR]: {
        messageId: 'dictation_command_nav_prev_char',
        build: RepeatableKeyPress.NavPreviousCharMacro,
      },
      [MacroName.NAV_NEXT_CHAR]: {
        messageId: 'dictation_command_nav_next_char',
        build: RepeatableKeyPress.NavNextCharMacro,
      },
      [MacroName.NAV_PREV_LINE]: {
        messageId: 'dictation_command_nav_prev_line',
        build: RepeatableKeyPress.NavPreviousLineMacro,
      },
      [MacroName.NAV_NEXT_LINE]: {
        messageId: 'dictation_command_nav_next_line',
        build: RepeatableKeyPress.NavNextLineMacro,
      },
      [MacroName.COPY_SELECTED_TEXT]: {
        messageId: 'dictation_command_copy_selected_text',
        build: RepeatableKeyPress.CopySelectedTextMacro,
      },
      [MacroName.PASTE_TEXT]: {
        messageId: 'dictation_command_paste_text',
        build: RepeatableKeyPress.PasteTextMacro,
      },
      [MacroName.CUT_SELECTED_TEXT]: {
        messageId: 'dictation_command_cut_selected_text',
        build: RepeatableKeyPress.CutSelectedTextMacro,
      },
      [MacroName.UNDO_TEXT_EDIT]: {
        messageId: 'dictation_command_undo_text_edit',
        build: RepeatableKeyPress.UndoTextEditMacro,
      },
      [MacroName.REDO_ACTION]: {
        messageId: 'dictation_command_redo_action',
        build: RepeatableKeyPress.RedoActionMacro,
      },
      [MacroName.SELECT_ALL_TEXT]: {
        messageId: 'dictation_command_select_all_text',
        build: RepeatableKeyPress.SelectAllTextMacro,
      },
      [MacroName.UNSELECT_TEXT]: {
        messageId: 'dictation_command_unselect_text',
        build: RepeatableKeyPress.UnselectTextMacro,
      },
      [MacroName.LIST_COMMANDS]: {
        messageId: 'dictation_command_list_commands',
        build: ListCommandsMacro,
      },
      [MacroName.NEW_LINE]:
          {messageId: 'dictation_command_new_line', build: NewLineMacro},
      [MacroName.TOGGLE_DICTATION]: {
        messageId: 'dictation_command_stop_listening',
        build: ToggleDictationMacro,
      },
      [MacroName.DELETE_PREV_WORD]: {
        messageId: 'dictation_command_delete_prev_word',
        build: RepeatableKeyPress.DeletePrevWordMacro,
      },
      [MacroName.DELETE_PREV_SENT]: {
        messageId: 'dictation_command_delete_prev_sent',
        build: DeletePrevSentMacro,
      },
      [MacroName.NAV_NEXT_WORD]: {
        messageId: 'dictation_command_nav_next_word',
        build: RepeatableKeyPress.NavNextWordMacro,
      },
      [MacroName.NAV_PREV_WORD]: {
        messageId: 'dictation_command_nav_prev_word',
        build: RepeatableKeyPress.NavPrevWordMacro,
      },
      [MacroName.SMART_DELETE_PHRASE]: {
        messageId: 'dictation_command_smart_delete_phrase',
        build: SmartDeletePhraseMacro,
      },
      [MacroName.SMART_REPLACE_PHRASE]: {
        messageId: 'dictation_command_smart_replace_phrase',
        build: SmartReplacePhraseMacro,
      },
      [MacroName.SMART_INSERT_BEFORE]: {
        messageId: 'dictation_command_smart_insert_before',
        build: SmartInsertBeforeMacro,
      },
      [MacroName.SMART_SELECT_BTWN_INCL]: {
        messageId: 'dictation_command_smart_select_btwn_incl',
        build: SmartSelectBetweenMacro,
      },
      [MacroName.NAV_NEXT_SENT]: {
        messageId: 'dictation_command_nav_next_sent',
        build: NavNextSentMacro,
      },
      [MacroName.NAV_PREV_SENT]: {
        messageId: 'dictation_command_nav_prev_sent',
        build: NavPrevSentMacro,
      },
    };
  }
}

/** A parsing strategy that utilizes SimpleMacroFactory. */
export class SimpleParseStrategy extends ParseStrategy {
  /** @param {!InputController} inputController */
  constructor(inputController) {
    super(inputController);

    /**
     * Map of macro names to a factory for that macro.
     * @private {!Map<MacroName, !SimpleMacroFactory>}
     */
    this.macroFactoryMap_ = new Map();

    /** @private {!Set<!MacroName>} */
    this.supportedMacros_ = new Set();

    this.initialize_();
  }

  /** @private */
  initialize_() {
    // Adds all macros that are supported by regular expressions. If a macro
    // has a string associated with it in dictation_strings.grd, then it belongs
    // in this set. Don't add macros that require arguments in their utterances
    // e.g. "select <phrase_or_word>" - these macros are better handled by
    // Pumpkin.
    this.supportedMacros_.add(MacroName.DELETE_PREV_CHAR)
        .add(MacroName.NAV_PREV_CHAR)
        .add(MacroName.NAV_NEXT_CHAR)
        .add(MacroName.NAV_PREV_LINE)
        .add(MacroName.NAV_NEXT_LINE)
        .add(MacroName.COPY_SELECTED_TEXT)
        .add(MacroName.PASTE_TEXT)
        .add(MacroName.CUT_SELECTED_TEXT)
        .add(MacroName.UNDO_TEXT_EDIT)
        .add(MacroName.REDO_ACTION)
        .add(MacroName.SELECT_ALL_TEXT)
        .add(MacroName.UNSELECT_TEXT)
        .add(MacroName.LIST_COMMANDS)
        .add(MacroName.NEW_LINE)
        .add(MacroName.TOGGLE_DICTATION)
        .add(MacroName.DELETE_PREV_WORD)
        .add(MacroName.DELETE_PREV_SENT)
        .add(MacroName.NAV_NEXT_WORD)
        .add(MacroName.NAV_PREV_WORD)
        .add(MacroName.SMART_DELETE_PHRASE)
        .add(MacroName.SMART_REPLACE_PHRASE)
        .add(MacroName.SMART_INSERT_BEFORE)
        .add(MacroName.SMART_SELECT_BTWN_INCL)
        .add(MacroName.NAV_NEXT_SENT)
        .add(MacroName.NAV_PREV_SENT);

    this.supportedMacros_.forEach((name) => {
      this.macroFactoryMap_.set(
          name, new SimpleMacroFactory(name, this.getInputController()));
    });
  }

  /** @override */
  refresh() {
    this.enabled = LocaleInfo.areCommandsSupported();
    if (!this.enabled) {
      return;
    }

    this.macroFactoryMap_ = new Map();
    this.initialize_();
  }

  /** @override */
  async parse(text) {
    const macros = [];
    for (const [name, factory] of this.macroFactoryMap_) {
      const macro = factory.createMacro(text);
      if (macro) {
        macros.push(macro);
      }
    }
    if (macros.length === 1) {
      return macros[0];
    } else if (macros.length === 2) {
      // Pick which macro to use from the list of matched macros.
      // TODO(crbug.com/1288965): Turn this into a disambiguation class as we
      // add more commands. Currently the only ambiguous macro is DELETE_PHRASE
      // which conflicts with other deletion macros. For example, the phrase
      // "Delete the previous word" should be parsed as a DELETE_PREV_WORD
      // instead of SMART_DELETE_PHRASE with phrase "the previous word".
      // Prioritize other deletion macros over SMART_DELETE_PHRASE.
      return macros[0].getName() === MacroName.SMART_DELETE_PHRASE ? macros[1] :
                                                                     macros[0];
    } else if (macros.length > 2) {
      console.warn(`Unexpected ambiguous macros found for text: ${text}.`);
      return macros[0];
    }

    // The command is simply to input the given text.
    // If `text` starts with `type`, then automatically remove it e.g. convert
    // 'Type testing 123' to 'testing 123'.
    const typePrefix =
        chrome.i18n.getMessage('dictation_command_input_text_view');
    if (text.trim().toLowerCase().startsWith(typePrefix)) {
      text = text.toLowerCase().replace(typePrefix, '').trimStart();
    }

    return new InputTextViewMacro(text, this.getInputController());
  }
}