Merge pull request #22732 from barredterra/po-translation

feat: translations via gettext
This commit is contained in:
Ankush Menat 2024-01-10 19:10:13 +05:30 committed by GitHub
commit a77093d6f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
185 changed files with 3155541 additions and 304175 deletions

9
babel_extractors.csv Normal file
View file

@ -0,0 +1,9 @@
hooks.py,frappe.gettext.extractors.navbar.extract
**/doctype/*/*.json,frappe.gettext.extractors.doctype.extract
**/workspace/*/*.json,frappe.gettext.extractors.workspace.extract
**/onboarding_step/*/*.json,frappe.gettext.extractors.onboarding_step.extract
**/module_onboarding/*/*.json,frappe.gettext.extractors.module_onboarding.extract
**/report/*/*.json,frappe.gettext.extractors.report.extract
**.py,frappe.gettext.extractors.python.extract
**.js,frappe.gettext.extractors.javascript.extract
**.html,frappe.gettext.extractors.jinja2.extract
1 hooks.py frappe.gettext.extractors.navbar.extract
2 **/doctype/*/*.json frappe.gettext.extractors.doctype.extract
3 **/workspace/*/*.json frappe.gettext.extractors.workspace.extract
4 **/onboarding_step/*/*.json frappe.gettext.extractors.onboarding_step.extract
5 **/module_onboarding/*/*.json frappe.gettext.extractors.module_onboarding.extract
6 **/report/*/*.json frappe.gettext.extractors.report.extract
7 **.py frappe.gettext.extractors.python.extract
8 **.js frappe.gettext.extractors.javascript.extract
9 **.html frappe.gettext.extractors.jinja2.extract

View file

@ -105,6 +105,7 @@ def call_command(cmd, context):
def get_commands():
# prevent circular imports
from .gettext import commands as gettext_commands
from .redis_utils import commands as redis_commands
from .scheduler import commands as scheduler_commands
from .site import commands as site_commands
@ -113,7 +114,12 @@ def get_commands():
clickable_link = "https://frappeframework.com/docs"
all_commands = (
scheduler_commands + site_commands + translate_commands + utils_commands + redis_commands
scheduler_commands
+ site_commands
+ translate_commands
+ gettext_commands
+ utils_commands
+ redis_commands
)
for command in all_commands:

View file

@ -0,0 +1,98 @@
import click
from frappe.commands import pass_context
from frappe.exceptions import SiteNotSpecifiedError
@click.command("generate-pot-file", help="Translation: generate POT file")
@click.option("--app", help="Only generate for this app. eg: frappe")
@pass_context
def generate_pot_file(context, app: str | None = None):
from frappe.gettext.translate import generate_pot
if not app:
connect_to_site(context.sites[0] if context.sites else None)
generate_pot(app)
@click.command("compile-po-to-mo", help="Translation: compile PO files to MO files")
@click.option("--app", help="Only compile for this app. eg: frappe")
@click.option(
"--force",
is_flag=True,
default=False,
help="Force compile even if there are no changes to PO files",
)
@click.option("--locale", help="Compile transaltions only for this locale. eg: de")
@pass_context
def compile_translations(context, app: str | None = None, locale: str = None, force=False):
from frappe.gettext.translate import compile_translations as _compile_translations
if not app:
connect_to_site(context.sites[0] if context.sites else None)
_compile_translations(app, locale, force=force)
@click.command(
"migrate-csv-to-po", help="Translation: migrate from CSV files (old) to PO files (new)"
)
@click.option("--app", help="Only migrate for this app. eg: frappe")
@click.option("--locale", help="Compile translations only for this locale. eg: de")
@pass_context
def csv_to_po(context, app: str | None = None, locale: str = None):
from frappe.gettext.translate import migrate
if not app:
connect_to_site(context.sites[0] if context.sites else None)
migrate(app, locale)
@click.command(
"update-po-files",
help="""Translation: sync PO files with POT file.
You might want to run generate-pot-file first.""",
)
@click.option("--app", help="Only update for this app. eg: frappe")
@pass_context
def update_po_files(context, app: str | None = None):
from frappe.gettext.translate import update_po
if not app:
connect_to_site(context.sites[0] if context.sites else None)
update_po(app)
@click.command("create-po-file", help="Translation: create a new PO file for a locale")
@click.argument("locale", nargs=1)
@click.option("--app", help="Only create for this app. eg: frappe")
@pass_context
def create_po_file(context, locale: str, app: str | None = None):
"""Create PO file for lang code"""
from frappe.gettext.translate import new_po
if not app:
connect_to_site(context.sites[0] if context.sites else None)
new_po(locale, app)
def connect_to_site(site):
from frappe import connect
if not site:
raise SiteNotSpecifiedError
connect(site=site)
commands = [
generate_pot_file,
compile_translations,
csv_to_po,
update_po_files,
create_po_file,
]

View file

@ -47,6 +47,7 @@ def build(
):
"Compile JS and CSS source files"
from frappe.build import bundle, download_frappe_assets
from frappe.gettext.translate import compile_translations
from frappe.utils.synchronization import filelock
frappe.init("")
@ -77,6 +78,16 @@ def build(
save_metafiles=save_metafiles,
)
if apps and isinstance(apps, str):
apps = apps.split(",")
if not apps:
apps = frappe.get_all_apps()
for app in apps:
print("Compiling translations for", app)
compile_translations(app, force=force)
@click.command("watch")
@click.option("--apps", help="Watch assets for specific apps")
@ -93,14 +104,12 @@ def watch(apps=None):
def clear_cache(context):
"Clear cache, doctype cache and defaults"
import frappe.sessions
from frappe.desk.notifications import clear_notifications
from frappe.website.utils import clear_website_cache
for site in context.sites:
try:
frappe.connect(site)
frappe.clear_cache()
clear_notifications()
clear_website_cache()
finally:
frappe.destroy()

View file

@ -3,7 +3,6 @@
import frappe
from frappe import _
from frappe.tests.utils import FrappeTestCase
from frappe.translate import clear_cache
class TestTranslation(FrappeTestCase):
@ -12,6 +11,8 @@ class TestTranslation(FrappeTestCase):
def tearDown(self):
frappe.local.lang = "en"
from frappe.translate import clear_cache
clear_cache()
def test_doctype(self):

View file

@ -7,7 +7,7 @@ import frappe
from frappe import _
from frappe.geo.country_info import get_country_info
from frappe.permissions import AUTOMATIC_ROLES
from frappe.translate import get_messages_for_boot, send_translations, set_default_language
from frappe.translate import send_translations, set_default_language
from frappe.utils import cint, now, strip
from frappe.utils.password import update_password
@ -304,6 +304,8 @@ def disable_future_access():
def load_messages(language):
"""Load translation messages for given language from all `setup_wizard_requires`
javascript files"""
from frappe.translate import get_messages_for_boot
frappe.clear_cache()
set_default_language(get_language_code(language))
frappe.db.commit()

View file

@ -0,0 +1,2 @@
Extractors should run on source files only.
They should not depend on an acitive web server or database connection.

View file

@ -0,0 +1,60 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from DocType JSON files. To be used to babel extractor
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
doctype = data.get("name")
yield None, "_", doctype, ["Name of a DocType"]
messages = []
fields = data.get("fields", [])
links = data.get("links", [])
for field in fields:
fieldtype = field.get("fieldtype")
if label := field.get("label"):
messages.append((label, f"Label of a {fieldtype} field in DocType '{doctype}'"))
if description := field.get("description"):
messages.append((description, f"Description of a {fieldtype} field in DocType '{doctype}'"))
if message := field.get("options"):
if fieldtype == "Select":
select_options = [option for option in message.split("\n") if option and not option.isdigit()]
if select_options and "icon" in select_options[0]:
continue
messages.extend(
(option, f"Option for a Select field in DocType '{doctype}'") for option in select_options
)
elif fieldtype == "HTML":
messages.append((message, f"Content of an HTML field in DocType '{doctype}'"))
for link in links:
if group := link.get("group"):
messages.append((group, f"Group in {doctype}'s connections"))
if link_doctype := link.get("link_doctype"):
messages.append((link_doctype, f"Linked DocType in {doctype}'s connections"))
# By using "pgettext" as the function name we can supply the doctype as context
yield from ((None, "pgettext", (doctype, message), [comment]) for message, comment in messages)
# Role names do not get context because they are used with multiple doctypes
yield from (
(None, "_", perm["role"], ["Name of a role"])
for perm in data.get("permissions", [])
if "role" in perm
)

View file

@ -0,0 +1,163 @@
from io import BufferedReader
def extract(fileobj: BufferedReader, keywords: str, comment_tags: tuple, options: dict):
code = fileobj.read().decode("utf-8")
for lineno, funcname, messages in extract_javascript(code, "__", options):
if not messages or not messages[0]:
continue
# `funcname` here will be `__` which is our translation function. We
# have to convert it back to usual function names
funcname = "gettext"
if isinstance(messages, tuple):
if len(messages) == 3 and messages[2]:
funcname = "pgettext"
messages = (messages[2], messages[0])
else:
messages = messages[0]
yield lineno, funcname, messages, []
def extract_javascript(code, keywords=("__",), options=None):
"""Extract messages from JavaScript source code.
This is a modified version of babel's JS parser. Reused under BSD license.
License: https://github.com/python-babel/babel/blob/master/LICENSE
Changes from upstream:
- Preserve arguments, babel's parser flattened all values in args,
we need order because we use different syntax for translation
which can contain 2nd arg which is array of many values. If
argument is non-primitive type then value is NOT returned in
args.
E.g. __("0", ["1", "2"], "3") -> ("0", None, "3")
- remove comments support
- changed signature to accept string directly.
:param code: code as string
:param keywords: a list of keywords (i.e. function names) that should be
recognized as translation functions
:param options: a dictionary of additional options (optional)
Supported options are:
* `template_string` -- set to false to disable ES6
template string support.
"""
from babel.messages.jslexer import Token, tokenize, unquote_string
if options is None:
options = {}
funcname = message_lineno = None
messages = []
last_argument = None
concatenate_next = False
last_token = None
call_stack = -1
# Tree level = depth inside function call tree
# Example: __("0", ["1", "2"], "3")
# Depth __()
# / | \
# 0 "0" [...] "3" <- only 0th level strings matter
# / \
# 1 "1" "2"
tree_level = 0
opening_operators = {"[", "{"}
closing_operators = {"]", "}"}
all_container_operators = opening_operators.union(closing_operators)
dotted = any("." in kw for kw in keywords)
for token in tokenize(
code,
jsx=True,
template_string=options.get("template_string", True),
dotted=dotted,
):
if ( # Turn keyword`foo` expressions into keyword("foo") calls:
funcname
and (last_token and last_token.type == "name") # have a keyword...
and token.type # we've seen nothing after the keyword...
== "template_string" # this is a template string
):
message_lineno = token.lineno
messages = [unquote_string(token.value)]
call_stack = 0
tree_level = 0
token = Token("operator", ")", token.lineno)
if token.type == "operator" and token.value == "(":
if funcname:
message_lineno = token.lineno
call_stack += 1
elif call_stack >= 0 and token.type == "operator" and token.value in all_container_operators:
if token.value in opening_operators:
tree_level += 1
if token.value in closing_operators:
tree_level -= 1
elif call_stack == -1 and token.type == "linecomment" or token.type == "multilinecomment":
pass # ignore comments
elif funcname and call_stack == 0:
if token.type == "operator" and token.value == ")":
if last_argument is not None:
messages.append(last_argument)
if len(messages) > 1:
messages = tuple(messages)
elif messages:
messages = messages[0]
else:
messages = None
if messages is not None:
yield (message_lineno, funcname, messages)
funcname = message_lineno = last_argument = None
concatenate_next = False
messages = []
call_stack = -1
tree_level = 0
elif token.type in ("string", "template_string"):
new_value = unquote_string(token.value)
if tree_level > 0:
pass
elif concatenate_next:
last_argument = (last_argument or "") + new_value
concatenate_next = False
else:
last_argument = new_value
elif token.type == "operator":
if token.value == ",":
if last_argument is not None:
messages.append(last_argument)
last_argument = None
else:
if tree_level == 0:
messages.append(None)
concatenate_next = False
elif token.value == "+":
concatenate_next = True
elif call_stack > 0 and token.type == "operator" and token.value == ")":
call_stack -= 1
tree_level = 0
elif funcname and call_stack == -1:
funcname = None
elif (
call_stack == -1
and token.type == "name"
and token.value in keywords
and (last_token is None or last_token.type != "name" or last_token.value != "function")
):
funcname = token.value
last_token = token

View file

@ -0,0 +1,11 @@
from jinja2.ext import babel_extract
def extract(*args, **kwargs):
"""Reuse the babel_extract function from jinja2.ext, but handle our own implementation of `_()`"""
for lineno, funcname, messages, comments in babel_extract(*args, **kwargs):
if funcname == "_" and isinstance(messages, tuple) and len(messages) > 1:
funcname = "pgettext"
messages = (messages[-1], messages[0]) # (context, message)
yield lineno, funcname, messages, comments

View file

@ -0,0 +1,30 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from Module Onboarding JSON files.
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Module Onboarding":
return
onboarding_name = data.get("name")
if title := data.get("title"):
yield None, "_", title, [f"Title of the Module Onboarding '{onboarding_name}'"]
if subtitle := data.get("subtitle"):
yield None, "_", subtitle, [f"Subtitle of the Module Onboarding '{onboarding_name}'"]
if success_message := data.get("success_message"):
yield None, "_", success_message, [
f"Success message of the Module Onboarding '{onboarding_name}'"
]

View file

@ -0,0 +1,41 @@
import importlib
from pathlib import Path
from frappe.utils import get_bench_path
def extract(fileobj, *args, **kwargs):
"""Extract standard navbar and help items from a python file.
:param fileobj: file-like object to extract messages from. Should be a
python file containing two global variables `standard_navbar_items` and
`standard_help_items` which are lists of dicts.
"""
module = get_module(fileobj.name)
if hasattr(module, "standard_navbar_items"):
standard_navbar_items = getattr(module, "standard_navbar_items")
for nav_item in standard_navbar_items:
if label := nav_item.get("item_label"):
item_type = nav_item.get("item_type")
yield None, "_", label, [
"Label of a standard navbar item",
f"Type: {item_type}",
]
if hasattr(module, "standard_help_items"):
standard_help_items = getattr(module, "standard_help_items")
for help_item in standard_help_items:
if label := help_item.get("item_label"):
item_type = nav_item.get("item_type")
yield None, "_", label, [
"Label of a standard help item",
f"Type: {item_type}",
]
def get_module(path):
_path = Path(path)
rel_path = _path.relative_to(get_bench_path())
import_path = ".".join(rel_path.parts[2:]).rstrip(".py")
return importlib.import_module(import_path)

View file

@ -0,0 +1,32 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from Onboarding Step JSON files.
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Onboarding Step":
return
step_title = data.get("title")
yield None, "_", step_title, ["Title of an Onboarding Step"]
if action_label := data.get("action_label"):
yield None, "_", action_label, [f"Label of an action in the Onboarding Step '{step_title}'"]
if description := data.get("description"):
yield None, "_", description, [f"Description of the Onboarding Step '{step_title}'"]
if report_description := data.get("report_description"):
yield None, "_", report_description, [
f"Description of a report in the Onboarding Step '{step_title}'"
]

View file

@ -0,0 +1,13 @@
from babel.messages.extract import extract_python
def extract(*args, **kwargs):
"""
Wrapper around babel's `extract_python`, handling our own implementation of `_()`
"""
for lineno, funcname, messages, comments in extract_python(*args, **kwargs):
if funcname == "_" and isinstance(messages, tuple) and len(messages) > 1:
funcname = "pgettext"
messages = (messages[-1], messages[0]) # (context, message)
yield lineno, funcname, messages, comments

View file

@ -0,0 +1,18 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from report JSON files. To be used to babel extractor
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Report":
return
yield None, "_", data.get("report_name"), ["Name of a report"]

View file

@ -0,0 +1,42 @@
import json
def extract(fileobj, *args, **kwargs):
"""
Extract messages from DocType JSON files. To be used to babel extractor
:param fileobj: the file-like object the messages should be extracted from
:rtype: `iterator`
"""
data = json.load(fileobj)
if isinstance(data, list):
return
if data.get("doctype") != "Workspace":
return
workspace_name = data.get("label")
yield None, "_", workspace_name, ["Name of a Workspace"]
yield from (
(None, "_", chart.get("label"), [f"Label of a chart in the {workspace_name} Workspace"])
for chart in data.get("charts", [])
)
yield from (
(
None,
"pgettext",
(link.get("link_to") if link.get("link_type") == "DocType" else None, link.get("label")),
[f"Label of a {link.get('type')} in the {workspace_name} Workspace"],
)
for link in data.get("links", [])
)
yield from (
(
None,
"pgettext",
(shortcut.get("link_to") if shortcut.get("type") == "DocType" else None, shortcut.get("label")),
[f"Label of a shortcut in the {workspace_name} Workspace"],
)
for shortcut in data.get("shortcuts", [])
)

View file

@ -0,0 +1,64 @@
from frappe.gettext.translate import (
generate_pot,
get_method_map,
get_mo_path,
get_po_path,
get_pot_path,
new_catalog,
new_po,
write_binary,
write_catalog,
)
from frappe.tests.utils import FrappeTestCase
class TestTranslate(FrappeTestCase):
def setUp(self):
pass
def tearDown(self):
pass
def test_generate_pot(self):
pot_path = get_pot_path("frappe")
pot_path.unlink(missing_ok=True)
generate_pot("frappe")
self.assertTrue(pot_path.exists())
self.assertIn("msgid", pot_path.read_text())
def test_write_catalog(self):
po_path = get_po_path("frappe", "test")
po_path.unlink(missing_ok=True)
catalog = new_catalog("frappe", "test")
write_catalog("frappe", catalog, "test")
self.assertTrue(po_path.exists())
self.assertIn("msgid", po_path.read_text())
def test_write_binary(self):
mo_path = get_mo_path("frappe", "test")
mo_path.unlink(missing_ok=True)
catalog = new_catalog("frappe", "test")
write_binary("frappe", catalog, "test")
self.assertTrue(mo_path.exists())
def test_get_method_map(self):
method_map = get_method_map("frappe")
self.assertTrue(len(method_map) > 0)
self.assertTrue(len(method_map[0]) == 2)
self.assertTrue(isinstance(method_map[0][0], str))
self.assertTrue(isinstance(method_map[0][1], str))
def test_new_po(self):
po_path = get_po_path("frappe", "test")
po_path.unlink(missing_ok=True)
new_po("test", target_app="frappe")
self.assertTrue(po_path.exists())
self.assertIn("msgid", po_path.read_text())

306
frappe/gettext/translate.py Normal file
View file

@ -0,0 +1,306 @@
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 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)
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")
for app in apps:
app_path = frappe.get_pymodule_path(app)
catalog = get_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
):
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.json, 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,))
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("%", "&#37;")

View file

@ -453,6 +453,83 @@ extend_bootinfo = [
export_python_type_annotations = True
standard_navbar_items = [
{
"item_label": "My Profile",
"item_type": "Route",
"route": "/app/user-profile",
"is_standard": 1,
},
{
"item_label": "My Settings",
"item_type": "Action",
"action": "frappe.ui.toolbar.route_to_user()",
"is_standard": 1,
},
{
"item_label": "Session Defaults",
"item_type": "Action",
"action": "frappe.ui.toolbar.setup_session_defaults()",
"is_standard": 1,
},
{
"item_label": "Reload",
"item_type": "Action",
"action": "frappe.ui.toolbar.clear_cache()",
"is_standard": 1,
},
{
"item_label": "View Website",
"item_type": "Action",
"action": "frappe.ui.toolbar.view_website()",
"is_standard": 1,
},
{
"item_label": "Toggle Full Width",
"item_type": "Action",
"action": "frappe.ui.toolbar.toggle_full_width()",
"is_standard": 1,
},
{
"item_label": "Toggle Theme",
"item_type": "Action",
"action": "new frappe.ui.ThemeSwitcher().show()",
"is_standard": 1,
},
{
"item_type": "Separator",
"is_standard": 1,
"item_label": "",
},
{
"item_label": "Log out",
"item_type": "Action",
"action": "frappe.app.logout()",
"is_standard": 1,
},
]
standard_help_items = [
{
"item_label": "About",
"item_type": "Action",
"action": "frappe.ui.toolbar.show_about()",
"is_standard": 1,
},
{
"item_label": "Keyboard Shortcuts",
"item_type": "Action",
"action": "frappe.ui.toolbar.show_shortcuts(event)",
"is_standard": 1,
},
{
"item_label": "Frappe Support",
"item_type": "Route",
"route": "https://frappe.io/support",
"is_standard": 1,
},
]
# log doctype cleanups to automatically add in log settings
default_log_clearing_doctypes = {
"Error Log": 30,

38588
frappe/locale/af.po Normal file

File diff suppressed because it is too large Load diff

38311
frappe/locale/am.po Normal file

File diff suppressed because it is too large Load diff

38437
frappe/locale/ar.po Normal file

File diff suppressed because it is too large Load diff

38619
frappe/locale/bg.po Normal file

File diff suppressed because it is too large Load diff

38539
frappe/locale/bn.po Normal file

File diff suppressed because it is too large Load diff

38545
frappe/locale/bs.po Normal file

File diff suppressed because it is too large Load diff

38711
frappe/locale/ca.po Normal file

File diff suppressed because it is too large Load diff

38537
frappe/locale/cs.po Normal file

File diff suppressed because it is too large Load diff

38275
frappe/locale/cz.po Normal file

File diff suppressed because it is too large Load diff

38526
frappe/locale/da.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/da_DK.po Normal file

File diff suppressed because it is too large Load diff

38728
frappe/locale/de.po Normal file

File diff suppressed because it is too large Load diff

38767
frappe/locale/el.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/en_GB.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/en_US.po Normal file

File diff suppressed because it is too large Load diff

38706
frappe/locale/es.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/es_AR.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/es_BO.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/es_CL.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/es_DO.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/es_EC.po Normal file

File diff suppressed because it is too large Load diff

38200
frappe/locale/es_MX.po Normal file

File diff suppressed because it is too large Load diff

38196
frappe/locale/es_NI.po Normal file

File diff suppressed because it is too large Load diff

38258
frappe/locale/es_PE.po Normal file

File diff suppressed because it is too large Load diff

38496
frappe/locale/et.po Normal file

File diff suppressed because it is too large Load diff

38486
frappe/locale/fa.po Normal file

File diff suppressed because it is too large Load diff

38525
frappe/locale/fi.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/fil.po Normal file

File diff suppressed because it is too large Load diff

38767
frappe/locale/fr.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/fr_CA.po Normal file

File diff suppressed because it is too large Load diff

38503
frappe/locale/gu.po Normal file

File diff suppressed because it is too large Load diff

38366
frappe/locale/he.po Normal file

File diff suppressed because it is too large Load diff

38518
frappe/locale/hi.po Normal file

File diff suppressed because it is too large Load diff

38559
frappe/locale/hr.po Normal file

File diff suppressed because it is too large Load diff

38641
frappe/locale/hu.po Normal file

File diff suppressed because it is too large Load diff

38569
frappe/locale/id.po Normal file

File diff suppressed because it is too large Load diff

38543
frappe/locale/is.po Normal file

File diff suppressed because it is too large Load diff

38671
frappe/locale/it.po Normal file

File diff suppressed because it is too large Load diff

38235
frappe/locale/ja.po Normal file

File diff suppressed because it is too large Load diff

38472
frappe/locale/km.po Normal file

File diff suppressed because it is too large Load diff

38584
frappe/locale/kn.po Normal file

File diff suppressed because it is too large Load diff

38265
frappe/locale/ko.po Normal file

File diff suppressed because it is too large Load diff

38549
frappe/locale/ku.po Normal file

File diff suppressed because it is too large Load diff

38505
frappe/locale/lo.po Normal file

File diff suppressed because it is too large Load diff

38575
frappe/locale/lt.po Normal file

File diff suppressed because it is too large Load diff

38535
frappe/locale/lv.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/main.pot Normal file

File diff suppressed because it is too large Load diff

38611
frappe/locale/mk.po Normal file

File diff suppressed because it is too large Load diff

38721
frappe/locale/ml.po Normal file

File diff suppressed because it is too large Load diff

38497
frappe/locale/mr.po Normal file

File diff suppressed because it is too large Load diff

38590
frappe/locale/ms.po Normal file

File diff suppressed because it is too large Load diff

38667
frappe/locale/my.po Normal file

File diff suppressed because it is too large Load diff

38615
frappe/locale/nl.po Normal file

File diff suppressed because it is too large Load diff

38520
frappe/locale/no.po Normal file

File diff suppressed because it is too large Load diff

38598
frappe/locale/pl.po Normal file

File diff suppressed because it is too large Load diff

38474
frappe/locale/ps.po Normal file

File diff suppressed because it is too large Load diff

38604
frappe/locale/pt.po Normal file

File diff suppressed because it is too large Load diff

38601
frappe/locale/pt_BR.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/quc.po Normal file

File diff suppressed because it is too large Load diff

38659
frappe/locale/ro.po Normal file

File diff suppressed because it is too large Load diff

38639
frappe/locale/ru.po Normal file

File diff suppressed because it is too large Load diff

38572
frappe/locale/rw.po Normal file

File diff suppressed because it is too large Load diff

38194
frappe/locale/se.po Normal file

File diff suppressed because it is too large Load diff

38515
frappe/locale/si.po Normal file

File diff suppressed because it is too large Load diff

38566
frappe/locale/sk.po Normal file

File diff suppressed because it is too large Load diff

38536
frappe/locale/sl.po Normal file

File diff suppressed because it is too large Load diff

38653
frappe/locale/sq.po Normal file

File diff suppressed because it is too large Load diff

38536
frappe/locale/sr.po Normal file

File diff suppressed because it is too large Load diff

38196
frappe/locale/sr_BA.po Normal file

File diff suppressed because it is too large Load diff

38196
frappe/locale/sr_SP.po Normal file

File diff suppressed because it is too large Load diff

38526
frappe/locale/sv.po Normal file

File diff suppressed because it is too large Load diff

38600
frappe/locale/sw.po Normal file

File diff suppressed because it is too large Load diff

38649
frappe/locale/ta.po Normal file

File diff suppressed because it is too large Load diff

38544
frappe/locale/te.po Normal file

File diff suppressed because it is too large Load diff

38415
frappe/locale/th.po Normal file

File diff suppressed because it is too large Load diff

38510
frappe/locale/tr.po Normal file

File diff suppressed because it is too large Load diff

38625
frappe/locale/uk.po Normal file

File diff suppressed because it is too large Load diff

38542
frappe/locale/ur.po Normal file

File diff suppressed because it is too large Load diff

38747
frappe/locale/uz.po Normal file

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more