Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download
<?php namespace ProcessWire;/*** ProcessWire Page Paths** Keeps a cache of page paths to improve performance and* make paths more queryable by selectors.** ProcessWire 3.x, Copyright 2021 by Ryan Cramer* https://processwire.com** @property array $rootSegments**/class PagePaths extends WireData implements Module, ConfigurableModule {public static function getModuleInfo() {return array('title' => 'Page Paths','version' => 4,'summary' => "Enables page paths/urls to be queryable by selectors. Also offers potential for improved load performance. Builds an index at install (may take time on a large site).",'singular' => true,'autoload' => true,);}/*** Table created by this module**/const dbTableName = 'pages_paths';/*** @var Languages|false**/protected $languages = null;/*** Construct**/public function __construct() {$this->set('rootSegments', array());parent::__construct();}/*** Initialize the hooks**/public function init() {$pages = $this->wire()->pages;$pages->addHook('moved', $this, 'hookPageMoved');$pages->addHook('renamed', $this, 'hookPageMoved');$pages->addHook('added', $this, 'hookPageMoved');$pages->addHook('deleted', $this, 'hookPageDeleted');}/*** API ready**/public function ready() {if($this->wire()->languages) {$this->addHookBefore('LanguageSupportFields::languageDeleted', $this, 'hookLanguageDeleted');}}/*** HOOKS ******************************************************************************************//*** Hook called when a page is moved or renamed** @param HookEvent $event**/public function hookPageMoved(HookEvent $event) {$page = $event->arguments[0];// $this->updatePagePath($page->id, $page->path);$this->updatePagePaths($page);}/*** Hook called when a page is deleted** @param HookEvent $event**/public function hookPageDeleted(HookEvent $event) {$table = self::dbTableName;$page = $event->arguments[0];$database = $this->wire()->database;$query = $database->prepare("DELETE FROM $table WHERE pages_id=:pages_id");$query->bindValue(":pages_id", $page->id, \PDO::PARAM_INT);$query->execute();$this->rebuildRootSegments();}/*** When a language is deleted** @param HookEvent $event**/public function hookLanguageDeleted(HookEvent $event) {$languages = $this->getLanguages();if(!$languages) return;$language = $event->arguments[0]; /** @var Language $language */if(!$language->id || $language->isDefault()) return;$table = self::dbTableName;$database = $this->wire()->database;$sql = "DELETE FROM $table WHERE language_id=:language_id";$query = $database->prepare($sql);$query->bindValue(':language_id', $language->id, \PDO::PARAM_INT);$this->executeQuery($query);}/*** PUBLIC API *************************************************************************************//*** Given a page ID, return the page path, NULL if not found, or boolean false if cannot be determined.** @param int $pageId Page ID* @param int $languageId Optionally specify language ID for path or 0 for default language* @return string|null Returns path or null if not found**/public function getPath($pageId, $languageId = 0) {$table = self::dbTableName;$database = $this->wire()->database;$sanitizer = $this->wire()->sanitizer;$languageId = $this->languageId($languageId);$sql = "SELECT path FROM `$table` WHERE pages_id=:pages_id AND language_id=:language_id";$query = $database->prepare($sql);$query->bindValue(":pages_id", $pageId, \PDO::PARAM_INT);$query->bindValue(":language_id", $languageId, \PDO::PARAM_INT);$path = null;if(!$this->executeQuery($query)) return null;if($query->rowCount()) {$path = $query->fetchColumn();$path = strlen($path) ? $sanitizer->pagePathName("/$path/", Sanitizer::toUTF8) : '/';}$query->closeCursor();return $path;}/*** Given a page ID, return all paths found for page** Return value is indexed by language ID (and index 0 for default language)** @param int $pageId Page ID* @return array**/public function getPaths($pageId) {$table = self::dbTableName;$database = $this->wire()->database;$sanitizer = $this->wire()->sanitizer;$paths = array();$sql = "SELECT path, language_id FROM `$table` WHERE pages_id=:pages_id ";$query = $database->prepare($sql);$query->bindValue(":pages_id", $pageId, \PDO::PARAM_INT);if(!$this->executeQuery($query)) return $paths;while($row = $query->fetch(\PDO::FETCH_NUM)) {$path = $row[0];$languageId = (int) $row[1];$path = strlen($path) ? $sanitizer->pagePathName("/$path/", Sanitizer::toUTF8) : '/';$paths[$languageId] = $path;}$query->closeCursor();return $paths;}/*** Given a page path, return the page ID or NULL if not found.** @param string $path* @return int|null**/public function getID($path) {$id = $this->getPageId($path);return $id ? $id : null;}/*** Given a page path, return the page ID or 0 if not found.** @param string|array $path* @return int|null* @since 3.0.186**/public function getPageID($path) {$a = $this->getPageAndLanguageId($path);return $a[0];}/*** Given a page path return array of [ page_id, language_id ]** If not found, returned page_id and language_id will be 0.** @param string|array $path* @return array* @since 3.0.186**/public function getPageAndLanguageID($path) {$table = self::dbTableName;$database = $this->wire()->database;$paths = is_array($path) ? array_values($path) : array($path);$bindValues = array();$wheres = array();foreach($paths as $n => $path) {$path = $this->wire()->sanitizer->pagePathName($path, Sanitizer::toAscii);$path = trim($path, '/');$wheres[] = "path=:path$n";$bindValues["path$n"] = $path;}$where = implode(' OR ', $wheres);$sql = "SELECT pages_id, language_id FROM $table WHERE $where LIMIT 1";$query = $database->prepare($sql);$row = array(0, 0);foreach($bindValues as $bindKey => $bindValue) {$query->bindValue(":$bindKey", $bindValue);}if(!$this->executeQuery($query)) return $row;if($query->rowCount()) {$row = $query->fetch(\PDO::FETCH_NUM);}$query->closeCursor();return array((int) $row[0], (int) $row[1]);}/*** Get page information about a given path** Returned array includes the following:** - `id` (int): ID of page for given path* - `language_id` (int): ID of language path was for, or 0 for default language* - `templates_id` (int): ID of template used by page* - `parent_id` (int): ID of parent page* - `status` (int): Status value for page ($page->status)* - `path` (string): Path that was found** @param string $path* @return array|bool Returns info array on success, boolean false if not found* @since 3.0.186**/public function getPageInfo($path) {$sanitizer = $this->wire()->sanitizer;$database = $this->wire()->database;$languages = $this->wire()->languages;$config = $this->wire()->config;$table = self::dbTableName;$useUTF8 = $config->pageNameCharset === 'UTF8';if($languages && !$languages->hasPageNames()) $languages = null;if($useUTF8) {$path = $sanitizer->pagePathName($path, Sanitizer::toAscii);}$columns = array('pages_paths.path AS path','pages_paths.pages_id AS id','pages_paths.language_id AS language_id','pages.templates_id AS templates_id','pages.parent_id AS parent_id','pages.status AS status');if($languages) {foreach($languages as $language) {if($language->isDefault()) continue;$columns[] = "pages.status$language->id AS status$language->id";}}$cols = implode(', ', $columns);$sql ="SELECT $cols FROM $table " ."JOIN pages ON pages_paths.pages_id=pages.id " ."WHERE pages_paths.path=:path";$query = $database->prepare($sql);$query->bindValue(':path', trim($path, '/'));if(!$this->executeQuery($query)) return false;$row = $query->fetch(\PDO::FETCH_ASSOC);$query->closeCursor();if(!$row) return false;foreach($row as $key => $value) {if($key === 'id' || strpos($key, 'status') === 0 || strpos($key, '_id')) {$row[$key] = (int) $value;}}if($useUTF8 && $row) {$row['path'] = $sanitizer->pagePathName($row['path'], Sanitizer::toUTF8);}return $row;}/*** Rebuild all paths table starting with $page and descending to its children** @param Page|null $page Page to start rebuild from or omit to rebuild all* @return int Number of paths added* @since 3.0.186**/public function rebuild(Page $page = null) {set_time_limit(3600);$table = self::dbTableName;if($page === null) {// rebuild all$this->wire()->database->exec("DELETE FROM $table");$page = $this->wire()->pages->get('/');}$result = $this->updatePagePaths($page, true);return $result;}/*** Perform a path match for use by PageFinder** @param DatabaseQuerySelect $query* @param Selector $selector* @throws PageFinderSyntaxException**/public function getMatchQuery(DatabaseQuerySelect $query, Selector $selector) {static $n = 0;$sanitizer = $this->wire()->sanitizer;$database = $this->wire()->database;$n++;$table = self::dbTableName;$alias = "$table$n";$value = $selector->value;$operator = $selector->operator;// $joinType = $selector->not ? 'leftjoin' : 'join';$query->join("$table AS $alias ON pages.id=$alias.pages_id");if(in_array($operator, array('=', '!=', '<>', '>', '<', '>=', '<='))) {if(!is_array($value)) $value = array($value);$where = '';foreach($value as $path) {if($where) $where .= $selector->not ? " AND " : " OR ";$path = $sanitizer->pagePathName($path, Sanitizer::toAscii);$path = $database->escapeStr(trim($path, '/'));$where .= ($selector->not ? "NOT " : "") . "$alias.path{$operator}'$path'";}$query->where("($where)");} else {if(is_array($value)) {$error = "Multi value using '|' is not supported with path/url and '$operator' operator";throw new PageFinderSyntaxException($error);}if($selector->not) {$error = "NOT mode isn't yet supported with path/url and '$operator' operator";throw new PageFinderSyntaxException($error);}/** @var DatabaseQuerySelectFulltext $ft */$ft = $this->wire(new DatabaseQuerySelectFulltext($query));$ft->match($alias, 'path', $operator, trim($value, '/'));}}/*** PROTECTED API **********************************************************************************//*** Updates path for page and all children** @param Page|int $page* @param bool|null $hasChildren Does this page have children? Specify false if known not to have children, true otherwise.* @param array $paths Paths indexed by language ID, use index 0 for default language.* @return int Number of paths updated* @since 3.0.186**/protected function updatePagePaths($page, $hasChildren = null, array $paths = array()) {static $level = 0;$rootPageId = $this->wire()->config->rootPageID;$database = $this->wire()->database;$sanitizer = $this->wire()->sanitizer;$languages = $this->getLanguages();$table = self::dbTableName;$numUpdated = 1;$homeDefaultName = '';$rebuildRoot = false;$level++;if($hasChildren === null) {$hasChildren = $page instanceof Page ? $page->numChildren > 0 : true;}if(empty($paths)) {// determine the pathsif(!is_object($page) || !$page instanceof Page) {throw new WireException('Page object required on first call to updatePagePaths');}$pageId = $page->id;if($page->parent_id === $rootPageId) $rebuildRoot = true;if($languages) {// multi-languageforeach($languages as $language) {/** @var Language $language */$languageId = $language->isDefault() ? 0 : $language->id;$paths[$languageId] = $page->localPath($language);if($pageId === 1 && !$languageId) $homeDefaultName = $page->name;}} else {// single language$paths[0] = $page->path();}} else {// $paths already populated$pageId = (int) "$page";}if($pageId === $rootPageId) $rebuildRoot = true;// sanitize and prepare paths for DB storageforeach($paths as $languageId => $path) {$path = $sanitizer->pagePathName($path, Sanitizer::toAscii);$paths[$languageId] = trim($path, '/');}$sql ="INSERT INTO $table (pages_id, language_id, path) " ."VALUES(:pages_id, :language_id, :path) " ."ON DUPLICATE KEY UPDATE " ."pages_id=VALUES(pages_id), language_id=VALUES(language_id), path=VALUES(path)";$query = $database->prepare($sql);$query->bindValue(":pages_id", $pageId, \PDO::PARAM_INT);foreach($paths as $languageId => $path) {$query->bindValue(":language_id", $languageId, \PDO::PARAM_INT);$query->bindValue(":path", $path);if($this->executeQuery($query)) $numUpdated += $query->rowCount();}if($hasChildren) {if($homeDefaultName && $homeDefaultName !== 'home' && empty($paths[0])) {// for when homepage has a name (lang segment) but it isn’t used on actual homepage// but is used on children$paths[0] = $homeDefaultName;}$numUpdated += $this->updatePagePathsChildren($pageId, $paths);}if($level === 1 && $numUpdated > 0) {$this->message(sprintf($this->_n('Updated %d path', 'Updated %d paths', $numUpdated), $numUpdated),Notice::admin);}$level--;if($rebuildRoot && !$level) $this->rebuildRootSegments();return $numUpdated;}/*** Companion to updatePagePaths method to handle children** @param int $pageId* @param array $paths Paths indexed by language ID, index 0 for default language* @return int* @since 3.0.186**/protected function updatePagePathsChildren($pageId, array $paths) {$database = $this->wire()->database;$languages = $this->getLanguages();$nameColumns = array('pages.name AS name');$numUpdated = 0;if($languages) {foreach($languages as $language) {/** @var Language $language */if($language->isDefault()) continue;$nameColumns[] = "pages.name$language->id AS name$language->id";}}$sql ="SELECT pages.id AS id, " . implode(', ', $nameColumns) . ", " ."COUNT(children.id) AS kids " ."FROM pages " ."LEFT JOIN pages AS children ON children.id=pages.parent_id " ."WHERE pages.parent_id=:id " ."GROUP BY pages.id ";$query = $database->prepare($sql);$query->bindValue(":id", $pageId, \PDO::PARAM_INT);$rows = array();if(!$this->executeQuery($query)) return $numUpdated;while($row = $query->fetch(\PDO::FETCH_ASSOC)) {$rows[] = $row;}$query->closeCursor();foreach($rows as $row) {$childPaths = array();foreach($paths as $languageId => $path) {$key = $languageId ? "name$languageId" : "name";$name = !empty($row[$key]) ? $row[$key] : $row["name"];$childPaths[$languageId] = "$path/$name";}$numUpdated += $this->updatePagePaths((int) $row['id'], $row['kids'] > 0, $childPaths);}return $numUpdated;}/*** ROOT SEGMENTS ******************************************************************************//*** Is given segment/page name a root segment?** A root segment is one that is owned by the homepage or a direct parent of the homepage, i.e.* /about/ might be a root page segment and /de/ might be a root language segment. If it is a* root page segment like /about/ then this will return the ID of that page. If it is a root* language segment like /de/ then it will return the homepage ID (1).** @param string $segment Page name string or path containing it* @return int Returns page ID or 0 for no match.* @since 3.0.186**/public function isRootSegment($segment) {$segment = trim($segment, '/');if(strpos($segment, '/')) list($segment,) = explode('/', $segment, 2);$rootSegments = $this->getRootSegments();$key = array_search($segment, $rootSegments);if($key === false) return 0;$key = ltrim($key, '_');if(strpos($key, '.')) {list($pageId, /*$languageId*/) = explode('.', $key, 2);} else {$pageId = $key;}return (int) $pageId;}/*** Get root segments** @param bool $rebuild* @return array* @since 3.0.186**/public function getRootSegments($rebuild = false) {if(empty($this->rootSegments) || $rebuild) $this->rebuildRootSegments();return $this->rootSegments;}/*** Rebuild root segments stored in module config** @since 3.0.186**/protected function rebuildRootSegments() {$database = $this->wire()->database;$config = $this->wire()->config;$languages = $this->getLanguages();$cols = array('id', 'name');$segments = array();if($languages) {foreach($languages as $language) {if(!$language->isDefault()) $cols[] = "name$language->id";}}$sql = 'SELECT ' . implode(',', $cols) . ' FROM pages WHERE parent_id=:id ';if($languages) $sql .= 'OR id=:id';$query = $database->prepare($sql);$query->bindValue(':id', $config->rootPageID, \PDO::PARAM_INT);$query->execute();while($row = $query->fetch(\PDO::FETCH_ASSOC)){$id = (int) $row['id'];unset($row['id']);foreach($row as $col => $name) {if(!strlen("$name")) continue;if($id === 1 && $col === 'name' && $name === Pages::defaultRootName) continue; // skip "/home/"$col = str_replace('name', '', $col);if(strlen($col)) {$segments["_$id.$col"] = $name; // _pageID.languageID i.e. 123.456} else {$segments["_$id"] = $name; // _pageID i.e. 123}}}$query->closeCursor();$this->rootSegments = $segments;$this->wire()->modules->saveConfig($this, 'rootSegments', $segments);return $segments;}/*** LANGUAGES **********************************************************************************//*** Returns Languages object or false if not available** @return Languages|Language[]|false**/public function getLanguages() {if($this->languages !== null) return $this->languages;$languages = $this->wire()->languages;if(!$languages) {$this->languages = false;} else if($languages->hasPageNames()) {$this->languages = $languages;} else {$this->languages = false;}return $this->languages;}/*** @param Language|int|string $language* @return int Returns language ID or 0 for default language* @since 3.0.186**/protected function languageId($language) {$language = $this->language($language);if(!$language->id || $language->isDefault()) return 0;return $language->id;}/*** @param Language|int|string $language* @return Language|NullPage* @since 3.0.186**/protected function language($language) {$languages = $this->getLanguages();if(!$languages) return new NullPage();if(is_object($language)) return ($language instanceof Language ? $language : new NullPage());return $languages->get($language);}/*** MODULE MAINT *******************************************************************************//*** Execute a query/PDOStatement** @param \PDOStatement $query* @param bool $throw Allow exceptions to be thrown? (default=true)* @return bool* @throws \PDOException**/protected function executeQuery($query, $throw = true) {try {$result = $query->execute();} catch(\Exception $e) {if(!$this->checkTableSchema()) {if($throw) throw $e;$this->error($e->getMessage(), Notice::superuser | Notice::log);}$result = false;}return $result;}/*** Check db schema** @return bool True if changes made, false if not**/protected function checkTableSchema() {$table = self::dbTableName;$database = $this->wire()->database;if(!$database->columnExists($table, 'language_id')) {$sqls = array("ALTER TABLE $table ADD language_id INT UNSIGNED NOT NULL DEFAULT 0 AFTER pages_id","ALTER TABLE $table DROP PRIMARY KEY, ADD PRIMARY KEY(pages_id, language_id)","ALTER TABLE $table ADD INDEX language_id (language_id)","ALTER TABLE $table DROP INDEX path, ADD UNIQUE KEY path(path(500), language_id)",);foreach($sqls as $sql) {$database->exec($sql);}$this->message("Added language_id column to table $table", Notice::admin);return true;}return false;}/*** Upgrade module** @param $fromVersion* @param $toVersion* @since 3.0.186**/public function ___upgrade($fromVersion, $toVersion) {if($fromVersion && $toVersion) {} // ignore$this->checkTableSchema();$this->rebuildRootSegments();}/*** Install the module**/public function ___install() {$table = self::dbTableName;$database = $this->wire()->database;$engine = $this->wire()->config->dbEngine;$charset = $this->wire()->config->dbCharset;$database->query("DROP TABLE IF EXISTS $table");$sql ="CREATE TABLE $table (" ."pages_id int(10) unsigned NOT NULL, " ."language_id int unsigned NOT NULL DEFAULT 0, " ."path text CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, " ."PRIMARY KEY (pages_id, language_id), " ."UNIQUE KEY path (path(500), language_id), " ."INDEX language_id (language_id), " ."FULLTEXT KEY path_fulltext (path)" .") ENGINE=$engine DEFAULT CHARSET=$charset";$database->query($sql);}/*** Uninstall the module**/public function ___uninstall() {$this->wire()->database->query("DROP TABLE " . self::dbTableName);}/*** Module config** @param InputfieldWrapper $inputfields**/public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {$session = $this->wire()->session;$input = $this->wire()->input;$numPages = 0;$numRows = -1;if($input->requestMethod('POST')) {if($input->post('_rebuild')) $session->setFor($this, 'rebuild', true);} else {$numPages = $this->wire()->pages->count("id>0, include=all");if($session->getFor($this, 'rebuild')) {$session->removeFor($this, 'rebuild');$timer = Debug::timer();$this->rebuild();$elapsed = Debug::timer($timer);$this->message(sprintf($this->_('Completed rebuild in %d seconds'), $elapsed), Notice::noGroup);} else {$table = self::dbTableName;$query = $this->wire()->database->prepare("SELECT COUNT(*) FROM $table");if($this->executeQuery($query, false)) {$numRows = (int) $query->fetchColumn();$query->closeCursor();}}}$f = $inputfields->InputfieldCheckbox;$f->attr('name', '_rebuild');$f->label = sprintf($this->_('Rebuild page paths index for %d pages'), $numPages);$f->label2 = $this->_('Rebuild now');if($numPages) $f->description =$this->_('Estimated rebuild time is up to 5 seconds per 1000 pages.') . ' ' .sprintf($this->_('There are %d pages to process.'), $numPages);if($numRows > 0) {$f->notes = sprintf($this->_('There are currently %d rows stored by this module (path paths and versions of path paths).'), $numRows);} else if($numRows === 0 && $input->requestMethod('GET')) {$this->warning($this->_('Please choose the “rebuild now” option to create your page paths index.'));}$inputfields->add($f);}}