Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download
<?php namespace ProcessWire;/*** ProcessWire Pages Path Finder** #pw-summary Enables finding pages by path, optionally with URL segments, pagination numbers, language prefixes, etc.* #pw-body =* This is built for use by the PagesRequest class and ProcessPageView module, but can also be useful from the public API.* The most useful method is the `get()` method which returns a verbose array of information about the given path.* Methods in this class should be acessed from `$pages->pathFinder()`, i.e.* ~~~~~* $result = $pages->pathFinder()->get('/en/foo/bar/page3');* ~~~~~* Note that PagesPathFinder does not perform any access control checks, so if using this class then validate access* afterwards when appropriate.* #pw-body** ProcessWire 3.x, Copyright 2022 by Ryan Cramer* https://processwire.com** @todo:* Determine how to handle case where this class is called before* LanguageSupport module has been loaded (for multi-language page names)**/class PagesPathFinder extends Wire {/*** @var Pages**/protected $pages;/*** Default options for the get() method** @var array** - useLanguages: Allows for selection of multi-language path paths (requires LanguageSupportPageNames module)* - usePathPaths: Allow use of PagePaths module to attempt to find a shortcut?* - useGlobalUnique: Allow support of shortcut for globally unique page names?* - useExcludeRoots: Exclude paths that do not have a known root segment, improves performance when lots of 404s.* - useHistory: Allow detecting of previous page paths via the PagePathHistory module?* - verbose: Return more verbose array that includes details on each path segment?**/protected $defaults = array('useLanguages' => true,'usePagePaths' => true,'useGlobalUnique' => true,'useExcludeRoot' => false,'useHistory' => true,'verbose' => true,'test' => false,);/*** @var array**/protected $options = array();/*** @var bool**/protected $verbose = true;/*** @var array**/protected $methods = array();/*** @var array**/protected $result = array();/*** @var Template|null**/protected $template = null;/*** @var bool|null**/protected $admin = null;/*** @var array|null**/protected $useLanguages = null;/*** URL part types (for reference)** @var array**/protected $partTypes = array('pageName', // references a page name directly'urlSegment', // part of urlSegmentStr after page path'pageNum', // pagination number segment'language', // language segment prefix);/*** Construct** @param Pages $pages**/public function __construct(Pages $pages) {$this->pages = $pages;parent::__construct();}/*** Init for new get()** @param string $path* @param array $options**/protected function init($path, array $options) {$this->options = array_merge($this->defaults, $options);$this->verbose = $this->options['verbose'];$this->methods = array();$this->useLanguages = $this->options['useLanguages'] ? $this->languages(true) : array();$this->result = $this->getBlankResult(array('request' => $path));$this->template = null;$this->admin = null;if(empty($this->pageNameCharset)) {$this->pageNameCharset = $this->wire()->config->pageNameCharset;}}/*** Get verbose array of info about a given page path** This method accepts a page path (not URL) with optional language segment* prefix, additional URL segments and/or pagination number. It translates* the given path to information about what page it matches, what type of* request it would result in.** If the `response` property in the return value is 301 or 302, then the* `redirect` property will be populated with a recommend redirect path.** Please access this method from `$pages->pathFinder()->get('…');`** Below is an example when given a `$path` argument of `/en/foo/bar/page3`* on a site that has default language homepage segment of `en`, a page living* at `/foo/` that accepts URL segment `bar` and has pagination enabled;* ~~~~~* $array = $pages->pathFinder()->get('/en/foo/bar/page3');* ~~~~~* ~~~~~* [* 'request' => '/en/foo/bar/page3',* 'response' => 200, // one of 200, 301, 302, 404, 414* 'type' => 'ok', // response type name* 'errors' => [], // array of error messages indexed by error name* 'redirect` => '/redirect/path/', // suggested path to redirect to or blank* 'page' => [* 'id' => 123, // ID of the page that was found* 'parent_id' => 456,* 'templates_id' => 12,* 'status' => 1,* 'name' => 'foo',* ],* 'language' => [* 'name' => 'default', // name of language detected* 'segment' => 'en', // segment prefix in path (if any)* 'status' => 1, // language status where 1 is on, 0 is off* ],* 'parts' => [ // all the parts of the path identified* [* 'type' => 'language',* 'value' => 'en',* 'language' => 'default'* ],* [* 'type' => 'pageName',* 'value' => 'foo',* 'language' => 'default'* ],* [* 'type' => 'urlSegment',* 'value' => 'bar',* 'language' => ''* ],* [* 'type' => 'pageNum',* 'value' => 'page3',* 'language' => 'default'* ],* ],* 'urlSegments' => [ // URL segments identified in order* 'bar',* ],* 'urlSegmentStr' => 'bar', // URL segment string* 'pageNum' => 3, // requested pagination number* 'pageNumPrefix' => 'page', // prefix used in pagination number* 'scheme' => 'https', // blank if no scheme required, 'https' or 'http' if one of those is required* 'method' => 'pagesRow', // method(s) that were used to find the page* ]* ~~~~~** @param string $path Page path optionally including URL segments, language prefix, pagination number* @param array $options* - `useLanguages` (bool): Allow use of multi-language page names? (default=true)* Requires LanguageSupportPageNames module installed.* - `useHistory` (bool): Allow use historical path names? (default=true)* Requires PagePathHistory module installed.* - `verbose` (bool): Return verbose array of information? (default=true)* If false, some optional information will be omitted in return value.* @return array* @see PagesPathFinder::getPage()**/public function get($path, array $options = array()) {$this->init($path, $options);// see if we can take a shortcutif($this->getShortcut($path)) return $this->result;// convert path to array of parts (page names)$parts = $this->getPathParts($path);// find a row matching the path/parts in the pages table$row = $this->getPagesRow($parts);$path = $this->applyPagesRow($parts, $row);// if we didn’t match row or matched with URL segments, see if history match availableif($this->options['useHistory']) {if(!$row || ($this->result['page']['id'] === 1 && count($this->result['urlSegments']))) {if($this->getPathHistory($this->result['request'])) return $this->result;}}return $this->finishResult($path);}/*** Given a path, get a Page object or NullPage if not found** This method is like the `get()` method except that it returns a `Page`* object rather than an array. It sets a `_pagePathFinder` property to the* returned Page, which is an associative array containing the same result* array returned by the `get()` method.** Please access this method from `$pages->pathFinder()->getPage('…');`** @param string $path* @param array $options* @return NullPage|Page* @see PagesPathFinder::get()**/public function getPage($path, array $options = array()) {if(!isset($options['verbose'])) $options['verbose'] = false;$result = $this->get($path, $options);if($result['response'] >= 400) {$page = $this->pages->newNullPage();} else {$page = $this->pages->getOneById($result['page']['id'], array('template' => $this->getResultTemplate(),'parent_id' => $result['page']['parent_id'],));}$page->setQuietly('_pagePathFinder', $result);return $page;}/*** Find a row for given $parts in pages table** @param array $parts* @return array|null**/protected function getPagesRow(array $parts) {$database = $this->wire()->database;$selects = array();$binds = array();$joins = array();$wheres = array();$lastTable = 'pages';// build the query to match each partforeach($parts as $n => $name) {$name = $this->pageNameToAscii($name);$key = "p$n";$table = $n ? $key : 'pages';$selects[] = "$table.id AS {$key}_id";$selects[] = "$table.parent_id AS {$key}_parent_id";$selects[] = "$table.templates_id AS {$key}_templates_id";$selects[] = "$table.status AS {$key}_status";$selects[] = "$table.name AS {$key}_name";$bindKey = "{$key}_name";$binds[$bindKey] = $name;$whereNames = array("$table.name=:$bindKey");foreach($this->useLanguages as $lang) {/** @var Language $lang */if($lang->isDefault()) continue;$whereNames[] = "$table.name$lang->id=:$bindKey";$selects[] = "$table.name$lang->id AS {$key}_name$lang->id";$selects[] = "$table.status$lang->id AS {$key}_status$lang->id";}$whereNames = implode(' OR ', $whereNames);if($n) {$joins[] = "LEFT JOIN pages AS $table ON $table.parent_id=$lastTable.id AND ($whereNames)";} else {$where = "($table.parent_id=1 AND ($whereNames))";$wheres[] = $where;}$lastTable = $table;}if(!count($selects)) {$this->addResultNote('No selects from pagesRow');return null;}$selects = implode(', ', $selects);$joins = implode(" \n", $joins);$wheres = implode(" AND ", $wheres);$sql = "SELECT $selects \nFROM pages \n$joins \nWHERE $wheres";$query = $database->prepare($sql);foreach($binds as $bindKey => $bindValue) {$query->bindValue(":$bindKey", $bindValue);}$query->execute();$rowCount = (int) $query->rowCount();$row = $query->fetch(\PDO::FETCH_ASSOC);$query->closeCursor();// multiple matches error (not likely)if($rowCount > 1) {$row = null;$this->addMethod('pagesRow', false, 'Multiple matches');} else if($rowCount) {$this->addMethod('pagesRow', true, "OK [id:$row[p0_id]]");} else {$this->addMethod('pagesRow', false, "No rows");}return $row;}/*** Apply a found pages table row to the $result and return corresponding path** @param array $parts* @param array|null $row* @return string Path string**/protected function applyPagesRow(array $parts, $row) {$maxUrlSegmentLength = $this->wire()->config->maxUrlSegmentLength;$result = &$this->result;// array of [language name] => [ 'a', 'b', 'c' ] (from /a/b/c/)$namesByLanguage = array('default' => array());$statusByLanguage = array();// determine which parts matched and which didn’tforeach($parts as $n => $name) {$key = "p$n";$id = $row ? (int) $row["{$key}_id"] : 0;if(!$id) {// if it didn’t resolve to DB page name then it is a URL segmentif(strlen($name) > $maxUrlSegmentLength) $name = substr($name, 0, $maxUrlSegmentLength);$result['urlSegments'][] = $name;if($this->verbose) {$result['parts'][] = array('type' => 'urlSegment','value' => $name,'language' => '');}continue;}$names[] = $name;$nameDefault = $this->pageNameToUTF8($row["{$key}_name"]);$namesByLanguage['default'][] = $nameDefault;// this is intentionally re-populated on each iteration$result['page'] = array('id' => $id,'parent_id' => (int) $row["{$key}_parent_id"],'templates_id' => (int) $row["{$key}_templates_id"],'status' => (int) $row["{$key}_status"],'name' => $nameDefault,);if($this->verbose && $nameDefault === $name) {$result['parts'][] = array('type' => 'pageName','value' => $name,'language' => 'default');}foreach($this->useLanguages as $language) {/** @var Language $language */if($language->isDefault()) continue;$nameLanguage = $this->pageNameToUTF8($row["{$key}_name$language->id"]);$statusLanguage = (int) $row["{$key}_status$language->id"];$statusByLanguage[$language->name] = $statusLanguage;if($nameLanguage != $nameDefault && $nameLanguage === $name) {if($this->verbose) {$result['parts'][] = array('type' => 'pageName','value' => $nameLanguage,'language' => $language->name);}// if there is a part in a non-default language, identify that as the// intended language for the entire pathif(empty($result['language']['name'])) $this->setResultLanguage($language);}if(!isset($namesByLanguage[$language->name])) $namesByLanguage[$language->name] = array();$namesByLanguage[$language->name][] = strlen("$nameLanguage") ? $nameLanguage : $nameDefault;}}// identify if a pageNum is present, must be applied before creation of urlSegmentStr and path$this->applyResultPageNum($parts);$langName = empty($result['language']['name']) ? 'default' : $result['language']['name'];if(!isset($namesByLanguage[$langName])) $langName = 'default';$path = '/' . implode('/', $namesByLanguage[$langName]);if($langName === 'default') {$result['page']['path'] = $path;} else {$result['page']['path'] = '/' . implode('/', $namesByLanguage['default']);}if(count($this->useLanguages)) {$langStatus = ($langName === 'default' ? 1 : $statusByLanguage[$langName]);$this->setResultLanguageStatus($langStatus);}return $path;}/*** Prepare $path and convert to array of $parts** If language segment detected then remove it and populate language to result** @param string $path* @return array|bool**/protected function getPathParts($path) {$result = &$this->result;$config = $this->wire()->config;$sanitizer = $this->wire()->sanitizer;$maxDepth = $config->maxUrlDepth;$maxUrlSegmentLength = $config->maxUrlSegmentLength;$maxPartLength = $maxUrlSegmentLength > 128 ? $maxUrlSegmentLength : 128;$maxPathLength = ($maxPartLength * $maxUrlSegmentLength) + $maxDepth;$badNames = array();$path = trim($path, '/');$lastPart = '';if($this->strlen($path) > $maxPathLength) {$result['response'] = 414; // 414=URI too long$this->addResultError('pathLengthMAX', "Path length exceeds max allowed $maxPathLength");$path = substr($path, 0, $maxPathLength);}$parts = explode('/', trim($path, '/'));if(count($parts) > $maxDepth) {$parts = array_slice($parts, 0, $maxDepth);$result['response'] = 414;$this->addResultError('pathDepthMAX', 'Path depth exceeds config.maxUrlDepth');} else if($path === '/' || $path === '' || !count($parts)) {return array();}foreach($parts as $n => $name) {$lastPart = $name;if(ctype_alnum($name)) continue;$namePrev = $name;$name = $sanitizer->pageNameUTF8($name);$parts[$n] = $name;if($namePrev !== $name) $badNames[$n] = $namePrev;}if(stripos($lastPart, 'index.') === 0 && preg_match('/^index\.(php\d?|s?html?)$/i', $lastPart)) {array_pop($parts); // removing last part will force a 301$this->addResultError('indexFile', 'Path had index file');}if($result['response'] < 400 && count($badNames)) {$result['response'] = 400; // 400=Bad request$this->addResultError('pathBAD', 'Path contains invalid character(s)');}// identify language from parts and populate to result$this->getPathPartsLanguage($parts);return $parts;}/*** Get language that path is in and apply it to result** @param array $parts* @return Language|null**/protected function getPathPartsLanguage(array &$parts) {if(!count($this->useLanguages) || !count($parts)) return null;$languages = $this->languages(); /** @var Languages|array $languages */if(!count($languages)) return null;$firstPart = reset($parts);$languageKey = array_search($firstPart, $this->languageSegments());if($languageKey === false) {// if no language segment present then do not identify language yet// allows for page /es/bici/ to still match /bici/ and redirect to /es/bici/return null;// Comment above and uncomment below to disable language detection without language prefix// $language = $languages->getDefault();// $segment = $this->languageSegment('default');} else {$segment = array_shift($parts);$language = $languages->get($languageKey);}if(!$language || !$language->id) return null;$this->addResultNote("Detected language '$language->name' from first segment '$segment'");$this->setResultLanguage($language, $segment);if($this->verbose && $languageKey !== false) {$this->result['parts'][] = array('type' => 'language','value' => $segment,'language' => $language->name);}// reduce to just applicable language to limit name columns// searched for by getPagesRow() methodif($language) $this->useLanguages = array($language);return $language;}/*** RESULT *********************************************************************************//*** Build blank result/return value array** @param array $result Optionally return blank result merged with this one* @return array**/protected function getBlankResult(array $result = array()) {$blankResult = array('request' => '','response' => 0,'type' => '','redirect' => '','errors' => array(),'page' => array('id' => 0,'parent_id' => 0,'templates_id' => 0,'status' => 0,'path' => '',),'language' => array('name' => '', // intentionally blank'segment' => '','status' => -1, // -1=not yet set),'parts' => array(),'urlSegments' => array(),'urlSegmentStr' => '','pageNum' => 1,'pageNumPrefix' => '','pathAdd' => '', // valid URL segments, page numbers, trailing slash, etc.'scheme' => '','methods' => array(),'notes' => array(),);if(empty($result)) return $blankResult;$result = array_merge($blankResult, $result);return $result;}/*** Update paths for template info like urlSegments and pageNum and populate urls property** @param string $path* @return bool|string**/protected function applyResultTemplate($path) {$config = $this->wire()->config;$fail = false;$result = &$this->result;if(empty($result['page']['templates_id']) && $this->isHomePath($path)) {$this->applyResultHome();}$template = $this->getResultTemplate();$slashUrls = $template ? (int) $template->slashUrls : 0;$useTrailingSlash = $slashUrls ? 1 : -1; // 1=yes, 0=either, -1=no$hadTrailingSlash = substr($result['request'], -1) === '/';$https = $template ? (int) $template->https : 0;$appendPath = '';// populate urlSegmentStr property if applicableif(empty($result['urlSegmentStr']) && !empty($result['urlSegments'])) {$urlSegments = $result['urlSegments'];$result['urlSegmentStr'] = count($urlSegments) ? implode('/', $urlSegments) : '';}// if URL segments are present validate themif(strlen($result['urlSegmentStr'])) {if($template && ($template->urlSegments || $template->name === 'admin')) {if($template->isValidUrlSegmentStr($result['urlSegmentStr'])) {$appendPath .= "/$result[urlSegmentStr]";if($result['pageNum'] < 2) $useTrailingSlash = (int) $template->slashUrlSegments;} else {// ERROR: URL segments did not validate$this->addResultError('urlSegmentsBAD', "Invalid urlSegments for template $template");$fail = true;}} else {// template does not allow URL segmentsif($template) {$this->addResultError('urlSegmentsOFF', "urlSegments disabled for template $template");}$fail = true;}}// if a pageNum is present validate itif($result['pageNum'] > 1) {if($template && $template->allowPageNum) {$maxPageNum = $this->wire()->config->maxPageNum;if($maxPageNum && $result['pageNum'] > $maxPageNum && $template->name != 'admin') {$this->addResultError('pageNumBAD', "pageNum exceeds config.maxPageNum $maxPageNum");$fail = true;}$segment = $this->pageNumUrlSegment($result['pageNum'], $result['language']['name']);if(strlen($segment)) {$appendPath .= "/$segment";}$useTrailingSlash = (int) $template->slashPageNum;} else {// template does not allow page numbers$this->addResultError('pageNumOFF', "pageNum disabled for template $template");$fail = true;}}// determine whether path should end with a trailing slash or notif($useTrailingSlash > 0) {// trailing slash required$appendPath .= '/';if(!$hadTrailingSlash) $this->addResultNote('Enforced trailing slash');} else if($useTrailingSlash < 0) {// trailing slash disallowedif($hadTrailingSlash) $this->addResultNote('Enforced NO trailing slash');} else if($hadTrailingSlash) {// either acceptable, add slash if request had it$appendPath .= '/';}$_path = $path;if(strlen($appendPath)) $path = rtrim($path, '/') . $appendPath;if($fail || $_path !== $path) $result['redirect'] = '/' . ltrim($path, '/');$result['pathAdd'] = $appendPath;// determine if page requires specific https vs. http schemeif($https > 0 && !$config->noHTTPS) {// https required$result['scheme'] = 'https';} else if($https < 0) {// http required (https disallowed)$result['scheme'] = 'http';}return !$fail;}/*** Apply result for homepage match**/protected function applyResultHome() {$config = $this->wire()->config;$home = $this->getHomepage();$this->template = $home->template;$this->result['page'] = array_merge($this->result['page'], array('id' => $config->rootPageID,'templates_id' => $this->template->id,'parent_id' => 0,'status' => $home->status));$this->addMethod('resultHome', true);}/*** Identify and populate language information in result** @param string $path* @return string $path Path is updated as needed**/protected function applyResultLanguage($path) {if(!count($this->useLanguages)) return $path;$result = &$this->result;/*if(empty($result['language']['name']) && $path != '/') {// @todo why?return $path;}*/// admin does not use LanguageSupportPageNamesif($this->isResultInAdmin()) {return $this->updatePathForLanguage($path, 'default');}// if(empty($result['language']['name'])) $result['language']['name'] = 'default';// if there were any non-default language segments, let that dictate the languageif(empty($result['language']['segment'])) {$useLangName = 'default';foreach($result['parts'] as $key => $part) {$langName = $part['language'];if(empty($langName) || $langName === 'default') continue;$useLangName = $langName;break;}$segment = $this->languageSegment($useLangName);if($segment) $result['language']['segment'] = $segment;$result['language']['name'] = $useLangName;}$langName = $result['language']['name'];// prepend the redirect path with the language segmentif(!empty($langName)) {$updatePath = $this->updatePathForLanguage($path);$redirect = &$result['redirect'];if(empty($redirect) && $path != $updatePath) $redirect = $updatePath;if(!empty($redirect)) $redirect = $this->updatePathForLanguage($redirect);$path = $updatePath;}// determine language status if not yet known (likely only needed during shortcuts)if($result['language']['status'] < 0 && !empty($langName)) {if($langName === 'default') {$status = 1;} else if($result['page']['id']) {$status = $this->getPageLanguageStatus($result['page']['id'], $this->languageId($langName));} else {$status = -1;}$this->setResultLanguageStatus($status);}return $path;}/*** Identify and populate pagination number from $result['urlSegments']** @param array $parts**/protected function applyResultPageNum(array &$parts) {$result = &$this->result;$urlSegments = $result['urlSegments'];if(!count($urlSegments)) return;$lastSegment = end($urlSegments);if(!ctype_digit(substr($lastSegment, -1))) return;foreach($this->pageNumUrlPrefixes() as $languageName => $pageNumUrlPrefix) {if(strpos($lastSegment, $pageNumUrlPrefix) !== 0) continue;if(!preg_match('!^' . $pageNumUrlPrefix . '(\d+)$!i', $lastSegment, $matches)) continue;$segment = $matches[0];$pageNum = (int) $matches[1];$result['pageNum'] = $pageNum;$result['pageNumPrefix'] = $pageNumUrlPrefix;array_pop($urlSegments);array_pop($parts);if($this->verbose) {array_pop($result['parts']);$result['parts'][] = array('type' => 'pageNum','value' => $segment,'language' => (is_string($languageName) ? $languageName : 'default'),);}break;}$result['urlSegments'] = $urlSegments;}/*** Finish result/return value** @param string|bool $path Path string or boolean false when 404* @return array**/protected function finishResult($path) {$result = &$this->result;$types = $this->pages->request()->getResponseCodeNames();if($path !== false) $path = $this->applyResultLanguage($path);if($path !== false) $path = $this->applyResultTemplate($path);if($path === false) $result['response'] = 404;$response = &$result['response'];$language = &$result['language'];$errors = &$result['errors'];if($response === 404) {// page not foundif(empty($result['errors'])) $result['errors']['pageNotFound'] = "Page not found";} else if($response == 414) {// path too long} else if($result['request'] === $result['redirect'] || empty($result['redirect'])) {// blank redirect values mean no redirect is suggested$response = 200;$result['redirect'] = '';} else if($response === 301 || $response === 302) {// redirect identified} else {// redirect suggested$response = 301;}if($result['pageNum'] > 1 && ($response === 301 || $response === 302)) {// redirect where pageNum is greater than 1$response = $this->finishResultRedirectPageNum($response, $result);}if(empty($language['name'])) {// set language property (likely for non-multi-language install)$language['name'] = 'default';$language['status'] = 1;} else if($language['name'] != 'default' && !$language['status'] && $result['page']['id']) {// page found but not published in language (needs later decision)$response = 300; // 300 Multiple Choice$errors['languageOFF'] = "Page not active in request language ($language[name])";$result['redirect'] = $this->pages->getPath($result['page']['id']);if($result['pathAdd']) $result['redirect'] = rtrim($result['redirect'], '/') . $result['pathAdd'];}if(empty($result['type']) && isset($types[$response])) {if($result['response'] === 404 && !empty($result['redirect'])) {// when page found but path not use the 400 response type name w/404$result['type'] = $types[400];} else {$result['type'] = $types[$response];}}$result['methods'] = $this->methods;if(!$this->verbose) unset($result['parts'], $result['methods']);if(empty($errors)) {// force errors placeholder to end if there aren’t anyunset($result['errors']);$result['errors'] = array();}if($this->options['test']) {// add 'test' to the result that should match regardless of options (except useLanguages option)$redirect = ($result['response'] >= 300 && $result['response'] < 400 ? $result['redirect'] : '');$pageNumStr = ($result['pageNum'] > 1 ? "/$result[pageNumPrefix]$result[pageNum]" : '');$result['test'] = array('response=' . $result['response'],'page=' . $result['page']['id'],'redirect=' . $redirect,'language=' . $result['language']['name'] . '[' . $result['language']['status'] . ']','uss=' . $result['urlSegmentStr'] . $pageNumStr,);$result['test'] = implode(', ', $result['test']);}return $result;}/*** SHORTCUTS ******************************************************************************//*** Attempt to match path to page using shortcuts and return true if shortcut found** @param string $path* @return bool Return true if shortcut found and result ready, false if not**/protected function getShortcut($path) {$found = false;// $slash = substr($path, -1) === '/' ? '/' : '';$path = trim($path, '/');// check for pagination segment, which we don’t want in our path herelist($pageNum, $pageNumPrefix) = $this->getShortcutPageNum($path);if(strpos($path, '/') === false) {// single directory off root$found = $this->getShortcutRoot($path);}if(!$found) {if($this->getShortcutExcludeRoot($path)) {$found = true;} else if($this->getShortcutPagePaths($path)) {$found = true;}}if(!$found) return false;$this->result['pageNum'] = $pageNum;$this->result['pageNumPrefix'] = $pageNumPrefix;$this->result = $this->finishResult($path);return true;}/*** @param string $path* @return bool**/protected function getShortcutRoot($path) {if($path === '') {$this->setResultLanguage('default');$this->setResultLanguageStatus(1);$this->applyResultHome();return true;}if(count($this->useLanguages)) {$languageId = $this->isLanguageSegment($path);if($languageId !== false) {$langName = $this->languageName($languageId);$langStatus = $langName === 'default' ? 1 : $this->getHomepage()->get("status$languageId");$this->setResultLanguage($langName, $path);$this->setResultLanguageStatus($langStatus);$this->applyResultHome();return true;}}if($this->getShortcutGlobalUnique($path)) {return true;}return false;}/*** Find out if we can early exit 404 based on the root segment** Unlike other shortcuts, this one is an exclusion shortcut:* Returns false if the root segment matched and further analysis should take place.* Returns true if root segment is not in this site and 404 should be the result.** @param string $path* @return bool**/protected function getShortcutExcludeRoot($path) {if(!$this->options['useExcludeRoot']) return false;if(!$this->options['usePagePaths']) return false;$module = $this->pagePathsModule();if(!$module) return false;$homepage = $this->pages->get((int) $this->wire()->config->rootPageID);// if root/home template allows URL segments then potentially anything// can match the root segment, so this shortcut is not worthwhileif($homepage->template->urlSegments) return false;$config = $this->wire()->config;$path = trim($path, '/');if(strpos($path, '/')) {list($segment,) = explode('/', $path, 2);} else {$segment = $path;}if(strlen($segment) <= $config->maxUrlSegmentLength) {if($module->isRootSegment($segment)) {$this->addMethod('excludeRoot.paths', true);return false; // root segment found} else {$this->addMethod('excludeRoot.paths', false);}if($this->options['useHistory']) {$module = $this->pagePathHistoryModule();if($module && $this->options['useHistory']) {if($module->isRootSegment($segment)) {$this->addMethod('excludeRoot.history', true);return false; // root segment found}$this->addMethod('excludeRoot.history', false);}}$response = 404;} else {$response = 414;}// at this point we know given path does not have a valid root segment// and cannot possibly match any page so we can safely stop further// processing and return a 404 not found result$this->result['response'] = $response;$this->addMethod('excludeRoot', $response, 'Early exit for root segment 404');return true;}/*** Find a shortcut using the PagePaths module** @param string $path* @return bool Returns true if found, false if not installed or not found**/protected function getShortcutPagePaths(&$path) {if(!$this->options['usePagePaths']) return false;$module = $this->pagePathsModule();if(!$module) return false;$result = &$this->result;$info = $module->getPageInfo($path);if(!$info) {$this->addMethod('pagePaths', false);return false;}$language = $this->language((int) $info['language_id']);$path = "/$info[path]";unset($info['language_id'], $info['path']);$result['page']['id'] = $info['id'];$result['page']['status'] = $info['status'];$result['page']['templates_id'] = $info['templates_id'];$result['page']['parent_id'] = $info['parent_id'];$result['response'] = 200;if($language && $language->id) {$status = $language->isDefault() ? $info['status'] : $info["status$language->id"];$result['language'] = array_merge($result['language'], array('name' => $language->name,'status' => ($language->status < Page::statusUnpublished ? $status : 0),'segment' => $this->languageSegment($language)));}$this->addMethod('pagePaths', true);return true;}/*** Attempt to match a page with status 'unique' or having parent_id=1** This method only proceeds if the path contains no slashes, meaning it is 1-level from root.** @param string $path* @return bool**/protected function getShortcutGlobalUnique(&$path) {if(!$this->options['useGlobalUnique']) return false;$database = $this->wire()->database;$unique = Page::statusUnique;if(strpos($path, '/') !== false) return false;$name = $this->pageNameToAscii($path);$columns = array('id', 'parent_id', 'templates_id', 'status');$cols = implode(',', $columns);$sql = "SELECT $cols FROM pages WHERE name=:name AND (parent_id=1 OR (status & $unique))";$query = $database->prepare($sql);$query->bindValue(':name', $name);$query->execute();$row = $query->fetch(\PDO::FETCH_ASSOC);$query->closeCursor();if(!$row) {$this->addMethod('globalUnique', false);return false;}foreach($row as $k => $v) $row[$k] = (int) $v;$result = &$this->result;$result['page'] = array_merge($result['page'], $row);$result['response'] = 200;$result['language']['name'] = 'default';if($row['parent_id'] === 1) {$template = $this->wire()->templates->get($row['templates_id']);$slashUrls = $template ? (int) $template->slashUrls : 0;$slash = ($slashUrls ? $slashUrls > 0 : substr($path, -1) === '/');$path = "/$path" . ($slash ? '/' : '');} else {// global unique, must redirect to its actual path$page = $this->pages->getOneById((int) $row['id'], array('template' => (int) $row['templates_id'],'autojoin' => false,));if(!$page->id) return false; // not likely$path = $page->path();}$result['redirect'] = $path;$this->addMethod('globalUnique', true);return true;}/*** Identify shortcut pagination info** Returns found pagination number, or 1 if first pagination.* Extracts the pagination segment from the path.** @param string $path* @return array of [ pageNum, pageNumPrefix ]**/protected function getShortcutPageNum(&$path) {if(!ctype_digit(substr($path, -1))) return array(1, '');$pos = strrpos($path, '/');$lastPart = $pos ? substr($path, $pos+1) : $path;$pageNumPrefix = '';$pageNum = 1;foreach($this->pageNumUrlPrefixes() as $prefix) {if(strpos($lastPart, $prefix) !== 0) continue;if(!preg_match('/^' . $prefix . '(\d+)$/', $lastPart, $matches)) continue;$pageNumPrefix = $prefix;$pageNum = (int) $matches[1];break;}if($pageNumPrefix) {$path = $pos ? substr($path, 0, $pos) : '';if($pageNum < 2) $pageNum = 1;}return array($pageNum, $pageNumPrefix);}/*** Attempt to match page path from PagePathHistory module** @param string $path* @return bool**/protected function getPathHistory($path) {if(!$this->options['useHistory']) return false;$module = $this->pagePathHistoryModule();if(!$module) return false;$result = &$this->result;$info = $module->getPathInfo($path, array('allowUrlSegments' => true));// if no history found return falseif(!$info['id']) {$this->addMethod('pathHistory', false);return false;}// get page found in history$page = $this->pages->getOneById((int) $info['id'], array('template' => (int) $info['templates_id'],'parent_id' => $info['parent_id'],'autojoin' => false,));if(!$page->id) {$this->addMethod('pathHistory', false, 'Found row but could not match to page');return false;}$path = $page->path;$languageName = $this->languageName($info['language_id']);$keys = array('id', 'language_id', 'templates_id', 'parent_id', 'status', 'name');foreach($keys as $key) {$result['page'][$key] = $info[$key];}$result['language']['name'] = $languageName;$result['response'] = 301;$result['redirect'] = $path;$urlSegmentStr = $info['urlSegmentStr'];if(strlen($urlSegmentStr)) {$result['urlSegments'] = explode('/', $info['urlSegmentStr']);$this->applyResultPageNum($result['parts']);}$result = $this->finishResult($path);$this->addMethod('pathHistory', true);return true;}/*** UTILITIES ******************************************************************************//*** @var string**/protected $pageNameCharset = '';/*** Get page number segment with given pageNum and and in given language name** @param int $pageNum* @param string $langName* @return string**/protected function pageNumUrlSegment($pageNum, $langName = 'default') {$pageNum = (int) $pageNum;if($pageNum < 2) return '';$a = $this->pageNumUrlPrefixes();$prefix = isset($a[$langName]) ? $a[$langName] : $a['default'];return $prefix . $pageNum;}/*** Get pageNum URL prefixes indexed by language name** @return array**/protected function pageNumUrlPrefixes() {$config = $this->wire()->config;$a = $config->pageNumUrlPrefixes;if(!is_array($a)) $a = array();$default = $config->pageNumUrlPrefix;if(empty($default)) $default = 'page';if(empty($a['default'])) $a['default'] = $default;return $a;}/*** Does given path refer to homepage?** @param string $path* @return bool**/protected function isHomePath($path) {$path = trim($path, '/');if($path === '') return true;$isHomePath = false;foreach($this->languageSegments() as $segment) {if($path !== $segment) continue;$isHomePath = true;break;}return $isHomePath;}/*** Convert ascii page name to UTF-8** @param string $name* @return string**/protected function pageNameToUTF8($name) {$name = (string) $name;if($this->pageNameCharset !== 'UTF8') return $name;if(strpos($name, 'xn-') !== 0) return $name;return $this->wire()->sanitizer->pageName($name, Sanitizer::toUTF8);}/*** Convert UTF-8 page name to ascii** @param string $name* @return string**/protected function pageNameToAscii($name) {if($this->pageNameCharset !== 'UTF8') return $name;return $this->wire()->sanitizer->pageName($name, Sanitizer::toAscii);}/*** Get template used by page found in result or null if not yet known** @return null|Template**/protected function getResultTemplate() {if(!$this->template && !empty($this->result['page']['templates_id'])) {$this->template = $this->wire()->templates->get($this->result['page']['templates_id']);}return $this->template;}/*** Is matched result in admin?** @return bool**/protected function isResultInAdmin() {if($this->admin !== null) return $this->admin;$config = $this->wire()->config;if($this->result['page']['templates_id'] === 2) {$this->admin = true;} else if($this->result['page']['id'] === $config->adminRootPageID) {$this->admin = true;} else {$template = $this->getResultTemplate();if(!$template) {return false; // may need to detect later} if(in_array($template->name, $config->adminTemplates, true)) {$this->admin = true;} else if(in_array($template->name, array('user', 'role', 'permission', 'language'))) {$this->admin = true;} else {$this->admin = false;}}return $this->admin;}/*** Get string length, using mb_strlen() if available, strlen() if not** @param string $str* @return int**/protected function strlen($str) {return function_exists('mb_strlen') ? mb_strlen($str) : strlen($str);}/*** Add method debug info (verbose mode)** @param string $name* @param int|bool $code* @param string $note**/protected function addMethod($name, $code, $note = '') {if(!$this->verbose) return;if($code === true) $code = 200;if($code === false) $code = 404;if(empty($note)) {if($code === 200) $note = 'OK';if($code === 404) $note = 'Not found';}if($note) $code = "$code $note";$this->methods[] = "$name: $code";}/*** Get homepage** @return Page**/protected function getHomepage() {return $this->pages->get((int) $this->wire()->config->rootPageID);}/*** Add named error message to result** @param string $name* @param string $message**/protected function addResultError($name, $message) {if(!$this->verbose) return;$this->result['errors'][$name] = $message;}/*** Add note to result** @param string $message**/protected function addResultNote($message) {if(!$this->verbose) return;$this->result['notes'][] = $message;}/*** Get default options** #pw-internal** @return array**/public function getDefaultOptions() {return $this->defaults;}/*** MODULES **********************************************************************************//*** @var PagePaths|null**/protected $pagePathsModule = null;/*** @var PagePathHistory|null**/protected $pagePathHistoryModule = null;/*** @var null|PagesPathFinderTests**/protected $tester = null;/*** Get optional PathPaths module instance if it is installed, false if not** @return bool|PagePaths**/protected function pagePathsModule() {if($this->pagePathsModule !== null) return $this->pagePathsModule;$modules = $this->wire()->modules;if($modules->isInstalled('PagePaths')) {$this->pagePathsModule = $modules->getModule('PagePaths');} else {$this->pagePathsModule = false;}return $this->pagePathsModule;}/*** Get optional PagePathHistory module instance if it is installed, false if not** @return PagePathHistory|bool**/protected function pagePathHistoryModule() {if($this->pagePathHistoryModule !== null) return $this->pagePathHistoryModule;$modules = $this->wire()->modules;if($modules->isInstalled('PagePathHistory')) {$this->pagePathHistoryModule = $modules->getModule('PagePathHistory');} else {$this->pagePathHistoryModule = false;}return $this->pagePathHistoryModule;}/*** Get PagesPathFinderTests instance** #pw-internal** @return PagesPathFinderTests**/public function tester() {if($this->tester) return $this->tester;$this->tester = new PagesPathFinderTests();$this->wire($this->tester);return $this->tester;}/*** LANGUAGES ******************************************************************************//*** Cache for languageSegments() method** @var array**/protected $languageSegments = array();/*** Language names indexed by id** @var array**/protected $languageNames = array();/*** Set result language by name or ID** @param int|string|Language $language* @param string $segment**/protected function setResultLanguage($language, $segment = '') {if(is_object($language)) {$name = $language->name;} else if(ctype_digit("$language")) {$id = (int) $language;$name = $this->languageName($id);} else {$name = $language;}$this->result['language']['name'] = $name;if($segment !== '') $this->setResultLanguageSegment($segment);}/*** Set result language segment** @param string $segment**/protected function setResultLanguageSegment($segment) {$this->result['language']['segment'] = $segment;}/*** Set result language status** @param int|bool $status**/protected function setResultLanguageStatus($status) {$this->result['language']['status'] = (int) $status;}/*** Get value from page status column** @param int $pageId* @param int $languageId* @return int**/protected function getPageLanguageStatus($pageId, $languageId) {$pageId = (int) $pageId;$languageId = (int) $languageId;$langName = $languageId ? $this->languageName($languageId) : 'default';$col = $langName === 'default' ? 'status' : "status$languageId";$page = $this->pages->cacher()->getCache((int) $pageId);if($page) return $page->get($col);$query = $this->wire()->database->prepare("SELECT `$col` FROM pages WHERE id=:id");$query->bindValue(':id', $pageId, \PDO::PARAM_INT);try {$query->execute();$status = (int) $query->fetchColumn();$query->closeCursor();} catch(\Exception $e) {$status = -1;}return $status;}/*** Return Languages if installed w/languageSupportPageNames module or blank array if not** @param bool $getArray Force return value as an array indexed by language name* @return Languages|array**/protected function languages($getArray = false) {if(is_array($this->useLanguages) && !count($this->useLanguages)) {return array();}$languages = $this->wire()->languages;if(!$languages) {$this->useLanguages = array();return array();}if(!$languages->hasPageNames()) {$this->useLanguages = array();return array();}if($getArray) {$a = array();foreach($languages as $language) {$a[$language->name] = $language;}$languages = $a;}return $languages;}/*** Given a value return corresponding language** @param string|int|Language $value* @return Language|null**/protected function language($value) {$language = null;if($value instanceof Page) {if($value->className() === 'Language' || wireInstanceOf($value, 'Language')) {$language = $value;}} else {/** @var Languages|array $languages */$languages = $this->languages();if(!count($languages)) return null;$id = $this->languageId($value);$language = $id ? $languages->get($id) : null;}if(!$language || !$language->id) return null;return $language;}/*** Get homepage name segments used for each language, indexed by language id** #pw-internal** @return array**/public function languageSegments() {// use cached value when availableif(count($this->languageSegments)) return $this->languageSegments;$columns = array();$languages = $this->languages();if(!count($languages)) return array();foreach($languages as $language) {$name = $language->isDefault() ? "name" : "name$language->id";$columns[$name] = $language->id;}// see if homepage already loaded in cache$homepage = $this->pages->cacher()->getCache(1);// if homepage available, get segments from itif($homepage) {foreach($columns as $name => $languageId) {$value = $homepage->get($name);if($name === 'name' && $value === Pages::defaultRootName) $value = '';$this->languageSegments[$languageId] = $value;}} else {// if homepage not already loaded, pull segments directly from pages table$config = $this->wire()->config;$database = $this->wire()->database;$cols = implode(', ', array_keys($columns));$sql = "SELECT $cols FROM pages WHERE id=:id";$query = $database->prepare($sql);$query->bindValue(':id', (int) $config->rootPageID, \PDO::PARAM_INT);$query->execute();$row = $query->fetch(\PDO::FETCH_ASSOC);$query->closeCursor();foreach($row as $name => $value) {$languageId = $columns[$name];$value = $this->pageNameToUTF8($value);if($name === 'name' && $value === Pages::defaultRootName) $value = '';$this->languageSegments[$languageId] = $value;}}return $this->languageSegments;}/*** Is given segment a language segment? Returns language ID if yes, false if no** #pw-internal** @param string $segment* @return false|int**/public function isLanguageSegment($segment) {$key = array_search($segment, $this->languageSegments());return $key;}/*** Return homepage name segment used by given language** @param string|int|Language $language* @return string**/protected function languageSegment($language) {$id = $this->languageId($language);$segments = $this->languageSegments();return isset($segments[$id]) ? $segments[$id] : '';}/*** Return language identified by homepage name segment** @param string $segment* @param bool $getLanguageId* @return Language|null|int**/protected function segmentLanguage($segment, $getLanguageId = false) {$segments = $this->languageSegments();$languageId = array_search($segment, $segments);if($getLanguageId) return $languageId ? $languageId : 0;return $languageId ? $this->language($languageId) : null;}/*** Return name for given language id or object** @param int|string|Language $language* @return string**/protected function languageName($language) {$names = $this->languageNames();$languageId = $this->languageId($language);if(isset($names[$languageId])) return $names[$languageId];return 'default';}/*** Return all language names indexed by language id** @return array**/protected function languageNames() {if(empty($this->languageNames)) {foreach($this->languages() as $lang) {$this->languageNames[$lang->id] = $lang->name;}}return $this->languageNames;}/*** Return language id for given value (language name, name column or home segment)** @param int|string|Language $language* @return int**/protected function languageId($language) {$language = (string) $language;if(ctype_digit("$language")) return (int) "$language"; // object or number stringif($language === 'name' || $language === 'default') {// name property translates to default language$languages = $this->languages();return count($languages) ? $languages->getDefault()->id : 0;} else if(strpos($language, 'name') === 0 && ctype_digit(substr($language, 5))) {// i.e. name1234 where 1234 is language id$language = str_replace('name', '', $language);} else if(!ctype_digit($language)) {// likely a language name$langName = $language;$language = null;foreach($this->languages() as $lang) {if($lang->name === $langName) $language = $lang;if($language) break;}if(!$language) {// if not found by language name, try home language segments$languageId = 0;foreach($this->languageSegments() as $id => $segment) {if($segment === $langName) $languageId = (int) $id;if($languageId) break;}if($languageId) return $languageId;}$language = $language ? $language->id : 0;}return (int) $language;}/*** Update given path for result language and return it** @param string $path* @param string $langName* @return string**/protected function updatePathForLanguage($path, $langName = '') {$result = &$this->result;$config = $this->wire()->config;$template = $this->getResultTemplate();if($template && in_array($template->name, $config->adminTemplates)) {return $this->removeLanguageSegment($path);}if(empty($langName)) $langName = $result['language']['name'];if(empty($langName)) $langName = 'default';if($langName === 'default' && ($result['page']['id'] === 1 || $path === '/')) {$pageNames = $this->wire()->languages->pageNames();if(!$pageNames) return $path;$useSegment = $pageNames->useHomeSegment;} else {$useSegment = true;}if($useSegment) {$path = $this->addLanguageSegment($path, $langName);} else {$path = $this->removeLanguageSegment($path);}return $path;}/*** Add language segment** @param string $path* @param string|Language|int $language* @return string**/protected function addLanguageSegment($path, $language) {if(strpos($path, '/') !== 0) $path = "/$path";$segment = $this->languageSegment($language);if(!strlen($segment)) return $path;if($path === "/$segment" && $this->result['page']['id'] < 2) return $path;if(strpos($path, "/$segment/") === 0) return $path;return "/$segment$path";}/*** Remove any language segments present on given path** @param string $path* @return string**/protected function removeLanguageSegment($path) {if(strpos($path, '/') !== 0) $path = "/$path";if($path === '/') return $path;$segments = $this->languageSegments();$segments[] = Pages::defaultRootName;foreach($segments as $segment) {if($segment === null || !strlen($segment)) continue;if($path !== "/$segment" && strpos($path, "/$segment/") !== 0) continue;list(,$path) = explode("/$segment", $path, 2);if($path === '') $path = '/';break;}return $path;}/*** ADDITIONAL/HELPER LOGIC ****************************************************************//*** Update result for cases where a redirect was determined that involved pagination** Most of the logic here allows for the special case of admin URLs, which work with either* a custom pageNumUrlPrefix or the original/default one. This is a helper for the* finishResult() method.** @param int $response* @var array $result* @return int* @since 3.0.198**/protected function finishResultRedirectPageNum($response, &$result) {if($result['pageNum'] < 2) return $response;if(empty($result['page']['templates_id'])) return $response;if($result['page']['status'] >= Page::statusUnpublished) return $response;// The config[_pageNumUrlPrefix] property is set by LanguageSupportPageNames$pageNumUrlPrefix = $this->wire()->config->get('_pageNumUrlPrefix');if(empty($pageNumUrlPrefix)) $pageNumUrlPrefix = 'page';// if default/original pageNum prefix not in use then do nothing furtherif($result['pageNumPrefix'] !== $pageNumUrlPrefix) return $response;// if request is not for something in the admin then do nothing further$adminTemplate = $this->wire()->templates->get('admin');if(!$adminTemplate || $result['page']['templates_id'] != $adminTemplate->id) return $response;// request is for pagination within admin, where we allow either custom or original/default prefix$requestParts = explode('/', trim($result['request'], '/'));$redirectParts = explode('/', trim($result['redirect'], '/'));$requestPrefix = array_pop($requestParts);$redirectPrefix = array_pop($redirectParts);$requestPath = implode('/', $requestParts);$redirectPath = implode('/', $redirectParts);// if something other than pagination prefix differs then do nothing furtherif($requestPath != $redirectPath || $requestPrefix === $redirectPrefix) return $response;// only the pagination prefix differs, allow it when in admin$result['notes'][] = "Default pagination prefix '$pageNumUrlPrefix' allowed for admin template";$response = 200;return $response;}}/*** PagesPathFinder Tests** Usage:* ~~~~~* $tester = $pages->pathFinder()->tester();* $a = $tester->testPath('/path/to/page/');* $a = $tester->testPage(Page $page);* $a = $tester->testPages("has_parent!=2");* $a = $tester->testPages(PageArray $items);* ~~~~~**/class PagesPathFinderTests extends Wire {/*** @return PagesPathFinder**/public function pathFinder() {return $this->wire()->pages->pathFinder();}/*** @param string $path* @param int $expectResponse* @return array**/public function testPath($path, $expectResponse = 0) {$tests = array();$testResults = array();$results = array();$optionSets = array('defaults' => $this->pathFinder()->getDefaultOptions(),'noPagePaths' => array('usePagePaths' => false),'noGlobalUnique' => array('useGlobalUnique' => false),'noHistory' => array('useHistory' => false),'excludeRoot' => array('useExcludeRoot' => true),);foreach($optionSets as $name => $options) {$options['test'] = true;$result = $this->pathFinder()->get($path, $options);$test = $result['test'];$results[$name] = $result;$tests[$name] = $test;}$defaultTest = $tests['defaults'];foreach(array_keys($optionSets) as $name) {$test = $tests[$name];$result = $results[$name];if($expectResponse && $result['response'] != $expectResponse) {$status = "FAIL ($result[response] != $expectResponse)";} else {$status = ($test === $defaultTest ? 'OK' : 'FAIL');}$testResults[] = array('name' => $name,'status' => $status,'test' => $test);}return $testResults;}/*** @param Page $item* @return array**/public function testPage(Page $item) {$languages = $this->languages();$testResults = array();$defaultPath = $item->path();if($languages) {foreach($languages as $language) {// $user->setLanguage($language);$path = $item->localPath($language);if($language->isDefault() || $path === $defaultPath) {$expect = 200;} else {$expect = $item->get("status$language") > 0 ? 200 : 300;}$testResults["$language->name:$path"] = $this->testPath($path, $expect);}} else {$path = $item->path();$testResults[$path] = $this->testPath($path, 200);}return $testResults;}/*** @param string|PageArray $selector* @return array**/public function testPages($selector) {if($selector instanceof PageArray) {$items = $selector;} else {$items = $this->pages->find($selector);}$testResults = array();foreach($items as $item) {$testResults = array_merge($testResults, $this->testPage($item));}return $testResults;}}