'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 $path; 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 paths if(!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-language foreach($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 storage foreach($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|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); } }