diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 37e34ea5..95f7aac7 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -1,7 +1,6 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -import sys from sys import platform import json from os import mkdir @@ -17,6 +16,7 @@ import tkFont if __debug__: from traceback import print_exc +import l10n import companion import bpc import td @@ -27,8 +27,9 @@ import flightlog import prefs from config import appname, applongname, config +l10n.Translations().install() -shipyard_retry = 5 # retry pause for shipyard data [s] +SHIPYARD_RETRY = 5 # retry pause for shipyard data [s] class AppWindow: @@ -67,14 +68,14 @@ class AppWindow: frame.columnconfigure(1, weight=1) frame.rowconfigure(4, weight=1) - ttk.Label(frame, text="Cmdr:").grid(row=0, column=0, sticky=tk.W) - ttk.Label(frame, text="System:").grid(row=1, column=0, sticky=tk.W) - ttk.Label(frame, text="Station:").grid(row=2, column=0, sticky=tk.W) + ttk.Label(frame, text=_('Cmdr:')).grid(row=0, column=0, sticky=tk.W) # Main window + ttk.Label(frame, text=_('System:')).grid(row=1, column=0, sticky=tk.W) # Main window + ttk.Label(frame, text=_('Station:')).grid(row=2, column=0, sticky=tk.W) # Main window self.cmdr = ttk.Label(frame, width=-20) self.system = ttk.Label(frame, width=-20) self.station = ttk.Label(frame, width=-20) - self.button = ttk.Button(frame, text='Update', command=self.getandsend, default=tk.ACTIVE, state=tk.DISABLED) + self.button = ttk.Button(frame, text=_('Update'), command=self.getandsend, default=tk.ACTIVE, state=tk.DISABLED) # Update button in main window self.status = ttk.Label(frame, width=-25) self.w.bind('', self.getandsend) self.w.bind('', self.getandsend) @@ -93,12 +94,13 @@ class AppWindow: from Foundation import NSBundle # https://www.tcl.tk/man/tcl/TkCmd/menu.htm apple_menu = tk.Menu(menubar, name='apple') - apple_menu.add_command(label="About %s" % applongname, command=lambda:self.w.call('tk::mac::standardAboutPanel')) - apple_menu.add_command(label="Check for Update", command=lambda:self.updater.checkForUpdates()) + apple_menu.add_command(label=_("About {APP}").format(APP=applongname), command=lambda:self.w.call('tk::mac::standardAboutPanel')) # App menu entry on OSX + apple_menu.add_command(label=_("Check for Updates..."), command=lambda:self.updater.checkForUpdates()) menubar.add_cascade(menu=apple_menu) window_menu = tk.Menu(menubar, name='window') menubar.add_cascade(menu=window_menu) # https://www.tcl.tk/man/tcl/TkCmd/tk_mac.htm + self.w.call('set', 'tk::mac::useCompatibilityMetrics', '0') self.w.createcommand('tkAboutDialog', lambda:self.w.call('tk::mac::standardAboutPanel')) self.w.createcommand("::tk::mac::Quit", self.onexit) self.w.createcommand("::tk::mac::ShowPreferences", lambda:prefs.PreferencesDialog(self.w, self.login)) @@ -106,11 +108,11 @@ class AppWindow: self.w.protocol("WM_DELETE_WINDOW", self.w.withdraw) # close button shouldn't quit app else: file_menu = tk.Menu(menubar, tearoff=tk.FALSE) - file_menu.add_command(label="Check for Update", command=lambda:self.updater.checkForUpdates()) - file_menu.add_command(label="Settings", command=lambda:prefs.PreferencesDialog(self.w, self.login)) + file_menu.add_command(label=_("Check for Updates..."), command=lambda:self.updater.checkForUpdates()) + file_menu.add_command(label=_("Settings"), command=lambda:prefs.PreferencesDialog(self.w, self.login)) # Menu item file_menu.add_separator() - file_menu.add_command(label="Exit", command=self.onexit) - menubar.add_cascade(label="File", menu=file_menu) + file_menu.add_command(label=_("Exit"), command=self.onexit) # Menu item + menubar.add_cascade(label=_("File"), menu=file_menu) # Top-level menu on Windows self.w.protocol("WM_DELETE_WINDOW", self.onexit) if platform == 'linux2': # Fix up menu to use same styling as everything else @@ -148,7 +150,7 @@ class AppWindow: # call after credentials have changed def login(self): - self.status['text'] = 'Logging in...' + self.status['text'] = _('Logging in...') self.button['state'] = tk.DISABLED self.w.update_idletasks() try: @@ -188,7 +190,7 @@ class AppWindow: if not retrying: if time() < self.holdofftime: return # Was invoked by Return key while in cooldown self.cmdr['text'] = self.system['text'] = self.station['text'] = '' - self.status['text'] = 'Fetching station data...' + self.status['text'] = _('Fetching station data...') self.button['state'] = tk.DISABLED self.w.update_idletasks() @@ -206,15 +208,15 @@ class AppWindow: # Validation if not data.get('commander') or not data['commander'].get('name','').strip(): - self.status['text'] = "Who are you?!" # Shouldn't happen + self.status['text'] = _("Who are you?!") # Shouldn't happen elif not data.get('lastSystem') or not data['lastSystem'].get('name','').strip() or not data.get('lastStarport') or not data['lastStarport'].get('name','').strip(): - self.status['text'] = "Where are you?!" # Shouldn't happen + self.status['text'] = _("Where are you?!") # Shouldn't happen elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name','').strip(): - self.status['text'] = "What are you flying?!" # Shouldn't happen + self.status['text'] = _("What are you flying?!") # Shouldn't happen elif (config.getint('output') & config.OUT_EDDN) and data['commander'].get('docked') and not data['lastStarport'].get('ships') and not retrying: # API is flakey about shipyard info - retry if missing (<1s is usually sufficient - 5s for margin). - self.w.after(shipyard_retry * 1000, lambda:self.getandsend(retrying=True)) + self.w.after(SHIPYARD_RETRY * 1000, lambda:self.getandsend(retrying=True)) # Stuff we can do while waiting for retry if config.getint('output') & config.OUT_LOG: @@ -243,10 +245,10 @@ class AppWindow: if not (config.getint('output') & (config.OUT_CSV|config.OUT_TD|config.OUT_BPC|config.OUT_EDDN)): # no further output requested - self.status['text'] = strftime('Last updated at %H:%M:%S', localtime(querytime)) + self.status['text'] = strftime(_('Last updated at {HH}:{MM}:{SS}').format(HH='%H', MM='%M', SS='%S').encode('utf-8'), localtime(querytime)).decode('utf-8') elif not data['commander'].get('docked'): - self.status['text'] = "You're not docked at a station!" + self.status['text'] = _("You're not docked at a station!") else: if data['lastStarport'].get('commodities'): # Fixup anomalies in the commodity data @@ -261,16 +263,16 @@ class AppWindow: if config.getint('output') & config.OUT_EDDN: if data['lastStarport'].get('commodities') or data['lastStarport'].get('modules') or data['lastStarport'].get('ships'): - self.status['text'] = 'Sending data to EDDN...' + self.status['text'] = _('Sending data to EDDN...') self.w.update_idletasks() eddn.export(data) - self.status['text'] = strftime('Last updated at %H:%M:%S', localtime(querytime)) + self.status['text'] = strftime(_('Last updated at {HH}:{MM}:{SS}').format(HH='%H', MM='%M', SS='%S').encode('utf-8'), localtime(querytime)).decode('utf-8') else: - self.status['text'] = "Station doesn't have anything!" + self.status['text'] = _("Station doesn't have anything!") elif not data['lastStarport'].get('commodities'): - self.status['text'] = "Station doesn't have a market!" + self.status['text'] = _("Station doesn't have a market!") else: - self.status['text'] = strftime('Last updated at %H:%M:%S', localtime(querytime)) + self.status['text'] = strftime(_('Last updated at {HH}:{MM}:{SS}').format(HH='%H', MM='%M', SS='%S').encode('utf-8'), localtime(querytime)).decode('utf-8') except companion.VerificationRequired: return prefs.AuthenticationDialog(self.w, self.verify) @@ -281,11 +283,11 @@ class AppWindow: except requests.exceptions.ConnectionError as e: if __debug__: print_exc() - self.status['text'] = "Error: Can't connect to EDDN" + self.status['text'] = _("Error: Can't connect to EDDN") except requests.exceptions.Timeout as e: if __debug__: print_exc() - self.status['text'] = "Error: Connection to EDDN timed out" + self.status['text'] = _("Error: Connection to EDDN timed out") except Exception as e: if __debug__: print_exc() @@ -295,10 +297,10 @@ class AppWindow: def cooldown(self): if time() < self.holdofftime: - self.button['text'] = 'cool down %ds' % (self.holdofftime - time()) + self.button['text'] = _('cooldown {SS}s').format(SS = int(self.holdofftime - time())) # Update button in main window self.w.after(1000, self.cooldown) else: - self.button['text'] = 'Update' + self.button['text'] = _('Update') # Update button in main window self.button['state'] = tk.NORMAL def onexit(self, event=None): diff --git a/L10n/en.template b/L10n/en.template new file mode 100644 index 00000000..4561c53c --- /dev/null +++ b/L10n/en.template @@ -0,0 +1,150 @@ +/* Use same text as E:D Launcher's verification dialog. [prefs.py:208] */ +"A verification code has now been sent to the{CR}email address associated with your Elite account." = "A verification code has now been sent to the{CR}email address associated with your Elite account."; + +/* App menu entry on OSX. [EDMarketConnector.py:97] */ +"About {APP}" = "About {APP}"; + +/* Folder selection button on Windows. [prefs.py:101] */ +"Browse..." = "Browse..."; + +/* Folder selection button on OSX. [prefs.py:100] */ +"Change..." = "Change..."; + +/* [EDMarketConnector.py:98] */ +"Check for Updates..." = "Check for Updates..."; + +/* Privacy setting. [prefs.py:114] */ +"Cmdr name" = "Cmdr name"; + +/* Main window. [EDMarketConnector.py:71] */ +"Cmdr:" = "Cmdr:"; + +/* Update button in main window. [EDMarketConnector.py:300] */ +"cooldown {SS}s" = "cooldown {SS}s"; + +/* Section heading in settings. [prefs.py:58] */ +"Credentials" = "Credentials"; + +/* [EDMarketConnector.py:286] */ +"Error: Can't connect to EDDN" = "Error: Can't connect to EDDN"; + +/* [EDMarketConnector.py:290] */ +"Error: Connection to EDDN timed out" = "Error: Connection to EDDN timed out"; + +/* [companion.py:107] */ +"Error: Invalid Credentials" = "Error: Invalid Credentials"; + +/* [companion.py:103] */ +"Error: Server is down" = "Error: Server is down"; + +/* Menu item. [EDMarketConnector.py:114] */ +"Exit" = "Exit"; + +/* [EDMarketConnector.py:193] */ +"Fetching station data..." = "Fetching station data..."; + +/* Top-level menu on Windows. [EDMarketConnector.py:115] */ +"File" = "File"; + +/* Output folder prompt on Windows. [prefs.py:99] */ +"File location:" = "File location:"; + +/* [prefs.py:96] */ +"Flight log" = "Flight log"; + +/* Shouldn't happen. [companion.py:176] */ +"General error" = "General error"; + +/* [prefs.py:113] */ +"How do you want to be identified in the saved data" = "How do you want to be identified in the saved data"; + +/* [EDMarketConnector.py:248] */ +"Last updated at {HH}:{MM}:{SS}" = "Last updated at {HH}:{MM}:{SS}"; + +/* [EDMarketConnector.py:153] */ +"Logging in..." = "Logging in..."; + +/* [prefs.py:90] */ +"Market data in CSV format" = "Market data in CSV format"; + +/* [prefs.py:86] */ +"Market data in Slopey's BPC format" = "Market data in Slopey's BPC format"; + +/* [prefs.py:88] */ +"Market data in Trade Dangerous format" = "Market data in Trade Dangerous format"; + +/* [prefs.py:124] */ +"OK" = "OK"; + +/* Section heading in settings. [prefs.py:77] */ +"Output" = "Output"; + +/* Use same text as E:D Launcher's login dialog. [prefs.py:64] */ +"Password" = "Password"; + +/* [prefs.py:82] */ +"Please choose what data to save" = "Please choose what data to save"; + +/* Use same text as E:D Launcher's verification dialog. [prefs.py:211] */ +"Please enter the code into the box below." = "Please enter the code into the box below."; + +/* Use same text as E:D Launcher's login dialog. [prefs.py:62] */ +"Please log in with your Elite: Dangerous account details" = "Please log in with your Elite: Dangerous account details"; + +/* [prefs.py:37] */ +"Preferences" = "Preferences"; + +/* Section heading in settings. [prefs.py:108] */ +"Privacy" = "Privacy"; + +/* Privacy setting. [prefs.py:115] */ +"Pseudo-anonymized ID" = "Pseudo-anonymized ID"; + +/* [prefs.py:84] */ +"Send station data to the Elite Dangerous Data Network" = "Send station data to the Elite Dangerous Data Network"; + +/* [EDMarketConnector.py:266] */ +"Sending data to EDDN..." = "Sending data to EDDN..."; + +/* Menu item. [EDMarketConnector.py:112] */ +"Settings" = "Settings"; + +/* [prefs.py:94] */ +"Ship loadout in Coriolis format" = "Ship loadout in Coriolis format"; + +/* [prefs.py:92] */ +"Ship loadout in E:D Shipyard format" = "Ship loadout in E:D Shipyard format"; + +/* [EDMarketConnector.py:273] */ +"Station doesn't have a market!" = "Station doesn't have a market!"; + +/* [EDMarketConnector.py:271] */ +"Station doesn't have anything!" = "Station doesn't have anything!"; + +/* Main window. [EDMarketConnector.py:73] */ +"Station:" = "Station:"; + +/* Main window. [EDMarketConnector.py:72] */ +"System:" = "System:"; + +/* Update button in main window. [EDMarketConnector.py:78] */ +"Update" = "Update"; + +/* Use same text as E:D Launcher's login dialog. [prefs.py:63] */ +"Username (Email)" = "Username (Email)"; + +/* Shouldn't happen. [EDMarketConnector.py:215] */ +"What are you flying?!" = "What are you flying?!"; + +/* Shouldn't happen. [EDMarketConnector.py:213] */ +"Where are you?!" = "Where are you?!"; + +/* Output folder prompt on OSX. [prefs.py:98] */ +"Where:" = "Where:"; + +/* Shouldn't happen. [EDMarketConnector.py:211] */ +"Who are you?!" = "Who are you?!"; + +/* [EDMarketConnector.py:251] */ +"You're not docked at a station!" = "You're not docked at a station!"; + diff --git a/companion.py b/companion.py index f546a2db..59debd41 100644 --- a/companion.py +++ b/companion.py @@ -100,15 +100,14 @@ def listify(thing): class ServerError(Exception): def __str__(self): - return 'Error: Server is down' + return _('Error: Server is down') class CredentialsError(Exception): def __str__(self): - return 'Error: Invalid Credentials' + return _('Error: Invalid Credentials') class VerificationRequired(Exception): - def __str__(self): - return 'Authentication required' + pass # Server companion.orerve.net uses a session cookie ("CompanionApp") to tie together login, verification # and query. So route all requests through a single Session object which holds this state. @@ -174,7 +173,7 @@ class Session: def query(self): if self.state == Session.STATE_NONE: - raise Exception('General error') # Shouldn't happen + raise Exception(_('General error')) # Shouldn't happen elif self.state == Session.STATE_INIT: self.login() elif self.state == Session.STATE_AUTH: diff --git a/l10n.py b/l10n.py new file mode 100755 index 00000000..0c6ea08e --- /dev/null +++ b/l10n.py @@ -0,0 +1,152 @@ +#!/usr/bin/python +# +# Localization with gettext is a pain on non-Unix systems. Use OSX-style strings files instead. +# + +import codecs +import os +from os.path import dirname, isfile, join, normpath +import re +import sys +from sys import platform +import __builtin__ + + +class Translations: + + FALLBACK = 'en' # strings in this code are in English + + def __init__(self): + self.translations = {} + + def install(self): + path = join(self.respath(), 'L10n') + available = self.available() + available.add(Translations.FALLBACK) + + for preferred in self.preferred(): + if preferred in available: + lang = preferred + break + else: + for preferred in self.preferred(): + preferred = preferred.split('-',1)[0] # just base language + if preferred in available: + lang = preferred + break + else: + lang = Translations.FALLBACK + + if lang not in self.available(): + __builtin__.__dict__['_'] = lambda x: x + else: + regexp = re.compile(r'\s*"([^"]+)"\s*=\s*"([^"]+)"\s*;\s*$') + comment= re.compile(r'\s*/\*.*\*/\s*$') + with self.file(lang) as h: + for line in h: + if line.strip(): + match = regexp.match(line) + if match: + self.translations[match.group(1)] = match.group(2) + elif not comment.match(line): + assert match, 'Bad translation: %s' % line + __builtin__.__dict__['_'] = self.translate + + if __debug__: + def translate(self, x): + if x in self.translations: + return self.translations[x] + else: + print 'Missing translation: "%s"' % x + else: + def translate(self, x): + return self.translations.get(x, x) + + # Returns list of available language codes + def available(self): + path = self.respath() + if getattr(sys, 'frozen', False) and platform=='darwin': + available = set([x[:-len('.lproj')] for x in os.listdir(path) if x.endswith('.lproj') and isfile(join(x, 'Localizable.strings'))]) + else: + available = set([x[:-len('.strings')] for x in os.listdir(path) if x.endswith('.strings')]) + return available + + # Returns list of preferred language codes in lowercase RFC4646 format. + # Typically "lang[-script][-region]" where lang is a 2 alpha ISO 639-1 or 3 alpha ISO 639-2 code, + # script is a 4 alpha ISO 15924 code and region is a 2 alpha ISO 3166 code + def preferred(self): + + if platform=='darwin': + from Foundation import NSLocale + return [x.lower() for x in NSLocale.preferredLanguages()] or None + + elif platform=='win32': + + def wszarray_to_list(array): + offset = 0 + while offset < len(array): + sz = ctypes.wstring_at(ctypes.addressof(array) + offset*2) + if sz: + yield sz + offset += len(sz)+1 + else: + break + + # https://msdn.microsoft.com/en-us/library/windows/desktop/dd318124%28v=vs.85%29.aspx + import ctypes + MUI_LANGUAGE_ID = 4 + MUI_LANGUAGE_NAME = 8 + GetUserPreferredUILanguages = ctypes.windll.kernel32.GetUserPreferredUILanguages + + num = ctypes.c_ulong() + size = ctypes.c_ulong(0) + if (GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, ctypes.byref(num), None, ctypes.byref(size)) and size.value): + buf = ctypes.create_unicode_buffer(size.value) + if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, ctypes.byref(num), ctypes.byref(buf), ctypes.byref(size)): + return [x.lower() for x in wszarray_to_list(buf)] + return None + + else: # POSIX + import locale + lang = locale.getdefaultlocale()[0] + return lang and [lang.replace('_','-').lower()] + + def respath(self): + if getattr(sys, 'frozen', False): + if platform=='darwin': + return normpath(join(dirname(sys.executable), os.pardir, 'Resources')) + else: + return dirname(sys.executable) + elif __file__: + return join(dirname(__file__), 'L10n') + else: + return 'L10n' + + def file(self, lang): + if getattr(sys, 'frozen', False) and platform=='darwin': + return codecs.open(join(self.respath(), '%s.lproj' % lang, 'Localizable.strings'), 'r', 'utf-16') + else: + return codecs.open(join(self.respath(), '%s.strings' % lang), 'r', 'utf-8') + + +# generate template strings file - like xgettext +# parsing is limited - only single ' or " delimited strings, and only one string per line +if __name__ == "__main__": + import re + regexp = re.compile(r'''_\([ur]?(['"])(((?