Skip to content

Instantly share code, notes, and snippets.

@bfncs
Created March 21, 2015 14:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bfncs/4f52979da5ff7bf67d8a to your computer and use it in GitHub Desktop.
Save bfncs/4f52979da5ff7bf67d8a to your computer and use it in GitHub Desktop.
Multilanguage page name aware Page Path History module for Processwire
<?php
/**
* 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.
* This module is a fork of the original PagePathHistory by Ryan Cramer in the Processwire
* Core and should ideally be merged back there.
*
* Licensed under GNU/GPL v2
*/
class PagePathHistoryLanguage extends WireData implements Module {
public static function getModuleInfo() {
return array(
'title' => 'Page Path History Language',
'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. Handles multilanguage pagenames.",
'singular' => true,
'autoload' => true,
);
}
/**
* Table created by this module
*
*/
const dbTableName = 'page_path_history_language';
/**
* 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;
/**
* @var bool True if multilanguage page name support is active
*/
protected $isMultilanguage;
/**
* @var string The path that was requested, before any processing
*/
protected $requestPath = '';
/**
* Initialize the hooks
*
*/
public function init() {
$this->addHookBefore('ProcessPageView::execute', $this, 'hookProcessPageViewExecute', array('priority' => 90));
$this->pages->addHook('moved', $this, 'hookPageMoved');
$this->pages->addHook('renamed', $this, 'hookPageMoved');
$this->pages->addHook('deleted', $this, 'hookPageDeleted');
$this->addHook('ProcessPageView::pageNotFound', $this, 'hookPageNotFound');
}
/**
* Initialize the multilanguage specific hooks
*/
public function ready() {
$this->isMultilanguage = $this->modules->isInstalled('LanguageSupportPageNames') &&
count(wire('languages'));
if ($this->isMultilanguage) {
$this->addHookAfter('Page::loaded', $this, 'hookPageLoaded');
$this->addHookAfter('Pages::saveReady', $this, 'hookPageSaveReady');
$this->addHookAfter('Pages::saved', $this, 'hookPageSaved');
}
}
public function hookProcessPageViewExecute(HookEvent $event) {
// Save now, since ProcessPageView removes $_GET['it'] when it executes
$it = isset($_GET['it']) ? $_GET['it'] : '';
// Add leading slash if missing
if ('/' !== substr($it, 0, 1)) {
$it = "/{$it}";
}
$this->requestPath = $it;
}
public function hookPageLoaded(HookEvent $event) {
$page = $event->object;
$pageId = $page->id;
// Return if page has already been processed or is admin page
if ($page->namePreviousLanguage || 'admin' === $page->template) {
return;
}
// Add all page name language versions to $page->namePreviousLanguage
$languages = $this->wire('languages');
$languageNames = array();
foreach ($languages as $language) {
if ($language->isDefault()) {
continue;
}
$languageNames[$language->id] = $page->localName($language);
}
$page->namePreviousLanguage = $languageNames;
}
public function hookPageSaveReady(HookEvent $event) {
$page = $event->arguments[0];
$pageId = $page->id;
if (empty($page->namePreviousLanguage) || 'admin' === $page->template) {
return;
}
// Remove all unchanged language names from $page->namePreviousLanguage
$languages = $this->wire('languages');
$languageNames = $page->namePreviousLanguage;
foreach($languages as $language) {
if ($language->isDefault() || empty($languageNames[$language->id])) {
continue;
}
$currentName = $page->localName($language);
$previousName = $languageNames[$language->id];
if ($currentName === $previousName) {
unset($languageNames[$language->id]);
}
}
$page->namePreviousLanguage = $languageNames;
}
/**
* Trigger hookPageMoved after save if not moved or renamed in primary language.
* @param HookEvent $event
*/
public function hookPageSaved(HookEvent $event) {
$page = $event->arguments[0];
$changes = $event->arguments[1];
// No need to do anything if page is renamed or moved anyway
$renamed = $page->namePrevious && ($page->namePrevious !== $page->name);
$moved = (bool) $page->parentPrevious;
if ($renamed || $moved) {
return;
}
// Trigger hook manually if name was changed in any language
$nameLanguageChanged = false;
$languages = wire('languages');
foreach ($languages as $language) {
if ($language->isDefault()) {
continue;
}
$fieldName = "name{$language->id}";
if (in_array($fieldName, $changes)) {
$nameLanguageChanged = true;
break;
}
}
if ($nameLanguageChanged) {
$this->hookPageMoved($event);
}
}
/**
* 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;
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
$parentPage = $page->parentPrevious;
} else {
// the start of our redirect URL will be the current parent's URL (i.e. name changed)
$parentPage = $page->parent;
}
$paths = array();
$currentPaths = array();
// Process default language page name
$pageName = $page->namePrevious ?
$page->namePrevious :
$page->name;
$pastPath = $parentPage->path . $pageName;
$currentPath = $page->path;
if ($currentPath !== $pastPath) {
$paths[] = $pastPath;
$currentPaths[] = $currentPath;
}
// Process language page names
if ($this->isMultilanguage) {
$languages = wire('languages');
/* @var LanguageSupportPageNames $languagePageNames */
$languagePageNames = $this->modules->get('LanguageSupportPageNames');
foreach ($languages as $language) {
if ($language->isDefault()) {
continue;
}
$parentPagePath = $parentPage->localPath($language);
$pageName = !empty($page->namePreviousLanguage[$language->id]) ?
$page->namePreviousLanguage[$language->id] :
$page->localName($language);
$pastPath = $parentPagePath . $pageName;
$currentPath = $page->localPath($language);
if ($currentPath !== $pastPath) {
// Remove language prefix from path
$paths[] = $pastPath;
$currentPaths[] = $currentPath;
}
}
}
/* @var WireDatabasePDO $database
* @var PDOStatement $query */
$database = $this->wire('database');
$query = $database->prepare("INSERT INTO " . self::dbTableName . " SET path=:path, pages_id=:pages_id, created=NOW()");
// Insert all past paths
foreach ($paths as $path) {
$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");
foreach ($currentPaths as $path) {
$query->bindValue(":path", rtrim($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;
$page = $this->getPage($this->requestPath);
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;
/* @var Database $database */
$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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment