1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-12 09:44:38 +02:00

Upgrade core PagePaths module for multi-language page name support. Previously it asked you to uninstall it if you had multi-language, but now it has full support!

This commit is contained in:
Ryan Cramer
2021-10-01 13:21:00 -04:00
parent fc1b252d04
commit d0f7961d89

View File

@@ -6,23 +6,21 @@
* Keeps a cache of page paths to improve performance and
* make paths more queryable by selectors.
*
*
* ProcessWire 3.x, Copyright 2016 by Ryan Cramer
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
* https://processwire.com
*
*
*/
class PagePaths extends WireData implements Module {
class PagePaths extends WireData implements Module, ConfigurableModule {
public static function getModuleInfo() {
return array(
'title' => 'Page Paths',
'version' => 1,
'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). Currently supports only single languages sites.",
'version' => 2,
'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,
);
);
}
/**
@@ -42,51 +40,24 @@ class PagePaths extends WireData implements Module {
*
*/
public function init() {
$this->pages->addHook('moved', $this, 'hookPageMoved');
$this->pages->addHook('renamed', $this, 'hookPageMoved');
$this->pages->addHook('added', $this, 'hookPageMoved');
$this->pages->addHook('deleted', $this, 'hookPageDeleted');
$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');
}
}
public function ready() {
$page = $this->wire('page');
if($page->template == 'admin' && $page->name == 'module') {
$this->wire('modules')->addHookAfter('refresh', $this, 'hookModulesRefresh');
}
}
/**
* Returns Languages object or false if not available
*
* @return Languages|null
*
*/
public function getLanguages() {
if(!is_null($this->languages)) return $this->languages;
$languages = $this->wire('languages');
if(!$languages) return null;
if(!$this->wire('modules')->isInstalled('LanguageSupportPageNames')) {
$this->languages = false;
} else {
$this->languages = $this->wire('languages');
}
return $this->languages;
}
/**
* Hook to ProcessModule::refresh
*
* @param HookEvent $event
*
*/
public function hookModulesRefresh(HookEvent $event) {
if($event) {} // ignore
if($this->getLanguages()) {
$this->wire('session')->warning(
$this->_('Please uninstall the Core > PagePaths module (it is not compatible with LanguageSupportPageNames)')
);
}
}
/*** HOOKS ******************************************************************************************/
/**
* Hook called when a page is moved or renamed
@@ -96,7 +67,8 @@ class PagePaths extends WireData implements Module {
*/
public function hookPageMoved(HookEvent $event) {
$page = $event->arguments[0];
$this->updatePagePath($page->id, $page->path);
// $this->updatePagePath($page->id, $page->path);
$this->updatePagePaths($page);
}
@@ -107,32 +79,103 @@ class PagePaths extends WireData implements Module {
*
*/
public function hookPageDeleted(HookEvent $event) {
$table = self::dbTableName;
$page = $event->arguments[0];
$database = $this->wire('database');
$query = $database->prepare("DELETE FROM " . self::dbTableName . " WHERE pages_id=:pages_id");
$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();
}
/**
* 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);
$query->execute();
}
/*** PUBLIC API *************************************************************************************/
/**
* Given a page ID, return the page path, NULL if not found, or boolean false if cannot be determined.
*
* @param int $id
* @return string|null|false
* @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($id) {
if($this->getLanguages()) return false; // we do not support multi-language yet for this module
public function getPath($pageId, $languageId = 0) {
$table = self::dbTableName;
$database = $this->wire('database');
$query = $database->prepare("SELECT path FROM `$table` WHERE pages_id=:pages_id");
$query->bindValue(":pages_id", $id, \PDO::PARAM_INT);
$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);
$query->execute();
if(!$query->rowCount()) return null;
$path = $query->fetchColumn();
$path = strlen($path) ? $this->wire('sanitizer')->pagePathName("/$path/", Sanitizer::toUTF8) : "/";
if($query->rowCount()) {
$path = $query->fetchColumn();
$path = strlen($path) ? $sanitizer->pagePathName("/$path/", Sanitizer::toUTF8) : '/';
} else {
$path = null;
}
$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);
$query->execute();
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.
@@ -142,16 +185,153 @@ class PagePaths extends WireData implements Module {
*
*/
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');
$path = $this->wire('sanitizer')->pagePathName($path, Sanitizer::toAscii);
$path = trim($path, '/');
$query = $database->prepare("SELECT pages_id FROM $table WHERE path=:path");
$query->bindValue(":path", $path);
$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);
foreach($bindValues as $bindKey => $bindValue) {
$query->bindValue(":$bindKey", $bindValue);
}
$query->execute();
if(!$query->rowCount()) return null;
$id = $query->fetchColumn();
return (int) $id;
if($query->rowCount()) {
$row = $query->fetch(\PDO::FETCH_NUM);
} else {
$row = array(0, 0);
}
$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;
$config = $this->wire()->config;
$table = self::dbTableName;
$useUTF8 = $config->pageNameCharset === 'UTF8';
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'
);
$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, '/'));
$query->execute();
$row = $query->fetch(\PDO::FETCH_ASSOC);
$query->closeCursor();
if(!$row) return false;
foreach($row as $key => $value) {
if($key === 'id' || $key === 'status' || 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('/');
}
return $this->updatePagePaths($page, true);
}
/**
@@ -165,89 +345,267 @@ class PagePaths extends WireData implements Module {
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($selector->operator, array('=', '!=', '<>', '>', '<', '>=', '<='))) {
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 = $this->wire('sanitizer')->pagePathName($path, Sanitizer::toAscii);
$path = $this->wire('database')->escapeStr(trim($path, '/'));
$where .= ($selector->not ? "NOT " : "") . "$alias.path{$selector->operator}'$path'";
$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 '$selector->operator' operator";
$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 '$selector->operator' operator";
$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', $selector->operator, trim($value, '/'));
$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;
$database = $this->wire()->database;
$sanitizer = $this->wire()->sanitizer;
$languages = $this->getLanguages();
$table = self::dbTableName;
$numUpdated = 1;
$homeDefaultName = '';
$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($languages) {
// multi-language
foreach($languages as $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";
}
// 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);
$query->execute();
$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--;
return $numUpdated;
}
/**
* Updates path for $page and all children
*
* @param int $id
* @param string $path
* @param bool $hasChildren Omit if true or unknown
* @param int $level Recursion level, you should omit this param
* @return int Number of paths updated
* 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 updatePagePath($id, $path, $hasChildren = true, $level = 0) {
$table = self::dbTableName;
$id = (int) $id;
$database = $this->wire('database');
$path = $this->wire('sanitizer')->pagePathName($path, Sanitizer::toAscii);
$path = trim($path, '/');
$_path = $database->escapeStr($path);
$numUpdated = 1;
$sql = "INSERT INTO $table (pages_id, path) VALUES(:id, :path) " .
"ON DUPLICATE KEY UPDATE pages_id=VALUES(pages_id), path=VALUES(path)";
protected function updatePagePathsChildren($pageId, array $paths) {
$query = $database->prepare($sql);
$query->bindValue(":id", $id, \PDO::PARAM_INT);
$query->bindValue(":path", $_path);
$query->execute();
if($hasChildren) {
$sql = "SELECT pages.id, pages.name, COUNT(children.id) FROM pages " .
"LEFT JOIN pages AS children ON children.id=pages.parent_id " .
"WHERE pages.parent_id=:id " .
"GROUP BY pages.id ";
$database = $this->wire()->database;
$languages = $this->getLanguages();
$nameColumns = array('pages.name AS name');
$numUpdated = 0;
$query = $database->prepare($sql);
$query->bindValue(":id", $id, \PDO::PARAM_INT);
$query->execute();
while($row = $query->fetch(\PDO::FETCH_NUM)) {
list($id, $name, $numChildren) = $row;
$numUpdated += $this->updatePagePath($id, "$path/$name", $numChildren > 0, $level+1);
if($languages) {
foreach($languages as $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 ";
if(!$level) $this->message(sprintf($this->_n('Updated %d path', 'Updated %d paths', $numUpdated), $numUpdated));
$query = $database->prepare($sql);
$query->bindValue(":id", $pageId, \PDO::PARAM_INT);
$query->execute();
$rows = array();
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;
}
/*** LANGUAGES **********************************************************************************/
/**
* Returns Languages object or false if not available
*
* @return Languages|null|false
*
*/
public function getLanguages() {
if($this->languages !== null) return $this->languages;
$languages = $this->wire()->languages;
if(!$languages) {
$this->languages = false;
} else if($this->wire()->modules->isInstalled('LanguageSupportPageNames')) {
$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 *******************************************************************************/
/**
* Upgrade module
*
* @param $fromVersion
* @param $toVersion
* @since 3.0.186
*
*/
public function ___upgrade($fromVersion, $toVersion) {
if($fromVersion && $toVersion) {} // ignore
$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);
}
}
/**
* Install the module
@@ -256,23 +614,24 @@ class PagePaths extends WireData implements Module {
public function ___install() {
$table = self::dbTableName;
$database = $this->wire('database');
$engine = $this->wire('config')->dbEngine;
$charset = $this->wire('config')->dbCharset;
$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, " .
"path text CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, " .
"PRIMARY KEY pages_id (pages_id), " .
"UNIQUE KEY path (path(500)), " .
"FULLTEXT KEY path_fulltext (path)" .
") ENGINE=$engine DEFAULT CHARSET=$charset";
$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);
$numUpdated = $this->updatePagePath(1, '/');
if($numUpdated) {} // ignore
}
/**
@@ -280,7 +639,43 @@ class PagePaths extends WireData implements Module {
*
*/
public function ___uninstall() {
$this->wire('database')->query("DROP TABLE " . self::dbTableName);
$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;
if($input->requestMethod('POST')) {
if($input->post('_rebuild')) $session->setFor($this, 'rebuild', true);
$numPages = 0;
$eta = 0;
} else {
$numPages = $this->wire()->pages->count("id>0, include=all");
$eta = ($numPages / 1000) * 1.1;
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);
}
}
$f = $inputfields->InputfieldCheckbox;
$f->attr('name', '_rebuild');
$f->label = sprintf($this->_('Rebuild page paths index for %d pages'), $numPages);
$f->label2 = $this->_('Rebuild now');
$f->description = sprintf($this->_('Estimated rebuild time is roughly %01.1f seconds.'), $eta);
$inputfields->add($f);
}
}