Source

frontend/controllers/module_search_controller.js

/* This file is part of Ezra Bible App.

   Copyright (C) 2019 - 2023 Ezra Bible App Development Team <contact@ezrabibleapp.net>

   Ezra Bible App is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 2 of the License, or
   (at your option) any later version.

   Ezra Bible App is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with Ezra Bible App. See the file LICENSE.
   If not, see <http://www.gnu.org/licenses/>. */

const eventController = require('./event_controller.js');
const {waitUntilIdle} = require('../helpers/ezra_helper.js');
const VerseSearch = require('../components/tab_search/verse_search.js');
const verseListController = require('../controllers/verse_list_controller.js');

const CANCEL_SEARCH_PERCENT_LIMIT = 90;

/**
 * The ModuleSearch controller implements the module search functionality.
 * It uses the VerseSearch component for highlighting search results.
 * 
 * @category Controller
 */
class ModuleSearchController {
  constructor() {
    this.currentSearchTerm = null;
    this.search_menu_opened = false;
    this.verseSearch = new VerseSearch();
    this.searchResultPerformanceLimit = platformHelper.getSearchResultPerformanceLimit();
    this.currentSearchCancelled = false;

    eventController.subscribe('on-tab-selected', async (tabIndex) => {
      await waitUntilIdle();

      // Cancel any potentially ongoing module search
      await this.cancelAnyModuleSearch();

      // Populate search menu based on last search (if any)
      this.populateSearchMenu(tabIndex);
    });

    eventController.subscribe('on-tab-added', async (tabIndex) => {
      // Cancel any potentially ongoing module search
      await this.cancelAnyModuleSearch();

      this.initModuleSearch(tabIndex);
    });
  }

  initModuleSearch(tabIndex=undefined) {
    var currentVerseListMenu = app_controller.getCurrentVerseListMenu(tabIndex);
    currentVerseListMenu.find('.module-search-button').unbind('click').bind('click', (event) => { this.handleSearchMenuClick(event); });
    
    // Handle the click on the search button
    $('#start-module-search-button:not(.bound)').addClass('bound').bind('click', (event) => { this.startSearch(event); });

    let moduleSearchInput = document.getElementById('module-search-input');

    if (!moduleSearchInput.classList.contains('bound')) {
      moduleSearchInput.classList.add('bound');

      if (platformHelper.isCordova()) {
        // Handle the enter key in the search field and start the search when it is pressed
        moduleSearchInput.addEventListener('beforeinput', (event) => {
          if (event.data != null) {
            const lastCharacter = event.data[event.data.length - 1];

            if (lastCharacter == '\n') {
              this.startSearch(event);
            }
          }
        });
      } else {
        // Handle the enter key in the search field and start the search when it is pressed
        moduleSearchInput.addEventListener('keydown', (event) => {
          if (event.keyCode == 13) {
            this.startSearch(event);
          }
        });
      }

      moduleSearchInput.addEventListener('keyup', () => {
        this.validateSearchTerm();
      });
    }


    var searchTypeField = document.getElementById('module-search-menu').querySelector('#search-type');
    $(searchTypeField).on("change", () => {
      this.validateSearchTerm();
    });

    var searchScopeField = document.getElementById('module-search-menu').querySelector('#search-scope');
    $(searchScopeField).on("change", () => {
      this.validateSearchTerm();
    });

    const verseListFrame = verseListController.getCurrentVerseListFrame(tabIndex);
    const searchResultsBox = $('#search-results-box');
    const cancelSearchButtonContainer1 = verseListFrame.find('.cancel-module-search-button-container');
    const cancelSearchButtonContainer2 = searchResultsBox.find('.cancel-module-search-button-container');

    const cancelSearchButton1 = cancelSearchButtonContainer1.find('button');
    const cancelSearchButton2 = cancelSearchButtonContainer2.find('button');

    cancelSearchButton1[0].addEventListener('mousedown', async () => {
      this.cancelModuleSearch();
    });

    cancelSearchButton2[0].addEventListener('mousedown', async () => {
      this.cancelModuleSearch();
    });
  }

  async cancelAnyModuleSearch() {
    for (let i = 0; i < app_controller.tab_controller.getTabCount(); i++) {
      let tab = app_controller.tab_controller.getTab(i);
      if (tab.getTextType() == 'search_results') {
        this.cancelModuleSearch(i);
      }
    }
  }

  async cancelModuleSearch(tabIndex=undefined) {
    this.currentSearchCancelled = true;
    this.disableCancelButton();

    if (tabIndex === undefined) {
      tabIndex = this.currentSearchTabIndex;
    }

    const tab = app_controller.tab_controller.getTab(tabIndex);
    const showSearchResultsInPopup = app_controller.optionsMenu._showSearchResultsInPopupOption.isChecked;

    if (tab != null && !showSearchResultsInPopup) {
      tab.setSearchCancelled(true);
    }

    this.enableOtherFunctionsAfterSearch();

    var currentProgressValue = this.getCurrentProgressValue(tabIndex);

    if (currentProgressValue != null && !isNaN(currentProgressValue)) {
      if (currentProgressValue <= CANCEL_SEARCH_PERCENT_LIMIT) {
        await ipcNsi.terminateModuleSearch();
      }
    }
  }

  getCurrentProgressValue(tabIndex=undefined) {
    if (tabIndex === undefined) {
      tabIndex = this.currentSearchTabIndex;
    }

    var searchProgressBar = verseListController.getCurrentSearchProgressBar(tabIndex);
    var currentProgressValue = null;

    try {
      currentProgressValue = parseInt(searchProgressBar[0].getAttribute("aria-valuenow"));
    } catch (e) {
      console.log('Got error from progress bar', e);
    }

    return currentProgressValue;
  }

  disableCancelButton() {
    var cancelSearchButtonContainer = verseListController.getCurrentSearchCancelButtonContainer(this.currentSearchTabIndex);
    var cancelSearchButton = cancelSearchButtonContainer.find('button');

    cancelSearchButton.removeClass('ui-state-active');
    cancelSearchButton.addClass('ui-state-disabled');
  }

  getAllMenuButtonSelectors() {
    return [
      '.book-select-button',
      '.tag-select-button',
      '.assign-tag-menu-button',
      '.new-standard-tag-button',
      '.display-options-button',
      '.text-size-settings-button'
    ];
  }

  toggleMenuButtons(enable=true) {
    var currentVerseListMenu = app_controller.getCurrentVerseListMenu();
    var allMenuButtonSelectors = this.getAllMenuButtonSelectors();

    allMenuButtonSelectors.forEach((selector) => {
      var button = currentVerseListMenu.find(selector);

      if (enable) {
        button.removeClass('ui-state-disabled');
      } else {
        button.addClass('ui-state-disabled');
      }
    });
  }

  disableOtherFunctionsDuringSearch() {
    this.toggleMenuButtons(false);

    var currentVerseListMenu = app_controller.getCurrentVerseListMenu();
    var bibleSelect = currentVerseListMenu.find('select.bible-select');
    bibleSelect.selectmenu("disable");
  }

  enableOtherFunctionsAfterSearch() {
    this.toggleMenuButtons(true);

    var currentVerseListMenu = app_controller.getCurrentVerseListMenu(this.currentSearchTabIndex);

    if (currentVerseListMenu != null) {
      var bibleSelect = currentVerseListMenu.find('select.bible-select');
      bibleSelect.selectmenu("enable");
    }
  }

  validateSearchTerm() {
    if (this.getSearchType() == "strongsNumber") {
      this.validateStrongsKey();
    } else {
      this.setModuleSearchValid();
    }
  }

  hideSearchMenu() {
    if (this.search_menu_opened) {
      $('#app-container').find('#module-search-menu').hide();
      this.search_menu_opened = false;

      var module_search_button = $('#app-container').find('.module-search-button');
      module_search_button.removeClass('ui-state-active');
    }
  }

  resetSearch(tabIndex=undefined) {
    $('#module-search-input').val('');
    $('#search-type')[0].value = "phrase";
    $('#search-scope')[0].value = "BIBLE";
    $('#search-is-case-sensitive').prop("checked", false);
    $('#search-extended-verse-boundaries').prop("checked", false);
    this.clearModuleSearchHeader(tabIndex);
    this.hideModuleSearchHeader(tabIndex);
    app_controller.verse_statistics_chart.resetChart(tabIndex);
    this.currentSearchCancelled = false;

    var tab = app_controller.tab_controller.getTab(tabIndex);
    if (tab != null) {
      tab.setSearchCancelled(false);
    }
  }

  clearModuleSearchHeader(tabIndex=undefined) {
    this.getModuleSearchHeader(tabIndex).html('');
  }

  hideModuleSearchHeader(tabIndex=undefined) {
    this.getModuleSearchHeader(tabIndex).hide();
  }

  populateSearchMenu(tabIndex) {
    var currentTab = app_controller.tab_controller.getTab(tabIndex);

    if (currentTab != null) {
      var searchType = currentTab.getSearchOptions()['searchType'];
      var searchScope = currentTab.getSearchOptions()['searchScope'];
      var isCaseSensitive = currentTab.getSearchOptions()['caseSensitive'];
      var useExtendedVerseBoundaries = currentTab.getSearchOptions()['extendedVerseBoundaries'];
      var searchTerm = currentTab.getSearchTerm();

      $('#search-type').val(searchType);
      $('#search-scope').val(searchScope);
      $('#search-is-case-sensitive').prop("checked", isCaseSensitive);
      $('#search-extended-verse-boundaries').prop("checked", useExtendedVerseBoundaries);
      $('#module-search-input').val(searchTerm);
    }
  }

  handleSearchMenuClick(event) {
    var currentVerseListMenu = app_controller.getCurrentVerseListMenu();
    var moduleSearchButton = currentVerseListMenu.find('.module-search-button');

    if (moduleSearchButton.hasClass('ui-state-disabled')) {
      return;
    }

    if (this.search_menu_opened) {
      app_controller.handleBodyClick();
    } else {
      app_controller.hideAllMenus();

      moduleSearchButton.addClass('ui-state-active');

      var module_search_button_offset = moduleSearchButton.offset();
      var menu = $('#app-container').find('#module-search-menu');
      var top_offset = module_search_button_offset.top + moduleSearchButton.height() + 1;
      var left_offset = module_search_button_offset.left;

      menu.css('top', top_offset);
      menu.css('left', left_offset);

      $('#app-container').find('#module-search-menu').show();
      $('#module-search-input').focus(function() { $(this).select(); } );
      $('#module-search-input').focus();

      this.search_menu_opened = true;
      event.stopPropagation();
    }
  }

  getSearchTerm() {
    return document.getElementById('module-search-input').value;
  }

  getSearchType() {
    var selectField = document.getElementById('module-search-menu').querySelector('#search-type');
    var selectedValue = selectField.options[selectField.selectedIndex].value;
    return selectedValue;
  }

  getSearchScope() {
    var selectField = document.getElementById('module-search-menu').querySelector('#search-scope');
    var selectedValue = selectField.options[selectField.selectedIndex].value;
    return selectedValue;
  }

  isCaseSensitive() {
    return document.getElementById('search-is-case-sensitive').checked;
  }

  useExtendedVerseBoundaries() {
    return document.getElementById('search-extended-verse-boundaries').checked;
  }

  getModuleSearchHeader(tabIndex=undefined) {
    const showSearchResultsInPopup = app_controller.optionsMenu._showSearchResultsInPopupOption.isChecked;
    let verseListHeader = null;

    if (showSearchResultsInPopup) {
      const $dialogBox = $('#search-results-box');
      verseListHeader = $dialogBox.find('.verse-list-header');
    } else {
      const currentVerseListFrame = verseListController.getCurrentVerseListFrame(tabIndex);
      verseListHeader = currentVerseListFrame.find('.verse-list-header');
    }

    return verseListHeader;
  }

  searchResultsExceedPerformanceLimit(index=undefined) {
    if (index === undefined) {
      index = app_controller.tab_controller.getSelectedTabIndex();
    }

    var currentSearchResults = app_controller.tab_controller.getTab(index).getSearchResults();

    if (currentSearchResults != null) {
      return currentSearchResults.length > this.searchResultPerformanceLimit;
    } else {
      return false;
    }
  }

  validateStrongsKey() {
    if (!app_controller.dictionary_controller.isValidStrongsKey(this.getSearchTerm())) {
      $('#module-search-validation-message').css('visibility', 'visible');
      $('#module-search-validation-message').prop('title', i18n.t('bible-browser.strongs-number-not-valid'));
      $('#start-module-search-button').addClass('ui-state-disabled');
    } else {
      this.setModuleSearchValid();
    }
  }

  setModuleSearchValid() {
    $('#module-search-validation-message').css('visibility', 'hidden');
    $('#module-search-validation-message').prop('title', '');
    $('#start-module-search-button').removeClass('ui-state-disabled');
  }

  async startSearch(event, tabIndex=undefined, searchTerm=undefined) {
    if (event != null) {
      event.stopPropagation();
    }
    
    if (searchTerm !== undefined) {
      this.currentSearchTerm = searchTerm;
    } else {
      this.currentSearchTerm = this.getSearchTerm();
    }

    if (this.currentSearchTerm == null || this.currentSearchTerm.length == 0) {
      return;
    }

    this.currentSearchTabIndex = app_controller.tab_controller.getSelectedTabIndex();

    // Do not allow another concurrent search, disable the search menu button
    $('.module-search-button').addClass('ui-state-disabled');

    this.disableOtherFunctionsDuringSearch();

    const showSearchResultsInPopup = app_controller.optionsMenu._showSearchResultsInPopupOption.isChecked;

    if (tabIndex === undefined) {
      var tab = app_controller.tab_controller.getTab();

      tab.setSearchOptions(this.getSearchType(),
                           this.getSearchScope(),
                           this.isCaseSensitive(),
                           this.useExtendedVerseBoundaries());
      
      if (!showSearchResultsInPopup) {
        tab.setTextType('search_results');
        tab.setSearchCancelled(false);
      }
    }

    await eventController.publishAsync('on-module-search-started', tabIndex);

    //console.log("Starting search for " + this.currentSearchTerm + " on tab " + tabIndex);
    var currentTab = app_controller.tab_controller.getTab(tabIndex);

    if (currentTab != null) {
      var currentBibleTranslationId = currentTab.getBibleTranslationId();
      var searchType = currentTab.getSearchOptions()['searchType'];

      var searchScope = currentTab.getSearchOptions()['searchScope'];
      if (searchScope == null) {
        searchScope = "BIBLE";
      }

      const isCaseSensitive = currentTab.getSearchOptions()['caseSensitive'];
      const useExtendedVerseBoundaries = currentTab.getSearchOptions()['extendedVerseBoundaries'];

      if (searchType == "strongsNumber" && event != null) {
        if (!app_controller.dictionary_controller.isValidStrongsKey(this.currentSearchTerm)) {
          return;
        }

        app_controller.dictionary_controller.showStrongsInfo(this.currentSearchTerm, false /* do not show strongs box */);
      }

      app_controller.tab_controller.setTabSearch(this.currentSearchTerm, tabIndex);

      if (!showSearchResultsInPopup) {
        // Set book, tagIdList and xrefs to null, since we just switched to search content
        currentTab.setBook(null, null, null);
        currentTab.setTagIdList("");
        currentTab.setXrefs(null);
        currentTab.setReferenceVerseElementId(null);
        app_controller.tag_selection_menu.resetTagMenu();
      }

      this.hideSearchMenu();

      var searchProgressBar = verseListController.getCurrentSearchProgressBar();

      if (showSearchResultsInPopup) {
        await waitUntilIdle();

        this.clearModuleSearchHeader(tabIndex);

        const dialogWidth = uiHelper.getMaxDialogWidth();
        const dialogHeight = 550;
        const draggable = true;
        const position = [55, 120];
    
        let dialogOptions = uiHelper.getDialogOptions(dialogWidth, dialogHeight, draggable, position);
        dialogOptions.title = `${i18n.t("bible-browser.search-result-header")} <i>${this.currentSearchTerm}</i>`;
        dialogOptions.dialogClass = 'ezra-dialog search-results-box';
        
        const $dialogBox = $('#search-results-box');
        $dialogBox.dialog(dialogOptions);
        uiHelper.fixDialogCloseIconOnAndroid('search-results-box');
      }

      if (tabIndex == undefined || tabIndex == app_controller.tab_controller.getSelectedTabIndex()) {
        var cancelSearchButtonContainer = verseListController.getCurrentSearchCancelButtonContainer(tabIndex);
        var cancelSearchButton = cancelSearchButtonContainer.find('button');
        cancelSearchButton.removeClass('ui-state-disabled');

        uiHelper.initProgressBar(searchProgressBar);
        searchProgressBar.show();
        cancelSearchButtonContainer.show();
      }

      if (showSearchResultsInPopup) {
        $('#search-results-box-content')[0].innerHTML = '';
      } else {
        // Only reset view if we got an event (in other words: not initially)
        await app_controller.text_controller.prepareForNewText(event != null, true, tabIndex);
      }

      try {
        Sentry.addBreadcrumb({category: "app",
                              message: `Performing module search in ${currentBibleTranslationId}`,
                              level: "info"});

        var searchResults = await ipcNsi.getModuleSearchResults(
          (progress) => {
            var progressPercent = progress.totalPercent;
            searchProgressBar.progressbar("value", progressPercent);
            if (progressPercent >= CANCEL_SEARCH_PERCENT_LIMIT) {
              this.disableCancelButton();
            }
          },
          currentBibleTranslationId,
          this.currentSearchTerm,
          searchType,
          searchScope,
          isCaseSensitive,
          useExtendedVerseBoundaries
        );

        //console.log("Got " + searchResults.length + " from Sword");
        currentTab.setSearchResults(searchResults);

        var searchResultBookId = -1; // all books requested
        if (this.searchResultsExceedPerformanceLimit(this.currentSearchTabIndex)) {
          searchResultBookId = 0; // no books requested - only list headers at first
        }

        let target=undefined;
        if (showSearchResultsInPopup) {
          target = $('#search-results-box-content');
        }
  
        await this.renderCurrentSearchResults(searchResultBookId, this.currentSearchTabIndex, target);
      } catch (error) {
        console.log(error);
        verseListController.hideVerseListLoadingIndicator();
        this.enableOtherFunctionsAfterSearch();
      }
    }

    // We're done with the search and so we're re-enabling the search menu button
    $('.module-search-button').removeClass('ui-state-disabled');
  }

  highlightSearchResults(searchTerm, tabIndex=undefined) {
    const showSearchResultsInPopup = app_controller.optionsMenu._showSearchResultsInPopupOption.isChecked;
    var currentVerseList = null;

    if (showSearchResultsInPopup) {
      const $dialogBox = $('#search-results-box');
      currentVerseList = $dialogBox.find('#search-results-box-content');
    } else {
      currentVerseList = verseListController.getCurrentVerseList(tabIndex);
    }

    var verses = currentVerseList[0].querySelectorAll('.verse-text');

    var searchType = this.getSearchType();
    var isCaseSensitive = this.isCaseSensitive();
    var useExtendedVerseBoundaries = this.useExtendedVerseBoundaries();

    for (var i = 0; i < verses.length; i++) {
      var currentVerse = verses[i];
      this.verseSearch.doVerseSearch(currentVerse, searchTerm, searchType, isCaseSensitive, useExtendedVerseBoundaries);
    }
  }

  async renderCurrentSearchResults(searchResultBookId=-1, tabIndex=undefined, target=undefined, cachedText=null) {
    //console.log("Rendering search results on tab " + tabIndex);

    const showSearchResultsInPopup = app_controller.optionsMenu._showSearchResultsInPopupOption.isChecked;

    const currentTab = app_controller.tab_controller.getTab(tabIndex);
    const currentTabId = app_controller.tab_controller.getSelectedTabId(tabIndex);
    const currentSearchTerm = currentTab.getSearchTerm();
    const currentSearchResults = currentTab.getSearchResults();

    if (currentSearchResults != null && currentSearchResults.length > 0) {
      await app_controller.text_controller.requestTextUpdate(currentTabId,
                                                             null,
                                                             null,
                                                             cachedText,
                                                             null,
                                                             currentSearchResults,
                                                             null,
                                                             null,
                                                             true,
                                                             tabIndex,
                                                             searchResultBookId,
                                                             target);
      
    } else {
      verseListController.hideVerseListLoadingIndicator(this.currentSearchTabIndex);
      uiHelper.hideTextLoadingIndicator(this.currentSearchTabIndex);
      verseListController.hideSearchProgressBar(this.currentSearchTabIndex);
    }

    this.hideSearchMenu();
    var moduleSearchHeaderText;

    if (currentSearchResults != null && currentSearchResults.length > 0) {
      moduleSearchHeaderText = `<span i18n="bible-browser.search-result-header">${i18n.t("bible-browser.search-result-header")}</span> <i>${currentSearchTerm}</i> (${currentSearchResults.length})`;
    } else {
      var searchCancelled = false;

      if (showSearchResultsInPopup) {
        searchCancelled = this.currentSearchCancelled;
      } else {
        const tab = app_controller.tab_controller.getTab(tabIndex);
        searchCancelled = tab != null ? tab.isSearchCancelled() : false;
      }

      if (searchCancelled) {
        moduleSearchHeaderText = `<span i18n="bible-browser.module-search-cancelled">${i18n.t("bible-browser.module-search-cancelled")} <i>${currentSearchTerm}</i>`;
      } else {
        moduleSearchHeaderText = `<span i18n="bible-browser.no-search-results">${i18n.t("bible-browser.no-search-results")} <i>${currentSearchTerm}</i>`;
      }
    }

    var header = "";

    header += "<h2>" + moduleSearchHeaderText + "</h2>";

    if (this.searchResultsExceedPerformanceLimit(tabIndex)) {
      header += `<div style="margin-left: 0.6em; margin-top: 1em;" i18n="bible-browser.search-performance-hint">${i18n.t("bible-browser.search-performance-hint")}</div>`;
    }

    var moduleSearchHeader = this.getModuleSearchHeader(tabIndex);
    moduleSearchHeader.html(header);

    if (!showSearchResultsInPopup && currentSearchResults != null && currentSearchResults.length > 0) {
      uiHelper.addButton(moduleSearchHeader,
                         'select-all-verses-button',
                         'bible-browser.select-all-search-results',
                         this.selectAllSearchResults,
                         this.searchResultsExceedPerformanceLimit(tabIndex));

      uiHelper.configureButtonStyles('.verse-list-header');
    }

    moduleSearchHeader.show();

    this.enableOtherFunctionsAfterSearch();
  }

  selectAllSearchResults() {
    app_controller.verse_selection.selectAllVerses('bible-browser.all-search-results');
  }

  getBibleBookStatsFromSearchResults(search_results) {
    var bibleBookStats = {};

    if (search_results != null) {
      for (var i = 0; i < search_results.length; i++) {
        var bibleBookId = search_results[i].bibleBookShortTitle;

        if (bibleBookStats[bibleBookId] === undefined) {
          bibleBookStats[bibleBookId] = 1;
        } else {
          bibleBookStats[bibleBookId] += 1;
        }
      }
    }

    return bibleBookStats;
  }

  loadBookResults(bookId) {
    const showSearchResultsInPopup = app_controller.optionsMenu._showSearchResultsInPopupOption.isChecked;
    const currentTabId = app_controller.tab_controller.getSelectedTabId();
    let parentElement = null;

    if (showSearchResultsInPopup) {
      parentElement = $('#search-results-box');
    } else {
      parentElement = $('#' + currentTabId);
    }

    const bookSection = parentElement.find('#' + currentTabId + '-book-section-' + bookId);

    this.renderCurrentSearchResults(bookId, undefined, bookSection);
  }
}

module.exports = ModuleSearchController;