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:

Showing countdown timer in email

Last day I received an email newsletter which contained a countdown timer in it. It was quite interesting because most of the times emails are static and we are unable to show any dynamic data. Then again, there is the GIF image format which can show animated images. From investigating the source I figured that they are doing exactly that. They are using the service from a third party provider to create “countdown” GIF images.

So, I thought why not make one for myself. And I came up with a class to accept target time (end time of countdown) and some other parameters which will return an animated GIF image resource. We can then embed that image in the email and the email body will show a nice little countdown timer. More about the class and usage is available on GitHub.

Demo: A 15 minute timer

15 minutes

Fork the class from GitHub: https://github.com/ashiqur-rony/gif-timer

There is a catch though. As the timer creates a frame for each step (one frame per second in the above demo), this method is really resource hungry.

Email Obfuscation – WordPress Shortcode

Recently I was working on a WordPress website and was into a situation where I wanted to protect the email address from spam bots but also didn’t want the legitimate users to go through the trouble of filling out captcha and click an extra link. WordPress do have a nice function called antispambot which does it alright. But not up to my satisfaction. This function converts the email address to special characters which is quite intelligent but let’s face it. Robots are going to take over the world soon. They are becoming more smart every day. It is quite possible that some bots (at least their coder) knows how WordPress does it and a simple decode will reveal the email address for them. Recaptcha also have a feature to protect emails from bots. But this is quite tedious. User have to leave the website just to see the email address. Also the Recaptcha’s popup window looks really awful on mobile devices.

So, I created a shortcode that will hide the email address by default and reveal it to users using JavaScript. At least bots are not smart enough to use JavaScript (yet!). As a very limited number of users will use browsers with JavaScript disable, they can do the extra work and get the email address through recaptcha validation. Let’s see the code.

function my_hide_email_shortcode( $atts , $content = null ) {
    $a = shortcode_atts( array(
        'email' => '',
    ), $atts );
    if ( ! is_email( $a['email']) ) {
        return;
    }
    $email_parts = explode('@', $a['email']);
    $email_head = $email_parts[0];
    $email_tail = $email_parts[1];
    $obfuscated_email_link = get_options('recaptcha_email_link');  //It's wise to set the link from admin side instead of hard coding.
    wp_register_script( 'email-obfusc', get_stylesheet_directory_uri() . '/library/js/email_obfusc.js', array( 'jquery' ), '', true );
    wp_localize_script( 'email-obfusc', 'email', array('content' => antispambot( $content )) );
    wp_enqueue_script( 'email-obfusc' );
    return '<span class="mail_hide" data-mail-h="'.strrev($email_head).'" data-mail-t="'.strrev($email_tail).'"><a class="obfocused-email" href="'.$obfuscated_email_link.'" target="_blank"> ' . (strlen($content) > 0 ? antispambot( $content ) : __('View email address', 'mytextdomain')) . '</a></span>';
}
add_shortcode( 'hide_email', 'my_hide_email_shortcode' );

If we use the shortcode like [hide_email email="[email protected]"]See the Email[/hide_email] the email address will first be broken into parts, reversed and stored as data variable of the wrapper. Later I’ve revealed the email address to users using JavaScript. If JavaScript is not available, user will be sent to recaptcha link to retrieve the email address. Here is the JavaScript code.

jQuery(document).ready(function(){
    var mail_head = jQuery('.mail_hide').first().attr('data-mail-h').split('').reverse().join('');
    var mail_tail = jQuery('.mail_hide').first().attr('data-mail-t').split('').reverse().join('');
    var oldhtml = '';
    var mail = mail_head+'@'+mail_tail;
    var mail_content = email.content;
    if(mail_content.length < 3) {
        mail_content = mail_head + '@' + mail_tail;
    }
    jQuery('.choobs_mail_hide').first().html('<a class="obfocused-email" href="mailto:'+mail+'">'+mail_content+'</a>');
    jQuery('.obfocused-email').on('mouseover', function(e){
        oldhtml = jQuery(this).html();
        jQuery(this).html(mail);
        jQuery(this).attr('href', 'mailto:'+mail);
    }).on('mouseleave', function(e) {
        jQuery(this).html(oldhtml);
    });
});

This script fetches the parts of email address, reverses them again and the joins to form the actual email address. I’ve added an effect to reveal the email address when user mouse over the content text. But if there was no content and shortcode was used only like [hide_email email="[email protected]"] the email will be revealed by default.

Go away spam bots!!