commit e0af89b1b14e75b32d6900c9071cec3c1afedf87 parent 50c8f7124e1a6595e0454eeec5677bb3f54e8a3e Author: Henry Wilkes <henry@torproject.org> Date: Thu, 10 Aug 2023 17:54:40 +0100 Tor Browser localization migration scripts. Diffstat:
15 files changed, 1318 insertions(+), 0 deletions(-)
diff --git a/tools/torbrowser/l10n/migrate.py b/tools/torbrowser/l10n/migrate.py @@ -0,0 +1,672 @@ +""" +Migration script for running migration recipes. + +Requires the mozilla fluent.migrate package (available with pip). + +NOTE: This will likely send queries to the weblate API as part of the run. +Weblate may have a query limit of 100 requests per day for unauthorised users. + + +Based on the firefox migration script, adapted for tor browser. See +https://firefox-source-docs.mozilla.org/l10n/migrations/ for more context and +information on how to write the migration recipes. + + +Usage with an example: + +We have some en-US file "updated.ftl" in the tor-browser repository that we have +added new strings to. Some of these strings are copies or concatenations of some +already existing strings in a `.dtd`, `.properties` or `.ftl` file (including +strings within "updated.ftl"). + +So we also write a migration recipe in +`./l10n_migrations/bug-xxxx-my-migration-recipe.py` +which includes a `migrate(ctx)` method. This method takes the old strings and +create new Messages or Terms for "updated.ftl" using `ctx.add_transforms`. These +should match what was already done manually in "updated.ftl". + +Then we copy the en-US file to our local translation repository, say at +`/home/user/translation/en-US/updated.ftl`. The en-US file is needed as a +reference during migration to know where in the new file the new Messages or +Terms should be placed. Moreover, the migration can be verified against the +en-US file to ensure it is working as intended. + +Then we run the migration for individual locales: + +``` + python migrate_l10n.py --translation-git /home/user/translation --locales 'es-ES sr' l10n_migrations.bug-xxxx-my-migration-recipe +``` + +This will first run a mock migration using the en-US localization files. This is +to help ensure that the migration will work as intended. I.e. if we ran the +migration recipe for en-US, using it as both the source of old strings and as a +reference, then each transformation using these old strings should produce a +Message or Term that is identical to the one in the reference file. + +Then this will run `migrate(ctx)` for the es-ES and sr locales. Some of the +transformations will not be completed for a locale if that locale does not +contain a translated version of the old strings the transformation requires +(because there is no string to copy). + +If one of the old strings is from a `.dtd` or `.properties` file, this will +check whether they are marked as translated on weblate using its API. This +prevents copying "placeholder" en-US values into the locale's fluent files. + +Any warnings or errors will be logged to stderr in the console. +""" + +import argparse +import atexit +import importlib +import json +import logging +import os +import sys +import urllib.request + +from fluent.migrate.context import MigrationContext +from fluent.migrate.errors import EmptyLocalizationError, UnreadableReferenceError +from fluent.syntax import ast, parse, serialize + + +def in_red(string): + """ + Wrap a string so that it is shown as red in the console. + """ + return f"\x1b[1;91m{string}\x1b[0m" + + +class WeblateMetadata: + """ + Manages and fetches string metadata from weblate. + """ + + def __init__(self): + self.logger = logging.getLogger("weblate-metadata") + self.logger.setLevel(logging.INFO) + + # The _components property will be lazy-loaded with weblate data. + # + # On weblate, for monolingual formats, the component will have a + # "template" file, which matches the en-US file path in the + # "translation" git repo. So we can find a component through a matching + # template. + # + # Each translation in that component will have a "filename" that will + # match the locale's file path in the "translation" git repo. So we can + # find a translation through a matching filename. + # + # Each translation can be queried for its units, including metadata. + # Each unit's "context" will match the string's id in the corresponding + # file. + # + # Expect the final structure to be: + # { + # template: { + # "translations-url": str, # Used for API translations query. + # "translations": { + # filename: { + # "units-url": str, # Used for API units query. + # "units": { + # context: { + # "translated": bool, + # }, + # }, + # }, + # }, + # }, + # } + self._components = None + + def _get_weblate_response(self, url): + weblate_request = urllib.request.Request( + url, + # NOTE: can set the request header: + # "Authorization": "Token WEBLATE_TOKEN" + # to get around rate-limiting + headers={"User-Agent": ""}, + ) + + with urllib.request.urlopen(weblate_request, timeout=20) as response: + return json.load(response) + + def _get_from_weblate(self, url): + ret = [] + while url: + response = self._get_weblate_response(url) + # Continue to fetch the next page, if it is present. + # Expect the "next" url to be empty + # or the same url with "?page=2", etc. + url = response["next"] + ret.extend(response["results"]) + return ret + + def _get_components(self): + if self._components is None: + self._components = { + comp["template"]: { + "translations": None, + "translations-url": comp["translations_url"], + } + for comp in self._get_from_weblate( + "https://hosted.weblate.org/api/projects/tor/components/" + ) + if comp["template"] + } + return self._components + + def _get_translations(self, template): + component = self._get_components().get(template, None) + if not component: + self.logger.warning(f"No component in weblate for {template}.") + return None + if component["translations"] is None: + component["translations"] = { + trans["filename"]: { + "units": None, + "units-url": trans["units_list_url"], + } + for trans in self._get_from_weblate(component["translations-url"]) + } + return component["translations"] + + def _get_units(self, template, file): + translation_dict = self._get_translations(template) + if translation_dict is None: + return None + translation = translation_dict.get(file, None) + if translation is None: + self.logger.warning(f"No translation in weblate for {file}.") + return None + if translation["units"] is None: + translation["units"] = { + unit["context"]: { + "translated": unit["translated"], + } + for unit in self._get_from_weblate(translation["units-url"]) + } + return translation["units"] + + def is_translated(self, template_path, locale_path, string_id): + """ + Whether the given string is marked as translated on weblate. + """ + unit_dict = self._get_units(template_path, locale_path) + if unit_dict is None: + return False + unit = unit_dict.get(string_id, None) + if unit is None: + self.logger.warning(f"No unit in weblate for {locale_path}:{string_id}.") + return False + return unit["translated"] + + +class TorBrowserMigrationContext(MigrationContext): + """ + Extension which adds some extra methods to use for tor-browser. + """ + + def __init__(self, locale, reference_dir, localization_dir): + super().__init__(locale, reference_dir, localization_dir) + + def _fluent_keys(self, resource): + # ast.Resource, want to extract all Message and Term identifiers, as + # well as their + for entry in resource.body: + if not isinstance(entry, (ast.Term, ast.Message)): + continue + key = entry.id.name + if isinstance(entry, ast.Term): + key = f"-{key}" + if entry.value: + yield key + for attr in entry.attributes: + yield f"{key}.{attr.id.name}" + + def tb_get_available_strings(self): + """ + Return all the (path, string_id) pairs for all loaded localization + resources. + """ + all_strings = set() + # ctx.localization_resources is a dict containing all the loaded + # localization_resources that have been added during add_transforms. + # { localization_path: resource } + for path, resource in self.localization_resources.items(): + if path.endswith(".ftl"): + all_strings.update((path, key) for key in self._fluent_keys(resource)) + else: + # dictionary of { identifies: value } in legacy resource. + # For tor-browser, + # Only include references that differ from the en-US strings. + all_strings.update((path, key) for key in resource.keys()) + return all_strings + + def tb_get_missing_resources(self): + """ + Return the missing localization resources. + """ + return set( + path + for dep_set in self.dependencies.values() + for path, string_id in dep_set + if path not in self.localization_resources + ) + + def tb_get_transform(self, target_path, transform_id): + """ + Find the transformation node with the given id for the given path. + """ + for node in self.transforms[target_path]: + if node.id.name == transform_id: + return node + return None + + def tb_get_reference_entry(self, target_path, entry_id): + """ + Find the reference node that would be used for the given id and path. + """ + for entry in self.reference_resources[target_path].body: + if isinstance(entry, (ast.Term, ast.Message)) and entry.id.name == entry_id: + return entry.clone() + return None + + +class TorBrowserMigrator: + """ + Performs a tor-browser migration. + """ + + def __init__( + self, + en_US_dir, + locale_dirs, + migrate_module, + weblate_metadata, + ): + self.logger = logging.getLogger("tor-browser-migrator") + self.logger.setLevel(logging.INFO) + self.en_US_dir = en_US_dir + self.locale_dirs = locale_dirs + self.migrate_module = migrate_module + self.weblate_metadata = weblate_metadata + + def run(self): + """ + Run the migration. + """ + if not self._check_en_US_resources(): + sys.exit(1) + + fluent_errors = [] + for locale, locale_dir in self.locale_dirs.items(): + if locale == "en-US": + print("", file=sys.stderr) + self.logger.warning( + "Skipping running migration on 'en-US' files since this " + "locale should act as a reference only.\n" + ) + continue + fluent_errors.extend(self._run_locale(locale, locale_dir)) + + if fluent_errors: + print("\n", file=sys.stderr) + self.logger.error( + "Fluent parsing errors found for the following files. " + "Migration does not need to be run again, but the following " + "syntax errors should be fixed manually.\n" + + "\n".join( + f"{in_red(full_path)}: line {line}: {message}: [[{sample}]]" + for full_path, message, line, sample in fluent_errors + ) + ) + + def _run_locale(self, locale, locale_dir): + print("\n\n", file=sys.stderr) + self.logger.info(f"Migrating '{in_red(locale)}' locale\n") + + ctx = self._get_migration_context(locale, locale_dir) + + # NOTE: We do not use the existing ctx.serialize_changeset method. + # The problem with this approach was that it would re-shuffle the order + # of already existing strings to match the en-US locale. + # But Weblate currently does not preserve the order of translated + # strings: https://github.com/WeblateOrg/weblate/issues/11134 + # so this created extra noise in the diff. + # Instead, we just always append transformations to the end of the + # existing file. + # Moreover, it would inject group comments into the translated files, + # which Weblate does not handle well. Instead, we just do not add any + # comments. + # + # In case we want to use it again in the future, here is a reference + # to how it works: + # + # ctx.serialize_changeset expects a set of (path, identifier) of + # localization resources that can be used to evaluate the + # transformations. + # e.g. ("example.dtd", "exampleStringInDTD") + # ("example.ftl", "some-message") + # ("example.ftl", "some-message.attribute") + # + # Mozilla splits its changesets into the authors/users who are + # attributed to creating the identified sources, using hg blame and + # checking the author to identify the user. + # For tor-browser, we just want to apply all the changes possible in + # one step, so we want to fill the changeset with all required + # (path, identifier) pairs found in the localization resources. + + available_strings = ctx.tb_get_available_strings() + wrote_file = False + errors = [] + + for target_path, reference in ctx.reference_resources.items(): + translated_ids = [ + entry.id.name + for entry in ctx.target_resources[target_path].body + if isinstance(entry, (ast.Message, ast.Term)) + # NOTE: We're assuming that the Message and Term ids do not + # conflict with each other. + ] + new_entries = [] + + # Apply transfomations in the order they appear in the reference + # (en-US) file. + for entry in reference.body: + if not isinstance(entry, (ast.Message, ast.Term)): + continue + transform_id = entry.id.name + transform = ctx.tb_get_transform(target_path, transform_id) + if not transform: + # No transformation for this reference entry. + continue + + if transform_id in translated_ids: + self.logger.info( + f"Skipping transform {target_path}:{transform_id} " + f"for '{locale}' locale because it already has a " + f"translation." + ) + continue + + # ctx.dependencies is a dict of dependencies for all + # transformations + # { (target_path, transform_identifier): set( + # (localization_path, string_identifier), + # )} + # + # e.g. if we want to create a new fluent Message called + # "new-string1", and it uses "oldString1" from "old-file1.dtd" + # and "oldString2" from "old-file2.dtd". And "new-string2" using + # "oldString3" from "old-file2.dtd", it would be + # { + # ("new-file.ftl", "new-string1"): set( + # ("old-file1.dtd", "oldString1"), + # ("old-file2.dtd", "oldString2"), + # ), + # ("new-file.ftl", "new-string2"): set( + # ("old-file2.dtd", "oldString3"), + # ), + # } + dep_set = ctx.dependencies[(target_path, transform_id)] + can_transform = True + for dep in dep_set: + path, string_id = dep + if dep not in available_strings: + can_transform = False + self.logger.info( + f"Skipping transform {target_path}:{transform_id} " + f"for '{locale}' locale because it is missing the " + f"string {path}:{string_id}." + ) + break + # Strings in legacy formats might have an entry in the file + # that is just a copy of the en-US strings. + # For these we want to check the weblate metadata to ensure + # it is a translated string. + if not path.endswith( + ".ftl" + ) and not self.weblate_metadata.is_translated( + os.path.join("en-US", path), + os.path.join(locale, path), + string_id, + ): + can_transform = False + self.logger.info( + f"Skipping transform {target_path}:{transform_id} " + f"for '{locale}' locale because the string " + f"{path}:{string_id} has not been translated on " + "weblate." + ) + break + if not can_transform: + continue + + # Run the transformation. + new_entries.append(ctx.evaluate(transform)) + + if not new_entries: + continue + + full_path = os.path.join(locale_dir, target_path) + print("", file=sys.stderr) + self.logger.info(f"Writing to {full_path}") + + # For Fluent we can just serialize the transformations and append + # them to the end of the existing file. + resource = ast.Resource(new_entries) + with open(full_path, "a") as file: + file.write(serialize(resource)) + + with open(full_path) as file: + full_content = file.read() + wrote_file = True + # Collect any fluent parsing errors from the newly written file. + errors.extend( + (full_path, message, line, sample) + for message, line, sample in self._fluent_errors(full_content) + ) + + if not wrote_file: + self.logger.info(f"No files written for '{locale}' locale.") + return errors + + def _fluent_errors(self, fluent): + """ + Verify that the given fluent string can be parsed correctly. + """ + resource = parse(fluent) + for entry in resource.body: + if not isinstance(entry, ast.Junk): + continue + for annotation in entry.annotations: + line = fluent[0 : annotation.span.start].count("\n") + 1 + sample_start = max(annotation.span.start - 15, 0) + sample = "…" + fluent[sample_start : sample_start + 30] + "…" + yield annotation.message, line, sample + + def _get_migration_context(self, locale, locale_dir): + prev_missing_resources = set() + while True: + ctx = TorBrowserMigrationContext(locale, self.en_US_dir, locale_dir) + + try: + self.migrate_module.migrate(ctx) + except EmptyLocalizationError: + # This case will be handled by missing_resources below. + # NOTE: At the time of writing, add_transforms only throws if + # ctx.localization_resources is empty after add_transforms, + # which means whether it throws can depend on whether the + # missing resource was found missing before or after some + # non-missing resource was found. I.e. the order in which + # add_transforms is called can influence whether add_transforms + # will throw. + # Therefore, we want to handle the case where it throws or does + # not throw in the same way. We also need to create a new + # context for the next run so that the early exit from + # add_transforms in this run does not make a difference. + pass + + missing_resources = ctx.tb_get_missing_resources() + + if not missing_resources: + return ctx + + still_missing = missing_resources & prev_missing_resources + if still_missing: + # Unexpected to still be missing the same files after the + # previous run. + self.logger.error( + f"Still missing files in '{locale}' locale: " + + ", ".join(in_red(path) for path in still_missing) + ) + sys.exit(1) + + for path in missing_resources: + # Create an empty file to try and get migrate() to succeed + # the next round. + # NOTE: Missing strings within a resource is ok, we just want to + # add the missing file to prevent add_transforms from throwing + # to allow us to proceed. + full_path = os.path.join(locale_dir, path) + self.logger.info(f"Creating temporary empty file: {full_path}") + # Throw if it already exists. + file = open(full_path, "x") + # Remove the empty file on exit if it is still empty. + atexit.register(self._remove_if_empty, full_path) + # Immediately close. + file.close() + + # Try again with the newly added resources. + # Don't expect it to throw EmptyLocalizationError the second time, + # although it may still be missing resources if the last run threw + # before localization_resources was fully populated. + prev_missing_resources = missing_resources + + def _remove_if_empty(self, path): + if os.stat(path).st_size: + self.logger.warning(f"{path} is no longer empty. Not deleting.") + return + os.remove(path) + + def _check_en_US_resources(self): + # We pass in the en-US directory as the localization directory, as well + # as the reference directory. + ctx = TorBrowserMigrationContext("en-US", self.en_US_dir, self.en_US_dir) + + have_error = False + try: + self.migrate_module.migrate(ctx) + except EmptyLocalizationError: + # Handle with localization_resources check. + # NOTE: This throwing may have prevented further add_transforms from + # proceeding, so we will only report missing string errors up to + # this point. + # Set have_error here just in case. + have_error = True + except UnreadableReferenceError: + # Reference filename is printed before this. + self.logger.error("Missing an en-US reference file.") + return False + + # Check each transform would create the same entry in the target as the + # already existing reference file for en-US, using the existing en-US + # localization files. + # I.e. if we ran the transforms for en-US we expect to get the same file + # as the reference. + available_strings = ctx.tb_get_available_strings() + for (target_path, transform_id), dep_set in ctx.dependencies.items(): + transform_name = in_red(f"{target_path}:{transform_id}") + has_deps = True + for dep in dep_set: + path, string_id = dep + if path not in ctx.localization_resources: + has_deps = False + self.logger.error( + f"Missing en-US localization file {in_red(path)} " + f" for transform {transform_name}" + ) + continue + if dep not in available_strings: + has_deps = False + self.logger.error( + "Missing en-US localization string " + + in_red(f"{path}:{string_id}") + + f" for transform {transform_name}" + ) + if not has_deps: + have_error = True + continue + + transformed = ctx.evaluate(ctx.tb_get_transform(target_path, transform_id)) + reference_entry = ctx.tb_get_reference_entry(target_path, transform_id) + if reference_entry is None: + self.logger.error( + f"Missing en-US reference entry for transform {transform_name}" + ) + have_error = True + continue + + # Serialized the single transformed and reference and compare. + transform_serialized = serialize(ast.Resource([transformed])) + # Remove comment in reference for comparison. + reference_entry.comment = None + ref_serialized = serialize(ast.Resource([reference_entry])) + if transform_serialized != ref_serialized: + self.logger.error( + f"Transform {transform_name} would not produce the same " + "entry as the existing en-US reference when acting on " + "en-US localization files.\n" + f"Transform result:\n{transform_serialized}\n" + f"Reference entry:\n{ref_serialized}" + ) + have_error = True + + return not have_error + + +parser = argparse.ArgumentParser( + description="Run a tor-browser localization migration. " + "See documentation within migrate_l10n.py." +) +parser.add_argument( + "--translation-git", + required=True, + metavar="<dir>", + help="Location of the translation-git directory to read and write to.", +) +parser.add_argument( + "--locales", + required=True, + metavar="<locale1> <locale2> ...", + help="Set of locales to restrict the migration to, separated by space.", +) +parser.add_argument( + "migration", + help="Migration to run, given as a python module. " + 'E.g. "l10n_migrations.my-migration-script".', +) + + +parsed_args = parser.parse_args() + + +def check_dir(path): + if not os.path.isdir(path): + print(f"{in_red(path)} is not a directory.", file=sys.stderr) + sys.exit(1) + return path + + +translation_dir = check_dir(os.path.abspath(parsed_args.translation_git)) + +TorBrowserMigrator( + check_dir(os.path.join(translation_dir, "en-US")), + { + locale: check_dir(os.path.join(translation_dir, locale)) + for locale in (l.strip() for l in parsed_args.locales.split(" ")) + if locale + }, + importlib.import_module(parsed_args.migration), + WeblateMetadata(), +).run() diff --git a/tools/torbrowser/l10n/migrations/__init__.py b/tools/torbrowser/l10n/migrations/__init__.py diff --git a/tools/torbrowser/l10n/migrations/bug-41333-new-about-tor.py b/tools/torbrowser/l10n/migrations/bug-41333-new-about-tor.py @@ -0,0 +1,24 @@ +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +menu-open-tor-manual = + .label = { COPY(path, "aboutTor.torbrowser_user_manual.label") } + .accesskey = { COPY(path, "aboutTor.torbrowser_user_manual.accesskey") } + +tor-browser-home-heading-stable = { COPY(path, "aboutTor.ready.label") } +tor-browser-home-heading-testing = { COPY(path, "aboutTor.alpha.ready.label") } + +tor-browser-home-duck-duck-go-input = + .placeholder = { COPY(path, "aboutTor.search.label") } + +tor-browser-home-message-introduction = { COPY(path, "aboutTor.ready2.label") } +""", + path="aboutTor.dtd", + ), + ) diff --git a/tools/torbrowser/l10n/migrations/bug-41622-neterror.py b/tools/torbrowser/l10n/migrations/bug-41622-neterror.py @@ -0,0 +1,21 @@ +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +onion-neterror-not-found-description = { COPY(path, "onionServices.descNotFound") } +onion-neterror-unreachable-description = { COPY(path, "onionServices.descInvalid") } +onion-neterror-disconnected-description = { COPY(path, "onionServices.introFailed") } +onion-neterror-connection-failed-description = { COPY(path, "onionServices.rendezvousFailed") } +onion-neterror-missing-authentication-description = { COPY(path, "onionServices.clientAuthMissing") } +onion-neterror-incorrect-authetication-description = { COPY(path, "onionServices.clientAuthIncorrect") } +onion-neterror-invalid-address-description = { COPY(path, "onionServices.badAddress") } +onion-neterror-timed-out-description = { COPY(path, "onionServices.introTimedOut") } +""", + path="torbutton.properties", + ), + ) diff --git a/tools/torbrowser/l10n/migrations/bug-41820-moz-message-bar-download-warning.py b/tools/torbrowser/l10n/migrations/bug-41820-moz-message-bar-download-warning.py @@ -0,0 +1,67 @@ +import re + +import fluent.syntax.ast as FTL +from fluent.migrate.transforms import COPY_PATTERN, FluentSource +from fluent.syntax.visitor import Visitor + + +class RemoveAnchorVisitor(Visitor): + """Class to remove <a> and </a> wrappers from a Fluent TextElement.""" + + def __init__(self): + # Good enough regex for our needs that will match starting and ending + # tags. + self._anchor_regex = re.compile(r"<\/?[aA](| [^>]*)>") + super().__init__() + + def visit_TextElement(self, node): + node.value = self._anchor_regex.sub("", node.value) + + +class RemoveAnchorTransform(FluentSource): + """Class to remove <a> and </a> wrappers from a Fluent source.""" + + def __call__(self, ctx): + pattern = ctx.get_fluent_source_pattern(self.path, self.key).clone() + # Visit every node in the pattern, replacing each TextElement's content. + RemoveAnchorVisitor().visit(pattern) + return pattern + + +def migrate(ctx): + # Convert + # + # downloads-tor-warning-title = A + # downloads-tor-warning-description = B<a data-l10n-name="tails-link">C</a>D + # + # to + # + # downloads-tor-warning-message-bar = + # .heading = A + # .message = BCD + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + [ + FTL.Message( + id=FTL.Identifier("downloads-tor-warning-message-bar"), + value=None, + attributes=[ + FTL.Attribute( + id=FTL.Identifier("heading"), + value=COPY_PATTERN( + "tor-browser.ftl", + "downloads-tor-warning-title", + ), + ), + FTL.Attribute( + id=FTL.Identifier("message"), + value=RemoveAnchorTransform( + "tor-browser.ftl", + "downloads-tor-warning-description", + ), + ), + ], + ), + ], + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42202-crypto-safety.py b/tools/torbrowser/l10n/migrations/bug-42202-crypto-safety.py @@ -0,0 +1,34 @@ +import fluent.syntax.ast as FTL +from fluent.migrate.helpers import VARIABLE_REFERENCE, transforms_from +from fluent.migrate.transforms import REPLACE + + +def migrate(ctx): + legacy_path = "cryptoSafetyPrompt.properties" + + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +crypto-safety-prompt-title = { COPY(path, "cryptoSafetyPrompt.cryptoTitle") } +crypto-safety-prompt-reload-button = { COPY(path, "cryptoSafetyPrompt.primaryAction") } +crypto-safety-prompt-dismiss-button = { COPY(path, "cryptoSafetyPrompt.secondaryAction") } +""", + path=legacy_path, + ) + + [ + # Replace "%1$S" and "%2$S" with "{ $address }" and "{ $host }" + FTL.Message( + id=FTL.Identifier("crypto-safety-prompt-body"), + value=REPLACE( + legacy_path, + "cryptoSafetyPrompt.cryptoBody", + { + "%1$S": VARIABLE_REFERENCE("address"), + "%2$S": VARIABLE_REFERENCE("host"), + }, + ), + ), + ], + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42203-about-dialog.py b/tools/torbrowser/l10n/migrations/bug-42203-about-dialog.py @@ -0,0 +1,75 @@ +import fluent.syntax.ast as FTL +from fluent.migrate.helpers import TERM_REFERENCE, transforms_from +from fluent.migrate.transforms import CONCAT, COPY, REPLACE + + +def migrate(ctx): + legacy_path = "aboutDialog.dtd" + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +about-dialog-questions-link = { COPY(path, "bottomLinks.questions") } +about-dialog-grow-tor-network-link = { COPY(path, "bottomLinks.grow") } +about-dialog-browser-license-link = { COPY(path, "bottomLinks.license") } +""", + path=legacy_path, + ) + + [ + # Concatenate as + # &project.start; + # <label data-l10n-name="project-link">&project.tpoLink;</a> + # &project.end; + # + # And replace any occurrence of "&brandShortName;" and + # "&vendorShortName;" with "-brand-short-name" and + # "-vendor-short-name", wherever they may appear. + FTL.Message( + id=FTL.Identifier("about-dialog-tor-project"), + value=CONCAT( + REPLACE( + legacy_path, + "project.start", + { + "&brandShortName;": TERM_REFERENCE("brand-short-name"), + "&vendorShortName;": TERM_REFERENCE("vendor-short-name"), + }, + ), + FTL.TextElement('<label data-l10n-name="project-link">'), + REPLACE( + legacy_path, + "project.tpoLink", + { + "&brandShortName;": TERM_REFERENCE("brand-short-name"), + "&vendorShortName;": TERM_REFERENCE("vendor-short-name"), + }, + ), + FTL.TextElement("</label>"), + REPLACE( + legacy_path, + "project.end", + { + "&brandShortName;": TERM_REFERENCE("brand-short-name"), + "&vendorShortName;": TERM_REFERENCE("vendor-short-name"), + }, + ), + ), + ), + # Concatenate with two link labels. + FTL.Message( + id=FTL.Identifier("about-dialog-help-out"), + value=CONCAT( + COPY(legacy_path, "help.start"), + FTL.TextElement('<label data-l10n-name="donate-link">'), + COPY(legacy_path, "help.donateLink"), + FTL.TextElement("</label>"), + COPY(legacy_path, "help.or"), + FTL.TextElement('<label data-l10n-name="community-link">'), + COPY(legacy_path, "help.getInvolvedLink"), + FTL.TextElement("</label>"), + COPY(legacy_path, "help.end"), + ), + ), + ], + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42206-rulesets.py b/tools/torbrowser/l10n/migrations/bug-42206-rulesets.py @@ -0,0 +1,70 @@ +import fluent.syntax.ast as FTL +from fluent.migrate.helpers import transforms_from +from fluent.migrate.transforms import REPLACE + + +def migrate(ctx): + legacy_path = "rulesets.properties" + + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +rulesets-warning-heading = { COPY(path, "rulesets.warningTitle") } +rulesets-warning-description = { COPY(path, "rulesets.warningDescription") } +rulesets-warning-checkbox = { COPY(path, "rulesets.warningEnable") } +rulesets-warning-continue-button = { COPY(path, "rulesets.warningButton") } + +rulesets-side-panel-heading = { COPY(path, "rulesets.rulesets") } +rulesets-side-panel-no-rules = { COPY(path, "rulesets.noRulesets") } + +rulesets-update-never = { COPY(path, "rulesets.neverUpdated") } +rulesets-update-rule-disabled = { COPY(path, "rulesets.disabled") } + +rulesets-details-edit-button = { COPY(path, "rulesets.edit") } +rulesets-details-enable-checkbox = { COPY(path, "rulesets.enable") } +rulesets-details-update-button = { COPY(path, "rulesets.checkUpdates") } +rulesets-details-save-button = { COPY(path, "rulesets.save") } +rulesets-details-cancel-button = { COPY(path, "rulesets.cancel") } +rulesets-details-jwk-input = + .placeholder = { COPY(path, "rulesets.jwkPlaceholder") } +rulesets-details-jwk-input-invalid = { COPY(path, "rulesets.jwkInvalid") } +rulesets-details-path = { COPY(path, "rulesets.pathPrefix") } +rulesets-details-path-input = + .placeholder = { COPY(path, "rulesets.pathPrefixPlaceholder") } +rulesets-details-path-input-invalid = { COPY(path, "rulesets.pathPrefixInvalid") } +rulesets-details-scope = { COPY(path, "rulesets.scope") } +rulesets-details-scope-input = + .placeholder = { COPY(path, "rulesets.scopePlaceholder") } +rulesets-details-scope-input-invalid = { COPY(path, "rulesets.scopeInvalid") } +""", + path=legacy_path, + ) + + [ + # Replace "%1$S" with "{ DATETIME($date, dateStyle: "long") }" + FTL.Message( + FTL.Identifier("rulesets-update-last"), + value=REPLACE( + legacy_path, + "rulesets.lastUpdated", + { + "%1$S": FTL.FunctionReference( + FTL.Identifier("DATETIME"), + arguments=FTL.CallArguments( + positional=[ + FTL.VariableReference(FTL.Identifier("date")) + ], + named=[ + FTL.NamedArgument( + FTL.Identifier("dateStyle"), + value=FTL.StringLiteral("long"), + ) + ], + ), + ) + }, + ), + ), + ], + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42207-settings.py b/tools/torbrowser/l10n/migrations/bug-42207-settings.py @@ -0,0 +1,88 @@ +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + legacy_path = "settings.properties" + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +tor-connection-settings-heading = { COPY(path, "settings.categoryTitle") } +tor-connection-browser-learn-more-link = { COPY(path, "settings.learnMore") } + +tor-connection-quickstart-heading = { COPY(path, "settings.quickstartHeading") } +tor-connection-quickstart-checkbox = + .label = { COPY(path, "settings.quickstartCheckbox") } + +tor-connection-internet-status-label = { COPY(path, "settings.statusInternetLabel") } +tor-connection-internet-status-test-button = { COPY(path, "settings.statusInternetTest") } +tor-connection-internet-status-online = { COPY(path, "settings.statusInternetOnline") } +tor-connection-internet-status-offline = { COPY(path, "settings.statusInternetOffline") } + +tor-bridges-heading = { COPY(path, "settings.bridgesHeading") } +tor-bridges-overview = { COPY(path, "settings.bridgesDescription2") } +tor-bridges-learn-more-link = { COPY(path, "settings.learnMore") } + +tor-bridges-built-in-obfs4-description = { COPY(path, "settings.builtinBridgeObfs4Description2") } +tor-bridges-built-in-snowflake-name = { COPY(path, "settings.builtinBridgeSnowflake") } +tor-bridges-built-in-snowflake-description = { COPY(path, "settings.builtinBridgeSnowflakeDescription2") } +tor-bridges-built-in-meek-azure-name = { COPY(path, "settings.builtinBridgeMeekAzure") } +tor-bridges-built-in-meek-azure-description = { COPY(path, "settings.builtinBridgeMeekAzureDescription2") } + +remove-all-bridges-warning-title = { COPY(path, "settings.bridgeRemoveAllDialogTitle") } +remove-all-bridges-warning-description = { COPY(path, "settings.bridgeRemoveAllDialogDescription") } +remove-all-bridges-warning-remove-button = { COPY(path, "settings.remove") } + +bridge-qr-dialog-title = + .title = { COPY(path, "settings.scanQrTitle") } + +bridge-dialog-button-connect = { COPY(path, "settings.bridgeButtonConnect") } +bridge-dialog-button-accept = { COPY(path, "settings.bridgeButtonAccept") } +bridge-dialog-button-submit = { COPY(path, "settings.submitCaptcha") } + +built-in-dialog-title = + .title = { COPY(path, "settings.builtinBridgeHeader") } +built-in-dialog-snowflake-radio-option = + .label = { COPY(path, "settings.builtinBridgeSnowflake") } +built-in-dialog-meek-azure-radio-option = + .label = { COPY(path, "settings.builtinBridgeMeekAzure") } + +request-bridge-dialog-title = + .title = { COPY(path, "settings.requestBridgeDialogTitle") } +request-bridge-dialog-top-wait = { COPY(path, "settings.contactingBridgeDB") } +request-bridge-dialog-top-solve = { COPY(path, "settings.solveTheCaptcha") } +request-bridge-dialog-captcha-input = + .placeholder = { COPY(path, "settings.captchaTextboxPlaceholder") } +request-bridge-dialog-captcha-failed = { COPY(path, "settings.incorrectCaptcha") } + +tor-advanced-settings-heading = { COPY(path, "settings.advancedHeading") } +tor-advanced-settings-button = { COPY(path, "settings.advancedButton") } + +tor-log-dialog-copy-button-copied = + .label = { COPY(path, "settings.copied") } + +tor-advanced-dialog-proxy-socks4-menuitem = + .label = { COPY(path, "settings.proxyTypeSOCKS4") } +tor-advanced-dialog-proxy-socks5-menuitem = + .label = { COPY(path, "settings.proxyTypeSOCKS5") } +tor-advanced-dialog-proxy-http-menuitem = + .label = { COPY(path, "settings.proxyTypeHTTP") } +tor-advanced-dialog-proxy-address-input-label = { COPY(path, "settings.proxyAddress") } +tor-advanced-dialog-proxy-address-input = + .placeholder = { COPY(path, "settings.proxyAddressPlaceholder") } +tor-advanced-dialog-proxy-port-input-label = { COPY(path, "settings.proxyPort") } +tor-advanced-dialog-proxy-username-input-label = { COPY(path, "settings.proxyUsername") } +tor-advanced-dialog-proxy-username-input = + .placeholder = { COPY(path, "settings.proxyUsernamePasswordPlaceholder") } +tor-advanced-dialog-proxy-password-input-label = { COPY(path, "settings.proxyPassword") } +tor-advanced-dialog-proxy-password-input = + .placeholder = { COPY(path, "settings.proxyUsernamePasswordPlaceholder") } +tor-advanced-dialog-firewall-checkbox = + .label = { COPY(path, "settings.useFirewall") } +tor-advanced-dialog-firewall-ports-input = + .placeholder = { COPY(path, "settings.allowedPortsPlaceholder") } +""", + path=legacy_path, + ), + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42209-tor-circuit.py b/tools/torbrowser/l10n/migrations/bug-42209-tor-circuit.py @@ -0,0 +1,83 @@ +import fluent.syntax.ast as FTL +from fluent.migrate.helpers import VARIABLE_REFERENCE, transforms_from +from fluent.migrate.transforms import CONCAT, REPLACE + + +def migrate(ctx): + legacy_dtd = "torbutton.dtd" + legacy_properties = "torbutton.properties" + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +menu-new-tor-circuit = + .label = { COPY(dtd_path, "torbutton.context_menu.new_circuit") } + .accesskey = { COPY(dtd_path, "torbutton.context_menu.new_circuit_key") } +appmenuitem-new-tor-circuit = + .label = { COPY(dtd_path, "torbutton.context_menu.new_circuit_sentence_case") } +toolbar-new-tor-circuit = + .label = { COPY(dtd_path, "torbutton.context_menu.new_circuit_sentence_case") } + .tooltiptext = { toolbar-new-tor-circuit.label } + +tor-circuit-urlbar-button = + .tooltiptext = { COPY(dtd_path, "torbutton.circuit_display.title") } + +tor-circuit-panel-node-list-introduction = { COPY(dtd_path, "torbutton.circuit_display.title") } +tor-circuit-panel-node-browser = { COPY(path, "torbutton.circuit_display.this_browser") } +tor-circuit-panel-node-onion-relays = { COPY(path, "torbutton.circuit_display.onion-site-relays") } +tor-circuit-panel-node-bridge = { COPY(path, "torbutton.circuit_display.tor_bridge") } +tor-circuit-panel-node-unknown-region = { COPY(path, "torbutton.circuit_display.unknown_region") } + +tor-circuit-panel-new-button = { COPY(dtd_path, "torbutton.context_menu.new_circuit_sentence_case") } +tor-circuit-panel-new-button-description-guard = { COPY(path, "torbutton.circuit_display.new-circuit-guard-description") } +tor-circuit-panel-new-button-description-bridge = { COPY(path, "torbutton.circuit_display.new-circuit-bridge-description") } +""", + dtd_path=legacy_dtd, + path=legacy_properties, + ) + + [ + # Replace "%S" with "{ $host }" + FTL.Message( + id=FTL.Identifier("tor-circuit-panel-heading"), + value=REPLACE( + legacy_properties, + "torbutton.circuit_display.heading", + {"%1$S": VARIABLE_REFERENCE("host")}, + ), + ), + # Replace "%S" with "<a data-l10n-name="alias-link">{ $alias }</a>" + FTL.Message( + id=FTL.Identifier("tor-circuit-panel-alias"), + value=REPLACE( + legacy_properties, + "torbutton.circuit_display.connected-to-alias", + { + "%1$S": CONCAT( + FTL.TextElement('<a data-l10n-name="alias-link">'), + VARIABLE_REFERENCE("alias"), + FTL.TextElement("</a>"), + ) + }, + ), + ), + # Replace "%S" with "{ $region }" + FTL.Message( + id=FTL.Identifier("tor-circuit-panel-node-region-guard"), + value=REPLACE( + legacy_properties, + "torbutton.circuit_display.region-guard-node", + {"%1$S": VARIABLE_REFERENCE("region")}, + ), + ), + # Replace "%S" with "{ $bridge-type }" + FTL.Message( + id=FTL.Identifier("tor-circuit-panel-node-typed-bridge"), + value=REPLACE( + legacy_properties, + "torbutton.circuit_display.tor_typed_bridge", + {"%1$S": VARIABLE_REFERENCE("bridge-type")}, + ), + ), + ], + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42210-download-warning.py b/tools/torbrowser/l10n/migrations/bug-42210-download-warning.py @@ -0,0 +1,39 @@ +import fluent.syntax.ast as FTL +from fluent.migrate.helpers import transforms_from +from fluent.migrate.transforms import CONCAT, COPY, REPLACE + + +def migrate(ctx): + legacy_path = "torbutton.properties" + + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +downloads-tor-warning-title = { COPY(path, "torbutton.download.warning.title") } +downloads-tor-warning-dismiss-button = { COPY(path, "torbutton.download.warning.dismiss") } +""", + path=legacy_path, + ) + + [ + # Replace "%S" with link to Tails website. + FTL.Message( + id=FTL.Identifier("downloads-tor-warning-description"), + value=REPLACE( + legacy_path, + "torbutton.download.warning.description", + { + "%1$S": CONCAT( + FTL.TextElement('<a data-l10n-name="tails-link">'), + COPY( + legacy_path, + "torbutton.download.warning.tails_brand_name", + ), + FTL.TextElement("</a>"), + ) + }, + ), + ), + ], + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42211-new-identity.py b/tools/torbrowser/l10n/migrations/bug-42211-new-identity.py @@ -0,0 +1,48 @@ +import fluent.syntax.ast as FTL +from fluent.migrate.helpers import TERM_REFERENCE, transforms_from +from fluent.migrate.transforms import REPLACE + + +def migrate(ctx): + legacy_path = "newIdentity.properties" + + ctx.add_transforms( + "base-browser.ftl", + "base-browser.ftl", + transforms_from( + """ +menu-new-identity = + .label = { COPY(path, "new_identity") } + .accesskey = { COPY(path, "new_identity_menu_accesskey") } +appmenuitem-new-identity = + .label = { COPY(path, "new_identity_sentence_case") } +toolbar-new-identity = + .label = { COPY(path, "new_identity_sentence_case") } + .tooltiptext = { toolbar-new-identity.label } + +new-identity-dialog-title = { COPY(path, "new_identity_prompt_title") } +new-identity-dialog-never-ask-checkbox = + .label = { COPY(path, "new_identity_ask_again") } + +new-identity-blocked-home-ignore-button = { COPY(path, "new_identity_home_load_button") } +""", + path=legacy_path, + ) + + [ + # Replace "%S" with "{ -brand-short-name }" in confirm button. + FTL.Message( + id=FTL.Identifier("new-identity-dialog-confirm"), + value=None, + attributes=[ + FTL.Attribute( + id=FTL.Identifier("label"), + value=REPLACE( + legacy_path, + "new_identity_restart", + {"%1$S": TERM_REFERENCE("brand-short-name")}, + ), + ), + ], + ), + ], + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42212-onion-services.py b/tools/torbrowser/l10n/migrations/bug-42212-onion-services.py @@ -0,0 +1,27 @@ +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + dtd_path = "torbutton.dtd" + properties_path = "torbutton.properties" + + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +onion-site-authentication-prompt-learn-more = { COPY(path, "onionServices.learnMore") } +onion-site-authentication-prompt-remember-checkbox = + .label = { COPY(dtd_path, "torbutton.onionServices.authPrompt.persistCheckboxLabel") } +onion-site-authentication-prompt-invalid-key = { COPY(path, "onionServices.authPrompt.invalidKey") } +onion-site-authentication-prompt-setting-key-failed = { COPY(path, "onionServices.authPrompt.failedToSetKey") } +onion-site-authentication-preferences-learn-more = { COPY(path, "onionServices.learnMore") } +onion-site-saved-keys-dialog-table-header-key = + .label = { COPY(path, "onionServices.authPreferences.onionKey") } +onion-site-saved-keys-dialog-remove-button = { COPY(path, "onionServices.authPreferences.remove") } +onion-site-saved-keys-dialog-remove-keys-error-message = { COPY(path, "onionServices.authPreferences.failedToRemoveKey") } +""", + dtd_path=dtd_path, + path=properties_path, + ), + ) diff --git a/tools/torbrowser/l10n/migrations/bug-42214-security-level.py b/tools/torbrowser/l10n/migrations/bug-42214-security-level.py @@ -0,0 +1,43 @@ +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + ctx.add_transforms( + "base-browser.ftl", + "base-browser.ftl", + transforms_from( + """ +security-level-panel-level-standard = { COPY(path, "security_level_standard") } +security-level-panel-level-safer = { COPY(path, "security_level_safer") } +security-level-panel-level-safest = { COPY(path, "security_level_safest") } +security-level-panel-learn-more-link = { COPY(path, "security_level_learn_more") } +security-level-panel-open-settings-button = { COPY(path, "security_level_open_settings") } + +security-level-preferences-heading = { COPY(path, "security_level") } +security-level-preferences-overview = { COPY(path, "security_level_overview") } +security-level-preferences-learn-more-link = { COPY(path, "security_level_learn_more") } +security-level-preferences-level-standard = + .label = { COPY(path, "security_level_standard") } +security-level-preferences-level-safer = + .label = { COPY(path, "security_level_safer") } +security-level-preferences-level-safest = + .label = { COPY(path, "security_level_safest") } + +security-level-summary-standard = { COPY(path, "security_level_standard_summary") } +security-level-summary-safer = { COPY(path, "security_level_safer_summary") } +security-level-summary-safest = { COPY(path, "security_level_safest_summary") } + + +security-level-preferences-bullet-https-only-javascript = { COPY(path, "security_level_js_https_only") } +security-level-preferences-bullet-limit-font-and-symbols = { COPY(path, "security_level_limit_typography") } +security-level-preferences-bullet-limit-media = { COPY(path, "security_level_limit_media") } +security-level-preferences-bullet-disabled-javascript = { COPY(path, "security_level_js_disabled") } +security-level-preferences-bullet-limit-font-and-symbols-and-images = { COPY(path, "security_level_limit_typography_svg") } + +security-level-panel-custom-badge = { COPY(path, "security_level_custom") } +security-level-preferences-custom-heading = { COPY(path, "security_level_custom_heading") } +security-level-summary-custom = { COPY(path, "security_level_custom_summary") } +""", + path="securityLevel.properties", + ), + ) diff --git a/tools/torbrowser/l10n/migrations/bug-43531-bridge-dialog-buttons.py b/tools/torbrowser/l10n/migrations/bug-43531-bridge-dialog-buttons.py @@ -0,0 +1,27 @@ +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + # Convert + # + # my-button = MY TEXT + # + # to + # + # my-button2 = + # .label = MY TEXT + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +bridge-dialog-button-connect2 = + .label = { COPY_PATTERN(path, "bridge-dialog-button-connect") } +bridge-dialog-button-accept2 = + .label = { COPY_PATTERN(path, "bridge-dialog-button-accept") } +bridge-dialog-button-submit2 = + .label = { COPY_PATTERN(path, "bridge-dialog-button-submit") } +""", + path="tor-browser.ftl", + ), + )