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

853 lines
23 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 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 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 isnt 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);
}
}