Localization low-level framework added
authorDan
Sat, 27 Oct 2007 13:29:17 -0400
changeset 205 c4542792db2b
parent 204 473cc747022a
child 208 c75ad574b56d
Localization low-level framework added
includes/common.php
includes/constants.php
includes/lang.php
includes/template.php
language/english/enano.json
schema.sql
--- a/includes/common.php	Fri Oct 26 19:28:54 2007 -0400
+++ b/includes/common.php	Sat Oct 27 13:29:17 2007 -0400
@@ -78,6 +78,7 @@
 require_once(ENANO_ROOT.'/includes/sessions.php');
 require_once(ENANO_ROOT.'/includes/template.php');
 require_once(ENANO_ROOT.'/includes/plugins.php');
+require_once(ENANO_ROOT.'/includes/lang.php');
 require_once(ENANO_ROOT.'/includes/comment.php');
 require_once(ENANO_ROOT.'/includes/wikiformat.php');
 require_once(ENANO_ROOT.'/includes/diff.php');
--- a/includes/constants.php	Fri Oct 26 19:28:54 2007 -0400
+++ b/includes/constants.php	Sat Oct 27 13:29:17 2007 -0400
@@ -39,6 +39,9 @@
 define('PAGE_GRP_NORMAL', 3);
 define('PAGE_GRP_REGEX', 4);
 
+// Identifier for the default meta-language
+define('LANG_DEFAULT', 0);
+
 //
 // User types - don't touch these
 //
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/includes/lang.php	Sat Oct 27 13:29:17 2007 -0400
@@ -0,0 +1,407 @@
+<?php
+
+/*
+ * Enano - an open-source CMS capable of wiki functions, Drupal-like sidebar blocks, and everything in between
+ * Version 1.1.1
+ * Copyright (C) 2006-2007 Dan Fuhry
+ *
+ * This program is Free Software; you can redistribute and/or modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
+ */
+
+/**
+ * Language class - processes, stores, and retrieves language strings.
+ * @package Enano
+ * @subpackage Localization
+ * @copyright 2007 Dan Fuhry
+ * @license GNU General Public License
+ */
+
+class Language
+{
+  
+  /**
+   * The numerical ID of the loaded language.
+   * @var int
+   */
+  
+  var $lang_id;
+  
+  /**
+   * The ISO-639-3 code for the loaded language. This should be grabbed directly from the database.
+   * @var string
+   */
+  
+  var $lang_code;
+  
+  /**
+   * Will be an object that holds an instance of the class configured with the site's default language. Only instanciated when needed.
+   * @var object
+   */
+  
+  var $default;
+  
+  /**
+   * The list of loaded strings.
+   * @var array
+   * @access private
+   */
+  
+  var $strings = array();
+  
+  /**
+   * Constructor.
+   * @param int|string Language ID or code to load.
+   */
+  
+  function __construct($lang)
+  {
+    global $db, $session, $paths, $template, $plugins; // Common objects
+    
+    if ( defined('IN_ENANO_INSTALL') )
+    {
+      // special case for the Enano installer: it will load its own strings from a JSON file and just use this API for fetching and templatizing them.
+      $this->lang_id   = LANG_DEFAULT;
+      $this->lang_code = 'neutral';
+      return true;
+    }
+    if ( is_string($lang) )
+    {
+      $sql_col = 'lang_code="' . $db->escape($lang) . '"';
+    }
+    else if ( is_int($lang) )
+    {
+      $sql_col = 'lang_id=' . $lang . '';
+    }
+    else
+    {
+      $db->_die('lang.php - attempting to pass invalid value to constructor');
+    }
+    
+    $lang_default = ( $x = getConfig('default_language') ) ? intval($x) : 1;
+    $q = $db->sql_query("SELECT lang_id, lang_code, ( lang_id = $lang_default ) AS is_default FROM " . table_prefix . "language WHERE $sql_col OR lang_id = $lang_default ORDER BY is_default DESC LIMIT 1;");
+    
+    if ( !$q )
+      $db->_die('lang.php - main select query');
+    
+    if ( $db->numrows() < 1 )
+      $db->_die('lang.php - There are no languages installed');
+    
+    $row = $db->fetchrow();
+    
+    $this->lang_id   = intval( $row['lang_id'] );
+    $this->lang_code = $row['lang_code'];
+  }
+  
+  /**
+   * PHP 4 constructor.
+   * @param int|string Language ID or code to load.
+   */
+  
+  function Language($lang)
+  {
+    $this->__construct($lang);
+  }
+  
+  /**
+   * Fetches language strings from the database, or a cache file if it's available.
+   * @param bool If true (default), allows the cache to be used.
+   */
+  
+  function fetch($allow_cache = true)
+  {
+    global $db, $session, $paths, $template, $plugins; // Common objects
+    
+    $lang_file = ENANO_ROOT . "/cache/lang_{$this->lang_id}.php";
+    // Attempt to load the strings from a cache file
+    if ( file_exists($lang_file) && $allow_cache )
+    {
+      // Yay! found it
+      $this->load_cache_file($lang_file);
+    }
+    else
+    {
+      // No cache file - select and retrieve from the database
+      $q = $db->sql_unbuffered_query("SELECT string_category, string_name, string_content FROM " . table_prefix . "language_strings WHERE lang_id = {$this->lang_id};");
+      if ( !$q )
+        $db->_die('lang.php - selecting language string data');
+      if ( $row = $db->fetchrow() )
+      {
+        $strings = array();
+        do
+        {
+          $cat =& $row['string_category'];
+          if ( !is_array($strings[$cat]) )
+          {
+            $strings[$cat] = array();
+          }
+          $strings[$cat][ $row['string_name'] ] = $row['string_content'];
+        }
+        while ( $row = $db->fetchrow() );
+        // all done fetching
+        $this->merge($strings);
+      }
+      else
+      {
+        $db->_die('lang.php - No strings for language ' . $this->lang_code);
+      }
+    }
+  }
+  
+  /**
+   * Loads a file from the disk cache (treated as PHP) and merges it into RAM.
+   * @param string File to load
+   */
+  
+  function load_cache_file($file)
+  {
+    global $db, $session, $paths, $template, $plugins; // Common objects
+    
+    // We're using eval() here because it makes handling scope easier.
+    
+    if ( !file_exists($file) )
+      $db->_die('lang.php - requested cache file doesn\'t exist');
+    
+    $contents = file_get_contents($file);
+    $contents = preg_replace('/([\s]*)<\?php/', '', $contents);
+    
+    @eval($contents);
+    
+    if ( !isset($lang_cache) || ( isset($lang_cache) && !is_array($lang_cache) ) )
+      $db->_die('lang.php - the cache file is invalid (didn\'t set $lang_cache as an array)');
+    
+    $this->merge($lang_cache);
+  }
+  
+  /**
+   * Merges a standard language assoc array ($arr[cat][stringid]) with the master in RAM.
+   * @param array
+   */
+  
+  function merge($strings)
+  {
+    // This is stupidly simple.
+    foreach ( $strings as $cat_id => $contents )
+    {
+      if ( !is_array($this->strings[$cat_id]) )
+        $this->strings[$cat_id] = array();
+      foreach ( $contents as $string_id => $string )
+      {
+        $this->strings[$cat_id][$string_id] = $string;
+      }
+    }
+  }
+  
+  /**
+   * Imports a JSON-format language file into the database and merges with current strings.
+   * @param string Path to the JSON file to load
+   */
+  
+  function import($file)
+  {
+    global $db, $session, $paths, $template, $plugins; // Common objects
+    
+    if ( !file_exists($file) )
+      $db->_die('lang.php - can\'t import language file: string file doesn\'t exist');
+    
+    $contents = trim(@file_get_contents($file));
+    
+    if ( empty($contents) )
+      $db->_die('lang.php - can\'t load the contents of the language file');
+    
+    // Trim off all text before and after the starting and ending braces
+    $contents = preg_replace('/^([^{]+)\{/', '{', $contents);
+    $contents = preg_replace('/\}([^}]+)$/', '}', $contents);
+    
+    $json = new Services_JSON(SERVICES_JSON_LOOSE_TYPE);
+    $langdata = $json->decode($contents);
+    
+    if ( !is_array($langdata) )
+      $db->_die('lang.php - invalid language file');
+    
+    if ( !isset($langdata['categories']) || !isset($langdata['strings']) )
+      $db->_die('lang.php - language file does not contain the proper items');
+    
+    $insert_list = array();
+    $delete_list = array();
+    
+    foreach ( $langdata['categories'] as $category )
+    {
+      if ( isset($langdata['strings'][$category]) )
+      {
+        foreach ( $langdata['strings'][$category] as $string_name => $string_value )
+        {
+          $string_name = $db->escape($string_name);
+          $string_value = $db->escape($string_value);
+          $category_name = $db->escape($category);
+          $insert_list[] = "({$this->lang_id}, '$category_name', '$string_name', '$string_value')";
+          $delete_list[] = "( lang_id = {$this->lang_id} AND string_category = '$category_name' AND string_name = '$string_name' )";
+        }
+      }
+    }
+    
+    $delete_list = implode(" OR\n  ", $delete_list);
+    $sql = "DELETE FROM " . table_prefix . "language_strings WHERE $delete_list;";
+    
+    // Free some memory
+    unset($delete_list);
+    
+    // Run the query
+    $q = $db->sql_query($sql);
+    if ( !$q )
+      $db->_die('lang.php - couldn\'t kill off them old strings');
+    
+    $insert_list = implode(",\n  ", $insert_list);
+    $sql = "INSERT INTO " . table_prefix . "language_strings(lang_id, string_category, string_name, string_content) VALUES\n  $insert_list;";
+    
+    // Free some memory
+    unset($insert_list);
+    
+    // Run the query
+    $q = $db->sql_query($sql);
+    if ( !$q )
+      $db->_die('lang.php - couldn\'t insert strings in import()');
+    
+    // YAY! done!
+    // This will regenerate the cache file if possible.
+    $this->regen_caches();
+  }
+  
+  /**
+   * Refetches the strings and writes out the cache file.
+   */
+  
+  function regen_caches()
+  {
+    global $db, $session, $paths, $template, $plugins; // Common objects
+    
+    $lang_file = ENANO_ROOT . "/cache/lang_{$this->lang_id}.php";
+    
+    // Refresh the strings in RAM to the latest copies in the DB
+    $this->fetch(false);
+    
+    $handle = @fopen($lang_file, 'w');
+    if ( !$handle )
+      // Couldn't open the file. Silently fail and let the strings come from the database.
+      return false;
+      
+    // The file's open, that means we should be good.
+    fwrite($handle, '<?php
+// This file was generated automatically by Enano. You should not edit this file because any changes you make
+// to it will not be visible in the ACP and all changes will be lost upon any changes to strings in the admin panel.
+
+$lang_cache = ');
+    
+    $exported = $this->var_export_string($this->strings);
+    if ( empty($exported) )
+      // Ehh, that's not good
+      $db->_die('lang.php - var_export_string() failed');
+    
+    fwrite($handle, $exported . '; ?>');
+    
+    // Done =)
+    fclose($handle);
+  }
+  
+  /**
+   * Calls var_export() on whatever, and returns the function's output.
+   * @param mixed Whatever you want var_exported. Usually an array.
+   * @return string
+   */
+  
+  function var_export_string($val)
+  {
+    ob_start();
+    var_export($val);
+    $contents = ob_get_contents();
+    ob_end_clean();
+    return $contents;
+  }
+  
+  /**
+   * Fetches a language string from the cache in RAM. If it isn't there, it will call fetch() again and then try. If it still can't find it, it will ask for the string
+   * in the default language. If even then the string can't be found, this function will return what was passed to it.
+   *
+   * This will also templatize strings. If a string contains variables in the format %foo%, you may specify the second parameter as an associative array in the format
+   * of 'foo' => 'foo substitute'.
+   *
+   * @param string ID of the string to fetch. This will always be in the format of category_stringid.
+   * @param array Optional. Associative array of substitutions.
+   * @return string
+   */
+  
+  function get($string_id, $substitutions = false)
+  {
+    // Extract the category and string ID
+    $category = substr($string_id, 0, ( strpos($string_id, '_') ));
+    $string_name = substr($string_id, ( strpos($string_id, '_') + 1 ));
+    $found = false;
+    if ( isset($this->strings[$category]) && isset($this->strings[$category][$string_name]) )
+    {
+      $found = true;
+      $string = $this->strings[$category][$string_name];
+    }
+    if ( !$found )
+    {
+      // Ehh, the string wasn't found. Rerun fetch() and try again.
+      $this->fetch();
+      if ( isset($this->strings[$category]) && isset($this->strings[$category][$string_name]) )
+      {
+        $found = true;
+        $string = $this->strings[$category][$string_name];
+      }
+      if ( !$found )
+      {
+        // STILL not found. Check the default language.
+        $lang_default = ( $x = getConfig('default_language') ) ? intval($x) : $this->lang_id;
+        if ( $lang_default != $this->lang_id )
+        {
+          if ( !is_object($this->default) )
+            $this->default = new Language($lang_default);
+          return $this->default->get($string_id, $substitutions);
+        }
+      }
+    }
+    if ( !$found )
+    {
+      // Alright, it's nowhere. Return the input, grumble grumble...
+      return $string_id;
+    }
+    // Found it!
+    // Perform substitutions.
+    if ( !is_array($substitutions) )
+      $substitutions = array();
+    return $this->substitute($string, $substitutions);
+  }
+  
+  /**
+   * Processes substitutions.
+   * @param string
+   * @param array
+   * @return string
+   */
+  
+  function substitute($string, $subs)
+  {
+    preg_match_all('/%this\.([a-z0-9_]+)%/', $string, $matches);
+    if ( count($matches[0]) > 0 )
+    {
+      foreach ( $matches[1] as $i => $string_id )
+      {
+        $result = $this->get($string_id);
+        $string = str_replace($matches[0][$i], $result, $string);
+      }
+    }
+    foreach ( $subs as $key => $value )
+    {
+      $string = str_replace("%$key%", $value, $string);
+    }
+    return $string;
+  }
+  
+} // class Language
+
+?>
--- a/includes/template.php	Fri Oct 26 19:28:54 2007 -0400
+++ b/includes/template.php	Sat Oct 27 13:29:17 2007 -0400
@@ -958,6 +958,7 @@
   
   function compile_tpl_code($text)
   {
+    global $db, $session, $paths, $template, $plugins; // Common objects
     // A random seed used to salt tags
     $seed = md5 ( microtime() . mt_rand() );
     
@@ -986,29 +987,88 @@
     // Conditionals
     //
     
-    // If-else-end
-    $text = preg_replace('/<!-- BEGIN ([A-z0-9_-]+?) -->(.*?)<!-- BEGINELSE \\1 -->(.*?)<!-- END \\1 -->/is', '\'; if ( $this->tpl_bool[\'\\1\'] ) { echo \'\\2\'; } else { echo \'\\3\'; } echo \'', $text);
-    
-    // If-end
-    $text = preg_replace('/<!-- BEGIN ([A-z0-9_-]+?) -->(.*?)<!-- END \\1 -->/is', '\'; if ( $this->tpl_bool[\'\\1\'] ) { echo \'\\2\'; } echo \'', $text);
+    $keywords = array('BEGIN', 'BEGINNOT', 'IFSET', 'IFPLUGIN');
+    $code = $plugins->setHook('template_compile_logic_keyword');
+    foreach ( $code as $cmd )
+    {
+      eval($cmd);
+    }
     
-    // If not-else-end
-    $text = preg_replace('/<!-- BEGINNOT ([A-z0-9_-]+?) -->(.*?)<!-- BEGINELSE \\1 -->(.*?)<!-- END \\1 -->/is', '\'; if ( !$this->tpl_bool[\'\\1\'] ) { echo \'\\2\'; } else { echo \'\\3\'; } echo \'', $text);
+    $keywords = implode('|', $keywords);
     
-    // If not-end
-    $text = preg_replace('/<!-- BEGINNOT ([A-z0-9_-]+?) -->(.*?)<!-- END \\1 -->/is', '\'; if ( !$this->tpl_bool[\'\\1\'] ) { echo \'\\2\'; } echo \'', $text);
+    // Matches
+    //          1     2                               3                 4   56                       7     8
+    $regexp = '/(<!-- ('. $keywords .') ([A-z0-9_-]+) -->)(.*)((<!-- BEGINELSE \\3 -->)(.*))?(<!-- END \\3 -->)/isU';
+    
+    /*
+    The way this works is: match all blocks using the standard form with a different keyword in the block each time,
+    and replace them with appropriate PHP logic. Plugin-extensible now. :-)
     
-    // If set-else-end
-    $text = preg_replace('/<!-- IFSET ([A-z0-9_-]+?) -->(.*?)<!-- BEGINELSE \\1 -->(.*?)<!-- END \\1 -->/is', '\'; if ( isset($this->tpl_strings[\'\\1\']) ) { echo \'\\2\'; } else { echo \'\\3\'; } echo \'', $text);
-    
-    // If set-end
-    $text = preg_replace('/<!-- IFSET ([A-z0-9_-]+?) -->(.*?)<!-- END \\1 -->/is', '\'; if ( isset($this->tpl_strings[\'\\1\']) ) { echo \'\\2\'; } echo \'', $text);
+    The while-loop is to bypass what is apparently a PCRE bug. It's hackish but it works. Properly written plugins should only need
+    to compile templates (using this method) once for each time the template file is changed.
+    */
+    while ( preg_match($regexp, $text) )
+    {
+      preg_match_all($regexp, $text, $matches);
+      for ( $i = 0; $i < count($matches[0]); $i++ )
+      {
+        $start_tag =& $matches[1][$i];
+        $type =& $matches[2][$i];
+        $test =& $matches[3][$i];
+        $particle_true  =& $matches[4][$i];
+        $else_tag =& $matches[6][$i];
+        $particle_else =& $matches[7][$i];
+        $end_tag =& $matches[8][$i];
+        
+        switch($type)
+        {
+          case 'BEGIN':
+            $cond = "isset(\$this->tpl_bool['$test']) && \$this->tpl_bool['$test']";
+            break;
+          case 'BEGINNOT':
+            $cond = "!isset(\$this->tpl_bool['$test']) || ( isset(\$this->tpl_bool['$test']) && !\$this->tpl_bool['$test'] )";
+            break;
+          case 'IFPLUGIN':
+            $cond = "getConfig('plugin_$test') == '1'";
+            break;
+          case 'IFSET':
+            $cond = "isset(\$this->tpl_strings['$test'])";
+            break;
+          default:
+            $code = $plugins->setHook('template_compile_logic_cond');
+            foreach ( $code as $cmd )
+            {
+              eval($cmd);
+            }
+            break;
+        }
+        
+        if ( !isset($cond) || ( isset($cond) && !is_string($cond) ) )
+          continue;
+        
+        $tag_complete = <<<TPLCODE
+        ';
+        /* START OF CONDITION: $type ($test) */
+        if ( $cond )
+        {
+          echo '$particle_true';
+        /* ELSE OF CONDITION: $type ($test) */
+        }
+        else
+        {
+          echo '$particle_else';
+        /* END OF CONDITION: $type ($test) */
+        }
+        echo '
+TPLCODE;
+        
+        $text = str_replace_once($matches[0][$i], $tag_complete, $text);
+        
+      }
+    }
     
-    // If plugin loaded-else-end
-    $text = preg_replace('/<!-- IFPLUGIN ([A-z0-9_\.-]+?) -->(.*?)<!-- BEGINELSE \\1 -->(.*?)<!-- END \\1 -->/is', '\'; if ( getConfig(\'plugin_\\1\') == \'1\' ) { echo \'\\2\'; } else { echo \'\\3\'; } echo \'', $text);
-    
-    // If plugin loaded-end
-    $text = preg_replace('/<!-- IFPLUGIN ([A-z0-9_\.-]+?) -->(.*?)<!-- END \\1 -->/is', '\'; if ( getConfig(\'plugin_\\1\') == \'1\' ) { echo \'\\2\'; } echo \'', $text);
+    // For debugging ;-)
+    // die("<pre>&lt;?php\n" . htmlspecialchars($text."\n\n".print_r($matches,true)) . "\n\n?&gt;</pre>");
     
     //
     // Data substitution/variables
@@ -1029,6 +1089,8 @@
       $text = str_replace_once($tag, "'; $match echo '", $text);
     }
     
+    // echo('<pre>' . htmlspecialchars($text) . '</pre>');
+    
     return $text;  
     
   }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/language/english/enano.json	Sat Oct 27 13:29:17 2007 -0400
@@ -0,0 +1,52 @@
+/*
+ * Enano - an open-source CMS capable of wiki functions, Drupal-like sidebar blocks, and everything in between
+ * Version 1.1.1
+ * Copyright (C) 2006-2007 Dan Fuhry
+ *
+ * This program is Free Software; you can redistribute and/or modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
+ */
+
+// This is the main language file for Enano. Feel free to use it as a base for your own translations.
+// All text in this file before the first left curly brace and all text after the last curly brace will
+// be trimmed. So you can use a limited amount of Javascript in this so that the language can be imported
+// via Javascript as well.
+
+var enano_lang = {
+  categories: [
+    'meta', 'user', 'page', 'comment', 'onpage'
+  ],
+  strings: {
+    meta: {
+      meta: 'Language category strings',
+      user: 'Login, logout, and authentication',
+      page: 'Page creation and control',
+      comment: 'Comment display',
+      onpage: 'On-page controls',
+      plural: 's'
+    },
+    user: {
+      login_message_short: 'Please enter your username and password to log in.',
+      login_message_short_elev: 'Please re-enter your login details',
+      err_key_not_found: 'Enano couldn\'t look up the encryption key used to encrypt your password. This most often happens if a cache rotation occurred during your login attempt, or if you refreshed the login page.',
+      err_key_wrong_length: 'The encryption key was the wrong length.',
+      err_too_big_for_britches: 'You are trying to authenticate at a level that your user account does not permit.',
+      err_invalid_credentials: 'You have entered an invalid username or password. Please enter your login details again.',
+      err_invalid_credentials_lockout: ' You have used up %lockout_fails% out of %lockout_threshold% login attempts. After you have used up all %lockout_threshold% login attempts, you will be locked out from logging in for %lockout_duration% minutes.',
+      err_invalid_credentials_lockout_captcha: ' You have used up %lockout_fails% out of %lockout_threshold% login attempts. After you have used up all %lockout_threshold% login attempts, you will have to enter a visual confirmation code while logging in, effective for %lockout_duration% minutes.',
+      err_backend_fail: 'You entered the right credentials and everything was validated, but for some reason Enano couldn\'t register your session. This is an internal problem with the site and you are encouraged to contact site administration.',
+      err_locked_out: 'You have used up all %lockout_threshold% allowed login attempts. Please wait %time_rem% minute%plural% before attempting to log in again%captcha_blurb%.',
+      err_locked_out_captcha_blurb: ', or enter the visual confirmation code shown above in the appropriate box'
+    },
+    page: {
+    },
+    adm: {
+    },
+  }
+};
+
+// All done! :-)
+
--- a/schema.sql	Fri Oct 26 19:28:54 2007 -0400
+++ b/schema.sql	Sat Oct 27 13:29:17 2007 -0400
@@ -264,6 +264,27 @@
   PRIMARY KEY ( id )
 ) CHARACTER SET `utf8`;
 
+-- Added in 1.1.1
+
+CREATE TABLE {{TABLE_PREFIX}}language(
+  lang_id smallint(5) NOT NULL auto_increment,
+  lang_code varchar(16) NOT NULL,
+  lang_name_default varchar(64) NOT NULL,
+  lang_name_native varchar(64) NOT NULL,
+  PRIMARY KEY ( lang_id )
+) CHARACTER SET `utf8`;
+
+-- Added in 1.1.1
+
+CREATE TABLE {{TABLE_PREFIX}}language_strings(
+  string_id bigint(15) NOT NULL auto_increment,
+  lang_id smallint(5) NOT NULL,
+  string_category varchar(32) NOT NULL,
+  string_name varchar(64) NOT NULL,
+  string_content longtext NOT NULL,
+  PRIMARY KEY ( string_id )
+);
+
 INSERT INTO {{TABLE_PREFIX}}config(config_name, config_value) VALUES
   ('site_name', '{{SITE_NAME}}'),
   ('main_page', 'Main_Page'),