'Page Path History', 'version' => 8, 'summary' => "Keeps track of past URLs where pages have lived and automatically redirects (301 permanent) 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 { // 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; } } 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); } } }