El tutorial Jobeet

19.5. Internacionalización

19.5.1. Idiomas, codificaciones y conjuntos de caracteres

Cada idioma define su propio conjunto de caracteres. El idioma inglés es el más sencillo porque sólo utiliza los caracteres ASCII. Otros idiomas como el francés son más complicados porque utilizan por ejemplo caracteres acentuados como é. Por último, idiomas como el ruso, el chino o el árabe son mucho más complicados porque todos sus caracteres se encuentran fuera del conjunto de caracteres ASCII. Estos últimos idiomas definen conjuntos de caracteres completamente diferentes.

Cuando se trabaja con aplicaciones internacionalizadas, es mejor seguir la norma unicode. La idea del estándar unicode consiste en crear un conjunto universal de caracteres que incluya todos los caracteres de todos los idiomas de la humanidad. El problema de unicode es que, debido a este enorme conjunto de caracteres, cada carácter puede llegar a necesitar hasta 21 bits para ser representado. Por tanto, para las aplicaciones web utilizamos UTF-8, que transforma los caracteres de Unicode en secuencias de octetos de longitud variable. Empleando UTF-8, los caracteres de los idiomas más utilizados en el mundo se representan con menos de 3 bits cada uno.

UTF-8 es la codificación que utiliza por defecto Symfony, tal y como se establece en el archivo de configuración settings.yml:

# apps/frontend/config/settings.yml
all:
  .settings:
    charset: utf-8

Además, para activar la internacionalización en Symfony, debes establecer la opción i18n a un valor on en el archivo de configuración settings.yml:

# apps/frontend/config/settings.yml
all:
  .settings:
    i18n: on

19.5.2. Plantillas

Un sitio web internacionalizado es aquel cuya interfaz de usuario se traduce a varios idiomas.

En las plantillas, las cadenas de texto que dependen del idioma utilizado se deben encerrar con el helper __() (cuidado al escribir el helper porque son dos guiones bajos seguidos).

El helper __() es parte del grupo de helpers I18N, que contiene helpers que facilitan el trabajo con la internacionalización de las plantillas. Como este grupo de helpers no se carga por defecto, debes incluirlo manualmente en la plantilla mediante use_helper('I18N') (como ya hicimos en su día para el grupo de helpers Text) o puedes cargarlo de forma global en la aplicación utilizando la opción standard_helpers:

# apps/frontend/config/settings.yml
all:
  .settings:
    standard_helpers: [Partial, Cache, I18N]

El siguiente código muestra cómo utilizar el helper __() en el pie de página de Jobeet:

// apps/frontend/templates/layout.php
<div id="footer">
  <div class="content">
    <span class="symfony">
      <img src="/images/jobeet-mini.png" />
      powered by <a href="http://www.symfony-project.org/">
      <img src="/images/symfony.gif" alt="symfony framework" /></a>
    </span>
    <ul>
      <li>
        <a href=""><?php echo __('About Jobeet') ?></a>
      </li>
      <li class="feed">
        <?php echo link_to(__('Full feed'), '@job?sf_format=atom') ?>
      </li>
      <li>
        <a href=""><?php echo __('Jobeet API') ?></a>
      </li>
      <li class="last">
        <?php echo link_to(__('Become an affiliate'), '@affiliate_new') ?>
      </li>
    </ul>
    <?php include_component('language', 'language') ?>
  </div>
</div>

Nota Al helper __() se le puede pasar como argumento la cadena de texto mostrada para el idioma por defecto o también se le puede pasar el identificador único de cada cadena. Elegir una u otra opción es simplemente una cuestión de gusto personal. En Jobeet vamos a utilizar la primera forma porque así las plantillas son mucho más fáciles de leer.

Cuando Symfony procesa la plantilla para mostrarla, cada vez que encuentra una llamada al helper __(), Symfony busca la traducción de la cadena de texto para la cultura actual del usuario. Si se encuentra la traducción, se muestra directamente en la plantilla. Si no se encuentra la traducción, se devuelve el primer argumento del helper __().

Las traducciones se guardan en catálogos. El framework de internacionalización de Symfony incluye muchas formas de guardar las traducciones. En este caso vamos a utilizar el formato XLIFF, que es un estándar internacional y también es el más flexible. Además, XLIFF es el formato utilizado por el generador de la parte de administración y por la mayoría de plugins de Symfony.

Nota Las otras formas de guardar los catálogos son gettext, MySQL y SQLite. Como siempre, no te olvides de echar un vistazo a la API de i18n para descubrir todos los detalles.

19.5.3. La tarea i18n:extract

Si no quieres crear el catálogo a mano, puedes utilizar la tarea i18n:extract:

$ php symfony i18n:extract frontend fr --auto-save

La tarea i18n:extract del ejemplo anterior busca todas las cadenas de texto que deben traducirse al idioma fr en la aplicación frontend y crea o actualiza el catálogo correspondiente. La opción --auto-save hace que se guarden en el catálogo las nuevas cadenas de texto. También puedes hacer uso de la opción --auto-delete para eliminar automáticamente todas las cadenas de texto que ya no existen.

En nuestro caso, la tarea anterior añade todas las cadenas de texto al archivo que hemos creado:

<!-- apps/frontend/i18n/fr/messages.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xliff PUBLIC " *XLIFF*DTD XLIFF//EN"
  "http://www.oasis-open.org/committees/xliff/documents/xliff.dtd">
<xliff version="1.0">
  <file source-language="EN" target-language="fr" datatype="plaintext"
      original="messages" date="2008-12-14T12:11:22Z"
      product-name="messages">
    <header/>
    <body>
      <trans-unit id="1">
        <source>About Jobeet</source>
        <target/>
      </trans-unit>
      <trans-unit id="2">
        <source>Feed</source>
        <target/>
      </trans-unit>
      <trans-unit id="3">
        <source>Jobeet API</source>
        <target/>
      </trans-unit>
      <trans-unit id="4">
        <source>Become an affiliate</source>
        <target/>
      </trans-unit>
    </body>
  </file>
</xliff>

Cada traducción se define mediante una etiqueta trans-unit que tiene un identificador único en forma de atributo id. Ahora ya puedes modificar ese archivo para añadir las traducciones al francés:

<!-- apps/frontend/i18n/fr/messages.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xliff PUBLIC " *XLIFF*DTD XLIFF//EN"
  "http://www.oasis-open.org/committees/xliff/documents/xliff.dtd">
<xliff version="1.0">
  <file source-language="EN" target-language="fr" datatype="plaintext"
      original="messages" date="2008-12-14T12:11:22Z"
      product-name="messages">
    <header/>
    <body>
      <trans-unit id="1">
        <source>About Jobeet</source>
        <target>A propos de Jobeet</target>
      </trans-unit>
      <trans-unit id="2">
        <source>Feed</source>
        <target>Fil RSS</target>
      </trans-unit>
      <trans-unit id="3">
        <source>Jobeet API</source>
        <target>API Jobeet</target>
      </trans-unit>
      <trans-unit id="4">
        <source>Become an affiliate</source>
        <target>Devenir un affilié</target>
      </trans-unit>
    </body>
  </file>
</xliff>

Nota Como XLIFF es un formato estándar, existen muchas herramientas que facilitan el proceso de traducción. Open Language Tools es un proyecto de software libre creado con Java que incluye un editor de archivos en formato XLIFF.

Nota Como XLIFF es un formato basado en archivos de texto, se le aplican las mismas reglas de la configuración en cascada que se utiliza para los archivos de configuración de Symfony. Se pueden definir archivos i18n a nivel de proyecto, aplicación y módulo, aplicándose siempre la traducción del archivo más específico.

19.5.4. Traducciones con variables

El principal objetivo de la internacionalización consiste en traducir frases enteras. No obstante, algunas frases incluyen partes variables. En Jobeet, este caso se produce con los enlaces "and X more..." de la portada, donde X es el número de ofertas de trabajo disponibles:

<!-- apps/frontend/modules/job/templates/indexSuccess.php -->
<div class="more_jobs">
  and <?php echo link_to($count, 'category', $category) ?> more...
</div>

Como el número de ofertas de trabajo es variable, en la traducción tenemos que sustituirlo por una variable:

<!-- apps/frontend/modules/job/templates/indexSuccess.php -->
<div class="more_jobs">
  <?php echo __('and %count% more...', array('%count%' => link_to($count, 'category', $category))) ?>
</div>

Ahora la cadena de texto que tenemos que traducir es and %count% more..., siendo %count% la variable que se va a sustituir por el número de ofertas de trabajo indicado como segundo argumento del helper __().

Añade la nueva cadena de texto en una etiqueta trans-unit del archivo messages.xml, o utiliza la tarea i18n:extract para actualizar el archivo automáticamente:

$ php symfony i18n:extract frontend fr --auto-save

Después de ejecutar la tarea, abre el archivo XLIFF y añade la correspondiente traducción al francés:

<trans-unit id="5">
  <source>and %count% more...</source>
  <target>et %count% autres...</target>
</trans-unit>

El único requisito de la traduccón es que debes utilizar en algún sitio la variable %count%.

Traducir otras cadenas de texto puede llegar a ser muy complicado por el uso de los plurales. Estas cadenas de texto cambian en función del valor de algunos números. Además, el comportamiento de los plurales no es idéntico en todos los idiomas, ya que idiomas como el ruso o el polaco tienen reglas gramaticales muy complejas para los plurales.

En la página de cada categoría, se muestra el número de ofertas de trabajo disponibles para esa categoría:

<!-- apps/frontend/modules/category/templates/showSuccess.php -->
<strong><?php echo $pager->getNbResults() ?></strong> jobs in this category

Cuando la traducción de una cadena de texto es diferente en función del valor de un número, debes utilizar el helper format_number_choice():

<?php echo format_number_choice(
    '[0]No job in this category|[1]One job in this category|(1,+Inf]%count% jobs in this category',
    array('%count%' => '<strong>'.$pager->getNbResults().'</strong>'),
    $pager->getNbResults()
  )
?>

El helper format_number_choice() requiere tres argumentos:

  • La cadena de texto que se utiliza en función del número
  • Un array con las sustituciones de la parte variable
  • El número empleado para determinar la traducción que se utiliza

La cadena que establece las diferentes traducciones a utilizar en función del valor del número emplea el siguiente formato:

  • Cada posible traducción se separa de las demás mediante una barra vertical (|)
  • Cada cadena de texto está formada por un rango seguido de una traducción

El rango puede describir cualquier tipo de rango numérico:

  • [1,2]: acepta todos los valores entre 1 y 2, incluyendo 1 y 2
  • (1,2): acepta todos los valores entre 1 y 2, salvo 1 y 2
  • {1,2,3,4}: sólo acepta los números indicados en ese conjunto de valores
  • [-Inf,0): acepta valores mayores o iguales que -infinito y estrictamente inferiores a 0
  • {n: n % 10 > 1 && n % 10 < 5}: acepta números como 2, 3, 4, 22, 23, 24, etc.

Traducir esta cadena de texto es similar a traducir cualquier otra cadena:

<trans-unit id="6">
  <source>[0]No job in this category|[1]One job in this category|(1,+Inf]%count% jobs in this category</source>
  <target>[0]Aucune annonce dans cette catégorie|[1]Une annonce dans cette catégorie|(1,+Inf]%count% annonces dans cette catégorie</target>
</trans-unit>

Ahora que ya sabes cómo traducir cualquier tipo de cadena de texto, dedica un tiempo a añadir llamadas al helper __() en todas las plantillas de la aplicación frontend. Por el momento no vamos a traducir la aplicación backend.

19.5.5. Formularios

Las clases de los formularios incluyen muchas cadenas de texto que tenemos que traducir, como etiquetas, mensajes de error y mensajes de ayuda. Symfony se encarga de internacionalizar automáticamente todas estas cadenas de texto, por lo que sólo es necesario que definas la traducción en los archivos XLIFF.

Nota Desafortunadamente, la tarea i18n:extract no es capaz por el momento de procesar las clases de los formularios en busca de cadenas de texto sin traducir.

19.5.6. Objetos Propel

En el sitio web de Jobeet no vamos a traducir el contenido de todas las tablas porque no tiene sentido que los usuarios que publican ofertas de trabajo tengan que traducir sus ofertas a todos los idiomas disponibles. No obstante, sí que vamos a traducir el contenido de la tabla category.

El plugin de Propel ya incluye el soporte de tablas internacionalizadas. Por cada tabla que vamos a traducir, tenemos que crear dos tablas: una para las columnas que son independientes de la internacionalización y otra para todas las columnas cuyos valores se van a traducir. Las dos tablas están relacionadas mediante una relación de tipo uno-a-muchos.

Por lo tanto, actualiza el archivo schema.yml para crear las dos tablas relacionadas con las categorías:

# config/schema.yml
jobeet_category:
  _attributes:  { isI18N: true, i18nTable: jobeet_category_i18n }
  id:           ~

jobeet_category_i18n:
  id:           { type: integer, required: true, primaryKey: true, foreignTable: jobeet_category, foreignReference: id }
  culture:      { isCulture: true, type: varchar, size: 7, required: true, primaryKey: true }
  name:         { type: varchar(255), required: true }
  slug:         { type: varchar(255), required: true }

La opción _attributes define las opciones de la tabla. Después de modificar el esquema, actualiza la parte de las categorías en los archivos de datos:

# data/fixtures/010_categories.yml
JobeetCategory:
  design:        { }
  programming:   { }
  manager:       { }
  administrator: { }

JobeetCategoryI18n:
  design_en:        { id: design, culture: en, name: Design }
  programming_en:   { id: programming, culture: en, name: Programming }
  manager_en:       { id: manager, culture: en, name: Manager }
  administrator_en: { id: administrator, culture: en, name: Administrator }

  design_fr:        { id: design, culture: fr, name: Design }
  programming_fr:   { id: programming, culture: fr, name: Programmation }
  manager_fr:       { id: manager, culture: fr, name: Manager }
  administrator_fr: { id: administrator, culture: fr, name: Administrateur }

A continuación, vuelve a generar las clases del modelo para que se creen las clases relacionadas con la internacionalización:

$ php symfony propel:build-all --no-confirmation
$ php symfony cc

Como las columnas name y slug se han movido a la tabla internacionalizada, mueve el método setName() de JobeetCategory a JobeetCategoryI18n:

// lib/model/JobeetCategoryI18n.php
public function setName($name)
{
  parent::setName($name);

  $this->setSlug(Jobeet::slugify($name));
}

También debemos arreglar el método getForSlug() de la clase JobeetCategoryPeer:

// lib/model/JobeetCategoryPeer.php
static public function getForSlug($slug)
{
  $criteria = new Criteria();
  $criteria->addJoin(JobeetCategoryI18nPeer::ID, self::ID);
  $criteria->add(JobeetCategoryI18nPeer::CULTURE, 'en');
  $criteria->add(JobeetCategoryI18nPeer::SLUG, $slug);

  return self::doSelectOne($criteria);
}

Nota Como la tarea propel:build-all borra todas las tablas y toda la información de la base de datos, no te olvides de volver a crear un usuario para acceder a la parte de administración de Jobeet mediante la tarea guard:create-user. Si lo prefieres, puedes crear un archivo de datos para añadir este usuario de forma automática.

Después de construir el modelo, verás que Symfony crea métodos en el objeto JobeetCategory principal para acceder a las columnas internacionalizadas definidas en la clase JobeetCategoryI18n:

$category = new JobeetCategory();

$category->setName('foo');       // sets the name for the current culture
$category->setName('foo', 'fr'); // sets the name for French

echo $category->getName();     // gets the name for the current culture
echo $category->getName('fr'); // gets the name for French

Nota Si quieres reducir el número de consultas a la base de datos, utiliza el método doSelectWithI18n() en vez del tradicional método doSelect(). Este nuevo método obtiene en una sola consulta el objeto principal y el objeto internacionalizado asociado.

$categories = JobeetCategoryPeer::doSelectWithI18n($c, $culture);

Como la ruta category está asociada a la clase JobeetCategory del modelo y como slug ahora es parte de JobeetCategoryI18n, la ruta no es capaz de obtener el objeto Category automáticamente. Vamos a crear un método para ayudar al sistema de enrutamiento a obtener el objeto:

// lib/model/JobeetCategoryPeer.php
class JobeetCategoryPeer extends BaseJobeetCategoryPeer
{
  static public function doSelectForSlug($parameters)
  {
    $criteria = new Criteria();
    $criteria->addJoin(JobeetCategoryI18nPeer::ID, JobeetCategoryPeer::ID);
    $criteria->add(JobeetCategoryI18nPeer::CULTURE, $parameters['sf_culture']);
    $criteria->add(JobeetCategoryI18nPeer::SLUG, $parameters['slug']);

    return self::doSelectOne($criteria);
  }
}

Después, utiliza la opción method en la ruta category para indicar que doSelectForSlug() es el método que se debe utilizar para obtener el objeto:

# apps/frontend/config/routing.yml
category:
  url:     /:sf_culture/category/:slug.:sf_format
  class:   sfPropelRoute
  param:   { module: category, action: show, sf_format: html }
  options: { model: JobeetCategory, type: object, method: doSelectForSlug }
  requirements:
    sf_format: (?:html|atom)

Por último, volvemos a cargar los archivos de datos para que se generen los slugs adecuados para cada categoría:

$ php symfony propel:data-load

Después de todos estos cambios, la ruta category ya está internacionalizada y la URL de una categoría incluye la traducción del slug correspondiente:

/frontend_dev.php/fr/category/programmation
/frontend_dev.php/en/category/programming

19.5.7. El generador de la parte de administración

Debido a un error en la versión 1.2.1 de Symfony, comenta la opción title en la sección edit:

# apps/backend/modules/category/config/generator.yml
edit:
  #title: Editing Category "%%name%%" (#%%id%%)

En la aplicación backend, queremos utilizar el mismo formulario para modificar las categorías tanto en inglés como en francés:

Modificando las categorías en dos idiomas a la vez

Figura 19.2 Modificando las categorías en dos idiomas a la vez

Utiliza el método embedI18N() para incluir un formulario internacionalizado:

// lib/form/JobeetCategoryForm.class.php
class JobeetCategoryForm extends BaseJobeetCategoryForm
{
  public function configure()
  {
    unset($this['jobeet_category_affiliate_list']);

    $this->embedI18n(array('en', 'fr'));
    $this->widgetSchema->setLabel('en', 'English');
    $this->widgetSchema->setLabel('fr', 'French');
  }
}

La interfaz del generador de la parte de administración incluye soporte para su internacionalización. Por defecto incluye las traducciones en 20 idiomas y es realmente sencillo añadir una nueva traducción o modificar una traducción existente. Copia en el directorio i18n de la aplicación el archivo del idioma que vas a modificar (las traducciones de la parte de administración se encuentran en lib/vendor/symfony/lib/plugins/sfPropelPlugin/i18n/). Como el archivo de tu aplicación se fusiona después con el de Symfony, puedes borrar todas las cadenas de texto cuya traducción no vas a modificar.

Como ya habrás visto, los archivos con las traducciones del administrador se llaman sf_admin.fr.xml en vez de fr/messages.xml. De hecho, el valor messages es el nombre del catálogo que utiliza Symfony por defecto y puedes modificarlo por cualquier otro nombre que quieras para permitir una mejor separación entre las diferentes partes de la aplicación. No obstante, si utilizas cualquier catálogo diferente al de por defecto, tienes que indicarlo explícitamente en cada llamada al helper __():

<?php echo __('About Jobeet', array(), 'jobeet') ?>

En el ejemplo anterior, Symfony busca la traducción de la cadena "About Jobeet" en el catálogo llamado jobeet.

19.5.8. Pruebas

Para completar la migración a una aplicación internacionalizada, no te olvides de arreglar las pruebas. En primer lugar, actualiza la información de las categorías en los archivos de datos copiando en el archivo test/fixtures/010_categories.yml los datos utilizados en las secciones anteriores. Después, vuelve a generar las clases del modelo para el entorno test:

$ php symfony propel:build-all-load --no-confirmation --env=test

Por último, ejecuta todas las pruebas para asegurar que no has cometido ningún error:

$ php symfony test:all

Nota Cuando creamos la aplicación backend de Jobeet, no añadimos ninguna prueba funcional. Sin embargo, siempre que creas un módulo mediante la línea de comandos de Symfony se crean unas pruebas funcionales de ejemplo. Si quieres, puedes borrar todos estos archivos de prueba.