Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * Multi-language support page names module
 *
 * ProcessWire 3.x, Copyright 2017 by Ryan Cramer
 * https://processwire.com
 * 
 * @property int $moduleVersion
 * @property int $inheritInactive
 * @property int $useHomeSegment
 *
 */

class LanguageSupportPageNames extends WireData implements Module, ConfigurableModule {

  /**
   * Return information about the module
   *
   */
  static public function getModuleInfo() {
      return array(
        'title' => 'Languages Support - Page Names',
        'version' => 10,
        'summary' => 'Required to use multi-language page names.',
        'author' => 'Ryan Cramer',
        'autoload' => true,
        'singular' => true,
        'requires' => array(
          'LanguageSupport',
          'LanguageSupportFields'
          )
        );
  }

  /**
   * The path that was requested, before processing
   *
   */
  protected $requestPath = '';

  /**
   * Language that should be set for this request
   *
   */
  protected $setLanguage = null;

  /**
   * Whether to force a 404 when ProcessPageView runs
   *
   */
  protected $force404 = null;

  /**
   * Whether to bypass the functions provided by this module (like for a secure pagefile request)
   * 
   */
  protected $bypass = false;

  /**
   * Default configuration data
   *
   */
  static protected $defaultConfigData = array(
    /**
     * module version, for schema changes when necessary
     *
     */
    'moduleVersion' => 0,

    /**
     * Whether an 'inactive' state (status123=0) should inherit to children
     *
     * Note: we don't have a reasonable way to make this work with PageFinder queries, 
     * so it is not anything more than a placeholder at present. 
     *
     */
    'inheritInactive' => 0,

    /**
     * Whether or not the default language homepage should be served by a language segment.
     *
     */
    'useHomeSegment' => 0,

    );

  /**
   * Populate default config data
   *
   */
  public function __construct() {
    foreach(self::$defaultConfigData as $key => $value) $this->set($key, $value);
  }

  /**
   * Initialize the module, save the requested path
   *
   */
  public function init() { 
    $this->addHookBefore('ProcessPageView::execute', $this, 'hookProcessPageViewExecute'); 
    $this->addHookAfter('PageFinder::getQuery', $this, 'hookPageFinderGetQuery'); 

    // tell ProcessPageView which segments are allowed for pagination
    $config = $this->wire('config');
    $pageNumUrlPrefixes = array();
    $fields = $this->wire('fields');
    foreach($this->wire('languages') as $language) {
      $pageNumUrlPrefix = $this->get("pageNumUrlPrefix$language"); 
      if($pageNumUrlPrefix) $pageNumUrlPrefixes[] = $pageNumUrlPrefix;
      $fields->setNative("name$language"); 
      $fields->setNative("status$language"); 
    }
    if(count($pageNumUrlPrefixes)) {
      $pageNumUrlPrefixes[] = $config->pageNumUrlPrefix; // original/fallback prefix
      $config->set('pageNumUrlPrefixes', $pageNumUrlPrefixes);
    }
  }

  /**
   * Attach hooks
   *
   */
  public function ready() {

    $this->checkModuleVersion();
    $this->addHookAfter('Page::path', $this, 'hookPagePath'); 
    $this->addHookAfter('Page::viewable', $this, 'hookPageViewable'); 
    $this->addHookBefore('Page::render', $this, 'hookPageRender'); 
    $this->addHook('Page::localName', $this, 'hookPageLocalName'); 
    $this->addHook('Page::localUrl', $this, 'hookPageLocalUrl');
    $this->addHook('Page::localHttpUrl', $this, 'hookPageLocalHttpUrl'); 
    $this->addHook('Page::localPath', $this, 'hookPageLocalPath');

    // bypass means the request was to something in /site/*/ that has no possibilty of language support
    // note that the hooks above are added before this so that 404s can still be handled properly
    if($this->bypass) return;

    // verify that page path doesn't have mixed languages where it shouldn't
    $redirectURL = $this->verifyPath($this->requestPath);
    if($redirectURL) {
      $this->wire('session')->redirect($redirectURL);
      return;
    }

    $page = $this->wire('page'); 

    if($page->template == 'admin' && $page->process && in_array('WirePageEditor', wireClassImplements($page->process))) { 
      // when in admin, add inputs for each language's page name
      if(!in_array('ProcessPageType', wireClassParents($page->process))) {
        $page->addHookBefore('WirePageEditor::execute', $this, 'hookWirePageEditorExecute');
        $this->addHookAfter('InputfieldPageName::render', $this, 'hookInputfieldPageNameRenderAfter');
        $this->addHookAfter('InputfieldPageName::processInput', $this, 'hookInputfieldPageNameProcess');
      }
    }

    $this->addHookBefore('LanguageSupportFields::languageDeleted', $this, 'hookLanguageDeleted'); 
    $this->addHookBefore('LanguageSupportFields::languageAdded', $this, 'hookLanguageAdded'); 

    $this->wire('pages')->addHookAfter('saveReady', $this, 'hookPageSaveReady');
    $this->wire('pages')->addHookAfter('saved', $this, 'hookPageSaved');
    $this->wire('pages')->addHookAfter('setupNew', $this, 'hookPageSetupNew'); 
    
    $language = $this->wire('user')->language; 
    $prefix = $this->get("pageNumUrlPrefix$language"); 
    if(strlen($prefix)) {
      $config = $this->wire('config');
      $config->set('_pageNumUrlPrefix', $config->pageNumUrlPrefix); // origial/backup url prefix
      $config->pageNumUrlPrefix = $prefix;
    }
  }

  /**
   * Is the given path a site assets path? (i.e. /site/)
   * 
   * Determines whether this is a path we should attempt to perform any language processing on.
   * 
   * @param string $path
   * @return bool
   *
   */
  protected function isAssetPath($path) {
    $config = $this->wire('config');
    // determine if this is a asset request, for compatibility with pagefileSecure
    $segments = explode('/', trim($config->urls->assets, '/')); // start with [subdir]/site/assets
    array_pop($segments); // pop off /assets, reduce to [subdir]/site
    $sitePath = '/' . implode('/', $segments) . '/'; // combine to [/subdir]/site/
    $sitePath = str_replace($config->urls->root, '', $sitePath); // remove possible subdir, reduce to: site/
    // if it is a request to assets, then don't attempt to modify it
    $sitePath = rtrim($sitePath, '/') . '/';
    $path = rtrim($path, '/') . '/';
    return strpos($path, $sitePath) === 0;
  }

  /**
   * Given a page path, return an updated version that lacks the language segment
   *
   * It extracts the language segment and uses that to later set the language
   * 
   * @param string $path
   * @return string
   *
   */
  public function updatePath($path) {
    if($path === '/' || !strlen($path)) return $path;
    $trailingSlash = substr($path, -1) == '/';
    $testPath = trim($path, '/') . '/';
    $home = $this->wire('pages')->get(1);
    foreach($this->wire('languages') as $language) {
      $name = $language->isDefault() ? $home->get("name") : $home->get("name$language"); 
      if($name == Pages::defaultRootName) continue;
      if(!strlen($name)) continue; 
      $name = "$name/"; 
      if(strpos($testPath, $name) === 0) {
        $this->setLanguage = $language; 
        $path = substr($testPath, strlen($name)); 
      }
    } 
    if(!$trailingSlash && $path != '/') $path = rtrim($path, '/'); 
    return $path; 
  }

  /**
   * Determine language from requested path, and if a redirect needs to be performed
   *
   * Sets the user's language to that determined from the URL.
   *
   * @param string $requestPath
   * @return string $redirectURL Returns URL to be redirected to, when applicable. Blank when not.
   *
   */
  protected function verifyPath($requestPath) {

    $languages = $this->wire('languages'); 
    if(!count($languages)) return '';
    
    $page = $this->wire('page');
    if($page->template == 'admin') return ''; 
    
    $user = $this->wire('user');
    $config = $this->wire('config');
  
    $requestedParts = explode('/', $requestPath); 
    $parentsAndPage = $page->parents()->getArray();
    $parentsAndPage[] = $page; 
    array_shift($parentsAndPage); // shift off the homepage
    $redirectURL = '';
    $setLanguage = $this->setLanguage;

    // determine if we should set the current language based on requested URL
    if(!$setLanguage) foreach($parentsAndPage as $p) {
      /** @var Page $p */

      $requestedPart = strtolower(array_shift($requestedParts)); 
      if($requestedPart === $p->name) continue; 

      foreach($languages as $language) {
        if($language->isDefault()) {
          $name = $p->get("name"); 
        } else {
          $name = $p->get("name$language"); 
        }
        if($name === $requestedPart) {
          $setLanguage = $language; 
        }
      }
    }

    // check to see if the $page or any of its parents has an inactive status for the $setLanguage
    if($setLanguage && !$setLanguage->isDefault()) {
      $active = true;
      if($this->inheritInactive) {
        // inactive status on a parent inherits through to children
        foreach($parentsAndPage as $p) {
          $status = $p->get("status$setLanguage"); 
          if(!$status) $active = false;
        }
      } else {  
        // inactive status only applies to the page itself
        $active = $page->get("status$setLanguage") > 0; 
      
        // https://github.com/processwire/processwire-issues/issues/463
        // $active = $page->get("status$setLanguage") > 0 || $page->template->noLang;
      }
      // if page is inactive for a language, and it's not editable, send a 404
      if(!$active && !$page->editable() && $page->id != $config->http404PageID) {
        // 404 or redirect to default language version
        $this->force404 = true; 
        return '';
      }
    }


    // set the language 
    if(!$setLanguage) $setLanguage = $languages->get('default'); 
    $user->language = $setLanguage; 
    $this->setLanguage = $setLanguage;
    $languages->setLocale();
  
    // if $page is the 404 page, exit out now
    if($page->id == $config->http404PageID) return '';

    // determine if requested URL was correct or if we need to redirect
    $hasSlashURL = substr($requestPath, -1) == '/';
    $useSlashURL = (bool) $page->template->slashUrls;
    $expectedPath = trim($this->getPagePath($page, $user->language), '/');
    $requestPath = trim($requestPath, '/');
    $pageNum = $this->wire('input')->pageNum;
    $urlSegmentStr = $this->wire('input')->urlSegmentStr;
  
    // URL segments
    if(strlen($urlSegmentStr)) {
      $expectedPath .= '/' . $urlSegmentStr; 
      $useSlashURL = $hasSlashURL;
    }
  
    // page numbers
    if($pageNum > 1) {
      $prefix = $this->get("pageNumUrlPrefix$user->language");
      if(empty($prefix)) $prefix = $this->wire('config')->pageNumUrlPrefix;
      $expectedPath .= (strlen($expectedPath) ? "/" : "") . "$prefix$pageNum";
      $useSlashURL = false;
    }
    
    $expectedPathLength = strlen($expectedPath);
  
    if($expectedPathLength) {
      $requestPath = substr($requestPath, 0, $expectedPathLength); 
    }
    
    if(trim($expectedPath, '/') != trim($requestPath, '/')) {
      if($expectedPathLength && $useSlashURL) $expectedPath .= '/';
      $redirectURL = $this->wire('config')->urls->root . ltrim($expectedPath, '/');
      
    } else if($useSlashURL && !$hasSlashURL && strlen($expectedPath)) {
      $redirectURL = $this->wire('config')->urls->root . $expectedPath . '/';
      
    } else if(!$useSlashURL && $hasSlashURL && $pageNum == 1) {
      $redirectURL = $this->wire('config')->urls->root . $expectedPath; 
    }

    return $redirectURL;  
  }

  /**
   * Given a page and language, return the path to the page in that language
   * 
   * @param Page $page
   * @param Language $language
   * @return string
   *
   */
  public function getPagePath(Page $page, Language $language) {

    $isDefault = $language->isDefault();
    
    if(!$isDefault && $page->template && $page->template->noLang) {
      $language = $this->wire()->languages->getDefault();
      $isDefault = true;
    }

    if($page->id == 1) {
      // special case: homepage
      $name = $isDefault ? '' : $page->get("name$language"); 
      if($isDefault && $this->useHomeSegment) $name = $page->name;
      if($name == Pages::defaultRootName || !strlen($name)) return '/';
      return $page->template->slashUrls ? "/$name/" : "/$name";
    }

    $path = '';

    foreach($page->parents() as $parent) {
      $name = $isDefault ? $parent->get("name") : $parent->get("name$language|name"); 
      if($parent->id == 1) { 
        // bypass ProcessWire's default homepage name of 'home', as we don't want it in URLs
        if($name == Pages::defaultRootName) continue; 
        // avoid having default language name inherited at homepage level
        // if($isDefault && $name === $parent->get("name")) continue; 
      }
      if(strlen($name)) $path .= "/" . $name;
    }

    $name = $page->get("name$language|name"); 
    $path = strlen($name) ? "$path/$name/" : "$path/";
    
    if(!$page->template->slashUrls && $path != '/') $path = rtrim($path, '/'); 
    
    return $path;
  }


  /**
   * Hook in before ProcesssPageView::execute to capture and modify $_GET[it] as needed
   * 
   * @param HookEvent $event
   *
   */
  public function hookProcessPageViewExecute(HookEvent $event) {
    /** @var ProcessPageView $process */
    $process = $event->object;
    $process->setDelayRedirects(true); 
    // save now, since ProcessPageView removes $_GET['it'] when it executes
    $it = isset($_GET['it']) ? $_GET['it'] : '';
    $this->requestPath = $it; 
    if($this->isAssetPath($it)) {
      $this->bypass = true; 
    } else {
      $it = $this->updatePath($it); 
      if($it != $this->requestPath) $_GET['it'] = $it;
    }
  }

  /**
   * Hook in before ProcesssPageView::render to throw 404 when appropriate
   * 
   * @param HookEvent $event
   * @throws WireException
   *
   */
  public function hookPageRender(HookEvent $event) {
    if($event) {}
    if($this->force404) {
      $this->force404 = false; // prevent another 404 on the 404 page
      throw new Wire404Exception('Not available in requested language', Wire404Exception::codeLanguage);
      // $page = $event->wire('page');    
      // if(!$page || ($page->id != $event->wire('config')->http404PageID)) {
      //  throw new Wire404Exception();
      // }
    }
  }

  /**
   * Hook in after ProcesssPageView::viewable account for specific language versions
   *
   * May be passed a Language name or page to check viewable for that language
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageViewable(HookEvent $event) {
    if(!$event->return) return;
    /** @var Page $page */
    $page = $event->object;
    // if(wire('user')->isSuperuser() || $page->editable()) return;
    /** @var Language $language */
    $language = $event->arguments(0);
    if(!$language) return;
    if(is_string($language)) $language = $this->wire('languages')->get($this->wire('sanitizer')->pageNameUTF8($language));
    if(!$language instanceof Language) return; // some other non-language argument
    if($language->isDefault()) return; // we accept the result of the original viewable() call
    $status = $page->get("status$language");
    $event->return = $status > 0 && $status < Page::statusUnpublished;
  }

  /**
   * Hook into WirePageEditor (i.e. ProcessPageEdit) to remove the non-applicable default home name of 'home'
   * 
   * @param HookEvent $event
   *
   */
  public function hookWirePageEditorExecute(HookEvent $event) {
    /** @var WirePageEditor $editor */
    $editor = $event->object; 
    $page = $editor->getPage();
    if($page && $page->id == 1) {
      if($page->name == Pages::defaultRootName) $page->name = '';
    }
  }

  /**
   * Hook into the page name render for when in ProcessPageEdit
   *
   * Adds additional inputs for each language
   * 
   * @param HookEvent $event
   *
   */
  public function hookInputfieldPageNameRenderAfter(HookEvent $event) {

    /** @var InputfieldPageName $inputfield */
    $inputfield = $event->object; 
    if($inputfield->languageSupportLabel) return; // prevent recursion

    $page = $this->process instanceof WirePageEditor ? $this->process->getPage() : $this->wire('pages')->newNullPage();
    if(!$page->id && $inputfield->editPage) $page = $inputfield->editPage;
    $template = $page->template ? $page->template : null;
    if($template && $template->noLang) return;
    
    /** @var Languages $languages */
    $user = $this->wire('user');
    $languages = $this->wire('languages');
    $savedLanguage = $user->language; 
    $savedValue = $inputfield->attr('value');
    $savedName = $inputfield->attr('name'); 
    $savedID = $inputfield->attr('id');
    $trackChanges = $inputfield->trackChanges();
    $inputfield->setTrackChanges(false);
    $checkboxLabel = $this->_('Active?');
    $out = ''; 

    $language = $languages->getDefault();
    $user->language = $language; 
    $inputfield->languageSupportLabel = $language->get('title|name');
    $out .= $inputfield->render();
    $editable = true; 
    if($page->id && !$page->editable('name', false)) $editable = false;

    // add labels and inputs for other languages
    foreach($languages as $language) {
      if($language->isDefault()) continue; 
      $user->language = $language; 
      $value = $page->get("name$language"); 
      if(is_null($value)) $value = $savedValue; 
      $id = "$savedID$language"; 
      $name  = "$savedName$language";
      $label = $language->get('title|name'); 
      $inputfield->languageSupportLabel = $label;
      $inputfield->attr('id', $id); 
      $inputfield->attr('name', $name); 
      $inputfield->attr('value', $value); 
      $inputfield->checkboxName = "status" . $language->id; 
      $inputfield->checkboxValue = 1; 
      $inputfield->checkboxLabel = $checkboxLabel;
      if($page->id > 0) $inputfield->checkboxChecked = $page->get($inputfield->checkboxName) > 0; 
      if(!$editable) $inputfield->attr('disabled', 'disabled');
      $out .= $inputfield->render();
    }

    // restore language that was saved in the 'before' hook
    $user->language = $savedLanguage; 

    // restore Inputfield values back to what they were
    $inputfield->attr('name', $savedName); 
    $inputfield->attr('savedID', $savedID); 
    $inputfield->attr('value', $savedValue); 
    $inputfield->setTrackChanges($trackChanges); 

    $event->return = $out; 
  }

  /**
   * Process the input data from hookInputfieldPageNameRender
   *
   * @todo Just move this to the InputfieldPageName module rather than using hooks
   * 
   * @param HookEvent $event
   *
   */
  public function hookInputfieldPageNameProcess(HookEvent $event) {

    /** @var InputfieldPageName $inputfield */
    $inputfield = $event->object; 
    //$page = $this->process == 'ProcessPageEdit' ? $this->process->getPage() : new NullPage();
    /** @var WirePageEditor $process */
    $process = $this->process; 
    /** @var Page $page */
    $page = $process instanceof WirePageEditor ? $process->getPage() : new NullPage();
    if($page->id && !$page->editable('name', false)) return; // name is not editable
    $input = $event->arguments[0];
    /** @var Languages $languages */
    $languages = $this->wire('languages');
    $sanitizer = $this->wire('sanitizer');

    foreach($languages as $language) {

      if($language->isDefault()) continue; 
      if(!$languages->editable($language)) continue;

      // set language status 
      $key = "status" . (int) $language->id; 
      $value = (int) $input->{"$key$inputfield->checkboxSuffix"};
      if($page->get($key) != $value) {
        $inputfield->trackChange($key);
        $inputfield->trackChange('value');
        if($page->id) $page->set($key, $value); 
          else $page->setQuietly($key, $value); 
      }

      // set language page name
      $name = $inputfield->attr('name') . $language;
      $value = $sanitizer->pageNameUTF8($input->$name);
      
      // if it matches the value for the default language, avoid double storing it
      if($value === $page->name) $value = '';
      
      // if it matches the value already on the page, then no need to go further
      $key = "name$language";
      if($value == $page->get($key)) continue; 
      
      $parentID = $page->parent_id;
      if(!$parentID) $parentID = (int) $this->wire('input')->post('parent_id');
      if(!$this->checkLanguagePageName($language, $page, $parentID, $value, $inputfield)) continue;

      if($page->id) {
        $page->set($key, $value);
      } else {
        $page->setQuietly($key, $value); // avoid non-template exception when new page
      }
    }
  }

  /**
   * Check changed page name for given language
   * 
   * @param Language $language
   * @param Page $page
   * @param int $parentID
   * @param string $value New page name
   * @param Wire|null $errorTarget Object to send error to (Inputfield likely)
   * @return bool True if all good, false if not
   * 
   */
  public function checkLanguagePageName(Language $language, Page $page, $parentID, $value, Wire $errorTarget = null) {
    // verify that it does not conflict with another page inheriting name from default language
    $isValid = true;
    $nameKey = "name$language->id";
    
    if(!strlen($value)) return true; 
    
    if($this->wire('config')->pageNameCharset == 'UTF8') {
      $value = $this->wire('sanitizer')->pageName($value, Sanitizer::toAscii); 
    }
    
    $sql =
      "SELECT id, name, $nameKey FROM pages " .
      "WHERE parent_id=:parent_id " .
      "AND id!=:id " .
      "AND (" .
        "(name=:newName AND $nameKey IS NULL) " . // default name matches and lang name inherits it (is null)
        "OR ($nameKey=:newName2)" . // or lang name is same as requested one
      ")";
    
    $query = $this->wire('database')->prepare($sql);
    $query->bindValue(':parent_id', $parentID, \PDO::PARAM_INT);
    $query->bindValue(':newName', $value);
    $query->bindValue(':newName2', $value);
    $query->bindValue(':id', $page->id, \PDO::PARAM_INT);
    
    try {
      $query->execute();
      $row = $query->fetch(\PDO::FETCH_ASSOC); 
      if($row) {
        $isValid = false;
        if($errorTarget) $errorTarget->error(sprintf(
          $this->_('A sibling page (id=%1$d) is already using name "%2$s" for language: %3$s'), 
          $row['id'], $value, $language->get('title|name')
        ));
      }
    } catch(\Exception $e) {
      $this->error($e->getMessage());
      $isValid = false;
    }
    
    $query->closeCursor();
    
    return $isValid;
  }

  /**
   * Hook into PageFinder::getQuery to add language status check
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageFinderGetQuery(HookEvent $event) {
    $query = $event->return;
    /** @var PageFinder $pageFinder */
    $pageFinder = $event->object; 
    $options = $pageFinder->getOptions();
    
    // don't enforce language status check with findAll is active
    if(!empty($options['findAll'])) return; 
    
    // don't apply exclusions when output formatting is off
    if(!$this->wire('pages')->outputFormatting) return;

    $language = $this->wire('user')->language; 
    if(!$language || $language->isDefault()) return;

    $status = "status" . (int) $language->id; 
    $query->where("pages.$status>0"); 
  }

  /**
   * Hook into Page::path to localize path for current language
   * 
   * @param HookEvent $event
   *
   */
  public function hookPagePath(HookEvent $event) {
    /** @var Page $page */
    $page = $event->object; 
    if($page->template->name == 'admin') return;
    $language = $this->wire()->user->language;
    if(!$language) $language = $this->wire()->languages->getDefault();
    $event->return = $this->getPagePath($page, $language); 
  }

  /**
   * Add a Page::localName function with optional $language as argument
   *
   * event param Language|string|int|bool Optional language, or boolean true for behavior of 2nd argument. 
   * event param bool Substitute default language page name when page name is not defined for requested language.
   * event return string Localized language name or blank if not set
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageLocalName(HookEvent $event) {
    /** @var Page $page */
    $page = $event->object; 
    $language = $this->getLanguage($event->arguments(0)); 
    $nameField = $language->isDefault() ? "name" : "name$language";
    $value = $page->get($nameField);
    if(is_null($value)) $value = '';
    if(empty($value) && $nameField !== 'name' && ($event->arguments(0) === true || $event->arguments(1) === true)) {
      $value = $page->name;
    }
    $event->return = $value; 
  }

  /**
   * Add a Page::localPath function with optional $language as argument
   *
   * event param Language|string|int Optional language 
   * event return string Localized language path
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageLocalPath(HookEvent $event) {
    /** @var Page $page */
    $page = $event->object; 
    $language = $this->getLanguage($event->arguments(0)); 
    $event->return = $this->getPagePath($page, $language);  
  }

  /**
   * Add a Page::localUrl function with optional $language as argument
   *
   * event param Language|string|int Optional language 
   * event return string Localized language URL
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageLocalUrl(HookEvent $event) {
    /** @var Page $page */
    $page = $event->object; 
    $language = $this->getLanguage($event->arguments(0)); 
    $event->return = $this->wire('config')->urls->root . ltrim($this->getPagePath($page, $language), '/');  
  }
  
  /**
   * Add a Page::localHttpUrl function with optional $language as argument
   *
   * event param Language|string|int Optional language
   * event return string Localized language name or blank if not set
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageLocalHttpUrl(HookEvent $event) {
    $this->hookPageLocalUrl($event); 
    $url = $event->return;
    $event->return = $this->wire('input')->scheme() . "://" . $this->wire('config')->httpHost . $url;
  }

  /**
   * Given an object, integer or string, return the Language object instance
   *
   * @param int|string|Language
   * @return Language
   *
   */
  protected function getLanguage($language) {

    if(is_object($language)) {
      if($language instanceof Language) return $language; 
      $language = '';
    }

    if($language && (is_string($language) || is_int($language))) {
      if(ctype_digit("$language")) $language = (int) $language; 
        else $language = $this->wire('sanitizer')->pageNameUTF8($language); 
      $language = $this->wire("languages")->get($language); 
    }

    if(!$language || !$language->id || !$language instanceof Language) {
      $language = $this->wire('languages')->get('default'); 
    }

    return $language; 
  }

  /**
   * Update pages table for new column when a language is added
   * 
   * @param Language|Page $language
   *
   */
  public function languageAdded(Page $language) {
    
    static $languagesAdded = array();
    
    if(!$language->id || $language->name == 'default') return;
    if($language instanceof Language && $language->isDefault()) return;
    if(isset($languagesAdded[$language->id])) return;
    
    $name = "name" . (int) $language->id;
    $status = "status" . (int) $language->id;
    $database = $this->wire('database');
    $errors = 0;
    $sqls = array(
      "Add column $name" => "ALTER TABLE pages ADD $name VARCHAR(" . Pages::nameMaxLength . ") CHARACTER SET ascii",
      "Add index for $name" => "ALTER TABLE pages ADD UNIQUE {$name}_parent_id ($name, parent_id)",
      "Add column $status" => "ALTER TABLE pages ADD $status INT UNSIGNED NOT NULL DEFAULT " . Page::statusOn,
    );
    
    foreach($sqls as $label => $sql) {
      try {
        $database->exec($sql);
      } catch(\Exception $e) {
        $this->error("$label: " . $e->getMessage(), Notice::log);
        $errors++;
      }
    }
    
    if(!$errors) $languagesAdded[$language->id] = $language->id;
  }

  /**
   * Hook called when language is added
   * 
   * @param HookEvent $event
   *
   */
  public function hookLanguageAdded(HookEvent $event) {
    $language = $event->arguments[0]; 
    $this->languageAdded($language); 
  }

  /**
   * Update pages table to remove column when a language is deleted
   * 
   * @param Language|Page $language
   *
   */
  protected function languageDeleted(Page $language) {
    if(!$language->id || $language->name == 'default') return;
    $name = "name" . (int) $language->id; 
    $status = "status" . (int) $language->id;
    $database = $this->wire('database');
    try {
      $database->exec("ALTER TABLE pages DROP INDEX {$name}_parent_id"); 
      $database->exec("ALTER TABLE pages DROP $name"); 
      $database->exec("ALTER TABLE pages DROP $status"); 
    } catch(\Exception $e) {
      // $this->error($e->getMessage(), Notice::log); // error message can be ignored here
    }
  }

  /**
   * Hook called when language is deleted
   * 
   * @param HookEvent $event
   *
   */
  public function hookLanguageDeleted(HookEvent $event) {
    $language = $event->arguments[0]; 
    $this->languageDeleted($language); 
  }

  /**
   * Hook called immediately before a page is saved
   *
   * Here we make use of the 'extraData' return property of the saveReady hook
   * to bundle in the language name fields into the query.
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageSaveReady(HookEvent $event) {

    /** @var Page $page */
    $page = $event->arguments[0];
    
    /** @var Pages $pages */
    $pages = $event->object; 
  
    /** @var Sanitizer $sanitizer */
    $sanitizer = $this->wire('sanitizer');
  
    /** @var array $extraData */
    $extraData = $event->return; 
    
    $alwaysActiveTypes = array('User', 'Role', 'Permission', 'Language'); 
    $pageNameCharset = $this->wire('config')->pageNameCharset;
    $isCloning = $pages->editor()->isCloning();
    
    if(!is_array($extraData)) $extraData = array();
    
    foreach($this->wire('languages') as $language) {
      
      if($language->isDefault()) continue; 
      $language_id = (int) $language->id; 

      // populate a name123 field for each language
      $name = "name$language_id";
      $value = $sanitizer->pageNameUTF8($page->get($name)); 
      if(!strlen($value)) {
        $value = 'NULL';
      } else if($isCloning) {
        // this save is the result of a clone() operation
        // make sure that the name is unique for other languages
        $value = $pages->names()->uniquePageName(array(
          'name' => $value, 
          'page' => $page, 
          'language' => $language, 
        ));
      }
      if($pageNameCharset == 'UTF8') {
        $extraData[$name] = $sanitizer->pageName($value, Sanitizer::toAscii);
      } else {
        $extraData[$name] = $value;
      }

      // populate a status123 field for each language
      $name = "status$language_id";
      if(method_exists($page, 'getForPage')) {
        // repeater page, pull status from 'for' page
        $value = (int) $page->getForPage()->get($name); 
        
      } else if(in_array($page->className(), $alwaysActiveTypes)) {
        // User, Role, Permission or Language: assume active status
        $value = Page::statusOn;
        
      } else {
        // regular page
        $value = (int) $page->get($name);
      }
      $extraData[$name] = $value; 
    }

    $event->return = $extraData; 
  }

  /**
   * Hook into Pages::setupNew
   * 
   * Used to assign a $page->name when none has been assigned, like if a user has added
   * a page in another language but not configured anything for default language
   * 
   * @param HookEvent $event
   * 
   */
  public function hookPageSetupNew(HookEvent $event) {
  
    /** @var Page $page */
    $page = $event->arguments[0];
    
    // if page already has a name, then no need to continue
    if($page->name) return;
    
    // account for possibility that a new page with non-default language name/title exists
    // this prevents an exception from being thrown by Pages::save
    $user = $this->wire('user');
    $userTrackChanges = $user->trackChanges();
    $userLanguage = $user->language;
    if($userTrackChanges) $user->setTrackChanges(false); 
    
    foreach($this->wire('languages') as $language) {
      if($language->isDefault()) continue; 
      $user->language = $language; 
      $name = $page->get("name$language"); 
      if(strlen($name)) $page->name = $name; 
      $title = $page->title;
      if(strlen($title)) {
        $page->title = $title;
        if(!$page->name) {
          if($this->wire('config')->pageNameCharset === 'UTF8') {
            $page->name = $this->wire('sanitizer')->pageNameUTF8($title);
          } else {
            $page->name = $this->wire('sanitizer')->pageName($title, Sanitizer::translate);
          }
        }
      }
      if($page->name) break;
    }
  
    // restore user to previous state
    $user->language = $userLanguage; 
    if($userTrackChanges) $user->setTrackChanges(true); 
  }

  /**
   * Hook called immediately after a page is saved
   * 
   * @param HookEvent $event
   * 
   */
  public function hookPageSaved(HookEvent $event) {
    // The setLanguage may get lost upon some page save events, so this restores that
    // $this->user->language = $this->setLanguage;
    $page = $event->arguments(0);
    $sanitizer = $this->wire('sanitizer');
    if(!$page->namePrevious) {
      // go into this only if we know the renamed hook hasn't already been called
      $renamed = false;
      foreach($this->wire('languages') as $language) {
        if($language->isDefault()) continue;
        $namePrevious = $page->get("-name$language");
        if(!$namePrevious) continue;
        $name = $sanitizer->pageNameUTF8($page->get("name$language"));
        if($sanitizer->pageNameUTF8($namePrevious) != $name) {
          $renamed = true;
          break;
        }
      }
      // trigger renamed hook if one of the language names changed
      if($renamed) $this->wire('pages')->renamed($page);
    }
  }

  /**
   * Return the unsanitized/original requested path
   * 
   * @return string
   * 
   */ 
  public function getRequestPath() {
    return $this->requestPath;  
  }
  
  /**
   * Return the Language that the given path is in or null if can't determine
   *
   * @param string $path Page path without without installation subdir or URL segments or page numbers
   * @param Page $page If you already know the $page that resulted from the path, provide it here for faster performance
   * @return Language|null
   *
   */
  public function getPagePathLanguage($path, Page $page = null) {

    $languages = $this->wire('languages');

    if(!$page || !$page->id) $page = $this->wire('pages')->getByPath($path, array(
      'useLanguages' => true,
      'useHistory' => true
    ));

    $foundLanguage = null;
    $path = trim($path, '/');
  
    // a blank path can only be homepage in default language
    if(!strlen($path)) return $languages->getDefault();

    // first check entire path for a match
    if($page->id) foreach($languages as $language) {
      $languages->setLanguage($language);
      if($path === trim($page->path(), '/')) $foundLanguage = $language;
      $languages->unsetLanguage();
      if($foundLanguage) break;
    }

    if($foundLanguage) return $foundLanguage;

    // if we get to this point, then we'll be checking the first segment and last segment
    $parts = explode('/', $path);
    $homepageID = $this->wire('config')->rootPageID;
    $homepage = $this->wire('pages')->get($homepageID);
    $firstPart = reset($parts);
    $lastPart = end($parts);
    
    $tests = array($firstPart => $homepage);
    if($homepage->id != $page->id && $firstPart != $lastPart) $tests[$lastPart] = $page; 

    foreach($tests as $part => $p) {
      if(!$p->id) continue;
      $duplicates = 0; // count duplicate names, which would invalidate any $foundLanguage
      foreach($languages as $language) {
        $key = 'name' . ($language->isDefault() ? '' : $language->id);
        $name = $p->get($key);
        if($name === $part) {
          $foundLanguage = $language;
          $duplicates++;
        }
      }
      if($foundLanguage && $duplicates > 1) $foundLanguage = null;
      if($foundLanguage) break;
    }
    
    if(!$foundLanguage && $page->parent_id > $homepageID && count($parts) > 1) {
      // if language not yet found, go recursive on the parent path before we throw in the towel
      array_pop($parts);
      $foundLanguage = $this->getPagePathLanguage(implode('/', $parts), $page->parent());
    }

    return $foundLanguage;
  }


  /**
   * Check to make sure that the status table exists and creates it if not
   * 
   * @param bool $force
   *
   */
  public function checkModuleVersion($force = false) {

    $info = self::getModuleInfo();

    if(!$force) {
      if($info['version'] == $this->moduleVersion) return;
    }

    $database = $this->wire('database');  
    
    // version 3 to 4 check: addition of language-specific status columns
    $query = $database->prepare("SHOW COLUMNS FROM pages WHERE Field LIKE 'status%'");
    $query->execute();
    
    if($query->rowCount() < 2) {
      foreach($this->wire('languages') as $language) {
        if($language->isDefault()) continue;
        $status = "status" . (int) $language->id;
        $database->exec("ALTER TABLE pages ADD $status INT UNSIGNED NOT NULL DEFAULT " . Page::statusOn);
        $this->message("Added status column for language: $language->name", Notice::log);
      }
    }

    // save module version in config data
    if($info['version'] != $this->moduleVersion) {
      $data = $this->wire('modules')->getModuleConfigData($this); 
      $data['moduleVersion'] = $info['version'];
      $this->wire('modules')->saveModuleConfigData($this, $data);
    }

  }

  /**
   * Module interactive configuration fields
   * 
   * @param array $data
   * @return InputfieldWrapper
   *
   */
  public function getModuleConfigInputfields(array $data) {

    $module = $this->wire('modules')->get('LanguageSupportPageNames'); 
    $module->checkModuleVersion(true); 
    $inputfields = $this->wire(new InputfieldWrapper());
    $config = $this->wire('config');
    $defaultUrlPrefix = $config->get('_pageNumUrlPrefix|pageNumUrlPrefix');
    
    foreach($this->wire('languages') as $language) {
      $f = $this->wire('modules')->get('InputfieldName'); 
      $name = "pageNumUrlPrefix$language";
      if($language->isDefault() && empty($data[$name])) $data[$name] = $defaultUrlPrefix;
      $f->attr('name', $name); 
      $f->attr('value', isset($data[$name]) ? $data[$name] : ''); 
      $f->label = "$language->title ($language->name) - " . $this->_('Page number prefix for pagination'); 
      $f->description = sprintf(
        $this->_('The page number is appended to this word in paginated URLs for this language. If omitted, "%s" will be used.'), 
        $defaultUrlPrefix
      ); 
      $f->required = false;
      $inputfields->add($f); 
    }

    $input = $this->wire('modules')->get('InputfieldRadios'); 
    $input->attr('name', 'useHomeSegment'); 
    $input->label = $this->_('Default language homepage URL is same as root URL?'); // label for the home segment option
    $input->description = $this->_('Choose **Yes** if you want the homepage of your default language to be served by the root URL **/** (recommended). Choose **No** if you want your root URL to perform a redirect to **/name/** (where /name/ is the default language name of your homepage).');  // description for the home segment option
    $input->notes = $this->_('This setting only affects the homepage behavior. If you select No, you must also make sure your homepage has a name defined for the default language.'); // notes for the home segment option
    $input->addOption(0, $this->_('Yes - Root URL serves default language homepage (recommended)'));  
    $input->addOption(1, $this->_('No - Root URL performs a redirect to: /name/')); 
    $input->attr('value', empty($data['useHomeSegment']) ? 0 : 1); 
    $input->collapsed = Inputfield::collapsedYes; 
    $inputfields->add($input); 

    return $inputfields;
  }

  /**
   * Install the module
   *
   */
  public function ___install() {

    foreach($this->wire('languages') as $language) {
      $this->languageAdded($language); 
    }

  }

  /**
   * Uninstall the module
   *
   */
  public function ___uninstall() {
    foreach($this->wire('languages') as $language) {
      $this->languageDeleted($language); 
    }
  }

}