'Page Path History', 'version' => 1, '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 it's 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; /** * 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'); } /** * Hook called when a page is moved or renamed * */ public function hookPageMoved(HookEvent $event) { $page = $event->arguments[0]; if($page->template == 'admin') return; $age = time() - $page->created; if($age < self::minimumAge) return; // note that the paths we store have no trailing slash 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 .= $page->namePrevious; else $path .= $page->name; $database = $this->wire('database'); $query = $database->prepare("INSERT INTO " . self::dbTableName . " SET path=:path, pages_id=:pages_id, created=NOW()"); $query->bindValue(":path", $path); $query->bindValue(":pages_id", $page->id, PDO::PARAM_INT); try { $query->execute(); } catch(Exception $e) { // catch the exception because it means there is already a past URL (duplicate) } // delete any possible entries that overlap with the $page since are no longer applicable $query = $database->prepare("DELETE FROM " . self::dbTableName . " WHERE path=:path LIMIT 1"); $query->bindValue(":path", rtrim($page->path, '/')); $query->execute(); } /** * Hook called upon 404 from ProcessPageView::pageNotFound * */ public function hookPageNotFound(HookEvent $event) { $page = $event->arguments[0]; // 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) return; $path = $event->arguments[1]; $page = $this->getPage($path); if($page->id && $page->viewable()) { // if a page was found, redirect to it $this->session->redirect($page->url); } } /** * Given a previously existing path, return the matching Page object or NullPage if not found. * * @param string $path Historical path of page you want to retrieve * @param int $level Recursion level for internal recursive use only * @return Page|NullPage * */ protected function getPage($path, $level = 0) { $page = new NullPage(); $pathRemoved = ''; $path = rtrim($path, '/'); $cnt = 0; $database = $this->wire('database'); while(strlen($path) && !$page->id && $cnt < self::maxSegments) { $query = $database->prepare("SELECT pages_id FROM " . self::dbTableName . " WHERE path=:path"); $query->bindValue(":path", $path); $query->execute(); if($query->rowCount() > 0) { $pages_id = $query->fetchColumn(); $page = $this->pages->get((int) $pages_id); } else { $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 = $this->pages->get($path); // if not, then go recursive, trying again if(!$page->id && $level < self::maxSegments) $page = $this->getPage($path, $level+1); } return $page; } /** * When a page is deleted, remove it from our redirects list as well * */ public function hookPageDeleted(HookEvent $event) { $page = $event->arguments[0]; $database = $this->wire('database'); $query = $database->prepare("DELETE FROM " . self::dbTableName . " WHERE pages_id=:pages_id"); $query->bindValue(":pages_id", $page->id, PDO::PARAM_INT); $query->execute(); } public function ___install() { $sql = "CREATE TABLE " . self::dbTableName . " (" . "path VARCHAR(255) NOT NULL, " . "pages_id INT UNSIGNED NOT NULL, " . "created TIMESTAMP NOT NULL, " . "PRIMARY KEY path (path), " . "INDEX pages_id (pages_id), " . "INDEX created (created) " . ") ENGINE={$this->config->dbEngine} DEFAULT CHARSET={$this->config->dbCharset}"; $this->wire('database')->exec($sql); } public function ___uninstall() { $this->wire('database')->query("DROP TABLE " . self::dbTableName); } }