Repository: gratipay/gratipay.com

Issue:
Internationalize user interface messages (i18n)
Although I’m not sure if it’s really necessary for open source hackers, but I think it would be nice.  The most of open source hackers are already enough familiar with English, but I think givers might not.

Aspen seems not to have any special considerations about internationalization, but we can simply go [Babel](http://babel.edgewall.org/).

Opinions?

<!---
@huboard:{"order":2300.0,"custom_state":""}
-->



Discussion:
This would be awesome! You're right that Aspen doesn't make any provisions for i18n. Have you used Babel before?

@whit537 I’ve done i18n several times using Babel before.  I want to work on this if you don’t mind.

@dahlia Yes, please. This is great! :D

Dropping from Infrastructure. See #1030.

To note, our templating engine is now Jinja2 which has a plugin for i18n

+1 from eliasfbarros in private email.

I'm happy to help with translation. 
I spoke with a friend about the project he also would like to help. 
The translation is being done using Aspen or Babel? 
Is there any simple tutorial to install Gittip in Ubuntu13.10?

There is a [PR with a script to install gittip on ubuntu 12.04](https://github.com/gittip/www.gittip.com/pull/1999). You can take a look. It might help. If I remember right the postgres package from 12.04 can be installed on 13.10 if you uninstall logrotate. There was a repo with postgres 9.3 for ubuntu 13.10 @rummik is using https://launchpad.net/~chris-lea/+archive/postgresql-9.3 (I haven't tried it).

All other info should be in the [README.md](https://github.com/gittip/www.gittip.com/blob/master/README.md).

+1 from Gérard Foucher from France via support@gittip.com.

Thoughts on using a [python port](https://github.com/tuvistavie/python-i18n) of rails' [i18n gem](https://github.com/svenfuchs/i18n)? I've been messing around with LocaleApp, and it seems like it would help us make internationalization dead-simple to crowdsource:
http://www.localeapp.com/

Technically, LocaleApp is meant to work for the ruby i18n lib, but since python-i18n is a direct port, we should be able to get it working. Could even have travis pull in the most recent translations before deploying a slug to heroku :)

(I don't like that localeapp isn't open source, but the UI seems really slick, so I think it might be worth using a closed source app in to get the best experience to our volunteer translators!)

Oh, I might take the process for a test-spin by internationalizing the strings for our invoke tasks. Will submit a PR.

cc: @YenaHong (I saw in your intro repo that you do translations, and thought this might be up your alley)

Well, I usually do translations on [translatewiki.net](https://translatewiki.net), which was originally designed for MediaWiki i18n. Though translation using any platform is OK, but what we should do is deploying translation quickly (and if possible, automatically).

Gotcha. LocaleApp seems to have a really slick UI, so it might suffice

cc: @nemobis @siebrand @Nikerabbit from translatewiki project:
https://translatewiki.net/wiki/Project:About
https://github.com/wikimedia/translatewiki

Interesting project. Taking a look at your i18n.

Hmm, there is no i18n yet, is there? I'm not familiar with the  above mentioned python port of a Ruby i18n framework. If that's rails-i18n with YAML files, that would be very nice.

Yep, you're right about lacking i18n. And yes, the railsy yaml format is how it works in ruby-land, so personally would prefer to stay cross-compatible for the sake of tooling

Yes, that's a format we support. See https://translatewiki.net/wiki/Translating:New_project for requirements, I see it's a CC-0 website and while you i18n it you'll also need to add message documentation. https://translatewiki.net/wiki/Translating:Localisation_for_developers#Message_documentation

FYI someone else is using translatewiki for non-mediawiki sites (the flash editor UI):
https://translatewiki.net/wiki/Translating:OpenStreetMap

Not quite as smooth as localeapp, but free software is nice :)

The next thing to figure out is whether there's a cli for pushing and pulling translations, as that's a really helpful feature of localeapp that will allow us to sync translations on travis deploy...

@michaelbaudino @futhr
I don't suppose @Locale has any plans to go open source anytime soon? Closed source software def part of your business model? *cough*[opencompany](http://www.opencompany.org/about/)*cough* :)

@patcon Localeapp is a free service for open source projects.

@futhr thanks man :) I'm aware it's free for OSS projects, but I still lean toward using services that use free software, for all the reasons here: https://github.com/bevry/goopen

So that's a no? It's not an internal topic of discussion either? No problem if it isn't, just looking for clarity

@patcon I'm unable to answer that. Its a question for the project owner @tigrish

@futhr :+1: thanks. I'll just hold off on his comment before moving forward

Yes, some dozens projects use translatewiki.net. https://translatewiki.net/wiki/Category:Supported_projects
You don't need cli access, translations are committed directly to your repo, as explained in https://translatewiki.net/wiki/Translating:New_project (please give @siebrand and @nikerabbit push access when you want to move forward).

"Not quite as smooth": have you tried translating? Please register on the main page and give it a try, or use https://meta.wikimedia.org/wiki/Special:Translate which is open for everyone. At any rate, _please_ file the things you (would) miss from Localeapp: https://bugzilla.wikimedia.org/enter_bug.cgi?product=MediaWiki%20extensions&component=Translate

@nemobis thanks very much! I'll give the docs a further read. They're great, but it's simply that, having dedicated about equal time to each, I feel I understand LocaleApp a little better. But I'll commit some more time to both :)

Perhaps it would be beneficial to start with LocaleApp, so we understand that workflow, and then migrate to TranslateWiki, so we'll know what might be missing, if anything (and then we can perhaps commit code ourselves to scratch that itch ;)

EDIT: In retrospect, I think all the pretty screen captures on LocaleApp were what made my lazy skimming so information-dense :)

There's also https://www.transifex.com/, which is an [open product](https://github.com/transifex/transifex).

Not by the definition of open service http://opendefinition.org/software-service/

@nemobis Thanks, I hadn't seen the opendefinition.org website before, nor their definition of an "open service." Ftr, I was referring to the [definition of an open product](http://thechangelog.com/open-products/) that I laid out last year.

Mysterious translation app comparison spreadsheet of unknown origins and authorship ;)
https://docs.google.com/spreadsheet/ccc?key=0AvdoxmL5NpHydFFKMXJVZ1B0ZnpsUWd3LTVQRngtcmc&usp=drive_web#gid=0

Hey guys, Dimitris, founder of Transifex here. We'd be happy to host gittip on Transifex. There are more than 15.000 open-source projects hosted, including Linus Torvald's own pet projects, so you'll be in good company.

In terms of openness, Transifex is much closer to GitHub than your average open-source project.

My advice, based on experience, is to invest in a solid localization platform. It makes the difference between 4 languages 85% translated and 20 languages 98% translated.

> My advice, based on experience, is to invest in a solid localization platform. It makes the difference between 4 languages 85% translated and 20 languages 98% translated.

Agreed. One such solid platform is translatewiki.net (or any MediaWiki + Translate install of course, it's all free software) and the last project added got 100 % translated in 12 languages in less than a month. Of course it's not as big as fedora. :)
https://translatewiki.net/wiki/Special:MessageGroupStats/out-nfcring-control#sortable:3=desc

http://blog.discourse.org/2014/05/translating-discourse-with-transifex/

Transiflex does a very good job.
I used it for both "The Foreman" and theforeman.org, I warmly recommend it .

+1 for transifex, have used it and loved it.


Resolving patch:
diff --git a/configure-aspen.py b/configure-aspen.py
--- a/configure-aspen.py
+++ b/configure-aspen.py
@@ -10,7 +10,7 @@
 import gittip.wireup
 from gittip import canonize, utils
 from gittip.security import authentication, csrf, x_frame_options
-from gittip.utils import cache_static, set_cookie, timer
+from gittip.utils import cache_static, i18n, set_cookie, timer
 
 
 import aspen
@@ -106,6 +106,7 @@ def add_stuff_to_context(request):
                       , authentication.inbound
                       , csrf.inbound
                       , add_stuff_to_context
+                      , i18n.inbound
 
                       , algorithm['dispatch_request_to_filesystem']
                       , algorithm['apply_typecasters_to_path']
diff --git a/gittip/utils/__init__.py b/gittip/utils/__init__.py
--- a/gittip/utils/__init__.py
+++ b/gittip/utils/__init__.py
@@ -1,9 +1,8 @@
 from datetime import datetime, timedelta
-import locale
 import re
 
 from aspen import Response
-from aspen.utils import typecheck, to_age, to_rfc822, utcnow
+from aspen.utils import typecheck, to_rfc822, utcnow
 import gittip
 from postgres.cursors import SimpleCursorBase
 from jinja2 import escape
@@ -378,8 +377,8 @@ def update_global_stats(website):
         SELECT nactive, transfer_volume FROM paydays
         ORDER BY ts_end DESC LIMIT 1
     """, default=(0, 0.0))
-    website.gnactive = locale.format("%d", stats[0], grouping=True)
-    website.gtransfer_volume = locale.format("%d", stats[1], grouping=True)
+    website.gnactive = stats[0]
+    website.gtransfer_volume = stats[1]
     website.glast_week = last_week(website.db)
 
 
@@ -415,20 +414,6 @@ def wrapper(*a, **kw):
     return wrapper
 
 
-def _to_age(participant):
-    # XXX I can't believe I'm doing this. Evolve aspen.utils.to_age!
-    age = to_age(participant.claimed_time, fmt_past="%(age)s")
-    age = age.replace('just a moment', 'just now')
-    age = age.replace('an ', '1 ').replace('a ', '1 ')
-    if age.endswith(' seconds'):
-        age = '1 minute'
-    words = ('zero', 'one', 'two','three', 'four', 'five', 'six', 'seven',
-                                                               'eight', 'nine')
-    for i, word in enumerate(words):
-        age = age.replace(word, str(i))
-    return age.replace(' ', ' <span class="unit">') + "</span>"
-
-
 def format_money(money):
     format = '%.2f' if money < 1000 else '%.0f'
     return format % money
@@ -436,7 +421,7 @@ def format_money(money):
 
 def to_statement(prepend, string, length=140, append='...'):
     if prepend and string:
-        statement = prepend + string
+        statement = prepend.format(string)
         if len(string) > length:
             return statement[:length] + append
         elif len(string) > 0:
diff --git a/gittip/utils/i18n.py b/gittip/utils/i18n.py
new file mode 100644
--- /dev/null
+++ b/gittip/utils/i18n.py
@@ -0,0 +1,104 @@
+from __future__ import print_function, unicode_literals
+
+import os
+import re
+
+from aspen.utils import utcnow
+from babel.dates import format_timedelta
+import babel.messages.pofile
+from babel.numbers import (
+    format_currency, format_decimal, format_number, format_percent, parse_decimal, get_decimal_symbol
+)
+
+
+ternary_re = re.compile(r'^\(? *(.+?) *\? *(.+?) *: *(.+?) *\)?$')
+and_re = re.compile(r' *&& *')
+or_re = re.compile(r' *\|\| *')
+
+
+def ternary_sub(m):
+    g1, g2, g3 = m.groups()
+    return '%s if %s else %s' % (g2, g1, ternary_re.sub(ternary_sub, g3))
+
+
+def get_function_from_rule(rule):
+    rule = ternary_re.sub(ternary_sub, rule.strip())
+    rule = and_re.sub(' and ', rule)
+    rule = or_re.sub(' or ', rule)
+    return eval('lambda n: ' + rule, {'__builtins__': {}})
+
+
+def get_text(request, loc, s, *a, **kw):
+    catalog = LANGS.get(loc)
+    msg = catalog.get(s) if catalog else None
+    if isinstance(s, tuple):
+        n = kw.pop('n', None)
+        if n is None:
+            try:
+                raise TypeError('n should not be None')
+            except Exception as e:
+                request.website.tell_sentry(e, request)
+        s2 = None
+        if msg:
+            try:
+                s2 = msg.string[catalog.plural_func(n)]
+            except Exception as e:
+                request.website.tell_sentry(e, request)
+        if s2 is None:
+            loc = 'en'
+            s2 = s[n != 1]
+        kw['n'] = n != None and format_number(n, locale=loc) or '{n}'
+        s = s2
+    elif msg:
+        s = msg.string
+    if a or kw:
+        if isinstance(s, bytes):
+            s = s.decode('ascii')
+        return s.format(*a, **kw)
+    return s
+
+
+def to_age(dt, loc):
+    return format_timedelta(dt - utcnow(), add_direction=True, locale=loc)
+
+
+def load_langs(localeDir):
+    langs = {}
+    for file in os.listdir(localeDir):
+        parts = file.split(".")
+        if len(parts) == 2 and parts[1] == "po":
+            lang = parts[0]
+            with open(os.path.join(localeDir, file)) as f:
+                c = langs[lang] = babel.messages.pofile.read_po(f)
+                c.plural_func = get_function_from_rule(c.plural_expr)
+    return langs
+
+
+LANGS = load_langs("i18n")
+
+
+def get_locale_for_request(request):
+    accept_lang = request.headers.get("Accept-Language", "")
+    languages = (lang.split(";", 1)[0] for lang in accept_lang.split(","))
+    for loc in languages:
+        if loc.startswith("en") or LANGS.has_key(loc):
+            return loc
+    return "en"
+
+
+def inbound(request):
+    context = request.context
+    loc = context.locale = get_locale_for_request(request)
+    context._ = lambda s, *a, **kw: get_text(request, loc, s, *a, **kw)
+    context.format_number = lambda *a: format_number(*a, locale=loc)
+    context.format_decimal = lambda *a: format_decimal(*a, locale=loc)
+    context.format_currency = lambda *a: format_currency(*a, locale=loc)
+    context.format_percent = lambda *a: format_percent(*a, locale=loc)
+    context.parse_decimal = lambda *a: parse_decimal(*a, locale=loc)
+    context.decimal_symbol = get_decimal_symbol(locale=loc)
+    def _to_age(delta):
+        try:
+            return to_age(delta, loc)
+        except:
+            return to_age(delta, 'en')
+    context.to_age = _to_age


Test patch:
diff --git a/tests/py/test_goal_json.py b/tests/py/test_goal_json.py
--- a/tests/py/test_goal_json.py
+++ b/tests/py/test_goal_json.py
@@ -28,22 +28,22 @@ def test_participant_can_set_their_goal_to_null(self):
     def test_participant_can_set_their_goal_to_zero(self):
         response = self.change_goal("0")
         actual = json.loads(response.body)['goal']
-        assert actual == "0.00"
+        assert actual == "0"
 
     def test_participant_can_set_their_goal_to_a_custom_amount(self):
         response = self.change_goal("custom", "100.00")
         actual = json.loads(response.body)['goal']
-        assert actual == "100.00"
+        assert actual == "100"
 
     def test_custom_amounts_can_include_comma(self):
         response = self.change_goal("custom", "1,100.00")
         actual = json.loads(response.body)['goal']
-        assert actual == "1,100.00"
+        assert actual == "1,100"
 
     def test_wonky_custom_amounts_are_standardized(self):
         response = self.change_goal("custom", ",100,100.00000")
         actual = json.loads(response.body)['goal']
-        assert actual == "100,100.00"
+        assert actual == "100,100"
 
     def test_anonymous_gets_404(self):
         response = self.change_goal("100.00", auth_as=None, expecting_error=True)