diff --git a/include/ajax.content.php b/include/ajax.content.php
index b7c0700f27775e780467fef361bdac466835e351..68297e991fc891140c69c49ee714d9cd5cde943e 100644
--- a/include/ajax.content.php
+++ b/include/ajax.content.php
@@ -138,12 +138,12 @@ class ContentAjaxAPI extends AjaxController {
         $content = Page::lookup($id, $lang);
 
         $langs = $cfg->getSecondaryLanguages();
-        $tag = $content->getTranslateTag('title:body');
-        $translations = CustomDataTranslation::allTranslations($tag, 'article');
+        $translations = $content->getAllTranslations();
         foreach ($translations as $t) {
-            list($title, $body) = explode("\x04", $t->text);
-            $info['title'][$t->lang] = $title;
-            $info['body'][$t->lang] = $body;
+            if (!($data = $t->getComplex()))
+                continue;
+            $info['title'][$t->lang] = $data['name'];
+            $info['body'][$t->lang] = $data['body'];
         }
 
         include STAFFINC_DIR . 'templates/content-manage.tmpl.php';
diff --git a/include/class.page.php b/include/class.page.php
index 5bf1e7b4e6b0cb54c0dbb8828024225375594b5b..7c29fab113a9b25406ddea40fb0102c6d40be4e5 100644
--- a/include/class.page.php
+++ b/include/class.page.php
@@ -80,26 +80,29 @@ class Page {
         return Format::viewableImages($this->getLocalBody(), ROOT_PATH.'image.php');
     }
 
-    function _fetchTranslation($lang=false) {
-        if (!isset($this->_local) || $lang) {
-            $tag = $this->getTranslateTag('title:body');
-            $this->_local = CustomDataTranslation::allTranslations($tag, 'article', $lang);
-        }
-        return $this->_local;
-    }
     function _getLocal($what, $lang=false) {
-        if (!$lang)
+        if (!$lang) {
             $lang = Internationalization::getCurrentLanguage();
-        foreach ($this->_fetchTranslation($lang) as $t) {
+        }
+        $translations = $this->getAllTranslations();
+        foreach ($translations as $t) {
             if ($lang == $t->lang) {
-                list($title, $body) = explode("\x04", $t->text, 2);
-                return $what == 'body' ? $body : $title;
+                $data = $t->getComplex();
+                if (isset($data[$what]))
+                    return $data[$what];
             }
         }
-
         return $this->ht[$what];
     }
 
+    function getAllTranslations() {
+        if (!isset($this->_local)) {
+            $tag = $this->getTranslateTag('name:body');
+            $this->_local = CustomDataTranslation::allTranslations($tag, 'article');
+        }
+        return $this->_local;
+    }
+
     function getNotes() {
         return $this->ht['notes'];
     }
@@ -323,18 +326,21 @@ class Page {
     function saveTranslations($vars, &$errors) {
         global $thisstaff;
 
-        $tag = $this->getTranslateTag('title:body');
+        $tag = $this->getTranslateTag('name:body');
         $translations = CustomDataTranslation::allTranslations($tag, 'article');
         foreach ($translations as $t) {
             $title = @$vars['trans'][$t->lang]['title'];
             $body = @$vars['trans'][$t->lang]['body'];
             if (!$title && !$body)
                 continue;
+
             // Content is not new and shouldn't be added below
             unset($vars['trans'][$t->lang]['title']);
             unset($vars['trans'][$t->lang]['body']);
-            $content = $title . "\x04" . Format::sanitize($body);
-            if ($content == $t->text)
+            $content = array('name' => $title, 'body' => Format::sanitize($body));
+
+            // Don't update content which wasn't updated
+            if ($content == $t->getComplex())
                 continue;
 
             $t->text = $content;
@@ -345,10 +351,8 @@ class Page {
         }
         // New translations (?)
         foreach ($vars['trans'] as $lang=>$parts) {
-            $title = @$parts['title'];
-            $body = @$parts['body'];
-            $content = $title . "\x04" . Format::sanitize($body);
-            if ($content == "\x04")
+            $content = array('name' => @$parts['title'], 'body' => Format::sanitize(@$parts['body']));
+            if (!array_filter($content))
                 continue;
             $t = CustomDataTranslation::create(array(
                 'type'      => 'article',
diff --git a/include/class.translation.php b/include/class.translation.php
index 8951857b2ae5eb8be42fb91fc5d7f8ecc933fecf..141e13051c98dd709bce0a4bcc705a1aaba7751e 100644
--- a/include/class.translation.php
+++ b/include/class.translation.php
@@ -866,6 +866,9 @@ class CustomDataTranslation extends VerySimpleModel {
     const FLAG_FUZZY        = 0x01;     // Source string has been changed
     const FLAG_UNAPPROVED   = 0x02;     // String has been reviewed by an authority
     const FLAG_CURRENT      = 0x04;     // If more than one version exist, this is current
+    const FLAG_COMPLEX      = 0x08;     // Multiple strings in one translation. For instance article title and body
+
+    var $_complex;
 
     static function lookup($msgid, $flags=0) {
         if (!is_string($msgid))
@@ -930,10 +933,92 @@ class CustomDataTranslation extends VerySimpleModel {
         return $msgid;
     }
 
+    /**
+     * Decode complex translation message. Format is given in the $text
+     * parameter description. Complex data should be stored with the
+     * FLAG_COMPLEX flag set, and allows for complex key:value paired data
+     * to be translated. This is useful for strings which are translated
+     * together, such as the title and the body of an article. Storing the
+     * data in a single, complex record allows for a single database query
+     * to fetch or update all data for a particular object, such as a
+     * knowledgebase article. It also simplifies search indexing as only one
+     * translation record could be added for all the translatable elements
+     * for a single translatable object.
+     *
+     * Caveats:
+     * ::$text will return the stored, complex text. Use ::getComplex() to
+     * decode the complex storage format and retrieve the array.
+     *
+     * Parameters:
+     * $text - (string) - encoded text with the following format
+     *      version \x03 key \x03 item1 \x03 key \x03 item2 ...
+     *
+     * Returns:
+     * (array) key:value pairs of translated content
+     */
+    function decodeComplex($text) {
+        $blocks = explode("\x03", $text);
+        $version = array_shift($blocks);
+
+        $data = array();
+        switch ($version) {
+        case 'A':
+            while (count($blocks) > 1) {
+                $key = array_shift($blocks);
+                $data[$key] = array_shift($blocks);
+            }
+            break;
+        default:
+            throw new Exception($version . ': Unknown complex format');
+        }
+
+        return $data;
+    }
+
+    /**
+     * Encode complex content using the format outlined in ::decodeComplex.
+     *
+     * Caveats:
+     * This method does not set the FLAG_COMPLEX flag for this record, which
+     * should be set when storing complex data.
+     */
+    static function encodeComplex(array $data) {
+        $encoded = 'A';
+        foreach ($data as $key=>$text) {
+            $encoded .= "\x03{$key}\x03{$text}";
+        }
+        return $encoded;
+    }
+
+    function getComplex() {
+        if (!$this->flags && self::FLAG_COMPLEX)
+            throw new Exception('Data consistency error. Translation is not complex');
+        if (!isset($this->_complex))
+            $this->_complex = $this->decodeComplex($this->text);
+        return $this->_complex;
+    }
+
     static function translateArticle($msgid, $locale=false) {
         return static::translate($msgid, $locale, false, 'article');
     }
 
+    function save($refetch=false) {
+        if (isset($this->text) && is_array($this->text)) {
+            $this->text = static::encodeComplex($this->text);
+            $this->flags |= self::FLAG_COMPLEX;
+        }
+        return parent::save($refetch);
+    }
+
+    static function create(array $ht=array()) {
+        if (is_array($ht['text'])) {
+            // The parent constructor does not honor arrays
+            $ht['text'] = static::encodeComplex($ht['text']);
+            $ht['flags'] = ($ht['flags'] ?: 0) | self::FLAG_COMPLEX;
+        }
+        return parent::create($ht);
+    }
+
     static function allTranslations($msgid, $type='phrase', $lang=false) {
         $criteria = array('type' => $type);