Multilingual models

Hi! Thank you for that nice behavior. I start to use it now, but I can’t figure out, how can I reate CRUD for create translations.

Can anybody help me out with this?

I’ve created inputs in my views like this:




<div class="row">

	<?php echo $form->labelEx($model,'title'); ?>

	<?php echo $form->textField($model,'title',array('maxlength'=>50)); ?>

	<?php echo $form->textField($model,'title_hu',array('maxlength'=>50)); ?>

	<?php echo $form->textField($model,'title_en',array('maxlength'=>50)); ?>

</div>



When I post the form, I got these errors:

Failed to set unsafe attribute "title_hu".

Failed to set unsafe attribute "title_en".

You should create rules for those attributes, as if they were attributes from the model itself. Better if you use params instead of hardcoding them, something like:




public function rules() {

	$rules = array(

		array('title', 'required'),

		array('title', 'length', 'max' => 80),

		array('content', 'safe'),			

		array('id, title', 'safe', 'on' => 'search'),

	);

	foreach (Yii::app()->params['translateLangs'] as $k => $v) {

		$rules[] = array('title_'.$k, 'length', 'max' => 80);

		$rules[] = array('content_'.$k, 'safe');

	}

	return $rules;

}



Thanks for the help, guillemc!

You probably want to add a __isset magic overload as well:




public function __isset($name){

  if (! parent::__isset($name)) {

    return ($this->hasLangAttribute($name));

  } else {

    return true;

  }

}



I haven’t tested it yet, but something along those lines will do.

I also have suggestions for better names for relName1 and relName2: localizedRelation and multilangRelation

As a final thought, the point where you create $this->langForeignKey from $owner->tableName() is broken if tableName returns a string containing the database name (e.g. ‘mydb.products’)

You can replace




$owner->tableName()



with




array_pop(explode('.',$owner->tableName()))



to achieve that extra compatibility.

But those are all small points. All in all, this multilang stuff is really useful.

Thanks to you and to everyone involved!

Thanks, good suggestions!

Now, if there was a way to get rid of this getter and setter overloads, it would start looking less like a hack and more like a real behavior :)

Hi guillemc and heal!

I’m using the MultilingualBehaviour, and it’s working fine in an example I created. The user gets to choose in wich language wants to display the sections and posts related, and it gets the translation correctly.

But my question is how I change the Models and Controllers of the Posts and Section (in the example) to be able to introduce all the files in the diferent lenguages in the Add and Update Form?

Using the previous MultilingualActiveRecord class, by defining the localizedAttributes, languages, etc. it was correctly displayed all the language fields on the form view…

Can you show us the code in order to achieve that?

Thank you!!

Hi!

Now I get the fields in the forms,I understood that you have to edit the _form.php in orther to include the translate fields…

And the Add/Create works fine for me.

But the Edit/Update returns an error (I cannot even see the form):

Property "Posts.title_es" is not defined. //Posts is my Model and title_es the first field in PostLang

the error trace comes from:

return parent::__get($name);

public function __get($name) {

  try { return parent::__get(&#036;name); }  //this line


  catch (CException &#036;e) {


     if (&#036;this-&gt;hasLangAttribute(&#036;name)) return &#036;this-&gt;getLangAttribute(&#036;name);


     else throw &#036;e;


   }                                                     


}

So… i’m a bit lost in here, because I don’t undestand why is looking for the file in the Posts model, instead of the PostsLang model…

Any help?

Hi, it’s looking in the Post model because that’s what we’re after, in “multilang” mode: to make appear the attributes of PostLang model as if they were attributes of Post, for easier handling (like title_es, title_de, etc).

Probably the problem is that you are not loading your model in multilang mode. In my controllers I use this modified loadModel():




  public function loadModel($id, $ml=false) {

    if ($ml) {

      $model = News::model()->with('multilang')->findByPk((int) $id);

    } else {

      $model = News::model()->findByPk((int) $id);

    }

    if ($model === null)

      throw new CHttpException(404, 'The requested page does not exist.');

    return $model;

  }



Then in my update action I load the model like this:




  public function actionUpdate($id) {

    $model = $this->loadModel($id, true);

    ...

  }



Thanks a lot guillemc!!

I missed that completely! I’m not only a newbie on the forum, but also in yii developing… You did a very useful behavior!!

Now it’s working perfectly.

Diana.

First of all, kudos to guillemc for making this incredibly useful behavior.

I have a sugestion, though, following on G_Gus footsteps; he sugested changing this…


$owner->tableName()

… into this:


array_pop(explode('.',$owner->tableName()))

But I’ll go as far as changing this (already with his sugestion)…


str_replace(array('{{','}}'),'',array_pop(explode('.',$owner->tableName()))).'_id'

… into this:


$owner->getTableSchema()->primaryKey

Why? In my experience, more often than not, the foreign key is the primary key of the non-translated table. For me, it’s every single time.

Hi guillemc,

first:lots of thanks for this great behavior!

second: You’re message above made me think, whereupon I tried the following, which works so far:

  1. I created a file ‘[font=“Courier New”]ActiveRecord.php[/font]’ with the following content (all my models extend this base model class ActiveRecord):



class ActiveRecord extends CActiveRecord

{	

	/**

	 * @array $translations translations of all translatable attributes of the current model

	 * in all translation languages.

	 * To get the translation of $field in the language $lang use: $translations[$lang][$field]

	 * To add a translation to the array, use the addTranslation() method.

	 * Since getter/setter mothods are declared, you can access the property with YourModelClass::model()->translations

	 */

	public $translations = array();

	

	public function addTranslation($lang, $field, $value) {

		$this->translations[$lang][$field] = $value;

	}

	public function getTranslations($array) {

		return $this->translations;

	}

	public function setTranslations($tarray) {

		$this->translations = $tarray;

	}

	

	public function multilang() {

		$this->getDbCriteria()->mergeWith(array('with' => 'multilang'));

		return $this;

	}

	

	public function behaviors()

	{

		return array(

			'translation' => array(

				'class' => 'ext.behaviors.TranslationBehavior',

				'sourceLanguage' => Yii::app()->sourceLanguage,

				'language' => Yii::app()->language,

				'translationLanguages' => Yii::app()->params->translationLanguages,

				'translationRelation' => RES::lc(get_class($this)).'translation',

				'multiLangRelation' => 'multilang',

				'translationActive' => (Yii::app()->sourceLanguage==Yii::app()->language)?false:true,

			)

		);

	}



I put this all here into the base file, since the code will always be the same for each model and it will be needed by almost all of my models (and if not, that’s no problem, they get ignored).

Note: RES::lc is just a helper function turning every letter into lower case.

  1. Then in my particular model file, say [font="Courier New"]Section.php[/font], I add:



	/**

	 * $translatableAttributes: array of the translatable attributes of this class (e.g. array('Name', 'Description')). 

	 * Add translatable attribute names, or leave empty if there are none. Don't remove this declaration.

	 */

	public $translatableAttributes = array('Name', 'Description');


	// Don't forget to add the new attribute $translations to the 'safe' rule in your (every) model:

	public function rules() {

		return array(

			...

			array('Name, Description, translations', 'safe', 'on'=>'search'),

		);

	}




  1. In the form, the according textfields are added for example with:



// $lang is the current language in the loop of all translationLanguages

echo $form->textField($model,"translations[$lang][Name]", array('maxlength' => 100));



  1. In the Controller, the massive assignment looks like this:



$model->setAttributes($_POST['Section']);

$model->translations = $_POST['Section']['translations'];

$model->save();



  1. In the Behavior file ‘[font=“Courier New”]TranslationBehavior.php[/font]’ I made the according changes:



public function afterFind($event) {

...

	} else if ($owner->hasRelated($this->multiLangRelation)) {

		$related = $owner->getRelated($this->multiLangRelation);

		foreach ($this->translationLanguages as $lang) {

			// change her -->

			foreach ($owner->translatableAttributes as $field) {

				$owner->addTranslation( $lang, $field, isset($related[$lang][$field])?$related[$lang][$field]:null );

			}

		}

	}

}

...

public function afterSave($event) {

...

	foreach ($this->translationLanguages as $lang) {

		if (!isset($rs[$lang])) {

			$obj = new $this->translationClassName;          

			$obj->{$this->langField} = $lang;

			$obj->{$this->translationForeignKey} = $ownerPk;          

		} else {

			$obj = $rs[$lang];

		}

		// change here -->

		foreach ($owner->translations[$lang] as $fieldname => $value) {

			$obj->$fieldname = $value;

		}

		$obj->save(true);

	}

...



thanks guillemc, i figure out finally to run it up. :)

i have such situation


//Section.php

public function relations() {

  return array(

    'subsections' => array(self::HAS_MANY, 'Section', 'parent_id'),

    'i18nSection' => array(self::HAS_MANY, 'SectionLang', 'section_id', 'on'=>"i18nSection.lang_id='".Yii::app()->language."'", 'index'=>'lang_id'),

    'multilang' => array(self::HAS_MANY, 'SectionLang', 'section_id', 'index' => 'lang_id'),

  );

}

i made the changes for Section localized method


//Section.php

public function localized() {

  $this->getDbCriteria()->mergeWith(array(                      

    'with'=>array(

        'i18nSection'=>array(),

        'subsections'=>array('scopes'=>array('localized')),

    ),

  ));

  return $this;

}

and i got the error


Fatal error: Maximum function nesting level of '100' reached, aborting!

how should i act in this case, with relation to itself ? any suggestions, please.

Hey guys,

first of all thanks for the (I suppose, because I havn’t tried it yet) great work! has anyone tried to compile the above behaviours, components and changes to be made in the models and controllers and publish it as an extension?

I’m going to try to filter all this information and use it to suit my needs anyways, but I just wanted to get the idea out there.

Hello everyone,

I’ve tried to make a compilation of everything in this thread and to make things work only with a behavior, without changing the model. So you don’t have to overload getters or setters in the model. Only to attach the behavior and define the defaultScope.

And of course create a new table in the database to store translations.

The table has this form by default (example for a base table named "post") :

postLang: l_id,post_id,lang_id,[list of attributes to translate prefixed by ‘l_’(configurable)]

The attributes to translate have to be also in the base table but with no prefix.

Here is the file : 2705

MultilingualBehavior.php

To attach the behavior to the model, use this piece of code (everything that is commented is default values):




public function behaviors() {

    /*$table_name_chunks = explode('.', $this->tableName());

    $simple_table_name = str_replace(array('{{', '}}'), '', array_pop($table_name_chunks));*/

    return array(

        'ml' => array(

            'class' => 'application.components.MultilingualBehavior',

            /*

            'langClassName' => __CLASS__ . 'Lang', //example if model class name is 'Post' : 'PostLang'

            'langTableName' => $simple_table_name . 'Lang', //example if table name is 'post' : 'postLang'

            'langForeignKey' => $simple_table_name . '_id', //example if table name is 'post' : 'post_id'

            'langField' => 'lang_id',

            'localizedRelation' => 'i18n' . __CLASS__, //example if model class name is 'Post' : 'i18nPost'

            'multilangRelation' => 'multilang' . __CLASS__, //example if model class name is 'Post' : 'multilangPost'

            'dynamicLangClass' => true, //Set to true if you don't want to create a 'PostLang.php' in your models folder

            'localizedPrefix' => 'l_', //In the lang table, localized attributes have to be prefixed with this to avoid collisions in some queries and allow search on translations

            */

            'localizedAttributes' => array('slug', 'title'), //attributes of the model to be translated

            'languages' => Yii::app()->params['translatedLanguages'], // array of your translated languages. Example : array('fr' => 'Français', 'en' => 'English')

            'primaryLang' => Yii::app()->params['defaultLanguage'], //your main language. Example : 'fr'

        ),

    );

}



In order to retrieve translated models by default, add this function in the model class :




public function defaultScope()

{

    return $this->ml->localizedCriteria();

}



You also can modify the loadModel function of your controller as guillemc suggested in a previous post :




public function loadModel($id, $ml=false) {

    if ($ml) {

        $model = Post::model()->with('multilang')->findByPk((int) $id);

    } else {

        $model = Post::model()->findByPk((int) $id);

    }

    if ($model === null)

        throw new CHttpException(404, 'The requested page does not exist.');

    return $model;

}



and use it like this in the update action :




public function actionUpdate($id) {

    $model = $this->loadModel($id, true);

    ...

}



Here is a very simple example for the form view :




<?php foreach (Yii::app()->params['translatedLanguages'] as $l => $lang) :

    if($l === Yii::app()->params['defaultLanguage']) $suffix = '';

    else $suffix = '_'.$l;

    ?>

<fieldset>

    <legend><?php echo $lang; ?></legend>

    <div class="row">

    <?php echo $form->labelEx($model,'slug'); ?>

    <?php echo $form->textField($model,'slug'.$suffix,array('size'=>60,'maxlength'=>255)); ?>

    <?php echo $form->error($model,'slug'.$suffix); ?>

    </div>


    <div class="row">

    <?php echo $form->labelEx($model,'title'); ?>

    <?php echo $form->textField($model,'title'.$suffix,array('size'=>60,'maxlength'=>255)); ?>

    <?php echo $form->error($model,'title'.$suffix); ?>

    </div>

</fieldset>

<?php endforeach; ?>



To enable search on translated fields, you can modify the search() function in the model like this :




public function search()

{

    $criteria=new CDbCriteria;

    

    //...

    //here your criteria definition

    //...


    return new CActiveDataProvider($this, array(

        'criteria'=>$this->ml->modifySearchCriteria($criteria),

        //instead of

        //'criteria'=>$criteria,

    ));

}



warning: the modification of the search criteria is based on a simple str_replace so it may not work properly under certain circumstances.

Hope this will help, it’s my first contribution so be cool please :). And sorry for my poor english.

@fredpeaks YOU FREAKIN’ ROCK, that’s a sweet compilation, man… Thanks a lot!

Thanks Trakina!

I’ve edited my previous post and the behavior file with bug corrections and improvements. Mainly I’ve tried to enable search on translated fields and to do so I’ve changed a little the way the lang table has to be created in the database.

Now the translated attributes and the id have to be prefixed (with ‘l_’ by default, but configurable).

And I’ve added a “modifySearchCriteria($criteria)” function in the behavior to use in the “search()” function of the model.

Look at my previous post if you are interested.

Hey there, @fredpeaks, I’ve been using your behavior and it works like a charm, I’ve also made a few changes of my own, namely including vars for a “created” and “modified” datetime fields. That was amazing work, man. Also thanks to @gillemc, I believe your work was based on his, right? And yes, this should be on the base setup of Yii sometime soon.

Thanks again and of course thanks to guillemc who has made the biggest part of the work on this behavior.

About your modifications, I’m sorry but I don’t think datetime fields management have to be included in this behavior because it’s a different functionality. In my application, I’ve used the CTimestampBehavior (included in Yii) next to the MultilingualBehavior and it work fine. And maybe I’m gonna have multilinguals models where I won’t have datetime fields for some reason. So I don’t think we should mix both functionalities.

I’m going to contact guillemc and ask him if he’s interested in publishing this behavior in the Yii extension repository because I think this is an important feature and it should be easily accessible to everyone.

Ok, I wasn’t aware of that functionality. Anyway, great work, you guys!

A few bugs corrected, add a "multilang()" function to ease the use and a change made on the "multilangRelation" default name ("multilangPost" instead of "multilang") in order to be able to retrieve languages translation of two or more related models in one query.

Example for a Page with a "articles" HAS_MANY relation :




$model = Page::model()->multilang()->with('articles', 'articles.multilangArticle')->findByPk((int) $id);

echo $model->articles[0]->content_en;



With this method it’s possible to make multi model forms like it’s explained here.

Here is the new version : 2705

MultilingualBehavior.php