-
Notifications
You must be signed in to change notification settings - Fork 3.2k
/
translate.py
311 lines (228 loc) · 8.72 KB
/
translate.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
import csv
import gettext
import multiprocessing
import os
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from babel.messages.catalog import Catalog
from babel.messages.extract import DEFAULT_KEYWORDS, extract_from_dir
from babel.messages.mofile import read_mo, write_mo
from babel.messages.pofile import read_po, write_po
import frappe
from frappe.utils import get_bench_path
PO_DIR = "locale" # po and pot files go into [app]/locale
POT_FILE = "main.pot" # the app's pot file is always main.pot
def new_catalog(app: str, locale: str | None = None) -> Catalog:
def get_hook(hook, app):
return frappe.get_hooks(hook, [None], app)[0]
app_email = get_hook("app_email", app)
return Catalog(
locale=locale,
domain="messages",
msgid_bugs_address=app_email,
language_team=app_email,
copyright_holder=get_hook("app_publisher", app),
last_translator=app_email,
project=get_hook("app_title", app),
creation_date=datetime.now(),
revision_date=datetime.now(),
fuzzy=False,
)
def get_po_dir(app: str) -> Path:
return Path(frappe.get_app_path(app)) / PO_DIR
def get_locale_dir() -> Path:
return Path(get_bench_path()) / "sites" / "assets" / "locale"
def get_locales(app: str) -> list[str]:
po_dir = get_po_dir(app)
if not po_dir.exists():
return []
return [locale.stem for locale in po_dir.iterdir() if locale.suffix == ".po"]
def get_po_path(app: str, locale: str | None = None) -> Path:
return get_po_dir(app) / f"{locale}.po"
def get_mo_path(app: str, locale: str | None = None) -> Path:
return get_locale_dir() / locale / "LC_MESSAGES" / f"{app}.mo"
def get_pot_path(app: str) -> Path:
return get_po_dir(app) / POT_FILE
def get_catalog(app: str, locale: str | None = None) -> Catalog:
"""Returns a catatalog for the given app and locale"""
po_path = get_po_path(app, locale) if locale else get_pot_path(app)
if not po_path.exists():
return new_catalog(app, locale)
with open(po_path, "rb") as f:
return read_po(f)
def write_catalog(app: str, catalog: Catalog, locale: str | None = None) -> Path:
"""Writes a catalog to the given app and locale"""
po_path = get_po_path(app, locale) if locale else get_pot_path(app)
if not po_path.parent.exists():
po_path.parent.mkdir(parents=True)
with open(po_path, "wb") as f:
write_po(f, catalog, sort_output=True, ignore_obsolete=True, width=None)
return po_path
def write_binary(app: str, catalog: Catalog, locale: str) -> Path:
mo_path = get_mo_path(app, locale)
if not mo_path.parent.exists():
mo_path.parent.mkdir(parents=True)
with open(mo_path, "wb") as mo_file:
write_mo(mo_file, catalog)
return mo_path
def get_method_map(app: str):
file_path = Path(frappe.get_app_path(app)).parent / "babel_extractors.csv"
if file_path.exists():
with open(file_path) as f:
reader = csv.reader(f)
return [(row[0], row[1]) for row in reader]
return []
def generate_pot(target_app: str | None = None):
"""
Generate a POT (PO template) file. This file will contain only messages IDs.
https://en.wikipedia.org/wiki/Gettext
:param target_app: If specified, limit to `app`
"""
def directory_filter(dirpath: str | os.PathLike[str]) -> bool:
if "public/dist" in dirpath:
return False
subdir = os.path.basename(dirpath)
return not (subdir.startswith(".") or subdir.startswith("_"))
apps = [target_app] if target_app else frappe.get_all_apps(True)
default_method_map = get_method_map("frappe")
keywords = DEFAULT_KEYWORDS.copy()
keywords["_lt"] = None
for app in apps:
app_path = frappe.get_pymodule_path(app)
catalog = new_catalog(app)
# Each file will only be processed by the first method that matches,
# so more specific methods should come first.
method_map = [] if app == "frappe" else get_method_map(app)
method_map.extend(default_method_map)
for filename, lineno, message, comments, context in extract_from_dir(
app_path, method_map, directory_filter=directory_filter, keywords=keywords
):
if not message:
continue
catalog.add(message, locations=[(filename, lineno)], auto_comments=comments, context=context)
pot_path = write_catalog(app, catalog)
print(f"POT file created at {pot_path}")
def new_po(locale, target_app: str | None = None):
apps = [target_app] if target_app else frappe.get_all_apps(True)
for app in apps:
po_path = get_po_path(app, locale)
if os.path.exists(po_path):
print(f"{po_path} exists. Skipping")
continue
pot_catalog = get_catalog(app)
pot_catalog.locale = locale
po_path = write_catalog(app, pot_catalog, locale)
print(f"PO file created_at {po_path}")
print(
"You will need to add the language in frappe/geo/languages.csv, if you haven't done it already."
)
def compile_translations(target_app: str | None = None, locale: str | None = None, force=False):
apps = [target_app] if target_app else frappe.get_all_apps(True)
tasks = []
for app in apps:
locales = [locale] if locale else get_locales(app)
for current_locale in locales:
tasks.append((app, current_locale, force))
# Execute all tasks, doing this sequentially is quite slow hence use processpool of 4
# processes.
executer = multiprocessing.Pool(processes=4)
executer.starmap(_compile_translation, tasks)
executer.close()
executer.join()
def _compile_translation(app, locale, force=False):
po_path = get_po_path(app, locale)
mo_path = get_mo_path(app, locale)
if not po_path.exists():
return
if mo_path.exists() and po_path.stat().st_mtime < mo_path.stat().st_mtime and not force:
print(f"MO file already up to date at {mo_path}")
return
with open(po_path, "rb") as f:
catalog = read_po(f)
mo_path = write_binary(app, catalog, locale)
print(f"MO file created at {mo_path}")
def update_po(target_app: str | None = None, locale: str | None = None):
"""
Add keys to available PO files, from POT file. This could be used to keep
track of available keys, and missing translations
:param target_app: Limit operation to `app`, if specified
"""
apps = [target_app] if target_app else frappe.get_all_apps(True)
for app in apps:
locales = [locale] if locale else get_locales(app)
pot_catalog = get_catalog(app)
for locale in locales:
po_catalog = get_catalog(app, locale)
po_catalog.update(pot_catalog)
po_path = write_catalog(app, po_catalog, locale)
print(f"PO file modified at {po_path}")
def migrate(app: str | None = None, locale: str | None = None):
apps = [app] if app else frappe.get_all_apps(True)
for app in apps:
if locale:
csv_to_po(app, locale)
else:
app_path = Path(frappe.get_app_path(app))
for filename in (app_path / "translations").iterdir():
if filename.suffix != ".csv":
continue
csv_to_po(app, filename.stem)
def csv_to_po(app: str, locale: str):
csv_file = Path(frappe.get_app_path(app)) / "translations" / f"{locale.replace('_', '-')}.csv"
locale = locale.replace("-", "_")
if not csv_file.exists():
return
catalog: Catalog = get_catalog(app)
msgid_context_map = defaultdict(list)
for message in catalog:
msgid_context_map[message.id].append(message.context)
with open(csv_file) as f:
for row in csv.reader(f):
if len(row) < 2:
continue
msgid = escape_percent(row[0])
msgstr = escape_percent(row[1])
msgctxt = row[2] if len(row) >= 3 else None
if not msgctxt:
# if old context is not defined, add msgstr to all contexts
for context in msgid_context_map.get(msgid, []):
if message := catalog.get(msgid, context):
message.string = msgstr
elif message := catalog.get(msgid, msgctxt):
message.string = msgstr
po_path = write_catalog(app, catalog, locale)
print(f"PO file created at {po_path}")
def get_translations_from_mo(lang, app):
"""Get translations from MO files.
For dialects (i.e. es_GT), take translations from the base language (i.e. es)
and then update with specific translations from the dialect (i.e. es_GT).
If we only have a translation with context, also use it as a translation
without context. This way we can provide the context for each source string
but don't have to create a translation for each context.
"""
if not lang or not app:
return {}
translations = {}
lang = lang.replace("-", "_") # Frappe uses dash, babel uses underscore.
locale_dir = get_locale_dir()
mo_file = gettext.find(app, locale_dir, (lang,))
if not mo_file:
return translations
with open(mo_file, "rb") as f:
catalog = read_mo(f)
for m in catalog:
if not m.id:
continue
key = m.id
if m.context:
context = m.context.decode() # context is encoded as bytes
translations[f"{key}:{context}"] = m.string
if m.id not in translations:
# better a translation with context than no translation
translations[m.id] = m.string
else:
translations[m.id] = m.string
return translations
def escape_percent(s: str):
return s.replace("%", "%")