1# Copyright 2004-2021 Tom Rothamel <pytom@bishoujo.us>
2#
3# Permission is hereby granted, free of charge, to any person
4# obtaining a copy of this software and associated documentation files
5# (the "Software"), to deal in the Software without restriction,
6# including without limitation the rights to use, copy, modify, merge,
7# publish, distribute, sublicense, and/or sell copies of the Software,
8# and to permit persons to whom the Software is furnished to do so,
9# subject to the following conditions:
10#
11# The above copyright notice and this permission notice shall be
12# included in all copies or substantial portions of the Software.
13#
14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22from __future__ import division, absolute_import, with_statement, print_function, unicode_literals
23from renpy.compat import *
24
25import renpy.translation
26
27import re
28import os
29import time
30import collections
31import shutil
32
33from renpy.translation import quote_unicode
34from renpy.parser import elide_filename
35
36################################################################################
37# Translation Generation
38################################################################################
39
40
41def scan_comments(filename):
42
43    rv = [ ]
44
45    if filename not in renpy.config.translate_comments:
46        return rv
47
48    comment = [ ]
49    start = 0
50
51    with open(filename, "r", encoding="utf-8") as f:
52        lines = [ i.rstrip() for i in f.read().replace(u"\ufeff", "").split('\n') ]
53
54    for i, l in enumerate(lines):
55
56        if not comment:
57            start = i + 1
58
59        m = re.match(r'\s*## (.*)', l)
60
61        if m:
62            c = m.group(1)
63
64            if comment:
65                c = c.strip()
66
67            comment.append(c)
68
69        elif comment:
70            s = "## " + " ".join(comment)
71
72            if s.endswith("#"):
73                s = s.rstrip("# ")
74
75            comment = [ ]
76
77            rv.append((start, s))
78
79    return rv
80
81
82tl_file_cache = { }
83
84# Should we write the TODO marker?
85todo = True
86
87
88def open_tl_file(fn):
89
90    if fn in tl_file_cache:
91        return tl_file_cache[fn]
92
93    if not os.path.exists(fn):
94        dn = os.path.dirname(fn)
95
96        try:
97            os.makedirs(dn)
98        except:
99            pass
100
101        f = open(fn, "a", encoding="utf-8")
102        f.write(u"\ufeff")
103
104    else:
105        f = open(fn, "a", encoding="utf-8")
106
107    if todo:
108        f.write(u"# TO" + "DO: Translation updated at {}\n".format(time.strftime("%Y-%m-%d %H:%M")))
109
110    f.write(u"\n")
111
112    tl_file_cache[fn] = f
113
114    return f
115
116
117def close_tl_files():
118
119    for i in tl_file_cache.values():
120        i.close()
121
122    tl_file_cache.clear()
123
124
125def shorten_filename(filename):
126    """
127    Shortens a file name. Returns the shortened filename, and a flag that says
128    if the filename is in the common directory.
129    """
130
131    commondir = os.path.normpath(renpy.config.commondir)
132    gamedir = os.path.normpath(renpy.config.gamedir)
133
134    if filename.startswith(commondir):
135        fn = os.path.relpath(filename, commondir)
136        common = True
137
138    elif filename.startswith(gamedir):
139        fn = os.path.relpath(filename, gamedir)
140        common = False
141
142    else:
143        fn = os.path.basename(filename)
144        common = False
145
146    return fn, common
147
148
149def write_translates(filename, language, filter): # @ReservedAssignment
150
151    fn, common = shorten_filename(filename)
152
153    # The common directory should not have dialogue in it.
154    if common:
155        return
156
157    tl_filename = os.path.join(renpy.config.gamedir, renpy.config.tl_directory, language, fn)
158
159    if tl_filename[-1] == "m":
160        tl_filename = tl_filename[:-1]
161
162    if language == "None":
163        language = None
164
165    translator = renpy.game.script.translator
166
167    for label, t in translator.file_translates[filename]:
168
169        if (t.identifier, language) in translator.language_translates:
170            continue
171
172        if hasattr(t, "alternate"):
173            if (t.alternate, language) in translator.language_translates:
174                continue
175
176        f = open_tl_file(tl_filename)
177
178        if label is None:
179            label = ""
180
181        f.write(u"# {}:{}\n".format(t.filename, t.linenumber))
182        f.write(u"translate {} {}:\n".format(language, t.identifier.replace('.', '_')))
183        f.write(u"\n")
184
185        for n in t.block:
186            f.write(u"    # " + n.get_code() + "\n")
187
188        for n in t.block:
189            f.write(u"    " + n.get_code(filter) + "\n")
190
191        f.write(u"\n")
192
193
194def translation_filename(s):
195
196    if renpy.config.translate_launcher:
197        return s.launcher_file
198
199    if s.common:
200        return "common.rpy"
201
202    filename = s.elided
203
204    if filename[-1] == "m":
205        filename = filename[:-1]
206
207    return filename
208
209
210def write_strings(language, filter, min_priority, max_priority, common_only): # @ReservedAssignment
211    """
212    Writes strings to the file.
213    """
214
215    if language == "None":
216        stl = renpy.game.script.translator.strings[None] # @UndefinedVariable
217    else:
218        stl = renpy.game.script.translator.strings[language] # @UndefinedVariable
219
220    # If this function changes, count_missing may also need to
221    # change.
222
223    strings = renpy.translation.scanstrings.scan(min_priority, max_priority, common_only)
224
225    stringfiles = collections.defaultdict(list)
226
227    for s in strings:
228
229        tlfn = translation_filename(s)
230
231        if tlfn is None:
232            continue
233
234        # Already seen.
235        if s.text in stl.translations:
236            continue
237
238        if language == "None" and tlfn == "common.rpy":
239            tlfn = "common.rpym"
240
241        stringfiles[tlfn].append(s)
242
243    for tlfn, sl in stringfiles.items():
244
245        # sl.sort(key=lambda s : (s.filename, s.line))
246
247        tlfn = os.path.join(renpy.config.gamedir, renpy.config.tl_directory, language, tlfn)
248        f = open_tl_file(tlfn)
249
250        f.write(u"translate {} strings:\n".format(language))
251        f.write(u"\n")
252
253        for s in sl:
254            text = filter(s.text)
255
256            f.write(u"    # {}:{}\n".format(elide_filename(s.filename), s.line))
257            f.write(u"    old \"{}\"\n".format(quote_unicode(s.text)))
258            f.write(u"    new \"{}\"\n".format(quote_unicode(text)))
259            f.write(u"\n")
260
261
262def null_filter(s):
263    return s
264
265
266def empty_filter(s):
267    return ""
268
269
270def generic_filter(s, function):
271    """
272    :doc: text_utility
273
274    Transforms `s`, while leaving text tags and interpolation the same.
275
276    `function`
277        A function that is called with strings corresponding to runs of
278        text, and should return a second string that replaces that run
279        of text.
280
281    ::
282
283        init python:
284            def upper(s):
285                return s.upper()
286
287        $ upper_string = renpy.transform_text("{b}Not Upper{/b}", upper)
288
289    """
290
291    def remove_special(s, start, end, process):
292        specials = 0
293        first = False
294
295        rv = ""
296        buf = ""
297
298        for i in s:
299
300            if i == start:
301                if first:
302                    specials = 0
303                else:
304                    rv += process(buf)
305                    buf = ""
306
307                    if specials == 0:
308                        first = True
309
310                    specials += 1
311
312                rv += start
313
314            elif i == end:
315
316                first = False
317
318                specials -= 1
319                if specials < 0:
320                    specials += 1
321
322                rv += end
323
324            else:
325                if specials:
326                    rv += i
327                else:
328                    buf += i
329
330        if buf:
331            rv += process(buf)
332
333        return rv
334
335    def remove_braces(s):
336        return remove_special(s, "{", "}", function)
337
338    return remove_special(s, "[", "]", remove_braces)
339
340
341def rot13_transform(s):
342
343    ROT13 = { }
344
345    for i, j in zip("ABCDEFGHIJKLM", "NOPQRSTUVWXYZ"):
346        ROT13[i] = j
347        ROT13[j] = i
348
349        i = i.lower()
350        j = j.lower()
351
352        ROT13[i] = j
353        ROT13[j] = i
354
355    return "".join(ROT13.get(i, i) for i in s)
356
357
358def rot13_filter(s):
359    return generic_filter(s, rot13_transform)
360
361
362def piglatin_transform(s):
363    # Based on http://stackoverflow.com/a/23177629/3549890
364
365    lst = ['sh', 'gl', 'ch', 'ph', 'tr', 'br', 'fr', 'bl', 'gr', 'st', 'sl', 'cl', 'pl', 'fl']
366
367    def replace(m):
368        i = m.group(0)
369
370        if i[0] in ['a', 'e', 'i', 'o', 'u']:
371            rv = i + 'ay'
372        elif i[:2] in lst:
373            rv = i[2:] + i[:2] + 'ay'
374        else:
375            rv = i[1:] + i[0] + 'ay'
376
377        if i[0].isupper():
378            rv = rv.capitalize()
379
380        return rv
381
382    return re.sub(r'\w+', replace, s)
383
384
385def piglatin_filter(s):
386    return generic_filter(s, piglatin_transform)
387
388
389def translate_list_files():
390    """
391    Returns a list of files that exist and should be scanned for translations.
392    """
393
394    filenames = list(renpy.config.translate_files)
395
396    for dirname, filename in renpy.loader.listdirfiles():
397        if dirname is None:
398            continue
399
400        if filename.startswith("tl/"):
401            continue
402
403        filename = os.path.join(dirname, filename)
404
405        if not (filename.endswith(".rpy") or filename.endswith(".rpym")):
406            continue
407
408        filename = os.path.normpath(filename)
409
410        if not os.path.exists(filename):
411            continue
412
413        filenames.append(filename)
414
415    return filenames
416
417
418def count_missing(language, min_priority, max_priority, common_only):
419    """
420    Prints a count of missing translations for `language`.
421    """
422
423    translator = renpy.game.script.translator
424
425    missing_translates = 0
426
427    for filename in translate_list_files():
428        for _, t in translator.file_translates[filename]:
429            if (t.identifier, language) not in translator.language_translates:
430                missing_translates += 1
431
432    missing_strings = 0
433
434    stl = renpy.game.script.translator.strings[language] # @UndefinedVariable
435
436    strings = renpy.translation.scanstrings.scan(min_priority, max_priority, common_only)
437
438    for s in strings:
439
440        tlfn = translation_filename(s)
441
442        if tlfn is None:
443            continue
444
445        if s.text in stl.translations:
446            continue
447
448        missing_strings += 1
449
450    print("{}: {} missing dialogue translations, {} missing string translations.".format(
451        language,
452        missing_translates,
453        missing_strings
454        ))
455
456
457def translate_command():
458    """
459    The translate command. When called from the command line, this generates
460    the translations.
461    """
462
463    ap = renpy.arguments.ArgumentParser(description="Generates or updates translations.")
464    ap.add_argument("language", help="The language to generate translations for.")
465    ap.add_argument("--rot13", help="Apply rot13 while generating translations.", dest="rot13", action="store_true")
466    ap.add_argument("--piglatin", help="Apply pig latin while generating translations.", dest="piglatin", action="store_true")
467    ap.add_argument("--empty", help="Produce empty strings while generating translations.", dest="empty", action="store_true")
468    ap.add_argument("--count", help="Instead of generating files, print a count of missing translations.", dest="count", action="store_true")
469    ap.add_argument("--min-priority", help="Translate strings with more than this priority.", dest="min_priority", default=0, type=int)
470    ap.add_argument("--max-priority", help="Translate strings with more than this priority.", dest="max_priority", default=0, type=int)
471    ap.add_argument("--strings-only", help="Only translate strings (not dialogue).", dest="strings_only", default=False, action="store_true")
472    ap.add_argument("--common-only", help="Only translate string from the common code.", dest="common_only", default=False, action="store_true")
473    ap.add_argument("--no-todo", help="Do not include the TODO flag.", dest="todo", default=True, action="store_false")
474
475    args = ap.parse_args()
476
477    global todo
478    todo = args.todo
479
480    if renpy.config.translate_launcher:
481        max_priority = args.max_priority or 499
482    else:
483        max_priority = args.max_priority or 299
484
485    if args.count:
486        count_missing(args.language, args.min_priority, max_priority, args.common_only)
487        return False
488
489    if args.rot13:
490        filter = rot13_filter # @ReservedAssignment
491    elif args.piglatin:
492        filter = piglatin_filter # @ReservedAssignment
493    elif args.empty:
494        filter = empty_filter # @ReservedAssignment
495    else:
496        filter = null_filter # @ReservedAssignment
497
498    if not args.strings_only:
499        for filename in translate_list_files():
500            write_translates(filename, args.language, filter)
501
502    write_strings(args.language, filter, args.min_priority, max_priority, args.common_only)
503
504    close_tl_files()
505
506    if renpy.config.translate_launcher and (not args.strings_only):
507        src = os.path.join(renpy.config.renpy_base, "gui", "game", "script.rpy")
508        dst = os.path.join(renpy.config.gamedir, "tl", args.language, "script.rpym")
509
510        if os.path.exists(src) and not os.path.exists(dst):
511            shutil.copy(src, dst)
512
513    return False
514
515
516renpy.arguments.register_command("translate", translate_command)
517