902 lines
27 KiB
Text
902 lines
27 KiB
Text
|
<?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 2019 by Ryan Cramer
|
|||
|
* https://processwire.com
|
|||
|
*
|
|||
|
* @method upgrade($fromVersion, $toVersion)
|
|||
|
* @property int $minimumAge
|
|||
|
*
|
|||
|
*
|
|||
|
*/
|
|||
|
|
|||
|
class PagePathHistory extends WireData implements Module, ConfigurableModule {
|
|||
|
|
|||
|
public static function getModuleInfo() {
|
|||
|
return array(
|
|||
|
'title' => 'Page Path History',
|
|||
|
'version' => 6,
|
|||
|
'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);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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;
|
|||
|
if(!$this->wire('modules')->isInstalled('LanguageSupportPageNames')) return false;
|
|||
|
return $this->wire('languages');
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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) {
|
|||
|
|
|||
|
$database = $this->wire('database');
|
|||
|
$table = self::dbTableName;
|
|||
|
$path = $this->wire('sanitizer')->pagePathName('/' . trim($path, '/'), Sanitizer::toAscii);
|
|||
|
|
|||
|
$selector = "path=$path";
|
|||
|
if($this->wire('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;
|
|||
|
}
|
|||
|
|
|||
|
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");
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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,
|
|||
|
);
|
|||
|
|
|||
|
/** @var WireDatabasePDO $database */
|
|||
|
$database = $this->wire('database');
|
|||
|
|
|||
|
/** @var Sanitizer $sanitizer */
|
|||
|
$sanitizer = $this->wire('sanitizer');
|
|||
|
|
|||
|
$paths = array();
|
|||
|
$options = is_array($options) ? array_merge($defaults, $options) : $defaults;
|
|||
|
|
|||
|
if($this->getVersion() < 2) {
|
|||
|
$options['language'] = null;
|
|||
|
$allowLanguage = false;
|
|||
|
} else {
|
|||
|
$allowLanguage = $this->wire('languages') && $this->wire('modules')->isInstalled('LanguageSupportPageNames');
|
|||
|
}
|
|||
|
|
|||
|
$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) {
|
|||
|
// intentionally blank
|
|||
|
}
|
|||
|
|
|||
|
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;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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 == '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;
|
|||
|
}
|
|||
|
|
|||
|
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();
|
|||
|
if($languages) {
|
|||
|
// the LanguageSupportPageNames may change the original requested path, so we ask it for the original
|
|||
|
$path = $this->wire('modules')->get('LanguageSupportPageNames')->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);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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) {
|
|||
|
|
|||
|
$page = $this->wire('pages')->newNullPage();
|
|||
|
$pathRemoved = '';
|
|||
|
$cnt = 0;
|
|||
|
$database = $this->wire('database');
|
|||
|
$table = self::dbTableName;
|
|||
|
$languages = $this->getLanguages();
|
|||
|
|
|||
|
if(!$level) $path = $this->wire('sanitizer')->pagePathName($path, Sanitizer::toAscii);
|
|||
|
$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 = $this->wire('pages')->getByPath($path, array(
|
|||
|
'useHistory' => false,
|
|||
|
'useLanguages' => $languages ? true : false
|
|||
|
));
|
|||
|
if($page->id) {
|
|||
|
// found a page
|
|||
|
if($languages) {
|
|||
|
$language = $this->wire('modules')->get('LanguageSupportPageNames')->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;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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');
|
|||
|
$query = $database->prepare("DELETE FROM " . self::dbTableName . " 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));
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Install
|
|||
|
*
|
|||
|
*/
|
|||
|
public function ___install() {
|
|||
|
|
|||
|
$len = $this->wire('database')->getMaxIndexLength();
|
|||
|
|
|||
|
$sql = "CREATE TABLE " . self::dbTableName . " (" .
|
|||
|
"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}";
|
|||
|
|
|||
|
$this->wire('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($fromVersion == 1) {
|
|||
|
|
|||
|
$messagePrefix = "PagePathHistory v$fromVersion => v$toVersion: ";
|
|||
|
$database = $this->wire('database');
|
|||
|
$table = self::dbTableName;
|
|||
|
|
|||
|
try {
|
|||
|
$database->exec("ALTER TABLE $table ADD language_id INT UNSIGNED DEFAULT 0");
|
|||
|
$message = "Added 'language_id' column";
|
|||
|
$error = false;
|
|||
|
} catch(\Exception $e) {
|
|||
|
$error = true;
|
|||
|
$message = $e->getMessage();
|
|||
|
}
|
|||
|
|
|||
|
$message = $messagePrefix . $message;
|
|||
|
$error ? $this->error($message) : $this->message($message);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 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);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
}
|