您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 
 
 

960 行
30 KiB

  1. # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
  2. # License: MIT. See LICENSE
  3. """
  4. frappe.translate
  5. ~~~~~~~~~~~~~~~~
  6. Translation tools for frappe
  7. """
  8. import io
  9. import itertools
  10. import json
  11. import operator
  12. import functools
  13. import os
  14. import re
  15. from csv import reader
  16. from typing import List, Union, Tuple
  17. import frappe
  18. from frappe.model.utils import InvalidIncludePath, render_include
  19. from frappe.utils import get_bench_path, is_html, strip, strip_html_tags
  20. from frappe.query_builder import Field, DocType
  21. from pypika.terms import PseudoColumn
  22. def get_language(lang_list: List = None) -> str:
  23. """Set `frappe.local.lang` from HTTP headers at beginning of request
  24. Order of priority for setting language:
  25. 1. Form Dict => _lang
  26. 2. Cookie => preferred_language (Non authorized user)
  27. 3. Request Header => Accept-Language (Non authorized user)
  28. 4. User document => language
  29. 5. System Settings => language
  30. """
  31. is_logged_in = frappe.session.user != "Guest"
  32. # fetch language from form_dict
  33. if frappe.form_dict._lang:
  34. language = get_lang_code(
  35. frappe.form_dict._lang or get_parent_language(frappe.form_dict._lang)
  36. )
  37. if language:
  38. return language
  39. # use language set in User or System Settings if user is logged in
  40. if is_logged_in:
  41. return frappe.local.lang
  42. lang_set = set(lang_list or get_all_languages() or [])
  43. # fetch language from cookie
  44. preferred_language_cookie = get_preferred_language_cookie()
  45. if preferred_language_cookie:
  46. if preferred_language_cookie in lang_set:
  47. return preferred_language_cookie
  48. parent_language = get_parent_language(language)
  49. if parent_language in lang_set:
  50. return parent_language
  51. # fetch language from request headers
  52. accept_language = list(frappe.request.accept_languages.values())
  53. for language in accept_language:
  54. if language in lang_set:
  55. return language
  56. parent_language = get_parent_language(language)
  57. if parent_language in lang_set:
  58. return parent_language
  59. # fallback to language set in User or System Settings
  60. return frappe.local.lang
  61. @functools.lru_cache()
  62. def get_parent_language(lang: str) -> str:
  63. """If the passed language is a variant, return its parent
  64. Eg:
  65. 1. zh-TW -> zh
  66. 2. sr-BA -> sr
  67. """
  68. is_language_variant = "-" in lang
  69. if is_language_variant:
  70. return lang[:lang.index("-")]
  71. def get_user_lang(user: str = None) -> str:
  72. """Set frappe.local.lang from user preferences on session beginning or resumption"""
  73. user = user or frappe.session.user
  74. lang = frappe.cache().hget("lang", user)
  75. if not lang:
  76. # User.language => Session Defaults => frappe.local.lang => 'en'
  77. lang = (
  78. frappe.db.get_value("User", user, "language")
  79. or frappe.db.get_default("lang")
  80. or frappe.local.lang
  81. or "en"
  82. )
  83. frappe.cache().hset("lang", user, lang)
  84. return lang
  85. def get_lang_code(lang: str) -> Union[str, None]:
  86. return (
  87. frappe.db.get_value("Language", {"name": lang})
  88. or frappe.db.get_value("Language", {"language_name": lang})
  89. )
  90. def set_default_language(lang):
  91. """Set Global default language"""
  92. if frappe.db.get_default("lang") != lang:
  93. frappe.db.set_default("lang", lang)
  94. frappe.local.lang = lang
  95. def get_lang_dict():
  96. """Returns all languages in dict format, full name is the key e.g. `{"english":"en"}`"""
  97. result = dict(frappe.get_all("Language", fields=["language_name", "name"], order_by="modified", as_list=True))
  98. return result
  99. def get_dict(fortype, name=None):
  100. """Returns translation dict for a type of object.
  101. :param fortype: must be one of `doctype`, `page`, `report`, `include`, `jsfile`, `boot`
  102. :param name: name of the document for which assets are to be returned.
  103. """
  104. fortype = fortype.lower()
  105. cache = frappe.cache()
  106. asset_key = fortype + ":" + (name or "-")
  107. translation_assets = cache.hget("translation_assets", frappe.local.lang, shared=True) or {}
  108. if asset_key not in translation_assets:
  109. messages = []
  110. if fortype=="doctype":
  111. messages = get_messages_from_doctype(name)
  112. elif fortype=="page":
  113. messages = get_messages_from_page(name)
  114. elif fortype=="report":
  115. messages = get_messages_from_report(name)
  116. elif fortype=="include":
  117. messages = get_messages_from_include_files()
  118. elif fortype=="jsfile":
  119. messages = get_messages_from_file(name)
  120. elif fortype=="boot":
  121. apps = frappe.get_all_apps(True)
  122. for app in apps:
  123. messages.extend(get_server_messages(app))
  124. messages += get_messages_from_navbar()
  125. messages += get_messages_from_include_files()
  126. messages += (
  127. frappe.qb.from_("Print Format")
  128. .select(PseudoColumn("'Print Format:'"), "name")).run()
  129. messages += (
  130. frappe.qb.from_("DocType")
  131. .select(PseudoColumn("'DocType:'"), "name")).run()
  132. messages += (
  133. frappe.qb.from_("Role").select(PseudoColumn("'Role:'"), "name").run()
  134. )
  135. messages += (
  136. frappe.qb.from_("Module Def")
  137. .select(PseudoColumn("'Module:'"), "name")).run()
  138. messages += (
  139. frappe.qb.from_("Workspace Shortcut")
  140. .where(Field("format").isnotnull())
  141. .select(PseudoColumn("''"), "format")).run()
  142. messages += (
  143. frappe.qb.from_("Onboarding Step")
  144. .select(PseudoColumn("''"), "title")).run()
  145. messages = deduplicate_messages(messages)
  146. message_dict = make_dict_from_messages(messages, load_user_translation=False)
  147. message_dict.update(get_dict_from_hooks(fortype, name))
  148. # remove untranslated
  149. message_dict = {k: v for k, v in message_dict.items() if k!=v}
  150. translation_assets[asset_key] = message_dict
  151. cache.hset("translation_assets", frappe.local.lang, translation_assets, shared=True)
  152. translation_map = translation_assets[asset_key]
  153. translation_map.update(get_user_translations(frappe.local.lang))
  154. return translation_map
  155. def get_dict_from_hooks(fortype, name):
  156. translated_dict = {}
  157. hooks = frappe.get_hooks("get_translated_dict")
  158. for (hook_fortype, fortype_name) in hooks:
  159. if hook_fortype == fortype and fortype_name == name:
  160. for method in hooks[(hook_fortype, fortype_name)]:
  161. translated_dict.update(frappe.get_attr(method)())
  162. return translated_dict
  163. def make_dict_from_messages(messages, full_dict=None, load_user_translation=True):
  164. """Returns translated messages as a dict in Language specified in `frappe.local.lang`
  165. :param messages: List of untranslated messages
  166. """
  167. out = {}
  168. if full_dict is None:
  169. if load_user_translation:
  170. full_dict = get_full_dict(frappe.local.lang)
  171. else:
  172. full_dict = load_lang(frappe.local.lang)
  173. for m in messages:
  174. if m[1] in full_dict:
  175. out[m[1]] = full_dict[m[1]]
  176. # check if msg with context as key exist eg. msg:context
  177. if len(m) > 2 and m[2]:
  178. key = m[1] + ':' + m[2]
  179. if full_dict.get(key):
  180. out[key] = full_dict[key]
  181. return out
  182. def get_lang_js(fortype, name):
  183. """Returns code snippet to be appended at the end of a JS script.
  184. :param fortype: Type of object, e.g. `DocType`
  185. :param name: Document name
  186. """
  187. return "\n\n$.extend(frappe._messages, %s)" % json.dumps(get_dict(fortype, name))
  188. def get_full_dict(lang):
  189. """Load and return the entire translations dictionary for a language from :meth:`frape.cache`
  190. :param lang: Language Code, e.g. `hi`
  191. """
  192. if not lang:
  193. return {}
  194. # found in local, return!
  195. if getattr(frappe.local, 'lang_full_dict', None) and frappe.local.lang_full_dict.get(lang, None):
  196. return frappe.local.lang_full_dict
  197. frappe.local.lang_full_dict = load_lang(lang)
  198. try:
  199. # get user specific translation data
  200. user_translations = get_user_translations(lang)
  201. frappe.local.lang_full_dict.update(user_translations)
  202. except Exception:
  203. pass
  204. return frappe.local.lang_full_dict
  205. def load_lang(lang, apps=None):
  206. """Combine all translations from `.csv` files in all `apps`.
  207. For derivative languages (es-GT), take translations from the
  208. base language (es) and then update translations from the child (es-GT)"""
  209. if lang=='en':
  210. return {}
  211. out = frappe.cache().hget("lang_full_dict", lang, shared=True)
  212. if not out:
  213. out = {}
  214. for app in (apps or frappe.get_all_apps(True)):
  215. path = os.path.join(frappe.get_pymodule_path(app), "translations", lang + ".csv")
  216. out.update(get_translation_dict_from_file(path, lang, app) or {})
  217. if '-' in lang:
  218. parent = lang.split('-')[0]
  219. parent_out = load_lang(parent)
  220. parent_out.update(out)
  221. out = parent_out
  222. frappe.cache().hset("lang_full_dict", lang, out, shared=True)
  223. return out or {}
  224. def get_translation_dict_from_file(path, lang, app):
  225. """load translation dict from given path"""
  226. translation_map = {}
  227. if os.path.exists(path):
  228. csv_content = read_csv_file(path)
  229. for item in csv_content:
  230. if len(item)==3 and item[2]:
  231. key = item[0] + ':' + item[2]
  232. translation_map[key] = strip(item[1])
  233. elif len(item) in [2, 3]:
  234. translation_map[item[0]] = strip(item[1])
  235. elif item:
  236. raise Exception("Bad translation in '{app}' for language '{lang}': {values}".format(
  237. app=app, lang=lang, values=repr(item).encode("utf-8")
  238. ))
  239. return translation_map
  240. def get_user_translations(lang):
  241. if not frappe.db:
  242. frappe.connect()
  243. out = frappe.cache().hget('lang_user_translations', lang)
  244. if out is None:
  245. out = {}
  246. user_translations = frappe.get_all('Translation',
  247. fields=["source_text", "translated_text", "context"],
  248. filters={'language': lang})
  249. for translation in user_translations:
  250. key = translation.source_text
  251. value = translation.translated_text
  252. if translation.context:
  253. key += ':' + translation.context
  254. out[key] = value
  255. frappe.cache().hset('lang_user_translations', lang, out)
  256. return out
  257. def clear_cache():
  258. """Clear all translation assets from :meth:`frappe.cache`"""
  259. cache = frappe.cache()
  260. cache.delete_key("langinfo")
  261. # clear translations saved in boot cache
  262. cache.delete_key("bootinfo")
  263. cache.delete_key("lang_full_dict", shared=True)
  264. cache.delete_key("translation_assets", shared=True)
  265. cache.delete_key("lang_user_translations")
  266. def get_messages_for_app(app, deduplicate=True):
  267. """Returns all messages (list) for a specified `app`"""
  268. messages = []
  269. modules = [frappe.unscrub(m) for m in frappe.local.app_modules[app]]
  270. # doctypes
  271. if modules:
  272. if isinstance(modules, str):
  273. modules = [modules]
  274. filtered_doctypes = frappe.qb.from_("DocType").where(
  275. Field("module").isin(modules)
  276. ).select("name").run(pluck=True)
  277. for name in filtered_doctypes:
  278. messages.extend(get_messages_from_doctype(name))
  279. # pages
  280. filtered_pages = frappe.qb.from_("Page").where(
  281. Field("module").isin(modules)
  282. ).select("name", "title").run()
  283. for name, title in filtered_pages:
  284. messages.append((None, title or name))
  285. messages.extend(get_messages_from_page(name))
  286. # reports
  287. report = DocType("Report")
  288. doctype = DocType("DocType")
  289. names = (
  290. frappe.qb.from_(doctype)
  291. .from_(report)
  292. .where((report.ref_doctype == doctype.name) & doctype.module.isin(modules))
  293. .select(report.name).run(pluck=True))
  294. for name in names:
  295. messages.append((None, name))
  296. messages.extend(get_messages_from_report(name))
  297. for i in messages:
  298. if not isinstance(i, tuple):
  299. raise Exception
  300. # workflow based on app.hooks.fixtures
  301. messages.extend(get_messages_from_workflow(app_name=app))
  302. # custom fields based on app.hooks.fixtures
  303. messages.extend(get_messages_from_custom_fields(app_name=app))
  304. # app_include_files
  305. messages.extend(get_all_messages_from_js_files(app))
  306. # server_messages
  307. messages.extend(get_server_messages(app))
  308. # messages from navbar settings
  309. messages.extend(get_messages_from_navbar())
  310. if deduplicate:
  311. messages = deduplicate_messages(messages)
  312. return messages
  313. def get_messages_from_navbar():
  314. """Return all labels from Navbar Items, as specified in Navbar Settings."""
  315. labels = frappe.get_all('Navbar Item', filters={'item_label': ('is', 'set')}, pluck='item_label')
  316. return [('Navbar:', label, 'Label of a Navbar Item') for label in labels]
  317. def get_messages_from_doctype(name):
  318. """Extract all translatable messages for a doctype. Includes labels, Python code,
  319. Javascript code, html templates"""
  320. messages = []
  321. meta = frappe.get_meta(name)
  322. messages = [meta.name, meta.module]
  323. if meta.description:
  324. messages.append(meta.description)
  325. # translations of field labels, description and options
  326. for d in meta.get("fields"):
  327. messages.extend([d.label, d.description])
  328. if d.fieldtype=='Select' and d.options:
  329. options = d.options.split('\n')
  330. if not "icon" in options[0]:
  331. messages.extend(options)
  332. if d.fieldtype=='HTML' and d.options:
  333. messages.append(d.options)
  334. # translations of roles
  335. for d in meta.get("permissions"):
  336. if d.role:
  337. messages.append(d.role)
  338. messages = [message for message in messages if message]
  339. messages = [('DocType: ' + name, message) for message in messages if is_translatable(message)]
  340. # extract from js, py files
  341. if not meta.custom:
  342. doctype_file_path = frappe.get_module_path(meta.module, "doctype", meta.name, meta.name)
  343. messages.extend(get_messages_from_file(doctype_file_path + ".js"))
  344. messages.extend(get_messages_from_file(doctype_file_path + "_list.js"))
  345. messages.extend(get_messages_from_file(doctype_file_path + "_list.html"))
  346. messages.extend(get_messages_from_file(doctype_file_path + "_calendar.js"))
  347. messages.extend(get_messages_from_file(doctype_file_path + "_dashboard.html"))
  348. # workflow based on doctype
  349. messages.extend(get_messages_from_workflow(doctype=name))
  350. return messages
  351. def get_messages_from_workflow(doctype=None, app_name=None):
  352. assert doctype or app_name, 'doctype or app_name should be provided'
  353. # translations for Workflows
  354. workflows = []
  355. if doctype:
  356. workflows = frappe.get_all('Workflow', filters={'document_type': doctype})
  357. else:
  358. fixtures = frappe.get_hooks('fixtures', app_name=app_name) or []
  359. for fixture in fixtures:
  360. if isinstance(fixture, str) and fixture == 'Worflow':
  361. workflows = frappe.get_all('Workflow')
  362. break
  363. elif isinstance(fixture, dict) and fixture.get('dt', fixture.get('doctype')) == 'Workflow':
  364. workflows.extend(frappe.get_all('Workflow', filters=fixture.get('filters')))
  365. messages = []
  366. document_state = DocType("Workflow Document State")
  367. for w in workflows:
  368. states = frappe.db.get_values(
  369. document_state,
  370. filters=document_state.parent == w["name"],
  371. fieldname="state",
  372. distinct=True,
  373. as_dict=True,
  374. order_by=None,
  375. )
  376. messages.extend([('Workflow: ' + w['name'], state['state']) for state in states if is_translatable(state['state'])])
  377. states = frappe.db.get_values(
  378. document_state,
  379. filters=(document_state.parent == w["name"])
  380. & (document_state.message.isnotnull()),
  381. fieldname="message",
  382. distinct=True,
  383. order_by=None,
  384. as_dict=True,
  385. )
  386. messages.extend([("Workflow: " + w['name'], state['message'])
  387. for state in states if is_translatable(state['message'])])
  388. actions = frappe.db.get_values(
  389. "Workflow Transition",
  390. filters={"parent": w["name"]},
  391. fieldname="action",
  392. as_dict=True,
  393. distinct=True,
  394. order_by=None,
  395. )
  396. messages.extend([("Workflow: " + w['name'], action['action']) \
  397. for action in actions if is_translatable(action['action'])])
  398. return messages
  399. def get_messages_from_custom_fields(app_name):
  400. fixtures = frappe.get_hooks('fixtures', app_name=app_name) or []
  401. custom_fields = []
  402. for fixture in fixtures:
  403. if isinstance(fixture, str) and fixture == 'Custom Field':
  404. custom_fields = frappe.get_all('Custom Field', fields=['name','label', 'description', 'fieldtype', 'options'])
  405. break
  406. elif isinstance(fixture, dict) and fixture.get('dt', fixture.get('doctype')) == 'Custom Field':
  407. custom_fields.extend(frappe.get_all('Custom Field', filters=fixture.get('filters'),
  408. fields=['name','label', 'description', 'fieldtype', 'options']))
  409. messages = []
  410. for cf in custom_fields:
  411. for prop in ('label', 'description'):
  412. if not cf.get(prop) or not is_translatable(cf[prop]):
  413. continue
  414. messages.append(('Custom Field - {}: {}'.format(prop, cf['name']), cf[prop]))
  415. if cf['fieldtype'] == 'Selection' and cf.get('options'):
  416. for option in cf['options'].split('\n'):
  417. if option and 'icon' not in option and is_translatable(option):
  418. messages.append(('Custom Field - Description: ' + cf['name'], option))
  419. return messages
  420. def get_messages_from_page(name):
  421. """Returns all translatable strings from a :class:`frappe.core.doctype.Page`"""
  422. return _get_messages_from_page_or_report("Page", name)
  423. def get_messages_from_report(name):
  424. """Returns all translatable strings from a :class:`frappe.core.doctype.Report`"""
  425. report = frappe.get_doc("Report", name)
  426. messages = _get_messages_from_page_or_report("Report", name,
  427. frappe.db.get_value("DocType", report.ref_doctype, "module"))
  428. if report.columns:
  429. context = "Column of report '%s'" % report.name # context has to match context in `prepare_columns` in query_report.js
  430. messages.extend([(None, report_column.label, context) for report_column in report.columns])
  431. if report.filters:
  432. messages.extend([(None, report_filter.label) for report_filter in report.filters])
  433. if report.query:
  434. messages.extend([(None, message) for message in re.findall('"([^:,^"]*):', report.query) if is_translatable(message)])
  435. messages.append((None,report.report_name))
  436. return messages
  437. def _get_messages_from_page_or_report(doctype, name, module=None):
  438. if not module:
  439. module = frappe.db.get_value(doctype, name, "module")
  440. doc_path = frappe.get_module_path(module, doctype, name)
  441. messages = get_messages_from_file(os.path.join(doc_path, frappe.scrub(name) +".py"))
  442. if os.path.exists(doc_path):
  443. for filename in os.listdir(doc_path):
  444. if filename.endswith(".js") or filename.endswith(".html"):
  445. messages += get_messages_from_file(os.path.join(doc_path, filename))
  446. return messages
  447. def get_server_messages(app):
  448. """Extracts all translatable strings (tagged with :func:`frappe._`) from Python modules
  449. inside an app"""
  450. messages = []
  451. file_extensions = ('.py', '.html', '.js', '.vue')
  452. for basepath, folders, files in os.walk(frappe.get_pymodule_path(app)):
  453. for dontwalk in (".git", "public", "locale"):
  454. if dontwalk in folders: folders.remove(dontwalk)
  455. for f in files:
  456. f = frappe.as_unicode(f)
  457. if f.endswith(file_extensions):
  458. messages.extend(get_messages_from_file(os.path.join(basepath, f)))
  459. return messages
  460. def get_messages_from_include_files(app_name=None):
  461. """Returns messages from js files included at time of boot like desk.min.js for desk and web"""
  462. from frappe.utils.jinja_globals import bundled_asset
  463. messages = []
  464. app_include_js = frappe.get_hooks("app_include_js", app_name=app_name) or []
  465. web_include_js = frappe.get_hooks("web_include_js", app_name=app_name) or []
  466. include_js = app_include_js + web_include_js
  467. for js_path in include_js:
  468. file_path = bundled_asset(js_path)
  469. relative_path = os.path.join(frappe.local.sites_path, file_path.lstrip('/'))
  470. messages_from_file = get_messages_from_file(relative_path)
  471. messages.extend(messages_from_file)
  472. return messages
  473. def get_all_messages_from_js_files(app_name=None):
  474. """Extracts all translatable strings from app `.js` files"""
  475. messages = []
  476. for app in ([app_name] if app_name else frappe.get_installed_apps()):
  477. if os.path.exists(frappe.get_app_path(app, "public")):
  478. for basepath, folders, files in os.walk(frappe.get_app_path(app, "public")):
  479. if "frappe/public/js/lib" in basepath:
  480. continue
  481. for fname in files:
  482. if fname.endswith(".js") or fname.endswith(".html") or fname.endswith('.vue'):
  483. messages.extend(get_messages_from_file(os.path.join(basepath, fname)))
  484. return messages
  485. def get_messages_from_file(path: str) -> List[Tuple[str, str, str, str]]:
  486. """Returns a list of transatable strings from a code file
  487. :param path: path of the code file
  488. """
  489. frappe.flags.setdefault('scanned_files', [])
  490. # TODO: Find better alternative
  491. # To avoid duplicate scan
  492. if path in set(frappe.flags.scanned_files):
  493. return []
  494. frappe.flags.scanned_files.append(path)
  495. bench_path = get_bench_path()
  496. if os.path.exists(path):
  497. with open(path, 'r') as sourcefile:
  498. try:
  499. file_contents = sourcefile.read()
  500. except Exception:
  501. print("Could not scan file for translation: {0}".format(path))
  502. return []
  503. return [
  504. (os.path.relpath(path, bench_path), message, context, line)
  505. for (line, message, context) in extract_messages_from_code(file_contents)
  506. ]
  507. else:
  508. return []
  509. def extract_messages_from_code(code):
  510. """
  511. Extracts translatable strings from a code file
  512. :param code: code from which translatable files are to be extracted
  513. :param is_py: include messages in triple quotes e.g. `_('''message''')`
  514. """
  515. from jinja2 import TemplateError
  516. try:
  517. code = frappe.as_unicode(render_include(code))
  518. # Exception will occur when it encounters John Resig's microtemplating code
  519. except (TemplateError, ImportError, InvalidIncludePath, IOError) as e:
  520. if isinstance(e, InvalidIncludePath):
  521. frappe.clear_last_message()
  522. messages = []
  523. pattern = r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,\s*(.)*?\s*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)"
  524. for m in re.compile(pattern).finditer(code):
  525. message = m.group('message')
  526. context = m.group('py_context') or m.group('js_context')
  527. pos = m.start()
  528. if is_translatable(message):
  529. messages.append([pos, message, context])
  530. return add_line_number(messages, code)
  531. def is_translatable(m):
  532. if re.search("[a-zA-Z]", m) and not m.startswith("fa fa-") and not m.endswith("px") and not m.startswith("eval:"):
  533. return True
  534. return False
  535. def add_line_number(messages, code):
  536. ret = []
  537. messages = sorted(messages, key=lambda x: x[0])
  538. newlines = [m.start() for m in re.compile(r'\n').finditer(code)]
  539. line = 1
  540. newline_i = 0
  541. for pos, message, context in messages:
  542. while newline_i < len(newlines) and pos > newlines[newline_i]:
  543. line+=1
  544. newline_i+= 1
  545. ret.append([line, message, context])
  546. return ret
  547. def read_csv_file(path):
  548. """Read CSV file and return as list of list
  549. :param path: File path"""
  550. with io.open(path, mode='r', encoding='utf-8', newline='') as msgfile:
  551. data = reader(msgfile)
  552. newdata = [[val for val in row] for row in data]
  553. return newdata
  554. def write_csv_file(path, app_messages, lang_dict):
  555. """Write translation CSV file.
  556. :param path: File path, usually `[app]/translations`.
  557. :param app_messages: Translatable strings for this app.
  558. :param lang_dict: Full translated dict.
  559. """
  560. app_messages.sort(key = lambda x: x[1])
  561. from csv import writer
  562. with open(path, 'w', newline='') as msgfile:
  563. w = writer(msgfile, lineterminator='\n')
  564. for app_message in app_messages:
  565. context = None
  566. if len(app_message) == 2:
  567. path, message = app_message
  568. elif len(app_message) == 3:
  569. path, message, lineno = app_message
  570. elif len(app_message) == 4:
  571. path, message, context, lineno = app_message
  572. else:
  573. continue
  574. t = lang_dict.get(message, '')
  575. # strip whitespaces
  576. translated_string = re.sub(r'{\s?([0-9]+)\s?}', r"{\g<1>}", t)
  577. if translated_string:
  578. w.writerow([message, translated_string, context])
  579. def get_untranslated(lang, untranslated_file, get_all=False):
  580. """Returns all untranslated strings for a language and writes in a file
  581. :param lang: Language code.
  582. :param untranslated_file: Output file path.
  583. :param get_all: Return all strings, translated or not."""
  584. clear_cache()
  585. apps = frappe.get_all_apps(True)
  586. messages = []
  587. untranslated = []
  588. for app in apps:
  589. messages.extend(get_messages_for_app(app))
  590. messages = deduplicate_messages(messages)
  591. def escape_newlines(s):
  592. return (s.replace("\\\n", "|||||")
  593. .replace("\\n", "||||")
  594. .replace("\n", "|||"))
  595. if get_all:
  596. print(str(len(messages)) + " messages")
  597. with open(untranslated_file, "wb") as f:
  598. for m in messages:
  599. # replace \n with ||| so that internal linebreaks don't get split
  600. f.write((escape_newlines(m[1]) + os.linesep).encode("utf-8"))
  601. else:
  602. full_dict = get_full_dict(lang)
  603. for m in messages:
  604. if not full_dict.get(m[1]):
  605. untranslated.append(m[1])
  606. if untranslated:
  607. print(str(len(untranslated)) + " missing translations of " + str(len(messages)))
  608. with open(untranslated_file, "wb") as f:
  609. for m in untranslated:
  610. # replace \n with ||| so that internal linebreaks don't get split
  611. f.write((escape_newlines(m) + os.linesep).encode("utf-8"))
  612. else:
  613. print("all translated!")
  614. def update_translations(lang, untranslated_file, translated_file):
  615. """Update translations from a source and target file for a given language.
  616. :param lang: Language code (e.g. `en`).
  617. :param untranslated_file: File path with the messages in English.
  618. :param translated_file: File path with messages in language to be updated."""
  619. clear_cache()
  620. full_dict = get_full_dict(lang)
  621. def restore_newlines(s):
  622. return (s.replace("|||||", "\\\n")
  623. .replace("| | | | |", "\\\n")
  624. .replace("||||", "\\n")
  625. .replace("| | | |", "\\n")
  626. .replace("|||", "\n")
  627. .replace("| | |", "\n"))
  628. translation_dict = {}
  629. for key, value in zip(frappe.get_file_items(untranslated_file, ignore_empty_lines=False),
  630. frappe.get_file_items(translated_file, ignore_empty_lines=False)):
  631. # undo hack in get_untranslated
  632. translation_dict[restore_newlines(key)] = restore_newlines(value)
  633. full_dict.update(translation_dict)
  634. for app in frappe.get_all_apps(True):
  635. write_translations_file(app, lang, full_dict)
  636. def import_translations(lang, path):
  637. """Import translations from file in standard format"""
  638. clear_cache()
  639. full_dict = get_full_dict(lang)
  640. full_dict.update(get_translation_dict_from_file(path, lang, 'import'))
  641. for app in frappe.get_all_apps(True):
  642. write_translations_file(app, lang, full_dict)
  643. def rebuild_all_translation_files():
  644. """Rebuild all translation files: `[app]/translations/[lang].csv`."""
  645. for lang in get_all_languages():
  646. for app in frappe.get_all_apps():
  647. write_translations_file(app, lang)
  648. def write_translations_file(app, lang, full_dict=None, app_messages=None):
  649. """Write a translation file for a given language.
  650. :param app: `app` for which translations are to be written.
  651. :param lang: Language code.
  652. :param full_dict: Full translated language dict (optional).
  653. :param app_messages: Source strings (optional).
  654. """
  655. if not app_messages:
  656. app_messages = get_messages_for_app(app)
  657. if not app_messages:
  658. return
  659. tpath = frappe.get_pymodule_path(app, "translations")
  660. frappe.create_folder(tpath)
  661. write_csv_file(os.path.join(tpath, lang + ".csv"),
  662. app_messages, full_dict or get_full_dict(lang))
  663. def send_translations(translation_dict):
  664. """Append translated dict in `frappe.local.response`"""
  665. if "__messages" not in frappe.local.response:
  666. frappe.local.response["__messages"] = {}
  667. frappe.local.response["__messages"].update(translation_dict)
  668. def deduplicate_messages(messages):
  669. ret = []
  670. op = operator.itemgetter(1)
  671. messages = sorted(messages, key=op)
  672. for k, g in itertools.groupby(messages, op):
  673. ret.append(next(g))
  674. return ret
  675. def rename_language(old_name, new_name):
  676. if not frappe.db.exists('Language', new_name):
  677. return
  678. language_in_system_settings = frappe.db.get_single_value("System Settings", "language")
  679. if language_in_system_settings == old_name:
  680. frappe.db.set_value("System Settings", "System Settings", "language", new_name)
  681. frappe.db.sql("""update `tabUser` set language=%(new_name)s where language=%(old_name)s""",
  682. { "old_name": old_name, "new_name": new_name })
  683. @frappe.whitelist()
  684. def update_translations_for_source(source=None, translation_dict=None):
  685. if not (source and translation_dict):
  686. return
  687. translation_dict = json.loads(translation_dict)
  688. if is_html(source):
  689. source = strip_html_tags(source)
  690. # for existing records
  691. translation_records = frappe.db.get_values('Translation', {
  692. 'source_text': source
  693. }, ['name', 'language'], as_dict=1)
  694. for d in translation_records:
  695. if translation_dict.get(d.language, None):
  696. doc = frappe.get_doc('Translation', d.name)
  697. doc.translated_text = translation_dict.get(d.language)
  698. doc.save()
  699. # done with this lang value
  700. translation_dict.pop(d.language)
  701. else:
  702. frappe.delete_doc('Translation', d.name)
  703. # remaining values are to be inserted
  704. for lang, translated_text in translation_dict.items():
  705. doc = frappe.new_doc('Translation')
  706. doc.language = lang
  707. doc.source_text = source
  708. doc.translated_text = translated_text
  709. doc.save()
  710. return translation_records
  711. @frappe.whitelist()
  712. def get_translations(source_text):
  713. if is_html(source_text):
  714. source_text = strip_html_tags(source_text)
  715. return frappe.db.get_list('Translation',
  716. fields = ['name', 'language', 'translated_text as translation'],
  717. filters = {
  718. 'source_text': source_text
  719. }
  720. )
  721. @frappe.whitelist()
  722. def get_messages(language, start=0, page_length=100, search_text=''):
  723. from frappe.frappeclient import FrappeClient
  724. translator = FrappeClient(get_translator_url())
  725. translated_dict = translator.post_api('translator.api.get_strings_for_translation', params=locals())
  726. return translated_dict
  727. @frappe.whitelist()
  728. def get_source_additional_info(source, language=''):
  729. from frappe.frappeclient import FrappeClient
  730. translator = FrappeClient(get_translator_url())
  731. return translator.post_api('translator.api.get_source_additional_info', params=locals())
  732. @frappe.whitelist()
  733. def get_contributions(language):
  734. return frappe.get_all('Translation', fields=['*'], filters={
  735. 'contributed': 1,
  736. })
  737. @frappe.whitelist()
  738. def get_contribution_status(message_id):
  739. from frappe.frappeclient import FrappeClient
  740. doc = frappe.get_doc('Translation', message_id)
  741. translator = FrappeClient(get_translator_url())
  742. contributed_translation = translator.get_api('translator.api.get_contribution_status', params={
  743. 'translation_id': doc.contribution_docname
  744. })
  745. return contributed_translation
  746. def get_translator_url():
  747. return frappe.get_hooks()['translator_url'][0]
  748. @frappe.whitelist(allow_guest=True)
  749. def get_all_languages(with_language_name=False):
  750. """Returns all language codes ar, ch etc"""
  751. def get_language_codes():
  752. return frappe.get_all("Language", pluck="name")
  753. def get_all_language_with_name():
  754. return frappe.db.get_all('Language', ['language_code', 'language_name'])
  755. if not frappe.db:
  756. frappe.connect()
  757. if with_language_name:
  758. return frappe.cache().get_value('languages_with_name', get_all_language_with_name)
  759. else:
  760. return frappe.cache().get_value('languages', get_language_codes)
  761. @frappe.whitelist(allow_guest=True)
  762. def set_preferred_language_cookie(preferred_language):
  763. frappe.local.cookie_manager.set_cookie("preferred_language", preferred_language)
  764. def get_preferred_language_cookie():
  765. return frappe.request.cookies.get("preferred_language")