mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-06-23 15:30:45 -04:00
387 lines
14 KiB
Python
387 lines
14 KiB
Python
#!/usr/bin/env python
|
|
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
|
|
from __future__ import with_statement
|
|
|
|
__license__ = 'GPL v3'
|
|
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
|
|
__docformat__ = 'restructuredtext en'
|
|
|
|
import os, tempfile, shutil, subprocess, glob, re, time, textwrap
|
|
from functools import partial
|
|
|
|
from setup import Command, __appname__, __version__, require_git_master
|
|
|
|
def qt_sources():
|
|
qtdir = glob.glob('/usr/src/qt-*')[-1]
|
|
j = partial(os.path.join, qtdir)
|
|
return list(map(j, [
|
|
'src/gui/widgets/qdialogbuttonbox.cpp',
|
|
]))
|
|
|
|
class POT(Command): # {{{
|
|
|
|
description = 'Update the .pot translation template and upload it'
|
|
LP_BASE = os.path.join(os.path.dirname(Command.SRC))
|
|
if not os.path.exists(os.path.join(LP_BASE, 'setup', 'iso_639')):
|
|
# We are in a git checkout, translations are assumed to be in a
|
|
# directory called calibre-translations at the same level as the
|
|
# calibre directory.
|
|
LP_BASE = os.path.join(os.path.dirname(os.path.dirname(Command.SRC)), 'calibre-translations')
|
|
LP_SRC = os.path.join(LP_BASE, 'src')
|
|
LP_PATH = os.path.join(LP_SRC, os.path.join(__appname__, 'translations'))
|
|
LP_ISO_PATH = os.path.join(LP_BASE, 'setup', 'iso_639')
|
|
|
|
def upload_pot(self, pot):
|
|
msg = 'Updated translations template'
|
|
subprocess.check_call(['bzr', 'commit', '-m', msg, pot])
|
|
|
|
def source_files(self):
|
|
ans = []
|
|
for root, _, files in os.walk(self.j(self.SRC, __appname__)):
|
|
for name in files:
|
|
if name.endswith('.py'):
|
|
ans.append(self.a(self.j(root, name)))
|
|
return ans
|
|
|
|
def get_tweaks_docs(self):
|
|
path = self.a(self.j(self.SRC, '..', 'resources', 'default_tweaks.py'))
|
|
with open(path, 'rb') as f:
|
|
raw = f.read().decode('utf-8')
|
|
msgs = []
|
|
lines = list(raw.splitlines())
|
|
for i, line in enumerate(lines):
|
|
if line.startswith('#:'):
|
|
msgs.append((i, line[2:].strip()))
|
|
j = i
|
|
block = []
|
|
while True:
|
|
j += 1
|
|
line = lines[j]
|
|
if not line.startswith('#'):
|
|
break
|
|
block.append(line[1:].strip())
|
|
if block:
|
|
msgs.append((i+1, '\n'.join(block)))
|
|
|
|
ans = []
|
|
for lineno, msg in msgs:
|
|
ans.append('#: %s:%d'%(path, lineno))
|
|
slash = unichr(92)
|
|
msg = msg.replace(slash, slash*2).replace('"', r'\"').replace('\n',
|
|
r'\n').replace('\r', r'\r').replace('\t', r'\t')
|
|
ans.append('msgid "%s"'%msg)
|
|
ans.append('msgstr ""')
|
|
ans.append('')
|
|
|
|
return '\n'.join(ans)
|
|
|
|
def run(self, opts):
|
|
require_git_master()
|
|
pot_header = textwrap.dedent('''\
|
|
# Translation template file..
|
|
# Copyright (C) %(year)s Kovid Goyal
|
|
# Kovid Goyal <kovid@kovidgoyal.net>, %(year)s.
|
|
#
|
|
msgid ""
|
|
msgstr ""
|
|
"Project-Id-Version: %(appname)s %(version)s\\n"
|
|
"POT-Creation-Date: %(time)s\\n"
|
|
"PO-Revision-Date: %(time)s\\n"
|
|
"Last-Translator: Automatically generated\\n"
|
|
"Language-Team: LANGUAGE\\n"
|
|
"MIME-Version: 1.0\\n"
|
|
"Report-Msgid-Bugs-To: https://bugs.launchpad.net/calibre\\n"
|
|
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n"
|
|
"Content-Type: text/plain; charset=UTF-8\\n"
|
|
"Content-Transfer-Encoding: 8bit\\n"
|
|
|
|
''')%dict(appname=__appname__, version=__version__,
|
|
year=time.strftime('%Y'),
|
|
time=time.strftime('%Y-%m-%d %H:%M+%Z'))
|
|
|
|
files = self.source_files()
|
|
qt_inputs = qt_sources()
|
|
|
|
with tempfile.NamedTemporaryFile() as fl:
|
|
fl.write('\n'.join(files))
|
|
fl.flush()
|
|
out = tempfile.NamedTemporaryFile(suffix='.pot', delete=False)
|
|
out.close()
|
|
self.info('Creating translations template...')
|
|
subprocess.check_call(['xgettext', '-f', fl.name,
|
|
'--default-domain=calibre', '-o', out.name, '-L', 'Python',
|
|
'--from-code=UTF-8', '--sort-by-file', '--omit-header',
|
|
'--no-wrap', '-k__', '--add-comments=NOTE:',
|
|
])
|
|
subprocess.check_call(['xgettext', '-j',
|
|
'--default-domain=calibre', '-o', out.name,
|
|
'--from-code=UTF-8', '--sort-by-file', '--omit-header',
|
|
'--no-wrap', '-kQT_TRANSLATE_NOOP:2',
|
|
] + qt_inputs)
|
|
|
|
with open(out.name, 'rb') as f:
|
|
src = f.read()
|
|
os.remove(out.name)
|
|
src = pot_header + '\n' + src
|
|
src += '\n\n' + self.get_tweaks_docs()
|
|
pot = os.path.join(self.LP_PATH, __appname__+'.pot')
|
|
with open(pot, 'wb') as f:
|
|
f.write(src)
|
|
self.info('Translations template:', os.path.abspath(pot))
|
|
self.upload_pot(os.path.abspath(pot))
|
|
|
|
return pot
|
|
# }}}
|
|
|
|
class Translations(POT): # {{{
|
|
description='''Compile the translations'''
|
|
DEST = os.path.join(os.path.dirname(POT.SRC), 'resources', 'localization',
|
|
'locales')
|
|
|
|
def po_files(self):
|
|
return glob.glob(os.path.join(self.LP_PATH, '*.po'))
|
|
|
|
def mo_file(self, po_file):
|
|
locale = os.path.splitext(os.path.basename(po_file))[0]
|
|
return locale, os.path.join(self.DEST, locale, 'messages.mo')
|
|
|
|
def run(self, opts):
|
|
self.iso639_errors = []
|
|
for f in self.po_files():
|
|
locale, dest = self.mo_file(f)
|
|
base = os.path.dirname(dest)
|
|
if not os.path.exists(base):
|
|
os.makedirs(base)
|
|
self.info('\tCompiling translations for', locale)
|
|
subprocess.check_call(['msgfmt', '-o', dest, f])
|
|
iscpo = {'bn':'bn_IN', 'zh_HK':'zh_CN'}.get(locale, locale)
|
|
iso639 = self.j(self.LP_ISO_PATH, '%s.po'%iscpo)
|
|
|
|
if os.path.exists(iso639):
|
|
self.check_iso639(iso639)
|
|
dest = self.j(self.d(dest), 'iso639.mo')
|
|
if self.newer(dest, iso639):
|
|
self.info('\tCopying ISO 639 translations for %s' % iscpo)
|
|
subprocess.check_call(['msgfmt', '-o', dest, iso639])
|
|
elif locale not in {
|
|
'en_GB', 'en_CA', 'en_AU', 'si', 'ur', 'sc', 'ltg', 'nds',
|
|
'te', 'yi', 'fo', 'sq', 'ast', 'ml', 'ku', 'fr_CA', 'him',
|
|
'jv', 'ka', 'fur', 'ber', 'my', 'fil'}:
|
|
self.warn('No ISO 639 translations for locale:', locale)
|
|
|
|
if self.iso639_errors:
|
|
for err in self.iso639_errors:
|
|
print (err)
|
|
raise SystemExit(1)
|
|
|
|
self.write_stats()
|
|
self.freeze_locales()
|
|
|
|
def check_iso639(self, path):
|
|
from calibre.utils.localization import langnames_to_langcodes
|
|
with open(path, 'rb') as f:
|
|
raw = f.read()
|
|
rmap = {}
|
|
msgid = None
|
|
for match in re.finditer(r'^(msgid|msgstr)\s+"(.*?)"', raw, re.M):
|
|
if match.group(1) == 'msgid':
|
|
msgid = match.group(2)
|
|
else:
|
|
msgstr = match.group(2)
|
|
if not msgstr:
|
|
continue
|
|
omsgid = rmap.get(msgstr, None)
|
|
if omsgid is not None:
|
|
cm = langnames_to_langcodes([omsgid, msgid])
|
|
if cm[msgid] and cm[omsgid] and cm[msgid] != cm[omsgid]:
|
|
self.iso639_errors.append('In file %s the name %s is used as translation for both %s and %s' % (
|
|
os.path.basename(path), msgstr, msgid, rmap[msgstr]))
|
|
# raise SystemExit(1)
|
|
rmap[msgstr] = msgid
|
|
|
|
def freeze_locales(self):
|
|
zf = self.DEST + '.zip'
|
|
from calibre import CurrentDir
|
|
from calibre.utils.zipfile import ZipFile, ZIP_DEFLATED
|
|
with ZipFile(zf, 'w', ZIP_DEFLATED) as zf:
|
|
with CurrentDir(self.DEST):
|
|
zf.add_dir('.')
|
|
shutil.rmtree(self.DEST)
|
|
|
|
@property
|
|
def stats(self):
|
|
return self.j(self.d(self.DEST), 'stats.pickle')
|
|
|
|
def get_stats(self, path):
|
|
return subprocess.Popen(['msgfmt', '--statistics', '-o', '/dev/null',
|
|
path],
|
|
stderr=subprocess.PIPE).stderr.read()
|
|
|
|
def write_stats(self):
|
|
files = self.po_files()
|
|
dest = self.stats
|
|
if not self.newer(dest, files):
|
|
return
|
|
self.info('Calculating translation statistics...')
|
|
raw = self.get_stats(self.j(self.LP_PATH, 'calibre.pot'))
|
|
total = int(raw.split(',')[-1].strip().split()[0])
|
|
stats = {}
|
|
for f in files:
|
|
raw = self.get_stats(f)
|
|
trans = int(raw.split()[0])
|
|
locale = self.mo_file(f)[0]
|
|
stats[locale] = min(1.0, float(trans)/total)
|
|
|
|
import cPickle
|
|
cPickle.dump(stats, open(dest, 'wb'), -1)
|
|
|
|
def clean(self):
|
|
if os.path.exists(self.stats):
|
|
os.remove(self.stats)
|
|
zf = self.DEST + '.zip'
|
|
if os.path.exists(zf):
|
|
os.remove(zf)
|
|
|
|
# }}}
|
|
|
|
class GetTranslations(Translations): # {{{
|
|
|
|
description = 'Get updated translations from Launchpad'
|
|
BRANCH = 'lp:~kovid/calibre/translations'
|
|
LP_BASE = os.path.dirname(POT.LP_SRC)
|
|
CMSG = 'Updated translations'
|
|
|
|
@property
|
|
def modified_translations(self):
|
|
raw = subprocess.check_output(['bzr', 'status', '-S', self.LP_PATH]).strip()
|
|
ans = []
|
|
for line in raw.splitlines():
|
|
line = line.strip()
|
|
if line.startswith('M') and line.endswith('.po'):
|
|
ans.append(line.split()[-1])
|
|
return ans
|
|
|
|
def resolve_conflicts(self):
|
|
conflict = False
|
|
for line in subprocess.check_output(['bzr', 'status'], cwd=self.LP_BASE).splitlines():
|
|
if line == 'conflicts:':
|
|
conflict = True
|
|
break
|
|
if not conflict:
|
|
raise Exception('bzr merge failed and no conflicts found')
|
|
subprocess.check_call(['bzr', 'resolve', '--take-other'], cwd=self.LP_BASE)
|
|
|
|
def run(self, opts):
|
|
require_git_master()
|
|
if not self.modified_translations:
|
|
try:
|
|
subprocess.check_call(['bzr', 'merge', self.BRANCH], cwd=self.LP_BASE)
|
|
except subprocess.CalledProcessError:
|
|
self.resolve_conflicts()
|
|
self.check_for_errors()
|
|
|
|
if self.modified_translations:
|
|
subprocess.check_call(['bzr', 'commit', '-m',
|
|
self.CMSG], cwd=self.LP_BASE)
|
|
else:
|
|
print('No updated translations available')
|
|
|
|
def check_for_errors(self):
|
|
errors = os.path.join(tempfile.gettempdir(), 'calibre-translation-errors')
|
|
if os.path.exists(errors):
|
|
shutil.rmtree(errors)
|
|
os.mkdir(errors)
|
|
pofilter = ('pofilter', '-i', self.LP_PATH, '-o', errors,
|
|
'-t', 'accelerators', '-t', 'escapes', '-t', 'variables',
|
|
#'-t', 'xmltags',
|
|
#'-t', 'brackets',
|
|
#'-t', 'emails',
|
|
#'-t', 'doublequoting',
|
|
#'-t', 'filepaths',
|
|
#'-t', 'numbers',
|
|
'-t', 'options',
|
|
#'-t', 'urls',
|
|
'-t', 'printf')
|
|
subprocess.check_call(pofilter)
|
|
errfiles = glob.glob(errors+os.sep+'*.po')
|
|
if errfiles:
|
|
subprocess.check_call(['gvim', '-f', '-p', '--']+errfiles)
|
|
for f in errfiles:
|
|
with open(f, 'r+b') as f:
|
|
raw = f.read()
|
|
raw = re.sub(r'# \(pofilter\).*', '', raw)
|
|
f.seek(0)
|
|
f.truncate()
|
|
f.write(raw)
|
|
|
|
subprocess.check_call(['pomerge', '-t', self.LP_PATH, '-i', errors, '-o',
|
|
self.LP_PATH])
|
|
return True
|
|
return False
|
|
|
|
# }}}
|
|
|
|
class ISO639(Command): # {{{
|
|
|
|
description = 'Compile language code maps for performance'
|
|
DEST = os.path.join(os.path.dirname(POT.SRC), 'resources', 'localization',
|
|
'iso639.pickle')
|
|
|
|
def run(self, opts):
|
|
src = self.j(self.d(self.SRC), 'setup', 'iso_639_3.xml')
|
|
if not os.path.exists(src):
|
|
raise Exception(src + ' does not exist')
|
|
dest = self.DEST
|
|
base = self.d(dest)
|
|
if not os.path.exists(base):
|
|
os.makedirs(base)
|
|
if not self.newer(dest, [src, __file__]):
|
|
self.info('Pickled code is up to date')
|
|
return
|
|
self.info('Pickling ISO-639 codes to', dest)
|
|
from lxml import etree
|
|
root = etree.fromstring(open(src, 'rb').read())
|
|
by_2 = {}
|
|
by_3b = {}
|
|
by_3t = {}
|
|
m2to3 = {}
|
|
m3to2 = {}
|
|
m3bto3t = {}
|
|
nm = {}
|
|
codes2, codes3t, codes3b = set(), set(), set()
|
|
for x in root.xpath('//iso_639_3_entry'):
|
|
two = x.get('part1_code', None)
|
|
threet = x.get('id')
|
|
threeb = x.get('part2_code', None)
|
|
if threeb is None:
|
|
# Only recognize languages in ISO-639-2
|
|
continue
|
|
name = x.get('name')
|
|
|
|
if two is not None:
|
|
by_2[two] = name
|
|
codes2.add(two)
|
|
m2to3[two] = threet
|
|
m3to2[threeb] = m3to2[threet] = two
|
|
by_3b[threeb] = name
|
|
by_3t[threet] = name
|
|
if threeb != threet:
|
|
m3bto3t[threeb] = threet
|
|
codes3b.add(threeb)
|
|
codes3t.add(threet)
|
|
base_name = name.lower()
|
|
nm[base_name] = threet
|
|
|
|
from cPickle import dump
|
|
x = {'by_2':by_2, 'by_3b':by_3b, 'by_3t':by_3t, 'codes2':codes2,
|
|
'codes3b':codes3b, 'codes3t':codes3t, '2to3':m2to3,
|
|
'3to2':m3to2, '3bto3t':m3bto3t, 'name_map':nm}
|
|
dump(x, open(dest, 'wb'), -1)
|
|
|
|
def clean(self):
|
|
if os.path.exists(self.DEST):
|
|
os.remove(self.DEST)
|
|
|
|
# }}}
|
|
|