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 wellforeach($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 itif($page->parent_id > 1) {$checkParents[] = $page->parent;}// if historical parent path differs from page’s current parent path, include itif($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 checkif($parent->id > 1 && $parent->id != $page->parent_id && $parent->id != $page->id) {$checkParents[] = $parent;}}// get paths for each parentforeach($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 pageNamesDatesif($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 itforeach($pageNamesDates as $name => $date) {// iterate through all possible parent pathsforeach($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 itif(isset($paths[$path])) continue;// non-verbose mode only includes pathsif(empty($options['verbose'])) {$paths[$path] = $path;continue;}// if parent change date is older than page change date, then we can skip itif(strtotime($parentPathInfo['date']) < $date) continue;// if path is related to trash do not include itif(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 valueif(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 segmentsforeach($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 nowif(!$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 nowif(!$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 slashif($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 oneif(!$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 redirectsif($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-nameif(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 404if(!$exception) {// pageNotFound was called without an Exceptionreturn;} 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 redirectreturn;}}$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 languageif($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);}}}