Merge pull request #22732 from barredterra/po-translation
feat: translations via gettext
This commit is contained in:
commit
a77093d6f6
185 changed files with 3155541 additions and 304175 deletions
9
babel_extractors.csv
Normal file
9
babel_extractors.csv
Normal 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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
|||
98
frappe/commands/gettext.py
Normal file
98
frappe/commands/gettext.py
Normal 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,
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
2
frappe/gettext/extractors/README.md
Normal file
2
frappe/gettext/extractors/README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Extractors should run on source files only.
|
||||
They should not depend on an acitive web server or database connection.
|
||||
60
frappe/gettext/extractors/doctype.py
Normal file
60
frappe/gettext/extractors/doctype.py
Normal 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
|
||||
)
|
||||
163
frappe/gettext/extractors/javascript.py
Normal file
163
frappe/gettext/extractors/javascript.py
Normal 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
|
||||
11
frappe/gettext/extractors/jinja2.py
Normal file
11
frappe/gettext/extractors/jinja2.py
Normal 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
|
||||
30
frappe/gettext/extractors/module_onboarding.py
Normal file
30
frappe/gettext/extractors/module_onboarding.py
Normal 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}'"
|
||||
]
|
||||
41
frappe/gettext/extractors/navbar.py
Normal file
41
frappe/gettext/extractors/navbar.py
Normal 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)
|
||||
32
frappe/gettext/extractors/onboarding_step.py
Normal file
32
frappe/gettext/extractors/onboarding_step.py
Normal 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}'"
|
||||
]
|
||||
13
frappe/gettext/extractors/python.py
Normal file
13
frappe/gettext/extractors/python.py
Normal 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
|
||||
18
frappe/gettext/extractors/report.py
Normal file
18
frappe/gettext/extractors/report.py
Normal 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"]
|
||||
42
frappe/gettext/extractors/workspace.py
Normal file
42
frappe/gettext/extractors/workspace.py
Normal 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", [])
|
||||
)
|
||||
64
frappe/gettext/test_translate.py
Normal file
64
frappe/gettext/test_translate.py
Normal 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
306
frappe/gettext/translate.py
Normal 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("%", "%")
|
||||
|
|
@ -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
38588
frappe/locale/af.po
Normal file
File diff suppressed because it is too large
Load diff
38311
frappe/locale/am.po
Normal file
38311
frappe/locale/am.po
Normal file
File diff suppressed because it is too large
Load diff
38437
frappe/locale/ar.po
Normal file
38437
frappe/locale/ar.po
Normal file
File diff suppressed because it is too large
Load diff
38619
frappe/locale/bg.po
Normal file
38619
frappe/locale/bg.po
Normal file
File diff suppressed because it is too large
Load diff
38539
frappe/locale/bn.po
Normal file
38539
frappe/locale/bn.po
Normal file
File diff suppressed because it is too large
Load diff
38545
frappe/locale/bs.po
Normal file
38545
frappe/locale/bs.po
Normal file
File diff suppressed because it is too large
Load diff
38711
frappe/locale/ca.po
Normal file
38711
frappe/locale/ca.po
Normal file
File diff suppressed because it is too large
Load diff
38537
frappe/locale/cs.po
Normal file
38537
frappe/locale/cs.po
Normal file
File diff suppressed because it is too large
Load diff
38275
frappe/locale/cz.po
Normal file
38275
frappe/locale/cz.po
Normal file
File diff suppressed because it is too large
Load diff
38526
frappe/locale/da.po
Normal file
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
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
38728
frappe/locale/de.po
Normal file
File diff suppressed because it is too large
Load diff
38767
frappe/locale/el.po
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
38496
frappe/locale/et.po
Normal file
File diff suppressed because it is too large
Load diff
38486
frappe/locale/fa.po
Normal file
38486
frappe/locale/fa.po
Normal file
File diff suppressed because it is too large
Load diff
38525
frappe/locale/fi.po
Normal file
38525
frappe/locale/fi.po
Normal file
File diff suppressed because it is too large
Load diff
38194
frappe/locale/fil.po
Normal file
38194
frappe/locale/fil.po
Normal file
File diff suppressed because it is too large
Load diff
38767
frappe/locale/fr.po
Normal file
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
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
38503
frappe/locale/gu.po
Normal file
File diff suppressed because it is too large
Load diff
38366
frappe/locale/he.po
Normal file
38366
frappe/locale/he.po
Normal file
File diff suppressed because it is too large
Load diff
38518
frappe/locale/hi.po
Normal file
38518
frappe/locale/hi.po
Normal file
File diff suppressed because it is too large
Load diff
38559
frappe/locale/hr.po
Normal file
38559
frappe/locale/hr.po
Normal file
File diff suppressed because it is too large
Load diff
38641
frappe/locale/hu.po
Normal file
38641
frappe/locale/hu.po
Normal file
File diff suppressed because it is too large
Load diff
38569
frappe/locale/id.po
Normal file
38569
frappe/locale/id.po
Normal file
File diff suppressed because it is too large
Load diff
38543
frappe/locale/is.po
Normal file
38543
frappe/locale/is.po
Normal file
File diff suppressed because it is too large
Load diff
38671
frappe/locale/it.po
Normal file
38671
frappe/locale/it.po
Normal file
File diff suppressed because it is too large
Load diff
38235
frappe/locale/ja.po
Normal file
38235
frappe/locale/ja.po
Normal file
File diff suppressed because it is too large
Load diff
38472
frappe/locale/km.po
Normal file
38472
frappe/locale/km.po
Normal file
File diff suppressed because it is too large
Load diff
38584
frappe/locale/kn.po
Normal file
38584
frappe/locale/kn.po
Normal file
File diff suppressed because it is too large
Load diff
38265
frappe/locale/ko.po
Normal file
38265
frappe/locale/ko.po
Normal file
File diff suppressed because it is too large
Load diff
38549
frappe/locale/ku.po
Normal file
38549
frappe/locale/ku.po
Normal file
File diff suppressed because it is too large
Load diff
38505
frappe/locale/lo.po
Normal file
38505
frappe/locale/lo.po
Normal file
File diff suppressed because it is too large
Load diff
38575
frappe/locale/lt.po
Normal file
38575
frappe/locale/lt.po
Normal file
File diff suppressed because it is too large
Load diff
38535
frappe/locale/lv.po
Normal file
38535
frappe/locale/lv.po
Normal file
File diff suppressed because it is too large
Load diff
38194
frappe/locale/main.pot
Normal file
38194
frappe/locale/main.pot
Normal file
File diff suppressed because it is too large
Load diff
38611
frappe/locale/mk.po
Normal file
38611
frappe/locale/mk.po
Normal file
File diff suppressed because it is too large
Load diff
38721
frappe/locale/ml.po
Normal file
38721
frappe/locale/ml.po
Normal file
File diff suppressed because it is too large
Load diff
38497
frappe/locale/mr.po
Normal file
38497
frappe/locale/mr.po
Normal file
File diff suppressed because it is too large
Load diff
38590
frappe/locale/ms.po
Normal file
38590
frappe/locale/ms.po
Normal file
File diff suppressed because it is too large
Load diff
38667
frappe/locale/my.po
Normal file
38667
frappe/locale/my.po
Normal file
File diff suppressed because it is too large
Load diff
38615
frappe/locale/nl.po
Normal file
38615
frappe/locale/nl.po
Normal file
File diff suppressed because it is too large
Load diff
38520
frappe/locale/no.po
Normal file
38520
frappe/locale/no.po
Normal file
File diff suppressed because it is too large
Load diff
38598
frappe/locale/pl.po
Normal file
38598
frappe/locale/pl.po
Normal file
File diff suppressed because it is too large
Load diff
38474
frappe/locale/ps.po
Normal file
38474
frappe/locale/ps.po
Normal file
File diff suppressed because it is too large
Load diff
38604
frappe/locale/pt.po
Normal file
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
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
38194
frappe/locale/quc.po
Normal file
File diff suppressed because it is too large
Load diff
38659
frappe/locale/ro.po
Normal file
38659
frappe/locale/ro.po
Normal file
File diff suppressed because it is too large
Load diff
38639
frappe/locale/ru.po
Normal file
38639
frappe/locale/ru.po
Normal file
File diff suppressed because it is too large
Load diff
38572
frappe/locale/rw.po
Normal file
38572
frappe/locale/rw.po
Normal file
File diff suppressed because it is too large
Load diff
38194
frappe/locale/se.po
Normal file
38194
frappe/locale/se.po
Normal file
File diff suppressed because it is too large
Load diff
38515
frappe/locale/si.po
Normal file
38515
frappe/locale/si.po
Normal file
File diff suppressed because it is too large
Load diff
38566
frappe/locale/sk.po
Normal file
38566
frappe/locale/sk.po
Normal file
File diff suppressed because it is too large
Load diff
38536
frappe/locale/sl.po
Normal file
38536
frappe/locale/sl.po
Normal file
File diff suppressed because it is too large
Load diff
38653
frappe/locale/sq.po
Normal file
38653
frappe/locale/sq.po
Normal file
File diff suppressed because it is too large
Load diff
38536
frappe/locale/sr.po
Normal file
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
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
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
38526
frappe/locale/sv.po
Normal file
File diff suppressed because it is too large
Load diff
38600
frappe/locale/sw.po
Normal file
38600
frappe/locale/sw.po
Normal file
File diff suppressed because it is too large
Load diff
38649
frappe/locale/ta.po
Normal file
38649
frappe/locale/ta.po
Normal file
File diff suppressed because it is too large
Load diff
38544
frappe/locale/te.po
Normal file
38544
frappe/locale/te.po
Normal file
File diff suppressed because it is too large
Load diff
38415
frappe/locale/th.po
Normal file
38415
frappe/locale/th.po
Normal file
File diff suppressed because it is too large
Load diff
38510
frappe/locale/tr.po
Normal file
38510
frappe/locale/tr.po
Normal file
File diff suppressed because it is too large
Load diff
38625
frappe/locale/uk.po
Normal file
38625
frappe/locale/uk.po
Normal file
File diff suppressed because it is too large
Load diff
38542
frappe/locale/ur.po
Normal file
38542
frappe/locale/ur.po
Normal file
File diff suppressed because it is too large
Load diff
38747
frappe/locale/uz.po
Normal file
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
Loading…
Add table
Reference in a new issue