'Languages Support', 'version' => 103, 'summary' => 'ProcessWire multi-language support.', 'author' => 'Ryan Cramer', 'autoload' => true, 'singular' => true, 'installs' => array( 'ProcessLanguage', 'ProcessLanguageTranslator', ), 'addFlag' => Modules::flagsNoUserConfig ); } /** * Name of template used for language pages * */ const languageTemplateName = 'language'; /** * Name of field used to store the language page ref * */ const languageFieldName = 'language'; /** * This module can possibly be init'd before PW's Modules class fully loads, so we keep this to prevent double initialization * */ protected $initialized = false; /** * Reference to the default language page * * @var Language|null * */ protected $defaultLanguagePage = null; /** * Array of pages that were cached before this module was loaded. * * @var array * */ protected $earlyCachedPages = array(); /** * Instanceof LanguageSupportFields, if installed * * @var LanguageSupportFields|null * */ protected $LanguageSupportFields = null; /** * Instanceof LanguageTabs, if installed * * @var LanguageTabs|null * */ protected $languageTabs = null; /** * Construct and set our dynamic config vars * */ public function __construct() { parent::__construct(); $this->set('initialized', false); // load other required classes $dirname = dirname(__FILE__); require_once($dirname . '/FieldtypeLanguageInterface.php'); require_once($dirname . '/Language.php'); require_once($dirname . '/Languages.php'); require_once($dirname . '/LanguageTranslator.php'); require_once($dirname . '/LanguagesValueInterface.php'); require_once($dirname . '/LanguagesPageFieldValue.php'); // set our config var placeholders $this->set('languagesPageID', 0); $this->set('defaultLanguagePageID', 0); $this->set('languageTranslatorPageID', 0); // quick reference to non-default language IDs, for when needed before languages loaded $this->set('otherLanguagePageIDs', array()); } /** * Initialize the language support API vars * */ public function init() { $pages = $this->wire()->pages; $user = $this->wire()->user; $config = $this->wire()->config; $templates = $this->wire()->templates; $modules = $this->wire()->modules; // document which pages were already cached at this point, as their values may need // to be reloaded to account for language fields. foreach($pages->getCache() as $id => $value) { $this->earlyCachedPages[$id] = $value; } // prevent possible double init if($this->initialized) return; $this->initialized = true; FieldtypePageTitle::$languageSupport = true; $defaultLanguagePageID = $this->defaultLanguagePageID; // create the $languages API var $languageTemplate = $templates->get('language'); if(!$languageTemplate) return; if(!$this->languagesPageID) { // fallback if LanguageSupport config lost or not accessible for some reason $this->languagesPageID = $pages->get("template=admin, name=languages"); } // prevent fields like 'title' from autojoining until languages are fully loaded $pages->setAutojoin(false); /** @var Languages $languages */ $languages = $this->wire(new Languages($this->wire('wire'), $languageTemplate, $this->languagesPageID)); $_default = null; // just in case // ensure all languages are loaded and get instantiated versions of system/default languages $numOtherLanguages = 0; foreach($languages as $language) { if($language->id == $defaultLanguagePageID) { $this->defaultLanguagePage = $language; } else if($language->name === 'default') { $_default = $language; // backup plan } else { $numOtherLanguages++; PageProperties::$languageProperties["name$language->id"] = array('name', $language->id); PageProperties::$languageProperties["status$language->id"] = array('status', $language->id); } } if(!$this->defaultLanguagePage) { if($_default) { $this->defaultLanguagePage = $_default; } else { $this->defaultLanguagePage = $languages->getAll()->first(); } } $this->defaultLanguagePage->setIsDefaultLanguage(); $languages->setDefault($this->defaultLanguagePage); // set $languages API variable $this->wire('languages', $languages); // identify the current language from the user, or set one if it's not already if($user->language && $user->language->id) { // $language = $this->user->language; } else { $language = $this->defaultLanguagePage; $user->language = $language; } $config->dateFormat = $this->_('Y-m-d H:i:s'); // Sortable date format used in the admin $locale = $this->_('C'); // Value to pass to PHP's setlocale(LC_ALL, 'value') function when initializing this language // Default is 'C'. Specify '0' to skip the setlocale() call (and carry on system default). Specify CSV string of locales to try multiple locales in order. if($locale != '0') $languages->setLocale(LC_ALL, $locale); // setup our hooks handled by this class $this->addHookBefore('Inputfield::render', $this, 'hookInputfieldBeforeRender'); $this->addHookBefore('Inputfield::renderValue', $this, 'hookInputfieldBeforeRender'); $this->addHookAfter('Inputfield::render', $this, 'hookInputfieldAfterRender'); $this->addHookAfter('Inputfield::renderValue', $this, 'hookInputfieldAfterRender'); $this->addHookAfter('Inputfield::processInput', $this, 'hookInputfieldAfterProcessInput'); $this->addHookBefore('Inputfield::processInput', $this, 'hookInputfieldBeforeProcessInput'); $this->addHookAfter('Field::getInputfield', $this, 'hookFieldGetInputfield'); $pages->addHook('added', $this, 'hookPageAdded'); $pages->addHook('deleteReady', $this, 'hookPageDeleteReady'); $this->addHook('Page::setLanguageValue', $this, 'hookPageSetLanguageValue'); $this->addHook('Page::getLanguageValue', $this, 'hookPageGetLanguageValue'); if($modules->isInstalled('LanguageSupportFields')) { $this->LanguageSupportFields = $modules->get('LanguageSupportFields'); $this->LanguageSupportFields->LS_init(); if($languages->getPageEditPermissions('none') && !$user->hasPermission('page-edit-lang-none')) { $this->addHookBefore('InputfieldWrapper::renderInputfield', $this, 'hookInputfieldWrapperBeforeRenderInputfield'); } } if($numOtherLanguages && $numOtherLanguages != count($this->otherLanguagePageIDs)) { $this->refreshLanguageIDs(); } // restore autojoin state for pages $pages->setAutojoin(true); } /** * Called by ProcessWire when API is fully ready with known $page * */ public function ready() { $page = $this->wire()->page; // styles used by our Inputfield hooks if($page->template->name === 'admin') { $config = $this->wire()->config; $config->styles->add($config->urls('LanguageSupport') . "LanguageSupport.css"); $language = $this->wire()->user->language; if($language) $config->js('LanguageSupport', array( 'language' => array( 'id' => $language->id, 'name' => $language->name, 'title' => (string) $language->title, ) )); $modules = $this->wire()->modules; if($modules->isInstalled('LanguageTabs')) { $this->languageTabs = $modules->get('LanguageTabs'); } } // if languageSupportFields is here, then we have to deal with pages that loaded before this module did if($this->LanguageSupportFields) { // save the names of all fields that support languages $fieldNames = $this->LanguageSupportFields->getMultilangFieldNames(); // unset the values from all the early cached pages since they didn't recognize languages // this will force them to reload when accessed foreach($this->earlyCachedPages as /* $id => */ $p) { $t = $p->trackChanges(); if($t) $p->setTrackChanges(false); foreach($fieldNames as $name) unset($p->$name); if($t) $p->setTrackChanges(true); } } // release this as we don't need it anymore $this->earlyCachedPages = array(); if($this->LanguageSupportFields) $this->LanguageSupportFields->LS_ready(); } /** * Returns whether or not Inputfield is editable for current user in language context * * Takes the page-edit-lang-none permission into account * * @param Inputfield $inputfield * @return bool * */ protected function editableInputfield(Inputfield $inputfield) { $page = $this->wire()->page; $alwaysAllowInputfields = array( 'InputfieldWrapper', 'InputfieldPageName', 'InputfieldSubmit', 'InputfieldButton', 'InputfieldHidden', ); // ignore this call if in ProcessProfile if($page->process == 'ProcessProfile') return true; if($page->process == 'ProcessLanguage') return true; $user = $this->wire()->user; $languages = $this->wire()->languages; if($user->isSuperuser()) return true; if($inputfield->getSetting('useLanguages')) return true; if(!$this->LanguageSupportFields) return true; $permissions = $languages->getPageEditPermissions(); if(!isset($permissions['none'])) return true; if(!$this->wire()->process instanceof WirePageEditor) return true; if($inputfield->name == 'delete_page') return true; $allow = false; foreach($alwaysAllowInputfields as $type) { $type = __NAMESPACE__ . "\\$type"; if($inputfield instanceof $type) { $allow = true; break; } } if($allow) return true; if($inputfield->hasFieldtype && $this->LanguageSupportFields->isAlternateField($inputfield->name)) return true; if($languages->editable('none')) return true; return false; } /** * Hook before Inputfield::render to set proper default language value * * Only applies to Inputfields that have: useLanguages == true * * @param HookEvent $event * */ public function hookInputfieldBeforeRender(HookEvent $event) { /** @var Inputfield $inputfield */ $inputfield = $event->object; if(!$inputfield->useLanguages) return; $user = $this->wire()->user; $userLanguage = $user->language; if(!$userLanguage) return; // set 'value' attribute to default language values if($userLanguage->id !== $this->defaultLanguagePageID) { $t = $inputfield->trackChanges(); if($t) $inputfield->setTrackChanges(false); $inputfield->attr('value', $inputfield->get('value' . $this->defaultLanguagePageID)); if($t) $inputfield->setTrackChanges(true); } } /** * Hook before InputfieldWrapper::renderInputfield * * Only applies to Inputfields that have: useLanguages == false. * Applies only if page-edit-lang-none permission is installed. * * @param HookEvent $event * */ public function hookInputfieldWrapperBeforeRenderInputfield(HookEvent $event) { /** @var Inputfield $inputfield */ $inputfield = $event->arguments(0); if($inputfield->getSetting('useLanguages')) return; $renderValueMode = $event->arguments(1); if(!$this->editableInputfield($inputfield) && !$renderValueMode) { $event->return = ''; $event->replace = true; } } /** * Wrap the inputfield output with a language name label * * @param string $out Existing inputfield output * @param string $id ID attribute to use * @param Language $language * @return string * */ public function wrapInputfieldOutput($out, $id, Language $language) { $label = (string) $language->title; if(!strlen($label)) $label = $language->name; $class = 'LanguageSupport'; $labelClass = 'LanguageSupportLabel detail'; if(!$this->wire()->languages->editable($language)) { $labelClass .= ' LanguageNotEditable'; $class .= ' LanguageNotEditable'; $label = "$label"; $out = "

" . sprintf($this->_('Changes to this field will not be saved because you do not have permission for language: %s.'), $language->get('title|name')) . "

" . $out; } $out = "
" . "" . $out . "
"; return $out; } /** * Hook into Inputfield::render to duplicate inputs for other languages * * Only applies to Inputfields that have: useLanguages == true * * @param HookEvent $event * */ public function hookInputfieldAfterRender(HookEvent $event) { static $numLanguages = null; if(!$event->return) return; // if already empty, nothing to do /** @var Inputfield $inputfield */ $inputfield = $event->object; $name = $inputfield->attr('name'); $renderValueMode = $event->method == 'renderValue'; $languages = $this->wire()->languages; if(is_null($numLanguages)) $numLanguages = $languages->count(); // provide an automatic translation for some system/default fields if they've not been overridden in the fields editor if($name == 'language' && $inputfield->label == 'Language') { $inputfield->label = $this->_('Language'); // Label for 'language' field in user profile } else if($name == 'email' && $inputfield->label == 'E-Mail Address') { $inputfield->label = $this->_('E-Mail Address'); // Label for 'email' field in user profile } else if($name == 'title' && $inputfield->label == 'Title') { $inputfield->label = $this->_('Title'); // Label for 'title' field used throughout ProcessWire } // check if this is a language alternate field (i.e. title_es or title) if($this->LanguageSupportFields) { $language = $this->LanguageSupportFields->isAlternateField($name); if($language) { $event->return = $this->wrapInputfieldOutput($event->return, $inputfield->attr('id'), $language); return; } } if(!$inputfield->getSetting('useLanguages') || $numLanguages < 2) return; // keep originals to restore later (including $name, which we already got above) $id = $inputfield->attr('id'); $value = $inputfield->attr('value'); $required = $inputfield->required; $collapsed = $inputfield->collapsed; $trackChanges = $inputfield->trackChanges(); $inputfield->setTrackChanges(false); if($this->languageTabs) $this->languageTabs->resetTabs(); $out = ''; foreach($languages as $language) { $languageID = (int) $language->id; $languages->setLanguage($language); if($language->isDefault) { // default language $newID = $id; $o = $event->return; $inputfield->attr('id', $newID); } else { // non-default language $newID = $id . "__$languageID"; $newName = $name . "__$languageID"; $inputfield->attr('id', $newID); $inputfield->attr('name', $newName); $valueAttr = "value$languageID"; $inputfield->required = false; $inputfield->setAttribute('value', $inputfield->$valueAttr); $o = $renderValueMode ? $inputfield->___renderValue() : $inputfield->___render(); } $languages->unsetLanguage(); if($collapsed == Inputfield::collapsedBlank && !$inputfield->isEmpty()) { $inputfield->collapsed = Inputfield::collapsedNo; } $out .= $this->wrapInputfieldOutput($o, $newID, $language); if($this->languageTabs) $this->languageTabs->addTab($inputfield, $language); } $inputfield->setAttribute('name', $name); $inputfield->setAttribute('id', $id); $inputfield->setAttribute('value', $value); $inputfield->required = $required; $inputfield->setTrackChanges($trackChanges); if($this->languageTabs) { $out = $this->languageTabs->renderTabs($inputfield, $out); } $event->return = $out; } /** * Hook before Inputfield::processInput to process input for other languages (or prevent it) * * @param HookEvent $event * */ public function hookInputfieldBeforeProcessInput(HookEvent $event) { /** @var Inputfield $inputfield */ $inputfield = $event->object; $replace = false; if($inputfield->getSetting('useLanguages') || $inputfield->getSetting('hasLanguages')) { // multi-language field $this->hookInputfieldBeforeRender($event); // ensures default language values are populated if(!$this->wire()->languages->editable($this->defaultLanguagePage)) $replace = true; } else { // not a native multi-language field, check if it's language alternate or not editable if(!$this->editableInputfield($inputfield)) { $replace = true; } else if($inputfield->hasFieldtype && $this->LanguageSupportFields) { $language = $this->LanguageSupportFields->isAlternateField($inputfield->name); if($language && !$this->wire()->languages->editable($language)) $replace = true; } } if($replace) { // if field or language not editable, prevent processInput from running $event->replace = true; $event->return = $inputfield; } } /** * Hook into Inputfield::processInput to process input for other languages * * Only applies to Inputfields that have: useLanguages == true * * @param HookEvent $event * */ public function hookInputfieldAfterProcessInput(HookEvent $event) { /** @var Inputfield $inputfield */ $inputfield = $event->object; if(!$inputfield->getSetting('useLanguages')) return; $post = $event->arguments[0]; $languages = $this->wire()->languages; // originals $name = $inputfield->attr('name'); $id = $inputfield->attr('id'); $value = $inputfield->attr('value'); $required = $inputfield->required; // process and set value for each language foreach($languages as $language) { // default language was already handled if($language->isDefault()) continue; // if language isn't editable, don't process it if(!$languages->editable($language)) continue; $languageID = (int) $language->id; $newID = $id . "__$languageID"; $newName = $name . "__$languageID"; $inputfield->setTrackChanges(false); $inputfield->attr('id', $newID); $inputfield->attr('name', $newName); // other language values not required, even if default language value is $inputfield->required = false; $valueAttr = "value$languageID"; $inputfield->attr('value', $inputfield->$valueAttr); $inputfield->setTrackChanges(true); $inputfield->___processInput($post); $inputfield->set($valueAttr, $inputfield->attr('value')); } // restore originals $inputfield->setTrackChanges(false); $inputfield->setAttribute('name', $name); $inputfield->setAttribute('id', $id); $inputfield->setAttribute('value', $value); $inputfield->required = $required; $inputfield->setTrackChanges(true); } /** * Hook into Field::getInputfield to change label/description to proper language * * @param HookEvent $event * */ public function hookFieldGetInputfield(HookEvent $event) { $language = $this->wire()->user->language; if(!$language || !$language->id) return; $field = $event->object; /** @var Field $field */ $page = $event->arguments[0]; /** @var Page $page */ $template = $page ? $page->template : null; /** @var Template|null $template */ $inputfield = $event->return; /** @var Inputfield $inputfield */ if(!$inputfield) return; $translatable = array('label', 'description', 'notes'); if($inputfield->attr('placeholder') !== null && $this->wire()->process != 'ProcessField') { $translatable[] = 'placeholder'; } $languages = $template ? $template->getLanguages() : $this->wire()->languages; $useLanguages = $template && $template->noLang ? false : true; if(!$languages) $languages = $this->wire()->languages; // populate language versions where available foreach($translatable as $key) { $langKey = $key . $language->id; // i.e. label1234 $value = $field->$langKey; if(!$value) continue; $inputfield->$key = $value; } // see if this fieldtype supports languages natively if($field->type instanceof FieldtypeLanguageInterface && $useLanguages) { // populate useLanguages in the inputfield so we can detect it elsehwere $inputfield->set('useLanguages', true); $value = $page->get($field->name); // set values in this field specific to each language foreach($languages as $language) { $languageValue = ''; if($value instanceof LanguagesPageFieldValue) { $languageValue = $value->getLanguageValue($language->id); } else { if($language->isDefault) $languageValue = $value; } $inputfield->set('value' . $language->id, $languageValue); } // following this hookInputfieldBeforeRender() completes the process after // Fieldgroup::getPageInputfields() which sets the value attribute of Inputfields } $event->return = $inputfield; } /** * Hook called when new language added * * @param HookEvent $event * */ public function hookPageAdded(HookEvent $event) { /** @var Page $page */ $page = $event->arguments[0]; if($page->template->name != self::languageTemplateName) return; // trigger hook in $languages $ids = $this->otherLanguagePageIDs; $ids[] = $page->id; $this->set('otherLanguagePageIDs', $ids); $this->wire()->languages->added($page); // save this as a known language page with module settings // this is a shortcut used to identify language pages before the API is fully ready $modules = $this->wire()->modules; $configData = $modules->getModuleConfigData('LanguageSupport'); $configData['otherLanguagePageIDs'][] = $page->id; $modules->saveModuleConfigData('LanguageSupport', $configData); } /** * Hook called when language is deleted * * @param HookEvent $event * */ public function hookPageDeleteReady(HookEvent $event) { /** @var Page $page */ $page = $event->arguments[0]; if($page->template->name != self::languageTemplateName) return; $language = $page; // remove any language-specific values from any fields foreach($this->wire()->fields as $field) { /** @var Field $field */ $changed = false; foreach(array('label', 'description', 'notes') as $name) { $name = $name . $language->id; if(!isset($field->$name)) continue; $field->remove($name); $this->message("Removed $language->name $name from field $field->name"); $changed = true; } if($changed) $field->save(); } // remove template labels foreach($this->wire()->templates as $template) { /** @var Template $template */ $name = 'label' . $page->id; if(isset($template->$name)) { $template->remove($name); $template->save(); $this->message("Removed $language->name label from template $template->name"); } } // trigger hook in $languages $this->wire()->languages->deleted($page); // update the other language module IDs to remove the uninstalled language $modules = $this->wire()->modules; $configData = $modules->getModuleConfigData('LanguageSupport'); $key = array_search($page->id, $configData['otherLanguagePageIDs']); if($key !== false) { unset($configData['otherLanguagePageIDs'][$key]); $modules->saveModuleConfigData('LanguageSupport', $configData); } } /** * Adds a Page::setLanguageValue($language, $fieldName, $value) method * * Provides a common interface for setting all language values to a Page. * * This method exists in this class rather than one of the field-specific classes * because it deals with both language fields and page names, and potentially * other types of unknown types that implement LanguagesValueInterface. * * @param HookEvent $event * @throws WireException * */ public function hookPageSetLanguageValue(HookEvent $event) { $page = $event->object; /** @var Page $page */ $language = $event->arguments(0); /** @var Language $language */ $field = $event->arguments(1); /** @var string|Field $field */ $value = $event->arguments(2); $languages = $this->wire()->languages; $event->return = $page; if(!is_object($language)) { if(ctype_digit("$language")) $language = (int) $language; $language = $languages ? $languages->get($language) : null; } if(!$language instanceof Language) { throw new WireException('Unknown language set to Page::setLanguageValue'); } if($field === 'name' || $field === 'status') { // set page name or status if($languages && !$languages->hasPageNames()) { throw new WireException("Please install LanguageSupportPageNames module before attempting to set multi-language names/paths/status/URLs."); } if($language->isDefault()) { $page->set($field, $value); } else { $page->set("$field$language->id", $value); } } else { if(is_object($field)) $field = $field->name; $previousValue = $page->get($field); if($previousValue instanceof LanguagesValueInterface) { // utilize existing set methods available in LanguagesValueInterface (which might be slightly quicker than the else condition method if($value instanceof LanguagesValueInterface) { // if given a LanguagesPageFieldValue, then just set it to the page $page->set($field, $value); } else { // otherwise use existing setLanguageValue method provided by LanguagesValueInterface $previousValue->setLanguageValue($language->id, $value); } } else { // temporarily set user's language to field language, set the field value, then set user's language back // we don't know what exactly $field might be, whether custom field or some other field, but we'll set it anyway $user = $this->wire()->user; $userLanguage = $user->language->id != $language->id ? $user->language : null; if($userLanguage) $user->language = $language; $page->set($field, $value); if($userLanguage) $user->language = $userLanguage; } } } /** * Adds a Page::getLanguageValue($language, $fieldName) method * * Provides a common interface for getting all language values from a Page. * * This method exists in this class rather than one of the field-specific classes * because it deals with both language fields and page names, and potentially * other types of unknown types that implement LanguagesValueInterface. * * @param HookEvent $event * @throws WireException * */ public function hookPageGetLanguageValue(HookEvent $event) { $page = $event->object; /** @var Page $page */ $language = $event->arguments(0); /** @var Language $language */ $field = $event->arguments(1); /** @var string|Field $field */ if(!is_object($language)) { if(ctype_digit("$language")) $language = (int) $language; $language = $this->wire()->languages->get($language); } if(!$language instanceof Language) { throw new WireException('Unknown language sent to Page::getLanguageValue'); } if($field === 'name') { // get a page name if($language->isDefault()) { $value = $page->name; } else { $value = $page->get("name$language->id"); } } else { if(is_object($field)) $field = $field->name; $value = $page->get($field); if($value instanceof LanguagesValueInterface) { $value = $value->getLanguageValue($language->id); } else { // temporarily set user's language to field language, get the field value, then set user's language back $user = $this->wire()->user; $userLanguage = $user->language->id != $language->id ? $user->language : null; if($userLanguage) $user->language = $language; $value = $page->get($field); if($userLanguage) $user->language = $userLanguage; } } $event->return = $value; } /** * Module configuration screen * * @param array $data * @return InputfieldWrapper * */ public function getModuleConfigInputfields(array $data) { if($data) { } require(dirname(__FILE__) . '/LanguageSupportInstall.php'); /** @var LanguageSupportInstall $installer */ $installer = $this->wire(new LanguageSupportInstall()); return $installer->getModuleConfigInputfields(); } /** * Refresh the config stored value for $this->otherLanguagePageIDs * */ public function refreshLanguageIDs() { $languages = $this->wire()->languages; $modules = $this->wire()->modules; $this->message('Refreshing other language page IDs', Notice::debug); if(!$languages) return; $ids = array(); foreach($languages as $language) { if($language->isDefault()) continue; $ids[] = $language->id; } if($this->otherLanguagePageIDs != $ids) { $this->set('otherLanguagePageIDs', $ids); $configData = $modules->getModuleConfigData('LanguageSupport'); if($configData['otherLanguagePageIDs'] != $ids) { $configData['otherLanguagePageIDs'] = $ids; $modules->saveModuleConfigData('LanguageSupport', $configData); } } } /** * Install or uninstall by loading the LanguageSupportInstall script * * @param bool $install * */ protected function installer($install = true) { require_once(dirname(__FILE__) . '/LanguageSupportInstall.php'); /** @var LanguageSupportInstall $installer */ $installer = $this->wire(new LanguageSupportInstall()); if($install) $installer->install(); else $installer->uninstall(); } /** * Get the LanguageTabs module instance, if it is installed, or null if not * * @return LanguageTabs|null * */ public function getLanguageTabs() { return $this->languageTabs; } /** * Install the module * */ public function ___install() { $this->installer(true); } /** * Uninstall the module * */ public function ___uninstall() { $this->installer(false); } }