Source

frontend/controllers/app_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 Mousetrap = require('mousetrap');

const VerseBoxHelper = require("../helpers/verse_box_helper.js");
const VerseSelection = require("../components/verse_selection.js");
const VerseListPopup = require("../components/verse_list_popup.js");
const TagSelectionMenu = require("../components/tags/tag_selection_menu.js");
const TagStatistics = require("../components/tool_panel/tag_statistics.js");
const DocxExport = require("../components/docx_export/docx_export.js");
const ModuleSearchController = require("./module_search_controller.js");
const TranslationController = require("./translation_controller.js");
const TextController = require("./text_controller.js");
const VerseContextController = require("./verse_context_controller.js");
const TabSearch = require("../components/tab_search/tab_search.js");
const TabController = require("./tab_controller.js");
const OptionsMenu = require("../components/options_menu/options_menu.js");
const NavigationPane = require("../components/navigation_pane.js");
const TranslationComparison = require("../components/tool_panel/translation_comparison.js");
const CommentaryPanel = require("../components/tool_panel/commentary_panel.js");
const BookSelectionMenu = require("../components/book_selection_menu.js");
const DictionaryController = require("./dictionary_controller.js");
const NotesController = require("./notes_controller.js");
const SwordNotes = require("../components/sword_notes.js");
const InfoPopup = require("../components/info_popup.js");
const TextSizeSettings = require("../components/text_size_settings.js");
const VerseStatisticsChart = require('../components/verse_statistics_chart.js');
const verseListController = require('./verse_list_controller.js');
const referenceVerseController = require('./reference_verse_controller.js');
const { waitUntilIdle } = require('../helpers/ezra_helper.js');
const eventController = require('./event_controller.js');
const wheelnavController = require('./wheelnav_controller.js');
const fullscreenController = require('./fullscreen_controller.js');
const moduleUpdateController = require('./module_update_controller.js');
const transChangeTitles = require('../components/trans_change_titles.js');
const sectionLabelHelper = require('../helpers/section_label_helper.js');

/**
 * AppController is Ezra Bible App's main controller class which initiates all other controllers and components.
 * It is only instantiated once and an instance is available at `global.app_controller`.
 * 
 * @category Controller
 */
class AppController {
  constructor() {
    this.verseContextMenuOpened = false;
    this.startupCompleted = false;
  }

  isStartupCompleted() {
    return this.startupCompleted;
  }

  /**
   * This function is a "macro" for instantiating a component as a member of AppController.
   * 
   * @param {String} componentClassName The class name of the component 
   * @param {String} componentName The variable name that this component shall get within the AppController instance
   * @param {String} componentPath The path to the component js file
   */
  init_component(componentClassName, componentName) {
    var expression = "";
    expression += "this." + componentName + " = new " + componentClassName + "();";
    eval(expression);
  }

  async init() {
    this.tabHtmlTemplate = $($('.verse-list-container')[0]).html();

    this.init_component("VerseBoxHelper", "verse_box_helper");
    this.init_component("VerseSelection", "verse_selection");
    this.init_component("TagSelectionMenu", "tag_selection_menu");
    this.init_component("TagStatistics", "tag_statistics");
    this.init_component("ModuleSearchController", "module_search_controller");
    this.init_component("TranslationController", "translation_controller");
    this.init_component("TextController", "text_controller");
    this.init_component("DocxExport", "docxExport");
    this.init_component("VerseContextController", "verse_context_controller");
    this.init_component("TabController", "tab_controller");
    this.init_component("OptionsMenu", "optionsMenu");
    this.init_component("NavigationPane", "navigation_pane");
    this.init_component("TranslationComparison", "translationComparison");
    this.init_component("CommentaryPanel", "commentaryPanel");
    this.init_component("BookSelectionMenu", "book_selection_menu");
    this.init_component("VerseListPopup", "verse_list_popup");
    this.init_component("DictionaryController", "dictionary_controller");
    this.init_component("NotesController", "notes_controller");
    this.init_component("SwordNotes", "sword_notes");
    this.init_component("InfoPopup", "info_popup");
    this.init_component("TextSizeSettings", "textSizeSettings");
    this.init_component("VerseStatisticsChart", "verse_statistics_chart");

    this.moduleAssistant = document.querySelector('module-assistant');

    this.initGlobalShortCuts();

    await this.book_selection_menu.init();

    var bibleTranslations = await ipcNsi.getAllLocalModules();
    var defaultBibleTranslationId = null;

    if (bibleTranslations != null && bibleTranslations.length > 0) {
      defaultBibleTranslationId = bibleTranslations[0].name;
    }

    this.tab_controller.init('verse-list-tabs', 'verse-list-container', 'add-tab-button', this.tabHtmlTemplate, defaultBibleTranslationId);
    
    fullscreenController.init();
    wheelnavController.init();
    verseListController.init();
    moduleUpdateController.init();
    transChangeTitles.init();

    eventController.subscribe('on-tab-selected', async (tabIndex=0) => { await this.onTabSelected(tabIndex); });
    eventController.subscribe('on-tab-added', (tabIndex) => { this.onTabAdded(tabIndex); });
    eventController.subscribe('on-verses-selected', (details) => { this.toggleVerseContextMenuButton(details.tabIndex); });
    eventController.subscribe('on-tag-group-list-activated', () => { this.hideAllMenus(); });
    eventController.subscribe('on-tag-group-selected', () => { this.hideAllMenus(); });
    eventController.subscribe('on-button-clicked', () => { this.hideAllMenus(); });

    this.verse_context_controller.initButtonEvents();
  }

  toggleVerseContextMenuButton(tabIndex=undefined) {
    var currentVerseListMenu = this.getCurrentVerseListMenu(tabIndex);
    var verseContextMenuButton = currentVerseListMenu[0].querySelector('.verse-context-menu-button');

    if (app_controller.verse_selection.versesSelected()) {
      verseContextMenuButton.classList.remove('ui-state-disabled');
    } else {
      verseContextMenuButton.classList.add('ui-state-disabled');
    }
  }

  async onTabSelected(tabIndex=0) {
    var metaTab = this.tab_controller.getTab(tabIndex);

    if (metaTab != null && metaTab.selectCount >= 2) {
      // Only perform the following action from the 2nd select (The first is done when the tab is created)
      this.hideAllMenus();
    }

    // Re-configure tab search
    var currentVerseList = verseListController.getCurrentVerseList(tabIndex);
    if (metaTab != null && metaTab.tab_search != null) {
      metaTab.tab_search.setVerseList(currentVerseList);
    }

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

  async onTabAdded(tabIndex=0) {
    this.hideAllMenus();
    
    await this.initCurrentVerseListMenu(tabIndex);
    
    var currentTab = this.tab_controller.getTab(tabIndex);

    if (currentTab) {
      const verseListContainer = verseListController.getCurrentVerseListFrame(tabIndex).parent();

      currentTab.tab_search = new TabSearch();
      currentTab.tab_search.init(
        verseListContainer,
        '.tab-search',
        '.tab-search-input',
        '.tab-search-occurances',
        '.tab-search-previous',
        '.tab-search-next',
        '.tab-search-is-case-sensitive',
        '.tab-search-type',
      );
  
    }
  }

  async loadSettings() {
    try {
      if (this.tab_controller.getTab().isValid() && await ipcDb.getTagCount() > 0) {
        tags_controller.showTagListLoadingIndicator();
      }

      await this.tab_controller.loadTabConfiguration();

      if (await ipcSettings.has('lastUsedTagGroupId')) {
        tags_controller.currentTagGroupId = await ipcSettings.get('lastUsedTagGroupId', null);
        const tagGroupList = document.getElementById('tag-panel-tag-group-list');
        const tagGroup = await tagGroupList._tagGroupManager.getItemById(tags_controller.currentTagGroupId);
        await eventController.publishAsync('on-tag-group-selected', tagGroup);
      }

      await this.book_selection_menu.updateAvailableBooks();
      sectionLabelHelper.initHelper(ipcNsi);
    } catch (e) {
      console.trace("Failed to load settings ... got exception.", e);
    }
    
    this.tab_controller.bindEvents();
  }

  async initCurrentVerseListMenu(tabIndex=undefined) {
    var currentVerseListMenu = this.getCurrentVerseListMenu(tabIndex)[0];

    currentVerseListMenu.querySelectorAll('.fg-button').forEach((el) => el.classList.remove('events-configured'));
    var bookSelectButton = currentVerseListMenu.querySelector('.book-select-button');
    var moduleSearchButton = currentVerseListMenu.querySelector('.module-search-button');

    var bibleTranslations = await ipcNsi.getAllLocalModules();
    if (bibleTranslations != null && bibleTranslations.length > 0) {
      bookSelectButton.classList.remove('ui-state-disabled');
      moduleSearchButton.classList.remove('ui-state-disabled');
    } else {
      bookSelectButton.classList.add('ui-state-disabled');
      moduleSearchButton.classList.add('ui-state-disabled');
    }

    $(bookSelectButton).unbind('click').bind('click', (event) => {
      this.book_selection_menu.handleBookMenuClick(event);
    });

    var verseContextMenu = document.getElementById('verse-context-menu');
    verseContextMenu.currentTabIndex = tabIndex;

    var tabId = this.tab_controller.getSelectedTabId(tabIndex);
    if (tabId !== undefined) {
      uiHelper.configureButtonStyles('#' + tabId);
    }
  }

  initGlobalShortCuts() {
    let shortCut = 'ctrl+c';
    if (platformHelper.isMac()) {
      shortCut = 'command+c';
    }

    Mousetrap.bind(shortCut, async () => {
      await this.verse_selection.copySelectedVerseTextToClipboard();
      return false;
    });

    let searchShortCut = 'ctrl+f';
    if (platformHelper.isMac()) {
      searchShortCut = 'command+f';
    }

    let selectAllShortCut = 'ctrl+a';
    if (platformHelper.isMac()) {
      selectAllShortCut = 'command+a';
    }

    Mousetrap.bind(searchShortCut, () => {
      let currentTab = app_controller.tab_controller.getTab();
      currentTab.tab_search.show();
      currentTab.tab_search.focus();
      return false;
    });

    Mousetrap.bind(selectAllShortCut, () => {
      this.selectAllVerses();
      return false;
    });

    Mousetrap.bind('esc', () => {
      let currentTab = app_controller.tab_controller.getTab();
      currentTab.tab_search.resetSearch();
      return false;
    });

    Mousetrap.bind('enter', () => {
      let currentTab = app_controller.tab_controller.getTab();
      // We need to notify the TabSearch component that there has been a mouse trap event.
      // This is to avoid double event processing, because the TabSearch also listens for key press events.
      currentTab.tab_search.mouseTrapEvent = true;
      currentTab.tab_search.jumpToNextOccurance();
      return false;
    });

    Mousetrap.bind('shift+enter', () => {
      let currentTab = app_controller.tab_controller.getTab();
      // We need to notify the TabSearch component that there has been a mouse trap event.
      // This is to avoid double event processing, because the TabSearch also listens for key press events.
      currentTab.tab_search.mouseTrapEvent = true;
      currentTab.tab_search.shiftKeyPressed = true;
      currentTab.tab_search.jumpToNextOccurance(false);
      return false;
    });
  }

  selectAllVerses() {
    let currentTab = app_controller.tab_controller.getTab();
    let textType = currentTab.getTextType();
    
    if (textType == 'search_results') {
      this.module_search_controller.selectAllSearchResults();
    } else if (textType == 'tagged_verses') {
      this.text_controller.selectAllVerses();
    }
  }

  getCurrentVerseListTabs(tabIndex=undefined) {
    var selectedTabId = this.tab_controller.getSelectedTabId(tabIndex);
    var currentVerseListTabs = document.getElementById(selectedTabId);
    return $(currentVerseListTabs);
  }

  getCurrentVerseListMenu(tabIndex=undefined) {
    var currentVerseListTabs = this.getCurrentVerseListTabs(tabIndex);
    var currentVerseListMenu = null;

    try {
      currentVerseListMenu = $(currentVerseListTabs[0].querySelector('.verse-list-menu'));
    // eslint-disable-next-line no-empty
    } catch (e) { }

    return currentVerseListMenu;
  }

  handleBodyClick(event) {
    if($(this).hasClass('verse-selection-menu')) {
      event.stopPropagation();
      return;
    }
    
    app_controller.hideAllMenus();
    eventController.publish('on-body-clicked');
  }

  hideAllMenus() {
    this.book_selection_menu.hideBookMenu();
    this.tag_selection_menu.hideTagMenu();
    this.module_search_controller.hideSearchMenu();
    this.optionsMenu.hideDisplayMenu();
    this.textSizeSettings.hideTextSizeMenu();
    document.getElementById('verse-context-menu').hidden = true;
    wheelnavController.closeWheelNav();
  }

  async openXrefVerses(referenceVerseBox, xrefTitle, xrefs) {
    var xrefVerseReferenceId = this.verse_box_helper.getVerseReferenceId(referenceVerseBox);
    var currentTab = this.tab_controller.getTab();

    currentTab.setTextType('xrefs');
    currentTab.setXrefs(xrefs);
    currentTab.setReferenceVerseElementId(xrefVerseReferenceId);

    app_controller.tab_controller.setCurrentTabXrefTitle(xrefTitle);

    // Set book, search term and tag id list to null, since we just switched to xrefs
    currentTab.setBook(null, null, null);
    currentTab.setSearchTerm(null);
    currentTab.setTagIdList("");

    await referenceVerseController.renderReferenceVerse(referenceVerseBox);
    await this.getXrefVerses(xrefs);
  }

  async openTaggedVerses(tagIdList, tagTitleList, referenceVerseBox=undefined) {
    var currentTab = this.tab_controller.getTab();
    currentTab.setTextType('tagged_verses');
    currentTab.setTagIdList(tagIdList);
    var localizedVerseReference = null;

    if (referenceVerseBox != null && referenceVerseBox.length != 0) {
      localizedVerseReference = await this.verse_box_helper.getLocalizedVerseReference(referenceVerseBox[0]);
      var verseReferenceId = this.verse_box_helper.getVerseReferenceId(referenceVerseBox);
      currentTab.setReferenceVerseElementId(verseReferenceId);
    } else {
      currentTab.setReferenceVerseElementId(null);
    }

    app_controller.tab_controller.setCurrentTagTitleList(tagTitleList, localizedVerseReference);

    // Set book, search term and xrefs to null, since we just switched to a tag
    currentTab.setBook(null, null, null);
    currentTab.setSearchTerm(null);
    currentTab.setXrefs(null);
    
    this.module_search_controller.resetSearch();
    
    if (tagIdList != "") {
      setTimeout(() => {
        this.tag_selection_menu.hideTagMenu();
      }, 700);
    }

    if (referenceVerseBox != undefined) {
      await referenceVerseController.renderReferenceVerse(referenceVerseBox);
    } else {
      referenceVerseController.clearReferenceVerse();
    }

    await this.getTaggedVerses();
  }

  async getXrefVerses(xrefs) {
    var currentTabId = this.tab_controller.getSelectedTabId();
    var currentVerseList = verseListController.getCurrentVerseList();

    var currentTab = this.tab_controller.getTab();
    currentTab.tab_search.setVerseList(currentVerseList);

    if (xrefs.length > 0) {
      // Only reset the view if the current text type has changed
      var resetView = this.tab_controller.getTab().hasTextTypeChanged();

      await this.text_controller.prepareForNewText(resetView, false);
      this.text_controller.requestTextUpdate(currentTabId, null, null, null, null, null, xrefs);
    }
  }

  async getTaggedVerses() {
    var currentTab = this.tab_controller.getTab();
    var currentTagIdList = currentTab.getTagIdList();
    var currentTabId = this.tab_controller.getSelectedTabId();
    var currentVerseList = verseListController.getCurrentVerseList();

    if (currentTab.tab_search != null) {
      currentTab.tab_search.setVerseList(currentVerseList);
    }

    if (currentTagIdList != "") {
      // Only reset the view if the current text type has changed
      var resetView = this.tab_controller.getTab().hasTextTypeChanged();

      await this.text_controller.prepareForNewText(resetView, false);

      this.text_controller.requestTextUpdate(
        currentTabId,
        null,
        currentTagIdList,
        null,
        null,
        null,
        null
      );

      await waitUntilIdle();
      tags_controller.updateTagList(null, tags_controller.currentTagGroupId, currentTab.getContentId());
    }
  }

  openModuleSettingsAssistant(moduleType) {
    this.optionsMenu.hideDisplayMenu();
    this.moduleAssistant.openAssistant(moduleType);
  }
}

module.exports = AppController;