Subversion Repositories web.active

Rev

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

<?php namespace ProcessWire;

/**
 * ProcessWire Page Path History
 *
 * Keeps track of past URLs where pages have lived and automatically 301 redirects
 * to the new location whenever the past URL is accessed. 
 *
 * ProcessWire 3.x, Copyright 2021 by Ryan Cramer
 * https://processwire.com
 * 
 * @method upgrade($fromVersion, $toVersion)
 * @property int $minimumAge
 * @property array|bool $rootSegments
 * 
 *
 */

class PagePathHistory extends WireData implements Module, ConfigurableModule {

  public static function getModuleInfo() {
    return array(
      'title' => 'Page Path History', 
      'version' => 8, 
      'summary' => "Keeps track of past URLs where pages have lived and automatically redirects (301 permament) to the new location whenever the past URL is accessed.",
      'singular' => true, 
      'autoload' => true, 
    );
  }

  /**
   * Table created by this module
   *
   */
  const dbTableName = 'page_path_history';

  /**
   * Minimum age in seconds that a page must be before we'll bother remembering its previous path
   *
   */
  const minimumAge = 120; 

  /**
   * Maximum segments to support in a redirect URL
   *
   * Used to place a limit on recursion and paths
   *
   */
  const maxSegments = 10;

  /**
   * PagePageHistory module/schema version
   * 
   * @var int
   * 
   */
  protected $version = 0;

  /**
   * Construct
   * 
   */
  public function __construct() {
    parent::__construct();
    $this->set('minimumAge', self::minimumAge); 
    $this->set('rootSegments', false);
  }

  /**
   * Initialize the hooks
   *
   */
  public function init() {
    $this->pages->addHook('moved', $this, 'hookPageMoved'); 
    $this->pages->addHook('renamed', $this, 'hookPageMoved'); 
    $this->pages->addHook('deleted', $this, 'hookPageDeleted');
    $this->addHook('ProcessPageView::pageNotFound', $this, 'hookPageNotFound'); 
    $this->addHook('Page::addUrl', $this, 'hookPageAddUrl');
    $this->addHook('Page::removeUrl', $this, 'hookPageRemoveUrl');
  }

  /**
   * Get version of this module/schema
   * 
   * @return int
   * 
   */
  protected function getVersion() {
    if($this->version) return $this->version;
    $this->version = $this->wire('modules')->getModuleInfoProperty($this, 'version');
    if(!$this->version) $this->version = 1;
    return $this->version;
  }

  /**
   * Whether or not to consider language_id in page_path_history module table
   * 
   * @return Languages|bool Returns Languages object if yes, or boolean false if not
   * 
   */
  protected function getLanguages() {
    if($this->getVersion() < 2) return false;
    $languages = $this->wire()->languages;
    return $languages && $languages->hasPageNames() ? $languages : false;
  }

  /**
   * Given a language ID, name or Language object, return Language object or NULL if not found
   * 
   * @param int|string|Language $language
   * @return Language|null
   * 
   */
  protected function getLanguage($language) {
    $languages = $this->getLanguages();
    if(!$languages) return null;
    if($language instanceof Page) {
      // ok
    } else if($language === 0) {
      $language = $languages->getDefault();
    } else if(is_int($language) || ctype_digit($language)) {
      $language = $languages->get((int) $language);
    } else if(is_string($language) && $language) {
      $language = $languages->get($this->wire('sanitizer')->pageNameUTF8($language));
    }
    if($language && !$language->id) $language = null;
    return $language;
  }

  /**
   * Set a history path for a page and delete any existing entries for page’s current path
   * 
   * @param Page $page
   * @param string $path
   * @param Language|int $language
   * @return bool True on success, or false if path already consumed in history
   *
   */
  public function setPathHistory(Page $page, $path, $language = null) {
    
    $database = $this->wire('database');
    $table = self::dbTableName;
    $result = $this->addPathHistory($page, $path, $language);
    
    if($result) {
      // delete any possible entries that overlap with the $page current path since are no longer applicable
      $query = $database->prepare("DELETE FROM $table WHERE path=:path LIMIT 1");
      $query->bindValue(":path", rtrim($this->wire()->sanitizer->pagePathName($page->path, Sanitizer::toAscii), '/'));
      $query->execute();
    }
    
    return $result;
  }

  /**
   * Add a history path for a page
   * 
   * @param Page $page
   * @param string $path
   * @param null|Language $language
   * @return bool True if path was added, or false if it likely overlaps with an existing path
   * 
   */
  public function addPathHistory(Page $page, $path, $language = null) {
  
    $sanitizer = $this->wire()->sanitizer;
    $database = $this->wire()->database;
    $modules = $this->wire()->modules;
    $table = self::dbTableName;
    $path = $sanitizer->pagePathName('/' . trim($path, '/'), Sanitizer::toAscii);
    
    $selector = "path=$path";
    if($modules->isInstalled('PagePaths')) $selector .= ", id!=$page->id";
    if($this->wire()->pages->count($selector)) return false; 
    
    $language = $this->getLanguage($language);

    $sql = "INSERT INTO $table SET path=:path, pages_id=:pages_id, created=NOW()";
    if($language) $sql .= ', language_id=:language_id';

    $query = $database->prepare($sql);
    $query->bindValue(":path", $path);
    $query->bindValue(":pages_id", $page->id, \PDO::PARAM_INT);
    if($language) $query->bindValue(':language_id', $language->id, \PDO::PARAM_INT);

    try {
      $result = $query->execute();
    } catch(\Exception $e) {
      // ignore the exception because it means there is already a past URL (duplicate)
      $result = false;
    }
    
    $this->addRootSegment($path);
  
    return $result;
  }
  
  /**
   * Delete path entry for given page and path
   * 
   * @param Page $page
   * @param string $path
   * @return int
   * 
   */
  public function deletePathHistory(Page $page, $path) {
    
    $database = $this->wire()->database;
    $table = self::dbTableName;
    $path = $this->wire()->sanitizer->pagePathName('/' . trim($path, '/'), Sanitizer::toAscii);
    
    $sql = "DELETE FROM $table WHERE path=:path AND pages_id=:pages_id LIMIT 1";
    $query = $database->prepare($sql);
    $query->bindValue(':path', $path);
    $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT);
    $query->execute();
    
    $cnt = $query->rowCount();
    $query->closeCursor();
    
    return $cnt;
  }
  
  /**
   * Delete all path history for a given Page or for all pages
   * 
   * @param Page|true $page If value of this param is boolean true (rather than Page), all paths for all pages are cleared
   * @throws WireException if param $page is not of expected type (true or Page)
   * @since 3.0.178
   *
   */
  public function deleteAllPathHistory($page) {
    $database = $this->wire()->database;
    if($page === true) {
      $database->exec('DELETE FROM ' . self::dbTableName);
    } else if($page instanceof Page && $page->id) {
      $query = $database->prepare('DELETE FROM ' . self::dbTableName . ' WHERE pages_id=:pages_id');
      $query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT);
      $query->execute();
    } else {
      throw new WireException("Invalid param: instance of Page or boolean true expected");
    }
    $this->rebuildRootSegments();
  }
  
  /**
   * Get an array of all paths the given page has previously had, oldest to newest
   *
   * For the options argument: 
   * - Optionally specify a Language instance (or name or ID) to isolate results to a specific language.
   * - Optionally specify boolean true to return verbose info. 
   * 
   * @param Page $page Page to retrieve paths for. 
   * @param Language|null|array|bool Specify an option below: 
   *  - `language` (Language|int|string): Limit returned paths to this language. If none specified, then all languages are included.
   *  - `verbose` (bool): Return associative array for each path with additional info (date and language, if present).
   *  - `virtual` (bool): Return history that includes auto-determined virtual entries from parent history? (default=true)
   *     What this does is also include changes to parent pages that would affect overall URL to requested page. 
   *  - Or you may specify the `language` option for the options argument. 
   *  - Or you may specify boolean `true` for options argument as a shortcut for the `verbose` option. 
   * @return array of paths
   * 
   */
  public function getPathHistory(Page $page, $options = array()) {
    
    static $level = 0;
    $level++;
    
    $defaults = array(
      'language' => !is_array($options) && !is_bool($options) ? $options : null, 
      'verbose' => is_bool($options) ? $options : false, 
      'virtual' => true, 
    );
    
    $database = $this->wire()->database;
    $sanitizer = $this->wire()->sanitizer;
    $languages = $this->wire()->languages;
    
    $paths = array();
    $options = is_array($options) ? array_merge($defaults, $options) : $defaults;
    
    if($this->getVersion() < 2) {
      $options['language'] = null;
      $allowLanguage = false;
    } else {
      $allowLanguage = $languages && $languages->hasPageNames();
    }
    
    $language = $options['language'] && $allowLanguage ? $this->getLanguage($options['language']) : null;
    $finds = array('pages_id' => $page->id);
    $selects = array('path');
    $wheres = array();
    
    if($options['verbose']) $selects[] = 'created';
    if($options['verbose'] && $allowLanguage) $selects[] = 'language_id';
    if($language) $finds['language_id'] = $language->isDefault() ? 0 : $language->id;
    
    foreach($finds as $col => $value) {
      $wheres[] = "$col=:$col";
    }
  
    $query = $database->prepare(
      'SELECT ' . implode(', ', $selects) . ' FROM ' . self::dbTableName . ' ' . 
      'WHERE ' . implode(' AND ', $wheres) . ' ' . 
      "ORDER BY created"
    );
    
    foreach($finds as $col => $value) {
      $query->bindValue(":$col", $value, \PDO::PARAM_INT); 
    }
    
    try {
      $query->execute();
      
      /** @noinspection PhpAssignmentInConditionInspection */
      while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
        
        $path = $sanitizer->pagePathName($row['path'], Sanitizer::toUTF8);
        
        if($options['verbose']) {
          $value = array('path' => $path);
          $pathDate = $row['created'];
          $value['date'] = $pathDate;
          if($allowLanguage && isset($row['language_id'])) {
            $pathLanguage = $this->getLanguage((int) $row['language_id']);
            $value['language'] = $pathLanguage && $pathLanguage->id ? $pathLanguage : null;
          }
        } else {
          $value = $path;
        }
        
        $paths[$path] = $value;
      }
    } catch(\Exception $e) {
      if(!$this->checkTableSchema()) {
        $this->error($e->getMessage(), Notice::superuser | Notice::log);
      }
    }
    
    if($options['virtual']) {
      // get changes to current and previous parents as well
      foreach($paths as $value) {
        $virtualPaths = $this->getVirtualHistory($page, $value, $options);
        foreach($virtualPaths as $virtualPath => $virtualInfo) {
          if(isset($paths[$virtualPath])) continue;
          $paths[$virtualPath] = $virtualInfo;
        }
      }
      if($level === 1 && $options['verbose']) {
        $paths = $this->sortVerbosePathInfo($paths); 
      }
    } 
  
    $level--;
    
    return array_values($paths);
  }

  /**
   * Sort verbose paths by date
   * 
   * @param array $paths Verbose paths
   * @param bool $newest Sort newest to oldest? Specify false so sort oldest to newest. (default=true)
   * @return array
   * 
   */
  protected function sortVerbosePathInfo(array $paths, $newest = true) {
    
    $sortPaths = array();
    
    foreach($paths as $value) {
      $date = strtotime($value['date']);
      while(isset($sortPaths[$date])) $date++;
      $sortPaths[$date] = $value;
    }
    
    if($newest) {
      krsort($sortPaths);
    } else {
      ksort($sortPaths);
    }
    
    return $sortPaths;
  }

  /**
   * Get history which includes entries not actually in pages_paths table reflecting changes to parents
   * 
   * @param Page $page
   * @param string|array $path
   * @param array $options
   *
   * @return array
   * 
   */
  protected function getVirtualHistory(Page $page, $path, array $options) {
    
    $paths = array();
    $checkParents = array();
    
    if(is_array($path)) {
      // path is verbose info
      $pathInfo = $path;
      $path = $pathInfo['path'];
    } else {
      // path is string
      $pathInfo = array('path');
    }
  
    // separate page name and parent path
    $parts = explode('/', trim($path, '/'));
    $pageName = array_pop($parts);
    $parentPath = implode('/', $parts);
  
    // if page’s current parent is not homepage, include it 
    if($page->parent_id > 1) {
      $checkParents[] = $page->parent;
    }

    // if historical parent path differs from page’s current parent path, include it
    if($parentPath === '' || $parentPath === '/') {
      // historial parent is root/home
    } else if($parentPath === trim($page->parent()->path(), '/')) {
      // historial parent is the same as current parent
    } else if($parentPath === trim($page->path(), '/')) {
      // historial parent is the page itself
    } else {
      // historial parent may be one we want to check
      $parent = $this->wire()->pages->get("/$parentPath");
      if(!$parent->id) $parent = $this->getPage($parentPath);
      // if parent from path is different from current page parent, include in our list of parents to check
      if($parent->id > 1 && $parent->id != $page->parent_id && $parent->id != $page->id) {
        $checkParents[] = $parent;
      }
    }

    // get paths for each parent 
    foreach($checkParents as $parent) {
      $parentPaths = $this->getVirtualHistoryParent($page, $pageName, $pathInfo, $parent, $options);
      foreach($parentPaths as $parentPath => $parentInfo) {
        if(!isset($paths[$parentPath])) {
          $paths[$parentPath] = $parentInfo;
        }
      }
    }
    
    return $paths;
  }

  /**
   * Get virtual history for page in context of a specific parent (companion to getVirtualHistory method)
   * 
   * @param Page $page
   * @param string $pageName Historical name (or same as page->name)
   * @param array|string $pagePathInfo Path or pathInfo array
   * @param Page $parent
   * @param array $options
   * @return array
   * 
   */
  protected function getVirtualHistoryParent(Page $page, $pageName, array $pagePathInfo, Page $parent, array $options) {
    
    $paths = array();
    
    // get path history for this parent
    $parentPaths = $this->getPathHistory($parent, $options);

    // pageNamesDates is array of name => timestamp
    $pageNamesDates = array(
      $pageName => isset($pagePathInfo['date']) ? strtotime($pagePathInfo['date']) : 0
    );

    // if historical name differs from current name, include current name in pageNamesDates
    if($page->name != $pageName) {
      $pageNamesDates[$page->name] = $page->modified;
    }

    // iterate through each of the names this page has had, along with the date that it was changed to it
    foreach($pageNamesDates as $name => $date) {

      // iterate through all possible parent paths
      foreach($parentPaths as $parentPathInfo) {

        $parentPath = $options['verbose'] ? $parentPathInfo['path'] : $parentPathInfo;

        // create path that is historical parent path plus current iteration of page name
        $path = $parentPath . '/' . $name;

        // if we've already got this path, skip it
        if(isset($paths[$path])) continue;

        // non-verbose mode only includes paths
        if(empty($options['verbose'])) {
          $paths[$path] = $path;
          continue;
        }
        
        // if parent change date is older than page change date, then we can skip it
        if(strtotime($parentPathInfo['date']) < $date) continue;
      
        // if path is related to trash do not include it
        if(strpos($path, '/trash/') === 0 || preg_match('!/\d+\.\d+\.\d+_[-_a-z0-9]+!', $path)) {
          continue;
        }
        
        // create verbose info for this entry
        $pathInfo = array(
          'path' => $path,
          'date' => $parentPathInfo['date'],
          'virtual' => $parent->id
        );
        
        // if parent is specific to a language, include that info in the verbose value
        if(isset($parentPathInfo['language'])) {
          $pathInfo['language'] = $parentPathInfo['language'];
        }
        
        $paths[$path] = $pathInfo;
      }
    }
    
    return $paths;
  }

  /**
   * Get array of info about a path if it is in history
   * 
   * If path is found in history, the returned array `id` value will be populated with a positive 
   * integer of the found page ID. If not found, it will be populated with integer 0. 
   * 
   * By default this method attempts to perform exact path matches only. To enable partial matches
   * of paths that may be appended with additional URL segments, set the `allowUrlSegments` option
   * to true. Note that it will only apply to matched pages that have templates allowing URL 
   * segments. 
   * 
   * Return array includes:
   * 
   * - `id` (int): ID of matched page or 0 if no match.
   * - `path` (string): Path that was matched.
   * - `language_id` (int): ID of language for path, if applicable.
   * - `templates_id` (int): ID of template for page that was matched.
   * - `parent_id (int): ID of parent for page that was matched.
   * - `status` (int): Status of the page that was matched.
   * - `created` (string): Date that this entry was created (ISO-8601 date/time string).
   * - `name` (string): Name of page that was matched in default language.
   * - `urlSegmentStr` (string): Portion of path that was identified as URL segments (for partial match).
   * - `matchType` (string): Contains value “exact” when exact match, “partial” when partial/URL segments 
   *    match, or blank string when no match.
   * 
   * Note that the `urlSegmentStr` and `matchType` properties may only be of interest if the
   * given `allowUrlSegments` option is set to `true`. 
   * 
   * @param string $path
   * @param array $options
   *  - `allowUrlSegments` (bool): Allow matching paths with URL segments? (default=false)
   *     When used, the `urlSegmentStr` return value property will be populated with slash
   *     separated URL segments that were not part of the matched path, and the `matchType`
   *     property will contain the value “partial”. 
   * @return array
   * @since 3.0.186
   * 
   */
  public function getPathInfo($path, array $options = array()) {
    
    $defaults = array(
      'allowUrlSegments' => false,
    );
  
    $options = array_merge($defaults, $options);
    $sanitizer = $this->wire()->sanitizer;
    $templates = $this->wire()->templates;
    $database = $this->wire()->database;
    $config = $this->wire()->config;
    $table = self::dbTableName;
    $path = '/' . trim($path, '/');
    $originalPath = $path; // original path (without ascii conversion)
    $namesUTF8 = $config->pageNameCharset === 'UTF8';
    
    $result = array(
      'id' => 0,
      'path' => $path,
      'language_id' => 0,
      'templates_id' => 0,
      'parent_id' => 0,
      'created' => '',
      'status' => 0,
      'name' => '',
      'matchType' => '',
      'urlSegmentStr' => '',
    );
    
    if($namesUTF8) $path = $sanitizer->pagePathName($path, Sanitizer::toAscii);
    $requestPath = $path; // path that was requested (with ascii conversion)

    $wheres = array("$table.path=:path");
    $binds['path'] = $requestPath;
    
    if($options['allowUrlSegments']) {
      $n = 0;
      while(strlen($path)) {
        $pos = strrpos($path, '/'); 
        if(!$pos) break;
        $path = substr($path, 0, $pos);
        $wheres[] = "$table.path=:path$n";
        $binds["path$n"] = rtrim($path, '/');
        $n++;
      }
    }
  
    $sql = 
      "SELECT $table.path AS path, $table.pages_id AS id, $table.created AS created, $table.language_id AS language_id, " . 
      "pages.templates_id AS templates_id, pages.parent_id AS parent_id, pages.status AS status, pages.name AS name " . 
      "FROM $table " . 
      "LEFT JOIN pages ON $table.pages_id=pages.id " . 
      "WHERE " . implode(' OR ', $wheres);
    
    
    try {
      $query = $database->prepare($sql);
      foreach($binds as $bindKey => $bindValue) {
        $query->bindValue(":$bindKey", $bindValue);
      }
      $query->execute();
      $rowCount = $query->rowCount();
      $query->closeCursor();
    } catch(\Exception $e) {
      if(!$this->checkTableSchema()) throw $e;
      $rowCount = 0;
      $query = null;
    }
    
    if(!$rowCount || $query) return $result;
    
    $rows = array();
    $pathCounts = array();
    $matchRow = null;
    
    while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
      
      $path = $row['path'];
      
      if($path === $requestPath) {
        // found exact match
        $matchRow = $row;
        break;
      } else {
        // path with urlSegments match
        $rows[$path] = $row;
        $pathCounts[$path] = substr_count($path, '/');
      }
    }
    
    $query->closeCursor();
    
    if($matchRow) {
      // ok found
      $result['matchType'] = 'exact';
    } else if($rowCount) {
      // select from multiple matched rows (urlSegments mode only)
      // order by quantity of slashes (most to least)
      arsort($pathCounts);
      // find first row that has a template allowing URL segments
      foreach($pathCounts as $path => $count) {
        $row = $rows[$path];
        $template = $templates->get((int) $row['templates_id']);
        if(!$template || !$template->urlSegments) continue;
        $matchRow = $row;
        $result['matchType'] = 'partial';
        break;
      }
    } else {
      // no match
    }
    
    if($matchRow) {
      $result = array_merge($result, $matchRow);
    }
  
    // if no match return now
    if(!$result['id']) return $result;
    
    foreach($result as $key => $value) {
      if($key === 'id' || $key === 'status' || strpos($key, '_id')) {
        $result[$key] = (int) $value;
      } else if($key === 'path' && $namesUTF8) {
        $result['path'] = $sanitizer->pagePathName($value, Sanitizer::toUTF8);
      } else if($key === 'name' && $namesUTF8) {
        $result['name'] = $sanitizer->pageName($value, Sanitizer::toUTF8);
      }
    }
    
    if($result['matchType'] === 'partial') {
      $result['urlSegmentStr'] = trim(substr($originalPath, strlen($result['path'])+1), '/');
    }
    
    return $result;
  }

  /**
   * Given a previously existing path, return the matching Page object or NullPage if not found.
   * 
   * If the path is for a specific language, this method also sets a $page->_language property
   * containing the Language object the path is for. 
   *
   * @param string $path Historical path of page you want to retrieve
   * @param int $level Recursion level for internal recursive use only
   * @return Page|NullPage
   *
   */
  public function getPage($path, $level = 0) {
    
    $pages = $this->wire()->pages;
    $page = $pages->newNullPage();
    $sanitizer = $this->wire()->sanitizer;
    $languages = $this->getLanguages();
    $database = $this->wire()->database;
    $table = self::dbTableName;
    $pathRemoved = '';
    $cnt = 0;
    
    if(!$level) {
      $path = $sanitizer->pagePathName($path, Sanitizer::toAscii);
      if(!$this->isRootSegment($path)) return $pages->newNullPage();
    }
    
    $path = '/' . trim($path, '/');

    while(strlen($path) && !$page->id && $cnt < self::maxSegments) {

      $sql = "SELECT pages_id ";
      if($languages) $sql .= ", language_id ";
      $sql .= "FROM $table WHERE path=:path";
      $query = $database->prepare($sql);
      $query->bindValue(":path", $path); 
      $error = false;
      
      try {
        $query->execute();
      } catch(\Exception $e) {
        if(strpos($e->getMessage(), '1054') !== false) $this->upgrade(1, 2);
        $this->wire()->log->error('PagePathHistory::getPage() - ' . $e->getMessage());
        $error = true;
      }
      
      if($error) break;
      
      if($query->rowCount() > 0) {
        // found a match
        $row = $query->fetch(\PDO::FETCH_NUM);
        $pages_id = (int) $row[0];
        $language_id = $languages && isset($row[1]) ? $row[1] : 0;
        $page = $this->pages->get((int) $pages_id);
        if($language_id) $page->setQuietly("_language", $this->getLanguage($language_id));
      } else {
        // didn't find a match, we'll pop the last segment off and try again for the parent
        $pos = strrpos($path, '/');
        $pathRemoved = substr($path, $pos) . $pathRemoved;
        $path = substr($path, 0, $pos);
      }
      
      $query->closeCursor();
      $cnt++;
    } 

    // if no page was found, then we can stop trying now
    if(!$page->id) return $page; 

    if($cnt > 1) {
      // a parent match was found if our counter is > 1
      $parent = $page; 
      // use the new parent path and add the removed components back on to it
      $path = rtrim($parent->path, '/') . $pathRemoved; 
      // see if it might exist at the new parent's URL
      $page = $pages->getByPath($path, array(
        'useHistory' => false,
        'useLanguages' => $languages ? true : false
      )); 
      if($page->id) {
        // found a page
        $languagePageNames = $languages ? $languages->pageNames() : null;
        if($languagePageNames) {
          $language = $languagePageNames->getPagePathLanguage($path, $page);
          if($language) $page->setQuietly('_language', $language);
        }
      } else if($level < self::maxSegments) {
        // if not, then go recursive, trying again
        $page = $this->getPage($path, $level + 1);
      }
    }
    
    return $page;   
  }

  /*** ROOT SEGMENTS ***********************************************************/
  
  /**
   * Get all root segments
   *
   * @return array
   * @since 3.0.186
   *
   */
  public function getRootSegments() {
    if(is_array($this->rootSegments)) return $this->rootSegments;
    return $this->rebuildRootSegments();
  }

  /**
   * Is/was given segment ever a root segment?
   * 
   * @param string $segment Segment or path containing it (in ascii format)
   * @return bool
   * @since 3.0.186
   * 
   */
  public function isRootSegment($segment) {
    $segment = trim($segment, '/');
    if(strpos($segment, '/')) list($segment,) = explode('/', $segment, 2);
    $segments = $this->getRootSegments();
    return in_array($segment, $segments, true);
  }

  /**
   * Add a root segment
   *
   * @param string $segment May be a segment or path to extract it from (in ascii format)
   * @return bool True if added, false if it was already present
   * @since 3.0.186
   *
   */
  protected function addRootSegment($segment) {
    $segment = trim($segment, '/');
    if(strpos($segment, '/')) list($segment,) = explode('/', $segment, 2);
    $rootSegments = $this->rootSegments;
    if(!is_array($rootSegments)) $rootSegments = array();
    if(in_array($segment, $rootSegments, true)) return false;
    $rootSegments[] = $segment;
    $this->rootSegments = $rootSegments;
    $this->wire()->modules->saveConfig($this, 'rootSegments', $rootSegments);
    return true;
  }

  /**
   * Rebuild all root segments
   *
   * @return array
   * @since 3.0.186
   *
   */
  protected function rebuildRootSegments() {
    $segments = array();
    $sql = 'SELECT path FROM ' . self::dbTableName;
    $query = $this->wire()->database->prepare($sql);
    $query->execute();
    while($row = $query->fetch(\PDO::FETCH_NUM)) {
      $path = trim($row[0], '/');
      list($segment,) = explode('/', $path, 2);
      $segments[$segment] = $segment;
    }
    $query->closeCursor();
    $segments = array_values($segments);
    $this->rootSegments = $segments;
    $this->wire()->modules->saveConfig($this, 'rootSegments', $segments);
    return $segments;
  }
  
  /*** HOOKS *******************************************************************/
  
  /**
   * Hook called when a page is moved or renamed
   *
   * @param HookEvent $event
   *
   */
  public function hookPageMoved(HookEvent $event) {

    /** @var Page $page */
    $page = $event->arguments[0];
    /** @var Languages $languages */
    $languages = $this->getLanguages();

    $age = time() - $page->created;
    if($page->template->name === 'admin' || $this->wire()->pages->cloning || $age < $this->minimumAge) return;

    // note that the paths we store have no trailing slash

    if($languages) {
      $parent = $page->parent();
      $parentPrevious = $page->parentPrevious;
      if($parentPrevious && $parentPrevious->id == $parent->id) $parentPrevious = null;
      foreach($languages as $language) {
        /** @var Language $language */
        if($language->isDefault()) continue;
        $namePrevious = $page->get("-name$language");
        if(!$namePrevious && !$parentPrevious) continue;
        if(!$namePrevious) $namePrevious = $page->name;
        $languages->setLanguage($language);
        $pathPrevious = $parentPrevious ? $parentPrevious->path() : $page->parent()->path();
        $pathPrevious = rtrim($pathPrevious, '/') . "/$namePrevious";
        $this->setPathHistory($page, $pathPrevious, $language->id);
        $languages->unsetLanguage();
      }
    }

    if(!$page->namePrevious) {
      // abort saving a former URL if it looks like there isn't going to be one
      if(!$page->parentPrevious || $page->parentPrevious->id == $page->parent->id) return;
    }

    if($page->parentPrevious) {

      // if former or current parent is in trash, then don't bother saving redirects
      if($page->parentPrevious->isTrash() || $page->parent->isTrash()) return;

      // the start of our redirect URL will be the previous parent's URL
      $path = $page->parentPrevious->path;

    } else {
      // the start of our redirect URL will be the current parent's URL (i.e. name changed)
      $path = $page->parent->path;
    }

    if($page->namePrevious) {
      $path = rtrim($path, '/') . '/' . $page->namePrevious;
    } else {
      $path = rtrim($path, '/') . '/' . $page->name;
    }

    // do not save paths that reference recovery format used by trash
    // example: /blog/posts/5134.3096.83_page-name
    if(strpos($path, '.') !== false && strpos($path, '_') !== false) {
      if(preg_match('!/\d+\.\d+\.\d+_!', $path)) return;
    }

    // do not save paths that match any untitled page name
    // example: /blog/posts/untitled-123123
    $untitled = $this->wire()->pages->names()->untitledPageName();
    if(strpos($path, $untitled) !== false) {
      if(preg_match('!/' . preg_quote($untitled) . '[-]!', $path)) return;
    }

    if($languages) $languages->setDefault();
    $this->setPathHistory($page, $path);
    if($languages) $languages->unsetDefault();
  }

  /**
   * Hook called upon 404 from ProcessPageView::pageNotFound
   *
   * @param HookEvent $event
   *
   */
  public function hookPageNotFound(HookEvent $event) {

    /** @var Page $page */
    $page = $event->arguments(0);
    /** @var Wire404Exception $exception */
    $exception = $event->arguments(4);

    // If there is a page object set, then it means the 404 was triggered
    // by the user not having access to it, or by the $page's template 
    // throwing a 404 exception. In either case, we don't want to do a 
    // redirect if there is a $page since any 404 is intentional there.
    if($page && $page->id) {
      // it did resolve to a Page: maybe a front-end 404
      if(!$exception) {
        // pageNotFound was called without an Exception
        return;
      } else if($exception->getCode() == Wire404Exception::codeFunction) {
        // the wire404() function was called: allow PagePathHistory
      } else if($exception->getMessage() === "1") {
        // also allow PagePathHistory to operate when: throw new WireException(true);
      } else {
        // likely user didn't have access or intentional 404 that should not redirect
        return;
      }
    }

    $languages = $this->getLanguages();
    $languagePageNames = $languages ? $languages->pageNames() : null;
    if($languagePageNames) {
      // the LanguageSupportPageNames may change the original requested path, so we ask it for the original
      $path = $languagePageNames->getRequestPath();
      $path = $path ? $this->wire()->sanitizer->pagePathName($path) : $event->arguments(1);
    } else {
      $path = $event->arguments(1);
    }

    $page = $this->getPage($path);

    if($page->id && $page->viewable()) {
      // if a page was found, redirect to it...
      $language = $page->get('_language');
      if($language && $languages) {
        // ...optionally for a specific language
        if($page->get("status$language")) {
          $languages->setLanguage($language);
        }
      }
      $this->session->redirect($page->url);
    }
  }

  /**
   * When a page is deleted, remove it from our redirects list as well
   * 
   * @param HookEvent $event
   *
   */
  public function hookPageDeleted(HookEvent $event) {
    $page = $event->arguments[0];
    $database = $this->wire()->database;
    $table = self::dbTableName;
    $query = $database->prepare("DELETE FROM $table WHERE pages_id=:pages_id"); 
    $query->bindValue(":pages_id", $page->id, \PDO::PARAM_INT);
    $query->execute();
  }

  /**
   * Implementation for $page->addUrl($url, [$language]) method
   * 
   * @param HookEvent $event
   * 
   */
  public function hookPageAddUrl(HookEvent $event) {
    /** @var Page $page */
    $page = $event->object; 
    /** @var string $url */
    $url = $event->arguments(0);
    /** @var Language|null $language */
    $language = $event->arguments(1);
    $event->return = $this->addPathHistory($page, $this->urlToPath($url), $language); 
  }
  
  /**
   * Implementation for $page->removeUrl($url, [$language]) method
   *
   * @param HookEvent $event
   *
   */
  public function hookPageRemoveUrl(HookEvent $event) {
    /** @var page $page */
    $page = $event->object;
    /** @var string $url */
    $url = $event->arguments(0);
    $event->return = (bool) $this->deletePathHistory($page, $this->urlToPath($url));
  }
  
  /*** MODULE ******************************************************************/

  /**
   * Given URL that may include a root subdirectory, convert it to path relative to root subdirectory
   * 
   * @param string $url
   * @return string
   * 
   */
  protected function urlToPath($url) {
    $rootUrl = $this->wire()->config->urls->root;
    if(strlen($rootUrl) > 1 && strpos($url, $rootUrl) === 0) {
      $path = substr($url, strlen($rootUrl) - 1);
    } else {
      $path = $url;
    }
    return $path;
  }

  /**
   * Check table schema and update as needed
   * 
   * @return bool True if schema updated, false if not
   * 
   */
  protected function checkTableSchema() {
    
    $database = $this->wire()->database;
    $table = self::dbTableName;
    $updated = false;
    
    if(!$database->columnExists($table, 'language_id')) {
      try {
        $database->exec("ALTER TABLE $table ADD language_id INT UNSIGNED DEFAULT 0");
        $this->message("Added 'language_id' column to table $table", Notice::debug); 
        $updated = true;
      } catch(\Exception $e) {
        $this->error($e->getMessage(), Notice::superuser | Notice::log);
      }
    }
    
    return $updated;
  }

  /**
   * Install
   * 
   */
  public function ___install() {
  
    $database = $this->wire()->database;
    $len = $database->getMaxIndexLength();
    $table = self::dbTableName;
  
    if($database->tableExists($table)) {
      $this->checkTableSchema();
      return;
    }

    $sql =  
      "CREATE TABLE $table (" . 
      "path VARCHAR($len) NOT NULL, " . 
      "pages_id INT UNSIGNED NOT NULL, " .
      "language_id INT UNSIGNED DEFAULT 0, " . // v2
      "created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " . 
      "PRIMARY KEY path (path), " . 
      "INDEX pages_id (pages_id), " . 
      "INDEX created (created) " . 
      ") ENGINE={$this->config->dbEngine} DEFAULT CHARSET={$this->config->dbCharset}";

    $database->exec($sql);
  }

  /**
   * Uninstall
   * 
   */
  public function ___uninstall() {
    $this->wire()->database->query("DROP TABLE " . self::dbTableName); 
  }

  /**
   * Upgrade PagePathHistory module schema
   * 
   * @param int $fromVersion
   * @param int $toVersion
   * 
   */
  public function ___upgrade($fromVersion, $toVersion) {
    if($this->checkTableSchema()) { 
      if($fromVersion != $toVersion) $this->message("PagePathHistory v$fromVersion => v$toVersion");
    }
    $this->rebuildRootSegments();
  }

  /**
   * Module config
   * 
   * @param InputfieldWrapper $inputfields
   * 
   */
  public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {
    $modules = $this->wire()->modules;
    
    /** @var InputfieldInteger $f */
    $f = $modules->get('InputfieldInteger');
    $f->attr('name', 'minimumAge');
    $f->label = $this->_('Minimum age (seconds)');
    $f->description = $this->_('Start recording history for a page this many seconds after it has been created. At least 2 or more seconds recommended.');
    $f->notes = sprintf($this->_('Default: %s'), self::minimumAge);
    $f->val((int) $this->minimumAge);
    $f->required = true;
    $inputfields->add($f);
    
    $query = $this->wire()->database->query('SELECT COUNT(*) FROM ' . self::dbTableName);
    $numPaths = (int) $query->fetchColumn();
    $query->closeCursor();

    if($numPaths) {
      $input = $this->wire()->input;
      $deleteNow = $input->post('_deleteAll') && $input->post('_deleteAllConfirm') === "$numPaths";
      
      if($deleteNow) {
        $this->deleteAllPathHistory(true);
        $inputfields->message(sprintf($this->_('Deleted %d historical page paths'), $numPaths));
        $numPaths = 0;
      }

      /** @var InputfieldCheckbox $f */
      $f = $modules->get('InputfieldCheckbox');
      $f->attr('name', '_deleteAll');
      $f->attr('value', 1);
      $f->label = $this->_('Delete all page path history?');
      $f->description = sprintf($this->_('There are currently %d historical page paths in the database.'), $numPaths);
      $f->collapsed = Inputfield::collapsedYes;
      $inputfields->add($f);

      /** @var InputfieldCheckbox $f */
      $f = $modules->get('InputfieldCheckbox');
      $f->attr('name', '_deleteAllConfirm');
      $f->attr('value', 1);
      $f->label = $this->_('Are you sure?');
      $f->description = $this->_('This information is used for automatic redirects and more. It cannot be recovered once deleted. Check the box to confirm you really want to do this.');
      $f->showIf = '_deleteAll=1';
      $f->val($numPaths);
      $inputfields->add($f);
    }
  }

}