diff --git a/LICENSE.txt b/LICENSE.txt
index d178230aa0a14527224de2f1a0a5c2adcbe068a7..53e248f9b07603e7f2221d873f2f91a9a9a211fc 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -64,6 +64,9 @@ Public domain except as listed below:
    Copyright 2006 Gregory Rubin grrubin@gmail.com
    See licenses/LICENSE-HashCash.txt
 
+   GettextResource from gettext v0.18:
+   Copyright (C) 2001, 2007 Free Software Foundation, Inc.
+   See licenses/LICENSE-LGPLv2.1.txt
 
 
 Router:
diff --git a/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java b/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java
index a82debab367c21f0ff23b3d1a415a168b8c266f4..fdae016fca78d6f10a3167a638df744d7b5484ab 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/I2PSnarkUtil.java
@@ -430,4 +430,9 @@ public class I2PSnarkUtil {
     public String getString(String s, Object o, Object o2) {
         return Translate.getString(s, o, o2, _context, BUNDLE_NAME);
     }
+
+    /** ngettext @since 0.7.14 */
+    public String getString(int n, String s, String p) {
+        return Translate.getString(n, s, p, _context, BUNDLE_NAME);
+    }
 }
diff --git a/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
index af793721de52a3bedabba4c83fe190d381b50de9..5ec39ff90a60453d750122bbc9e2fe5d786f650a 100644
--- a/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
+++ b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java
@@ -269,10 +269,10 @@ public class I2PSnarkServlet extends Default {
                       "    <th align=\"left\" colspan=\"2\">");
             out.write(_("Totals"));
             out.write(" (");
-            out.write(_("{0} torrents", snarks.size()));
+            out.write(ngettext("1 torrent", "{0} torrents", snarks.size()));
             out.write(", ");
             out.write(DataHelper.formatSize2(stats[5]) + "B, ");
-            out.write(_("{0} connected peers", stats[4]));
+            out.write(ngettext("1 connected peer", "{0} connected peers", (int) stats[4]));
             out.write(")</th>\n" +
                       "    <th>&nbsp;</th>\n" +
                       "    <th align=\"right\">" + formatSize(stats[0]) + "</th>\n" +
@@ -629,10 +629,12 @@ public class I2PSnarkServlet extends Default {
         if (err != null) {
             if (isRunning && curPeers > 0 && !showPeers)
                 statusString = "<a title=\"" + err + "\">" + _("TrackerErr") + "</a> (" +
-                               curPeers + "/" + knownPeers +
-                               " <a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" + _("peers") + "</a>)";
+                               "<a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" +
+                               curPeers + '/' +
+                               ngettext("1 peer", "{0} peers", knownPeers) + "</a>)";
             else if (isRunning)
-                statusString = "<a title=\"" + err + "\">" + _("TrackerErr") + " (" + curPeers + '/' + knownPeers + ' ' + _("peers") + ')';
+                statusString = "<a title=\"" + err + "\">" + _("TrackerErr") + " (" + curPeers + '/' +
+                               ngettext("1 peer", "{0} peers", knownPeers) + ')';
             else {
                 if (err.length() > MAX_DISPLAYED_ERROR_LENGTH)
                     err = err.substring(0, MAX_DISPLAYED_ERROR_LENGTH) + "&hellip;";
@@ -641,25 +643,31 @@ public class I2PSnarkServlet extends Default {
         } else if (remaining <= 0) {
             if (isRunning && curPeers > 0 && !showPeers)
                 statusString = _("Seeding") + " (" +
-                               curPeers + '/' + knownPeers +
-                               " <a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" + _("peers") + "</a>)";
+                               "<a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" +
+                               curPeers + '/' +
+                               ngettext("1 peer", "{0} peers", knownPeers) + "</a>)";
             else if (isRunning)
-                statusString = _("Seeding") + " (" + curPeers + "/" + knownPeers + ' ' + _("peers") + ')';
+                statusString = _("Seeding") + " (" + curPeers + "/" +
+                               ngettext("1 peer", "{0} peers", knownPeers) + ')';
             else
                 statusString = _("Complete");
         } else {
             if (isRunning && curPeers > 0 && downBps > 0 && !showPeers)
                 statusString = _("OK") + " (" +
-                               curPeers + "/" + knownPeers +
-                               " <a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" + _("peers") + "</a>)";
+                               "<a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" +
+                               curPeers + "/" +
+                               ngettext("1 peer", "{0} peers", knownPeers) + "</a>)";
             else if (isRunning && curPeers > 0 && downBps > 0)
-                statusString = _("OK") + " (" + curPeers + "/" + knownPeers + ' ' + _("peers") + ')';
+                statusString = _("OK") + " (" + curPeers + "/" +
+                               ngettext("1 peer", "{0} peers", knownPeers) + ')';
             else if (isRunning && curPeers > 0 && !showPeers)
                 statusString = _("Stalled") + " (" +
-                               curPeers + '/' + knownPeers +
-                               " <a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" + _("peers") + "</a>)";
+                               "<a href=\"" + uri + "?p=" + Base64.encode(snark.meta.getInfoHash()) + "\">" +
+                               curPeers + '/' +
+                               ngettext("1 peer", "{0} peers", knownPeers) + "</a>)";
             else if (isRunning && curPeers > 0)
-                statusString = _("Stalled") + " (" + curPeers + '/' + knownPeers + ' ' + _("peers") + ')';
+                statusString = _("Stalled") + " (" + curPeers + '/' +
+                               ngettext("1 peer", "{0} peers", knownPeers) + ')';
             else if (isRunning)
                 statusString = _("No Peers") + " (0/" + knownPeers + ')';
             else
@@ -1078,11 +1086,11 @@ public class I2PSnarkServlet extends Default {
     }
     
     /** copied from ConfigTunnelsHelper */
-    private static final String HOP = _x("hop");
-    private static final String TUNNEL = _x("tunnel");
+    private static final String HOP = "hop";
+    private static final String TUNNEL = "tunnel";
     /** dummies for translation */
-    private static final String HOPS = _x("hops");
-    private static final String TUNNELS = _x("tunnels");
+    private static final String HOPS = ngettext("1 hop", "{0} hops");
+    private static final String TUNNELS = ngettext("1 tunnel", "{0} tunnels");
 
     /** modded from ConfigTunnelsHelper @since 0.7.14 */
     private String renderOptions(int min, int max, String strNow, String selName, String name) {
@@ -1096,13 +1104,7 @@ public class I2PSnarkServlet extends Default {
             buf.append("<option value=\"").append(i).append("\" ");
             if (i == now)
                 buf.append("selected=\"true\" ");
-            String pname;
-            // pluralize and then translate
-            if (i != 1 && i != -1)
-                pname = name + 's';
-            else
-                pname = name;
-            buf.append(">").append(i).append(' ').append(_(pname));
+            buf.append(">").append(ngettext("1 " + name, "{0} " + name + 's', i));
             buf.append("</option>\n");
         }
         buf.append("</select>\n");
@@ -1119,9 +1121,14 @@ public class I2PSnarkServlet extends Default {
         return _manager.util().getString(s, o);
     }
 
+    /** translate (ngettext) @since 0.7.14 */
+    private String ngettext(String s, String p, int n) {
+        return _manager.util().getString(n, s, p);
+    }
+
     /** dummy for tagging */
-    private static String _x(String s) {
-        return s;
+    private static String ngettext(String s, String p) {
+        return null;
     }
 
     // rounding makes us look faster :)
diff --git a/apps/i2psnark/locale/messages_ru.po b/apps/i2psnark/locale/messages_ru.po
index ca02857f9b276508c5f44e8d0f8b0fd827188db3..853edcf36c3ec844b4bb19887144bc9fb5833b2d 100644
--- a/apps/i2psnark/locale/messages_ru.po
+++ b/apps/i2psnark/locale/messages_ru.po
@@ -16,6 +16,8 @@ msgstr ""
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
 "X-Poedit-Language: Russian\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%"
+"10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n"
 
 #: ../java/src/org/klomp/snark/SnarkManager.java:87
 #, java-format
diff --git a/apps/i2psnark/locale/messages_zh.po b/apps/i2psnark/locale/messages_zh.po
index 283f79d121d427aae8cfae9d68d0699e13f60ed6..860e93fcff8126664b55bc78874f1cc01280b35d 100644
--- a/apps/i2psnark/locale/messages_zh.po
+++ b/apps/i2psnark/locale/messages_zh.po
@@ -16,6 +16,7 @@ msgstr ""
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
 "X-Poedit-Language: Chinese\n"
+"Plural-Forms: nplurals=1; plural=0\n"
 
 #: ../java/src/org/klomp/snark/SnarkManager.java:84
 #, java-format
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/ConfigTunnelsHelper.java b/apps/routerconsole/java/src/net/i2p/router/web/ConfigTunnelsHelper.java
index 4d007e9d76162b5b9356ea4c05d91167da4d80ed..d3579f17c56be33ef7f4a85ae8538c0933c6ac84 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/ConfigTunnelsHelper.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/ConfigTunnelsHelper.java
@@ -8,11 +8,11 @@ import net.i2p.data.Destination;
 import net.i2p.router.TunnelPoolSettings;
 
 public class ConfigTunnelsHelper extends HelperBase {
-    static final String HOP = _x("hop");
-    static final String TUNNEL = _x("tunnel");
+    private static final String HOP = "hop";
+    private static final String TUNNEL = "tunnel";
     /** dummies for translation */
-    static final String HOPS = _x("hops");
-    static final String TUNNELS = _x("tunnels");
+    private static final String HOPS = ngettext("1 hop", "{0} hops");
+    private static final String TUNNELS = ngettext("1 tunnel", "{0} tunnels");
 
     public ConfigTunnelsHelper() {}
     
@@ -196,14 +196,13 @@ public class ConfigTunnelsHelper extends HelperBase {
             buf.append("<option value=\"").append(i).append("\" ");
             if (i == now)
                 buf.append("selected=\"true\" ");
-            String pname;
-            // pluralize and then translate
-            if (i != 1 && i != -1)
-                pname = name + 's';
-            else
-                pname = name;
-            buf.append(">").append(prefix).append(i).append(' ').append(_(pname));
+            buf.append(">").append(_(i, "1 " + name, "{0} " + name + 's'));
             buf.append("</option>\n");
         }
     }
+
+    /** dummy for tagging */
+    private static String ngettext(String s, String p) {
+        return null;
+    }
 }
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/HelperBase.java b/apps/routerconsole/java/src/net/i2p/router/web/HelperBase.java
index c43caa550adb91cdac14869abdf8c12ae29567dc..1d1d03d59239ef787a52017166f5e48d197d8102 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/HelperBase.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/HelperBase.java
@@ -51,6 +51,11 @@ public abstract class HelperBase {
         return Messages.getString(s, o, _context);
     }
 
+    /** translate (ngettext) @since 0.7.14 */
+    public String _(int n, String s, String p) {
+        return Messages.getString(n, s, p, _context);
+    }
+
     /**
      *  Mark a string for extraction by xgettext and translation.
      *  Use this only in static initializers.
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/Messages.java b/apps/routerconsole/java/src/net/i2p/router/web/Messages.java
index 39b7c2380e9ad20c3094581652d9e8755c18c329..427ac9d8aefc78f1022dfa16332a6fc207d7bcb3 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/Messages.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/Messages.java
@@ -29,4 +29,9 @@ public class Messages extends Translate {
     public static String getString(String s, Object o, I2PAppContext ctx) {
         return Translate.getString(s, o, ctx, BUNDLE_NAME);
     }
+
+    /** translate (ngettext) @since 0.7.14 */
+    public static String getString(int n, String s, String p, I2PAppContext ctx) {
+        return Translate.getString(n, s, p, ctx, BUNDLE_NAME);
+    }
 }
diff --git a/apps/routerconsole/locale/messages_ru.po b/apps/routerconsole/locale/messages_ru.po
index a22569b68801fd5ee354346eb656324ccafc166b..89ef1782baa999eab554f6b0d972669690d9296b 100644
--- a/apps/routerconsole/locale/messages_ru.po
+++ b/apps/routerconsole/locale/messages_ru.po
@@ -16,6 +16,8 @@ msgstr ""
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
 "X-Poedit-Language: Russian\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%"
+"10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n"
 "X-Poedit-Bookmarks: 283,-1,-1,-1,-1,-1,-1,-1,-1,-1\n"
 
 #: ../../../router/java/src/net/i2p/router/Blocklist.java:126
diff --git a/apps/routerconsole/locale/messages_zh.po b/apps/routerconsole/locale/messages_zh.po
index 89ab35634c0ec07cfdc47b4acb6b413b9eed18b4..f5fd269e66e46f97461b1e61521410026c40c7e7 100644
--- a/apps/routerconsole/locale/messages_zh.po
+++ b/apps/routerconsole/locale/messages_zh.po
@@ -17,6 +17,7 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "X-Poedit-Language: Chinese\n"
 "X-Poedit-Country: CHINA\n"
+"Plural-Forms: nplurals=1; plural=0\n"
 
 #: ../../../router/java/src/net/i2p/router/Blocklist.java:117
 #, java-format
diff --git a/core/java/src/gnu/gettext/GettextResource.java b/core/java/src/gnu/gettext/GettextResource.java
new file mode 100644
index 0000000000000000000000000000000000000000..727508f62eb069dff9bdf6995ab043461bf2b1fc
--- /dev/null
+++ b/core/java/src/gnu/gettext/GettextResource.java
@@ -0,0 +1,269 @@
+/* GNU gettext for Java
+ * Copyright (C) 2001, 2007 Free Software Foundation, Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Library General Public License as published
+ * by the Free Software Foundation; either version 2, 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
+ * Library General Public License for more details.
+ *
+ * You should have received a copy of the GNU Library General Public
+ * License along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+ * USA.
+ */
+
+package gnu.gettext;
+
+import java.lang.reflect.*;
+import java.util.*;
+
+/**
+ * This class implements the main GNU libintl functions in Java.
+ * <P>
+ * Using the GNU gettext approach, compiled message catalogs are normal
+ * Java ResourceBundle classes and are thus interoperable with standard
+ * ResourceBundle based code.
+ * <P>
+ * The main differences between the Sun ResourceBundle approach and the
+ * GNU gettext approach are:
+ * <UL>
+ *   <LI>In the Sun approach, the keys are abstract textual shortcuts.
+ *       In the GNU gettext approach, the keys are the English/ASCII version
+ *       of the messages.
+ *   <LI>In the Sun approach, the translation files are called
+ *       "<VAR>Resource</VAR>_<VAR>locale</VAR>.properties" and have non-ASCII
+ *       characters encoded in the Java
+ *       <CODE>\</CODE><CODE>u<VAR>nnnn</VAR></CODE> syntax. Very few editors
+ *       can natively display international characters in this format. In the
+ *       GNU gettext approach, the translation files are called
+ *       "<VAR>Resource</VAR>.<VAR>locale</VAR>.po"
+ *       and are in the encoding the translator has chosen. Many editors
+ *       can be used. There are at least three GUI translating tools
+ *       (Emacs PO mode, KDE KBabel, GNOME gtranslator).
+ *   <LI>In the Sun approach, the function
+ *       <CODE>ResourceBundle.getString</CODE> throws a
+ *       <CODE>MissingResourceException</CODE> when no translation is found.
+ *       In the GNU gettext approach, the <CODE>gettext</CODE> function
+ *       returns the (English) message key in that case.
+ *   <LI>In the Sun approach, there is no support for plural handling.
+ *       Even the most elaborate MessageFormat strings cannot provide decent
+ *       plural handling. In the GNU gettext approach, we have the
+ *       <CODE>ngettext</CODE> function.
+ * </UL>
+ * <P>
+ * To compile GNU gettext message catalogs into Java ResourceBundle classes,
+ * the <CODE>msgfmt</CODE> program can be used.
+ *
+ * @author Bruno Haible
+ */
+public abstract class GettextResource extends ResourceBundle {
+
+  public static boolean verbose = false;
+
+  /**
+   * Like gettext(catalog,msgid), except that it returns <CODE>null</CODE>
+   * when no translation was found.
+   */
+  private static String gettextnull (ResourceBundle catalog, String msgid) {
+    try {
+      return (String)catalog.getObject(msgid);
+    } catch (MissingResourceException e) {
+      return null;
+    }
+  }
+
+  /**
+   * Returns the translation of <VAR>msgid</VAR>.
+   * @param catalog a ResourceBundle
+   * @param msgid the key string to be translated, an ASCII string
+   * @return the translation of <VAR>msgid</VAR>, or <VAR>msgid</VAR> if
+   *         none is found
+   */
+  public static String gettext (ResourceBundle catalog, String msgid) {
+    String result = gettextnull(catalog,msgid);
+    if (result != null)
+      return result;
+    return msgid;
+  }
+
+  /**
+   * Like ngettext(catalog,msgid,msgid_plural,n), except that it returns
+   * <CODE>null</CODE> when no translation was found.
+   */
+  private static String ngettextnull (ResourceBundle catalog, String msgid, long n) {
+    // The reason why we use so many reflective API calls instead of letting
+    // the GNU gettext generated ResourceBundles implement some interface,
+    // is that we want the generated ResourceBundles to be completely
+    // standalone, so that migration from the Sun approach to the GNU gettext
+    // approach (without use of plurals) is as straightforward as possible.
+    ResourceBundle origCatalog = catalog;
+    do {
+      // Try catalog itself.
+      if (verbose)
+        System.out.println("ngettext on "+catalog);
+      Method handleGetObjectMethod = null;
+      Method getParentMethod = null;
+      try {
+        handleGetObjectMethod = catalog.getClass().getMethod("handleGetObject", new Class[] { java.lang.String.class });
+        getParentMethod = catalog.getClass().getMethod("getParent", new Class[0]);
+      } catch (NoSuchMethodException e) {
+      } catch (SecurityException e) {
+      }
+      if (verbose)
+        System.out.println("handleGetObject = "+(handleGetObjectMethod!=null)+", getParent = "+(getParentMethod!=null));
+      if (handleGetObjectMethod != null
+          && Modifier.isPublic(handleGetObjectMethod.getModifiers())
+          && getParentMethod != null) {
+        // A GNU gettext created class.
+        Method lookupMethod = null;
+        Method pluralEvalMethod = null;
+        try {
+          lookupMethod = catalog.getClass().getMethod("lookup", new Class[] { java.lang.String.class });
+          pluralEvalMethod = catalog.getClass().getMethod("pluralEval", new Class[] { Long.TYPE });
+        } catch (NoSuchMethodException e) {
+        } catch (SecurityException e) {
+        }
+        if (verbose)
+          System.out.println("lookup = "+(lookupMethod!=null)+", pluralEval = "+(pluralEvalMethod!=null));
+        if (lookupMethod != null && pluralEvalMethod != null) {
+          // A GNU gettext created class with plural handling.
+          Object localValue = null;
+          try {
+            localValue = lookupMethod.invoke(catalog, new Object[] { msgid });
+          } catch (IllegalAccessException e) {
+            e.printStackTrace();
+          } catch (InvocationTargetException e) {
+            e.getTargetException().printStackTrace();
+          }
+          if (localValue != null) {
+            if (verbose)
+              System.out.println("localValue = "+localValue);
+            if (localValue instanceof String)
+              // Found the value. It doesn't depend on n in this case.
+              return (String)localValue;
+            else {
+              String[] pluralforms = (String[])localValue;
+              long i = 0;
+              try {
+                i = ((Long) pluralEvalMethod.invoke(catalog, new Object[] { new Long(n) })).longValue();
+                if (!(i >= 0 && i < pluralforms.length))
+                  i = 0;
+              } catch (IllegalAccessException e) {
+                e.printStackTrace();
+              } catch (InvocationTargetException e) {
+                e.getTargetException().printStackTrace();
+              }
+              return pluralforms[(int)i];
+            }
+          }
+        } else {
+          // A GNU gettext created class without plural handling.
+          Object localValue = null;
+          try {
+            localValue = handleGetObjectMethod.invoke(catalog, new Object[] { msgid });
+          } catch (IllegalAccessException e) {
+            e.printStackTrace();
+          } catch (InvocationTargetException e) {
+            e.getTargetException().printStackTrace();
+          }
+          if (localValue != null) {
+            // Found the value. It doesn't depend on n in this case.
+            if (verbose)
+              System.out.println("localValue = "+localValue);
+            return (String)localValue;
+          }
+        }
+        Object parentCatalog = catalog;
+        try {
+          parentCatalog = getParentMethod.invoke(catalog, new Object[0]);
+        } catch (IllegalAccessException e) {
+          e.printStackTrace();
+        } catch (InvocationTargetException e) {
+          e.getTargetException().printStackTrace();
+        }
+        if (parentCatalog != catalog)
+          catalog = (ResourceBundle)parentCatalog;
+        else
+          break;
+      } else
+        // Not a GNU gettext created class.
+        break;
+    } while (catalog != null);
+    // The end of chain of GNU gettext ResourceBundles is reached.
+    if (catalog != null) {
+      // For a non-GNU ResourceBundle we cannot access 'parent' and
+      // 'handleGetObject', so make a single call to catalog and all
+      // its parent catalogs at once.
+      Object value;
+      try {
+        value = catalog.getObject(msgid);
+      } catch (MissingResourceException e) {
+        value = null;
+      }
+      if (value != null)
+        // Found the value. It doesn't depend on n in this case.
+        return (String)value;
+    }
+    // Default: null.
+    return null;
+  }
+
+  /**
+   * Returns the plural form for <VAR>n</VAR> of the translation of
+   * <VAR>msgid</VAR>.
+   * @param catalog a ResourceBundle
+   * @param msgid the key string to be translated, an ASCII string
+   * @param msgid_plural its English plural form
+   * @return the translation of <VAR>msgid</VAR> depending on <VAR>n</VAR>,
+   *         or <VAR>msgid</VAR> or <VAR>msgid_plural</VAR> if none is found
+   */
+  public static String ngettext (ResourceBundle catalog, String msgid, String msgid_plural, long n) {
+    String result = ngettextnull(catalog,msgid,n);
+    if (result != null)
+      return result;
+    // Default: English strings and Germanic plural rule.
+    return (n != 1 ? msgid_plural : msgid);
+  }
+
+  /* The separator between msgctxt and msgid.  */
+  private static final String CONTEXT_GLUE = "\u0004";
+
+  /**
+   * Returns the translation of <VAR>msgid</VAR> in the context of
+   * <VAR>msgctxt</VAR>.
+   * @param catalog a ResourceBundle
+   * @param msgctxt the context for the key string, an ASCII string
+   * @param msgid the key string to be translated, an ASCII string
+   * @return the translation of <VAR>msgid</VAR>, or <VAR>msgid</VAR> if
+   *         none is found
+   */
+  public static String pgettext (ResourceBundle catalog, String msgctxt, String msgid) {
+    String result = gettextnull(catalog,msgctxt+CONTEXT_GLUE+msgid);
+    if (result != null)
+      return result;
+    return msgid;
+  }
+
+  /**
+   * Returns the plural form for <VAR>n</VAR> of the translation of
+   * <VAR>msgid</VAR> in the context of <VAR>msgctxt</VAR>.
+   * @param catalog a ResourceBundle
+   * @param msgctxt the context for the key string, an ASCII string
+   * @param msgid the key string to be translated, an ASCII string
+   * @param msgid_plural its English plural form
+   * @return the translation of <VAR>msgid</VAR> depending on <VAR>n</VAR>,
+   *         or <VAR>msgid</VAR> or <VAR>msgid_plural</VAR> if none is found
+   */
+  public static String npgettext (ResourceBundle catalog, String msgctxt, String msgid, String msgid_plural, long n) {
+    String result = ngettextnull(catalog,msgctxt+CONTEXT_GLUE+msgid,n);
+    if (result != null)
+      return result;
+    // Default: English strings and Germanic plural rule.
+    return (n != 1 ? msgid_plural : msgid);
+  }
+}
diff --git a/core/java/src/net/i2p/util/Translate.java b/core/java/src/net/i2p/util/Translate.java
index 799b89c00cc2e0b5e0c5af4f22cfc7e21a93f4fb..40d07f76ce59c834ad88f31c894a8d8cab54125e 100644
--- a/core/java/src/net/i2p/util/Translate.java
+++ b/core/java/src/net/i2p/util/Translate.java
@@ -8,6 +8,8 @@ import java.util.ResourceBundle;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
+import gnu.gettext.GettextResource;
+
 import net.i2p.I2PAppContext;
 import net.i2p.util.ConcurrentHashSet;
 
@@ -102,6 +104,40 @@ public abstract class Translate {
         }
     }
 
+    /**
+     *  Use GNU ngettext
+     *  For .po file format see http://www.gnu.org/software/gettext/manual/gettext.html.gz#Translating-plural-forms
+     *
+     *  @param n how many
+     *  @param s singluar string, optionally with {0} e.g. "one tunnel"
+     *  @param s plural string optionally with {0} e.g. "{0} tunnels"
+     *  @since 0.7.14
+     */
+    public static String getString(int n, String s, String p, I2PAppContext ctx, String bun) {
+        String lang = getLanguage(ctx);
+        if (lang.equals(TEST_LANG))
+            return TEST_STRING + '(' + n + ')' + TEST_STRING;
+        ResourceBundle bundle = null;
+        if (!lang.equals("en"))
+            bundle = findBundle(bun, lang);
+        String x;
+        if (bundle == null)
+            x = n == 1 ? s : p;
+        else
+            x = GettextResource.ngettext(bundle, s, p, n);
+        Object[] oArray = new Object[1];
+        oArray[0] = Integer.valueOf(n);
+        try {
+            MessageFormat fmt = new MessageFormat(x, new Locale(lang));
+            return fmt.format(oArray, new StringBuffer(), null).toString();
+        } catch (IllegalArgumentException iae) {
+            System.err.println("Bad format: sing: \"" + s +
+                           "\" plural: \"" + p +
+                           "\" lang: " + lang);
+            return "FIXME: " + s + ' ' + p + ',' + n;
+        }
+    }
+
     /** @return lang in routerconsole.lang property, else current locale */
     public static String getLanguage(I2PAppContext ctx) {
         String lang = ctx.getProperty(PROP_LANG);
diff --git a/history.txt b/history.txt
index a16a2758c5feb42aa1d0e963b9490523d65a366e..b5de3e81cab42bce73822b73f41fc15c92885339 100644
--- a/history.txt
+++ b/history.txt
@@ -1,3 +1,6 @@
+2010-05-27 zzz
+    * Translate: Add GNU ngettext (plurals) support
+
 2010-05-26 zzz
     * i2psnark: Listing fixes and cleanups;
       icons on front page; tweak bw choker again
diff --git a/router/java/src/net/i2p/router/RouterVersion.java b/router/java/src/net/i2p/router/RouterVersion.java
index e36cd3b14352a16a1e79c5fea474cb2f20cbaa5c..7f4edb97520b081fa27255ad38c4da1068327822 100644
--- a/router/java/src/net/i2p/router/RouterVersion.java
+++ b/router/java/src/net/i2p/router/RouterVersion.java
@@ -18,7 +18,7 @@ public class RouterVersion {
     /** deprecated */
     public final static String ID = "Monotone";
     public final static String VERSION = CoreVersion.VERSION;
-    public final static long BUILD = 12;
+    public final static long BUILD = 13;
 
     /** for example "-test" */
     public final static String EXTRA = "";