Subversion Repositories web.active

Rev

Rev 22 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Module Process
 *
 * Provides list, install, and uninstall capability for ProcessWire modules
 * 
 * For more details about how Process modules work, please see: 
 * /wire/core/Process.php 
 * 
 * This version also lifts several pieces of code from Soma's Modules Manager
 * specific to the parts involved with downloading modules from the directory.
 * 
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 * 
 * @todo add support for module configuration inputfields with useLanguages option
 * 
 * @method string executeUpload($inputName = '')
 * @method string executeDownloadURL($url = '')
 * @method string executeDownload()
 * @method string executeEdit()
 * @method string executeInstallConfirm()
 * @method InputfieldForm buildDownloadConfirmForm(array $data, $update = false)
 * @method InputfieldForm buildDownloadSuccessForm($className)
 *
 */

class ProcessModule extends Process {

  public static function getModuleInfo() {
    return array(
      'title' => __('Modules', __FILE__), // getModuleInfo title          
      'summary' => __('List, edit or install/uninstall modules', __FILE__), // getModuleInfo summary
      'version' => 119, 
      'permanent' => true, 
      'permission' => 'module-admin', 
      'useNavJSON' => true,
      'nav' => array(
        array(
          'url' => '?site#tab_site_modules',
          'label' => 'Site', 
          'icon' => 'plug',
          'navJSON' => 'navJSON/?site=1'
        ),
        array(
          'url' => '?core#tab_core_modules',
          'label' => 'Core', 
          'icon' => 'plug',
          'navJSON' => 'navJSON/?core=1',
        ),
        array(
          'url' => '?configurable#tab_configurable_modules',
          'label' => 'Configure',
          'icon' => 'gear',
          'navJSON' => 'navJSON/?configurable=1',
        ),
        array(
          'url' => '?install#tab_install_modules',
          'label' => 'Install',
          'icon' => 'sign-in',
          'navJSON' => 'navJSON/?install=1',
        ),
        array(
          'url' => '?new#tab_new_modules',
          'label' => 'New',
          'icon' => 'plus',
        ),
        array(
          'url' => '?reset=1',
          'label' => 'Refresh',
          'icon' => 'refresh',
        )
      )
    );
  }
  
  /**
   * New core modules allowed to appear in the "new" list
   * 
   * By default, core modules don't appear in the "new" list, 
   * this array contains a list of core modules that are allowed to appear there.
   * 
   * @var array
   * 
   */
  protected $newCoreModules = array(
    'SystemNotifications', 
    'InputfieldCKEditor',
    'FieldtypeOptions',
    'InputfieldIcon', 
    'ProcessLogger',
    );

  protected $labels = array();

  /**
   * All modules indexed by class name and sorted by class name
   *
   */
  protected $modulesArray = array();

  /**
   * All modules that may be deleted
   *
   */
  protected $deleteableModules = array();

  /**
   * Categories of modules that we can't uninstall via this module
   * 
   */
  protected $uninstallableCategories = array(
    'language-pack',
    'site-profile',
  ); 

  /**
   * Number of new modules found after a reset
   * 
   */
  protected $numFound = 0;
  
  /**
   * @var ProcessModuleInstall|null
   *
   */
  protected $installer = null;

  /**
   * Construct
   *
   */
  public function __construct() {
    $this->labels['download'] = $this->_('Download'); 
    if($this->input->get('update')) {
      $this->labels['download_install'] = $this->_('Download and Update');
    } else { 
      $this->labels['download_install'] = $this->_('Download and Install');
    }
    $this->labels['get_module_info'] = $this->_('Get Module Info'); 
    $this->labels['module_information'] = $this->_x("Module Information", 'edit');
    $this->labels['download_now'] = $this->_('Download Now');
    $this->labels['download_dir'] = $this->_('Add Module From Directory');
    $this->labels['add_manually'] = $this->_('Add Module Manually');
    $this->labels['upload'] = $this->_('Upload');
    $this->labels['upload_zip'] = $this->_('Add Module From Upload');
    $this->labels['download_zip'] = $this->_('Add Module From URL');
    $this->labels['check_new'] = $this->_('Check for New Modules'); 
    $this->labels['installed_date'] = $this->_('Installed');
    $this->labels['requires'] = $this->_x("Requires", 'list'); // Label that precedes list of required prerequisite modules
    $this->labels['installs'] = $this->_x("Also Installs", 'list'); // Label that precedes list of other modules a given one installs
    $this->labels['reset'] = $this->_('Refresh'); 
    $this->labels['core'] = $this->_('Core');
    $this->labels['site'] = $this->_('Site');
    $this->labels['configure'] = $this->_('Configure');
    $this->labels['install_btn'] = $this->_x('Install', 'button'); // Label for Install button
    $this->labels['install'] = $this->_('Install'); // Label for Install tab
    $this->labels['cancel'] = $this->_('Cancel'); // Label for Cancel button
  
    if($this->wire('languages') && !$this->wire('user')->language->isDefault()) {
      // Use previous translations when new labels aren't available (can be removed in PW 2.6+ when language packs assumed updated)
      if($this->labels['install'] == 'Install') $this->labels['install'] = $this->labels['install_btn'];
      if($this->labels['reset'] == 'Refresh') $this->labels['reset'] = $this->labels['check_new'];
    }

    require(dirname(__FILE__) . '/ProcessModuleInstall.php'); 
  }

  /**
   * @return ProcessModuleInstall
   * 
   */
  public function installer() {
    if($this->installer === null) $this->installer = $this->wire(new ProcessModuleInstall());
    return $this->installer;
  }

  /**
   * Format a module version number from 999 to 9.9.9
   *
   * @param string $version
   * @return string
   *
   */
  protected function formatVersion($version) {
    return $this->wire('modules')->formatVersion($version); 
  }

  /**
   * Output JSON list of navigation items for this (intended to for ajax use)
   *
   * For 2.5+ admin themes
   * 
   * @param array $options
   * @return string
   *
   */
  public function ___executeNavJSON(array $options = array()) {
    $page = $this->wire('page');
    $data = array(
      'url' => $page->url,
      'label' => (string) $page->get('title|name'),
      'icon' => 'plug', 
      'list' => array(),
    );
    
    $site = $this->wire('input')->get('site');
    $core = $this->wire('input')->get('core');
    $configurable = $this->wire('input')->get('configurable');
    $install = $this->wire('input')->get('install'); 
  
    if($site || $install) $data['add'] = array(
      'url' => "?new#tab_new_modules",
      'label' => __('Add New', '/wire/templates-admin/default.php'),
      'icon' => 'plus-circle',
    );
    
    $modules = $this->wire('modules'); 
    $moduleNames = array();
    if($install) {
      $moduleNames = array_keys($modules->getInstallable());  
    } else {
      foreach($modules as $module) $moduleNames[] = $module->className();
    }
    sort($moduleNames); 

    foreach($moduleNames as $moduleName) {
      
      $info = $this->wire('modules')->getModuleInfoVerbose($moduleName); 
      
      if($site && $info['core']) continue; 
      if($core && !$info['core']) continue; 
      
      if($configurable) { 
        if(!$info['configurable'] || !$info['installed']) continue;
        $flags = $this->wire('modules')->getFlags($moduleName);
        if($flags & Modules::flagsNoUserConfig) continue;
      }
      
      if($install) {
        // exclude already installed modules
        if($info['installed']) continue; 
        // check that it can be installed NOW (i.e. all dependencies met)
        if(!$this->wire('modules')->isInstallable($moduleName, true)) continue; 
      }
      
      $label = $info['name'];
      $_label = $label;
      while(isset($data['list'][$_label])) $_label .= "_";
      if(empty($info['icon'])) $info['icon'] = $info['configurable'] ? 'gear' : 'plug';
      
      $url = $install ? "installConfirm" : "edit"; 
      $url .= "?name=$info[name]";
      if($configurable) $url .= "&collapse_info=1";
      
      $data['list'][$_label] = array(
        'url' => $url,
        'label' => $label,
        'icon' => $info['icon'], 
      );
    }
    
    ksort($data['list']);
    $data['list'] = array_values($data['list']); 

    if($this->wire('config')->ajax) header("Content-Type: application/json");
    return json_encode($data);
  }

  /**
   * Load all modules, install any requested, and render a list of all modules
   *
   */
  public function ___execute() {

    foreach($this->modules as $module) {
      $this->modulesArray[$module->className()] = 1; 
    }
    foreach($this->modules->getInstallable() as $module) {
      $this->modulesArray[basename(basename($module, '.php'), '.module')] = 0; 
    }
    ksort($this->modulesArray); 

    if($this->input->post('install')) {
      $this->session->CSRF->validate();
      $name = $this->wire('sanitizer')->name($this->input->post('install'));
      if($name && isset($this->modulesArray[$name]) && !$this->modulesArray[$name]) {
        $module = $this->modules->install($name, array('force' => true));     
        if($module) {
          $this->modulesArray[$name] = 1;
          $this->session->message($this->_("Module Install") . " - $name"); // Message that precedes the name of the module installed
          $this->session->redirect("edit?name=$name");
        } else {
          $this->session->error($this->_('Error installing module') . " - $name");
          $this->session->redirect("./");
        }
      }
    }

    if($this->input->post('delete')) {
      $this->session->CSRF->validate();
      $name = $this->input->post('delete');
      if($name && isset($this->modulesArray[$name])) {
        $info = $this->modules->getModuleInfoVerbose($name);    
        try {
          $this->modules->delete($name); 
          $this->message($this->_('Deleted module files') . ' - ' . $info['title']); 

        } catch(WireException $e) {
          $this->error($e->getMessage()); 
        }
        $this->session->redirect("./"); 
      }
    }
    
    if($this->input->post('download') && $this->input->post('download_name')) {
      $this->session->CSRF->validate();
      return $this->downloadConfirm($this->input->post('download_name'));
    } else if($this->input->get('download_name')) {
      return $this->downloadConfirm($this->input->get('download_name')); 
    }
    
    if($this->input->post('upload')) {
      $this->session->CSRF->validate();
      $this->executeUpload('upload_module'); 
    }
    
    if($this->input->post('download_zip') && $this->input->post('download_zip_url')) {
      $this->session->CSRF->validate();
      $this->executeDownloadURL($this->input->post('download_zip_url'));    
    }
    
    if($this->input->post('clear_file_compiler')) {
      $this->session->CSRF->validate();
      /** @var FileCompiler $compiler */
      $compiler = $this->wire(new FileCompiler($this->wire('config')->paths->siteModules)); 
      $compiler->clearCache(true);
      $this->session->message($this->_('Cleared file compiler cache'));
      $this->session->redirect('./');
    } 
    
    if($this->input->get('update')) {
      $name = $this->sanitizer->name($this->input->get('update')); 
      if(isset($this->modulesArray[$name])) return $this->downloadConfirm($name, true); 
    }

    if($this->input->get('reset') == 1) {
      $this->modules->resetCache();
      $this->message(sprintf($this->_('Modules cache refreshed (%d modules)'), count($this->modules)));
      $edit = $this->input->get->fieldName('edit');
      $duplicates = $this->modules->duplicates()->getDuplicates();
      foreach($duplicates as $className => $files) {
        $dup = $this->modules->duplicates()->getDuplicates($className); 
        if(!count($dup['files'])) continue; 
        $msg = sprintf($this->_('Module "%s" has multiple files (bold file is the one in use).'), $className) . ' ' . 
          "<a href='./edit?name=$className'>" . $this->_('Click here to change which file is used') . "</a><pre>";
        foreach($dup['files'] as $file) {
          if($dup['using'] == $file) $file = "<b>$file</b>";
          $msg .= "\n$file";
        }
        $this->message("$msg</pre>", Notice::allowMarkup);
      }
      if($edit) {
        $this->session->redirect("./edit?name=$edit&reset=2");
      } else {
        $this->session->redirect("./?reset=2");
      }
    }

    return $this->renderList();
  } 


  /**
   * Render a list of all modules
   *
   */
  protected function renderList() {

    // module arrays: array(moduleName => 0 (uninstalled) or 1 (installed))
    $modulesArray = $this->modulesArray; 
    $installedArray = array();
    $uninstalledArray = array();
    $configurableArray = array();
    $uninstalledNames = array();
    $siteModulesArray = array();
    $coreModulesArray = array();
    $newModulesArray = array(); 
    
    if($this->wire('input')->post('new_seconds')) {
      $this->wire('session')->set('ProcessModuleNewSeconds', (int) $this->wire('input')->post('new_seconds')); 
    }
    $newSeconds = (int) $this->wire('session')->get('ProcessModuleNewSeconds'); 
    if(!$newSeconds) $newSeconds = 86400; 

    foreach($modulesArray as $name => $installed) {
      
      if($installed) {
        $installedArray[$name] = $installed;
        $errors = $this->modules->getDependencyErrors($name); 
        if($errors) foreach($errors as $error) $this->error($error); 
      } else {
        $uninstalledNames[] = $name;
        $uninstalledArray[$name] = $installed;
      }
    
      $info = $this->modules->getModuleInfoVerbose($name); 
      
      $isNew = !$info['core'] || ($info['core'] && in_array($name, $this->newCoreModules));
      if($isNew) $isNew = $info['created'] > 0 && $info['created'] > (time()-$newSeconds);
      if($isNew) $newModulesArray[$name] = $installed; 
      
      if($info['core']) {
        $coreModulesArray[$name] = $installed;
      } else {
        $siteModulesArray[$name] = $installed;
      }
      
      if($info['configurable'] && $info['installed']) {
        $flags = $this->modules->getFlags($name);
        if(!($flags & Modules::flagsNoUserConfig)) {
          $configurableArray[$name] = $installed;
        }
      }
    }

    /** @var InputfieldForm $form */
    $form = $this->modules->get('InputfieldForm');
    $form->attr('action', './'); 
    $form->attr('method', 'post'); 
    $form->attr('enctype', 'multipart/form-data'); 
    $form->attr('id', 'modules_form');
    $form->addClass('ModulesList');

    $this->modules->get('JqueryWireTabs');
    
    // site 
    
    $tab = $this->wire(new InputfieldWrapper());
    $tab->attr('id', 'tab_site_modules'); 
    $tab->attr('title', $this->labels['site']);
    $tab->attr('class', 'WireTab');

    /** @var InputfieldSubmit $button */
    $button = $this->modules->get('InputfieldSubmit');
    $button->attr('name', 'clear_file_compiler');
    $button->attr('value', $this->_('Clear compiled files'));
    $button->addClass('ui-priority-secondary');
    $button->icon = 'trash-o';
  
    /** @var InputfieldMarkup $markup */
    $markup = $this->modules->get('InputfieldMarkup');
    $markup->label = $this->_('/site/modules/ - Modules specific to your site');
    $markup->icon = 'folder-open-o';
    $markup->value .= $this->renderListTable($siteModulesArray, true);
    $markup->value .= "<p class='detail'><i class='fa fa-fw fa-star'></i> " . sprintf($this->_('Browse the modules directory at %s'), "<a target='_blank' href='http://modules.processwire.com'>modules.processwire.com</a>") . "</p>"; 
    $markup->value .= "<p class='detail'><i class='fa fa-fw fa-eraser'></i> " .  $this->_("To remove a module, click the module to edit, check the Uninstall box, then save. Once uninstalled, the module's file(s) may be removed from /site/modules/. If it still appears in the list above, you may need to click the Refresh button for ProcessWire to see the change."); // Instructions on how to remove a module
    $markup->value .= "<p class='detail'><i class='fa fa-fw fa-info-circle'></i> " . $this->_('The button below clears compiled site modules and template files, forcing them to be re-compiled the next time they are accessed. Note that this may cause a temporary delay for one or more requests while files are re-compiled.') . "</p>";
    $markup->value .= "<p class='detail'>" . $button->render() . "</p>";
    $tab->add($markup);
    
    $form->add($tab); 
    
    // core

    /** @var InputfieldWrapper $tab */    
    $tab = $this->wire(new InputfieldWrapper());
    $tab->attr('id', 'tab_core_modules'); 
    $tab->attr('title', $this->labels['core']);
    $tab->attr('class', 'WireTab');
  
    /** @var InputfieldMarkup $markup */
    $markup = $this->modules->get('InputfieldMarkup');
    $markup->value = $this->renderListTable($coreModulesArray);
    $markup->label = $this->_('/wire/modules/ - Modules included with the ProcessWire core');
    $markup->icon = 'folder-open-o';
    $tab->add($markup); 
    $form->add($tab);
    
    // configurable
    
    $tab = $this->wire(new InputfieldWrapper());
    $tab->attr('id', 'tab_configurable_modules');
    $tab->attr('title', $this->labels['configure']);
    $tab->attr('class', 'WireTab');

    $markup = $this->modules->get('InputfieldMarkup');
    $markup->value = $this->renderListTable($configurableArray, true, true, false, false, true);
    $markup->label = $this->_('Modules that have configuration options');
    $markup->icon = 'folder-open-o';
    $tab->add($markup);
    $form->add($tab);

    // installable

    $tab = $this->wire(new InputfieldWrapper());
    $tab->attr('id', 'tab_install_modules');
    $tabLabel = $this->labels['install']; 
    $tab->attr('title', $tabLabel); 
    $tab->attr('class', 'WireTab');

    $markup = $this->modules->get('InputfieldMarkup');
    $markup->value = $this->renderListTable($uninstalledArray, true, true, false, false, true);
    $markup->label = $this->_('Modules on the file system that are not currently installed');
    $markup->icon = 'folder-open-o';
    $tab->add($markup);
    $form->add($tab);


    // new
  
    /** @var InputfieldWrapper $tab */
    $tab = $this->wire(new InputfieldWrapper());
    $tab->attr('id', 'tab_new_modules'); 
    $tab->attr('title', $this->_('New'));
    $tab->attr('class', 'WireTab');

    $newModules = $this->wire('session')->get($this, 'newModules'); 
    if($newModules) foreach($newModules as $name => $created) {
      if(!is_numeric($name) && !isset($newModulesArray[$name])) {
        // add to newModulesArray and identify as uninstalled
        $newModulesArray[$name] = 0;
      }
    }
  
    /** @var InputfieldSelect $select */
    $select = $this->wire('modules')->get('InputfieldSelect'); 
    $select->attr('name', 'new_seconds'); 
    $select->addClass('modules_filter'); 
    $select->addOption(3600, $this->_('Within the last hour'));
    $select->addOption(86400, $this->_('Within the last day'));
    $select->addOption(604800, $this->_('Within the last week'));
    $select->addOption(2419200, $this->_('Within the last month')); 
    $select->required = true; 
    $select->attr('value', $newSeconds);
  
    /** @var InputfieldSubmit $btn */
    $btn = $this->modules->get('InputfieldSubmit');
    $btn->attr('hidden', 'hidden');
    $btn->attr('name', 'submit_check');
    $btn->textFormat = Inputfield::textFormatNone;
    $btn->icon = 'check';
    $btn->value = ' ';
    $btn->setSmall(true);
    $btn->setSecondary(true);
    $btn = "<button type='submit' id='submit_check' name='submit_check' value='1' hidden>&nbsp;</button>";
    
    /** @var InputfieldMarkup $markup */
    $markup = $this->modules->get('InputfieldMarkup');
    $markup->icon = 'lightbulb-o';
    $markup->value = $select->render() . ' ' . $btn . $this->renderListTable($newModulesArray, false, false, true, true);
    $markup->label = $this->_('Recently Found and Installed Modules');
    $tab->add($markup);
    
    /** @var InputfieldFieldset $fieldset */
    $fieldset = $this->modules->get('InputfieldFieldset');
    $fieldset->label = $this->labels['download_dir'];
    $fieldset->icon = 'cloud-download';
    $tab->add($fieldset);
    //if($this->wire('input')->post('new_seconds')) $fieldset->collapsed = Inputfield::collapsedYes; 
    
    if($this->installer()->canInstallFromDirectory(false)) {
      /** @var InputfieldName $f */
      $f = $this->modules->get('InputfieldName');
      $f->attr('id+name', 'download_name');
      $f->label = $this->_('Module Class Name');
      $f->description =
        $this->_('You may browse the modules directory and locate the module you want to download and install.') . ' ' .
        sprintf(
          $this->_('Type or paste in the class name for the module you want to install, then click the “%s” button to proceed.'),
          $this->labels['get_module_info']
        );
      $f->notes = sprintf($this->_('The modules directory is located at %s'), '[modules.processwire.com](https://modules.processwire.com)');
      $f->attr('placeholder', $this->_('ModuleClassName')); // placeholder
      $f->required = false;
      $fieldset->add($f);
      
      /** @var InputfieldSubmit $f */
      $f = $this->modules->get('InputfieldSubmit');
      $f->attr('id+name', 'download');
      $f->value = $this->labels['get_module_info'];
      $f->icon = $fieldset->icon;
      $fieldset->add($f);
    } else {
      $fieldset->description = $this->installer()->installDisabledLabel('directory');
      $fieldset->collapsed = Inputfield::collapsedYes;
    }
  
    /** @var InputfieldFieldset $fieldset */
    $fieldset = $this->modules->get('InputfieldFieldset');
    $fieldset->label = $this->labels['download_zip'];
    $fieldset->icon = 'download';
    $fieldset->collapsed = Inputfield::collapsedYes;
    $tab->add($fieldset);
    $trustNote = $this->_('Be absolutely certain that you trust the source of the ZIP file.'); 
  
    if($this->installer()->canInstallFromDownloadUrl(false)) {
      /** @var InputfieldURL $f */
      $f = $this->modules->get('InputfieldURL');
      $f->attr('id+name', 'download_zip_url');
      $f->label = $this->_('Module ZIP file URL');
      $f->description = $this->_('Download a ZIP file containing a module. If you download a module that is already installed, the installed version will be overwritten with the newly downloaded version.');
      $f->notes = $trustNote;
      $f->attr('placeholder', $this->_('http://domain.com/ModuleName.zip')); // placeholder
      $f->required = false;
      $fieldset->add($f);
      
      /** @var InputfieldSubmit $f */
      $f = $this->modules->get('InputfieldSubmit');
      $f->attr('id+name', 'download_zip');
      $f->value = $this->labels['download'];
      $f->icon = $fieldset->icon;
      $fieldset->add($f);
    } else {
      $fieldset->description = $this->installer()->installDisabledLabel('download'); 
    }
  
    /** @var InputfieldFieldset $fieldset */
    $fieldset = $this->modules->get('InputfieldFieldset'); 
    $fieldset->label = $this->labels['upload_zip'];
    $fieldset->icon = 'upload';
    $fieldset->collapsed = Inputfield::collapsedYes;
    $tab->add($fieldset);
  
    if($this->installer()->canInstallFromFileUpload(false)) {
      /** @var InputfieldFile $f */
      $f = $this->modules->get('InputfieldFile');
      $f->extensions = 'zip';
      $f->maxFiles = 1;
      $f->descriptionRows = 0;
      $f->overwrite = true;
      $f->attr('id+name', 'upload_module');
      $f->label = $this->_('Module ZIP File');
      $f->description = $this->_('Upload a ZIP file containing module file(s). If you upload a module that is already installed, it will be overwritten with the one you upload.');
      $f->notes = $trustNote;
      $f->required = false;
      $f->noCustomButton = true;
      $fieldset->add($f);
      $f = $this->modules->get('InputfieldSubmit');
      $f->attr('id+name', 'upload');
      $f->value = $this->labels['upload'];
      $f->icon = $fieldset->icon;
      $fieldset->add($f);
    } else {
      $fieldset->description = $this->installer()->installDisabledLabel('upload'); 
    }

    /** @var InputfieldFieldset $fieldset */
    $fieldset = $this->modules->get('InputfieldFieldset');
    $fieldset->attr('id', 'fieldset_check_new'); 
    $fieldset->label = $this->labels['add_manually'];
    $fieldset->icon = 'plug';
    $fieldset->collapsed = Inputfield::collapsedYes; 
    $tab->add($fieldset);
  
    /** @var InputfieldMarkup $markup */
    $markup = $this->modules->get('InputfieldMarkup');
    $fieldset->add($markup); 
    $moduleNameLabel = $this->_('ModuleName'); // Example module class/directory name
    $moduleNameDir = $this->wire()->config->urls->siteModules . $moduleNameLabel . '/';
    $instructions = array(
      sprintf($this->_('1. Copy a module’s files into a new directory %s on the server.'), "<u>$moduleNameDir</u>") . ' * ',
      sprintf($this->_('2. Click the “%s” button below, which will find the new module.'), $this->labels['reset']),
      sprintf($this->_('3. Locate and click the “%s” button next to the new module.'), $this->labels['install_btn']) 
    );
    $markup->value = '<p>' . implode('</p><p>', $instructions) . '</p>';
    $markup->notes = '* ' . sprintf(
      $this->_('Replace “%s” with the actual module name, which is typically its PHP class name.'), 
      $moduleNameLabel
    ); 

    /** @var InputfieldFieldset $fieldset */
    /*
    $fieldset = $this->modules->get('InputfieldFieldset');
    $fieldset->attr('id', 'fieldset_check_new'); 
    $fieldset->label = $this->labels['reset'];
    $fieldset->description = $this->_('If you have placed new modules in /site/modules/ yourself, click this button to find them.');
    $fieldset->collapsed = Inputfield::collapsedYes; 
    $fieldset->icon = 'refresh';
    */
    
    /** @var InputfieldButton $submit */
    $submit = $this->modules->get('InputfieldButton');
    $submit->attr('href', './?reset=1');
    $submit->attr('id', 'reset_modules');
    $submit->showInHeader();
    $submit->attr('name', 'reset');
    $submit->attr('value', $this->labels['reset']);
    $submit->icon = 'refresh';
    $fieldset->add($submit);
    $tab->add($fieldset);

    $form->add($tab);
  
    // if($this->input->get->reset == 2 && !$this->numFound) $this->message($this->_("No new modules found")); 
    $this->session->set('ModulesUninstalled', $uninstalledNames);

    return $form->render(); 
  }

  /**
   * Render a modules listing table, as it appears in the 'site' and 'core' tabs
   * 
   * @param array $modulesArray
   * @param bool $allowDelete Whether or not delete is allowed (default=false)
   * @param bool $allowSections Whether to show module sections/categories (default=true)
   * @param bool $allowDates Whether to show created dates (default=false)
   * @param bool $allowClasses Whether to show module class names(default=false)
   * @param bool $allowType Whether to show if module is site or core
   * @return string
   * 
   */
  protected function renderListTable($modulesArray, $allowDelete = false, $allowSections = true, $allowDates = false, $allowClasses = false, $allowType = false) {
    
    if(!count($modulesArray)) return "<div class='ProcessModuleNoneFound'>" . $this->_('No modules found.') . "</div>";
    
    static $numCalls = 0; 
    $numCalls++;
    
    $uninstalledPrev = is_array($this->session->get('ModulesUninstalled')) ? $this->session->get('ModulesUninstalled') : array();
    $section = 'none';
    $tableHeader = array(
      $this->_x('Module', 'list'),  // Modules list table header for 'Module' column 
      $this->_x('Version', 'list'),   // Modules list table header for 'Version' column
      $this->_x('Summary', 'list')  // Modules list table header for 'Summary' column
    );
    /** @var MarkupAdminDataTable|null $table */
    $table = null;
    $total = 0;
    $out = '';
    $this->numFound = 0;
    $newModules = $this->wire('session')->get($this, 'newModules');
    if(!is_array($newModules)) $newModules = array();

    $sections = array();
    $sectionsQty = array();

    foreach($modulesArray as $name => $installed) {

      if(strpos($name, $section) !== 0 || preg_match('/' . $section . '[^A-Z0-9]/', $name)) {
        if(!preg_match('/^([A-Za-z][a-z]+)/', $name, $matches)) $this->error(sprintf($this->_('Invalid module name: %s'), $name));
        if($allowSections || is_null($table)) { 
          $section = $matches[1];
          $sections[] = $section;
          if($table) $out .= $table->render() . "</div>";
          $table = $this->modules->get("MarkupAdminDataTable");
          $table->setEncodeEntities(false);
          $table->headerRow($tableHeader);
          if($allowSections) $out .= "\n<div class='modules_section modules_$section'><h2>$section</h2>";
        }
      }
      
      $info = $this->modules->getModuleInfoVerbose($name); 
      
      // $interfaces = @class_implements($name, false);
      // $configurable = is_array($interfaces) && in_array('ConfigurableModule', $interfaces);
      $configurable = $info['configurable'];
      $title = !empty($info['title']) ? $this->wire('sanitizer')->entities1($info['title']) : substr($name, strlen($section));
      if($allowClasses) $title .= "<br /><small class='ModuleClass ui-priority-secondary'>$name</small>";
      if($info['icon']) $title = "<i class='fa fa-fw fa-$info[icon]'></i> $title"; 
      $class = $configurable ? 'ConfigurableModule' : '';
      if(!empty($info['permanent'])) $class .= ($class ? ' ' : '') . 'PermanentModule';
      if($class) $title = "<span class='$class'>$title</span>";
      $version = $this->formatVersion(isset($info['version']) ? $info['version'] : 0);
      if($allowType) $version .= "<br /><small class='ModuleClass ui-priority-secondary'>" . ($info['core'] ? $this->labels['core'] : $this->labels['site']) . "</small>";
      $summary = empty($info['summary']) ? '' : $this->wire('sanitizer')->entities1($info['summary']);
      if(strpos($summary, '&lt;') !== false) $summary = preg_replace('/([^\s]{35})[^\s]{20,}/', '$1...', $summary); // prevent excessively long text without whitespace
      $summary .= empty($info['href']) ? '' : (" <a href='$info[href]'>" . $this->_('more') . "</a>");
      if($summary) $summary = "<p class='module-summary'>$summary</p>";
      $buttons = '';
      $confirmDeleteJS = "return confirm('" . sprintf($this->_('Delete %s?'), $name) . "')";
      $confirmInstallJS = "return confirm('" . sprintf($this->_('Module requirements are not fulfilled so installing may cause problems. Are you sure you want to install?'), $name) . "')";
      $editUrl = "edit?name={$name}";

      if(!$installed) {

        if(count($info['requires'])) {
          $requires = $this->modules->getRequiresForInstall($name);
          if(count($requires)) {
            foreach($requires as $key => $value) {
              $nameOnly = preg_replace('/^([_a-zA-Z0-9]+)[=<>]+.*$/', '$1', $value);
              $requiresInfo = $this->modules->getModuleInfo($nameOnly); 
              if(!empty($requiresInfo['error'])) $requires[$key] = "<a href='./?download_name=$nameOnly'>$value</a>";
            }
            $summary .= "<span class='notes requires'>" . $this->labels['requires'] . " - " . implode(', ', $requires) . "</span>";
          }
        } else $requires = array();

        if(count($info['installs'])) {
          $summary .= "<span class='detail installs'>" . $this->labels['installs'] . " - " . implode(', ', $info['installs']) . "</span>";
        }

        $class = 'not_installed';
        if(count($uninstalledPrev) && !in_array($name, $uninstalledPrev)) {
          $class .= " new_module";
          if(!$this->input->get('uninstalled')) $this->message($this->_("Found new module") . " - $name"); // Message that precedes module name when new module is found
          $newModules[$name] = time();
          $this->numFound++;
        }

        $title = "<span data-name='$name' class='$class'>$title</span>";

        $isConfirm = count($modulesArray) == 1 && $this->wire('input')->get('name');
        $buttonState = 'ui-state-default';
        $buttonPriority = $isConfirm ? "ui-priority-primary" : "ui-priority-secondary";
        $buttonType = 'submit';
        $buttonWarning = '';
        if(count($requires)) {
          $buttonWarning = " onclick=\"$confirmInstallJS\"";
          $icon = 'fa-warning';
        } else {
          $icon = 'fa-sign-in';
        }
        $buttons .=
          "<button type='$buttonType' name='install' $buttonWarning data-install='$name' " . 
            "class='install_$name $buttonState ui-button $buttonPriority' value='$name'>" .
            "<span class='ui-button-text'>" . 
              "<i class='fa $icon'></i> " . 
              $this->labels['install_btn'] . 
            "</span>" . 
          "</button>"; 
        
        // install confirm, needs a cancel button
        if($isConfirm) $buttons .=
          "<button type='$buttonType' name='cancel' class='cancel_$name ui-button ui-priority-secondary' value='$name'>" .
            "<span class='ui-button-text'>" . 
              "<i class='fa fa-times-circle'></i> " . 
              $this->labels['cancel'] . 
            "</span>" . 
          "</button>"; 

        if($allowDelete && $this->wire('modules')->isDeleteable($name)) $buttons .= 
          "<button type='submit' name='delete' data-delete='$name' " . 
            "class='delete_$name ui-state-default ui-priority-secondary ui-button' " . 
            "value='$name' onclick=\"$confirmDeleteJS\">" . 
            "<span class='ui-button-text'>" . 
              "<i class='fa fa-eraser'></i> " . 
              $this->_x('Delete', 'button') . 
            "</span>" . 
          "</button>"; 

        $editUrl = '#';

      } else if($configurable) {
        $flags = $this->modules->getFlags($name);
        if(!($flags & Modules::flagsNoUserConfig)) {
          $buttons .=
            "<button type='button' class='ProcessModuleSettings ui-state-default ui-button'>" .
            "<span class='ui-button-text'><i class='fa fa-cog'></i> " . $this->_x('Settings', 'button') . "</span></button>"; // Text for 'Settings' button
        }
      }

      if($buttons) $buttons = "<small class='buttons'>$buttons</small>";
      
      if($allowDates) { 
        $summary .= "<span class='detail date'>";
        $summary .= $installed ? $this->labels['installed_date'] : $this->_('Found');
        $created = isset($newModules[$name]) ? $newModules[$name] : $info['created']; 
        $summary .= ': ' . wireRelativeTimeStr($created) . "</span>";
      }

      $row = array(
        $title => $editUrl,
        $version,
        $summary . $buttons,
        );

      $table->row($row);
      $total++;
      
      if(!isset($sectionsQty[$section])) $sectionsQty[$section] = 0;
      $sectionsQty[$section]++;

    }
    $out .= $table->render();
    
    if($allowSections) {
      $out .= "</div>";
      $select = "<p><select name='modules_section$numCalls' class='modules_filter modules_section_select'>";
      $select .= "<option value=''>" . $this->_('Show All') . "</option>";
      $current = $this->wire('input')->cookie("modules_section$numCalls");
      foreach($sections as $section) {
        $qty = $sectionsQty[$section]; 
        $selected = $current == $section ? " selected='selected'" : "";
        $select .= "<option$selected value='$section'>$section ($qty)</option>";
      }
      $select .= "</select></p>";
      $out = $select . $out; 
    }

    // modules that have no file or info present get removed from newModules
    $resetNewModules = false; 
    foreach($newModules as $key => $newModule) {
      $info = $this->wire('modules')->getModuleInfoVerbose($newModule); 
      if(!$info['file'] || !file_exists($info['file'])) {
        unset($newModules[$key]); 
        $resetNewModules = true;
      }
    }
    
    // if any new modules were found, this also forces rewrite of session data
    if($this->numFound) $resetNewModules = true; 
  
    // rewrite session data
    if($resetNewModules) $this->wire('session')->set($this, 'newModules', $newModules); 
    
    return $out; 
  }

  /**
   * Checks for compatibility, polls the modules directory web service and returns rendered markup for the download info table and confirmation form
   * 
   * @param string $name Class name of module
   * @param bool $update Whether this is a 'check for updates' request
   * @return string
   * 
   */
  protected function downloadConfirm($name, $update = false) {
  
    $name = $this->wire('sanitizer')->name($name); 
    $info = self::getModuleInfo();
    $this->headline($this->labels['download_install']); 
    $this->breadcrumb('./', $info['title']);
    if($update) $this->breadcrumb("./?edit=$name", $name); 
    
    $redirectURL = $update ? "./edit?name=$name" : "./";
    $className = $name; 
    $url = trim($this->wire('config')->moduleServiceURL, '/') . "/$className/?apikey=" . $this->wire('sanitizer')->name($this->wire('config')->moduleServiceKey);
    $http = $this->wire(new WireHttp());
    $data = $http->get($url); 
    if(empty($data)) {
      $this->error($this->_('Error retrieving data from web service URL') . ' - ' . $http->getError());
      $this->session->redirect($redirectURL);
      return '';
    }
    $data = json_decode($data, true); 
    if(empty($data)) {
      $this->error($this->_('Error decoding JSON from web service')); 
      $this->session->redirect($redirectURL);
      return '';
    }
    if($data['status'] !== 'success') {
      $this->error($this->_('Error reported by web service:') . ' ' . $this->wire('sanitizer')->entities($data['error']));
      $this->session->redirect($redirectURL); 
      return '';
    }
  
    $installable = true; 
    foreach($data['categories'] as $category) {
      if(!in_array($category['name'], $this->uninstallableCategories)) continue;
      $this->error(sprintf($this->_('Sorry modules of type "%s" are not installable from the admin.'), $category['title']));
      $installable = false;
    }
    if(!$installable) $this->session->redirect($redirectURL); 
  
    $form = $this->buildDownloadConfirmForm($data, $update);
    return $form->render();   
  }

  /**
   * Builds a confirmation form and table showing information about the requested module before download
   * 
   * @param array $data Array of information about the module from the directory service
   * @param bool $update Whether or not this is an 'update module' request
   * @return InputfieldForm
   * 
   */
  protected function ___buildDownloadConfirmForm(array $data, $update = false) {
    
    $warnings = array();
    $authors = '';
    
    foreach($data['authors'] as $author) $authors .= $author['title'] . ", ";
    $authors = rtrim($authors, ", ");

    $compat = '';
    $isCompat = false;
    $myVersion = substr($this->wire('config')->version, 0, 3);
    foreach($data['pw_versions'] as $v) {
      $compat .= $v['name'] . ", ";
      if(version_compare($v['name'], $myVersion) >= 0) $isCompat = true;
    }
    $compat = trim($compat, ", ");
    if(!$isCompat) $warnings[] = $this->_('This module does not indicate compatibility with this version of ProcessWire. It may still work, but you may want to check with the module author.');

    $form = $this->wire('modules')->get('InputfieldForm');
    $form->attr('action', './download/');
    $form->attr('method', 'post');
    $form->attr('id', 'ModuleInfo');
    $markup = $this->wire('modules')->get('InputfieldMarkup');
    $markup->label = $data['title'];
    $markup->icon = 'info-circle';
    $form->add($markup);
    
    $installed = $this->modules->isInstalled($data['class_name']) ? $this->modules->getModuleInfoVerbose($data['class_name']) : null;
  
    $moduleVersionNote = '';
    if($installed) {
      $installedVersion = $this->formatVersion($installed['version']); 
      if($installedVersion == $data['module_version']) {
        $note = $this->_('Current installed version is already up-to-date'); 
        $installedVersion .= ' - ' . $note;
        $this->message($note); 
        $this->session->redirect("./edit?name=$data[class_name]"); 
      } else {
        if(version_compare($installedVersion, $data['module_version']) < 0) {
          $this->message($this->_('An update to this module is available!')); 
        } else {
          $moduleVersionNote = " <span class='ui-state-error-text'>(" . $this->_('older than the one you already have installed!') . ")</span>";
        }
      }
    } else {
      $installedVersion = $this->_x('Not yet', 'install-table');
    }

    $table = $this->wire('modules')->get('MarkupAdminDataTable');
    $table->setEncodeEntities(false);
    $table->row(array($this->_x('Class', 'install-table'), $this->wire('sanitizer')->entities($data['class_name'])));
    $table->row(array($this->_x('Version', 'install-table'), $this->wire('sanitizer')->entities($data['module_version']) . $moduleVersionNote));
    $table->row(array($this->_x('Installed?', 'install-table'), $installedVersion)); 
    $table->row(array($this->_x('Authors', 'install-table'), $this->wire('sanitizer')->entities($authors)));
    $table->row(array($this->_x('Summary', 'install-table'), $this->wire('sanitizer')->entities($data['summary'])));
    $table->row(array($this->_x('Release State', 'install-table'), $this->wire('sanitizer')->entities($data['release_state']['title'])));
    $table->row(array($this->_x('Compatibility', 'install-table'), $this->wire('sanitizer')->entities($compat)));
    
    // $this->message("<pre>" . print_r($data, true) . "</pre>", Notice::allowMarkup); 
    $installable = true; 
    if(!empty($data['requires_versions'])) {
      $requiresVersions = array();
      foreach($data['requires_versions'] as $name => $requires) {
        list($op, $ver) = $requires;
        $label = $ver ? $this->sanitizer->entities("$name $op $ver") : $this->sanitizer->entities($name);
        if($this->modules->isInstalled("$name$op$ver") || in_array($name, $data['installs'])) {
          // installed
          $requiresVersions[] = "$label <i class='fa fa-fw fa-thumbs-up'></i>";
        } else if($this->modules->isInstalled($name)) {
          // installed, but version isn't adequate
          $installable = false;
          $info = $this->modules->getModuleInfo($name);
          $requiresVersions[] = $this->sanitizer->entities($name) . " " . $this->modules->formatVersion($info['version']) . " " . 
            "<span class='ui-state-error-text'>" . $this->sanitizer->entities("$op $ver") . " " . 
            "<i class='fa fa-fw fa-thumbs-down'></i></span>";
        } else {
          // not installed at all
          $requiresVersions[] = "<span class='ui-state-error-text'>$label <i class='fa fa-fw fa-thumbs-down'></i></span>";
          $installable = false; 
        }
      }
      $table->row(array($this->labels['requires'], implode('<br />', $requiresVersions))); 
      if(!$installable) $this->error("Module is not installable because not all required dependencies are currently met."); 
    }
    if(!empty($data['installs'])) {
      $installs = $this->sanitizer->entities(implode("\n", $data['installs'])); 
      $table->row(array($this->labels['installs'], nl2br($installs))); 
    }

    $links = array();

    $moduleName = $this->wire('sanitizer')->entities1($data['name']);
    $links[] = "<a target='_blank' href='http://modules.processwire.com/modules/$moduleName/'>" . $this->_('More Information') . "</a>";

    if($data['project_url']) {
      $projectURL = $this->wire('sanitizer')->entities($data['project_url']);
      $links[] = "<a target='_blank' href='$projectURL'>" . $this->_('Project Page') . "</a>";
    }

    if($data['forum_url']) {
      $forumURL = $this->wire('sanitizer')->entities($data['forum_url']);
      $links[] = "<a target='_blank' href='$forumURL'>" . $this->_('Support Page') . "</a>";
    }

    if(count($links)) $table->row(array($this->_x('Links', 'install-table'), implode(' &nbsp;/&nbsp; ', $links)));

    if($data['download_url']) {
      $downloadURL = $this->wire('sanitizer')->entities($data['download_url']);
      $table->row(array($this->_x('ZIP file', 'install-table'), $downloadURL));
      $warnings[] = $this->_('Ensure that you trust the source of the ZIP file above before continuing!');
    } else {
      $warnings[] = $this->_('This module has no download URL specified and must be installed manually.');
    }
    
    if(!$this->installer()->canInstallFromDirectory(false)) {
      $installable = false;
      $markup->notes = trim($markup->notes . ' ' . $this->installer()->installDisabledLabel('directory'));
    }

    foreach($warnings as $warning) {
      $table->row(array($this->_x('Please Note', 'install-table'), "<strong class='ui-state-error-text'> $warning</strong>"));
    }

    $markup->value = $table->render();

    if($installable && $data['download_url']) {
      /** @var InputfieldSubmit $btn */
      $btn = $this->wire('modules')->get('InputfieldSubmit');
      $btn->attr('id+name', 'godownload');
      $btn->value = $this->labels['download_now'];
      $btn->icon = 'cloud-download';
      $btn->showInHeader(true);
      if($update) $btn->value .= " ($data[module_version])";
      $form->add($btn);
      $this->session->set('ProcessModuleDownloadURL', $data['download_url']);
      $this->session->set('ProcessModuleClassName', $data['class_name']);
    } else {
      $this->session->remove('ProcessModuleDownloadURL');
      $this->session->remove('ProcessModuleClassName');
    }

    $btn = $this->wire('modules')->get('InputfieldButton');
    $btn->attr('name', 'cancel');
    $btn->href = $update ? "./edit?name=$data[class_name]" : './';
    $btn->value = $this->labels['cancel']; 
    $btn->icon = 'times-circle';
    $btn->class .= ' ui-priority-secondary';
    $form->add($btn);

    return $form;
  }

  /**
   * Triggered on the /download/ action - Downloads a module from the directory
   * 
   * Most code lifted from Soma's Modules Manager
   * 
   * @return string Rendered output or redirect
   * @throws WireException
   * 
   */
  public function ___executeDownload() {

    if(!$this->input->post('godownload')) {
      $this->message($this->_('Download cancelled')); 
      $this->session->redirect('../'); 
      return '';
    }
    
    $this->session->CSRF->validate();
    $this->modules->resetCache();

    $url = $this->session->get('ProcessModuleDownloadURL');
    $className = $this->session->get('ProcessModuleClassName');
    
    $this->session->remove('ProcessModuleDownloadURL');
    $this->session->remove('ProcessModuleClassName'); 
    
    if(!$url) throw new WireException("No download URL specified"); 
    if(!$className) throw new WireException("No class name specified");

    $destinationDir = $this->wire('config')->paths->siteModules . $className . '/';
    
    $completedDir = $this->installer()->downloadModule($url, $destinationDir);
    if($completedDir) {
      return $this->buildDownloadSuccessForm($className)->render();
    } else {
      $this->session->redirect('../'); 
      return '';
    }
  }

  /**
   * Build the form that gets displayed after a module has been successfully downloaded
   * 
   * @param string $className
   * @return InputfieldForm
   * 
   */
  protected function ___buildDownloadSuccessForm($className) {
  
    /** @var InputfieldForm $form */
    $form = $this->modules->get('InputfieldForm');

    // check if modules isn't already installed and this isn't an update
    if(!$this->modules->isInstalled($className)) {
      
      $info = $this->wire('modules')->getModuleInfoVerbose($className);
      $requires = array();
      if(count($info['requires'])) $requires = $this->modules->getRequiresForInstall($className);
      if(count($requires)) { 
        foreach($requires as $moduleName) {
          $this->warning("$className - " . sprintf($this->_('Requires module "%s" before it can be installed'), $moduleName), Notice::allowMarkup); 
        }
        $this->wire('session')->redirect('../'); 
      }

      $this->headline($this->_('Downloaded:') . ' ' . $className);

      $form->description = sprintf($this->_('%s is ready to install'), $className);
      $form->attr('action', '../');
      $form->attr('method', 'post');
      $form->attr('id', 'install_confirm_form');

      /** @var InputfieldHidden $f */
      $f = $this->modules->get('InputfieldHidden');
      $f->attr('name', 'install');
      $f->attr('value', $className);
      $form->add($f);
  
      /** @var InputfieldSubmit $submit */
      $submit = $this->modules->get('InputfieldSubmit');
      $submit->attr('name', 'submit');
      $submit->attr('id', 'install_now');
      $submit->attr('value', $this->_('Install Now'));
      $submit->icon = 'sign-in';
      $form->add($submit);

      /** @var InputfieldButton $button */
      $button = $this->modules->get('InputfieldButton');
      $button->attr('href', '../');
      $button->attr('value', $this->_('Leave Uninstalled'));
      $button->class .= " ui-priority-secondary";
      $button->icon = 'times-circle';
      $button->attr('id', 'no_install');
      $form->add($button);

    } else {

      $this->headline($this->_('Updated:') . ' ' . $className);
      $form->description = sprintf($this->_('%s was updated successfully.'), $className);
      /** @var InputfieldButton $button */
      $button = $this->modules->get('InputfieldButton');
      $button->attr('href', "../?reset=1&edit=$className");
      $button->attr('value', $this->_('Continue to module settings'));
      $button->attr('id', 'gosettings');
      $form->add($button);
    }
    
    return $form;
  }
  
  /**********************************************************************************************************************************************************/
  
  public function ___executeUpload($inputName = '') {
    if(!$inputName) throw new WireException("This URL may not be accessed directly"); 
    $this->installer()->uploadModule($inputName);
    $this->session->redirect('./?reset=1'); 
  }
  
  public function ___executeDownloadURL($url = '') {
    if(!$url) throw new WireException("This URL may not be accessed directly");
    $this->installer()->downloadModuleFromUrl($url);
    $this->session->redirect('./?reset=1'); 
  }

  /**********************************************************************************************************************************************************/

  /**
   * Load the form for editing a module's settings
   *
   */
  public function ___executeEdit() {

    $info = null; 
    $moduleName = '';

    if(isset($_POST['name'])) $moduleName = $_POST['name']; 
      else if(isset($_GET['name'])) $moduleName = $_GET['name'];

    $moduleName = $this->sanitizer->name($moduleName); 

    if(!$moduleName || !$info = $this->modules->getModuleInfoVerbose($moduleName)) {
      $this->session->message($this->_("No module specified")); 
      $this->session->redirect("./"); 
    }

    return $this->renderEdit($moduleName, $info); 

  }

  /**
   * Build and render for the form for editing a module's settings
   *
   * This method saves the settings if it's form has been posted
   * 
   * @param string $moduleName
   * @param array $moduleInfo
   * @return string
   *
   */
  protected function renderEdit($moduleName, $moduleInfo) {

    $out = ''; 
    $moduleId = $this->modules->getModuleID($moduleName); 
    $languages = $this->wire('languages');
    $submitSave = $this->input->post('submit_save_module');
    $collapseInfo = '';
    if($submitSave || $this->wire('input')->get('collapse_info') || $this->wire('input')->get('modal')) {
      $collapseInfo = '&collapse_info=1';
    }
    if(!$moduleId) {
      $this->error("Unknown module"); 
      $this->session->redirect('./'); 
      return '';
    }
    if($this->wire('input')->get('refresh') == $moduleName) {
      $this->wire('modules')->resetCache();
      $this->session->redirect("./edit?name=$moduleName$collapseInfo");
      return '';
    }
    $sinfo = self::getModuleInfo();
    $flags = $this->modules->getFlags($moduleName);
    $allowDisabledFlag = 
      ($this->wire('config')->debug && $this->wire('config')->advanced && ($flags & Modules::flagsAutoload)) ||
      ($flags & Modules::flagsDisabled);

    $this->breadcrumb('./', $sinfo['title']); 
    $this->headline($moduleInfo['title']); 
    $this->browserTitle(sprintf($this->_('Module: %s'), $moduleInfo['title'])); 

    /** @var InputfieldForm $form */
    $form = $this->modules->get("InputfieldForm"); 
    $form->attr('id', 'ModuleEditForm'); 
    $form->attr('action', "edit?name=$moduleName$collapseInfo"); 
    $form->attr('method', 'post'); 

    $dependents = $this->modules->getRequiredBy($moduleName, true); 
    $requirements = $this->modules->getRequires($moduleName, false, true); 
    $dependentsStr = '';
    $requirementsStr = '';
    foreach($dependents as $name) $dependentsStr .= ($dependentsStr ? ', ' : '') . "<a href='./edit?name=$name'>$name</a>";
    foreach($requirements as $name) {
      if(preg_match('/^([^<>!=]+)([<>!=]+.*)$/', $name, $matches)) {
        $name = $matches[1];
        $extra = "<span class='detail'>$matches[2]</span>";
      } else $extra = '';
      if($name == 'PHP' || $name == 'ProcessWire') {
        $requirementsStr .= ($requirementsStr ? ', ' : '') . "$name$extra";
      } else {
        $requirementsStr .= ($requirementsStr ? ', ' : '') . "<a href='./edit?name=$name'>$name</a>$extra";
      }
    }
    
    // identify duplicates
    $duplicates = $this->modules->duplicates()->getDuplicates($moduleName); 
    if(count($duplicates['files'])) {
      /** @var InputfieldRadios $field */
      $field = $this->modules->get('InputfieldRadios'); 
      $field->attr('name', '_use_duplicate');
      $field->label = $this->_('Module file to use'); 
      $field->icon = 'files-o';
      $field->description = $this->_('There are multiple copies of this module. Select the module file you want to use.'); 
      foreach($duplicates['files'] as $file) {
        $field->addOption($file); 
      }
      $field->attr('value', $duplicates['using']); 
      $form->add($field); 
    }
    
    $fields = $this->wire('modules')->getModuleConfigInputfields($moduleName, $form); 
    if($fields) {
      foreach($fields as $field) {
        $form->add($field);
      }
    }

    // uninstall checkbox
    $field = $this->modules->get("InputfieldCheckbox"); 
    $field->attr('id+name', 'uninstall'); 
    $field->attr('value', $moduleName); 
    $field->collapsed = Inputfield::collapsedYes; 
    $field->icon = 'times-circle';
    $field->label = $this->_x("Uninstall", 'checkbox');

    $reason = $this->modules->isUninstallable($moduleName, true); 
    $uninstallable = $reason === true; 

    if($uninstallable) {
      $field->description = $this->_("Uninstall this module? After uninstalling, you may remove the modules files from the server if it is not in use by any other modules."); // Uninstall field description
      if(count($moduleInfo['installs'])) {
        $uninstalls = $this->wire('modules')->getUninstalls($moduleName);
        if(count($uninstalls)) $field->notes = $this->_("This will also uninstall other modules") . " - " . implode(', ', $uninstalls); // Text that precedes a list of modules that are also uninstalled
      }

    } else {
      $field->attr('disabled', 'disabled');
      $field->label .= " " . $this->_("(Disabled)"); 
      $field->description = $this->_("Can't uninstall module") . " - " . $reason; // Text that precedes a reason why the module can't be uninstalled
      $dependents2 = $this->modules->getRequiresForUninstall($moduleName); 
      if(count($dependents2)) $field->notes = $this->_("You must first uninstall other modules") . " - " . implode(', ', $dependents2); // Text that precedes a list of modules that must be uninstalled first
    }

    $form->append($field); 

    // submit button
    if(count($form->children)) {
      /** @var InputfieldSubmit $field */
      $field = $this->modules->get("InputfieldSubmit"); 
      $field->attr('name', 'submit_save_module'); 
      $field->showInHeader();
      $field->addActionValue('exit', sprintf($this->_('%s + Exit'), $field->attr('value')), 'times');
      $form->append($field);
      
    } else {
      $this->message($this->_("This module doesn't have any fields to configure")); 
    }
  
    $data = null;
    if($languages && $fields) {
      // multi-language support for Inputfield with useLanguages==true
      // we populate the language values from module config data so module doesn't have to do this
      $data = $this->modules->getModuleConfigData($moduleName);
      foreach($fields->getAll() as $field) {
        if(!$field->getSetting('useLanguages')) continue;
        foreach($languages as $language) {
          if($language->isDefault()) continue;
          $name = $field->name . '__' . $language->id;
          if(!isset($data[$name])) continue;
          $field->set("value$language->id", $data[$name]);
        }
      }
    }

    // check for submitted form
    if($submitSave) {

      if(is_null($data)) $data = $this->modules->getModuleConfigData($moduleName);
      $form->processInput($this->input->post); 
      $updatedNames = array();
      
      if(wireCount($fields)) foreach($fields->getAll() as $field) {
        // note field names beginning with '_' will not be stored 
        if(($name = $field->attr('name')) && strpos($name, '_') !== 0) {
          $value = $field->attr('value');
          if(!isset($data[$name]) || $value != $data[$name]) $updatedNames[] = $name;
          $data[$name] = $value;
          // multi-language, if Inputfield specifies useLanguages==true
          // convert value1234 inputfield values to module config data in name__1234 format
          if($languages && $field->getSetting('useLanguages')) {
            $_name = $name;
            foreach($languages as $language) {
              if($language->isDefault()) continue;
              $name = $_name . "__" . $language->id;
              $value = $field->get("value$language->id");
              if(!isset($data[$name]) || $value != $data[$name]) $updatedNames[] = $name;
              $data[$name] = $value;
            }
          }
        }
      }
    
      if($uninstallable && $this->input->post('uninstall') === $moduleName) {
        $this->modules->uninstall($moduleName);   
        $this->session->message($this->_("Uninstalled Module") . " - $moduleName"); // Message shown before the name of a module that was just uninstalled
        $redirectURL = './?uninstalled=1'; 
      } else {
        $this->modules->saveModuleConfigData($moduleName, $data);
        $updatedNames = count($updatedNames) ? ' (' . implode(', ', $updatedNames) . ')' : '';
        $this->message($this->_("Saved Module") . " - $moduleName $updatedNames"); // Message shown before the name of a module that was just saved
        $redirectURL = $submitSave == 'exit' ? './' : "./edit?name=$moduleName$collapseInfo"; 
        if($allowDisabledFlag) {
          // module is autoload and has an option to diable
          if($this->input->post('_flags_disabled')) {
            // add disabled flag
            if(!($flags & Modules::flagsDisabled)) $this->modules->setFlag($moduleName, Modules::flagsDisabled, true);
          } else {
            // remove disabled flag
            if($flags & Modules::flagsDisabled) $this->modules->setFlag($moduleName, Modules::flagsDisabled, false); 
          }
        }
        
        if(count($duplicates['files'])) {
          $file = $form->getChildByName('_use_duplicate')->attr('value');
          if($file != $duplicates['using'] && in_array($file, $duplicates['files'])) {
            $this->modules->duplicates()->setUseDuplicate($moduleName, $file);
            $this->message("Updated $moduleName to use file: $file", Notice::debug);
            $redirectURL .= "&refresh=$moduleName";
          }
        }
      }
      
      $this->wire('session')->redirect($redirectURL);
    }

    // entity encode module info since it's turned off in our table
    foreach($moduleInfo as $key => $value) {
      if(!is_string($value)) continue; 
      $moduleInfo[$key] = $this->wire('sanitizer')->entities1($value);
    }
  
    $version = $this->formatVersion($moduleInfo['version']);
    $filename = str_replace($this->wire('config')->paths->root, '/', $this->wire('modules')->getModuleFile($moduleName)); 
    if(!$moduleInfo['core']) {
      $version .= " - <a href='./?update=$moduleName'>" . $this->_('check for updates') . "</a>"; 
    }

    $hooksStr = $this->renderModuleHooks($moduleName);  
    
    // build a table that displays module info
    /** @var MarkupAdminDataTable $table */
    $table = $this->modules->get("MarkupAdminDataTable"); 
    $table->setResponsive(false);
    $table->setEncodeEntities(false); 
    $table->setSortable(false);
    $table->row(array($this->_x('Title', 'edit'), $moduleInfo['title'])); 
    $table->row(array($this->_x('Class', 'edit'), $moduleName));
    $table->row(array($this->_x('File', 'edit'), str_replace('/', 
      // this sillyness allows for multi-line wrapping without the appearance of space.
      // someone please tell me if there is a better way to do this. I suspect there is, so
      // will leave it out here in the open rather hide it in ProcessModule.css
      '<span style="display:inline-block;width:1px;overflow:hidden;"> </span>/', $filename))); 
  
    if($this->wire('config')->debug) {
      if($moduleInfo['namespace'] === '') $namespace = "\\" . __NAMESPACE__ . ' ' . $this->_('(default namespace)'); 
        else if($moduleInfo['namespace'] === "\\") $namespace = $this->_('None (root namespace)');
        else $namespace = $moduleInfo['namespace'];
      $table->row(array($this->_x('Namespace', 'edit'), $namespace)); 
      $table->row(array($this->_x('ID', 'edit'), $moduleId)); 
    }
    $table->row(array($this->_x('Version', 'edit'), $version)); 
    if(!empty($moduleInfo['created'])) $table->row(array($this->labels['installed_date'], wireRelativeTimeStr($moduleInfo['created'])));
    if(!empty($moduleInfo['author'])) $table->row(array($this->_x('Author', 'edit'), $moduleInfo['author'])); 
    $table->row(array($this->_x('Summary', 'edit'), $moduleInfo['summary'])); 
    if($requirementsStr) $table->row(array($this->_x('Requires', 'edit'), $requirementsStr)); 
    if($dependentsStr) $table->row(array($this->_x('Required By', 'edit'), $dependentsStr)); 
    if(!empty($moduleInfo['permission'])) $table->row(array($this->_x('Required Permission', 'edit'), $moduleInfo['permission']));
    if($hooksStr) $table->row(array($this->_x('Hooks To', 'edit'), $hooksStr)); 
    if(!empty($moduleInfo['href'])) $table->row(array($this->_x('More Information', 'edit'), "<a class='label' href='$moduleInfo[href]'>$moduleInfo[href]</a>"));
    
    if($allowDisabledFlag) {
      $checked = $flags & Modules::flagsDisabled ? " checked='checked'" : "";
      $table->row(array($this->_x('Debug', 'edit'), 
        "<label class='checkbox'><input type='checkbox' name='_flags_disabled' value='1' $checked /> " . 
        $this->_('Autoload disabled?') . " <span class='detail'>" . 
        $this->_('Be careful, checking this box can break the module or your site. Use for temporary testing only.') . "</span></label>"));
    }

    $field = $this->modules->get("InputfieldMarkup"); 
    $field->attr('id', 'ModuleInfo'); 
    $field->attr('value', $table->render()); 
    $field->label = $this->labels['module_information'];
    $field->icon = 'info-circle';
    if($collapseInfo) $field->collapsed = Inputfield::collapsedYes;
    $form->prepend($field); 

    $out .= $form->render();

    return $out; 
  }
  
  protected function renderModuleHooks($moduleName) {
    $out = '';
    $hooks = array_merge($this->wire()->getHooks('*'), $this->wire('hooks')->getAllLocalHooks());
    foreach($hooks as $hook) {
      $toObject = !empty($hook['toObject']) ? $hook['toObject'] : '';
      if(empty($toObject) || wireClassName($toObject, false) != $moduleName) continue;
      $suffix = $hook['options']['type'] == 'method' ? '()' : '';
      $when = '';
      if($hook['options']['before']) $when .= $this->_('before');
      if($hook['options']['after']) $when .= ($when ? '+' : '') . $this->_('after');
      if($when) $when .= ".";
      if($out) $when = ", $when";
      $when = "<span class='detail'>$when</span>";
      $out .= "$when" . ($hook['options']['fromClass'] ? $hook['options']['fromClass'] . '::' : '') . "$hook[method]$suffix";
    }
    return $out;
  }
  
  public function ___executeInstallConfirm() {
    
    $name = $this->wire('input')->get('name');
    if(!$name) throw new WireException("No module name specified"); 
    $name = $this->wire('sanitizer')->fieldName($name); 
    if(!$this->wire('modules')->isInstallable($name, true)) throw new WireException("Module is not currently installable"); 
    
    $this->headline($this->labels['install']); 
  
    /** @var InputfieldForm $form */
    $form = $this->modules->get('InputfieldForm');
    $form->attr('action', './');
    $form->attr('method', 'post');
    $form->attr('id', 'modules_install_confirm_form');
    $form->addClass('ModulesList'); 
    $form->description = sprintf($this->_('Install %s?'), $name); 
    
    $modulesArray[$name] = (int) $this->modules->isInstalled($name); 
    /** @var InputfieldMarkup $markup */
    $markup = $this->modules->get('InputfieldMarkup');
    $markup->value = $this->renderListTable($modulesArray, false, false, false, true, true);
    $form->add($markup);
    
    return $form->render();
  }
}