From 6fb68374065a253ec51bc78592ac837518af5471 Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Wed, 31 Oct 2018 10:22:47 -0400 Subject: [PATCH] Add `$page->addUrl($url, [$language]);` and `$page->removeUrl($url);` methods that allow you to add or remove redirects to a page programatically. This is provided by updates to the PagePathHistory module, which also received several unrelated updates, like support for virtual path history, which is historical URLs for a page determined by changes to parent pages. --- wire/core/Page.php | 31 ++- wire/modules/PagePathHistory.module | 304 ++++++++++++++++++++++++++-- 2 files changed, 314 insertions(+), 21 deletions(-) diff --git a/wire/core/Page.php b/wire/core/Page.php index e4b66b31..3117da64 100644 --- a/wire/core/Page.php +++ b/wire/core/Page.php @@ -32,7 +32,7 @@ * @property string $title The page’s title (headline) text * @property string $path The page’s URL path from the homepage (i.e. /about/staff/ryan/) * @property string $url The page’s URL path from the server's document root - * @property array $urls All URLs the page is accessible from, whether current, former and multi-language. #pw-advanced + * @property array $urls All URLs the page is accessible from, whether current, former and multi-language. #pw-group-urls * @property string $httpUrl Same as $page->url, except includes scheme (http or https) and hostname. * @property Page|string|int $parent The parent Page object or a NullPage if there is no parent. For assignment, you may also use the parent path (string) or id (integer). #pw-group-traversal * @property Page|null $parentPrevious Previous parent, if parent was changed. #pw-group-traversal @@ -47,6 +47,7 @@ * @property int $hasChildren The number of visible children this page has. Excludes unpublished, no-access, hidden, etc. #pw-group-traversal * @property int $numVisibleChildren Verbose alias of $hasChildren #pw-internal * @property int $numDescendants Number of descendants (quantity of children, and their children, and so on). @since 3.0.116 #pw-group-traversal + * @property int $numParents Number of parent pages (i.e. depth) @since 3.0.117 #pw-group-traversal * @property PageArray $children All the children of this page. Returns a PageArray. See also $page->children($selector). #pw-group-traversal * @property Page|NullPage $child The first child of this page. Returns a Page. See also $page->child($selector). #pw-group-traversal * @property PageArray $siblings All the sibling pages of this page. Returns a PageArray. See also $page->siblings($selector). #pw-group-traversal @@ -72,7 +73,7 @@ * @property int|null $statusPrevious Previous status, if status was changed. #pw-group-status * @property string statusStr Returns space-separated string of status names active on this page. #pw-group-status * @property Fieldgroup $fieldgroup Fieldgroup used by page template. Shorter alias for $page->template->fieldgroup (same as $page->fields) #pw-advanced - * @property string $editUrl URL that this page can be edited at. #pw-group-advanced + * @property string $editUrl URL that this page can be edited at. #pw-group-urls * @property string $editURL Alias of $editUrl. #pw-internal * @property PageRender $render May be used for field markup rendering like $page->render->title. #pw-advanced * @property bool $loaderCache Whether or not pages loaded as a result of this one may be cached by PagesLoaderCache. #pw-internal @@ -113,7 +114,13 @@ * @property bool $moveable #pw-group-access * @property bool $sortable #pw-group-access * @property bool $listable #pw-group-access - * + * + * Methods added by PagePathHistory.module (installed by default) + * -------------------------------------------------------------- + * @method bool addUrl($url, $language = null) Add a new URL that redirects to this page and save immediately (returns false if already taken). #pw-group-urls #pw-group-manipulation + * @method bool removeUrl($url) Remove a URL that redirects to this page and save immediately. #pw-group-urls #pw-group-manipulation + * Note: you can use the $page->urls() method to get URLs added by PagePathHistory. + * * Methods added by LanguageSupport.module (not installed by default) * ----------------------------------------------------------------- * @method Page setLanguageValue($language, $field, $value) Set value for field in language (requires LanguageSupport module). $language may be ID, language name or Language object. Field should be field name (string). #pw-group-languages @@ -122,9 +129,9 @@ * Methods added by LanguageSupportPageNames.module (not installed by default) * --------------------------------------------------------------------------- * @method string localName($language = null, $useDefaultWhenEmpty = false) Return the page name in the current user’s language, or specify $language argument (Language object, name, or ID), or TRUE to use default page name when blank (instead of 2nd argument). #pw-group-languages - * @method string localPath($language = null) Return the page path in the current user's language, or specify $language argument (Language object, name, or ID). #pw-group-languages - * @method string localUrl($language = null) Return the page URL in the current user's language, or specify $language argument (Language object, name, or ID). #pw-group-languages - * @method string localHttpUrl($language = null) Return the page URL (including scheme and hostname) in the current user's language, or specify $language argument (Language object, name, or ID). #pw-group-languages + * @method string localPath($language = null) Return the page path in the current user's language, or specify $language argument (Language object, name, or ID). #pw-group-languages #pw-group-urls + * @method string localUrl($language = null) Return the page URL in the current user's language, or specify $language argument (Language object, name, or ID). #pw-group-languages #pw-group-urls + * @method string localHttpUrl($language = null) Return the page URL (including scheme and hostname) in the current user's language, or specify $language argument (Language object, name, or ID). #pw-group-languages #pw-group-urls * * Methods added by ProDrafts.module (if installed) * ------------------------------------------------ @@ -2851,6 +2858,7 @@ class Page extends WireData implements \Countable, WireMatchable { * * #pw-hookable * #pw-group-common + * #pw-group-urls * * ~~~~~ * // Difference between path and url on site running from subdirectory /my-site/ @@ -2976,6 +2984,9 @@ class Page extends WireData implements \Countable, WireMatchable { * ]); * ~~~~~ * + * #pw-group-common + * #pw-group-urls + * * @param array|int|string|bool|Language|null $options Optionally specify options to modify default behavior (see method description). * @return string Returns page URL, for example: `/my-site/about/contact/` * @see Page::path(), Page::httpUrl(), Page::editUrl(), Page::localUrl() @@ -3002,7 +3013,7 @@ class Page extends WireData implements \Countable, WireMatchable { * - If PagePathHistory core module is not installed then past/historical URLs are excluded. * - You can disable past/historical or multi-language URLs by using the $options argument. * - * #pw-advanced + * #pw-group-urls * * @param array $options Options to modify default behavior: * - `http` (bool): Make URLs include current scheme and hostname (default=false). @@ -3012,6 +3023,7 @@ class Page extends WireData implements \Countable, WireMatchable { * Note: the `languages` option must be true if using the `language` option. * @return array * @since 3.0.107 + * @see Page::addUrl(), page::removeUrl() * */ public function urls($options = array()) { @@ -3033,6 +3045,9 @@ class Page extends WireData implements \Countable, WireMatchable { * // Generating a link to this page using httpUrl * echo "$page->title"; * ~~~~~ + * + * #pw-group-common + * #pw-group-urls * * @param array $options For details on usage see `Page::url()` options argument. * @return string Returns full URL to page, for example: `https://processwire.com/about/` @@ -3072,7 +3087,7 @@ class Page extends WireData implements \Countable, WireMatchable { * } * ~~~~~~ * - * #pw-group-advanced + * #pw-group-urls * * @param array|bool $options Specify boolean true to force URL to include scheme and hostname, or use $options array: * - `http` (bool): True to force scheme and hostname in URL (default=auto detect). diff --git a/wire/modules/PagePathHistory.module b/wire/modules/PagePathHistory.module index 7c6c2fc8..055f5eb5 100644 --- a/wire/modules/PagePathHistory.module +++ b/wire/modules/PagePathHistory.module @@ -20,7 +20,7 @@ class PagePathHistory extends WireData implements Module { public static function getModuleInfo() { return array( 'title' => 'Page Path History', - 'version' => 3, + 'version' => 4, '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, @@ -64,6 +64,8 @@ class PagePathHistory extends WireData implements Module { $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'); } /** @@ -115,40 +117,91 @@ class PagePathHistory extends WireData implements Module { } /** - * Set a history path for a page + * 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); + if($this->wire('pages')->count("path=$path")) 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'; - + 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 { - $query->execute(); + $result = $query->execute(); } catch(\Exception $e) { // ignore the exception because it means there is already a past URL (duplicate) + $result = false; } - - // delete any possible entries that overlap with the $page 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; } - + + /** + * 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; + } + /** * Get an array of all paths the given page has previously had, oldest to newest * @@ -159,7 +212,9 @@ class PagePathHistory extends WireData implements Module { * @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). + * - `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 @@ -167,9 +222,13 @@ class PagePathHistory extends WireData implements Module { */ 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 */ @@ -231,12 +290,184 @@ class PagePathHistory extends WireData implements Module { $value = $path; } - $paths[] = $value; + $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 != $page->parent()->path()) { + $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) { + $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; + + // $path .= " $parentInfo[date] | $pathInfo[date]"; + + // 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; } @@ -447,6 +678,53 @@ class PagePathHistory extends WireData implements Module { $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 *