Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download
<?php namespace ProcessWire;/*** Multi-language support page names module** ProcessWire 3.x, Copyright 2022 by Ryan Cramer* https://processwire.com** @property int $moduleVersion* @property int $inheritInactive* @property int $useHomeSegment* @property int $redirect404** @method bool|string|array pageNotAvailableInLanguage(Page $page, Language $language)**/class LanguageSupportPageNames extends WireData implements Module, ConfigurableModule {/*** Return information about the module**/static public function getModuleInfo() {return array('title' => 'Languages Support - Page Names','version' => 13,'summary' => 'Required to use multi-language page names.','author' => 'Ryan Cramer','autoload' => true,'singular' => true,'requires' => array('LanguageSupport','LanguageSupportFields'));}/*** The path that was requested, before processing**/protected $requestPath = '';/*** Language that should be set for this request**/protected $setLanguage = null;/*** Whether to force a 404 when ProcessPageView runs**/protected $force404 = null;/*** Whether to bypass the functions provided by this module (like for a secure pagefile request)**/protected $bypass = false;/*** Default configuration data**/static protected $defaultConfigData = array(/*** module version, for schema changes when necessary**/'moduleVersion' => 0,/*** Whether an 'inactive' state (status123=0) should inherit to children** Note: we don't have a reasonable way to make this work with PageFinder queries,* so it is not anything more than a placeholder at present.**/'inheritInactive' => 0,/*** Whether or not the default language homepage should be served by a language segment.**/'useHomeSegment' => 0,/*** Redirect rather than throwing 404 when page not available in particular language?** - 200 to allow it to be rendered anyway.* - 301 when it should do a permanent redirect.* - 302 when it should do a temporary redirect.* - 404 (or 0) if it should proceed with throwing 404.**/'redirect404' => 0,);/*** Populate default config data**/public function __construct() {$this->setArray(self::$defaultConfigData);parent::__construct();}/*** Initialize the module and init hooks**/public function init() {$languages = $this->wire()->languages;$config = $this->wire()->config;$fields = $this->wire()->fields;$pageNumUrlPrefixes = array();$this->addHookBefore('ProcessPageView::execute', $this, 'hookProcessPageViewExecute');$this->addHookAfter('PagesRequest::getPage', $this, 'hookAfterPagesRequestGetPage');$this->addHookAfter('PageFinder::getQuery', $this, 'hookPageFinderGetQuery');// identify the pageNum URL prefixes for each languageforeach($languages as $language) {$pageNumUrlPrefix = $this->get("pageNumUrlPrefix$language");if($pageNumUrlPrefix) $pageNumUrlPrefixes[$language->name] = $pageNumUrlPrefix;// prevent user from creating fields with these names:$fields->setNative("name$language");$fields->setNative("status$language");}// tell ProcessPageView which segments are allowed for paginationif(count($pageNumUrlPrefixes)) {if(empty($pageNumUrlPrefixes['default'])) {$pageNumUrlPrefixes['default'] = $config->pageNumUrlPrefix; // original/fallback prefix} else if(!in_array($config->pageNumUrlPrefix, $pageNumUrlPrefixes)) {// if default prefix is also overridden then add it as an extra one allowed in admin$url = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';if($url && strpos($url, $config->urls->admin) === 0) {$key = 0; // PagesPathFinder maps non-string language names to default language$pageNumUrlPrefixes[$key] = $config->pageNumUrlPrefix; // original prefix}}$config->set('pageNumUrlPrefixes', $pageNumUrlPrefixes);}}/*** API ready: attach hooks**/public function ready() {$this->checkModuleVersion();$this->addHookAfter('Page::path', $this, 'hookPagePath');$this->addHookAfter('Page::viewable', $this, 'hookPageViewable');$this->addHookBefore('Page::render', $this, 'hookPageRender');$this->addHook('Page::localName', $this, 'hookPageLocalName');$this->addHook('Page::localUrl', $this, 'hookPageLocalUrl');$this->addHook('Page::localHttpUrl', $this, 'hookPageLocalHttpUrl');$this->addHook('Page::localPath', $this, 'hookPageLocalPath');// bypass means the request was to something in /site/*/ that has no possibilty of language support// note that the hooks above are added before this so that 404s can still be handled properlyif($this->bypass) return;// verify that page path doesn't have mixed languages where it shouldn't// @todo this can be replaced since logic is now in PagesRequest/PagesPathFinder/*$session = $this->wire()->session;$redirectUrl = $this->verifyPath($this->requestPath);if($redirectUrl) {// verifyPath says we should redirect to a different URLif(is_array($redirectUrl)) {list($code, $redirectUrl) = $redirectUrl;$session->redirect($redirectUrl, (int) $code);} else {$session->redirect($redirectUrl);}return;}*/$language = $this->wire()->user->language;$pages = $this->wire()->pages;$page = $this->wire()->page;$process = $page ? $page->process : null;$pageNumUrlPrefix = (string) $this->get("pageNumUrlPrefix$language");if($process && $page->template->name === 'admin' && in_array('WirePageEditor', wireClassImplements($process))) {// when in admin, add inputs for each language's page nameif(!in_array('ProcessPageType', wireClassParents($process))) {$page->addHookBefore('WirePageEditor::execute', $this, 'hookWirePageEditorExecute');$this->addHookAfter('InputfieldPageName::render', $this, 'hookInputfieldPageNameRenderAfter');$this->addHookAfter('InputfieldPageName::processInput', $this, 'hookInputfieldPageNameProcess');}}$this->addHookBefore('LanguageSupportFields::languageDeleted', $this, 'hookLanguageDeleted');$this->addHookBefore('LanguageSupportFields::languageAdded', $this, 'hookLanguageAdded');$pages->addHookAfter('saveReady', $this, 'hookPageSaveReady');$pages->addHookAfter('saved', $this, 'hookPageSaved');$pages->addHookAfter('setupNew', $this, 'hookPageSetupNew');if(strlen($pageNumUrlPrefix)) {$config = $this->wire()->config;if(!$config->admin) {$config->set('_pageNumUrlPrefix', $config->pageNumUrlPrefix); // original/backup url prefix$config->pageNumUrlPrefix = $pageNumUrlPrefix;}}}/*** Is the given path a site assets path? (i.e. /site/)** Determines whether this is a path we should attempt to perform any language processing on.** @param string $path* @return bool**/protected function isAssetPath($path) {$config = $this->wire()->config;// determine if this is a asset request, for compatibility with pagefileSecure$segments = explode('/', trim($config->urls->assets, '/')); // start with [subdir]/site/assetsarray_pop($segments); // pop off /assets, reduce to [subdir]/site$sitePath = '/' . implode('/', $segments) . '/'; // combine to [/subdir]/site/$sitePath = str_replace($config->urls->root, '', $sitePath); // remove possible subdir, reduce to: site/// if it is a request to assets, then don't attempt to modify it$sitePath = rtrim($sitePath, '/') . '/';$path = rtrim($path, '/') . '/';return strpos($path, $sitePath) === 0;}/*** Given a page path, return an updated version that lacks the language segment** It extracts the language segment and uses that to later set the language** @param string $path* @return string**/public function removeLanguageSegment($path) {if($path === '/' || !strlen($path)) return $path;$trailingSlash = substr($path, -1) == '/';$testPath = trim($path, '/') . '/';$segments = $this->wire()->pages->pathFinder()->languageSegments();foreach($segments as /* $languageId => */ $segment) {if(!strlen("$segment")) continue;$name = "$segment/";if(strpos($testPath, $name) !== 0) continue;$path = substr($testPath, strlen($name));break;}/*foreach($languages as $language) {$name = $language->isDefault() ? $home->get("name") : $home->get("name$language");if($name == Pages::defaultRootName) continue;if(!strlen($name)) continue;$name = "$name/";if(strpos($testPath, $name) === 0) {// $this->setLanguage = $language;$path = substr($testPath, strlen($name));}}*/if(!$trailingSlash && $path != '/') {$path = rtrim($path, '/');}return '/' . ltrim($path, '/');}/*** @param string $path* @return string* @deprecated use removeLanguageSegment instead**/public function updatePath($path) {return $this->removeLanguageSegment($path);}/*** Determine language from requested path, and if a redirect needs to be performed** Sets the user's language to that determined from the URL.** @param string $requestPath* @return string|array $redirectURL Returns one of hte following:* - String with URL to be redirected to.* - Array for redirect URL with redirect type, i.e. [ 302, '/path/to/redirect/to/' ]* - Blank string when no redirect should occur.** @todo this can be replaced/removed since logic is now in PagesRequest/PagesPathFinder*protected function verifyPath($requestPath) {$languages = $this->wire()->languages;$page = $this->wire()->page;$user = $this->wire()->user;$config = $this->wire()->config;$input = $this->wire()->input;if(!count($languages)) return '';if($page->template->name === 'admin') return '';$requestedParts = explode('/', $requestPath);$parentsAndPage = $page->parents()->getArray();$parentsAndPage[] = $page;array_shift($parentsAndPage); // shift off the homepage$redirectURL = '';$setLanguage = $this->setLanguage;// determine if we should set the current language based on requested URLif(!$setLanguage) {foreach($parentsAndPage as $p) {$requestedPart = strtolower(array_shift($requestedParts));if($requestedPart === $p->name) continue;foreach($languages as $language) {if($language->isDefault()) {$name = $p->get("name");} else {$name = $p->get("name$language");}if($name === $requestedPart) {$setLanguage = $language;}}}}// check to see if the $page or any of its parents has an inactive status for the $setLanguageif($setLanguage && !$setLanguage->isDefault()) {$active = true;if($this->inheritInactive) {// inactive status on a parent inherits through to childrenforeach($parentsAndPage as $p) {$status = $p->get("status$setLanguage");if(!$status) $active = false;}} else {// inactive status only applies to the page itself$active = $page->get("status$setLanguage") > 0;// https://github.com/processwire/processwire-issues/issues/463// $active = $page->get("status$setLanguage") > 0 || $page->template->noLang;}// if page is inactive for a language, and it's not editable, send a 404if(!$active) {$response = $this->pageNotAvailableInLanguage($page, $setLanguage);if($response === false) {// throw a 404$this->force404 = true;return '';} else if($response === true) {// render it} else if($response && (is_string($response) || is_array($response))) {// response contains redirect URL string or [ 302, 'url' ]return $response;}}}// set the languageif(!$setLanguage) $setLanguage = $languages->getDefault();$user->setLanguage($setLanguage);$this->setLanguage = $setLanguage;$languages->setLocale();// if $page is the 404 page, exit out nowif($page->id == $config->http404PageID) return '';// determine if requested URL was correct or if we need to redirect$hasSlashURL = substr($requestPath, -1) == '/';$useSlashURL = (bool) $page->template->slashUrls;$expectedPath = trim($this->getPagePath($page, $user->language), '/');$requestPath = trim($requestPath, '/');$pageNum = $input->pageNum();$urlSegmentStr = $input->urlSegmentStr();// URL segmentsif(strlen($urlSegmentStr)) {$expectedPath .= '/' . $urlSegmentStr;$useSlashURL = $hasSlashURL;}// page numbersif($pageNum > 1) {$prefix = $this->get("pageNumUrlPrefix$user->language");if(empty($prefix)) $prefix = $config->pageNumUrlPrefix;$expectedPath .= (strlen($expectedPath) ? "/" : "") . "$prefix$pageNum";$useSlashURL = false;}$expectedPathLength = strlen($expectedPath);if($expectedPathLength) {$requestPath = substr($requestPath, 0, $expectedPathLength);}if(trim($expectedPath, '/') != trim($requestPath, '/')) {if($expectedPathLength && $useSlashURL) $expectedPath .= '/';$redirectURL = $config->urls->root . ltrim($expectedPath, '/');} else if($useSlashURL && !$hasSlashURL && strlen($expectedPath)) {$redirectURL = $config->urls->root . $expectedPath . '/';} else if(!$useSlashURL && $hasSlashURL && $pageNum == 1) {$redirectURL = $config->urls->root . $expectedPath;}return $redirectURL;}*//*** Set the request language** @param Language|null $language**/public function setLanguage(Language $language = null) {$languages = $this->wire()->languages;if(!$language) $language = $languages->getDefault();$this->setLanguage = $language;$this->wire()->user->setLanguage($language);$languages->setLocale();}/*** Called when page is not available in a given language** Hook this method to change the behavior of what happens when a Page is requested in* a language that it is not marked as active in.** - Return boolean `true` if it should render the page anyway (like for editing user).* - Return boolean `false` if it should throw a “404 Page Not Found”.* - Return string containing URL like `/some/url/` if it should redirect to given URL.* - Return array `[ 302, '/some/url/' ]` if it should do a 302 “temporary” redirect to URL.* - Return array `[ 301, '/some/url/' ]` if it should do a 301 “permanent” redirect to URL.** #pw-hooker** @param Page $page* @param Language $language* @return bool|array* @since 3.0.186**/public function ___pageNotAvailableInLanguage(Page $page, Language $language) {if($page->editable()) return true;if($page->id == $this->wire()->config->http404PageID) return true;$redirect404 = (int) $this->redirect404;if(!$redirect404 || $redirect404 === 404 || $language->isDefault()) return false;$default = $this->wire()->languages->getDefault();if(!$page->viewable($default)) return false;if($redirect404 === 200) return true;$url = $this->getPageUrl($page, $default);if($redirect404 === 302 || $redirect404 === 301) return array($redirect404, $url);return false;}/*** Given a page and language, return the URL to the page in that language** @param Page $page* @param Language $language* @return string* @since 3.0.187**/public function getPageUrl(Page $page, Language $language) {$path = $this->getPagePath($page, $language);return $this->wire()->config->urls->root . ltrim($path, '/');}/*** Given a page and language, return the path to the page in that language** @param Page $page* @param Language $language* @return string**/public function getPagePath(Page $page, Language $language) {$isDefault = $language->isDefault();$template = $page->template;if($template) {if(!$isDefault && $template->noLang) {$language = $this->wire()->languages->getDefault();$isDefault = true;}}if($page->id === 1) {// special case: homepage$name = $isDefault ? '' : $page->get("name$language");if($isDefault && $this->useHomeSegment) $name = $page->name;if($name == Pages::defaultRootName || $name === null || !strlen($name)) return '/';return $template->slashUrls ? "/$name/" : "/$name";}$path = '';foreach($page->parents() as $parent) {$name = $isDefault ? $parent->get("name") : $parent->get("name$language|name");if($parent->id === 1) {// bypass ProcessWire's default homepage name of 'home', as we don't want it in URLsif($name == Pages::defaultRootName) continue;// avoid having default language name inherited at homepage level// if($isDefault && $name === $parent->get("name")) continue;}if(strlen("$name")) $path .= "/" . $name;}$name = (string) $page->get("name$language|name");$path = strlen($name) ? "$path/$name/" : "$path/";if(!$template->slashUrls && $path != '/') $path = rtrim($path, '/');return $path;}/*** Hook in before PagesRequest::getPage to capture and modify request path as needed** @param HookEvent $event* @since 3.0.186* @todo this can be replaced with an after() hook as PagesRequest can figure out language on its own now*public function hookBeforePagesRequestGetPage(HookEvent $event) {if($this->requestPath) return; // execute only once$request = $event->object;$requestPath = $request->getRequestPath();$this->requestPath = $requestPath;if($this->isAssetPath($requestPath)) {// bypass means the request was to something in /site/// that has no possibilty of language support$this->bypass = true;} else {// update path to remove language prefix$requestPath = $this->updatePath($requestPath);// determine if the update changed the request pathif($requestPath != $this->requestPath) {// update /es/path/to/page to /path/to/page// so that is recognized by PagesRequest$request->setRequestPath($requestPath);}}$event->removeHook($event);}*//*** Hook in after PagesRequest::getPage** @param HookEvent $event* @since 3.0.186**/public function hookAfterPagesRequestGetPage(HookEvent $event) {$request = $event->object; /** @var PagesRequest $request */$this->requestPath = $request->getRequestPath();$languageName = $request->getLanguageName();if($this->isAssetPath($this->requestPath)) {// bypass means the request was to something in /site/...// that has no possibilty of language support$this->bypass = true;} else if($languageName) {$config = $this->wire()->config;$page = $event->return; /** @var Page $page */$user = $this->wire()->user;$admin = $page && $page->id && in_array($page->template->name, $config->adminTemplates);if($admin && $user && $user->isLoggedin()) {// keep user’s configured language setting} else {$language = $this->wire()->languages->get($languageName);if($language && $language->id) $this->setLanguage($language);}}$event->removeHook($event);}/*** Hook in before ProcesssPageView::execute** @param HookEvent $event**/public function hookProcessPageViewExecute(HookEvent $event) {/** @var ProcessPageView $process */$process = $event->object;// tell it to delay redirects until after the $page API var is known/populated// this ensures our hook before PagesRequest::getPage() will always be called$process->setDelayRedirects(true);}/*** Hook in before ProcesssPageView::render to throw 404 when appropriate** @param HookEvent $event* @throws WireException**/public function hookPageRender(HookEvent $event) {if($this->force404) {$this->force404 = false; // prevent another 404 on the 404 pagethrow new Wire404Exception('Not available in requested language', Wire404Exception::codeLanguage);}}/*** Hook in after ProcesssPageView::viewable account for specific language versions** May be passed a Language name or page to check viewable for that language** @param HookEvent $event**/public function hookPageViewable(HookEvent $event) {// if page was already determined not viewable then do nothing furtherif(!$event->return) return;$page = $event->object; /** @var Page $page */$language = $event->arguments(0); /** @var Language|Field|Pagefile|string|bool $language */if(!$language) return;if(is_string($language)) {// can be a language name or a field name (we only want language name)$language = $this->wire()->sanitizer->pageNameUTF8($language);$language = strlen($language) ? $this->wire()->languages->get($language) : null;}// some other non-language argument was sent to Page::viewable()if(!$language instanceof Language) return;// we accept the result of the original viewable() call for default languageif($language->isDefault()) return;$status = (int) $page->get("status$language");$event->return = $status > 0 && $status < Page::statusUnpublished;}/*** Hook into WirePageEditor (i.e. ProcessPageEdit) to remove the non-applicable default home name of 'home'** @param HookEvent $event**/public function hookWirePageEditorExecute(HookEvent $event) {/** @var WirePageEditor $editor */$editor = $event->object;$page = $editor->getPage();// filter out everything but homepage (id=1)if(!$page || !$page->id || $page->id > 1) return;// if homepage has the defaultRootName then make the name blankif($page->name == Pages::defaultRootName) $page->name = '';}/*** Hook into the page name render for when in ProcessPageEdit** Adds additional inputs for each language** @param HookEvent $event**/public function hookInputfieldPageNameRenderAfter(HookEvent $event) {/** @var InputfieldPageName $inputfield */$inputfield = $event->object;if($inputfield->languageSupportLabel) return; // prevent recursion$process = $this->process;$page = $process instanceof WirePageEditor ? $process->getPage() : new NullPage();if(!$page->id && $inputfield->editPage) $page = $inputfield->editPage;$template = $page->template ? $page->template : null;if($template && $template->noLang) return;$user = $this->wire()->user;$languages = $this->wire()->languages;$savedLanguage = $user->language;$savedValue = $inputfield->attr('value');$savedName = $inputfield->attr('name');$savedID = $inputfield->attr('id');$trackChanges = $inputfield->trackChanges();$inputfield->setTrackChanges(false);$checkboxLabel = $this->_('Active?');$out = '';$language = $languages->getDefault();$user->setLanguage($language);$inputfield->languageSupportLabel = $language->get('title|name');$out .= $inputfield->render();$editable = true;if($page->id && !$page->editable('name', false)) $editable = false;// add labels and inputs for other languagesforeach($languages as $language) {/** @var Language $language */if($language->isDefault()) continue;$user->setLanguage($language);$value = $page->get("name$language");if(is_null($value)) $value = $savedValue;$id = "$savedID$language";$name = "$savedName$language";$label = $language->get('title|name');$inputfield->languageSupportLabel = $label;$inputfield->attr('id', $id);$inputfield->attr('name', $name);$inputfield->attr('value', $value);$inputfield->checkboxName = "status" . $language->id;$inputfield->checkboxValue = 1;$inputfield->checkboxLabel = $checkboxLabel;if($page->id > 0) {$inputfield->checkboxChecked = $page->get($inputfield->checkboxName) > 0;} else if($inputfield->parentPage) {$inputfield->checkboxChecked = $inputfield->parentPage->get($inputfield->checkboxName) > 0;}if(!$editable) $inputfield->attr('disabled', 'disabled');$out .= $inputfield->render();}// restore language that was saved in the 'before' hook$user->setLanguage($savedLanguage);// restore Inputfield values back to what they were$inputfield->attr('name', $savedName);$inputfield->attr('savedID', $savedID);$inputfield->attr('value', $savedValue);$inputfield->setTrackChanges($trackChanges);$event->return = $out;}/*** Process the input data from hookInputfieldPageNameRender** @todo Just move this to the InputfieldPageName module rather than using hooks** @param HookEvent $event**/public function hookInputfieldPageNameProcess(HookEvent $event) {$inputfield = $event->object; /** @var InputfieldPageName $inputfield */$process = $this->process; /** @var WirePageEditor $process */$page = $process instanceof WirePageEditor ? $process->getPage() : new NullPage(); /** @var Page $page */if($page->id && !$page->editable('name', false)) return; // name is not editable$input = $event->arguments[0]; /** @var WireInputData $input */$languages = $this->wire()->languages;$sanitizer = $this->wire()->sanitizer;foreach($languages as $language) {/** @var Language $language */if($language->isDefault()) continue;if(!$languages->editable($language)) continue;// set language status$key = "status" . (int) $language->id;$value = (int) $input->{"$key$inputfield->checkboxSuffix"};if($page->get($key) != $value) {$inputfield->trackChange($key);$inputfield->trackChange('value');if($page->id) {$page->set($key, $value);} else {$page->setQuietly($key, $value);}}// set language page name$name = $inputfield->attr('name') . $language;$value = $sanitizer->pageNameUTF8($input->$name);// if it matches the value for the default language, avoid double storing itif($value === $page->name) $value = '';// if it matches the value already on the page, then no need to go further$key = "name$language";if($value == $page->get($key)) continue;$parentID = $page->parent_id;if(!$parentID) $parentID = (int) $this->wire()->input->post('parent_id');if(!$this->checkLanguagePageName($language, $page, $parentID, $value, $inputfield)) continue;if($page->id) {$page->set($key, $value);} else {$page->setQuietly($key, $value); // avoid non-template exception when new page}}}/*** Check changed page name for given language** @param Language $language* @param Page $page* @param int $parentID* @param string $value New page name* @param Wire|null $errorTarget Object to send error to (Inputfield likely)* @return bool True if all good, false if not**/public function checkLanguagePageName(Language $language, Page $page, $parentID, $value, Wire $errorTarget = null) {// verify that it does not conflict with another page inheriting name from default language$isValid = true;$nameKey = "name$language->id";if(!strlen($value)) return true;if($this->wire()->config->pageNameCharset == 'UTF8') {$value = $this->wire()->sanitizer->pageName($value, Sanitizer::toAscii);}$sql ="SELECT id, name, $nameKey FROM pages " ."WHERE parent_id=:parent_id " ."AND id!=:id " ."AND (" ."(name=:newName AND $nameKey IS NULL) " . // default name matches and lang name inherits it (is null)"OR ($nameKey=:newName2)" . // or lang name is same as requested one")";$query = $this->wire()->database->prepare($sql);$query->bindValue(':parent_id', $parentID, \PDO::PARAM_INT);$query->bindValue(':newName', $value);$query->bindValue(':newName2', $value);$query->bindValue(':id', $page->id, \PDO::PARAM_INT);try {$query->execute();$row = $query->fetch(\PDO::FETCH_ASSOC);if($row) {$isValid = false;if($errorTarget) $errorTarget->error(sprintf($this->_('A sibling page (id=%1$d) is already using name "%2$s" for language: %3$s'),$row['id'], $value, $language->get('title|name')));}} catch(\Exception $e) {$this->error($e->getMessage());$isValid = false;}$query->closeCursor();return $isValid;}/*** Hook into PageFinder::getQuery to add language status check** @param HookEvent $event**/public function hookPageFinderGetQuery(HookEvent $event) {$query = $event->return;/** @var PageFinder $pageFinder */$pageFinder = $event->object;$options = $pageFinder->getOptions();// don't enforce language status check with findAll is activeif(!empty($options['findAll'])) return;// don't apply exclusions when output formatting is offif(!$this->wire()->pages->outputFormatting) return;$language = $this->wire()->user->language;if(!$language || $language->isDefault()) return;$status = "status" . (int) $language->id;$query->where("pages.$status>0");}/*** Hook into Page::path to localize path for current language** @param HookEvent $event**/public function hookPagePath(HookEvent $event) {/** @var Page $page */$page = $event->object;if($page->template->name == 'admin') return;$language = $this->wire()->user->language;if(!$language) $language = $this->wire()->languages->getDefault();$event->return = $this->getPagePath($page, $language);}/*** Add a Page::localName function with optional $language as argument** event param Language|string|int|bool Optional language, or boolean true for behavior of 2nd argument.* event param bool Substitute default language page name when page name is not defined for requested language.* event return string Localized language name or blank if not set** @param HookEvent $event**/public function hookPageLocalName(HookEvent $event) {/** @var Page $page */$page = $event->object;$language = $this->getLanguage($event->arguments(0));$nameField = $language->isDefault() ? "name" : "name$language";$value = $page->get($nameField);if(is_null($value)) $value = '';if(empty($value) && $nameField !== 'name' && ($event->arguments(0) === true || $event->arguments(1) === true)) {$value = $page->name;}$event->return = $value;}/*** Add a Page::localPath function with optional $language as argument** event param Language|string|int Optional language* event return string Localized language path** @param HookEvent $event**/public function hookPageLocalPath(HookEvent $event) {/** @var Page $page */$page = $event->object;$language = $this->getLanguage($event->arguments(0));$event->return = $this->getPagePath($page, $language);}/*** Add a Page::localUrl function with optional $language as argument** event param Language|string|int Optional language* event return string Localized language URL** @param HookEvent $event**/public function hookPageLocalUrl(HookEvent $event) {/** @var Page $page */$page = $event->object;$language = $this->getLanguage($event->arguments(0));$event->return = $this->wire()->config->urls->root . ltrim($this->getPagePath($page, $language), '/');}/*** Add a Page::localHttpUrl function with optional $language as argument** event param Language|string|int Optional language* event return string Localized language name or blank if not set** @param HookEvent $event**/public function hookPageLocalHttpUrl(HookEvent $event) {$this->hookPageLocalUrl($event);$url = $event->return;$event->return = $this->wire()->input->scheme() . "://" . $this->wire()->config->httpHost . $url;}/*** Given an object, integer or string, return the Language object instance** @param int|string|Language* @return Language**/protected function getLanguage($language) {if(is_object($language)) {if($language instanceof Language) return $language;$language = '';}$languages = $this->wire()->languages;if($language && (is_string($language) || is_int($language))) {if(ctype_digit("$language")) {$language = (int) $language;} else {$language = $this->wire()->sanitizer->pageNameUTF8($language);}$language = $languages->get($language);}if(!$language instanceof Language || !$language->id) {$language = $languages->getDefault();}return $language;}/*** Update pages table for new column when a language is added** @param Language|Page $language**/public function languageAdded(Page $language) {static $languagesAdded = array();if(!$language->id || $language->name == 'default') return;if($language instanceof Language && $language->isDefault()) return;if(isset($languagesAdded[$language->id])) return;$name = "name" . (int) $language->id;$status = "status" . (int) $language->id;$database = $this->wire()->database;$errors = 0;$sqls = array("Add column $name" => "ALTER TABLE pages ADD $name VARCHAR(" . Pages::nameMaxLength . ") CHARACTER SET ascii","Add index for $name" => "ALTER TABLE pages ADD INDEX parent_{$name} (parent_id, $name)","Add column $status" => "ALTER TABLE pages ADD $status INT UNSIGNED NOT NULL DEFAULT " . Page::statusOn,);foreach($sqls as $label => $sql) {try {$database->exec($sql);} catch(\Exception $e) {$this->error("$label: " . $e->getMessage(), Notice::log);$errors++;}}if(!$errors) $languagesAdded[$language->id] = $language->id;}/*** Hook called when language is added** @param HookEvent $event**/public function hookLanguageAdded(HookEvent $event) {$language = $event->arguments[0];$this->languageAdded($language);}/*** Update pages table to remove column when a language is deleted** @param Language|Page $language**/protected function languageDeleted(Page $language) {if(!$language->id || $language->name == 'default') return;$name = "name" . (int) $language->id;$status = "status" . (int) $language->id;$database = $this->wire()->database;try {$database->exec("ALTER TABLE pages DROP INDEX parent_$name");$database->exec("ALTER TABLE pages DROP $name");$database->exec("ALTER TABLE pages DROP $status");} catch(\Exception $e) {// $this->error($e->getMessage(), Notice::log); // error message can be ignored here}}/*** Hook called when language is deleted** @param HookEvent $event**/public function hookLanguageDeleted(HookEvent $event) {$language = $event->arguments[0];$this->languageDeleted($language);}/*** Hook called immediately before a page is saved** Here we make use of the 'extraData' return property of the saveReady hook* to bundle in the language name fields into the query.** @param HookEvent $event**/public function hookPageSaveReady(HookEvent $event) {/** @var Page $page */$page = $event->arguments[0];/** @var Pages $pages */$pages = $event->object;$sanitizer = $this->wire()->sanitizer;/** @var array $extraData */$extraData = $event->return;$alwaysActiveTypes = array('User', 'UserPage','Role', 'RolePage','Permission', 'PermissionPage','Language', 'LanguagePage',);$pageNameCharset = $this->wire()->config->pageNameCharset;$isCloning = $pages->editor()->isCloning();if(!is_array($extraData)) $extraData = array();foreach($this->wire()->languages as $language) {/** @var Language $language */if($language->isDefault()) continue;$language_id = (int) $language->id;// populate a name123 field for each language$name = "name$language_id";$value = $sanitizer->pageNameUTF8($page->get($name));if(!strlen($value)) {$value = 'NULL';} else if($isCloning) {// this save is the result of a clone() operation// make sure that the name is unique for other languages$value = $pages->names()->uniquePageName(array('name' => $value,'page' => $page,'language' => $language,));}if($pageNameCharset == 'UTF8') {$extraData[$name] = $sanitizer->pageName($value, Sanitizer::toAscii);} else {$extraData[$name] = $value;}// populate a status123 field for each language$name = "status$language_id";if(method_exists($page, 'getForPage')) {// repeater page, pull status from 'for' page$value = (int) $page->getForPage()->get($name);} else if(in_array($page->className(), $alwaysActiveTypes)) {// User, Role, Permission or Language: assume active status$value = Page::statusOn;} else {// regular page$value = (int) $page->get($name);}$extraData[$name] = $value;}$event->return = $extraData;}/*** Hook into Pages::setupNew** Used to assign a $page->name when none has been assigned, like if a user has added* a page in another language but not configured anything for default language** @param HookEvent $event**/public function hookPageSetupNew(HookEvent $event) {/** @var Page $page */$page = $event->arguments[0];// if page already has a name, then no need to continueif($page->name) return;// account for possibility that a new page with non-default language name/title exists// this prevents an exception from being thrown by Pages::save$user = $this->wire()->user;$config = $this->wire()->config;$sanitizer = $this->wire()->sanitizer;$userTrackChanges = $user->trackChanges();$userLanguage = $user->language;if($userTrackChanges) $user->setTrackChanges(false);foreach($this->wire()->languages as $language) {/** @var Language $language */if($language->isDefault()) continue;$user->setLanguage($language);$name = $page->get("name$language");if(strlen($name)) $page->name = $name;$title = $page->title;if(strlen($title)) {$page->title = $title;if(!$page->name) {if($config->pageNameCharset === 'UTF8') {$page->name = $sanitizer->pageNameUTF8($title);} else {$page->name = $sanitizer->pageName($title, Sanitizer::translate);}}}if($page->name) break;}// restore user to previous state$user->setLanguage($userLanguage);if($userTrackChanges) $user->setTrackChanges(true);}/*** Hook called immediately after a page is saved** @param HookEvent $event**/public function hookPageSaved(HookEvent $event) {// The setLanguage may get lost upon some page save events, so this restores that// $this->user->language = $this->setLanguage;$page = $event->arguments(0); /** @var Page $page */$sanitizer = $this->wire()->sanitizer;if(!$page->namePrevious) {// go into this only if we know the renamed hook hasn't already been called$renamed = false;foreach($this->wire()->languages as $language) {/** @var Language $language */if($language->isDefault()) continue;$namePrevious = $page->get("-name$language");if(!$namePrevious) continue;$name = $sanitizer->pageNameUTF8($page->get("name$language"));if($sanitizer->pageNameUTF8($namePrevious) != $name) {$renamed = true;break;}}// trigger renamed hook if one of the language names changedif($renamed) $this->wire()->pages->renamed($page);}}/*** Return the unsanitized/original requested path** @return string**/public function getRequestPath() {return $this->requestPath;}/*** Return the Language that the given path is in or null if can't determine** @param string $path Page path without without installation subdir or URL segments or page numbers* @param Page $page If you already know the $page that resulted from the path, provide it here for faster performance* @return Language|null**/public function getPagePathLanguage($path, Page $page = null) {$languages = $this->wire()->languages;$pages = $this->wire()->pages;if(!$page || !$page->id) $page = $pages->getByPath($path, array('useLanguages' => true,'useHistory' => true));$foundLanguage = null;$path = trim($path, '/');// a blank path can only be homepage in default languageif(!strlen($path)) return $languages->getDefault();// first check entire path for a matchif($page->id) {foreach($languages as $language) {$languages->setLanguage($language);if($path === trim($page->path(), '/')) $foundLanguage = $language;$languages->unsetLanguage();if($foundLanguage) break;}}if($foundLanguage) return $foundLanguage;// if we get to this point, then we'll be checking the first segment and last segment$parts = explode('/', $path);$homepageID = $this->wire()->config->rootPageID;$homepage = $pages->get($homepageID);$firstPart = reset($parts);$lastPart = end($parts);$tests = array($firstPart => $homepage);if($homepage->id != $page->id && $firstPart != $lastPart) $tests[$lastPart] = $page;foreach($tests as $part => $p) {if(!$p->id) continue;$duplicates = 0; // count duplicate names, which would invalidate any $foundLanguageforeach($languages as $language) {/** @var Language $language */$key = 'name' . ($language->isDefault() ? '' : $language->id);$name = $p->get($key);if($name === $part) {$foundLanguage = $language;$duplicates++;}}if($foundLanguage && $duplicates > 1) $foundLanguage = null;if($foundLanguage) break;}if(!$foundLanguage && $page->parent_id > $homepageID && count($parts) > 1) {// if language not yet found, go recursive on the parent path before we throw in the towelarray_pop($parts);$foundLanguage = $this->getPagePathLanguage(implode('/', $parts), $page->parent());}return $foundLanguage;}/*** Check to make sure that the status table exists and creates it if not** @param bool $force**/public function checkModuleVersion($force = false) {$info = self::getModuleInfo();if(!$force) {if($info['version'] == $this->moduleVersion) return;}$database = $this->wire()->database;// version 3 to 4 check: addition of language-specific status columns$query = $database->prepare("SHOW COLUMNS FROM pages WHERE Field LIKE 'status%'");$query->execute();if($query->rowCount() < 2) {foreach($this->wire()->languages as $language) {/** @var Language $language */if($language->isDefault()) continue;$status = "status" . (int) $language->id;$database->exec("ALTER TABLE pages ADD $status INT UNSIGNED NOT NULL DEFAULT " . Page::statusOn);$this->message("Added status column for language: $language->name", Notice::log);}}// save module version in config dataif($info['version'] != $this->moduleVersion) {$modules = $this->wire()->modules;$data = $modules->getModuleConfigData($this);$data['moduleVersion'] = $info['version'];$modules->saveModuleConfigData($this, $data);}}/*** Module interactive configuration fields** @param InputfieldWrapper $inputfields**/public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {$modules = $this->wire()->modules;$config = $this->wire()->config;$this->checkModuleVersion(true);$defaultUrlPrefix = $config->get('_pageNumUrlPrefix|pageNumUrlPrefix');foreach($this->wire()->languages as $language) {/** @var Language $language *//** @var InputfieldName $f */$f = $modules->get('InputfieldName');$name = "pageNumUrlPrefix$language";if($language->isDefault() && !$this->get($name)) $this->set($name, $defaultUrlPrefix);$f->attr('name', $name);$f->attr('value', $this->get($name));$f->label = "$language->title ($language->name) - " . $this->_('Page number prefix for pagination');$f->description = sprintf($this->_('The page number is appended to this word in paginated URLs for this language. If omitted, "%s" will be used.'),$defaultUrlPrefix);$f->required = false;$inputfields->add($f);}/** @var InputfieldRadios $f */$f = $modules->get('InputfieldRadios');$f->attr('name', 'useHomeSegment');$f->label = $this->_('Default language homepage URL is same as root URL?'); // label for the home segment option$f->description = $this->_('Choose **Yes** if you want the homepage of your default language to be served by the root URL **/** (recommended). Choose **No** if you want your root URL to perform a redirect to **/name/** (where /name/ is the default language name of your homepage).'); // description for the home segment option$f->notes = $this->_('This setting only affects the homepage behavior. If you select No, you must also make sure your homepage has a name defined for the default language.'); // notes for the home segment option$f->addOption(0, $this->_('Yes - Root URL serves default language homepage (recommended)'));$f->addOption(1, $this->_('No - Root URL performs a redirect to: /name/'));$f->attr('value', (int) $this->useHomeSegment);$inputfields->add($f);/** @var InputfieldRadios $f */$f = $modules->get('InputfieldRadios');$f->attr('name', 'redirect404');$f->label = $this->_('Behavior when page not available in requested language (but is available in default language)');$f->notes = $this->_('This setting does not apply if the page is editable to the user as it will always be available for preview purposes.');$f->addOption(0, $this->_('Throw a 404 (page not found) error - default behavior'));$f->addOption(200, $this->_('Allow it to be rendered for language anyway (if accessed directly by URL)'));$f->addOption(301, $this->_('Perform a 301 (permanent) redirect to the page in default language'));$f->addOption(302, $this->_('Perform a 302 (temporary) redirect to the page in default language'));$val = (int) $this->redirect404;if($val === 404) $val = 0;$f->val($val);$inputfields->add($f);}/*** Install the module**/public function ___install() {foreach($this->wire()->languages as $language) {$this->languageAdded($language);}}/*** Uninstall the module**/public function ___uninstall() {foreach($this->wire()->languages as $language) {$this->languageDeleted($language);}}/*** Upgrade the module** @param $fromVersion* @param $toVersion**/public function ___upgrade($fromVersion, $toVersion) {if($fromVersion && $toVersion) {} // ignore$languages = $this->wire()->languages;$database = $this->wire()->database;$sqls = array();foreach($languages as $language) {/** @var Language $language */if($language->isDefault()) continue;$name = 'name' . $language->id;if(!$database->columnExists("pages", $name)) continue;if($database->indexExists("pages", "{$name}_parent_id")) {$sqls[] = "ALTER TABLE pages DROP INDEX {$name}_parent_id";}if(!$database->indexExists("pages", "parent_{$name}")) {$sqls[] = "ALTER TABLE pages ADD INDEX parent_{$name}(parent_id, $name)";}}foreach($sqls as $sql) {try {$query = $database->prepare($sql);$query->execute();} catch(\Exception $e) {$this->warning($e->getMessage(), Notice::superuser);}}}}