Quellcode durchsuchen

initial work on translations, covering the PHP side of it

El RIDO vor 11 Jahren
Ursprung
Commit
a2af88a36e
10 geänderte Dateien mit 409 neuen und 57 gelöschten Zeilen
  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');
+    }
+}