artabro/wire/modules/PagePathHistory.module
2024-08-27 11:35:37 +02:00

1196 lines
36 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 2022 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 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 pages 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 pages current parent is not homepage, include it
if($page->parent_id > 1) {
$checkParents[] = $page->parent;
}
// if historical parent path differs from pages 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);
}
}
}