'Languages Support', 'version' => 103, 'summary' => 'ProcessWire multi-language support.', 'author' => 'Ryan Cramer', 'autoload' => true, 'singular' => true, 'installs' => array( 'ProcessLanguage', 'ProcessLanguageTranslator', ) ); } /** * 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 * */ protected $defaultLanguagePage = null; /** * Array of pages that were cached before this module was loaded. * */ protected $earlyCachedPages = array(); /** * Instanceof LanguageSupportFields, if installed * */ protected $LanguageSupportFields = null; /** * Instanceof LanguageTabs, if installed * */ protected $languageTabs = null; /** * Construct and set our dynamic config vars * */ public function __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() { // document which pages were already cached at this point, as their values may need // to be reloaded to account for language fields. foreach(wire('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 = $this->templates->get('language'); if(!$languageTemplate) return; $languages = new Languages($languageTemplate, $this->languagesPageID); $_default = null; // just in case // ensure all languages are loaded and get instantiated versions of system/default languages foreach($languages as $language) { if($language->id == $defaultLanguagePageID) { $this->defaultLanguagePage = $language; } else if($language->name == 'default') { $_default = $language; // backup plan } } if(!$this->defaultLanguagePage) { if($_default) $this->defaultLanguagePage = $_default; else $this->defaultLanguagePage = $languages->getAll()->first(); } $this->defaultLanguagePage->setIsDefaultLanguage(); $languages->setDefault($this->defaultLanguagePage); Wire::setFuel('languages', $languages); // identify the current language from the user, or set one if it's not already if($this->user->language && $this->user->language->id) { $language = $this->user->language; } else { $language = $this->defaultLanguagePage; $this->user->language = $language; } wire('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). if($locale != '0') setlocale(LC_ALL, $locale); // setup our hooks handled by this class $this->addHookBefore('Inputfield::render', $this, 'hookInputfieldBeforeRender'); $this->addHookAfter('Inputfield::render', $this, 'hookInputfieldAfterRender'); $this->addHookAfter('Inputfield::processInput', $this, 'hookInputfieldAfterProcessInput'); $this->addHookBefore('Inputfield::processInput', $this, 'hookInputfieldBeforeProcessInput'); $this->addHookAfter('Field::getInputfield', $this, 'hookFieldGetInputfield'); $this->pages->addHook('added', $this, 'hookPageAdded'); $this->pages->addHook('deleteReady', $this, 'hookPageDeleteReady'); $this->addHook('Page::setLanguageValue', $this, 'hookPageSetLanguageValue'); $this->addHook('Page::getLanguageValue', $this, 'hookPageGetLanguageValue'); if($this->wire('modules')->isInstalled('LanguageSupportFields')) { $this->LanguageSupportFields = wire('modules')->get('LanguageSupportFields'); $this->LanguageSupportFields->LS_init(); } } /** * Called by ProcessWire when API is fully ready with known $page * */ public function ready() { // styles used by our Inputfield hooks if($this->wire('page')->template == 'admin') { $this->config->styles->add($this->config->urls->LanguageSupport . "LanguageSupport.css"); $language = $this->wire('user')->language; $this->config->js('LanguageSupport', array( 'language' => array( 'id' => $language->id, 'name' => $language->name, 'title' => (string) $language->title, ) )); if($this->wire('modules')->isInstalled('LanguageTabs')) { $this->languageTabs = $this->wire('modules')->get('LanguageTabs'); } } // if languageSupportFields is here, then we have to deal with pages that loaded before this module did if($this->LanguageSupportFields) { $fieldNames = array(); // save the names of all fields that support languages foreach(wire('fields') as $field) { if($field->type instanceof FieldtypeLanguageInterface) $fieldNames[] = $field->name; } // 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(); } /** * Hook before Inputfield::render to set proper default language value * * Only applies to Inputfields that have: useLanguages == true * */ public function hookInputfieldBeforeRender(HookEvent $event) { $inputfield = $event->object; if(!$inputfield->useLanguages) return; $userLanguage = $this->wire('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); } } /** * 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 * */ protected function wrapInputfieldOutput($out, $id, Language $language) { $label = (string) $language->title; if(!strlen($label)) $label = $language->name; $out = "\n
" . "\n" . $out . "\n
"; return $out; } /** * Hook into Inputfield::render to duplicate inputs for other languages * * Only applies to Inputfields that have: useLanguages == true * */ public function hookInputfieldAfterRender(HookEvent $event) { static $numLanguages = null; $inputfield = $event->object; $name = $inputfield->attr('name'); $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 && strpos($name, '_') && wire('fields')->get($name)) { $language = $this->LanguageSupportFields->getAlternateFieldLanguage($name); if($language && $language->id) { $event->return = $this->wrapInputfieldOutput($event->return, $inputfield->attr('id'), $language); return; } } // if inputfield doesn't have a 'useLanguages' var set in it, then we're done, abort if(!$inputfield->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; $trackChanges = $inputfield->trackChanges(); $inputfield->setTrackChanges(false); if($this->languageTabs) $this->languageTabs->resetTabs(); $out = ''; foreach($languages as $language) { $languageID = (int) $language->id; if($language->isDefault) { // default language $newID = $id; $o = $event->return; } 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 = $inputfield->___render(); } $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 * * Only applies to Inputfields that have: useLanguages == true * */ public function hookInputfieldBeforeProcessInput(HookEvent $event) { // ensures default language values are populated $this->hookInputfieldBeforeRender($event); } /** * Hook into Inputfield::processInput to process input for other languages * * Only applies to Inputfields that have: useLanguages == true * */ public function hookInputfieldAfterProcessInput(HookEvent $event) { $inputfield = $event->object; if(!$inputfield->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) { if($language->isDefault) 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 * */ public function hookFieldGetInputfield(HookEvent $event) { $languages = $this->wire('languages'); $language = $this->wire('user')->language; if(!$language || !$language->id) return; $field = $event->object; $inputfield = $event->return; $translatable = array('label', 'description', 'notes'); // 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) { // populate useLanguages in the inputfield so we can detect it elsehwere $inputfield->set('useLanguages', true); $page = $event->arguments[0]; $value = $page->get($field->name); // set values in this field specific to each language foreach($languages as $language) { $languageValue = ''; if(is_object($value) && $value instanceof LanguagesPageFieldValue) { $languageValue = $value->getLanguageValue($language->id); } else { if($language->isDefault) $languageValue = $value; } $inputfield->set('value' . $language->id, $languageValue); } } $event->return = $inputfield; } /** * Hook called when new language added * */ public function hookPageAdded(HookEvent $event) { $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); 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 $configData = wire('modules')->getModuleConfigData('LanguageSupport'); $configData['otherLanguagePageIDs'][] = $page->id; wire('modules')->saveModuleConfigData('LanguageSupport', $configData); } /** * Hook called when language is deleted * */ public function hookPageDeleteReady(HookEvent $event) { $page = $event->arguments[0]; if($page->template->name != self::languageTemplateName) return; $language = $page; // remove any language-specific values from any fields foreach(wire('fields') as $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(wire('templates') as $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 wire('languages')->deleted($page); // update the other language module IDs to remove the uninstalled language $configData = wire('modules')->getModuleConfigData('LanguageSupport'); $key = array_search($page->id, $configData['otherLanguagePageIDs']); if($key !== false) { unset($configData['otherLanguagePageIDs'][$key]); wire('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. * */ public function hookPageSetLanguageValue(HookEvent $event) { $page = $event->object; $language = $event->arguments(0); $field = $event->arguments(1); $value = $event->arguments(2); $event->return = $page; 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 set to Page::setLanguageValue'); if($field == 'name') { // set page name if(!$this->wire('modules')->isInstalled('LanguageSupportPageNames')) { throw new WireException("Please install LanguageSupportPageNames module before attempting to set multi-language names/paths/URLs."); } if($language->isDefault()) { $page->set("name", $value); } else { $page->set("name$language->id", $value); } } else { if(is_object($field)) $field = $field->name; $previousValue = $page->get($field); if(is_object($previousValue) && $previousValue instanceof LanguagesValueInterface) { // utilize existing set methods available in LanguagesValueInterface (which might be slightly quicker than the else condition method if(is_object($value) && $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. * */ public function hookPageGetLanguageValue(HookEvent $event) { $page = $event->object; $language = $event->arguments(0); $field = $event->arguments(1); $value = null; 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(is_object($value) && $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 * */ public static function getModuleConfigInputfields(array $data) { $form = new InputfieldWrapper(); // TBA return $form; } /** * Install or uninstall by loading the LanguageSupportInstall script * */ protected function installer($install = true) { require_once($this->config->paths->LanguageSupport . 'LanguageSupportInstall.php'); $installer = new LanguageSupportInstall(); if($install) $installer->install(); else $installer->uninstall(); } /** * Install the module * */ public function ___install() { $this->installer(true); } /** * Uninstall the module * */ public function ___uninstall() { $this->installer(false); } }