Features and i18n configuration management, part 2: Menu item madness

Last week, I started writing about my tribulations managing the configuration of a multisite, multilingual application using Features, i18n, and friends. I listed the site components that needed to be managed, and described the basics of saving string translations in a feature.

This week, I'll describe a particularly challenging component I had to deal with: inoffensive-sounding menu items. Should be easy, right? Well, it wasn't.

Multilingual menu items

I won't get into the basics of creating multilingual menu items here. There is good documentation on how to get that set up using the i18n submodule i18n_menu. To provide a context for this post, I'll just mention that we are manually creating menu items, through the admin UI. We have two types of menu items:

  • Ones that should appear in both languages, localized, and pointing to the same place. Those are created with the language set to "Language neutral".
  • Ones that should only appear in a specific language. We set that language explicitly on the menu item's form.

Internally, the i18n_menu modifies the core menu_links and menu_custom tables by adding, among others, a language attribute to save the above info. In addition, the module creates a new text group called menu to save menu translations. In this group, each translation is identified by a menu name and mlid to refer back to the original menu item.

Problem #1: Using mlid to identify translations

The first problem occurred even before we introduced Features into the mix. The decision to use the mlid as part of the translation identifier means that, across site stages and instances, menu items should have the same database primary key in order to be correctly translated. This design decision introduced a lot of instability in our configurations, for example preventing us from creating new menu items on the fly, for testing or customization purposes. In essence, we would have needed to stick to an install profile approach to manage the site configuration - which is a desirable goal in itself, but one we didn't pursue. In the mean time, our menu item string translations were only reliably showing on the machine where translation occurred, but not elsewhere. This clearly had to be fixed.

We found the module Entity menu links to solve one half of this problem. This module creates a uuid for each menu item, ensuring that uuid remains unchanged throughout the lifetime of the menu item. This module also modifies the core menu_links table by adding to it a uuid attribute.

Still, the string translations were being saved with the mlid. How to convince i18n_menu to use the uuid instead? That's where we had to write some code. The i18n architecture uses the concept of i18n objects that correspond to Drupal site components. We used the following code to override the i18n object info for menu items to use the uuid as a key:

/**
 * Implements hook_i18n_object_info_alter().
 */
function checkdesk_core_i18n_object_info_alter(&$info) {
  // Use UUID field to identify menu_link.
  $info['menu_link']['key'] = 'uuid';
  $info['menu_link']['load callback'] = 'checkdesk_core_i18n_menu_link_uuid_load';
}

/**
 * Callback to load menu_link by UUID.
 */
function checkdesk_core_i18n_menu_link_uuid_load($uuid) {
 if (!empty($uuid)) {
    $query = db_select('menu_links', 'ml');
    $query->leftJoin('menu_router', 'm', 'm.path = ml.router_path');
    $query->fields('ml');
    // Weight should be taken from {menu_links}, not {menu_router}.
    $query->addField('ml', 'weight', 'link_weight');
    $query->fields('m');
    $query->condition('ml.uuid', $uuid);
    if ($item = $query->execute()->fetchAssoc()) {
      $item['weight'] = $item['link_weight'];
      _menu_link_translate($item);
      return $item;
    }
  }
  return FALSE;
}

Unfortunately, the strings were still showing up with the mlid on the translation interface. After many a WTF incantation, we found that i18n_menu was not honouring the i18n object key attribute while creating the string identifiers. We submitted a patch to fix this.

At this point, we had successfully modified the indexing mechanism of i18n_menu to use uuids, as shown below. Our testing with manual .po files revealed that regardless of mlid differences, menu item translations were being successfully moved from one instance to another.

i18n_menu with UUID identifiers

Problem #2: Persisting menu items in a feature

Now menu item translations were made reliable across instances, but they still weren't being saved in a feature. The core Features module does support menu items, but it does not export the additional attributes we introduced above, namely language and uuid. We also need to export customized because i18n will not translate menu items that are not marked as customized.

We submitted a simple patch to Features that allows a hook_query_TAG_alter to extend the relevant query and return extra fields to be exported. Our implementation of this hook looks like this:

/**
 * Implements hook_query_TAG_alter() for `features_menu_link`.
 */
function checkdesk_core_query_features_menu_link_alter($query) {
  // Add missing attributes for translation.
  $query->fields('menu_links', array('uuid', 'language', 'customized'));
}

After this change, the exported menu links look like this (note the last 3 attributes on each entry):

/**
 * @file
 * checkdesk_core_feature.features.menu_links.inc
 */

/**
 * Implements hook_menu_default_menu_links().
 */
function checkdesk_core_feature_menu_default_menu_links() {
  $menu_links = array();

  // Exported menu link: main-menu:node/add/discussion
  $menu_links['main-menu:node/add/discussion'] = array(
    'menu_name' => 'main-menu',
    'link_path' => 'node/add/discussion',
    'router_path' => 'node/add/discussion',
    'link_title' => 'Create story',
    'options' => array(
      'attributes' => array(
        'title' => '',
      ),
      'alter' => TRUE,
    ),
    'module' => 'menu',
    'hidden' => '0',
    'external' => '0',
    'has_children' => '0',
    'expanded' => '0',
    'weight' => '-47',
    'uuid' => 'edc54df9-4aa8-bf84-dd89-ca0a351af23b',
    'language' => 'und',
    'customized' => '1',
  );
  // Exported menu link: main-menu:node/add/media
  $menu_links['main-menu:node/add/media'] = array(
    'menu_name' => 'main-menu',
    'link_path' => 'node/add/media',
    'router_path' => 'node/add/media',
    'link_title' => 'Submit report',
    'options' => array(
      'attributes' => array(
        'title' => '',
      ),
      'alter' => TRUE,
    ),
    'module' => 'menu',
    'hidden' => '0',
    'external' => '0',
    'has_children' => '0',
    'expanded' => '0',
    'weight' => '-49',
    'uuid' => '0bc3af5d-28a8-c864-bd93-f17d8bea2366',
    'language' => 'und',
    'customized' => '1',
  );
  ...
}

Conclusion and a call for help

With these patches, we were able to reliably persist multilingual menu links using Features. The menu item translations are saved in the translations component of the feature, as described in part 1 of this series. They look like this:

/**
 * @file
 * checkdesk_core_feature.features.translations.inc
 */

/**
 * Implements hook_translations_defaults().
 */
function checkdesk_core_feature_translations_defaults() {
  $translations = array();
  $translations['ar:menu']['a6b48d33d248c146aa8193cb6f618651'] = array(
    'source' => 'Create story',
    'context' => 'item:edc54df9-4aa8-bf84-dd89-ca0a351af23b:title',
    'location' => 'menu:item:edc54df9-4aa8-bf84-dd89-ca0a351af23b:title',
    'translation' => 'أنشئ خبر',
    'plid' => '0',
    'plural' => '0',
  );
  $translations['ar:menu']['8578b45ff528c4333ef4034b3ca1fe07'] = array(
    'source' => 'Submit report',
    'context' => 'item:0bc3af5d-28a8-c864-bd93-f17d8bea2366:title',
    'location' => 'menu:item:0bc3af5d-28a8-c864-bd93-f17d8bea2366:title',
    'translation' => 'أضف تقرير',
    'plid' => '0',
    'plural' => '0',
  );
  ...
}

Now I need your help to review and support (and possibly enhance) the patches submitted to i18n and Features. Please visit them here:

As you know, the more people show interest in a patch, the more likely it will go in quickly. Your help is appreciated!

Next time

Next time, I'll describe other components of the multilingual puzzle: taxonomy terms, static pages, etc.

AttachmentSize
i18n_menu_with_uuid.png81.31 KB

Comments

Hello ! I've followed your

Hello !

I've followed your post to have my menu items translated and moved in features correctly - works perfect :-) I do encounter a problem when deploying my features from scratch though : I created a custom installation profile with some custom features, including menu and menu items. Everytime i deploy my feature, i see that my menus items uuid changes, so my features stays in overriden state. Any clue how to have it “stable” across installs ? Is it even possible ?

I've seen this happen with

I've seen this happen with menu items that are generated by modules, as opposed to menu items that are manually created. For example, Views-generated menu items change their uuid every time I deploy or regenerate the feature - not sure which, because I haven't debugged deep enough. My workaround is to avoid using module-generated items :-)

Hi Karim I've been following

Hi Karim

I've been following your guide and have so far managed to successfully export my taxonomy terms to my feature. However, I'm having a problem with the menu translations however, and I hope you can help me out. I'm using l10n_update to reduce the strings in my feature, as you explained how to do in part 3 of your guide. But the l10n_status field in my uuid menu items in locales_target never gets updated so always stays set to 0. This means that the db check in _features_translations_locale_export_get_strings() doesn't return my translations. Is there something else I need to do to get this to work?

Thanks in advance! Miriam

Update: both patches

Update: both patches mentioned in this post have been committed.

Hi, Nice articles (and

Hi,

Nice articles (and patches), very useful, thanks. Did you ever get to part 3?