Translate WPML via WordPress REST API

Although REST API is now included into the WordPress core, WPML – one of the most popular translation plugin – still didn’t catch up to that. Anyways, after extensive googling I decided it’s better to implement the feature myself. Here is how the translation worked.

Step 1: Add a custom end-point

I added an end-point /translate to do all the translation related works. Regardless of the translation type (string, postcategory etc.) all requested are sent to this end-point.

add_action( 'rest_api_init', function () {
    register_rest_route( 'mysite', '/translate', array(
        'methods' => 'POST',
        'callback' => 'mysite_translate',
        'args' => array(
            'lang_source' => array(
                'required' => true,
                'validate_callback' => function($param, $request, $key) {
                    if( !empty( $param ) ) {
                        return preg_match( '/[a-zA-Z]{2}/', $param );
                    }

                    return false;
                }
            ),
            'lang_dest' => array(
                'required' => true,
                'validate_callback' => function($param, $request, $key) {
                    if( !empty( $param ) ) {
                        return preg_match( '/[a-zA-Z]{2}/', $param );
                    }

                    return false;
                }
            ),
            'type' => array(
                'required' => true,
                'validate_callback' => function($param, $request, $key) {
                    if( !empty( $param ) ) {
                        return in_array( $param, array(
                            'post',
                            'page',
                            'category',
                            'string'
                        ) );
                    }

                    return false;
                }
            ),
            'params' => array(
                'required' => true,
                'validate_callback' => function($param, $request, $key) {
                    return is_array( $param );
                }
            )
        ),
        'permission_callback' => function () {
            return current_user_can( 'edit_others_posts' );
        }
    ) );
} );

So the API call should send a POST request to http://websiteurl.com/mysite/translate and the request should contain four parameters.

lang_source

The source language code (e.g. bn, en etc.). This should match the language code set from WPML settings. Generally, this should be the default language.

lang_dest

The target language code (e.g. bn, en etc.). This should also match a language code available in WPML settings.

type

Type of the translation request. This can be post, page, category, string etc. While post, page and category are set in WordPress core, string refers to the WPML strings that can be translated.

params

This is an array containing all the parameters required for the requested translation type. This may vary based on the request. For example, post requires title, description etc. while string requires string_source, context, string_dest etc.

Step 2: Implement the callback function

function mysite_translate( WP_REST_Request $request ) {
    // If wordpress isn't loaded load it up.
    if ( !defined('ABSPATH') ) {
        $path = $_SERVER['DOCUMENT_ROOT'];
        include_once $path . '/wp-load.php';
    }

    $parameters = $request->get_params();

    if( !isset( $parameters[ 'lang_source' ] ) || empty( $parameters[ 'lang_source' ] ) ) {
        return new WP_Error( 'invalid_translation_request', 'Parameter "lang_source" is required.' );
    }

    if( !isset( $parameters[ 'lang_dest' ] ) || empty( $parameters[ 'lang_dest' ] ) ) {
        return new WP_Error( 'invalid_translation_request', 'Parameter "lang_dest" is required.' );
    }

    if( !isset( $parameters[ 'type' ] ) || empty( $parameters[ 'type' ] ) ) {
        return new WP_Error( 'invalid_translation_request', 'Parameter "type" is required.' );
    }

    if( !isset( $parameters[ 'params' ] ) || empty( $parameters[ 'params' ] ) || !is_array( $parameters[ 'params' ] ) ) {
        return new WP_Error( 'invalid_translation_request', 'Parameter "params" is required and must be an array.' );
    }

    $langSource = $parameters['lang_source'];
    $langDest = $parameters['lang_dest'];
    $type = $parameters['type'];
    $params = $parameters['params'];

    switch( $type ) {
        case 'post':
        case 'page':
            return mysite_translate_post( $langSource, $langDest, $params );
            break;
        case 'category':
            return mysite_translate_term( $langSource, $langDest, $params );
            break;
        case 'string':
            return mysite_translate_string( $langSource, $langDest, $params );
            break;
        default:
            return new WP_Error( 'invalid_translation_request', '"' . $type . '" is not a valid translation type!');
            break;
    }
}

Notice that this callback does not actually do any translation. Here I just checked the parameters and then called the appropriate callback function based on request type. Another thing to note that any custom post type can also use the callback for post and any custom taxonomy can use the callback for category.

Step 3: Translate posts

Post translation is done within the mysite_translate_post function. The params parameter should contain the array with translated values for the post (or page or any custom post type).

Parameters

Values that can be sent within the params parameter field are these:

  • post_id
    – Numeric ID of the source object.
  • title
    – Translated title of the post.
  • content
    – Translated content of the post.
  • excerpt
    – Translated excerpt of the post.
  • custom_fields
    – A list of all the custom fields that need to be copied from the source language. This should only contain the field names and no values. Example values in custom_fields should be like this:

    'custom_fields' => array( 
        'custom_field_1', 
        'custom_field_2', 
        '...', 
        '...'
    )

The mysite_translate_post function description is like this:

function mysite_translate_post( $langSource, $langDest, $parameters )
{
    // If wordpress isn't loaded load it up.
    if ( !defined('ABSPATH') ) {
        $path = $_SERVER['DOCUMENT_ROOT'];
        include_once $path . '/wp-load.php';
    }

    // Include WPML API
    include_once( WP_PLUGIN_DIR . '/sitepress-multilingual-cms/inc/wpml-api.php' );

    if( !isset( $parameters[ 'post_id' ] ) || !is_numeric( $parameters[ 'post_id' ] ) ) {
        return new WP_Error( 'invalid_translation_parameters', 'Parameter "post_id" is required for the translation type.');
    }

    /* @var $post WP_Post */
    $post = get_post( $parameters['post_id'] );
    if ( is_null($post) ) {
        return new WP_Error( 'post_not_fount', 'Post with ID "'. $parameters['post_id'] .'" not found!' );
    }

    $postId = get_post_field( 'ID', $post );
    $postType = get_post_type( $post );

    //Copy the featured image ID.
    $postThumbnailId = get_post_thumbnail_id( $post );

    //Set title or copy from source object
    $postTranslatedTitle = $parameters[ 'title' ] ? sanitize_text_field( $parameters[ 'title' ] ) : get_post_field( 'post_title', $post );
    //Set the content or set from source object
    $postTranslatedContent = $parameters[ 'content' ] ? wpautop( wp_kses_post( $parameters[ 'content' ] ) ) : get_post_field( 'post_content', $post );
    //Set the excerpt or set from source object
    $postTranslatedExcerpt = $parameters[ 'excerpt' ] ? sanitize_textarea_field( $parameters[ 'excerpt' ] ) : get_post_field( 'post_excerpt', $post );
    
    // Check if translated post already exists
    if ( !is_null( wpml_object_id_filter( $postId, $postType, false, $langDest ) ) ) {
        return new WP_Error( 'post_exists', ucfirst($postType) . ' "'. get_the_title($post) .'" translated to "'. $langDest .'" already exists!' );
    }

    // Check if translated post title already exists
    if ( get_page_by_title( $postTranslatedTitle, OBJECT, $postType ) ) {
        return new WP_Error( 'post_title_exists', ucfirst($postType) . ' "'. $postTranslatedTitle .'" title already exists!' );
    }

    // Prepare post terms to duplicate
    $postTaxonomies = get_post_taxonomies( $post );
    $postTaxonomiesTranslated = array();
    foreach ( $postTaxonomies as $postTax ) {
        $postTerms = wp_get_post_terms( $postId, $postTax );
        
        foreach ( $postTerms as $postTerm ) {
            // Check if terms already translated
            $postTermTranslatedId = wpml_object_id_filter( get_term_field( 'term_id', $postTerm ), $postTax, false, $langDest );
            
            if ( is_null($postTermTranslatedId) ) {
                return new WP_Error( 'post_translated', 'Translate "'. get_term_field( 'name', $postTerm ) .'" term with ID = "'. get_term_field( 'term_id', $postTerm ) .'" in "'. $postTax .'" taxonomy first!' );
            }
            
            $postTaxonomiesTranslated[ $postTax ][] = $postTermTranslatedId;
        }
    }
    
    // Insert translated post
    $postTranslatedId = wp_insert_post( array(
                            'post_title' => $postTranslatedTitle,
                            'post_content' => $postTranslatedContent,
                            'post_excerpt' => $postTranslatedExcerpt,
                            'post_type' => $postType,
                            'post_status' => 'publish'
                        ), true );
    if ( $postTranslatedId instanceof WP_Error ) {
        return $postTranslatedId;
    }
    
    // Set post terms
    foreach ( $postTaxonomiesTranslated as $postTaxonomyTranslated => $postTermsTranslated ) {
        wp_set_post_terms( $postTranslatedId, $postTermsTranslated, $postTaxonomyTranslated );
    }

    // Set post featured image if any
    if ( $postThumbnailId ) {
        set_post_thumbnail( $postTranslatedId, $postThumbnailId );
    }
    
    // Set post custom fields
    foreach ( $parameters[ 'custom_fields' ] as $customFieldName ) {
        $customFieldValue = get_post_meta( $postId, $customFieldName, true );
        
        if ($customFieldValue) {
            add_post_meta( $postTranslatedId, $customFieldName, $customFieldValue );
        }
    }
    
    // Get trid of original post
    $trid = wpml_get_content_trid( 'post_' . $postType, $postId );
    
    // Associate original post and translated post
    global $wpdb;
    $wpdb->update( $wpdb->prefix.'icl_translations', array( 'trid' => $trid, 'element_type' => 'post_' . $postType, 'language_code' => $langDest, 'source_language_code' => $langSource ), array( 'element_id' => $postTranslatedId ) );

    // Return translated post
    return get_post( $postTranslatedId );
}

Just like that, the post is translated and linked to the original post through WPML.

Step 4: Translate taxonomy

All taxonomy terms along with custom taxonomies are translated using the mysite_translate_term function. This function also expects a set of values in the params parameter.

Parameters

Values that can be sent within the params parameter field are these:

  • term_id
    – Numeric ID of the source object.
  • name
    – Translated title of the term.
  • description
    – Translated description of the term.
  • custom_fields
    – A list of all the custom fields that need to be copied from the source language. This should only contain the field names and no values. Example values in custom_fields should be like this:

    'custom_fields' => array( 
        'custom_field_1', 
        'custom_field_2', 
        '...', 
        '...'
    )

The mysite_translate_term function description is like this:

function mysite_translate_term( $langSource, $langDest, $parameters )
{
    // If wordpress isn't loaded load it up.
    if ( !defined('ABSPATH') ) {
        $path = $_SERVER['DOCUMENT_ROOT'];
        include_once $path . '/wp-load.php';
    }

    // Include WPML API
    include_once( WP_PLUGIN_DIR . '/sitepress-multilingual-cms/inc/wpml-api.php' );

    if( !isset( $parameters[ 'term_id' ] ) || !is_numeric( $parameters[ 'term_id' ] ) ) {
        return new WP_Error( 'invalid_translation_parameters', 'Parameter "term_id" is required for the translation type.');
    }

    /* @var $term WP_Term */
    $term = get_term( $parameters[ 'term_id' ] );
    if ( $term instanceof WP_Error ) {
        return $term;
    }

    $termId = get_term_field( 'term_id', $term );
    $taxonomyName = get_term_field( 'taxonomy', $term );
    
    //Set the term name or copy from source language
    $termTranslatedName = $parameters[ 'name' ] ? sanitize_text_field( $parameters[ 'name' ] ) : get_term_field( 'name', $term ) .' ('. strtoupper( $langDest ).')';
    //Set the term description or copy from source language
    $termTranslatedDescription = $parameters[ 'description' ] ? wpautop( wp_kses_post( $parameters[ 'description' ] ) ) : get_term_field( 'description', $term );
    
    // Check if translated term already exists
    if ( !is_null( wpml_object_id_filter( $termId, $taxonomyName, false, $langDest ) ) ) {
        return new WP_Error( 'termt_exists', ucfirst( $taxonomyName ) . ' "'. get_term_field( 'name', $term ) .'" translated to "'. $langDest .'" already exists!' );
    }

    // Insert translated term
    $termData = wp_insert_term( $termTranslatedName, $taxonomyName, array( 'description' => $termTranslatedDescription ) );
    if ($termData instanceof WP_Error) {
        return $termData;
    }

    // Set term custom fields
    foreach ( $parameters[ 'custom_fields' ] as $customFieldName ) {
        $customFieldValue = get_term_meta( $termId, $customFieldName, true );
        
        if ($customFieldValue) {
            add_term_meta( $termData[ 'term_id' ], $customFieldName, $customFieldValue );
        }
    }

    // Get trid of original term
    $trid = wpml_get_content_trid( 'tax_' . $taxonomyName, $termId );
    
    // Associate original term and translated term
    global $wpdb;
    $wpdb->update( $wpdb->prefix.'icl_translations', array( 'trid' => $trid, 'language_code' => $langDest, 'source_language_code' => $langSource ), array( 'element_id' => $termData[ 'term_id' ], 'element_type' => 'tax_' . $taxonomyName ) );

    // Return translated term
    return get_term( $termData[ 'term_id' ] );
}

This function creates a new term in the target language and associate that with the source term.

Step 5: Translate string

WPML scans strings from themes and plugins and add them as source string in the database. I wanted to add the option to translate those strings as well using the API. This function does just that. This function also requires an array in the params parameter.

Parameters

Values that can be sent within the params parameter field are these:

  • context
    – The domain of the string (e.g. wordpress).
  • string_source
    – The string in the source language.
  • string_dest
    – The translated string in the target language.

The mysite_translate_string function descrption is like this:

function mysite_translate_string( $langSource, $langDest, $parameters )
{
    global $wpdb;
    
    //Hardcode translated status
    $statusTranslated = '10';

    $stringResults = $wpdb->get_results( $wpdb->prepare( "SELECT id, status FROM {$wpdb->prefix}icl_strings WHERE value=%s AND language=%s AND context=%s AND status!=%s", $parameters[ 'string_source' ], $langSource, $parameters[ 'context' ], $statusTranslated ), ARRAY_A );
    if ( !count( $stringResults ) ) {
        return new WP_Error( 'string_not_fount', 'Untranslated string "'. $parameters['string_source'] .'" with context "'. $parameters['context'] .'" and language "'. $langSource .'" not found!' );
    }

    foreach ( $stringResults as $stringRow ) {
        $stringTranslatedId = $wpdb->insert( $wpdb->prefix . 'icl_string_translations', array( 'string_id' => $stringRow[ 'id' ], 'language' => $langDest, 'status' => $statusTranslated, 'value' => sanitize_text_field( $parameters[ 'string_dest' ] ) ) );
        if ( !is_numeric( $stringTranslatedId ) ) {
            return new WP_Error( 'string_translation_failed', 'String "'. $parameters[ 'string_source' ] .'" with context "'. $parameters[ 'context' ] .'" and language "'. $langSource .'" translation failed!' );
        }

        $wpdb->update( $wpdb->prefix . 'icl_strings', array( 'status' => $statusTranslated ), array( 'id' => $stringRow[ 'id' ] ) );
        if ( !is_numeric( $stringTranslatedId ) ) {
            return new WP_Error( 'string_translated_status_set_failed', 'String "'. $parameters[ 'string_source' ] .'" with context "'. $parameters[ 'context' ] .'" and language "'. $langSource .'" translated status set failed!' );
        }
    }
    
    return true;
}

String translation will return true when successful otherwise an WP_ERROR will be returned.

This article in WPML documentation helps understand the table structure used by the WPML plugin.

Upload media via WordPress REST API

WordPress REST API is quite interesting especially when you are trying to update the website from some third-party resources. Recently I had to implement a similar feature where most of the things like custom post type, taxonomy etc. worked as they should except images. So, here I’m going to show how I made the media upload part work.

Although the media upload documentation says it should work with a single request, for some mysterious reason, it didn’t. I had to break the request into two parts.

Part 1: Upload the file

First I uploaded the image using a POST request. I only provided the image path in this request without any additional data.

//The image path in file system
$image_path = path/to/images/test-image.jpg;
//Upload the image
$uploaded_image = upload_image( $image_path );

I added CURL requests within the upload_image function.

function upload_image( $path ) {
    $request_url = 'http://websiteurl.com/wp/v2/media';

    $image = file_get_contents( $path );
    $mime_type = mime_content_type( $path );

    $api = curl_init();

    //set the url, POST data
    curl_setopt( $api, CURLOPT_URL, $request_url );
    curl_setopt( $api, CURLOPT_POST, 1 );
    curl_setopt( $api, CURLOPT_POSTFIELDS, $image );
    curl_setopt( $api, CURLOPT_HTTPHEADER, array( 'Content-Type: ' . $mime_type, 'Content-Disposition: attachment; filename="' . basename($path) . '"' ) );
    curl_setopt( $api, CURLOPT_RETURNTRANSFER, 1 );
    curl_setopt( $api, CURLOPT_HTTPAUTH, CURLAUTH_BASIC );
    curl_setopt( $api, CURLOPT_USERPWD, USERNAME . ':' . PASSWORD );

    //execute post
    $result = curl_exec( $api );

    //close connection
    curl_close( $api );

    return json_decode( $result );
}

The Content-Type and Content-Description values are very important while uploading image/media. Without appropriate mime type, the upload request will fail.

Part 2: Update media information

Once the image was successfully uploaded without any title, meta etc. I sent a second request to update the media.

/**
 * Let's update the image caption and other fields
 */
$fields = array(
    "date"          => date( 'Y-m-d H:i:s', $time ),
    "status"        => "publish",
    "title"         => "An interesting title for the image",
    "description"   => "Description of the image",
    "alt_text"      => "Images should have alt value",
    "caption"       => "Do not forget caption"
);
//Send the previously uploaded image ID in parameter
$updated_image = update_image_info( $uploaded_image->id, $fields );

The update_image_info function is pretty much the same as upload_image function.

function update_image_info( $id, $data = array() ) {
    $request_url = 'http://websiteurl.com/wp/v2/media/' . $id;

    $fields_string = json_encode( $data );

    $api = curl_init();

    //set the url, POST data
    curl_setopt( $api, CURLOPT_URL, $request_url );
    curl_setopt( $api, CURLOPT_POST, count($data) );
    curl_setopt( $api, CURLOPT_POSTFIELDS, $fields_string );
    curl_setopt( $api, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json' ) );
    curl_setopt( $api, CURLOPT_RETURNTRANSFER, 1 );
    curl_setopt( $api, CURLOPT_HTTPAUTH, CURLAUTH_BASIC );
    curl_setopt( $api, CURLOPT_USERPWD, USERNAME . ':' . PASSWORD );

    //execute post
    $result = curl_exec( $api );

    //close connection
    curl_close( $api );

    return json_decode( $result );
}

Notice that the Content-Type is different this time. That’s it. The $updated_image variable has the detail of newly uploaded media.

I’ve used basic authentication in the example for simplicity. But there are several other authentication methods available. Here is an interesting read about implementing OAuth2 with WP API.

 

এই শহরে বসন্ত এসেছে

কংক্রিটের ফাঁকে ফাঁকে অযত্নে বেড়ে ওঠা
বেয়াড়া সবুজ ধুলায় ধূসর,
নতুন মুকুলে ভরে আছে আমের শাখারা –
তবু ঘ্রাণ ছড়ায় না,
বিদ্যুতের তার জুড়ে সারি বাঁধা কাকের
দৃষ্টিতে আস্তাকুঁড়।

শহরটা মৃত্যু যন্ত্রণায় একই রকম গোমরায় –
ব্যস্ততার ভেঁপু, অসহিষ্ণু পথিকের আস্ফালন,
বস্তির গলি থেকে ভেসে আসা খিস্তিতে।
এই শহরে আজ বসন্ত এসেছে।

ফুলের আড়তদার বেলির বদলে
গাঁদার ঝুড়ি নামায়,
রঙ জ্বলে যাওয়া হলুদ পাঞ্জাবীর
এক বালক মালা কেনে
সদ্য কৈশোরের প্রথম শাড়ি পরা
কোনও বালিকার জন্য।

চৈত্রের রোদে শাহবাগের রাস্তায়
দ্বিগুণ দামে কুলফি কেনে ভীরু প্রেমিক,
ঘামে ভিজে রিকশা খোঁজে মরিয়া হয়ে।
এই শহরে আজ বসন্ত এসেছে।

আমি বরং অপেক্ষায় থাকি আগামী বর্ষার,
হাঁটু জলে ডুবে যাবে মিরপুর থেকে পল্টন,
হুড-তোলা রিকশায় জড়সড় প্রেম,
ধূসর ধুলো ঝেড়ে ফুটপাথের মৃতপ্রায় ঝাউঝোপ আবার সতেজ,
জানালার কার্নিশে চুপসানো চড়ুই
আর অজানা গলি থেকে কদমের উন্মাদ গন্ধ।

শ্যাওলা ধরা ভাঙা দেয়ালে বসে
সেদিন আমি বরং দেখবো
বৃষ্টি স্নানে এই বেজন্মা শহরটাকেও
কেমন পবিত্র মনে হয়।

Menu

CSS Dropdown Menu

Let’s create a dropdown menu with CSS and HTML only. There are lots of fancy menus out there. This is just an experimental HTML-CSS based multi-level menu with room for improvements. Someone starting to learn CSS might find this helpful.

To quickly explain how it’s done, menu is contained within ul and each menu item is contained within individual li elements. Menu item with children has a class .children and that adds some extra styling for the child menu segment. The child segment is hidden by default and is only displayed on mouse over (:hover). There is a small arrow before the child segment. Style for that can be found in the :before segment for child ul. This arrow also ensures continuous mouse over presence on the parent li. If the arrow is removed, the space between the parent li and child ul will break the continuity of mouse over effect. That can be fixed using padding to the children.

The nav element wrapping the whole thing is just to create a container and set a width for demo purpose.

Here is the demo code on JSFiddle:

WordPress

WPML URL override on archive page

Last week I ran into an issue with WPML URLs on post type archive page. When the permalink is set to %post_name%, we can access the archive page following //site_url/post_type_slug. And the posts can be filtered by taxonomy using //site_url/post_type_slug?taxonomy_name=taxonomy parameter in the URL. With WPML language switcher things become a bit tricky.

The issue

When I tried to change the language on the archive page while the taxonomy filter was present in the URL, WPML tend to take me to the taxonomy archive page instead. Let’s say for example my URL was http://ghumkumar.com/books/?genre=fiction and when I tried to change to a different language (e.g. French) the URL became http://ghumkumar.com/fr/le-genre/la-fiction while I expected it to be http://ghumkumar.com/livres/?le-genre=la-fiction. Here livresla-genre and la-fiction is French translation of booksgenre and fiction respectively.

This wouldn’t be an issue for most cases. But in this particular case, I wanted to display only the detail of each genre along with some other info on their archive page and then show the books of each genre on the books archive page using the URL parameter.

Solution using WPML filter

The filter icl_ls_languages to the rescue! I added this filter to catch the language switcher and modify the URL before sent out to the output. Here is the final code I used:

Inside the foreach loop of languages I checked if this is a post_type_archive using is_post_type_archive() function. We can use is_search(), is_archive() or any similar function based on our need. Used the get_queried_object() function to get the current taxonomy and get_post_type() to fetch current post type. I checked whether $archived_taxonomy->slug is set to make sure we are filtering by a taxonomy and the WPML’s language switcher is going to redirect me to a different URL. So, next step is to generate the right URL and replace the WPML generated one with that.

Now that we have the taxonomy ID, we can get the counterpart of that in other language using wpml_object_id filter. Problem is, when we pass that ID to get_term() function, WPML takes over and adjusts the ID to our active language. So, we’ll get the taxonomy that I already have! Here comes the use of the global variable $icl_adjust_id_url_filter_off. I backed up the existing value of that variable (generally false), set it to true and then called the get_term() function like this:

//Backup the global variable
$orig_flag_value = $icl_adjust_id_url_filter_off;
//Set the global variable to true
$icl_adjust_id_url_filter_off = true;
//Fetch the translated counterpart of the term
$translated_term = get_term( apply_filters( 'wpml_object_id', $archived_taxonomy->term_id, $archived_taxonomy->taxonomy, false, $lang_code ), $archived_taxonomy->taxonomy );
//Reset the global variable to it's old value
$icl_adjust_id_url_filter_off = $orig_flag_value;

Now that I have the translated taxonomy name and slug, I replaced the query string of current URL using the desired taxonomy slugs. Then replaced the language URL to the post type (books in this case) archive page instead of taxonomy archive page and appended the new query string.

That it! Now the taxonomy archive works fine on its own and the post type archive works fine with query strings. %DRUMROLLS%!!