2021-10-22 15:09:44 +03:00
|
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
import json
|
|
|
|
import re
|
|
|
|
from functools import wraps
|
|
|
|
from hashlib import md5
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
from django.core.cache import caches
|
|
|
|
from django.utils.encoding import force_bytes
|
|
|
|
from django.utils.functional import cached_property, lazy
|
|
|
|
|
2020-06-25 18:54:36 +03:00
|
|
|
from fluent.runtime import FluentLocalization, FluentResource
|
2019-09-06 23:53:00 +03:00
|
|
|
from fluent.syntax.ast import GroupComment, Message
|
|
|
|
|
|
|
|
from lib.l10n_utils import translation
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
"fluent_l10n",
|
|
|
|
"ftl",
|
|
|
|
"ftl_file_is_active",
|
|
|
|
"ftl_has_messages",
|
|
|
|
"ftl_lazy",
|
|
|
|
"get_metadata_file_path",
|
|
|
|
"has_messages",
|
|
|
|
"translate",
|
|
|
|
]
|
2019-11-14 00:15:10 +03:00
|
|
|
cache = caches["fluent"]
|
2019-09-06 23:53:00 +03:00
|
|
|
REQUIRED_RE = re.compile(r"^required\b", re.MULTILINE | re.IGNORECASE)
|
2020-06-25 18:54:36 +03:00
|
|
|
TERM_RE = re.compile(r"\{\s*-[a-z-]+\s*\}")
|
2019-09-06 23:53:00 +03:00
|
|
|
|
|
|
|
|
|
|
|
class FluentL10n(FluentLocalization):
|
|
|
|
def _localized_bundles(self):
|
|
|
|
for bundle in self._bundles():
|
|
|
|
if bundle.locales[0] == self.locales[0]:
|
|
|
|
yield bundle
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def _message_ids(self):
|
|
|
|
messages = set()
|
|
|
|
for bundle in self._bundles():
|
|
|
|
messages.update(bundle._messages.keys())
|
|
|
|
|
|
|
|
return list(messages)
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def _localized_message_ids(self):
|
|
|
|
messages = set()
|
|
|
|
for bundle in self._localized_bundles():
|
|
|
|
messages.update(bundle._messages.keys())
|
|
|
|
|
|
|
|
return list(messages)
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def required_message_ids(self):
|
|
|
|
"""
|
|
|
|
Look in the "en" file for message IDs grouped by a comment that starts with "Required"
|
|
|
|
|
|
|
|
:return: list of message IDs
|
|
|
|
"""
|
|
|
|
messages = set()
|
|
|
|
for resources in self.resource_loader.resources("en", self.resource_ids):
|
|
|
|
for resource in resources:
|
|
|
|
in_required = False
|
|
|
|
for item in resource.body:
|
|
|
|
if isinstance(item, GroupComment):
|
|
|
|
in_required = REQUIRED_RE.search(item.content)
|
|
|
|
continue
|
|
|
|
|
|
|
|
if isinstance(item, Message) and in_required:
|
|
|
|
messages.add(item.id.name)
|
|
|
|
|
|
|
|
return list(messages)
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def has_required_messages(self):
|
|
|
|
return all(self.has_message(m) for m in self.required_message_ids)
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def active_locales(self):
|
|
|
|
# first resource is the one to check for activation
|
|
|
|
return get_active_locales(self.resource_ids[0])
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def percent_translated(self):
|
2020-02-14 19:59:23 +03:00
|
|
|
if not self._message_ids:
|
|
|
|
return 0
|
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
return (float(len(self._localized_message_ids)) / float(len(self._message_ids))) * 100
|
|
|
|
|
|
|
|
def has_message(self, message_id):
|
|
|
|
# assume English locales have the message
|
|
|
|
if self.locales[0].startswith("en-"):
|
|
|
|
return True
|
|
|
|
|
|
|
|
return message_id in self._localized_message_ids
|
|
|
|
|
|
|
|
|
2020-06-25 18:54:36 +03:00
|
|
|
class FluentResourceLoader:
|
|
|
|
"""A resource loader that will add english brand terms to every bundle"""
|
2021-09-30 19:44:54 +03:00
|
|
|
|
2020-06-25 18:54:36 +03:00
|
|
|
@staticmethod
|
|
|
|
def resources(locale, resource_ids):
|
|
|
|
for root in settings.FLUENT_PATHS:
|
2020-07-06 17:21:01 +03:00
|
|
|
resources = load_fluent_resources(root, locale, resource_ids)
|
2020-06-25 18:54:36 +03:00
|
|
|
if resources:
|
|
|
|
yield resources
|
|
|
|
|
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
def _cache_key(*args, **kwargs):
|
|
|
|
key = f"fluent:{args}:{kwargs}"
|
|
|
|
return md5(force_bytes(key)).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
def memoize(f):
|
|
|
|
"""Decorator to cache the results of expensive functions"""
|
2021-09-30 19:44:54 +03:00
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
@wraps(f)
|
|
|
|
def inner(*args, **kwargs):
|
|
|
|
key = _cache_key(f.__name__, *args, **kwargs)
|
|
|
|
value = cache.get(key)
|
|
|
|
if value is None:
|
|
|
|
value = f(*args, **kwargs)
|
|
|
|
cache.set(key, value)
|
|
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
return inner
|
|
|
|
|
|
|
|
|
|
|
|
def l10nize(f):
|
|
|
|
"""Decorator to create and pass in the l10n object"""
|
2021-09-30 19:44:54 +03:00
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
@wraps(f)
|
2020-04-06 20:13:02 +03:00
|
|
|
def inner(*args, **kwargs):
|
|
|
|
ftl_files = kwargs.get("ftl_files", [])
|
|
|
|
if isinstance(ftl_files, str):
|
|
|
|
ftl_files = [ftl_files]
|
|
|
|
elif isinstance(ftl_files, tuple):
|
|
|
|
ftl_files = list(ftl_files)
|
|
|
|
|
|
|
|
# can not use += here because that mutates the original list
|
|
|
|
ftl_files = ftl_files + settings.FLUENT_DEFAULT_FILES
|
2019-09-06 23:53:00 +03:00
|
|
|
locale = kwargs.get("locale") or translation.get_language(True)
|
2022-12-07 21:55:38 +03:00
|
|
|
locales = [locale]
|
|
|
|
if locale != "en":
|
|
|
|
locales.append("en")
|
|
|
|
l10n = fluent_l10n(locales, ftl_files)
|
2019-09-06 23:53:00 +03:00
|
|
|
return f(l10n, *args, **kwargs)
|
|
|
|
|
|
|
|
return inner
|
|
|
|
|
|
|
|
|
2020-07-06 17:21:01 +03:00
|
|
|
@memoize
|
|
|
|
def load_fluent_resources(root, locale, resource_ids):
|
|
|
|
resources = []
|
|
|
|
for resource_id in resource_ids:
|
|
|
|
path = root.joinpath(locale, resource_id)
|
|
|
|
if not path.is_file():
|
|
|
|
continue
|
|
|
|
|
|
|
|
resources.append(load_fluent_file(path))
|
|
|
|
if resources:
|
|
|
|
if locale != "en":
|
|
|
|
path = settings.FLUENT_LOCAL_PATH.joinpath("en", "brands.ftl")
|
|
|
|
resources.append(load_fluent_file(path))
|
|
|
|
return resources
|
|
|
|
|
|
|
|
|
|
|
|
@memoize
|
|
|
|
def load_fluent_file(path):
|
|
|
|
with path.open(encoding="utf-8") as ftl_file:
|
|
|
|
return FluentResource(ftl_file.read())
|
|
|
|
|
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
def get_metadata_file_path(ftl_file):
|
|
|
|
return settings.FLUENT_REPO_PATH.joinpath("metadata", ftl_file).with_suffix(".json")
|
|
|
|
|
|
|
|
|
|
|
|
def get_metadata(ftl_file):
|
|
|
|
path = get_metadata_file_path(ftl_file)
|
|
|
|
try:
|
|
|
|
with path.open() as mdf:
|
|
|
|
return json.load(mdf)
|
2022-01-11 18:21:16 +03:00
|
|
|
except (OSError, ValueError):
|
2019-09-06 23:53:00 +03:00
|
|
|
return {}
|
|
|
|
|
|
|
|
|
2020-02-14 19:59:23 +03:00
|
|
|
def write_metadata(ftl_file, data):
|
|
|
|
metadata_path = get_metadata_file_path(ftl_file)
|
|
|
|
if not metadata_path.exists():
|
|
|
|
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
with metadata_path.open("w") as mdf:
|
|
|
|
json.dump(data, mdf, indent=2, sort_keys=True)
|
|
|
|
|
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
@memoize
|
2020-08-03 21:00:00 +03:00
|
|
|
def get_active_locales(ftl_files, force=False):
|
2020-04-22 16:56:15 +03:00
|
|
|
"""Return the list of active locales for a Fluent file.
|
|
|
|
|
|
|
|
If `settings.DEV` is `True` it will just return the full list of
|
|
|
|
available languages. You can pass `force=True` to override this
|
|
|
|
behavior.
|
|
|
|
"""
|
Lay in Pocket l10n workflow support (#11576)
* [Tiny] Improve error messaging if git repo interaction fails
* Override the Fluent repo-related config in Pocket mode
Note that for Pocket mode, we don't need an intermediary mozmeao/* repo to
hold the translations while we calculate activation metadata via a CI task
(which does happen for Mozorg). Why? All locales in Pocket should be 100%
ready to go, because they are translated by a vendor, whereas Mozorg has
community translation contributions as well, which aren't always exhaustive.
As a result, both FLUENT_REPO and FLUENT_L10N_TEAM_REPO point to the same repo
* Update Bedrock CI step to try to open a PR against the l10n repo
...if there are changes to the l10n files.
Note that we try to update both www-l10n (Mozorg) and pocket-www-l10n (Pocket)
each time it's run, with the plan that if it's run against an irrelevant repo
no changes will be detected, even though it's running twice
(This may need tweaking once it's running in CI properly, of course)
* Update settings.get_dev_languages to take params, to make re-use elsewhere easier
* Initial pass at setting up DEV and PROD languages for Pocket mode
* Ensure Pocket always uses all available production langs
All locales in Pocket should be 100% ready to go, because they are
translated by a vendor, whereas Mozorg has community translation
contributions as well, which aren't always exhaustive.
* Wind back "Update settings.get_dev_languages to take params, to make re-use elsewhere easier"
This reverts some of the work done in c653640ae56cf21e961c461a421d6dd5fa54efcd.
* Make DEV_LANGUAGES the same as PROD_LANGUAGES as there's no need not to
* Ensure Pocket-related l10n files are copied into Dockerimage during build
* Make the l10n_update command pull from both Mozorg and Pocket Fluent repos
* Split out pocket-l10n PR step in CI, so that Mozorg and Pocket steps run separately
* Update LANGUAGE_CODE for Pocket mode to be just 'en'
* Update output messaging to help keep track of FTL updates
* Nitfix: make env setting in Docker consistent
* Tidy up l10n-related settings following code review
* Nit: updating comments
* Fixup: Add quotes around bash variable substitution
* Tweak locale fallbacks
* Update locale setup for Spanish, but more work needed
Just noting some complexity to unpick here. getpocket.com/es-la/ exists, and
so does getpocket.com/es/. However it is sounding like /es/ needs to use
`es-ES` content from the vendor and /es-la/ needs to use `es` from the vendor.
Also note that Pocket currently uses `/es-la/` not `/es-LA/`
This all may require some extra wiring, as it feels like it's flipping over
the behaviour we have in bedrock.
* Move non-official es-la locale to FALLBACK_LOCALES
* Drop es-MX, es-CL, es-AR from FALLBACK_LOCALES - unnecessary
If the language_REGION locale string doesn't map to a specific locale, we try
to map the two-letter locale, so we don't need the variant locales for es.
* Drop unnecessary inclusion of en-* locale variants -- they'll fall back to /en/ fine without config
2022-05-11 13:32:38 +03:00
|
|
|
if settings.IS_POCKET_MODE:
|
|
|
|
# All locales in Pocket should be 100% ready to go, because they are
|
|
|
|
# translated by a vendor, so we can just use Prod languages all the time
|
|
|
|
return settings.PROD_LANGUAGES
|
|
|
|
|
2020-04-22 16:56:15 +03:00
|
|
|
if settings.DEV and not force:
|
|
|
|
return settings.DEV_LANGUAGES
|
|
|
|
|
2020-08-03 21:00:00 +03:00
|
|
|
if isinstance(ftl_files, str):
|
|
|
|
ftl_files = [ftl_files]
|
|
|
|
|
2019-09-06 23:53:00 +03:00
|
|
|
locales = {settings.LANGUAGE_CODE}
|
2020-08-03 21:00:00 +03:00
|
|
|
for ftl_file in ftl_files:
|
|
|
|
file_locales = set()
|
|
|
|
metadata = get_metadata(ftl_file)
|
|
|
|
if metadata and "active_locales" in metadata:
|
|
|
|
file_locales.update(metadata["active_locales"])
|
|
|
|
i_locales = metadata.get("inactive_locales")
|
|
|
|
if i_locales:
|
|
|
|
file_locales.difference_update(i_locales)
|
|
|
|
|
|
|
|
locales.update(file_locales)
|
2019-09-06 23:53:00 +03:00
|
|
|
|
|
|
|
return sorted(locales)
|
|
|
|
|
|
|
|
|
|
|
|
def ftl_file_is_active(ftl_file, locale=None):
|
|
|
|
"""Return True if the given FTL file is active in the given locale."""
|
|
|
|
locale = locale or translation.get_language(True)
|
|
|
|
return locale in get_active_locales(ftl_file)
|
|
|
|
|
|
|
|
|
|
|
|
def fluent_l10n(locales, files):
|
|
|
|
if isinstance(locales, str):
|
|
|
|
locales = [locales]
|
|
|
|
|
|
|
|
# file IDs may not have file extension
|
|
|
|
files = [f"{f}.ftl" if not f.endswith(".ftl") else f for f in files]
|
2020-06-25 18:54:36 +03:00
|
|
|
return FluentL10n(locales, files, FluentResourceLoader)
|
2019-09-06 23:53:00 +03:00
|
|
|
|
|
|
|
|
|
|
|
def ftl_has_messages(l10n, *message_ids, require_all=True):
|
|
|
|
test = all if require_all else any
|
|
|
|
return test([l10n.has_message(mid) for mid in message_ids])
|
|
|
|
|
|
|
|
|
|
|
|
def translate(l10n, message_id, fallback=None, **kwargs):
|
|
|
|
# check the `locale` bundle for the message if we have a fallback defined
|
|
|
|
if fallback and not l10n.has_message(message_id):
|
|
|
|
message_id = fallback
|
|
|
|
|
|
|
|
return l10n.format_value(message_id, kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
# View Utils
|
|
|
|
|
|
|
|
# for use in python views
|
|
|
|
has_messages = l10nize(ftl_has_messages)
|
|
|
|
ftl = l10nize(translate)
|
|
|
|
|
|
|
|
# for use in python outside of a view
|
|
|
|
ftl_lazy = lazy(ftl, str)
|