577 lines
16 KiB
Text
577 lines
16 KiB
Text
|
<?php namespace ProcessWire;
|
||
|
|
||
|
/**
|
||
|
* Multi-language support fields module
|
||
|
*
|
||
|
* ProcessWire 3.x, Copyright 2022 by Ryan Cramer
|
||
|
* https://processwire.com
|
||
|
*
|
||
|
* @method void languageAdded(Page $language) #pw-hooker
|
||
|
* @method void languageDeleted(Page $language) #pw-hooker
|
||
|
* @method void fieldLanguageAdded(Field $field, Page $language) #pw-hooker
|
||
|
* @method void fieldLanguageRemoved(Field $field, Page $language) #pw-hooker
|
||
|
*
|
||
|
*/
|
||
|
|
||
|
class LanguageSupportFields extends WireData implements Module {
|
||
|
|
||
|
/**
|
||
|
* Return information about the module
|
||
|
*
|
||
|
*/
|
||
|
static public function getModuleInfo() {
|
||
|
return array(
|
||
|
'title' => 'Languages Support - Fields',
|
||
|
'version' => 101,
|
||
|
'summary' => 'Required to use multi-language fields.',
|
||
|
'author' => 'Ryan Cramer',
|
||
|
'autoload' => true,
|
||
|
'singular' => true,
|
||
|
'requires' => array(
|
||
|
'LanguageSupport'
|
||
|
),
|
||
|
'installs' => array(
|
||
|
'FieldtypePageTitleLanguage',
|
||
|
'FieldtypeTextareaLanguage',
|
||
|
'FieldtypeTextLanguage',
|
||
|
)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Cached names of fields that are dealing in multiple languages.
|
||
|
*
|
||
|
*/
|
||
|
protected $multilangFields = array();
|
||
|
|
||
|
/**
|
||
|
* Cached names of fields that have alternate language-specific versions
|
||
|
*
|
||
|
* Indexed by original field name and value is an array of the language-alternate versions
|
||
|
*
|
||
|
*/
|
||
|
protected $multilangAlternateFields = array();
|
||
|
|
||
|
/**
|
||
|
* Construct and set our dynamic config vars
|
||
|
*
|
||
|
*/
|
||
|
public function __construct() {
|
||
|
parent::__construct();
|
||
|
// load other required classes
|
||
|
$dirname = dirname(__FILE__);
|
||
|
require_once($dirname . '/LanguagesValueInterface.php');
|
||
|
require_once($dirname . '/FieldtypeLanguageInterface.php');
|
||
|
require_once($dirname . '/LanguagesPageFieldValue.php');
|
||
|
}
|
||
|
|
||
|
public function wired() {
|
||
|
$this->addHookAfter('FieldtypeLanguageInterface::loadPageField', $this, 'fieldtypeLoadPageField');
|
||
|
$this->addHookAfter('FieldtypeLanguageInterface::wakeupValue', $this, 'fieldtypeWakeupValue');
|
||
|
$this->addHookAfter('FieldtypeLanguageInterface::getConfigInputfields', $this, 'fieldtypeGetConfigInputfields');
|
||
|
parent::wired();
|
||
|
}
|
||
|
|
||
|
public function init() {
|
||
|
// intentionally left blank
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Initialize the language support API vars
|
||
|
*
|
||
|
*/
|
||
|
public function LS_init() {
|
||
|
|
||
|
$fields = $this->wire()->fields;
|
||
|
|
||
|
$this->addHookBefore('FieldtypeLanguageInterface::sleepValue', $this, 'fieldtypeSleepValue');
|
||
|
$this->addHookBefore('PageFinder::getQuery', $this, 'pageFinderGetQuery');
|
||
|
$this->addHookBefore('Fieldtype::formatValue', $this, 'hookFieldtypeFormatValue');
|
||
|
|
||
|
$languageNames = array();
|
||
|
$fieldNames = $fields->getAllNames('name');
|
||
|
|
||
|
foreach($this->wire()->languages as $language) {
|
||
|
$languageNames[] = $language->name;
|
||
|
}
|
||
|
|
||
|
// keep track of which fields are multilanguage
|
||
|
$this->multilangFields = $fields->findByType('FieldtypeLanguageInterface', array(
|
||
|
'inherit' => true,
|
||
|
'valueType' => 'name',
|
||
|
'indexType' => '',
|
||
|
));
|
||
|
|
||
|
// determine which fields have language alternates, i.e. 'title_es' is an alternate for 'title'
|
||
|
foreach($fieldNames as $fieldName) {
|
||
|
foreach($languageNames as $languageName) {
|
||
|
$altName = $fieldName . '_' . $languageName;
|
||
|
if(isset($fieldNames[$altName])) {
|
||
|
if(!isset($this->multilangAlternateFields[$fieldName])) $this->multilangAlternateFields[$fieldName] = array();
|
||
|
$this->multilangAlternateFields[$fieldName][] = $altName;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Called by ProcessWire when the API and known $page is ready
|
||
|
*
|
||
|
*/
|
||
|
public function LS_ready() {
|
||
|
$languages = $this->wire()->languages;
|
||
|
$languages->addHook('added', $this, 'hookLanguageAdded');
|
||
|
$languages->addHook('deleted', $this, 'hookLanguageDeleted');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hook into FieldtypeText::formatValue
|
||
|
*
|
||
|
* Replace a value of one field with the value from another field that has the same name, but with the language name appended to it.
|
||
|
* Example: title and title_es
|
||
|
*
|
||
|
* @param HookEvent $event
|
||
|
*
|
||
|
*/
|
||
|
public function hookFieldtypeFormatValue(HookEvent $event) {
|
||
|
|
||
|
/** @var Field $field */
|
||
|
$field = $event->arguments[1];
|
||
|
if($field->name === 'language') return;
|
||
|
|
||
|
$language = $this->wire()->user->language;
|
||
|
if(!$language || !$language->id || $language->isDefault()) return;
|
||
|
|
||
|
// exit quickly if we can determine now we don't need to continue
|
||
|
if(!isset($this->multilangAlternateFields[$field->name])) return;
|
||
|
|
||
|
/** @var Page $page */
|
||
|
$page = $event->arguments[0];
|
||
|
|
||
|
// determine name of language field, if present.
|
||
|
// note that if the language name contains dashes or dots (- or .) the field name should contain underscores there instead
|
||
|
$newName = $field->name . '_' . str_replace(array('-', '.'), '_', $language->name);
|
||
|
$newField = $page->fieldgroup->getField($newName);
|
||
|
if(!$newField) return;
|
||
|
|
||
|
// unformatted so nothing can modify it first
|
||
|
$value = $page->getUnformatted($newName);
|
||
|
|
||
|
// if the page doesn't have a populated language-specific field, then exit
|
||
|
// this will make it fallback to the default language value
|
||
|
if($field->type->isEmptyValue($field, $value)) return;
|
||
|
if(is_object($value)) {
|
||
|
if($value instanceof WireArray && !$value->count()) return;
|
||
|
if($value instanceof NullPage) return;
|
||
|
}
|
||
|
|
||
|
// we have a new field: swap $field with $newField in the arguments
|
||
|
$newValue = $page->get($newName); // get formatted version
|
||
|
$arguments = $event->arguments;
|
||
|
$arguments[1] = $newField;
|
||
|
$arguments[2] = $newValue;
|
||
|
$event->arguments = $arguments;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hook called when new language added
|
||
|
*
|
||
|
* @param HookEvent $event
|
||
|
*
|
||
|
*/
|
||
|
public function hookLanguageAdded(HookEvent $event) {
|
||
|
|
||
|
$fields = $this->wire()->fields;
|
||
|
$language = $event->arguments[0];
|
||
|
|
||
|
if($language->template->name != LanguageSupport::languageTemplateName) return;
|
||
|
|
||
|
foreach($this->multilangFields as $name) {
|
||
|
$field = $fields->get($name);
|
||
|
if($field) $this->fieldLanguageAdded($field, $language);
|
||
|
}
|
||
|
|
||
|
$this->languageAdded($language);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hookable function called when a new language is added
|
||
|
*
|
||
|
* @param Page|Language $language
|
||
|
*
|
||
|
*/
|
||
|
public function ___languageAdded(Page $language) {
|
||
|
// hookable, intentionally blank
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hook called when languag is deleted
|
||
|
*
|
||
|
* @param HookEvent $event
|
||
|
*
|
||
|
*/
|
||
|
public function hookLanguageDeleted(HookEvent $event) {
|
||
|
|
||
|
$fields = $this->wire()->fields;
|
||
|
$language = $event->arguments[0];
|
||
|
if($language->template->name != LanguageSupport::languageTemplateName) return;
|
||
|
|
||
|
foreach($this->multilangFields as $name) {
|
||
|
$field = $fields->get($name);
|
||
|
if($field) $this->fieldLanguageRemoved($field, $language);
|
||
|
}
|
||
|
|
||
|
$this->languageDeleted($language);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hookable function called when a language is deleted
|
||
|
*
|
||
|
* @param Page|Language $language
|
||
|
*
|
||
|
*/
|
||
|
public function ___languageDeleted(Page $language) {
|
||
|
// hookable, intentionally blank
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Called when a new language is added to the system for each field that implements FieldtypeLanguageInterface
|
||
|
*
|
||
|
* @param Field $field
|
||
|
* @param Page|Language $language
|
||
|
*
|
||
|
*/
|
||
|
public function ___fieldLanguageAdded(Field $field, Page $language) {
|
||
|
|
||
|
if($language->isDefault) return;
|
||
|
|
||
|
if(!($field->type instanceof FieldtypeLanguageInterface)) return;
|
||
|
|
||
|
$schema = $field->type->getDatabaseSchema($field);
|
||
|
$database = $this->wire()->database;
|
||
|
$table = $database->escapeTable($field->table);
|
||
|
|
||
|
foreach($schema as $name => $value) {
|
||
|
if(!preg_match('/[^\d]+' . $language->id . '$/', $name)) continue;
|
||
|
// field in schema ends with the language ID
|
||
|
try {
|
||
|
$database->exec("ALTER TABLE `{$table}` ADD `$name` $value");
|
||
|
} catch(\Exception $e) {
|
||
|
$this->error($e->getMessage(), Notice::log);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
foreach($schema['keys'] as $name => $value) {
|
||
|
if(!preg_match('/[^\d]+' . $language->id . '$/', $name)) continue;
|
||
|
// index in schema ends with the language ID
|
||
|
try {
|
||
|
$database->exec("ALTER TABLE `{$table}` ADD $value");
|
||
|
} catch(\Exception $e) {
|
||
|
$this->error($e->getMessage(), Notice::log);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Called when a language is removed from the system for each field that implements FieldtypeLanguageInterface
|
||
|
*
|
||
|
* @param Field $field
|
||
|
* @param Page|Language $language
|
||
|
*
|
||
|
*/
|
||
|
protected function ___fieldLanguageRemoved(Field $field, Page $language) {
|
||
|
|
||
|
if($language->isDefault) return;
|
||
|
|
||
|
if(!($field->type instanceof FieldtypeLanguageInterface)) return;
|
||
|
|
||
|
$schema = $field->type->getDatabaseSchema($field);
|
||
|
$database = $this->wire()->database;
|
||
|
$table = $database->escapeTable($field->table);
|
||
|
|
||
|
foreach($schema as $name => $value) {
|
||
|
if(!preg_match('/[^\d]+' . $language->id . '$/', $name)) continue;
|
||
|
try {
|
||
|
$database->exec("ALTER TABLE `{$table}` DROP COLUMN `$name`");
|
||
|
} catch(\Exception $e) {
|
||
|
// just catch, no need for fatal errors here
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hook into PageFinder::getQuery
|
||
|
*
|
||
|
* Adjusts the selectors passed to the query so that find operations search in user's native language version, as well as the default version.
|
||
|
*
|
||
|
* We may make this behavior configurable later on, as one may want to limit the search to 1 language only.
|
||
|
*
|
||
|
* @param HookEvent $event
|
||
|
*
|
||
|
*/
|
||
|
public function pageFinderGetQuery(HookEvent $event) {
|
||
|
|
||
|
$user = $this->wire()->user;
|
||
|
$language = $user->language;
|
||
|
$database = $this->wire()->database;
|
||
|
|
||
|
if(!$language || !$language->id || $language->isDefault()) return;
|
||
|
|
||
|
$arguments = $event->arguments;
|
||
|
$selectors = $arguments[0];
|
||
|
|
||
|
foreach($selectors as $selector) {
|
||
|
|
||
|
$changed = false;
|
||
|
$fields = $selector->field;
|
||
|
$fields = is_array($fields) ? $fields : array($fields);
|
||
|
|
||
|
foreach($fields as $field) {
|
||
|
|
||
|
$subfield = '';
|
||
|
if(strpos($field, '.')) list($field, $subfield) = explode('.', $field);
|
||
|
|
||
|
$field = $database->escapeCol($field);
|
||
|
$subfield = $database->escapeCol($subfield);
|
||
|
|
||
|
if(isset($this->multilangAlternateFields[$field])) {
|
||
|
// account for multilang alternates like 'title_es' for 'title'
|
||
|
$altName = $field . '_' . $database->escapeCol($user->language->name);
|
||
|
if(in_array($altName, $this->multilangAlternateFields[$field])) {
|
||
|
if($subfield) $altName .= ".$subfield";
|
||
|
array_unshift($fields, $altName);
|
||
|
$changed = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// next we account for actual multilang fields
|
||
|
if(!in_array($field, $this->multilangFields)) continue;
|
||
|
if(!$subfield) $subfield = 'data';
|
||
|
|
||
|
if($subfield === 'data') {
|
||
|
array_unshift($fields, "$field.$subfield" . (int) $user->language->id);
|
||
|
$changed = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if($changed) $selector->field = $fields;
|
||
|
}
|
||
|
|
||
|
$arguments[0] = $selectors;
|
||
|
$event->arguments = $arguments;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hook into FieldtypeLanguageInterface::loadPageField
|
||
|
*
|
||
|
* Converts the value to a LanguagesPageFieldValue
|
||
|
*
|
||
|
* @param HookEvent $event
|
||
|
*
|
||
|
*/
|
||
|
public function fieldtypeLoadPageField(HookEvent $event) {
|
||
|
$page = $event->arguments[0];
|
||
|
$field = $event->arguments[1];
|
||
|
$value = $event->return;
|
||
|
if($value instanceof LanguagesPageFieldValue) return;
|
||
|
$v = new LanguagesPageFieldValue($page, $field, $value);
|
||
|
$this->wire($v);
|
||
|
$event->return = $v;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hook into FieldtypeLanguageInterface::wakeupValue
|
||
|
*
|
||
|
* Converts the value to a LanguagesPageFieldValue
|
||
|
*
|
||
|
* @param HookEvent $event
|
||
|
*
|
||
|
*/
|
||
|
public function fieldtypeWakeupValue(HookEvent $event) {
|
||
|
|
||
|
$page = $event->arguments[0];
|
||
|
$field = $event->arguments[1];
|
||
|
$value = $event->return;
|
||
|
|
||
|
if($value instanceof LanguagesPageFieldValue) {
|
||
|
$value->setTrackChanges(true);
|
||
|
$value->setField($field);
|
||
|
// good
|
||
|
} else if(is_array($value)) {
|
||
|
$value = new LanguagesPageFieldValue($page, $field, $value);
|
||
|
$this->wire($value);
|
||
|
$value->setTrackChanges(true);
|
||
|
$value->setField($field);
|
||
|
$event->return = $value;
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hook into FieldtypeLanguageInterface::sleepValue
|
||
|
*
|
||
|
* Converts a LanguagesPageFieldValue to an array
|
||
|
*
|
||
|
* @param HookEvent $event
|
||
|
*
|
||
|
*/
|
||
|
public function fieldtypeSleepValue(HookEvent $event) {
|
||
|
|
||
|
// $page = $event->arguments[0];
|
||
|
// $field = $event->arguments[1];
|
||
|
$value = $event->arguments[2];
|
||
|
//$value = $event->return;
|
||
|
$values = array();
|
||
|
|
||
|
if(!$value instanceof LanguagesPageFieldValue) return;
|
||
|
|
||
|
foreach($this->wire()->languages as $language) {
|
||
|
if($language->isDefault()) $key = 'data';
|
||
|
else $key = 'data' . $language->id;
|
||
|
$values[$key] = $value->getLanguageValue($language->id);
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
if(!strlen($values['data'])) foreach($values as $k => $v) {
|
||
|
// prevent the possibility of the default language having
|
||
|
// a blank value while some other language has a populated value
|
||
|
if(!strlen($v)) continue;
|
||
|
$values['data'] = $v;
|
||
|
break;
|
||
|
}
|
||
|
*/
|
||
|
|
||
|
// ensure that sleepValue is getting an array
|
||
|
$event->setArgument(2, $values);
|
||
|
// $event->return = $values;
|
||
|
}
|
||
|
|
||
|
public function fieldtypeGetConfigInputfields(HookEvent $event) {
|
||
|
|
||
|
/** @var Field $field */
|
||
|
$field = $event->arguments(0);
|
||
|
|
||
|
/** @var InputfieldWrapper $inputfields */
|
||
|
$inputfields = $event->return;
|
||
|
|
||
|
/** @var InputfieldRadios $f */
|
||
|
$f = $this->wire()->modules->get('InputfieldRadios');
|
||
|
$f->attr('name', 'langBlankInherit');
|
||
|
$f->label = $this->_('Language Support / Blank Behavior');
|
||
|
$f->description = $this->_("What should happen when this field's value is blank?");
|
||
|
$f->notes = $this->_('Applies only to non-default language values on the front-end of your site.');
|
||
|
$f->addOption(LanguagesPageFieldValue::langBlankInheritDefault, $this->_('Inherit value from default language'));
|
||
|
$f->addOption(LanguagesPageFieldValue::langBlankInheritNone, $this->_('Remain blank'));
|
||
|
$f->attr('value', (int) $field->get('langBlankInherit'));
|
||
|
$f->collapsed = Inputfield::collapsedBlank;
|
||
|
$inputfields->add($f);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Given a field name, return an array of alternate language field names
|
||
|
*
|
||
|
* Returns a blank array if none found
|
||
|
*
|
||
|
* @param string $fieldName
|
||
|
* @return array
|
||
|
*
|
||
|
*/
|
||
|
public function getAlternateFields($fieldName) {
|
||
|
if(isset($this->multilangAlternateFields[$fieldName])) {
|
||
|
return $this->multilangAlternateFields[$fieldName];
|
||
|
}
|
||
|
return array();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Given an alternate field name, return the parent (default-language) version of it
|
||
|
*
|
||
|
* @param string $altFieldName
|
||
|
* @param bool $returnLanguage Specify true if you want this function to return the language rather than the parent field name.
|
||
|
* @return string|Language Returns blank string if none found
|
||
|
*
|
||
|
*/
|
||
|
public function getAlternateFieldParent($altFieldName, $returnLanguage = false) {
|
||
|
$pos = strrpos($altFieldName, '_');
|
||
|
if(!$pos) return '';
|
||
|
$parentName = substr($altFieldName, 0, $pos);
|
||
|
if(!isset($this->multilangAlternateFields[$parentName])) return '';
|
||
|
if(!in_array($altFieldName, $this->multilangAlternateFields[$parentName])) return '';
|
||
|
if(!$returnLanguage) return $parentName;
|
||
|
$languageName = substr($altFieldName, $pos+1);
|
||
|
return $this->wire()->languages->get($languageName);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the language associated with the alternate field name
|
||
|
*
|
||
|
* @param string $altFieldName
|
||
|
* @return Page|false Language page associated with the field, or blank string or false if not found
|
||
|
*
|
||
|
*/
|
||
|
public function getAlternateFieldLanguage($altFieldName) {
|
||
|
return $this->getAlternateFieldParent($altFieldName, true);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get multi-language field names (fields that implement FieldtypeLanguageInterface)
|
||
|
*
|
||
|
* @return array
|
||
|
* @since 3.0.194
|
||
|
*
|
||
|
*/
|
||
|
public function getMultilangFieldNames() {
|
||
|
return $this->multilangFields;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Is the given field name a language alternate field?
|
||
|
*
|
||
|
* If it is, the Language of the field is returned.
|
||
|
* If it isn't, then boolean false is returned.
|
||
|
*
|
||
|
* This method also accounts for default language.
|
||
|
*
|
||
|
* @param string $name
|
||
|
* @return bool|Language
|
||
|
*
|
||
|
*/
|
||
|
public function isAlternateField($name) {
|
||
|
$name = (string) $name;
|
||
|
if(isset($this->multilangAlternateFields[$name])) {
|
||
|
// default language for an alternate field set
|
||
|
return $this->wire()->languages->getDefault();
|
||
|
}
|
||
|
if(!strpos($name, '_')) return false;
|
||
|
$language = $this->getAlternateFieldParent($name, true);
|
||
|
if($language && $language->id) return $language;
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Install the module
|
||
|
*
|
||
|
*/
|
||
|
public function ___install() {
|
||
|
$this->wire()->modules->get('FieldtypeTextLanguage');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Uninstall the module
|
||
|
*
|
||
|
*/
|
||
|
public function ___uninstall() {
|
||
|
// first check if there are any fields using the LanguageInterface
|
||
|
$errors = '';
|
||
|
foreach($this->wire()->fields as $field) {
|
||
|
if($field->type instanceof FieldtypeLanguageInterface) $errors .= $field->name . ", ";
|
||
|
}
|
||
|
if($errors) {
|
||
|
throw new WireException("Can't uninstall because these fields use the language interface: " . rtrim($errors, ", "));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
}
|