Prechádzať zdrojové kódy

initial work on translations, covering the PHP side of it

El RIDO 11 rokov pred
rodič
commit
a2af88a36e
10 zmenil súbory, kde vykonal 409 pridanie a 57 odobranie
  1. 4 0
      css/bootstrap/zerobin.css
  2. 78 0
      i18n/de.json
  3. 1 0
      index.php
  4. 5 0
      lib/RainTPL.php
  5. 219 0
      lib/i18n.php
  6. 26 19
      lib/zerobin.php
  7. 18 20
      tpl/bootstrap.html
  8. 16 18
      tpl/page.html
  9. 1 0
      tst/bootstrap.php
  10. 41 0
      tst/i18n.php

+ 4 - 0
css/bootstrap/zerobin.css

@@ -26,3 +26,7 @@ body {
 	padding: 5px 0 5px 5px;
 	white-space: pre-wrap;
 }
+
+h4 {
+	margin-top: 0;
+}

+ 78 - 0
i18n/de.json

@@ -0,0 +1,78 @@
+{
+    "en": "de",
+    "Paste does not exist, has expired or has been deleted.":
+        "Diesen Schnipsel gibt es nicht, er ist abgelaufen oder wurde gelöscht.",
+    "ZeroBin requires php 5.2.6 or above to work. Sorry.":
+        "ZeroBin benötigt PHP 5.2.6 oder höher um zu funktionieren, sorry.",
+    "ZeroBin requires configuration section [%s] to be present in configuration file.":
+        "ZeroBin benötigt die Konfigurationssektion [%s] in der Konfigurationsdatei um zu funktionieren.",
+    "Please wait %d seconds between each post.":
+        "Bitte warte %d Sekunden zwischen dem Absenden.",
+    "Paste is limited to %s of encrypted data.":
+        "Schnipsel sind auf %s verschlüsselte Datenmenge beschränkt.",
+    "Invalid data.":
+        "Ungültige Daten.",
+    "You are unlucky. Try again.":
+        "Du hast Pech. Versuchs nochmal.",
+    "Error saving comment. Sorry.":
+        "Fehler beim Speichern des Kommentars, sorry.",
+    "Error saving paste. Sorry.":
+        "Fehler beim Speichern des Schnipsels, sorry.",
+    "Invalid paste ID.":
+        "Ungültige Schnipsel ID.",
+    "Paste is not of burn-after-reading type.":
+        "Schnipsel ist kein \"Einweg\"-Typ.",
+    "Wrong deletion token. Paste was not deleted.":
+        "Falscher Lösch-Kode. Schnipsel wurde nicht gelöscht.",
+    "Paste was properly deleted.":
+        "Schnipsel wurde erfolgreich gelöscht.",
+    "ZeroBin": "ZeroBin",
+    "ZeroBin is a minimalist, opensource online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://github.com/elrido/ZeroBin/wiki\">project page</a>.":
+        "ZeroBin ist ein minimalistischer, quelloffener \"pastebin\"-artiger Dienst bei dem der Server keinerlei Kenntnis der Daten-Schnipsel hat. Die Daten werden <i>im Browser</i> mit 256 Bit AES ver- und entschlüsselt. Weitere Informationen sind auf der <a href=\"https://github.com/elrido/ZeroBin/wiki\">Projektseite</a> zu finden.",
+    "Because ignorance is bliss":
+        "Was ich nicht weiss, macht mich nicht heiss",
+    "Javascript is required for ZeroBin to work.<br />Sorry for the inconvenience.":
+        "Javascript ist eine Voraussetzung um ZeroBin zu nutzen.<br />Bitte entschuldige die Unannehmlichkeiten.",
+    "ZeroBin requires a modern browser to work.":
+        "ZeroBin setzt einen modernen Browser voraus um funktionieren zu können.",
+    "Still using Internet Explorer? Do yourself a favor, switch to a modern browser:":
+        "Du benutzt immer noch den Internet Explorer? Tu Dir einen Gefallen und wechsle zu einem moderneren Browser:",
+    "New":
+        "Neu",
+    "Send":
+        "Senden",
+    "Clone":
+        "Klonen",
+    "Raw text":
+        "Reiner Text",
+    "Expires":
+        "Ablaufzeit",
+    "Burn after reading":
+        "Einweg-Schnipsel",
+    "Open discussion":
+        "Diskussion eröffnen",
+    "Password (recommended)":
+        "Passwort (empfohlen)",
+    "Discussion":
+        "Diskussion",
+    "Toggle navigation":
+        "Navigation umschalten",
+    "5 minutes":
+        "5 Minuten",
+    "10 minutes":
+        "10 Minuten",
+    "1 hour":
+        "1 Stunde",
+    "1 day":
+        "1 Tag",
+    "1 week":
+        "1 Woche",
+    "1 month":
+        "1 Monat",
+    "1 year":
+        "1 Jahr",
+    "Never":
+        "Nie",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.":
+        "Hinweis: Dies ist ein Versuchsdienst. Daten können jederzeit gelöscht werden. Kätzchen werden sterben wenn Du diesen Dienst missbrauchst."
+}

+ 1 - 0
index.php

@@ -13,5 +13,6 @@
 // change this, if your php files and data is outside of your webservers document root
 define('PATH', '');
 
+define('PUBLIC_PATH', dirname(__FILE__));
 require PATH . 'lib/auto.php';
 new zerobin;

+ 5 - 0
lib/RainTPL.php

@@ -1162,4 +1162,9 @@ class RainTpl_SyntaxException extends RainTpl_Exception{
 	}
 }
 
+// shorthand translate function for use in templates
+function t() {
+	return call_user_func_array(array('i18n', 'translate'), func_get_args());
+}
+
 // -- end

+ 219 - 0
lib/i18n.php

@@ -0,0 +1,219 @@
+<?php
+/**
+ * ZeroBin
+ *
+ * a zero-knowledge paste bin
+ *
+ * @link      http://sebsauvage.net/wiki/doku.php?id=php:zerobin
+ * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
+ * @license   http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
+ * @version   0.20
+ */
+
+/**
+ * i18n
+ *
+ * provides internationalization tools like translation, browser language detection, etc.
+ */
+class i18n
+{
+    /**
+     * translation cache
+     *
+     * @access private
+     * @static
+     * @var    array
+     */
+    private static $_translations = array();
+
+    /**
+     * translate a string, alias for translate()
+     *
+     * @access public
+     * @static
+     * @param  string $messageId
+     * @param  mixed $args one or multiple parameters injected into placeholders
+     * @return string
+     */
+    public static function _($messageId)
+    {
+        return call_user_func_array(array('i18n', 'translate'), func_get_args());
+    }
+
+    /**
+     * translate a string
+     *
+     * @access public
+     * @static
+     * @param  string $messageId
+     * @param  mixed $args one or multiple parameters injected into placeholders
+     * @return string
+     */
+    public static function translate($messageId)
+    {
+        if (empty($messageId))
+        {
+            return $messageId;
+        }
+        if (count(self::$_translations) === 0) {
+            self::loadTranslations();
+        }
+        if (!array_key_exists($messageId, self::$_translations))
+        {
+            self::$_translations[$messageId] = $messageId;
+        }
+        $args = func_get_args();
+        $args[0] = self::$_translations[$messageId];
+        return call_user_func_array('sprintf', $args);
+    }
+
+    /**
+     * loads translations
+     *
+     * From: http://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
+     *
+     * @access protected
+     * @static
+     * @return void
+     */
+    public static function loadTranslations()
+    {
+        // find a matching translation file
+        $availableLanguages = array();
+        $path = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'i18n';
+        $i18n = dir($path);
+        while (false !== ($file = $i18n->read()))
+        {
+            if (preg_match('/^([a-z]{2}).json$/', $file, $match) === 1)
+            {
+                $availableLanguages[] = $match[1];
+            }
+        }
+
+        $match = self::_getMatchingLanguage(
+            self::getBrowserLanguages(), $availableLanguages
+        );
+        // load translations
+        if ($match != 'en')
+        {
+            self::$_translations = json_decode(
+                file_get_contents($path . DIRECTORY_SEPARATOR . $match . '.json'),
+                true
+            );
+        }
+    }
+
+    /**
+     * detect the clients supported languages and return them ordered by preference
+     *
+     * From: http://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
+     *
+     * @return array
+     */
+    public static function getBrowserLanguages()
+    {
+        $languages = array();
+        if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER))
+        {
+            $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
+            foreach ($languageRanges as $languageRange) {
+                if (preg_match(
+                    '/(\*|[a-zA-Z0-9]{1,8}(?:-[a-zA-Z0-9]{1,8})*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?/',
+                    trim($languageRange), $match
+                ))
+                {
+                    if (!isset($match[2]))
+                    {
+                        $match[2] = '1.0';
+                    }
+                    else
+                    {
+                        $match[2] = (string) floatval($match[2]);
+                    }
+                    if (!isset($languages[$match[2]]))
+                    {
+                        $languages[$match[2]] = array();
+                    }
+                    $languages[$match[2]][] = strtolower($match[1]);
+                }
+            }
+            krsort($languages);
+        }
+        return $languages;
+    }
+
+    /**
+     * compares two language preference arrays and returns the preferred match
+     *
+     * From: http://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
+     *
+     * @param array $acceptedLanguages
+     * @param array $availableLanguages
+     * @return string
+     */
+    protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages) {
+        $matches = array();
+        $any = false;
+        foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
+            $acceptedQuality = floatval($acceptedQuality);
+            if ($acceptedQuality === 0.0) continue;
+            foreach ($availableLanguages as $availableValue)
+            {
+                $availableQuality = 1.0;
+                foreach ($acceptedValues as $acceptedValue)
+                {
+                    if ($acceptedValue === '*')
+                    {
+                        $any = true;
+                    }
+                    $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
+                    if ($matchingGrade > 0)
+                    {
+                        $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
+                        if (!isset($matches[$q]))
+                        {
+                            $matches[$q] = array();
+                        }
+                        if (!in_array($availableValue, $matches[$q]))
+                        {
+                            $matches[$q][] = $availableValue;
+                        }
+                    }
+                }
+            }
+        }
+        if (count($matches) === 0 && $any)
+        {
+            if (count($availableLanguages) > 0)
+            {
+                $matches['1.0'] = $availableLanguages;
+            }
+        }
+        if (count($matches) === 0)
+        {
+            return 'en';
+        }
+        krsort($matches);
+        $topmatches = current($matches);
+        return current($topmatches);
+    }
+
+    /**
+     * compare two language IDs and return the degree they match
+     *
+     * From: http://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
+     *
+     * @param string $a
+     * @param string $b
+     * @return float
+     */
+    protected static function _matchLanguage($a, $b) {
+        $a = explode('-', $a);
+        $b = explode('-', $b);
+        for ($i=0, $n=min(count($a), count($b)); $i<$n; $i++)
+        {
+            if ($a[$i] !== $b[$i]) break;
+        }
+        return $i === 0 ? 0 : (float) $i / count($a);
+    }
+}

+ 26 - 19
lib/zerobin.php

@@ -93,7 +93,7 @@ class zerobin
     {
         if (version_compare(PHP_VERSION, '5.2.6') < 0)
         {
-            throw new Exception('ZeroBin requires php 5.2.6 or above to work. Sorry.', 1);
+            throw new Exception(i18n::_('ZeroBin requires php 5.2.6 or above to work. Sorry.'), 1);
         }
 
         // in case stupid admin has left magic_quotes enabled in php.ini
@@ -156,7 +156,7 @@ class zerobin
         $this->_conf = parse_ini_file(PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini', true);
         foreach (array('main', 'model') as $section) {
             if (!array_key_exists($section, $this->_conf)) {
-                throw new Exception("ZeroBin requires configuration section [$section] to be present in configuration file.", 2);
+                throw new Exception(i18n::_('ZeroBin requires configuration section [%s] to be present in configuration file.', $section), 2);
             }
         }
         $this->_model = $this->_conf['model']['class'];
@@ -184,12 +184,12 @@ class zerobin
      * Store new paste or comment
      *
      * POST contains:
-     * data (mandatory) = json encoded SJCL encrypted text (containing keys: iv,salt,ct)
+     * data (mandatory) = json encoded SJCL encrypted text (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct)
      *
      * All optional data will go to meta information:
      * expire (optional) = expiration delay (never,5min,10min,1hour,1day,1week,1month,1year,burn) (default:never)
      * opendiscusssion (optional) = is the discussion allowed on this paste ? (0/1) (default:0)
-     * nickname (optional) = in discussion, encoded SJCL encrypted text nickname of author of comment (containing keys: iv,salt,ct)
+     * nickname (optional) = in discussion, encoded SJCL encrypted text nickname of author of comment (containing keys: iv,v,iter,ks,ts,mode,adata,cipher,salt,ct)
      * parentid (optional) = in discussion, which comment this comment replies to.
      * pasteid (optional) = in discussion, which paste this comment belongs to.
      *
@@ -208,9 +208,10 @@ class zerobin
         {
             $this->_return_message(
                 1,
-                'Please wait ' .
-                $this->_conf['traffic']['limit'] .
-                ' seconds between each post.'
+                i18n::_(
+                    'Please wait %d seconds between each post.',
+                    $this->_conf['traffic']['limit']
+                )
             );
             return;
         }
@@ -221,9 +222,10 @@ class zerobin
         {
             $this->_return_message(
                 1,
-                'Paste is limited to ' .
-                filter::size_humanreadable($sizelimit) .
-                ' of encrypted data.'
+                i18n::_(
+                    'Paste is limited to %s of encrypted data.',
+                    filter::size_humanreadable($sizelimit)
+                )
             );
             return;
         }
@@ -232,15 +234,18 @@ class zerobin
         if (!sjcl::isValid($data)) return $this->_return_message(1, 'Invalid data.');
 
         // Read additional meta-information.
-        $meta=array();
+        $meta = array();
 
         // Read expiration date
         if (!empty($_POST['expire']))
         {
             $selected_expire = (string) $_POST['expire'];
-            if (array_key_exists($selected_expire, $this->_conf['expire_options'])) {
+            if (array_key_exists($selected_expire, $this->_conf['expire_options']))
+            {
                 $expire = $this->_conf['expire_options'][$selected_expire];
-            } else {
+            }
+            else
+            {
                 $expire = $this->_conf['expire_options'][$this->_conf['expire']['default']];
             }
             if ($expire > 0) $meta['expire_date'] = time() + $expire;
@@ -575,23 +580,25 @@ class zerobin
         // label all the expiration options
         $expire = array();
         foreach ($this->_conf['expire_options'] as $key => $value) {
-            $expire[$key] = array_key_exists($key, $this->_conf['expire_labels']) ?
+            $expire[$key] = i18n::_(
+                array_key_exists($key, $this->_conf['expire_labels']) ?
                 $this->_conf['expire_labels'][$key] :
-                $key;
+                $key
+            );
         }
 
         $page = new RainTPL;
         $page::$path_replace = false;
         // we escape it here because ENT_NOQUOTES can't be used in RainTPL templates
         $page->assign('CIPHERDATA', htmlspecialchars($this->_data, ENT_NOQUOTES));
-        $page->assign('ERROR', $this->_error);
-        $page->assign('STATUS', $this->_status);
+        $page->assign('ERROR', i18n::_($this->_error));
+        $page->assign('STATUS', i18n::_($this->_status));
         $page->assign('VERSION', self::VERSION);
         $page->assign('DISCUSSION', $this->_getMainConfig('discussion', true));
         $page->assign('OPENDISCUSSION', $this->_getMainConfig('opendiscussion', true));
         $page->assign('SYNTAXHIGHLIGHTING', $this->_getMainConfig('syntaxhighlighting', true));
         $page->assign('SYNTAXHIGHLIGHTINGTHEME', $this->_getMainConfig('syntaxhighlightingtheme', ''));
-        $page->assign('NOTICE', $this->_getMainConfig('notice', ''));
+        $page->assign('NOTICE', i18n::_($this->_getMainConfig('notice', '')));
         $page->assign('BURNAFTERREADINGSELECTED', $this->_getMainConfig('burnafterreadingselected', false));
         $page->assign('PASSWORD', $this->_getMainConfig('password', true));
         $page->assign('BASE64JSVERSION', $this->_getMainConfig('base64version', '2.1.9'));
@@ -629,7 +636,7 @@ class zerobin
         $result = array('status' => $status);
         if ($status)
         {
-            $result['message'] = $message;
+            $result['message'] = i18n::_($message);
         }
         else
         {

+ 18 - 20
tpl/bootstrap.html

@@ -5,7 +5,7 @@
 		<meta http-equiv="X-UA-Compatible" content="IE=edge">
 		<meta name="viewport" content="width=device-width, initial-scale=1">
 		<meta name="robots" content="noindex" />
-		<title>ZeroBin</title>
+		<title>{function="t('ZeroBin')"}</title>
 		<link type="text/css" rel="stylesheet" href="css/bootstrap/bootstrap-3.3.5.css" />
 		<link type="text/css" rel="stylesheet" href="css/bootstrap/bootstrap-theme-3.3.5.css" />
 		<link type="text/css" rel="stylesheet" href="css/bootstrap/zerobin.css?{$VERSION|rawurlencode}" />{if="$SYNTAXHIGHLIGHTING"}
@@ -28,31 +28,31 @@
 			<div class="container">
 				<div class="navbar-header">
 					<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
-						<span class="sr-only">Toggle navigation</span>
+						<span class="sr-only">{function="t('Toggle navigation')"}</span>
 						<span class="icon-bar"></span>
 						<span class="icon-bar"></span>
 						<span class="icon-bar"></span>
 					</button>
-				<a class="navbar-brand" href="/" onclick="window.location.href=scriptLocation();return false;">ZeroBin</a>
+				<a class="navbar-brand" href="/" onclick="window.location.href=scriptLocation();return false;">{function="t('ZeroBin')"}</a>
 				</div>
 				<div id="navbar" class="navbar-collapse collapse">
 					<ul class="nav navbar-nav pull-right">
 						<li>
 							<button id="newbutton" type="button" class="hidden btn btn-default navbar-btn" onclick="window.location.href=scriptLocation();return false;">
-								<span class="glyphicon glyphicon-file" aria-hidden="true"></span> New
+								<span class="glyphicon glyphicon-file" aria-hidden="true"></span> {function="t('New')"}
 							</button>
 						</li>
 					</ul>
 					<ul class="nav navbar-nav">
 						<li class="pr">
 							<button id="sendbutton" type="button" class="hidden btn btn-default navbar-btn" onclick="send_data();return false;">
-								<span class="glyphicon glyphicon-upload" aria-hidden="true"></span> Send
+								<span class="glyphicon glyphicon-upload" aria-hidden="true"></span> {function="t('Send')"}
 							</button>
 							<button id="clonebutton" type="button" class="hidden btn btn-default navbar-btn" onclick="clonePaste();return false;">
-								<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone
+								<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> {function="t('Clone')"}
 							</button>
 							<button id="rawtextbutton" type="button" class="hidden btn btn-default navbar-btn" onclick="rawText();return false;">
-								<span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> Raw text
+								<span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> {function="t('Raw text')"}
 							</button>
 						</li>
 						<li class="dropdown">
@@ -60,7 +60,7 @@
 {loop="EXPIRE"}
 								<option value="{$key}"{if="$key == $EXPIREDEFAULT"} selected="selected"{/if}>{$value}</option>{/loop}
 							</select>
-							<a id="expiration" href="#" class="hidden dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Expires:  <span id="pasteExpirationDisplay">{$EXPIREDEFAULT}</span> <span class="caret"></span></a>
+							<a id="expiration" href="#" class="hidden dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">{function="t('Expires')"}: <span id="pasteExpirationDisplay">{$EXPIRE[$EXPIREDEFAULT]}</span> <span class="caret"></span></a>
 							<ul class="dropdown-menu">
 {loop="EXPIRE"}
 								<li><a href="#" onclick="$('#pasteExpiration').val('{$key}');$('#pasteExpirationDisplay').text('{$value}');return false;">{$value}</a></li>{/loop}
@@ -70,7 +70,7 @@
 							<div id="burnafterreadingoption" class="navbar-text checkbox hidden">
 								<label>
 									<input type="checkbox" id="burnafterreading" name="burnafterreading" {if="$BURNAFTERREADINGSELECTED"} checked="checked"{/if} />
-									Burn after reading
+									{function="t('Burn after reading')"}
 								</label>
 							</div>
 						</li>{if="$DISCUSSION"}
@@ -78,13 +78,13 @@
 							<div id="opendisc" class="navbar-text checkbox hidden">
 								<label>
 									<input type="checkbox" id="opendiscussion" name="opendiscussion" {if="$OPENDISCUSSION"} checked="checked"{/if} />
-									Open discussion
+									{function="t('Open discussion')"}
 							 	</label>
 							</div>
 						</li>{/if}{if="$PASSWORD"}
 						<li>
 							<div id="password" class="navbar-form hidden">
-								<input type="password" id="passwordinput" placeholder="Password (optional)" class="form-control" size="19"/>
+								<input type="password" id="passwordinput" placeholder="{function="t('Password (recommended)')"}" class="form-control" size="19"/>
 							</div>
 						</li>{/if}
 						<li>
@@ -100,9 +100,9 @@
 			</div>{/if}{if="strlen($STATUS)"}
 			<div id="status" role="alert" class="alert alert-success"><span class="glyphicon glyphicon-ok" aria-hidden="true"></span> {$STATUS|htmlspecialchars}</div>{/if}
 			<div id="errormessage" role="alert" class="{if="!strlen($ERROR)"}hidden {/if}alert alert-danger"><span class="glyphicon glyphicon-alert" aria-hidden="true"></span> {$ERROR|htmlspecialchars}</div>
-			<div id="noscript" role="alert" class="nonworking alert alert-warning"><span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> Javascript is required for ZeroBin to work.<br />Sorry for the inconvenience.</div>
-			<div id="oldienotice" role="alert" class="hidden nonworking alert alert-danger"><span class="glyphicon glyphicon-alert" aria-hidden="true"></span> ZeroBin requires a modern browser to work.</div>
-			<div id="ienotice" role="alert" class="hidden alert alert-warning"><span class="glyphicon glyphicon-question-sign" aria-hidden="true"></span> Still using Internet Explorer?  Do yourself a favor, switch to a modern browser: 
+			<div id="noscript" role="alert" class="nonworking alert alert-warning"><span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> {function="t('Javascript is required for ZeroBin to work.<br />Sorry for the inconvenience.')"}</div>
+			<div id="oldienotice" role="alert" class="hidden nonworking alert alert-danger"><span class="glyphicon glyphicon-alert" aria-hidden="true"></span> {function="t('ZeroBin requires a modern browser to work.')"}</div>
+			<div id="ienotice" role="alert" class="hidden alert alert-warning"><span class="glyphicon glyphicon-question-sign" aria-hidden="true"></span> {function="t('Still using Internet Explorer? Do yourself a favor, switch to a modern browser:')"}
 				<a href="http://www.mozilla.org/firefox/">Firefox</a>, 
 				<a href="http://www.opera.com/">Opera</a>, 
 				<a href="http://www.google.com/chrome">Chrome</a>, 
@@ -125,18 +125,16 @@
 		</section>
 		<section class="container">
 			<div id="discussion" class="hidden">
-				<h4>Discussion</h4>
+				<h4>{function="t('Discussion')"}</h4>
 				<div id="comments"></div>
 			</div>
 		</section>
 		<footer class="container">
 			<div class="row">
-				<h4 class="col-md-3 col-xs-8">ZeroBin <small>- Because ignorance is bliss</small></h4>
+				<h4 class="col-md-5 col-xs-8">{function="t('ZeroBin')"} <small>- {function="t('Because ignorance is bliss')"}</small></h4>
 				<p class="col-md-1 col-xs-4 text-center">{$VERSION}</p>
-				<p id="aboutbox" class="col-md-8 col-xs-12">
-					ZeroBin is a minimalist, opensource online pastebin where the server has zero knowledge of pasted data.<br />
-					Data is encrypted/decrypted <em>in the browser</em> using 256 bits AES.<br />
-					More information on the <a href="https://github.com/elrido/ZeroBin/wiki" target="_blank">project page</a>.
+				<p id="aboutbox" class="col-md-6 col-xs-12">
+					{function="t('ZeroBin is a minimalist, opensource online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href="https://github.com/elrido/ZeroBin/wiki">project page</a>.')"}
 				</p>
 			</div>
 		</footer>

+ 16 - 18
tpl/page.html

@@ -3,7 +3,7 @@
 	<head>
 		<meta charset="utf-8" />
 		<meta name="robots" content="noindex" />
-		<title>ZeroBin</title>
+		<title>{function="t('ZeroBin')"}</title>
 		<link type="text/css" rel="stylesheet" href="css/zerobin.css?{$VERSION|rawurlencode}" />{if="$SYNTAXHIGHLIGHTING"}
 		<link type="text/css" rel="stylesheet" href="css/prettify/prettify.css?{$VERSION|rawurlencode}" />{if="strlen($SYNTAXHIGHLIGHTINGTHEME)"}
 		<link type="text/css" rel="stylesheet" href="css/prettify/{$SYNTAXHIGHLIGHTINGTHEME}.css?{$VERSION|rawurlencode}" />{/if}{/if}
@@ -21,17 +21,15 @@
 	<body>
 		<header>
 			<div id="aboutbox">
-				ZeroBin is a minimalist, opensource online pastebin where the server has zero knowledge of pasted data.
-				Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. 
-				More information on the <a href="https://github.com/elrido/ZeroBin/wiki">project page</a>.<br />{if="strlen($NOTICE)"}
+				{function="t('ZeroBin is a minimalist, opensource online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href="https://github.com/elrido/ZeroBin/wiki">project page</a>.')"}<br />{if="strlen($NOTICE)"}
 				<span class="blink">▶</span> {$NOTICE}{/if}
 			</div>
-			<h1 title="ZeroBin" onclick="window.location.href=scriptLocation();return false;">ZeroBin</h1><br />
-			<h2>Because ignorance is bliss</h2><br />
+			<h1 title="ZeroBin" onclick="window.location.href=scriptLocation();return false;">{function="t('ZeroBin')"}</h1><br />
+			<h2>{function="t('Because ignorance is bliss')"}</h2><br />
 			<h3>{$VERSION}</h3>
-			<div id="noscript" class="nonworking">Javascript is required for ZeroBin to work.<br />Sorry for the inconvenience.</div>
-			<div id="oldienotice" class="nonworking">ZeroBin requires a modern browser to work.</div>
-			<div id="ienotice">Still using Internet Explorer ?  Do yourself a favor, switch to a modern browser: 
+			<div id="noscript" class="nonworking">{function="t('Javascript is required for ZeroBin to work.<br />Sorry for the inconvenience.')"}</div>
+			<div id="oldienotice" class="nonworking">{function="t('ZeroBin requires a modern browser to work.')"}</div>
+			<div id="ienotice">{function="t('Still using Internet Explorer? Do yourself a favor, switch to a modern browser:')"}
 				<a href="http://www.mozilla.org/firefox/">Firefox</a>, 
 				<a href="http://www.opera.com/">Opera</a>, 
 				<a href="http://www.google.com/chrome">Chrome</a>, 
@@ -43,11 +41,11 @@
 				<div id="status">{$STATUS|htmlspecialchars}</div>
 				<div id="errormessage" class="hidden">{$ERROR|htmlspecialchars}</div>
 				<div id="toolbar">
-					<button id="newbutton" onclick="window.location.href=scriptLocation();return false;" class="hidden"><img src="img/icon_new.png" width="11" height="15" alt="" />New</button>
-					<button id="sendbutton" onclick="send_data();return false;" class="hidden"><img src="img/icon_send.png" width="18" height="15" alt="" />Send</button>
-					<button id="clonebutton" onclick="clonePaste();return false;" class="hidden"><img src="img/icon_clone.png" width="15" height="17" alt="" />Clone</button>
-					<button id="rawtextbutton" onclick="rawText();return false;" class="hidden"><img src="img/icon_raw.png" width="15" height="15" alt="" />Raw text</button>
-					<div id="expiration" class="hidden">Expires: 
+					<button id="newbutton" onclick="window.location.href=scriptLocation();return false;" class="hidden"><img src="img/icon_new.png" width="11" height="15" alt="" />{function="t('New')"}</button>
+					<button id="sendbutton" onclick="send_data();return false;" class="hidden"><img src="img/icon_send.png" width="18" height="15" alt="" />{function="t('Send')"}</button>
+					<button id="clonebutton" onclick="clonePaste();return false;" class="hidden"><img src="img/icon_clone.png" width="15" height="17" alt="" />{function="t('Clone')"}</button>
+					<button id="rawtextbutton" onclick="rawText();return false;" class="hidden"><img src="img/icon_raw.png" width="15" height="15" alt="" />{function="t('Raw text')"}</button>
+					<div id="expiration" class="hidden">{function="t('Expires')"}:
 						<select id="pasteExpiration" name="pasteExpiration">
 {loop="EXPIRE"}
 							<option value="{$key}"{if="$key == $EXPIREDEFAULT"} selected="selected"{/if}>{$value}</option>{/loop}
@@ -56,14 +54,14 @@
 					<div id="remainingtime" class="hidden"></div>
 					<div id="burnafterreadingoption" class="button hidden">
 						<input type="checkbox" id="burnafterreading" name="burnafterreading" {if="$BURNAFTERREADINGSELECTED"} checked="checked"{/if} />
-						<label for="burnafterreading">Burn after reading</label>
+						<label for="burnafterreading">{function="t('Burn after reading')"}</label>
 					</div>{if="$DISCUSSION"}
 					<div id="opendisc" class="button hidden">
 						<input type="checkbox" id="opendiscussion" name="opendiscussion" {if="$OPENDISCUSSION"} checked="checked"{/if} />
-						<label for="opendiscussion" {if="!$OPENDISCUSSION"} style="color: #BBBBBB;"{/if}>Open discussion</label>
+						<label for="opendiscussion" {if="!$OPENDISCUSSION"} style="color: #BBBBBB;"{/if}>{function="t('Open discussion')"}</label>
 					</div>{/if}{if="$PASSWORD"}
 					<div id="password" class="hidden">
-						<input id="passwordinput" placeholder="Optional password (recommended)" size="32" />
+						<input id="passwordinput" placeholder="{function="t('Password (recommended)')"}" size="32" />
 					</div>{/if}
 				</div>
 				<div id="pasteresult" class="hidden">
@@ -79,7 +77,7 @@
 		</section>
 		<section>
 			<div id="discussion" class="hidden">
-				<h4>Discussion</h4>
+				<h4>{function="t('Discussion')"}</h4>
 				<div id="comments"></div>
 			</div>
 		</section>

+ 1 - 0
tst/bootstrap.php

@@ -3,6 +3,7 @@ error_reporting( E_ALL | E_STRICT );
 
 // change this, if your php files and data is outside of your webservers document root
 if (!defined('PATH')) define('PATH', '..' . DIRECTORY_SEPARATOR);
+if (!defined('PUBLIC_PATH')) define('PUBLIC_PATH', '..');
 
 require PATH . 'lib/auto.php';
 

+ 41 - 0
tst/i18n.php

@@ -0,0 +1,41 @@
+<?php
+class i18nTest extends PHPUnit_Framework_TestCase
+{
+    private $_translations = array();
+
+    public function setUp()
+    {
+        /* Setup Routine */
+        $this->_translations = json_decode(
+            file_get_contents(PATH . 'i18n' . DIRECTORY_SEPARATOR . 'de.json'),
+            true
+        );
+    }
+
+    public function tearDown()
+    {
+        /* Tear Down Routine */
+    }
+
+    public function testTranslationFallback()
+    {
+        $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'foobar';
+        $messageId = 'It does not matter if the message ID exists';
+        i18n::loadTranslations();
+        $this->assertEquals($messageId, i18n::_($messageId), 'fallback to en');
+    }
+
+    public function testBrowserLanguageDetection()
+    {
+        $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'de-CH,de;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
+        i18n::loadTranslations();
+        $this->assertEquals($this->_translations['en'], i18n::_('en'), 'browser language de');
+    }
+
+    public function testVariableInjection()
+    {
+        $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'foobar';
+        i18n::loadTranslations();
+        $this->assertEquals('some string + 1', i18n::_('some %s + %d', 'string', 1), 'browser language de');
+    }
+}