summaryrefslogtreecommitdiffstats
path: root/comm/python
diff options
context:
space:
mode:
Diffstat (limited to 'comm/python')
-rw-r--r--comm/python/README13
-rw-r--r--comm/python/l10n/l10n_clone/l10n_clone.py96
-rw-r--r--comm/python/l10n/mach_commands.py264
-rw-r--r--comm/python/l10n/missing_ftl/__init__.py59
-rw-r--r--comm/python/l10n/tb_fluent_migrations/__init__.py0
-rw-r--r--comm/python/l10n/tb_fluent_migrations/bug_1827199_multi_message_view.py33
-rw-r--r--comm/python/l10n/tb_fluent_migrations/bug_1831422_backupKeyPassword.py25
-rw-r--r--comm/python/l10n/tb_fluent_migrations/bug_1833042_unfied_toolbar_button_style.py25
-rw-r--r--comm/python/l10n/tb_fluent_migrations/bug_1838109_changeExpiryDlg.py27
-rw-r--r--comm/python/l10n/tb_fluent_migrations/bug_1838770_properties_menu_item.py56
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_ics_file_dialog.py20
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_uri_redirect_dialog.py20
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1731837_delete_commands.py114
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1798509_unified_toolbar_customization.py52
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1803705_thread_pane.py111
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1805746_calendar_view.py29
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1805938_calendar_recurrence_ux.py25
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1806567_quick_filter_bar_migration.py182
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1809312_unified_toolbar_buttons.py218
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1811400_thread_pane_column_picker.py79
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1814393_unified_toolbar_customization_tabs.py36
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1814664_unified_toolbar_calendar_items.py48
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1815489_add_back_forward_and_stop.py33
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1815605_calendar_context_menu_has_empty_items.py52
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1816532_about_dialog_migration.py94
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1816593_flexbox_emulation_dialogs.py21
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1817914_tags_mode.py21
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1817915_get_new_messages.py22
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1820700_select_thread.py35
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1823033_activity_indicator.py23
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1827261_add_enable_disable_compact_mode_options_to_folder_mode_context_menu.py26
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1827891_dnt_prefs_learn_more.py21
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1828340_aboutdialog_layout_fixes.py42
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1830004_folder_quota.py28
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_cal_enable78.py30
-rw-r--r--comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_extensions_to_fluent.py531
-rw-r--r--comm/python/l10n/tbxchannel/__init__.py47
-rw-r--r--comm/python/l10n/tbxchannel/l10n_merge.py26
-rw-r--r--comm/python/l10n/tbxchannel/quarantine_to_strings.py194
-rw-r--r--comm/python/l10n/tbxchannel/tb_migration_test.py172
-rw-r--r--comm/python/moz.build13
-rw-r--r--comm/python/mutlh/README.md56
-rw-r--r--comm/python/mutlh/mutlh/__init__.py0
-rw-r--r--comm/python/mutlh/mutlh/decorators.py197
-rw-r--r--comm/python/mutlh/mutlh/site.py120
-rw-r--r--comm/python/mutlh/mutlh/test/__init__.py0
-rw-r--r--comm/python/mutlh/mutlh/test/conftest.py11
-rw-r--r--comm/python/mutlh/mutlh/test/python.ini11
-rw-r--r--comm/python/mutlh/mutlh/test/test_decorators.py82
-rw-r--r--comm/python/mutlh/mutlh/test/test_site.py33
-rw-r--r--comm/python/mutlh/mutlh/test/test_site_compatibility.py194
-rw-r--r--comm/python/rocboot/README.rst20
-rwxr-xr-xcomm/python/rocboot/bin/bootstrap.py444
-rw-r--r--comm/python/rocbuild/rocbuild/__init__.py0
-rw-r--r--comm/python/rocbuild/rocbuild/notify.py34
-rw-r--r--comm/python/sites/tb_common.txt7
-rw-r--r--comm/python/thirdroc/rnp_generated.py116
-rw-r--r--comm/python/thirdroc/setup.py24
-rw-r--r--comm/python/thirdroc/thirdroc/__init__.py0
-rw-r--r--comm/python/thirdroc/thirdroc/cmake_define_files.py102
-rw-r--r--comm/python/thirdroc/thirdroc/rnp_symbols.py131
61 files changed, 4545 insertions, 0 deletions
diff --git a/comm/python/README b/comm/python/README
new file mode 100644
index 0000000000..67028b9bff
--- /dev/null
+++ b/comm/python/README
@@ -0,0 +1,13 @@
+This directory contains common Python code for Thunderbird.
+
+The basic rule is that if Python code is cross-module (that's "module" in the
+Mozilla meaning - as in "module ownership") and is MPL-compatible, AND it
+applies only to applications build from comm-central derived repositories
+(Thunderbird and Seamonkey), it should go here.
+
+What should not go here:
+
+- Vendored python modules (use third_party/python instead)
+- Python that is not MPL-compatible (see other-licenses/)
+- Python that has good reason to remain close to its "owning" (Mozilla)
+ module (e.g. it is only being consumed from there).
diff --git a/comm/python/l10n/l10n_clone/l10n_clone.py b/comm/python/l10n/l10n_clone/l10n_clone.py
new file mode 100644
index 0000000000..899ad4a212
--- /dev/null
+++ b/comm/python/l10n/l10n_clone/l10n_clone.py
@@ -0,0 +1,96 @@
+# 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 http://mozilla.org/MPL/2.0/.
+"""
+Download and combine translations from l10n-central and comm-l10n for
+use by mach build installers-$AB_CD and mach build langpack-$AB_CD.
+"""
+
+import argparse
+import json
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+from mozpack.copier import FileCopier
+from mozpack.files import FileFinder
+from mozversioncontrol.repoupdate import update_mercurial_repo
+
+COMM_PATH = (Path(__file__).parent / "../../..").resolve()
+GECKO_PATH = COMM_PATH.parent
+COMM_PYTHON_L10N = os.path.join(COMM_PATH, "python/l10n")
+sys.path.insert(1, COMM_PYTHON_L10N)
+
+from tbxchannel.l10n_merge import (
+ COMM_L10N,
+ COMM_STRINGS_PATTERNS,
+ GECKO_STRINGS_PATTERNS,
+ L10N_CENTRAL,
+)
+
+ALL_LOCALES = [l.rstrip() for l in (COMM_PATH / "mail/locales/all-locales").open().readlines()]
+
+
+def tb_locale(locale):
+ if locale in ALL_LOCALES:
+ return locale
+ raise argparse.ArgumentTypeError("Locale {} invalid.".format(locale))
+
+
+def get_revision(project, locale):
+ json_file = {
+ "browser": GECKO_PATH / "browser/locales/l10n-changesets.json",
+ "mail": COMM_PATH / "mail/locales/l10n-changesets.json",
+ }.get(project)
+ if json_file is None:
+ raise Exception(f"Invalid project {project} for l10n-changesets.json!")
+
+ with open(json_file) as fp:
+ changesets = json.load(fp)
+
+ revision = changesets.get(locale, {}).get("revision")
+ if revision is None:
+ raise Exception(f"Locale {locale} not found in {project} l10n-changesets.json!")
+
+ return revision
+
+
+def get_strings_repos(locale, destination):
+ with tempfile.TemporaryDirectory() as tmproot:
+ central_url = "{}/{}".format(L10N_CENTRAL, locale)
+ l10n_central = Path(tmproot) / "l10n-central"
+ l10n_central.mkdir()
+ central_path = l10n_central / locale
+ central_revision = get_revision("browser", locale)
+ update_mercurial_repo("hg", central_url, central_path, revision=central_revision)
+
+ comm_l10n = Path(tmproot) / "comm-l10n"
+ comm_revision = get_revision("mail", locale)
+ update_mercurial_repo("hg", COMM_L10N, comm_l10n, revision=comm_revision)
+
+ file_copier = FileCopier()
+
+ def add_to_registry(base_path, patterns):
+ finder = FileFinder(base_path)
+ for pattern in patterns:
+ for _filepath, _fileobj in finder.find(pattern.format(lang=locale)):
+ file_copier.add(_filepath, _fileobj)
+
+ add_to_registry(l10n_central, GECKO_STRINGS_PATTERNS)
+ add_to_registry(comm_l10n, COMM_STRINGS_PATTERNS)
+
+ file_copier.copy(str(destination))
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Download translated strings from comm-l10n")
+ parser.add_argument("locale", help="The locale to download", type=tb_locale)
+ parser.add_argument("dest_path", help="Path where locale will be downloaded to.", type=Path)
+
+ args = parser.parse_args()
+ get_strings_repos(args.locale, args.dest_path)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/comm/python/l10n/mach_commands.py b/comm/python/l10n/mach_commands.py
new file mode 100644
index 0000000000..e4cfbfcf30
--- /dev/null
+++ b/comm/python/l10n/mach_commands.py
@@ -0,0 +1,264 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+import argparse
+import logging
+import os.path
+from pathlib import Path
+
+from mutlh.decorators import Command, CommandArgument
+
+
+# https://stackoverflow.com/a/14117511
+def _positive_int(value):
+ value = int(value)
+ if value <= 0:
+ raise argparse.ArgumentTypeError(f"{value} must be a positive integer.")
+ return value
+
+
+def _retry_run_process(command_context, *args, error_msg=None, **kwargs):
+ try:
+ return command_context.run_process(*args, **kwargs)
+ except Exception as exc:
+ raise Exception(error_msg or str(exc)) from exc
+
+
+def _get_rev(command_context, strings_path):
+ result = []
+
+ def save_output(line):
+ result.append(line)
+
+ status = _retry_run_process(
+ command_context,
+ [
+ "hg",
+ "--cwd",
+ str(strings_path),
+ "log",
+ "-r",
+ ".",
+ "--template",
+ "{node}\n",
+ ],
+ line_handler=save_output,
+ )
+ if status == 0:
+ return "\n".join(result)
+ raise Exception(f"Failed to get head revision: {status}")
+
+
+@Command(
+ "tb-l10n-x-channel",
+ category="thunderbird",
+ description="Create cross-channel content for Thunderbird (comm-strings).",
+)
+@CommandArgument(
+ "--strings-path",
+ "-s",
+ metavar="en-US",
+ type=Path,
+ default=Path("en-US"),
+ help="Path to mercurial repository for comm-strings-quarantine",
+)
+@CommandArgument(
+ "--outgoing-path",
+ "-o",
+ type=Path,
+ help="create an outgoing() patch if there are changes",
+)
+@CommandArgument(
+ "--attempts",
+ type=_positive_int,
+ default=1,
+ help="Number of times to try (for automation)",
+)
+@CommandArgument(
+ "--ssh-secret",
+ action="store",
+ help="Taskcluster secret to use to push (for automation)",
+)
+@CommandArgument(
+ "actions",
+ choices=("prep", "create", "push", "clean"),
+ nargs="+",
+ # This help block will be poorly formatted until we fix bug 1714239
+ help="""
+ "prep": clone repos and pull heads.
+ "create": create the en-US strings commit an optionally create an
+ outgoing() patch.
+ "push": push the en-US strings to the quarantine repo.
+ "clean": clean up any sub-repos.
+ """,
+)
+def tb_cross_channel(
+ command_context,
+ strings_path,
+ outgoing_path,
+ actions,
+ attempts,
+ ssh_secret,
+ **kwargs,
+):
+ """Run Thunderbird's l10n cross-channel content generation."""
+ from tbxchannel import TB_XC_NOTIFICATION_TMPL, get_thunderbird_xc_config
+ from tbxchannel.l10n_merge import COMM_STRINGS_QUARANTINE
+
+ from rocbuild.notify import email_notification
+
+ kwargs.update(
+ {
+ "strings_path": strings_path,
+ "outgoing_path": outgoing_path,
+ "actions": actions,
+ "attempts": attempts,
+ "ssh_secret": ssh_secret,
+ "get_config": get_thunderbird_xc_config,
+ }
+ )
+ command_context._mach_context.commands.dispatch(
+ "l10n-cross-channel", command_context._mach_context, **kwargs
+ )
+ if os.path.exists(outgoing_path):
+ head_rev = _get_rev(command_context, strings_path)
+ rev_url = f"{COMM_STRINGS_QUARANTINE}/rev/{head_rev}"
+
+ notification_body = TB_XC_NOTIFICATION_TMPL.format(rev_url=rev_url)
+ email_notification("X-channel comm-strings-quarantine updated", notification_body)
+
+
+@Command(
+ "tb-add-missing-ftls",
+ category="thunderbird",
+ description="Add missing FTL files after l10n merge.",
+)
+@CommandArgument(
+ "--merge",
+ type=Path,
+ help="Merge path base",
+)
+@CommandArgument(
+ "locale",
+ type=str,
+ help="Locale code",
+)
+def tb_add_missing_ftls(command_context, merge, locale):
+ """
+ Command to create empty FTL files for incomplete localizations to
+ avoid over-zealous en-US fallback as described in bug 1586984. This
+ mach command is based on the script used to update the l10n-central
+ repositories. It gets around the need to have write access to those
+ repositories in favor of creating the files during l10m-repackaging.
+ This code assumes that mach compare-locales --merge has already run.
+ """
+ from missing_ftl import add_missing_ftls, get_lang_ftls, get_source_ftls
+
+ print("Checking for missing .ftl files in locale {}".format(locale))
+ comm_src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
+ source_files = get_source_ftls(comm_src_dir)
+
+ l10n_path = os.path.join(merge, locale)
+ locale_files = get_lang_ftls(l10n_path)
+
+ add_missing_ftls(l10n_path, source_files, locale_files)
+
+
+@Command(
+ "tb-fluent-migration-test",
+ category="thunderbird",
+ description="Test Fluent migration recipes.",
+)
+@CommandArgument("test_paths", nargs="*", metavar="N", help="Recipe paths to test.")
+def run_migration_tests(command_context, test_paths=None, **kwargs):
+ if not test_paths:
+ test_paths = []
+ command_context.activate_virtualenv()
+
+ from tbxchannel.tb_migration_test import inspect_migration, prepare_object_dir, test_migration
+
+ rv = 0
+ with_context = []
+ for to_test in test_paths:
+ try:
+ context = inspect_migration(to_test)
+ for issue in context["issues"]:
+ command_context.log(
+ logging.ERROR,
+ "tb-fluent-migration-test",
+ {
+ "error": issue["msg"],
+ "file": to_test,
+ },
+ "ERROR in {file}: {error}",
+ )
+ if context["issues"]:
+ continue
+ with_context.append(
+ {
+ "to_test": to_test,
+ "references": context["references"],
+ }
+ )
+ except Exception as e:
+ command_context.log(
+ logging.ERROR,
+ "tb-fluent-migration-test",
+ {"error": str(e), "file": to_test},
+ "ERROR in {file}: {error}",
+ )
+ rv |= 1
+ obj_dir = prepare_object_dir(command_context)
+ for context in with_context:
+ rv |= test_migration(command_context, obj_dir, **context)
+ return rv
+
+
+from mutlh.decorators import Command, CommandArgument
+
+
+@Command(
+ "tb-l10n-quarantine-to-strings",
+ category="thunderbird",
+ description="Publish quarantines strings to comm-l10n.",
+)
+@CommandArgument(
+ "--quarantine-path",
+ "-q",
+ type=Path,
+ help="Path to comm-strings-quarantine repo",
+)
+@CommandArgument(
+ "--comm-l10n-path",
+ "-l",
+ type=Path,
+ help="Path to comm-l10n repo",
+)
+@CommandArgument(
+ "actions",
+ choices=("clean", "prep", "migrate", "push"),
+ nargs="+",
+ # This help block will be poorly formatted until we fix bug 1714239
+ help="""
+ "clean": remove existing clones of quarantine and comm-l10n repos
+ "prep": clone a new repository or update an existing one to latest rev
+ "migrate": update comm-l10n en_US from quarantine
+ "push": push comm-l10n
+ """,
+)
+def quarantine_to_strings(
+ command_context,
+ quarantine_path,
+ comm_l10n_path,
+ actions,
+ **kwargs,
+):
+ """Publish strings in Thunderbird's comm-l10n cross-channel repository from
+ comm-strings-quarantine."""
+ from tbxchannel.quarantine_to_strings import publish_strings
+
+ command_context._set_log_level(True)
+ command_context.activate_virtualenv()
+ command_context.log_manager.enable_unstructured()
+ publish_strings(command_context, quarantine_path, comm_l10n_path, actions, **kwargs)
diff --git a/comm/python/l10n/missing_ftl/__init__.py b/comm/python/l10n/missing_ftl/__init__.py
new file mode 100644
index 0000000000..1e90db6d5b
--- /dev/null
+++ b/comm/python/l10n/missing_ftl/__init__.py
@@ -0,0 +1,59 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+import os
+
+
+def walk_path(folder_path, prefix):
+ file_list = []
+ for root, dirs, files in os.walk(folder_path, followlinks=True):
+ for file_name in files:
+ if os.path.splitext(file_name)[1] == ".ftl":
+ file_name = os.path.relpath(os.path.join(root, file_name), folder_path)
+ file_name = os.path.join(prefix, file_name)
+ file_list.append(file_name)
+ file_list.sort()
+
+ return file_list
+
+
+def get_source_ftls(comm_src_dir):
+ """Find ftl files in en-US mail and calendar."""
+ file_list = []
+ for d in ["mail", "calendar"]:
+ folder_path = os.path.join(comm_src_dir, d, "locales/en-US")
+ file_list += walk_path(folder_path, d)
+ return file_list
+
+
+def get_lang_ftls(l10n_path):
+ """Find ftl files in the merge directory."""
+ file_list = []
+ for d in ["mail", "calendar"]:
+ folder_path = os.path.join(l10n_path, d)
+ file_list += walk_path(folder_path, d)
+ return file_list
+
+
+def add_missing_ftls(l10n_path, source_files, locale_files):
+ """
+ For any ftl files that are in source_files but missing in locale_files,
+ create a placeholder file.
+ """
+ for file_name in source_files:
+ if file_name not in locale_files:
+ full_file_name = os.path.join(l10n_path, file_name)
+ file_path = os.path.dirname(full_file_name)
+ if not os.path.isdir(file_path):
+ # Create missing folder
+ print("Creating missing folder: {}".format(os.path.relpath(file_path, l10n_path)))
+ os.makedirs(file_path)
+
+ print("Adding missing file: {}".format(file_name))
+ with open(full_file_name, "w") as f:
+ f.write(
+ "# This Source Code Form is subject to the terms of the Mozilla Public\n"
+ "# License, v. 2.0. If a copy of the MPL was not distributed with this\n"
+ "# file, You can obtain one at http://mozilla.org/MPL/2.0/.\n"
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/__init__.py b/comm/python/l10n/tb_fluent_migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/__init__.py
diff --git a/comm/python/l10n/tb_fluent_migrations/bug_1827199_multi_message_view.py b/comm/python/l10n/tb_fluent_migrations/bug_1827199_multi_message_view.py
new file mode 100644
index 0000000000..47cc910b67
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/bug_1827199_multi_message_view.py
@@ -0,0 +1,33 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb import COPY_PATTERN
+from fluent.migratetb.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1827199 - Message summary preview header buttons are not keyboard accessible"""
+
+ target = reference = "mail/locales/en-US/messenger/multimessageview.ftl"
+ ctx.add_transforms(
+ target,
+ reference,
+ transforms_from(
+ """
+multi-message-window-title =
+ .title = {{COPY_PATTERN(from_path, "window.title")}}
+
+selected-messages-label =
+ .label = {{COPY_PATTERN(from_path, "selectedmessages.label")}}
+
+multi-message-archive-button =
+ .label = {{COPY_PATTERN(from_path, "archiveButton.label")}}
+ .tooltiptext = {{COPY_PATTERN(from_path, "archiveButton.label")}}
+
+multi-message-delete-button =
+ .label = {{COPY_PATTERN(from_path, "deleteButton.label")}}
+ .tooltiptext = {{COPY_PATTERN(from_path, "deleteButton.label")}}
+ """,
+ from_path="mail/locales/en-US/chrome/messenger/multimessageview.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/bug_1831422_backupKeyPassword.py b/comm/python/l10n/tb_fluent_migrations/bug_1831422_backupKeyPassword.py
new file mode 100644
index 0000000000..1c86a338a4
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/bug_1831422_backupKeyPassword.py
@@ -0,0 +1,25 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb import COPY_PATTERN
+from fluent.migratetb.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1831422 - migrations in OpenPGP key backup dialog"""
+
+ target = reference = "mail/messenger/openpgp/backupKeyPassword.ftl"
+ ctx.add_transforms(
+ target,
+ reference,
+ transforms_from(
+ """
+set-password-window-title = {{COPY_PATTERN(from_path, "set-password-window.title")}}
+
+set-password-backup-pw-label = {{COPY_PATTERN(from_path, "set-password-backup-pw.value")}}
+
+set-password-backup-pw2-label = {{COPY_PATTERN(from_path, "set-password-repeat-backup-pw.value")}}
+ """,
+ from_path="mail/messenger/openpgp/backupKeyPassword.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/bug_1833042_unfied_toolbar_button_style.py b/comm/python/l10n/tb_fluent_migrations/bug_1833042_unfied_toolbar_button_style.py
new file mode 100644
index 0000000000..108f48474e
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/bug_1833042_unfied_toolbar_button_style.py
@@ -0,0 +1,25 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate.helpers import transforms_from
+from fluent.migrate import COPY_PATTERN
+
+
+def migrate(ctx):
+ """Bug 1833042 - Don't automatically update the button style preference when changed in the unified toolbar customization panel, part {index}."""
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbar.ftl",
+ "mail/messenger/unifiedToolbar.ftl",
+ transforms_from(
+ """
+customize-button-style-icons-beside-text-option = {COPY_PATTERN(from_path, "customize-button-style-icons-beside-text.label")}
+
+customize-button-style-icons-above-text-option = {COPY_PATTERN(from_path, "customize-button-style-icons-above-text.label")}
+
+customize-button-style-icons-only-option = {COPY_PATTERN(from_path, "customize-button-style-icons-only.label")}
+
+customize-button-style-text-only-option = {COPY_PATTERN(from_path, "customize-button-style-text-only.label")}
+ """,
+ from_path="mail/messenger/unifiedToolbar.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/bug_1838109_changeExpiryDlg.py b/comm/python/l10n/tb_fluent_migrations/bug_1838109_changeExpiryDlg.py
new file mode 100644
index 0000000000..68566c4131
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/bug_1838109_changeExpiryDlg.py
@@ -0,0 +1,27 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb import COPY_PATTERN
+from fluent.migratetb.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1838109 - migrate some strings in changeExpiryDlg.xhtml"""
+
+ target = reference = "mail/messenger/openpgp/changeExpiryDlg.ftl"
+ ctx.add_transforms(
+ target,
+ reference,
+ transforms_from(
+ """
+openpgp-change-expiry-title = {{COPY_PATTERN(from_path, "openpgp-change-key-expiry-title.title")}}
+
+expire-no-change-label = {{COPY_PATTERN(from_path, "expire-dont-change.label")}}
+
+expire-in-time-label = {{COPY_PATTERN(from_path, "expire-in-label.label")}}
+
+expire-never-expire-label = {{COPY_PATTERN(from_path, "expire-never-label.label")}}
+ """,
+ from_path="mail/messenger/openpgp/changeExpiryDlg.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/bug_1838770_properties_menu_item.py b/comm/python/l10n/tb_fluent_migrations/bug_1838770_properties_menu_item.py
new file mode 100644
index 0000000000..04e15628b8
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/bug_1838770_properties_menu_item.py
@@ -0,0 +1,56 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb import COPY
+
+import fluent.syntax.ast as FTL
+
+
+def migrate(ctx):
+ """Bug 1838770 - Edit > Folder Properties doesn't work, part {index}."""
+ source = "mail/chrome/messenger/messenger.dtd"
+ dest = "mail/messenger/messenger.ftl"
+ ctx.add_transforms(
+ dest,
+ dest,
+ [
+ FTL.Message(
+ id=FTL.Identifier("menu-edit-properties"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"), value=COPY(source, "folderPropsCmd2.label")
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(source, "folderPropsCmd.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("menu-edit-folder-properties"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(source, "folderPropsFolderCmd2.label"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(source, "folderPropsCmd.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("menu-edit-newsgroup-properties"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(source, "folderPropsNewsgroupCmd2.label"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(source, "folderPropsCmd.accesskey"),
+ ),
+ ],
+ ),
+ ],
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_ics_file_dialog.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_ics_file_dialog.py
new file mode 100644
index 0000000000..4e7df08f73
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_ics_file_dialog.py
@@ -0,0 +1,20 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1703164 - convert calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml to html"""
+
+ ctx.add_transforms(
+ "calendar/calendar/calendar-ics-file-dialog.ftl",
+ "calendar/calendar/calendar-ics-file-dialog.ftl",
+ transforms_from(
+ """
+calendar-ics-file-window-title = {{COPY_PATTERN(from_path, "calendar-ics-file-window-2.title")}}
+ """,
+ from_path="calendar/calendar/calendar-ics-file-dialog.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_uri_redirect_dialog.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_uri_redirect_dialog.py
new file mode 100644
index 0000000000..cd4e681da0
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1703164_calendar_uri_redirect_dialog.py
@@ -0,0 +1,20 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1703164 - convert calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml to top level html"""
+
+ ctx.add_transforms(
+ "calendar/calendar/calendar-uri-redirect-dialog.ftl",
+ "calendar/calendar/calendar-uri-redirect-dialog.ftl",
+ transforms_from(
+ """
+calendar-uri-redirect-window-title = {{COPY_PATTERN(from_path, "calendar-uri-redirect-window.title")}}
+ """,
+ from_path="calendar/calendar/calendar-uri-redirect-dialog.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1731837_delete_commands.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1731837_delete_commands.py
new file mode 100644
index 0000000000..fc045d1ed6
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1731837_delete_commands.py
@@ -0,0 +1,114 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import re
+
+from fluent.migratetb import COPY, COPY_PATTERN
+from fluent.migratetb.helpers import VARIABLE_REFERENCE
+from fluent.migratetb.transforms import Transform, TransformPattern
+
+import fluent.syntax.ast as FTL
+
+
+def migrate(ctx):
+ """Bug 1731837 - Fix multiple problems with the labelling of Delete commands, part {index}."""
+ source = "mail/chrome/messenger/messenger.dtd"
+ dest = "mail/messenger/messenger.ftl"
+ ctx.add_transforms(
+ dest,
+ dest,
+ [
+ FTL.Message(
+ id=FTL.Identifier("menu-edit-delete-folder"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"), value=COPY(source, "deleteFolderCmd.label")
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(source, "deleteFolderCmd.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("menu-edit-delete-messages"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=Transform.pattern_of(
+ FTL.SelectExpression(
+ selector=VARIABLE_REFERENCE("count"),
+ variants=[
+ FTL.Variant(
+ key=FTL.Identifier("one"),
+ value=COPY(source, "deleteMsgCmd.label"),
+ ),
+ FTL.Variant(
+ key=FTL.Identifier("other"),
+ default=True,
+ value=COPY(source, "deleteMsgsCmd.label"),
+ ),
+ ],
+ )
+ ),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(source, "deleteMsgCmd.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("menu-edit-undelete-messages"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=Transform.pattern_of(
+ FTL.SelectExpression(
+ selector=VARIABLE_REFERENCE("count"),
+ variants=[
+ FTL.Variant(
+ key=FTL.Identifier("one"),
+ value=COPY(source, "undeleteMsgCmd.label"),
+ ),
+ FTL.Variant(
+ key=FTL.Identifier("other"),
+ default=True,
+ value=COPY(source, "undeleteMsgsCmd.label"),
+ ),
+ ],
+ )
+ ),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(source, "undeleteMsgCmd.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("mail-context-undelete-messages"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=Transform.pattern_of(
+ FTL.SelectExpression(
+ selector=VARIABLE_REFERENCE("count"),
+ variants=[
+ FTL.Variant(
+ key=FTL.Identifier("one"),
+ value=COPY(source, "undeleteMsgCmd.label"),
+ ),
+ FTL.Variant(
+ key=FTL.Identifier("other"),
+ default=True,
+ value=COPY(source, "undeleteMsgsCmd.label"),
+ ),
+ ],
+ )
+ ),
+ )
+ ],
+ ),
+ ],
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1798509_unified_toolbar_customization.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1798509_unified_toolbar_customization.py
new file mode 100644
index 0000000000..7ac296ec35
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1798509_unified_toolbar_customization.py
@@ -0,0 +1,52 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1798509 - Unified toolbar customization fluent migration part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbar.ftl",
+ "mail/messenger/unifiedToolbar.ftl",
+ transforms_from(
+ """
+customize-menu-customize =
+ .label = { COPY(from_path, "customizeToolbar.label") }
+ """,
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbar.ftl",
+ "mail/messenger/unifiedToolbar.ftl",
+ transforms_from(
+ """
+customize-space-mail = { COPY_PATTERN(from_path, "spaces-toolbar-button-mail2.title") }
+
+customize-space-addressbook = { COPY_PATTERN(from_path, "spaces-toolbar-button-address-book2.title") }
+
+customize-space-calendar = { COPY_PATTERN(from_path, "spaces-toolbar-button-calendar2.title") }
+
+customize-space-tasks = { COPY_PATTERN(from_path, "spaces-toolbar-button-tasks2.title") }
+
+customize-space-chat = { COPY_PATTERN(from_path, "spaces-toolbar-button-chat2.title") }
+
+customize-space-settings = { COPY_PATTERN(from_path, "spaces-toolbar-button-settings2.title") }
+ """,
+ from_path="mail/messenger/messenger.ftl",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbar.ftl",
+ "mail/messenger/unifiedToolbar.ftl",
+ transforms_from(
+ """
+customize-button-style-icons-beside-text =
+ .label = { COPY(from_path, "iconsBesideText.label") }
+ """,
+ from_path="mail/chrome/messenger/customizeToolbar.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1803705_thread_pane.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1803705_thread_pane.py
new file mode 100644
index 0000000000..b3f41b1c5a
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1803705_thread_pane.py
@@ -0,0 +1,111 @@
+# coding=utf8
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate.helpers import transforms_from
+from fluent.migrate import COPY
+
+
+def migrate(ctx):
+ """Bug 1803705 - Migrate thread tree strings to fluent, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/treeView.ftl",
+ "mail/messenger/treeView.ftl",
+ transforms_from(
+ """
+tree-list-view-column-picker =
+ .title = { COPY(from_path, "columnChooser2.tooltip") }
+""",
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
+
+ ctx.add_transforms(
+ "mail/messenger/about3Pane.ftl",
+ "mail/messenger/about3Pane.ftl",
+ transforms_from(
+ """
+threadpane-column-header-select =
+ .title = { COPY(from_path, "selectColumn.tooltip") }
+threadpane-column-label-select =
+ .label = { COPY(from_path, "selectColumn.label") }
+threadpane-column-label-thread =
+ .label = { COPY(from_path, "threadColumn.label") }
+threadpane-column-header-flagged =
+ .title = { COPY(from_path, "starredColumn2.tooltip") }
+threadpane-column-label-flagged =
+ .label = { COPY(from_path, "starredColumn.label") }
+threadpane-column-header-attachments =
+ .title = { COPY(from_path, "attachmentColumn2.tooltip") }
+threadpane-column-label-attachments =
+ .label = { COPY(from_path, "attachmentColumn.label") }
+threadpane-column-header-sender = { COPY(from_path, "fromColumn.label") }
+ .title = { COPY(from_path, "fromColumn2.tooltip") }
+threadpane-column-label-sender =
+ .label = { COPY(from_path, "fromColumn.label") }
+threadpane-column-header-recipient = { COPY(from_path, "recipientColumn.label") }
+ .title = { COPY(from_path, "recipientColumn2.tooltip") }
+threadpane-column-label-recipient =
+ .label = { COPY(from_path, "recipientColumn.label") }
+threadpane-column-header-correspondents = { COPY(from_path, "correspondentColumn.label") }
+ .title = { COPY(from_path, "correspondentColumn2.tooltip") }
+threadpane-column-label-correspondents =
+ .label = { COPY(from_path, "correspondentColumn.label") }
+threadpane-column-header-subject = { COPY(from_path, "subjectColumn.label") }
+ .title = { COPY(from_path, "subjectColumn2.tooltip") }
+threadpane-column-label-subject =
+ .label = { COPY(from_path, "subjectColumn.label") }
+threadpane-column-header-date = { COPY(from_path, "dateColumn.label") }
+ .title = { COPY(from_path, "dateColumn2.tooltip") }
+threadpane-column-label-date =
+ .label = { COPY(from_path, "dateColumn.label") }
+threadpane-column-header-received = { COPY(from_path, "receivedColumn.label") }
+ .title = { COPY(from_path, "receivedColumn2.tooltip") }
+threadpane-column-label-received =
+ .label = { COPY(from_path, "receivedColumn.label") }
+threadpane-column-header-status = { COPY(from_path, "statusColumn.label") }
+ .title = { COPY(from_path, "statusColumn2.tooltip") }
+threadpane-column-label-status =
+ .label = { COPY(from_path, "statusColumn.label") }
+threadpane-column-header-size = { COPY(from_path, "sizeColumn.label") }
+ .title = { COPY(from_path, "sizeColumn2.tooltip") }
+threadpane-column-label-size =
+ .label = { COPY(from_path, "sizeColumn.label") }
+threadpane-column-header-tags = { COPY(from_path, "tagsColumn.label") }
+ .title = { COPY(from_path, "tagsColumn2.tooltip") }
+threadpane-column-label-tags =
+ .label = { COPY(from_path, "tagsColumn.label") }
+threadpane-column-header-account = { COPY(from_path, "accountColumn.label") }
+ .title = { COPY(from_path, "accountColumn2.tooltip") }
+threadpane-column-label-account =
+ .label = { COPY(from_path, "accountColumn.label") }
+threadpane-column-header-priority = { COPY(from_path, "priorityColumn.label") }
+ .title = { COPY(from_path, "priorityColumn2.tooltip") }
+threadpane-column-label-priority =
+ .label = { COPY(from_path, "priorityColumn.label") }
+threadpane-column-header-unread = { COPY(from_path, "unreadColumn.label") }
+ .title = { COPY(from_path, "unreadColumn2.tooltip") }
+threadpane-column-label-unread =
+ .label = { COPY(from_path, "unreadColumn.label") }
+threadpane-column-header-total = { COPY(from_path, "totalColumn.label") }
+ .title = { COPY(from_path, "totalColumn2.tooltip") }
+threadpane-column-label-total =
+ .label = { COPY(from_path, "totalColumn.label") }
+threadpane-column-header-location = { COPY(from_path, "locationColumn.label") }
+ .title = { COPY(from_path, "locationColumn2.tooltip") }
+threadpane-column-label-location =
+ .label = { COPY(from_path, "locationColumn.label") }
+threadpane-column-header-id = { COPY(from_path, "idColumn.label") }
+ .title = { COPY(from_path, "idColumn2.tooltip") }
+threadpane-column-label-id =
+ .label = { COPY(from_path, "idColumn.label") }
+threadpane-column-header-delete =
+ .title = { COPY(from_path, "deleteColumn.tooltip") }
+threadpane-column-label-delete =
+ .label = { COPY(from_path, "deleteColumn.label") }
+""",
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1805746_calendar_view.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1805746_calendar_view.py
new file mode 100644
index 0000000000..eec0fbe44e
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1805746_calendar_view.py
@@ -0,0 +1,29 @@
+# coding=utf8
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate.helpers import transforms_from
+from fluent.migrate import COPY
+
+
+def migrate(ctx):
+ """Bug 1805746 - Update Calendar View selection part {index}."""
+
+ ctx.add_transforms(
+ "calendar/calendar/calendar-widgets.ftl",
+ "calendar/calendar/calendar-widgets.ftl",
+ transforms_from(
+ """
+calendar-view-toggle-day = { COPY(from_path, "calendar.day.button.label") }
+ .title = { COPY(from_path, "calendar.day.button.tooltip") }
+calendar-view-toggle-week = { COPY(from_path, "calendar.week.button.label") }
+ .title = { COPY(from_path, "calendar.week.button.tooltip") }
+calendar-view-toggle-multiweek = { COPY(from_path, "calendar.multiweek.button.label") }
+ .title = { COPY(from_path, "calendar.multiweek.button.tooltip") }
+calendar-view-toggle-month = { COPY(from_path, "calendar.month.button.label") }
+ .title = { COPY(from_path, "calendar.month.button.tooltip") }
+""",
+ from_path="calendar/chrome/calendar/calendar.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1805938_calendar_recurrence_ux.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1805938_calendar_recurrence_ux.py
new file mode 100644
index 0000000000..715b475015
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1805938_calendar_recurrence_ux.py
@@ -0,0 +1,25 @@
+# coding=utf8
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate.helpers import transforms_from
+from fluent.migrate import COPY
+
+
+def migrate(ctx):
+ """Bug 1805938 - Refactor recurrence calendar UX part {index}."""
+
+ source = "calendar/chrome/calendar/calendar-event-dialog.dtd"
+ reference = target = "calendar/calendar/calendar-recurrence-dialog.ftl"
+
+ ctx.add_transforms(
+ target,
+ reference,
+ transforms_from(
+ """
+calendar-recurrence-preview-label = { COPY(from_path, "event.recurrence.preview.label") }
+""",
+ from_path=source,
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1806567_quick_filter_bar_migration.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1806567_quick_filter_bar_migration.py
new file mode 100644
index 0000000000..72967ef4d8
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1806567_quick_filter_bar_migration.py
@@ -0,0 +1,182 @@
+# coding=utf8
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import fluent.syntax.ast as FTL
+from fluent.migrate.helpers import (
+ MESSAGE_REFERENCE,
+ VARIABLE_REFERENCE,
+ transforms_from,
+)
+from fluent.migrate.transforms import (
+ CONCAT,
+ PLURALS,
+ REPLACE,
+ REPLACE_IN_TEXT,
+ Transform,
+)
+
+
+def migrate(ctx):
+ """Bug 1806567 - Migrate Quick Filter Bar strings from DTD to FTL, part {index}"""
+
+ source = "mail/chrome/messenger/quickFilterBar.dtd"
+ target1 = reference1 = "mail/messenger/about3Pane.ftl"
+ ctx.add_transforms(
+ target1,
+ reference1,
+ transforms_from(
+ """
+
+quick-filter-bar-sticky =
+ .title = { COPY(from_path, "quickFilterBar.sticky.tooltip") }
+quick-filter-bar-unread =
+ .title = { COPY(from_path, "quickFilterBar.unread.tooltip") }
+quick-filter-bar-unread-label = { COPY(from_path, "quickFilterBar.unread.label") }
+quick-filter-bar-starred =
+ .title = { COPY(from_path, "quickFilterBar.starred.tooltip") }
+quick-filter-bar-starred-label = { COPY(from_path, "quickFilterBar.starred.label") }
+quick-filter-bar-inaddrbook =
+ .title = { COPY(from_path, "quickFilterBar.inaddrbook.tooltip") }
+quick-filter-bar-inaddrbook-label = { COPY(from_path, "quickFilterBar.inaddrbook.label") }
+quick-filter-bar-tags =
+ .title = { COPY(from_path, "quickFilterBar.tags.tooltip") }
+quick-filter-bar-tags-label = { COPY(from_path, "quickFilterBar.tags.label") }
+quick-filter-bar-attachment =
+ .title = { COPY(from_path, "quickFilterBar.attachment.tooltip") }
+quick-filter-bar-attachment-label = { COPY(from_path, "quickFilterBar.attachment.label") }
+quick-filter-bar-no-results = { COPY(from_path, "quickFilterBar.resultsLabel.none") }
+""",
+ from_path=source,
+ ),
+ )
+ ctx.add_transforms(
+ target1,
+ reference1,
+ [
+ FTL.Message(
+ id=FTL.Identifier("quick-filter-bar-results"),
+ value=PLURALS(
+ source,
+ "quickFilterBar.resultsLabel.some.formatString",
+ VARIABLE_REFERENCE("count"),
+ lambda text: REPLACE_IN_TEXT(text, {"#1": VARIABLE_REFERENCE("count")}),
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("quick-filter-bar-textbox-shortcut"),
+ value=Transform.pattern_of(
+ FTL.SelectExpression(
+ selector=FTL.FunctionReference(
+ id=FTL.Identifier("PLATFORM"),
+ arguments=FTL.CallArguments(),
+ ),
+ variants=[
+ FTL.Variant(
+ key=FTL.Identifier("macos"),
+ default=False,
+ value=REPLACE(
+ source,
+ "quickFilterBar.textbox.emptyText.keyLabel2.mac",
+ {
+ "<": FTL.TextElement(""),
+ "⇧⌘": FTL.TextElement("⇧ ⌘ "),
+ ">": FTL.TextElement(""),
+ },
+ ),
+ ),
+ FTL.Variant(
+ key=FTL.Identifier("other"),
+ default=True,
+ value=REPLACE(
+ source,
+ "quickFilterBar.textbox.emptyText.keyLabel2.nonmac",
+ {
+ "<": FTL.TextElement(""),
+ ">": FTL.TextElement(""),
+ },
+ ),
+ ),
+ ],
+ )
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("quick-filter-bar-textbox"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("placeholder"),
+ value=REPLACE(
+ source,
+ "quickFilterBar.textbox.emptyText.base1",
+ {
+ "#1": CONCAT(
+ FTL.TextElement("<"),
+ MESSAGE_REFERENCE("quick-filter-bar-textbox-shortcut"),
+ FTL.TextElement(">"),
+ )
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ )
+ ctx.add_transforms(
+ target1,
+ reference1,
+ transforms_from(
+ """
+quick-filter-bar-boolean-mode =
+ .title = { COPY(from_path, "quickFilterBar.booleanMode.tooltip") }
+quick-filter-bar-boolean-mode-any =
+ .label = { COPY(from_path, "quickFilterBar.booleanModeAny.label") }
+ .title = { COPY(from_path, "quickFilterBar.booleanModeAny.tooltip") }
+quick-filter-bar-boolean-mode-all =
+ .label = { COPY(from_path, "quickFilterBar.booleanModeAll.label") }
+ .title = { COPY(from_path, "quickFilterBar.booleanModeAll.tooltip") }
+quick-filter-bar-text-filter-explanation = { COPY(from_path, "quickFilterBar.textFilter.explanation.label") }
+quick-filter-bar-text-filter-sender = { COPY(from_path, "quickFilterBar.textFilter.sender.label") }
+quick-filter-bar-text-filter-recipients = { COPY(from_path, "quickFilterBar.textFilter.recipients.label") }
+quick-filter-bar-text-filter-subject = { COPY(from_path, "quickFilterBar.textFilter.subject.label") }
+quick-filter-bar-text-filter-body = { COPY(from_path, "quickFilterBar.textFilter.body.label") }
+quick-filter-bar-gloda-upsell-line1 = { COPY(from_path, "quickFilterBar.glodaUpsell.continueSearch") }
+""",
+ from_path=source,
+ ),
+ )
+ ctx.add_transforms(
+ target1,
+ reference1,
+ [
+ FTL.Message(
+ id=FTL.Identifier("quick-filter-bar-gloda-upsell-line2"),
+ value=REPLACE(
+ source,
+ "quickFilterBar.glodaUpsell.pressEnterAndCurrent",
+ {
+ "#1": VARIABLE_REFERENCE("text"),
+ " '": FTL.TextElement(" ‘"),
+ "' ": FTL.TextElement("’ "),
+ },
+ ),
+ ),
+ ],
+ )
+
+ target2 = reference2 = "mail/messenger/messenger.ftl"
+ ctx.add_transforms(
+ target2,
+ reference2,
+ transforms_from(
+ """
+quick-filter-bar-toggle =
+ .label = { COPY(from_path, "quickFilterBar.toggleBarVisibility.menu.label") }
+ .accesskey = { COPY(from_path, "quickFilterBar.toggleBarVisibility.menu.accesskey") }
+quick-filter-bar-show =
+ .key = { COPY(from_path, "quickFilterBar.show.key2") }
+""",
+ from_path=source,
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1809312_unified_toolbar_buttons.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1809312_unified_toolbar_buttons.py
new file mode 100644
index 0000000000..6558f5d6a7
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1809312_unified_toolbar_buttons.py
@@ -0,0 +1,218 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1809312 - Implement mail space actions for unified toolbar fluent migration part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+spacer-label = { COPY(from_path, "springTitle") }
+ """,
+ from_path="mail/chrome/messenger/customizeToolbar.properties",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-write-message-label = { COPY(from_path, "newMsgButton.label") }
+
+toolbar-write-message =
+ .title = { COPY(from_path, "newMsgButton.tooltip") }
+
+toolbar-folder-location-label = { COPY(from_path, "folderLocationToolbarItem.title") }
+
+toolbar-get-messages-label = { COPY(from_path, "getMsgButton1.label") }
+
+toolbar-reply-label = { COPY(from_path, "replyButton.label") }
+
+toolbar-reply =
+ .title = { COPY(from_path, "replyButton.tooltip") }
+
+toolbar-reply-all-label = { COPY(from_path, "replyAllButton.label") }
+
+toolbar-reply-all =
+ .title = { COPY(from_path, "replyAllButton.tooltip") }
+
+toolbar-reply-to-list-label = { COPY(from_path, "replyListButton.label") }
+
+toolbar-reply-to-list =
+ .title = { COPY(from_path, "replyListButton.tooltip") }
+
+toolbar-archive-label = { COPY(from_path, "archiveButton.label") }
+
+toolbar-archive =
+ .title = { COPY(from_path, "archiveButton.tooltip") }
+
+toolbar-conversation-label = { COPY(from_path, "openConversationButton.label") }
+
+toolbar-conversation =
+ .title = { COPY(from_path, "openMsgConversationButton.tooltip") }
+
+toolbar-previous-unread-label = { COPY(from_path, "previousButtonToolbarItem.label") }
+
+toolbar-previous-unread =
+ .title = { COPY(from_path, "previousButton.tooltip") }
+
+toolbar-previous-label = { COPY(from_path, "previousButton.label") }
+
+toolbar-previous =
+ .title = { COPY(from_path, "previousMsgButton.tooltip") }
+
+toolbar-next-unread-label = { COPY(from_path, "nextButtonToolbarItem.label") }
+
+toolbar-next-unread =
+ .title = { COPY(from_path, "nextButton.tooltip") }
+
+toolbar-next-label = { COPY(from_path, "nextMsgButton.label") }
+
+toolbar-next =
+ .title = { COPY(from_path, "nextMsgButton.tooltip") }
+
+toolbar-compact-label = { COPY(from_path, "compactButton.label") }
+
+toolbar-compact =
+ .title = { COPY(from_path, "compactButton.tooltip") }
+
+toolbar-tag-message-label = { COPY(from_path, "tagButton.label") }
+
+toolbar-tag-message =
+ .title = { COPY(from_path, "tagButton.tooltip") }
+
+toolbar-forward-inline-label = { COPY(from_path, "forwardButton.label") }
+
+toolbar-forward-inline =
+ .title = { COPY(from_path, "forwardAsInline.tooltip") }
+
+toolbar-forward-attachment-label = { COPY(from_path, "buttonMenuForwardAsAttachment.label") }
+
+toolbar-forward-attachment =
+ .title = { COPY(from_path, "forwardAsAttachment.tooltip") }
+
+toolbar-mark-as-label = { COPY(from_path, "markButton.label") }
+
+toolbar-mark-as =
+ .title = { COPY(from_path, "markButton.tooltip") }
+
+toolbar-address-book-label = { COPY(from_path, "addressBookButton.title") }
+
+toolbar-address-book =
+ .title = { COPY(from_path, "addressBookButton.tooltip") }
+
+toolbar-chat-label = { COPY(from_path, "chatButton.label") }
+
+toolbar-chat =
+ .title = { COPY(from_path, "chatButton.tooltip") }
+
+toolbar-print-label = { COPY(from_path, "printButton.label") }
+
+toolbar-print =
+ .title = { COPY(from_path, "printButton.tooltip") }
+ """,
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-unifinder-label = { COPY(from_path, "showUnifinderCmd.label") }
+
+toolbar-unifinder =
+ .title = { COPY(from_path, "showUnifinderCmd.tooltip") }
+ """,
+ from_path="calendar/chrome/calendar/menuOverlay.dtd",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-edit-event-label = { COPY(from_path, "lightning.toolbar.edit.label") }
+
+toolbar-edit-event =
+ .title = { COPY(from_path, "lightning.toolbar.edit.tooltip") }
+
+toolbar-calendar-label = { COPY(from_path, "lightning.toolbar.calendar.label") }
+
+toolbar-calendar =
+ .title = { COPY(from_path, "lightning.toolbar.calendar.tooltip") }
+
+toolbar-tasks-label = { COPY(from_path, "lightning.toolbar.task.label") }
+
+toolbar-tasks =
+ .title = { COPY(from_path, "lightning.toolbar.task.tooltip") }
+ """,
+ from_path="calendar/chrome/lightning/lightning-toolbar.dtd",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-redirect-label = { COPY_PATTERN(from_path, "redirect-msg-button.label") }
+
+toolbar-redirect =
+ .title = { COPY_PATTERN(from_path, "redirect-msg-button.tooltiptext") }
+
+toolbar-add-ons-and-themes-label = { COPY_PATTERN(from_path, "addons-and-themes-toolbarbutton.label") }
+
+toolbar-add-ons-and-themes =
+ .title = { COPY_PATTERN(from_path, "addons-and-themes-toolbarbutton.tooltiptext") }
+
+
+toolbar-quick-filter-bar-label = { COPY_PATTERN(from_path, "quick-filter-toolbarbutton.label") }
+
+toolbar-quick-filter-bar =
+ .title = { COPY_PATTERN(from_path, "quick-filter-toolbarbutton.tooltiptext") }
+ """,
+ from_path="mail/messenger/messenger.ftl",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-junk-label = { COPY_PATTERN(from_path, "toolbar-junk-button.label") }
+
+toolbar-junk =
+ .title = { COPY_PATTERN(from_path, "toolbar-junk-button.tooltiptext") }
+
+toolbar-delete-label = { COPY_PATTERN(from_path, "toolbar-delete-button.label") }
+
+toolbar-delete =
+ .title = { COPY_PATTERN(from_path, "toolbar-delete-button.tooltiptext") }
+ """,
+ from_path="mail/messenger/menubar.ftl",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-add-as-event-label = { COPY(from_path, "calendar.extract.event.button") }
+
+toolbar-add-as-event =
+ .title = { COPY(from_path, "calendar.extract.event.button.tooltip") }
+
+toolbar-add-as-task-label = { COPY(from_path, "calendar.extract.task.button") }
+
+toolbar-add-as-task =
+ .title = { COPY(from_path, "calendar.extract.task.button.tooltip") }
+ """,
+ from_path="calendar/chrome/calendar/calendar.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1811400_thread_pane_column_picker.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1811400_thread_pane_column_picker.py
new file mode 100644
index 0000000000..925a4cbbb7
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1811400_thread_pane_column_picker.py
@@ -0,0 +1,79 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import fluent.syntax.ast as FTL
+from fluent.migrate.helpers import VARIABLE_REFERENCE, transforms_from
+from fluent.migrate.transforms import REPLACE
+
+
+def migrate(ctx):
+ """Bug 1811400 - Migrate thread pane strings, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/about3Pane.ftl",
+ "mail/messenger/about3Pane.ftl",
+ transforms_from(
+ """
+apply-columns-to-menu =
+ .label = { COPY(from_path, "columnPicker.applyTo.label") }
+
+apply-current-view-to-folder =
+ .label = { COPY(from_path, "columnPicker.applyToFolder.label") }
+
+apply-current-view-to-folder-children =
+ .label = { COPY(from_path, "columnPicker.applyToFolderAndChildren.label") }
+ """,
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/about3Pane.ftl",
+ "mail/messenger/about3Pane.ftl",
+ transforms_from(
+ """
+apply-current-view-to-menu =
+ .label = { COPY_PATTERN(from_path, "apply-current-view-to-menu.label") }
+
+apply-changes-to-folder-title = {
+ COPY_PATTERN(from_path, "threadpane-apply-changes-prompt-title")
+}
+
+apply-current-view-to-folder-message = {
+ COPY_PATTERN(from_path, "threadpane-apply-changes-prompt-no-children-text")
+}
+
+apply-current-view-to-folder-with-children-message = {
+ COPY_PATTERN(from_path, "threadpane-apply-changes-prompt-with-children-text")
+}
+ """,
+ from_path="mail/messenger/mailWidgets.ftl",
+ ),
+ )
+ ctx.add_transforms(
+ "mail/messenger/about3Pane.ftl",
+ "mail/messenger/about3Pane.ftl",
+ [
+ FTL.Message(
+ id=FTL.Identifier("apply-current-columns-to-folder-message"),
+ value=REPLACE(
+ "mail/chrome/messenger/messenger.properties",
+ "threadPane.columnPicker.confirmFolder.noChildren.message",
+ {
+ "%1$S": VARIABLE_REFERENCE("name"),
+ },
+ normalize_printf=True,
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("apply-current-columns-to-folder-with-children-message"),
+ value=REPLACE(
+ "mail/chrome/messenger/messenger.properties",
+ "threadPane.columnPicker.confirmFolder.withChildren.message",
+ {
+ "%1$S": VARIABLE_REFERENCE("name"),
+ },
+ normalize_printf=True,
+ ),
+ ),
+ ],
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1814393_unified_toolbar_customization_tabs.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1814393_unified_toolbar_customization_tabs.py
new file mode 100644
index 0000000000..5f4c3b469b
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1814393_unified_toolbar_customization_tabs.py
@@ -0,0 +1,36 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1814393 - Unified toolbar customization tab titles part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbar.ftl",
+ "mail/messenger/unifiedToolbar.ftl",
+ transforms_from(
+ """
+customize-space-tab-mail = { COPY_PATTERN(from_path, "customize-space-mail") }
+ .title = { COPY_PATTERN(from_path, "customize-space-mail") }
+
+customize-space-tab-addressbook = { COPY_PATTERN(from_path, "customize-space-addressbook") }
+ .title = { COPY_PATTERN(from_path, "customize-space-addressbook") }
+
+customize-space-tab-calendar = { COPY_PATTERN(from_path, "customize-space-calendar") }
+ .title = { COPY_PATTERN(from_path, "customize-space-calendar") }
+
+customize-space-tab-tasks = { COPY_PATTERN(from_path, "customize-space-tasks") }
+ .title = { COPY_PATTERN(from_path, "customize-space-tasks") }
+
+customize-space-tab-chat = { COPY_PATTERN(from_path, "customize-space-chat") }
+ .title = { COPY_PATTERN(from_path, "customize-space-chat") }
+
+customize-space-tab-settings = { COPY_PATTERN(from_path, "customize-space-settings") }
+ .title = { COPY_PATTERN(from_path, "customize-space-settings") }
+ """,
+ from_path="mail/messenger/unifiedToolbar.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1814664_unified_toolbar_calendar_items.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1814664_unified_toolbar_calendar_items.py
new file mode 100644
index 0000000000..5985124c42
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1814664_unified_toolbar_calendar_items.py
@@ -0,0 +1,48 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1814664 - Add calendar items to unified toolbar fluent migration part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-synchronize-label = { COPY(from_path, "lightning.toolbar.sync.label") }
+
+toolbar-synchronize =
+ .title = { COPY(from_path, "lightning.toolbar.sync.tooltip") }
+
+toolbar-delete-event-label = { COPY(from_path, "lightning.toolbar.delete.label") }
+
+toolbar-delete-event =
+ .title = { COPY(from_path, "lightning.toolbar.delete.tooltip") }
+
+toolbar-go-to-today-label = { COPY(from_path, "lightning.toolbar.gototoday.label") }
+
+toolbar-go-to-today =
+ .title = { COPY(from_path, "lightning.toolbar.gototoday.tooltip") }
+
+toolbar-print-event-label = { COPY(from_path, "lightning.toolbar.print.label") }
+
+toolbar-print-event =
+ .title = { COPY(from_path, "lightning.toolbar.print.tooltip") }
+
+toolbar-new-event-label = { COPY(from_path, "lightning.toolbar.newevent.label") }
+
+toolbar-new-event =
+ .title = { COPY(from_path, "lightning.toolbar.newevent.tooltip") }
+
+toolbar-new-task-label = { COPY(from_path, "lightning.toolbar.newtask.label") }
+
+toolbar-new-task =
+ .title = { COPY(from_path, "lightning.toolbar.newtask.tooltip") }
+ """,
+ from_path="calendar/chrome/lightning/lightning-toolbar.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1815489_add_back_forward_and_stop.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1815489_add_back_forward_and_stop.py
new file mode 100644
index 0000000000..8dab08d9ab
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1815489_add_back_forward_and_stop.py
@@ -0,0 +1,33 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb import COPY
+from fluent.migratetb.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1815489 - Add back, forward and stop to unified toolbar fluent migration part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-go-back-label = { COPY(from_path, "backButton1.label") }
+
+toolbar-go-back =
+ .title = { COPY(from_path, "goBackButton.tooltip") }
+
+toolbar-go-forward-label = { COPY(from_path, "goForwardButton1.label") }
+
+toolbar-go-forward =
+ .title = { COPY(from_path, "goForwardButton.tooltip") }
+
+toolbar-stop-label = { COPY(from_path, "stopButton.label") }
+
+toolbar-stop =
+ .title = { COPY(from_path, "stopButton.tooltip") }
+ """,
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1815605_calendar_context_menu_has_empty_items.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1815605_calendar_context_menu_has_empty_items.py
new file mode 100644
index 0000000000..de0157ac47
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1815605_calendar_context_menu_has_empty_items.py
@@ -0,0 +1,52 @@
+# coding=utf8
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate.helpers import transforms_from
+from fluent.migrate import COPY
+
+
+def migrate(ctx):
+ """Bug 1815605 - calendar context menu has empty items, part {index}."""
+
+ ctx.add_transforms(
+ "calendar/calendar/calendar-widgets.ftl",
+ "calendar/calendar/calendar-widgets.ftl",
+ transforms_from(
+ """
+calendar-context-menu-previous-day =
+ .label = { COPY(from_path, "calendar.prevday.label") }
+ .accesskey = { COPY(from_path, "calendar.prevday.accesskey") }
+
+calendar-context-menu-previous-week =
+ .label = { COPY(from_path, "calendar.prevweek.label") }
+ .accesskey = { COPY(from_path, "calendar.prevweek.accesskey") }
+
+calendar-context-menu-previous-multiweek =
+ .label = { COPY(from_path, "calendar.prevweek.label") }
+ .accesskey = { COPY(from_path, "calendar.prevweek.accesskey") }
+
+calendar-context-menu-previous-month =
+ .label = { COPY(from_path, "calendar.prevmonth.label") }
+ .accesskey = { COPY(from_path, "calendar.prevmonth.accesskey") }
+
+calendar-context-menu-next-day =
+ .label = { COPY(from_path, "calendar.nextday.label") }
+ .accesskey = { COPY(from_path, "calendar.nextday.accesskey") }
+
+calendar-context-menu-next-week =
+ .label = { COPY(from_path, "calendar.nextweek.label") }
+ .accesskey = { COPY(from_path, "calendar.nextweek.accesskey") }
+
+calendar-context-menu-next-multiweek =
+ .label = { COPY(from_path, "calendar.nextweek.label") }
+ .accesskey = { COPY(from_path, "calendar.nextweek.accesskey") }
+
+calendar-context-menu-next-month =
+ .label = { COPY(from_path, "calendar.nextmonth.label") }
+ .accesskey = { COPY(from_path, "calendar.nextmonth.accesskey") }
+""",
+ from_path="calendar/chrome/calendar/calendar.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1816532_about_dialog_migration.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1816532_about_dialog_migration.py
new file mode 100644
index 0000000000..cc47261099
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1816532_about_dialog_migration.py
@@ -0,0 +1,94 @@
+# coding=utf8
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+
+from fluent.migratetb.helpers import TERM_REFERENCE
+from fluent.migratetb.helpers import transforms_from
+
+
+# This can't just be a straight up literal dict (eg: {"a":"b"}) because the
+# validator fails... so make it a function call that returns a dict.. it works
+about_replacements = dict(
+ {
+ "&brandShorterName;": TERM_REFERENCE("brand-shorter-name"),
+ "&brandShortName;": TERM_REFERENCE("brand-short-name"),
+ "&vendorShortName;": TERM_REFERENCE("vendor-short-name"),
+ }
+)
+
+
+def migrate(ctx):
+ """Bug 1816532 - Migrate aboutDialog.dtd strings to Fluent, part {index}"""
+ target = reference = "mail/messenger/aboutDialog.ftl"
+ source = "mail/chrome/messenger/aboutDialog.dtd"
+
+ ctx.add_transforms(
+ target,
+ reference,
+ transforms_from(
+ """
+release-notes-link = { COPY(source, "releaseNotes.link") }
+
+update-check-for-updates-button = { COPY(source, "update.checkForUpdatesButton.label") }
+ .accesskey = { COPY(source, "update.checkForUpdatesButton.accesskey") }
+
+update-update-button = { REPLACE(source, "update.updateButton.label3", about_replacements) }
+ .accesskey = { COPY(source, "update.updateButton.accesskey") }
+
+update-checking-for-updates = { COPY(source, "update.checkingForUpdates") }
+
+update-downloading-message = { COPY(source, "update.downloading.start") }<span data-l10n-name="download-status"></span>
+
+update-applying = { COPY(source, "update.applying") }
+
+update-downloading = <img data-l10n-name="icon"/>{ COPY(source, "update.downloading.start") }<span data-l10n-name="download-status"></hspan>
+
+update-failed = { COPY(source, "update.failed.start") }<a data-l10n-name="failed-link">{ COPY(source, "update.failed.linkText") }</a>
+
+update-admin-disabled = { COPY(source, "update.adminDisabled") }
+
+update-no-updates-found = { REPLACE(source, "update.noUpdatesFound", about_replacements) }
+
+update-other-instance-handling-updates = { REPLACE(source, "update.otherInstanceHandlingUpdates", about_replacements) }
+
+update-unsupported = { COPY(source, "update.unsupported.start") }<a data-l10n-name="unsupported-link">{ COPY(source, "update.unsupported.linkText") }</a>
+
+update-restarting = { COPY(source, "update.restarting") }
+
+channel-description = { COPY(source, "channel.description.start") }<span data-l10n-name="current-channel">{ $channel }</span> { COPY(source, "channel.description.end", trim: "True") }
+
+warning-desc-version = { REPLACE(source, "warningDesc.version", about_replacements) }
+
+warning-desc-telemetry = { REPLACE(source, "warningDesc.telemetryDesc", about_replacements) }
+
+community-exp = <a data-l10n-name="community-exp-mozilla-link">
+ { REPLACE(source, "community.exp.mozillaLink", about_replacements) }</a>
+ { COPY(source, "community.exp.middle") }<a data-l10n-name="community-exp-credits-link">
+ { COPY(source, "community.exp.creditsLink") }</a>
+ { COPY(source, "community.exp.end") }
+
+community-2 = { REPLACE(source, "community.start2", about_replacements) }<a data-l10n-name="community-mozilla-link">
+ { REPLACE(source, "community.mozillaLink", about_replacements) }</a>
+ { COPY(source, "community.middle2") }<a data-l10n-name="community-credits-link">
+ { COPY(source, "community.creditsLink") }</a>
+ { COPY(source, "community.end3") }
+
+about-helpus = { COPY(source, "helpus.start") }<a data-l10n-name="helpus-donate-link">
+ { COPY(source, "helpus.donateLink") }</a> or <a data-l10n-name="helpus-get-involved-link">
+ { COPY(source, "helpus.getInvolvedLink") }</a>
+
+bottom-links-license = { COPY(source, "bottomLinks.license") }
+
+bottom-links-rights = { COPY(source, "bottomLinks.rights") }
+
+bottom-links-privacy = { COPY(source, "bottomLinks.privacy") }
+
+cmd-close-mac-command-key =
+ .key = { COPY(source, "cmdCloseMac.commandKey") }
+""",
+ source=source,
+ about_replacements=about_replacements,
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1816593_flexbox_emulation_dialogs.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1816593_flexbox_emulation_dialogs.py
new file mode 100644
index 0000000000..a920b9c0fd
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1816593_flexbox_emulation_dialogs.py
@@ -0,0 +1,21 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1816593 - Fix wrong sized dialogs due to flexbox emulation, part {index}"""
+
+ ctx.add_transforms(
+ "mail/messenger/compactFoldersDialog.ftl",
+ "mail/messenger/compactFoldersDialog.ftl",
+ transforms_from(
+ """
+compact-dialog-window-title =
+ .title = {{COPY_PATTERN(from_path, "compact-dialog-window.title")}}
+ """,
+ from_path="mail/messenger/compactFoldersDialog.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1817914_tags_mode.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1817914_tags_mode.py
new file mode 100644
index 0000000000..d8b3c80d0c
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1817914_tags_mode.py
@@ -0,0 +1,21 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1820700 - Implement a Tags folder pane mode, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/messenger.ftl",
+ "mail/messenger/messenger.ftl",
+ transforms_from(
+ """
+show-tags-folders-label =
+ .label = {COPY(from_path, "viewTags.label")}
+ .accesskey = {COPY(from_path, "viewTags.accesskey")}
+""",
+ from_path="mail/chrome/messenger/msgViewPickerOverlay.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1817915_get_new_messages.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1817915_get_new_messages.py
new file mode 100644
index 0000000000..2e2c1ac497
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1817915_get_new_messages.py
@@ -0,0 +1,22 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb.helpers import transforms_from
+from fluent.migratetb import COPY
+
+
+def migrate(ctx):
+ """Bug 1817915 - Add context menu to Get Messages folder pane button to fetch all messages or per account, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/about3Pane.ftl",
+ "mail/messenger/about3Pane.ftl",
+ transforms_from(
+ """
+folder-pane-get-all-messages-menuitem =
+ .label = { COPY(from_path, "getAllNewMsgCmd.label") }
+ .accesskey = { COPY(from_path, "getAllNewMsgCmd.accesskey") }
+ """,
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1820700_select_thread.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1820700_select_thread.py
new file mode 100644
index 0000000000..ae65f694d1
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1820700_select_thread.py
@@ -0,0 +1,35 @@
+# 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/.
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import fluent.syntax.ast as FTL
+from fluent.migratetb.helpers import VARIABLE_REFERENCE, transforms_from
+from fluent.migratetb.transforms import REPLACE
+
+
+def migrate(ctx):
+ """Bug 1820700 - Migrate thread img strings to button, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/treeView.ftl",
+ "mail/messenger/treeView.ftl",
+ transforms_from(
+ """
+tree-list-view-row-thread-button =
+ .title = {COPY_PATTERN(from_path, "tree-list-view-row-thread-icon.title")}
+
+tree-list-view-row-ignored-thread-button =
+ .title = {COPY_PATTERN(from_path, "tree-list-view-row-ignored-thread-icon.title")}
+
+tree-list-view-row-ignored-subthread-button =
+ .title = {COPY_PATTERN(from_path, "tree-list-view-row-ignored-subthread-icon.title")}
+
+tree-list-view-row-watched-thread-button =
+ .title = {COPY_PATTERN(from_path, "tree-list-view-row-watched-thread-icon.title")}
+""",
+ from_path="mail/messenger/treeView.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1823033_activity_indicator.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1823033_activity_indicator.py
new file mode 100644
index 0000000000..4cfaec4695
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1823033_activity_indicator.py
@@ -0,0 +1,23 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb import COPY
+from fluent.migratetb.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1823033 - Add activity indicator to unified toolbar fluent migration part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/unifiedToolbarItems.ftl",
+ "mail/messenger/unifiedToolbarItems.ftl",
+ transforms_from(
+ """
+toolbar-throbber-label = { COPY(from_path, "throbberItem.title") }
+
+toolbar-throbber =
+ .title = { COPY(from_path, "throbberItem.title") }
+ """,
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1827261_add_enable_disable_compact_mode_options_to_folder_mode_context_menu.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1827261_add_enable_disable_compact_mode_options_to_folder_mode_context_menu.py
new file mode 100644
index 0000000000..7ea090e903
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1827261_add_enable_disable_compact_mode_options_to_folder_mode_context_menu.py
@@ -0,0 +1,26 @@
+# 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/.
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb.helpers import transforms_from
+from fluent.migratetb import COPY
+
+
+def migrate(ctx):
+ """Bug 1827261 - Add enable/disable compact mode options to Folder Mode context menu, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/about3Pane.ftl",
+ "mail/messenger/about3Pane.ftl",
+ transforms_from(
+ """
+folder-pane-mode-context-toggle-compact-mode =
+ .label = { COPY(from_path, "compactVersion.label") }
+ .accesskey = { COPY(from_path, "compactVersion.accesskey") }
+ """,
+ from_path="mail/chrome/messenger/messenger.dtd",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1827891_dnt_prefs_learn_more.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1827891_dnt_prefs_learn_more.py
new file mode 100644
index 0000000000..4861e428bc
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1827891_dnt_prefs_learn_more.py
@@ -0,0 +1,21 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migratetb import COPY_PATTERN
+from fluent.migratetb.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1827891 - DNT Prefs 'learn more' link fix, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/preferences/preferences.ftl",
+ "mail/messenger/preferences/preferences.ftl",
+ transforms_from(
+ """
+dnt-learn-more-button =
+ .value = {{ COPY_PATTERN(from_path, "learn-button.label") }}
+ """,
+ from_path="mail/messenger/preferences/preferences.ftl",
+ ),
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1828340_aboutdialog_layout_fixes.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1828340_aboutdialog_layout_fixes.py
new file mode 100644
index 0000000000..d162e94659
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1828340_aboutdialog_layout_fixes.py
@@ -0,0 +1,42 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import re
+
+from fluent.migratetb import COPY_PATTERN
+from fluent.migratetb.transforms import TransformPattern
+
+import fluent.syntax.ast as FTL
+
+
+class STRIP_NEWLINES(TransformPattern):
+ def visit_TextElement(self, node):
+ node.value = re.sub("\n", "", node.value)
+ return node
+
+
+def migrate(ctx):
+ """Bug 1828340 - Fix aboutDialog layout issues, part {index}."""
+ path = "mail/messenger/aboutDialog.ftl"
+ ctx.add_transforms(
+ path,
+ path,
+ [
+ FTL.Message(
+ id=FTL.Identifier("about-dialog-title"),
+ value=COPY_PATTERN(path, "aboutDialog-title.title"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("community-experimental"),
+ value=STRIP_NEWLINES(path, "community-exp"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("community-desc"),
+ value=STRIP_NEWLINES(path, "community-2"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("about-donation"),
+ value=STRIP_NEWLINES(path, "about-helpus"),
+ ),
+ ],
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1830004_folder_quota.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1830004_folder_quota.py
new file mode 100644
index 0000000000..07f4505912
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1830004_folder_quota.py
@@ -0,0 +1,28 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import fluent.syntax.ast as FTL
+from fluent.migratetb.helpers import VARIABLE_REFERENCE, transforms_from
+from fluent.migratetb.transforms import REPLACE
+
+
+def migrate(ctx):
+ """Bug 1830004 - Migrate a quota string, part {index}."""
+
+ ctx.add_transforms(
+ "mail/messenger/folderprops.ftl",
+ "mail/messenger/folderprops.ftl",
+ [
+ FTL.Message(
+ id=FTL.Identifier("quota-percent-used"),
+ value=REPLACE(
+ "mail/chrome/messenger/messenger.properties",
+ "quotaPercentUsed",
+ {
+ "%1$S": VARIABLE_REFERENCE("percent"),
+ },
+ normalize_printf=True,
+ ),
+ ),
+ ],
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_cal_enable78.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_cal_enable78.py
new file mode 100644
index 0000000000..b3f6c420c6
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_cal_enable78.py
@@ -0,0 +1,30 @@
+# 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/.
+
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import fluent.syntax.ast as FTL
+from fluent.migratetb.helpers import VARIABLE_REFERENCE, transforms_from
+from fluent.migratetb.transforms import REPLACE
+from fluent.migratetb.transforms import COPY
+
+
+def migrate(ctx):
+ """Bug 1834662 - Migrate calendar enable button, part {index}."""
+ target = reference = "calendar/calendar/calendar-widgets.ftl"
+
+ ctx.add_transforms(
+ target,
+ reference,
+ [
+ FTL.Message(
+ id=FTL.Identifier("calendar-enable-button"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.sideloadEnable.label",
+ ),
+ ),
+ ],
+ )
diff --git a/comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_extensions_to_fluent.py b/comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_extensions_to_fluent.py
new file mode 100644
index 0000000000..cdd8736b67
--- /dev/null
+++ b/comm/python/l10n/tb_fluent_migrations/completed/bug_1834662_extensions_to_fluent.py
@@ -0,0 +1,531 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+import fluent.syntax.ast as FTL
+from fluent.migratetb.helpers import TERM_REFERENCE, VARIABLE_REFERENCE
+from fluent.migratetb.transforms import (
+ COPY,
+ COPY_PATTERN,
+ PLURALS,
+ REPLACE,
+ REPLACE_IN_TEXT,
+)
+
+
+def migrate(ctx):
+ """Bug 1834662 - Migrate addon/extension stuff, part {index}."""
+
+ # extensionPermissions.ftl - from addons.properties
+ ctx.add_transforms(
+ "mail/messenger/extensionPermissions.ftl",
+ "mail/messenger/extensionPermissions.ftl",
+ [
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-accountsFolders"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.accountsFolders",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-accountsIdentities"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.accountsIdentities",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-accountsRead"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.accountsRead2",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-addressBooks"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.addressBooks",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-compose"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.compose",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-compose"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.compose",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-compose-send"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.compose.send",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-compose-save"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.compose.save",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-experiment"),
+ value=REPLACE(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.experiment",
+ {"%1$S": TERM_REFERENCE("brand-short-name")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-messagesImport"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.messagesImport",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-messagesModify"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.messagesModify",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-messagesMove"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.messagesMove2",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-messagesDelete"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.messagesDelete",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-messagesRead"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.messagesRead",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-messagesTags"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.messagesTags",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-description-sensitiveDataUpload"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.description.sensitiveDataUpload",
+ ),
+ ),
+ ],
+ )
+
+ # extensionsUI.ftl - from here and there
+ ctx.add_transforms(
+ "mail/messenger/extensionsUI.ftl",
+ "mail/messenger/extensionsUI.ftl",
+ [
+ FTL.Message(
+ id=FTL.Identifier("webext-experiment-warning"),
+ value=COPY(
+ "mail/chrome/messenger/addons.properties",
+ "webextPerms.experimentWarning",
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-learn-more"),
+ value=COPY("mail/chrome/messenger/addons.properties", "webextPerms.learnMore2"),
+ ),
+ ],
+ )
+
+ # addonNotifications.ftl - copied from browser/ migration script
+
+ addons_properties = "mail/chrome/messenger/addons.properties"
+ notifications = "mail/messenger/addonNotifications.ftl"
+
+ ctx.add_transforms(
+ notifications,
+ notifications,
+ [
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt"),
+ value=REPLACE(
+ addons_properties,
+ "xpinstallPromptMessage",
+ {"%1$S": TERM_REFERENCE("brand-short-name")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-header"),
+ value=REPLACE(
+ addons_properties,
+ "xpinstallPromptMessage.header",
+ {"%1$S": VARIABLE_REFERENCE("host")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-message"),
+ value=REPLACE(
+ addons_properties,
+ "xpinstallPromptMessage.message",
+ {"%1$S": VARIABLE_REFERENCE("host")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-header-unknown"),
+ value=COPY(addons_properties, "xpinstallPromptMessage.header.unknown"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-message-unknown"),
+ value=COPY(addons_properties, "xpinstallPromptMessage.message.unknown"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-dont-allow"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(addons_properties, "xpinstallPromptMessage.dontAllow"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(
+ addons_properties,
+ "xpinstallPromptMessage.dontAllow.accesskey",
+ ),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-never-allow"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(addons_properties, "xpinstallPromptMessage.neverAllow"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(
+ addons_properties,
+ "xpinstallPromptMessage.neverAllow.accesskey",
+ ),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-never-allow-and-report"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(
+ addons_properties,
+ "xpinstallPromptMessage.neverAllowAndReport",
+ ),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(
+ addons_properties,
+ "xpinstallPromptMessage.neverAllowAndReport.accesskey",
+ ),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("site-permission-install-first-prompt-midi-header"),
+ value=COPY(addons_properties, "sitePermissionInstallFirstPrompt.midi.header"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("site-permission-install-first-prompt-midi-message"),
+ value=COPY(addons_properties, "sitePermissionInstallFirstPrompt.midi.message"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-prompt-install"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(addons_properties, "xpinstallPromptMessage.install"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(
+ addons_properties,
+ "xpinstallPromptMessage.install.accesskey",
+ ),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-disabled-locked"),
+ value=COPY(addons_properties, "xpinstallDisabledMessageLocked"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-disabled"),
+ value=COPY(addons_properties, "xpinstallDisabledMessage"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("xpinstall-disabled-button"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(addons_properties, "xpinstallDisabledButton"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(addons_properties, "xpinstallDisabledButton.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-blocked-by-policy"),
+ value=REPLACE(
+ addons_properties,
+ "addonInstallBlockedByPolicy",
+ {
+ "%1$S": VARIABLE_REFERENCE("addonName"),
+ "%2$S": VARIABLE_REFERENCE("addonId"),
+ "%3$S": FTL.TextElement(""),
+ },
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-domain-blocked-by-policy"),
+ value=COPY(addons_properties, "addonDomainBlockedByPolicy"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-full-screen-blocked"),
+ value=COPY(addons_properties, "addonInstallFullScreenBlocked"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-sideload-menu-item"),
+ value=REPLACE(
+ addons_properties,
+ "webextPerms.sideloadMenuItem",
+ {
+ "%1$S": VARIABLE_REFERENCE("addonName"),
+ "%2$S": TERM_REFERENCE("brand-short-name"),
+ },
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("webext-perms-update-menu-item"),
+ value=REPLACE(
+ addons_properties,
+ "webextPerms.updateMenuItem",
+ {"%1$S": VARIABLE_REFERENCE("addonName")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-removal-message"),
+ value=REPLACE(
+ addons_properties,
+ "webext.remove.confirmation.message",
+ {
+ "%1$S": VARIABLE_REFERENCE("name"),
+ "%2$S": TERM_REFERENCE("brand-shorter-name"),
+ },
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-removal-button"),
+ value=COPY(addons_properties, "webext.remove.confirmation.button"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-downloading-and-verifying"),
+ value=PLURALS(
+ addons_properties,
+ "addonDownloadingAndVerifying",
+ VARIABLE_REFERENCE("addonCount"),
+ foreach=lambda n: REPLACE_IN_TEXT(
+ n,
+ {"#1": VARIABLE_REFERENCE("addonCount")},
+ ),
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-download-verifying"),
+ value=COPY(addons_properties, "addonDownloadVerifying"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-cancel-button"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(addons_properties, "addonInstall.cancelButton.label"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(addons_properties, "addonInstall.cancelButton.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-accept-button"),
+ attributes=[
+ FTL.Attribute(
+ id=FTL.Identifier("label"),
+ value=COPY(addons_properties, "addonInstall.acceptButton2.label"),
+ ),
+ FTL.Attribute(
+ id=FTL.Identifier("accesskey"),
+ value=COPY(addons_properties, "addonInstall.acceptButton2.accesskey"),
+ ),
+ ],
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-confirm-install-message"),
+ value=PLURALS(
+ addons_properties,
+ "addonConfirmInstall.message",
+ VARIABLE_REFERENCE("addonCount"),
+ foreach=lambda n: REPLACE_IN_TEXT(
+ n,
+ {
+ "#1": TERM_REFERENCE("brand-short-name"),
+ "#2": VARIABLE_REFERENCE("addonCount"),
+ },
+ ),
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-confirm-install-unsigned-message"),
+ value=PLURALS(
+ addons_properties,
+ "addonConfirmInstallUnsigned.message",
+ VARIABLE_REFERENCE("addonCount"),
+ foreach=lambda n: REPLACE_IN_TEXT(
+ n,
+ {
+ "#1": TERM_REFERENCE("brand-short-name"),
+ "#2": VARIABLE_REFERENCE("addonCount"),
+ },
+ ),
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-confirm-install-some-unsigned-message"),
+ value=PLURALS(
+ addons_properties,
+ "addonConfirmInstallSomeUnsigned.message",
+ VARIABLE_REFERENCE("addonCount"),
+ foreach=lambda n: REPLACE_IN_TEXT(
+ n,
+ {
+ "#1": TERM_REFERENCE("brand-short-name"),
+ "#2": VARIABLE_REFERENCE("addonCount"),
+ },
+ ),
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-network-failure"),
+ value=COPY(addons_properties, "addonInstallError-1"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-incorrect-hash"),
+ value=REPLACE(
+ addons_properties,
+ "addonInstallError-2",
+ {"%1$S": TERM_REFERENCE("brand-short-name")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-corrupt-file"),
+ value=COPY(addons_properties, "addonInstallError-3"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-file-access"),
+ value=REPLACE(
+ addons_properties,
+ "addonInstallError-4",
+ {
+ "%2$S": VARIABLE_REFERENCE("addonName"),
+ "%1$S": TERM_REFERENCE("brand-short-name"),
+ },
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-not-signed"),
+ value=REPLACE(
+ addons_properties,
+ "addonInstallError-5",
+ {"%1$S": TERM_REFERENCE("brand-short-name")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-invalid-domain"),
+ value=REPLACE(
+ addons_properties,
+ "addonInstallError-8",
+ {"%2$S": VARIABLE_REFERENCE("addonName")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-local-install-error-network-failure"),
+ value=COPY(addons_properties, "addonLocalInstallError-1"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-local-install-error-incorrect-hash"),
+ value=REPLACE(
+ addons_properties,
+ "addonLocalInstallError-2",
+ {"%1$S": TERM_REFERENCE("brand-short-name")},
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-local-install-error-corrupt-file"),
+ value=COPY(addons_properties, "addonLocalInstallError-3"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-local-install-error-file-access"),
+ value=REPLACE(
+ addons_properties,
+ "addonLocalInstallError-4",
+ {
+ "%2$S": VARIABLE_REFERENCE("addonName"),
+ "%1$S": TERM_REFERENCE("brand-short-name"),
+ },
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-local-install-error-not-signed"),
+ value=COPY(addons_properties, "addonLocalInstallError-5"),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-incompatible"),
+ value=REPLACE(
+ addons_properties,
+ "addonInstallErrorIncompatible",
+ {
+ "%3$S": VARIABLE_REFERENCE("addonName"),
+ "%1$S": TERM_REFERENCE("brand-short-name"),
+ "%2$S": VARIABLE_REFERENCE("appVersion"),
+ },
+ ),
+ ),
+ FTL.Message(
+ id=FTL.Identifier("addon-install-error-blocklisted"),
+ value=REPLACE(
+ addons_properties,
+ "addonInstallErrorBlocklisted",
+ {"%1$S": VARIABLE_REFERENCE("addonName")},
+ ),
+ ),
+ ],
+ )
diff --git a/comm/python/l10n/tbxchannel/__init__.py b/comm/python/l10n/tbxchannel/__init__.py
new file mode 100644
index 0000000000..b6599032bc
--- /dev/null
+++ b/comm/python/l10n/tbxchannel/__init__.py
@@ -0,0 +1,47 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+from pathlib import Path
+
+from .l10n_merge import COMM_STRINGS_QUARANTINE, COMM_STRINGS_QUARANTINE_PUSH
+
+TB_XC_NOTIFICATION_TMPL = """\
+**Thunderbird L10n Cross Channel**
+
+Changes pushed to `comm-strings-quarantine`: {rev_url}
+"""
+
+
+def get_thunderbird_xc_config(topsrcdir, strings_path):
+ assert isinstance(topsrcdir, Path)
+ assert isinstance(strings_path, Path)
+ return {
+ "strings": {
+ "path": strings_path,
+ "url": COMM_STRINGS_QUARANTINE,
+ "heads": {"default": "default"},
+ "update_on_pull": True,
+ "push_url": COMM_STRINGS_QUARANTINE_PUSH,
+ },
+ "source": {
+ "comm-central": {
+ "path": topsrcdir / "comm",
+ "url": "https://hg.mozilla.org/comm-central/",
+ "heads": {
+ # This list of repositories is ordered, starting with the
+ # one with the most recent content (central) to the oldest
+ # (ESR). In case two ESR versions are supported, the oldest
+ # ESR goes last (e.g. esr102 goes after esr115).
+ "comm": "comm-central",
+ "comm-beta": "releases/comm-beta",
+ "comm-esr102": "releases/comm-esr102",
+ },
+ "config_files": [
+ "comm/calendar/locales/l10n.toml",
+ "comm/mail/locales/l10n.toml",
+ "comm/suite/locales/l10n.toml",
+ ],
+ },
+ },
+ }
diff --git a/comm/python/l10n/tbxchannel/l10n_merge.py b/comm/python/l10n/tbxchannel/l10n_merge.py
new file mode 100644
index 0000000000..b202e7dfe0
--- /dev/null
+++ b/comm/python/l10n/tbxchannel/l10n_merge.py
@@ -0,0 +1,26 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+L10N_CENTRAL = "https://hg.mozilla.org/l10n-central"
+COMM_L10N = "https://hg.mozilla.org/projects/comm-l10n"
+COMM_L10N_PUSH = f"ssh{COMM_L10N[5:]}"
+COMM_STRINGS_QUARANTINE = "https://hg.mozilla.org/projects/comm-strings-quarantine"
+COMM_STRINGS_QUARANTINE_PUSH = f"ssh{COMM_STRINGS_QUARANTINE[5:]}"
+
+
+GECKO_STRINGS_PATTERNS = (
+ "{lang}/browser/pdfviewer/**",
+ "{lang}/devtools/**",
+ "{lang}/dom/**",
+ "{lang}/extensions/spellcheck/**",
+ "{lang}/netwerk/**",
+ "{lang}/security/**",
+ "{lang}/toolkit/**",
+)
+
+COMM_STRINGS_PATTERNS = (
+ "{lang}/calendar/**",
+ "{lang}/chat/**",
+ "{lang}/mail/**",
+)
diff --git a/comm/python/l10n/tbxchannel/quarantine_to_strings.py b/comm/python/l10n/tbxchannel/quarantine_to_strings.py
new file mode 100644
index 0000000000..def3f59e5e
--- /dev/null
+++ b/comm/python/l10n/tbxchannel/quarantine_to_strings.py
@@ -0,0 +1,194 @@
+# 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/.
+
+import logging
+import os
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+from typing_extensions import Literal
+
+from mozversioncontrol import HgRepository
+from mozversioncontrol.repoupdate import update_mercurial_repo
+
+from .l10n_merge import COMM_L10N, COMM_L10N_PUSH, COMM_STRINGS_QUARANTINE
+
+ACTIONS = Literal["clean", "prep", "migrate", "push"]
+
+
+class HgL10nRepository(HgRepository):
+ log_trans_table = str.maketrans({"{": "{{", "}": "}}"})
+
+ def __init__(self, path: Path, check_url=None, logger=print):
+ super(HgL10nRepository, self).__init__(path, hg="hg")
+ self._logger = logger
+ if check_url is not None:
+ self._check_hg_url(check_url)
+
+ def logger(self, *args):
+ # Escape python-style format string substitutions because Sentry is annoying
+ self._logger(*args[:-1], args[-1].translate(self.log_trans_table))
+
+ def _check_hg_url(self, repo_url):
+ configured_url = self._run("config", "paths.default").strip()
+ if configured_url != repo_url:
+ raise Exception(f"Repository does not match {repo_url}.")
+
+ def check_status(self):
+ if not self.working_directory_clean() or self.get_outgoing_files():
+ raise Exception(f"Repository at {self.path} is not clean, run with 'clean'.")
+
+ def last_convert_rev(self):
+ args = (
+ "log",
+ "-r",
+ "last(extra('convert_source', 'comm-strings-quarantine'))",
+ "--template",
+ "{get(extras,'convert_revision')}\n",
+ )
+ self.logger(logging.INFO, "last_convert_rev", {}, " ".join(args))
+ rv = self._run(*args).strip()
+ self.logger(logging.INFO, "last_convert_rev", {}, rv)
+ return rv
+
+ def next_convert_rev(self, last_converted):
+ args = ("log", "-r", f"first(children({last_converted}))", "--template", "{node}\n")
+ self.logger(logging.INFO, "next_convert_rev", {}, " ".join(args))
+ rv = self._run(*args).strip()
+ self.logger(logging.INFO, "next_convert_rev", {}, rv)
+ return rv
+
+ def convert_quarantine(self, strings_path, filemap_path, splicemap_path, next_converted_rev):
+ args = (
+ "convert",
+ "--config",
+ "convert.hg.saverev=True",
+ "--config",
+ "convert.hg.sourcename=comm-strings-quarantine",
+ "--config",
+ f"convert.hg.revs={next_converted_rev}:tip",
+ "--filemap",
+ filemap_path,
+ "--splicemap",
+ splicemap_path,
+ "--datesort",
+ str(self.path),
+ str(strings_path.absolute()),
+ )
+ self.logger(logging.INFO, "convert_quarantine", {}, " ".join(args))
+ rv = self._run(*args)
+ self.logger(logging.INFO, "convert_quarantine", {}, rv)
+ return rv
+
+ def push(self, push_url):
+ popen_kwargs = {
+ "stdout": subprocess.PIPE,
+ "stderr": subprocess.PIPE,
+ "cwd": self.path,
+ "env": self._env,
+ "universal_newlines": True,
+ "bufsize": 1,
+ }
+ cmd = ("hg", "push", "-r", ".", push_url)
+ self.logger(logging.INFO, "push", {}, " ".join(cmd))
+ # This function doesn't really push to try...
+ self._push_to_try_with_log_capture(cmd, popen_kwargs)
+
+
+def _nuke_hg_repos(*paths: Path):
+ failed = {}
+ for path in paths:
+ try:
+ if path.exists():
+ shutil.rmtree(str(path))
+ except Exception as e:
+ failed[str(path)] = e
+
+ if failed:
+ for f in failed:
+ print(f"Unable to nuke '{f}': {failed[f]}")
+ raise Exception()
+
+
+def publish_strings(
+ command_context,
+ quarantine_path: Path,
+ comm_l10n_path: Path,
+ actions: ACTIONS,
+ **kwargs,
+):
+ if "clean" in actions:
+ command_context.log(logging.INFO, "clean", {}, "Removing old repository clones.")
+ _nuke_hg_repos(quarantine_path, comm_l10n_path)
+
+ if "prep" in actions:
+ # update_mercurial_repo also will clone if a repo is not already there
+ command_context.log(
+ logging.INFO, "prep", {}, f"Updating comm-strings-quarantine at {quarantine_path}."
+ )
+ update_mercurial_repo("hg", COMM_STRINGS_QUARANTINE, quarantine_path)
+ command_context.log(logging.INFO, "prep", {}, f"Updating comm-l10n at {comm_l10n_path}.")
+ update_mercurial_repo("hg", COMM_L10N, comm_l10n_path)
+
+ local_quarantine = HgL10nRepository(
+ quarantine_path, COMM_STRINGS_QUARANTINE, command_context.log
+ )
+ local_comm_l10n = HgL10nRepository(comm_l10n_path, COMM_L10N, command_context.log)
+
+ if "prep" not in actions:
+ local_quarantine.update("tip")
+ local_comm_l10n.update("tip")
+
+ if "migrate" in actions:
+ local_quarantine.check_status()
+ local_comm_l10n.check_status()
+
+ command_context.log(
+ logging.INFO, "migrate", {}, "Starting string migration from quarantine."
+ )
+ head_rev = local_comm_l10n.head_ref
+ last_convert_rev = local_comm_l10n.last_convert_rev()
+ first_convert_rev = local_quarantine.next_convert_rev(last_convert_rev)
+ command_context.log(
+ logging.INFO, "migrate", {}, f" Last converted rev: {last_convert_rev}"
+ )
+ command_context.log(
+ logging.INFO, "migrate", {}, f" First converted rev: {first_convert_rev}"
+ )
+
+ with tempfile.NamedTemporaryFile(
+ prefix="splicemap", suffix=".txt", delete=False
+ ) as splice_fp:
+ splicemap = splice_fp.name
+ command_context.log(
+ logging.INFO, "migrate", {}, f" Writing splicemap to: {splicemap}"
+ )
+ splice_fp.write(f"{first_convert_rev} {head_rev}\n".encode("utf-8"))
+
+ with tempfile.NamedTemporaryFile(prefix="filemap", suffix=".txt", delete=False) as file_fp:
+ filemap = file_fp.name
+ command_context.log(logging.INFO, "migrate", {}, f" Writing filemap to: {filemap}")
+ file_fp.writelines(
+ ["exclude _configs\n".encode("utf-8"), "rename . en-US\n".encode("utf-8")]
+ )
+
+ command_context.log(logging.INFO, "migrate", {}, " Running hg convert...")
+ local_quarantine.convert_quarantine(comm_l10n_path, filemap, splicemap, first_convert_rev)
+ try:
+ os.unlink(splicemap)
+ os.unlink(filemap)
+ except Exception:
+ pass
+
+ local_comm_l10n.update("tip")
+ command_context.log(logging.INFO, "migrate", {}, " Finished!")
+
+ if "push" in actions:
+ if local_comm_l10n.get_outgoing_files():
+ command_context.log(logging.INFO, "push", {}, " Pushing to comm-l10n.")
+ local_comm_l10n.push(COMM_L10N_PUSH)
+ else:
+ command_context.log(logging.INFO, "push", {}, "Skipping empty push.")
diff --git a/comm/python/l10n/tbxchannel/tb_migration_test.py b/comm/python/l10n/tbxchannel/tb_migration_test.py
new file mode 100644
index 0000000000..8e01e1d889
--- /dev/null
+++ b/comm/python/l10n/tbxchannel/tb_migration_test.py
@@ -0,0 +1,172 @@
+# 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 http://mozilla.org/MPL/2.0/.
+"""
+Test comm-l10n Fluent migrations
+"""
+
+import logging
+import os
+import re
+import shutil
+
+import hglib
+from compare_locales.merge import merge_channels
+from compare_locales.paths.configparser import TOMLParser
+from compare_locales.paths.files import ProjectFiles
+from fluent.migratetb import validator
+from test_fluent_migrations.fmt import diff_resources
+
+import mozpack.path as mozpath
+from mach.util import get_state_dir
+from mozversioncontrol.repoupdate import update_mercurial_repo
+
+from .l10n_merge import COMM_L10N
+
+
+def inspect_migration(path):
+ """Validate recipe and extract some metadata."""
+ return validator.Validator.validate(path)
+
+
+def prepare_object_dir(cmd):
+ """Prepare object dir to have an up-to-date clone of comm-l10n.
+
+ We run this once per mach invocation, for all tested migrations.
+ """
+ obj_dir = mozpath.join(cmd.topobjdir, "comm", "python", "l10n")
+ if not os.path.exists(obj_dir):
+ os.makedirs(obj_dir)
+ state_dir = get_state_dir()
+ update_mercurial_repo("hg", COMM_L10N, mozpath.join(state_dir, "comm-strings"))
+ return obj_dir
+
+
+def test_migration(cmd, obj_dir, to_test, references):
+ """Test the given recipe.
+
+ This creates a workdir by merging comm-strings-quarantine and the c-c source,
+ to mimic comm-strings-quarantine after the patch to test landed.
+ It then runs the recipe with a comm-strings-quarantine clone as localization, both
+ dry and wet.
+ It inspects the generated commits, and shows a diff between the merged
+ reference and the generated content.
+ The diff is intended to be visually inspected. Some changes might be
+ expected, in particular when formatting of the en-US strings is different.
+ """
+ rv = 0
+ migration_name = os.path.splitext(os.path.split(to_test)[1])[0]
+ l10n_lib = os.path.abspath(os.path.dirname(os.path.dirname(to_test)))
+ work_dir = mozpath.join(obj_dir, migration_name)
+
+ paths = os.path.normpath(to_test).split(os.sep)
+ # Migration modules should be in a sub-folder of l10n.
+ migration_module = ".".join(paths[paths.index("l10n") + 1 : -1]) + "." + migration_name
+
+ if os.path.exists(work_dir):
+ shutil.rmtree(work_dir)
+ os.makedirs(mozpath.join(work_dir, "reference"))
+ l10n_toml = mozpath.join(cmd.topsrcdir, cmd.substs["MOZ_BUILD_APP"], "locales", "l10n.toml")
+ pc = TOMLParser().parse(l10n_toml, env={"l10n_base": work_dir})
+ pc.set_locales(["reference"])
+ files = ProjectFiles("reference", [pc])
+ for ref in references:
+ if ref != mozpath.normpath(ref):
+ cmd.log(
+ logging.ERROR,
+ "tb-fluent-migration-test",
+ {
+ "file": to_test,
+ "ref": ref,
+ },
+ 'Reference path "{ref}" needs to be normalized for {file}',
+ )
+ rv = 1
+ continue
+ full_ref = mozpath.join(work_dir, "reference", ref)
+ m = files.match(full_ref)
+ if m is None:
+ raise ValueError(f"Bad reference path: {ref} - {full_ref}")
+ m_c_path = m[1]
+ g_s_path = mozpath.join(work_dir, "comm-strings", ref)
+ resources = [
+ b"" if not os.path.exists(f) else open(f, "rb").read() for f in (g_s_path, m_c_path)
+ ]
+ ref_dir = os.path.dirname(full_ref)
+ if not os.path.exists(ref_dir):
+ os.makedirs(ref_dir)
+ open(full_ref, "wb").write(merge_channels(ref, resources))
+ client = hglib.clone(
+ source=mozpath.join(get_state_dir(), "comm-strings"),
+ dest=mozpath.join(work_dir, "comm-strings"),
+ )
+ client.open()
+ old_tip = client.tip().node
+ run_migration = [
+ cmd._virtualenv_manager.python_path,
+ "-m",
+ "fluent.migratetb.tool",
+ "--locale",
+ "en-US",
+ "--reference-dir",
+ mozpath.join(work_dir, "reference"),
+ "--localization-dir",
+ mozpath.join(work_dir, "comm-strings"),
+ "--dry-run",
+ migration_module,
+ ]
+ append_env = {"PYTHONPATH": l10n_lib}
+ cmd.run_process(
+ run_migration,
+ append_env=append_env,
+ cwd=work_dir,
+ line_handler=print,
+ )
+ # drop --dry-run
+ run_migration.pop(-2)
+ cmd.run_process(
+ run_migration,
+ append_env=append_env,
+ cwd=work_dir,
+ line_handler=print,
+ )
+ tip = client.tip().node
+ if old_tip == tip:
+ cmd.log(
+ logging.WARN,
+ "tb-fluent-migration-test",
+ {
+ "file": to_test,
+ },
+ "No migration applied for {file}",
+ )
+ return rv
+ for ref in references:
+ diff_resources(
+ mozpath.join(work_dir, "reference", ref),
+ mozpath.join(work_dir, "comm-strings", "en-US", ref),
+ )
+ messages = [l.desc.decode("utf-8") for l in client.log(b"::%s - ::%s" % (tip, old_tip))]
+ bug = re.search("[0-9]{5,}", migration_name)
+ # Just check first message for bug number, they're all following the same pattern
+ if bug is None or bug.group() not in messages[0]:
+ rv = 1
+ cmd.log(
+ logging.ERROR,
+ "tb-fluent-migration-test",
+ {
+ "file": to_test,
+ },
+ "Missing or wrong bug number for {file}",
+ )
+ if any("part {}".format(n + 1) not in msg for n, msg in enumerate(messages)):
+ rv = 1
+ cmd.log(
+ logging.ERROR,
+ "tb-fluent-migration-test",
+ {
+ "file": to_test,
+ },
+ 'Commit messages should have "part {{index}}" for {file}',
+ )
+ return rv
diff --git a/comm/python/moz.build b/comm/python/moz.build
new file mode 100644
index 0000000000..5c0dac54b9
--- /dev/null
+++ b/comm/python/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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 http://mozilla.org/MPL/2.0/.
+
+# Default extra components to build config
+with Files("**"):
+ BUG_COMPONENT = ("Thunderbird", "Build Config")
+
+PYTHON_UNITTEST_MANIFESTS += [
+ "mutlh/mutlh/test/python.ini",
+]
diff --git a/comm/python/mutlh/README.md b/comm/python/mutlh/README.md
new file mode 100644
index 0000000000..0c3d3c4387
--- /dev/null
+++ b/comm/python/mutlh/README.md
@@ -0,0 +1,56 @@
+# mutlh
+
+Mutlh (Klingon for *construct, assemble, put together*) is an extension of
+Mozilla's Mach to meet the needs of Thunderbird developers.
+
+### Why you might need this
+
+When implementing a `mach` command in comm-central, you may need to utilize
+some of the Python code in the repository. Often, `mach` commands have difficulty
+importing these modules as they're not in sys.path usually.
+
+### Use case: mach command needs to import a library not on sys.path
+
+- In your `mach_commands.py` file, instead of importing from `mach.decorators`,
+ import from `mutlh.decorators`.
+- Implement your command as usual. `@Command`, `@CommandArgument`, `@SubCommand`,
+ and `@CommandArgumentGroup` are available and work just like `mach.decorators`
+ equivalents.
+- By default, the "tb_common" site is used for MutlhCommands.
+
+```python
+from mutlh.decorators import Command, CommandArgument
+
+@Command(
+ "tb-add-missing-ftls",
+ category="thunderbird",
+ description="Add missing FTL files after l10n merge.",
+)
+@CommandArgument(
+ "--merge",
+ type=Path,
+ help="Merge path base",
+)
+@CommandArgument(
+ "locale",
+ type=str,
+ help="Locale code",
+)
+def tb_add_missing_ftls(command_context, merge, locale):
+ """implementation"""
+```
+
+- The default "tb_common" virtualenv can be overridden by passing `virtualenv_name`
+ to `@Command`.
+
+```python
+@Command(
+ "crazytb",
+ category="thunderbird",
+ description="Something insane",
+ virtualenv_name="crazyenv"
+)
+def crazytb(command_context, *args, **kwargs):
+ command_context.activate_virtualenv()
+ """Do stuff"""
+```
diff --git a/comm/python/mutlh/mutlh/__init__.py b/comm/python/mutlh/mutlh/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/python/mutlh/mutlh/__init__.py
diff --git a/comm/python/mutlh/mutlh/decorators.py b/comm/python/mutlh/mutlh/decorators.py
new file mode 100644
index 0000000000..9a5f537009
--- /dev/null
+++ b/comm/python/mutlh/mutlh/decorators.py
@@ -0,0 +1,197 @@
+# 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/.
+
+import argparse
+import os
+
+from mach.decorators import _MachCommand
+from mozbuild.base import MachCommandBase
+
+
+class MutlhCommandBase(MachCommandBase):
+ @property
+ def virtualenv_manager(self):
+ from mozboot.util import get_state_dir
+
+ from .site import MutlhCommandSiteManager
+
+ if self._virtualenv_manager is None:
+ self._virtualenv_manager = MutlhCommandSiteManager.from_environment(
+ self.topsrcdir,
+ lambda: get_state_dir(specific_to_topsrcdir=True, topsrcdir=self.topsrcdir),
+ self._virtualenv_name,
+ os.path.join(self.topobjdir, "_virtualenvs"),
+ )
+
+ return self._virtualenv_manager
+
+
+class _MutlhCommand(_MachCommand):
+ def create_instance(self, context, virtualenv_name):
+ metrics = None
+ if self.metrics_path:
+ metrics = context.telemetry.metrics(self.metrics_path)
+
+ # This ensures the resulting class is defined inside `mach` so that logging
+ # works as expected, and has a meaningful name
+ subclass = type(self.name, (MutlhCommandBase,), {})
+
+ if virtualenv_name is None:
+ virtualenv_name = "tb_common"
+
+ return subclass(
+ context,
+ virtualenv_name=virtualenv_name,
+ metrics=metrics,
+ no_auto_log=self.no_auto_log,
+ )
+
+
+class Command(object):
+ def __init__(self, name, metrics_path=None, **kwargs):
+ self._mach_command = _MutlhCommand(name=name, **kwargs)
+ self._mach_command.metrics_path = metrics_path
+
+ def __call__(self, func):
+ if not hasattr(func, "_mach_command"):
+ func._mach_command = _MutlhCommand()
+
+ func._mach_command |= self._mach_command
+ func._mach_command.register(func)
+
+ return func
+
+
+class SubCommand(object):
+ global_order = 0
+
+ def __init__(
+ self,
+ command,
+ subcommand,
+ description=None,
+ parser=None,
+ metrics_path=None,
+ virtualenv_name=None,
+ ):
+ self._mach_command = _MutlhCommand(
+ name=command,
+ subcommand=subcommand,
+ description=description,
+ parser=parser,
+ virtualenv_name=virtualenv_name,
+ )
+ self._mach_command.decl_order = SubCommand.global_order
+ SubCommand.global_order += 1
+
+ self._mach_command.metrics_path = metrics_path
+
+ def __call__(self, func):
+ if not hasattr(func, "_mach_command"):
+ func._mach_command = _MutlhCommand()
+
+ func._mach_command |= self._mach_command
+ func._mach_command.register(func)
+
+ return func
+
+
+class CommandArgument(object):
+ def __init__(self, *args, **kwargs):
+ if kwargs.get("nargs") == argparse.REMAINDER:
+ # These are the assertions we make in dispatcher.py about
+ # those types of CommandArguments.
+ assert len(args) == 1
+ assert all(k in ("default", "nargs", "help", "group", "metavar") for k in kwargs)
+ self._command_args = (args, kwargs)
+
+ def __call__(self, func):
+ if not hasattr(func, "_mach_command"):
+ func._mach_command = _MutlhCommand()
+
+ func._mach_command.arguments.insert(0, self._command_args)
+
+ return func
+
+
+class CommandArgumentGroup(object):
+ def __init__(self, group_name):
+ self._group_name = group_name
+
+ def __call__(self, func):
+ if not hasattr(func, "_mach_command"):
+ func._mach_command = _MutlhCommand()
+
+ func._mach_command.argument_group_names.insert(0, self._group_name)
+
+ return func
+
+
+def mach2MutlhCommand(cmd: str, new_func=None, **replacekws):
+ """
+ Change a registered _MachCommand to a _MutlhCommand
+
+ :param str cmd: The name of the existing command
+ :param function new_func: New implementation function
+ :param dict replacekws: keyword arguments to replace
+ :return _MutlhCommand: replacement
+ """
+ from mach.registrar import Registrar
+
+ def get_mach_command(cmd_name):
+ mach_cmd = Registrar.command_handlers.get(cmd_name)
+ if mach_cmd:
+ del Registrar.command_handlers[cmd_name]
+ return mach_cmd
+ raise Exception(f"{cmd_name} unknown!")
+
+ mach_cmd = get_mach_command(cmd)
+
+ if mach_cmd.subcommand_handlers:
+ raise Exception("Commands with SubCommands not implemented!")
+
+ if "parser" in replacekws:
+ replacekws["_parser"] = replacekws["parser"]
+ del replacekws["parser"]
+
+ arg_names = (
+ "name",
+ "subcommand",
+ "category",
+ "description",
+ "conditions",
+ "_parser",
+ "virtualenv_name",
+ "ok_if_tests_disabled",
+ "order",
+ "no_auto_log",
+ )
+ kwargs = dict([(k, getattr(cmd, k)) for k in arg_names])
+ kwargs.update(dict([(k, v) for k, v in replacekws.items() if k in arg_names]))
+ if "_parser" in kwargs:
+ kwargs["parser"] = kwargs["_parser"]
+ del kwargs["_parser"]
+
+ mutlh_cmd = _MutlhCommand(**kwargs)
+ post_args = (
+ "arguments",
+ "argument_group_names",
+ "metrics_path",
+ "subcommand_handlers",
+ "decl_order",
+ )
+ for arg in post_args:
+ value = replacekws.get(arg, getattr(mach_cmd, arg))
+ setattr(mutlh_cmd, arg, value)
+
+ if new_func is None:
+ new_func = mach_cmd.func
+ delattr(new_func, "_mach_command")
+ if not hasattr(new_func, "_mach_command"):
+ new_func._mach_command = _MutlhCommand()
+
+ new_func._mach_command |= mutlh_cmd
+ mutlh_cmd.register(new_func)
+
+ return mutlh_cmd
diff --git a/comm/python/mutlh/mutlh/site.py b/comm/python/mutlh/mutlh/site.py
new file mode 100644
index 0000000000..ab93e874e9
--- /dev/null
+++ b/comm/python/mutlh/mutlh/site.py
@@ -0,0 +1,120 @@
+# 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/.
+
+import functools
+import os
+from typing import Callable, Optional
+
+from mach.requirements import MachEnvRequirements, UnexpectedFlexibleRequirementException
+from mach.site import (
+ PIP_NETWORK_INSTALL_RESTRICTED_VIRTUALENVS,
+ CommandSiteManager,
+ MozSiteMetadata,
+ SitePackagesSource,
+ _mach_virtualenv_root,
+)
+
+
+class SiteNotFoundException(Exception):
+ def __init__(self, site_name, manifest_paths):
+ self.site_name = site_name
+ self.manifest_paths = manifest_paths
+ self.args = (site_name, manifest_paths)
+
+
+@functools.lru_cache(maxsize=None)
+def find_manifest(topsrcdir, site_name):
+ manifest_paths = (
+ os.path.join(topsrcdir, "comm", "python", "sites", f"{site_name}.txt"),
+ os.path.join(topsrcdir, "python", "sites", f"{site_name}.txt"),
+ )
+
+ for check_path in manifest_paths:
+ if os.path.exists(check_path):
+ return check_path
+
+ raise SiteNotFoundException(site_name, manifest_paths)
+
+
+@functools.lru_cache(maxsize=None)
+def resolve_requirements(topsrcdir, site_name):
+ try:
+ manifest_path = find_manifest(topsrcdir, site_name)
+ except SiteNotFoundException as e:
+ raise Exception(
+ f'The current command is using the "{e.site_name}" '
+ "site. However, that site is missing its associated "
+ f"requirements definition file in one of the supported "
+ f"paths: {e.manifest_paths}."
+ )
+ is_thunderbird = True
+ try:
+ return MachEnvRequirements.from_requirements_definition(
+ topsrcdir,
+ is_thunderbird,
+ site_name not in PIP_NETWORK_INSTALL_RESTRICTED_VIRTUALENVS,
+ manifest_path,
+ )
+ except UnexpectedFlexibleRequirementException as e:
+ raise Exception(
+ f'The "{site_name}" site does not have all pypi packages pinned '
+ f'in the format "package==version" (found "{e.raw_requirement}").\n'
+ f"Only the {PIP_NETWORK_INSTALL_RESTRICTED_VIRTUALENVS} sites are "
+ "allowed to have unpinned packages."
+ )
+
+
+class MutlhCommandSiteManager(CommandSiteManager):
+ @classmethod
+ def from_environment(
+ cls,
+ topsrcdir: str,
+ get_state_dir: Callable[[], Optional[str]],
+ site_name: str,
+ command_virtualenvs_dir: str,
+ ):
+ """
+ Args:
+ topsrcdir: The path to the Firefox repo
+ get_state_dir: A function that resolves the path to the checkout-scoped
+ state_dir, generally ~/.mozbuild/srcdirs/<checkout-based-dir>/
+ site_name: The name of this site, such as "build"
+ command_virtualenvs_dir: The location under which this site's virtualenv
+ should be created
+ """
+ active_metadata = MozSiteMetadata.from_runtime()
+ assert (
+ active_metadata
+ ), "A Mach-managed site must be active before doing work with command sites"
+
+ mach_site_packages_source = active_metadata.mach_site_packages_source
+ pip_restricted_site = site_name in PIP_NETWORK_INSTALL_RESTRICTED_VIRTUALENVS
+ if not pip_restricted_site and mach_site_packages_source == SitePackagesSource.SYSTEM:
+ # Sites that aren't pip-network-install-restricted are likely going to be
+ # incompatible with the system. Besides, this use case shouldn't exist, since
+ # using the system packages is supposed to only be needed to lower risk of
+ # important processes like building Firefox.
+ raise Exception(
+ 'Cannot use MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE="system" for any '
+ f"sites other than {PIP_NETWORK_INSTALL_RESTRICTED_VIRTUALENVS}. The "
+ f'current attempted site is "{site_name}".'
+ )
+
+ mach_virtualenv_root = (
+ _mach_virtualenv_root(get_state_dir())
+ if mach_site_packages_source == SitePackagesSource.VENV
+ else None
+ )
+ populate_virtualenv = (
+ mach_site_packages_source == SitePackagesSource.VENV or not pip_restricted_site
+ )
+ return cls(
+ topsrcdir,
+ mach_virtualenv_root,
+ os.path.join(command_virtualenvs_dir, site_name),
+ site_name,
+ active_metadata,
+ populate_virtualenv,
+ resolve_requirements(topsrcdir, site_name),
+ )
diff --git a/comm/python/mutlh/mutlh/test/__init__.py b/comm/python/mutlh/mutlh/test/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/python/mutlh/mutlh/test/__init__.py
diff --git a/comm/python/mutlh/mutlh/test/conftest.py b/comm/python/mutlh/mutlh/test/conftest.py
new file mode 100644
index 0000000000..6fcac64308
--- /dev/null
+++ b/comm/python/mutlh/mutlh/test/conftest.py
@@ -0,0 +1,11 @@
+# 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/.
+
+import os
+import sys
+
+HERE = os.path.dirname(__file__)
+EXT_PATH = os.path.abspath(os.path.join(HERE, "..", ".."))
+
+sys.path.insert(0, EXT_PATH)
diff --git a/comm/python/mutlh/mutlh/test/python.ini b/comm/python/mutlh/mutlh/test/python.ini
new file mode 100644
index 0000000000..3dafe825cd
--- /dev/null
+++ b/comm/python/mutlh/mutlh/test/python.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+subsuite = mutlh
+
+[test_decorators.py]
+[test_site.py]
+[test_site_compatibility.py]
+# The Windows and Mac workers only use the internal PyPI mirror,
+# which will be missing packages required for this test.
+skip-if =
+ os == "win"
+ os == "mac"
diff --git a/comm/python/mutlh/mutlh/test/test_decorators.py b/comm/python/mutlh/mutlh/test/test_decorators.py
new file mode 100644
index 0000000000..7e27cd6383
--- /dev/null
+++ b/comm/python/mutlh/mutlh/test/test_decorators.py
@@ -0,0 +1,82 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+from pathlib import Path
+from unittest import mock
+from unittest.mock import Mock, patch
+
+import conftest # noqa: F401
+import pytest
+from mozunit import main
+
+import mach.decorators
+import mach.registrar
+from mach.requirements import MachEnvRequirements
+from mach.site import MozSiteMetadata, SitePackagesSource
+from mutlh.decorators import Command, CommandArgument, MutlhCommandBase
+from mutlh.site import MutlhCommandSiteManager
+
+
+@pytest.fixture
+def registrar(monkeypatch):
+ test_registrar = mach.registrar.MachRegistrar()
+ test_registrar.register_category("testing", "Mach unittest", "Testing for mach decorators")
+ monkeypatch.setattr(mach.decorators, "Registrar", test_registrar)
+ return test_registrar
+
+
+def test_register_command_with_argument(registrar):
+ inner_function = Mock()
+ context = Mock()
+ context.cwd = "."
+
+ @Command("cmd_foo", category="testing")
+ @CommandArgument("--arg", default=None, help="Argument help.")
+ def run_foo(command_context, arg):
+ inner_function(arg)
+
+ registrar.dispatch("cmd_foo", context, arg="argument")
+
+ inner_function.assert_called_with("argument")
+
+
+def test_register_command_sets_up_class_at_runtime(registrar):
+ inner_function = Mock()
+
+ context = Mock()
+ context.cwd = "."
+
+ # We test that the virtualenv is set up properly dynamically on
+ # the instance that actually runs the command.
+ @Command("cmd_foo", category="testing", virtualenv_name="env_foo")
+ def run_foo(command_context):
+ assert Path(command_context.virtualenv_manager.virtualenv_root).name == "env_foo"
+ inner_function("foo")
+
+ @Command("cmd_bar", category="testing", virtualenv_name="env_bar")
+ def run_bar(command_context):
+ assert Path(command_context.virtualenv_manager.virtualenv_root).name == "env_bar"
+ inner_function("bar")
+
+ def from_environment_patch(topsrcdir: str, state_dir: str, virtualenv_name, directory: str):
+ return MutlhCommandSiteManager(
+ "",
+ "",
+ virtualenv_name,
+ virtualenv_name,
+ MozSiteMetadata(0, "mach", SitePackagesSource.VENV, "", ""),
+ True,
+ MachEnvRequirements(),
+ )
+
+ with mock.patch.object(MutlhCommandSiteManager, "from_environment", from_environment_patch):
+ with patch.object(MutlhCommandBase, "activate_virtualenv"):
+ registrar.dispatch("cmd_foo", context)
+ inner_function.assert_called_with("foo")
+ registrar.dispatch("cmd_bar", context)
+ inner_function.assert_called_with("bar")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/comm/python/mutlh/mutlh/test/test_site.py b/comm/python/mutlh/mutlh/test/test_site.py
new file mode 100644
index 0000000000..f5c11cadd7
--- /dev/null
+++ b/comm/python/mutlh/mutlh/test/test_site.py
@@ -0,0 +1,33 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+import os
+from contextlib import nullcontext as does_not_raise
+
+import conftest # noqa: F401
+import mozunit
+import pytest
+
+from buildconfig import topsrcdir
+from mutlh.site import SiteNotFoundException, find_manifest
+
+
+@pytest.mark.parametrize(
+ "site_name,expected",
+ [
+ ("tb_common", does_not_raise("comm/python/sites/tb_common.txt")),
+ ("lint", does_not_raise("python/sites/lint.txt")),
+ ("not_a_real_site_name", pytest.raises(SiteNotFoundException)),
+ ],
+)
+def test_find_manifest(site_name, expected):
+ def get_path(result):
+ return os.path.relpath(result, topsrcdir)
+
+ with expected:
+ assert get_path(find_manifest(topsrcdir, site_name)) == expected.enter_result
+
+
+if __name__ == "__main__":
+ mozunit.main()
diff --git a/comm/python/mutlh/mutlh/test/test_site_compatibility.py b/comm/python/mutlh/mutlh/test/test_site_compatibility.py
new file mode 100644
index 0000000000..c316e54240
--- /dev/null
+++ b/comm/python/mutlh/mutlh/test/test_site_compatibility.py
@@ -0,0 +1,194 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from textwrap import dedent
+
+import mozunit
+
+from buildconfig import topsrcdir
+from mach.requirements import MachEnvRequirements
+from mach.site import PythonVirtualenv
+
+MUTLH_REQUIREMENTS_PATH = Path(topsrcdir) / "comm" / "python" / "sites"
+MACH_REQUIREMENTS_PATH = Path(topsrcdir) / "python" / "sites"
+
+
+def _resolve_command_site_names():
+ site_names = []
+ for child in MUTLH_REQUIREMENTS_PATH.iterdir():
+ if not child.is_file():
+ continue
+
+ if child.suffix != ".txt":
+ continue
+
+ if child.name == "mach.txt":
+ continue
+
+ site_names.append(child.stem)
+ return site_names
+
+
+def _requirement_definition_to_pip_format(site_name, cache, is_mach_or_build_env):
+ """Convert from parsed requirements object to pip-consumable format"""
+ if site_name == "mach":
+ requirements_path = MACH_REQUIREMENTS_PATH / f"{site_name}.txt"
+ else:
+ requirements_path = MUTLH_REQUIREMENTS_PATH / f"{site_name}.txt"
+ is_thunderbird = True
+
+ requirements = MachEnvRequirements.from_requirements_definition(
+ topsrcdir, is_thunderbird, not is_mach_or_build_env, requirements_path
+ )
+
+ lines = []
+ for pypi in requirements.pypi_requirements + requirements.pypi_optional_requirements:
+ lines.append(str(pypi.requirement))
+
+ for vendored in requirements.vendored_requirements:
+ lines.append(str(cache.package_for_vendor_dir(Path(vendored.path))))
+
+ for pth in requirements.pth_requirements:
+ path = Path(pth.path)
+
+ if "third_party" not in (p.name for p in path.parents):
+ continue
+
+ for child in path.iterdir():
+ if child.name.endswith(".dist-info"):
+ raise Exception(
+ f'In {requirements_path}, the "pth:" pointing to "{path}" has a '
+ '".dist-info" file.\n'
+ 'Perhaps it should change to start with "vendored:" instead of '
+ '"pth:".'
+ )
+ if child.name.endswith(".egg-info"):
+ raise Exception(
+ f'In {requirements_path}, the "pth:" pointing to "{path}" has an '
+ '".egg-info" file.\n'
+ 'Perhaps it should change to start with "vendored:" instead of '
+ '"pth:".'
+ )
+
+ return "\n".join(lines)
+
+
+class PackageCache:
+ def __init__(self, storage_dir: Path):
+ self._cache = {}
+ self._storage_dir = storage_dir
+
+ def package_for_vendor_dir(self, vendor_path: Path):
+ if vendor_path in self._cache:
+ return self._cache[vendor_path]
+
+ if not any((p for p in vendor_path.iterdir() if p.name.endswith(".dist-info"))):
+ # This vendored package is not a wheel. It may be a source package (with
+ # a setup.py), or just some Python code that was manually copied into the
+ # tree. If it's a source package, the setup.py file may be up a few levels
+ # from the referenced Python module path.
+ package_dir = vendor_path
+ while True:
+ if (package_dir / "setup.py").exists():
+ break
+ elif package_dir.parent == package_dir:
+ raise Exception(
+ f'Package "{vendor_path}" is not a wheel and does not have a '
+ 'setup.py file. Perhaps it should be "pth:" instead of '
+ '"vendored:"?'
+ )
+ package_dir = package_dir.parent
+
+ self._cache[vendor_path] = package_dir
+ return package_dir
+
+ # Pip requires that wheels have a version number in their name, even if
+ # it ignores it. We should parse out the version and put it in here
+ # so that failure debugging is easier, but that's non-trivial work.
+ # So, this "0" satisfies pip's naming requirement while being relatively
+ # obvious that it's a placeholder.
+ output_path = self._storage_dir / f"{vendor_path.name}-0-py3-none-any"
+ shutil.make_archive(str(output_path), "zip", vendor_path)
+
+ whl_path = output_path.parent / (output_path.name + ".whl")
+ (output_path.parent / (output_path.name + ".zip")).rename(whl_path)
+ self._cache[vendor_path] = whl_path
+
+ return whl_path
+
+
+def test_sites_compatible(tmpdir: str):
+ command_site_names = _resolve_command_site_names()
+ work_dir = Path(tmpdir)
+ cache = PackageCache(work_dir)
+ mach_requirements = _requirement_definition_to_pip_format("mach", cache, True)
+
+ # Create virtualenv to try to install all dependencies into.
+ virtualenv = PythonVirtualenv(str(work_dir / "env"))
+ subprocess.check_call(
+ [
+ sys.executable,
+ "-m",
+ "venv",
+ "--without-pip",
+ virtualenv.prefix,
+ ]
+ )
+ platlib_dir = virtualenv.resolve_sysconfig_packages_path("platlib")
+ third_party = Path(topsrcdir) / "third_party" / "python"
+ with open(os.path.join(platlib_dir, "site.pth"), "w") as pthfile:
+ pthfile.write(
+ "\n".join(
+ [
+ str(third_party / "pip"),
+ str(third_party / "wheel"),
+ str(third_party / "setuptools"),
+ ]
+ )
+ )
+
+ for name in command_site_names:
+ print(f'Checking compatibility of "{name}" site')
+ command_requirements = _requirement_definition_to_pip_format(name, cache, False)
+ with open(work_dir / "requirements.txt", "w") as requirements_txt:
+ requirements_txt.write(mach_requirements)
+ requirements_txt.write("\n")
+ requirements_txt.write(command_requirements)
+
+ # Attempt to install combined set of dependencies (global Mach + current
+ # command)
+ proc = subprocess.run(
+ [
+ virtualenv.python_path,
+ "-m",
+ "pip",
+ "install",
+ "-r",
+ str(work_dir / "requirements.txt"),
+ ],
+ cwd=topsrcdir,
+ )
+ if proc.returncode != 0:
+ print(
+ dedent(
+ f"""
+ Error: The '{name}' site contains dependencies that are not
+ compatible with the 'mach' site. Check the following files for
+ any conflicting packages mentioned in the prior error message:
+
+ python/sites/mach.txt
+ comm/python/sites/{name}.txt
+ """
+ )
+ )
+ assert False
+
+
+if __name__ == "__main__":
+ mozunit.main()
diff --git a/comm/python/rocboot/README.rst b/comm/python/rocboot/README.rst
new file mode 100644
index 0000000000..64db75f5a5
--- /dev/null
+++ b/comm/python/rocboot/README.rst
@@ -0,0 +1,20 @@
+rocboot - Bootstrap your system to build Mozilla Thunderbird!
+=============================================================
+
+This package contains code used for bootstrapping a system to build from
+comm-central.
+
+This code is not part of the build system per se. Instead, it is related
+to everything up to invoking the actual build system.
+
+If you have a copy of the source tree, you run:
+
+ python bin/bootstrap.py
+
+If you don't have a copy of the source tree, you can run:
+
+ curl https://hg.mozilla.org/comm-central/raw-file/default/python/rocboot/bin/bootstrap.py -o bootstrap.py
+ python bootstrap.py
+
+The bootstrap script will download everything it needs from hg.mozilla.org
+automagically!
diff --git a/comm/python/rocboot/bin/bootstrap.py b/comm/python/rocboot/bin/bootstrap.py
new file mode 100755
index 0000000000..3d1470a96a
--- /dev/null
+++ b/comm/python/rocboot/bin/bootstrap.py
@@ -0,0 +1,444 @@
+#!/usr/bin/env python
+# 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 http://mozilla.org/MPL/2.0/.
+
+# This script provides one-line bootstrap support to configure systems to build
+# the tree. It does so by cloning the repo before calling directly into `mach
+# bootstrap`.
+
+# mozboot bootstrap.py was mangled and maimed in the creation of this script.
+
+# Note that this script can't assume anything in particular about the host
+# Python environment (except that it's run with a sufficiently recent version of
+# Python 3), so we are restricted to stdlib modules.
+
+import sys
+
+major, minor = sys.version_info[:2]
+if (major < 3) or (major == 3 and minor < 5):
+ print("Bootstrap currently only runs on Python 3.5+." "Please try re-running with python3.5+.")
+ sys.exit(1)
+
+import ctypes
+import os
+import shutil
+import subprocess
+import tempfile
+from optparse import OptionParser
+from pathlib import Path
+
+CLONE_MERCURIAL_PULL_FAIL = """
+Failed to pull from hg.mozilla.org.
+
+This is most likely because of unstable network connection.
+Try running `cd %s && hg pull https://hg.mozilla.org/comm-central` manually,
+or download a mercurial bundle and use it:
+https://firefox-source-docs.mozilla.org/contributing/vcs/mercurial_bundles.html"""
+
+WINDOWS = sys.platform.startswith("win32") or sys.platform.startswith("msys")
+VCS_HUMAN_READABLE = {
+ "hg": "Mercurial",
+ "git": "Git",
+}
+
+
+def which(name):
+ """Python implementation of which.
+
+ It returns the path of an executable or None if it couldn't be found.
+ """
+ # git-cinnabar.exe doesn't exist, but .exe versions of the other executables
+ # do.
+ if WINDOWS and name != "git-cinnabar":
+ name += ".exe"
+ search_dirs = os.environ["PATH"].split(os.pathsep)
+
+ for path in search_dirs:
+ test = Path(path) / name
+ if test.is_file() and os.access(test, os.X_OK):
+ return test
+
+ return None
+
+
+def validate_clone_dest(dest: Path):
+ dest = dest.resolve()
+
+ if not dest.exists():
+ return dest
+
+ if not dest.is_dir():
+ print(f"ERROR! Destination {dest} exists but is not a directory.")
+ return None
+
+ if not any(dest.iterdir()):
+ return dest
+ else:
+ print(f"ERROR! Destination directory {dest} exists but is nonempty.")
+ print(
+ f"To re-bootstrap the existing checkout, go into '{dest}' and run './mach bootstrap'."
+ )
+ return None
+
+
+def input_clone_dest(vcs, no_interactive):
+ repo_name = "mozilla-unified"
+ print(f"Cloning into {repo_name} using {VCS_HUMAN_READABLE[vcs]}...")
+ while True:
+ dest = None
+ if not no_interactive:
+ dest = input(
+ f"Destination directory for clone (leave empty to use "
+ f"default destination of {repo_name}): "
+ ).strip()
+ if not dest:
+ dest = repo_name
+ dest = validate_clone_dest(Path(dest).expanduser())
+ if dest:
+ return dest
+ if no_interactive:
+ return None
+
+
+def hg_clone(hg: Path, repo, dest: Path, watchman: Path):
+ print(f"Cloning {repo} to {dest}...")
+ # We create an empty repo then modify the config before adding data.
+ # This is necessary to ensure storage settings are optimally
+ # configured.
+ args = [
+ str(hg),
+ # The unified repo is generaldelta, so ensure the client is as
+ # well.
+ "--config",
+ "format.generaldelta=true",
+ "init",
+ str(dest),
+ ]
+ res = subprocess.call(args)
+ if res:
+ print("unable to create destination repo; please try cloning manually")
+ return None
+
+ # Strictly speaking, this could overwrite a config based on a template
+ # the user has installed. Let's pretend this problem doesn't exist
+ # unless someone complains about it.
+ with open(dest / ".hg" / "hgrc", "a") as fh:
+ fh.write("[paths]\n")
+ fh.write("default = https://hg.mozilla.org/{}\n".format(repo))
+ fh.write("\n")
+
+ # The server uses aggressivemergedeltas which can blow up delta chain
+ # length. This can cause performance to tank due to delta chains being
+ # too long. Limit the delta chain length to something reasonable
+ # to bound revlog read time.
+ fh.write("[format]\n")
+ fh.write("# This is necessary to keep performance in check\n")
+ fh.write("maxchainlen = 10000\n")
+
+ res = subprocess.call(
+ [str(hg), "pull", "https://hg.mozilla.org/{}".format(repo)], cwd=str(dest)
+ )
+ print("")
+ if res:
+ print(CLONE_MERCURIAL_PULL_FAIL % dest)
+ return None
+
+ update_rev = {"mozilla-unified": "central", "comm-central": "tip"}[repo]
+ print("updating to {}".format(update_rev))
+ res = subprocess.call([str(hg), "update", "-r", update_rev], cwd=str(dest))
+ if res:
+ print(
+ f"error updating; you will need to `cd {dest} && hg update -r {update_rev}` "
+ "manually"
+ )
+ return dest
+
+
+def git_clone(git: Path, repo, dest: Path, watchman: Path):
+ print(f"Cloning {repo} to {dest}...")
+ tempdir = None
+ cinnabar = None
+ env = dict(os.environ)
+ try:
+ cinnabar = which("git-cinnabar")
+ if not cinnabar:
+ cinnabar_url = "https://github.com/glandium/git-cinnabar/"
+ # If git-cinnabar isn't installed already, that's fine; we can
+ # download a temporary copy. `mach bootstrap` will clone a full copy
+ # of the repo in the state dir; we don't want to copy all that logic
+ # to this tiny bootstrapping script.
+ tempdir = Path(tempfile.mkdtemp())
+ cinnabar_dir = tempdir / "git-cinnabar-master"
+ subprocess.check_call(
+ [str(git), "clone", "--depth=1", str(cinnabar_url), str(cinnabar_dir)],
+ cwd=str(tempdir),
+ env=env,
+ )
+ env["PATH"] = str(cinnabar_dir) + os.pathsep + env["PATH"]
+ subprocess.check_call(
+ [sys.executable, str(cinnabar_dir / "download.py")],
+ cwd=str(cinnabar_dir),
+ env=env,
+ )
+ print(
+ "WARNING! git-cinnabar is required for Firefox development "
+ "with git. After the clone is complete, the bootstrapper "
+ "will ask if you would like to configure git; answer yes, "
+ "and be sure to add git-cinnabar to your PATH according to "
+ "the bootstrapper output."
+ )
+
+ # We're guaranteed to have `git-cinnabar` installed now.
+ # Configure git per the git-cinnabar requirements.
+ cmd = [
+ str(git),
+ "clone",
+ ]
+ if repo == "mozilla-unified":
+ cmd += [
+ "-b",
+ "bookmarks/central",
+ ]
+ cmd += [
+ "hg::https://hg.mozilla.org/{}".format(repo),
+ str(dest),
+ ]
+ subprocess.check_call(cmd, env=env)
+ subprocess.check_call([str(git), "config", "fetch.prune", "true"], cwd=str(dest), env=env)
+ subprocess.check_call([str(git), "config", "pull.ff", "only"], cwd=str(dest), env=env)
+
+ watchman_sample = dest / ".git/hooks/fsmonitor-watchman.sample"
+ # Older versions of git didn't include fsmonitor-watchman.sample.
+ if watchman and watchman_sample.exists():
+ print("Configuring watchman")
+ watchman_config = dest / ".git/hooks/query-watchman"
+ if not watchman_config.exists():
+ print(f"Copying {watchman_sample} to {watchman_config}")
+ copy_args = [
+ "cp",
+ ".git/hooks/fsmonitor-watchman.sample",
+ ".git/hooks/query-watchman",
+ ]
+ subprocess.check_call(copy_args, cwd=str(dest))
+
+ config_args = [
+ str(git),
+ "config",
+ "core.fsmonitor",
+ ".git/hooks/query-watchman",
+ ]
+ subprocess.check_call(config_args, cwd=str(dest), env=env)
+ return dest
+ finally:
+ if not cinnabar:
+ print(
+ "Failed to install git-cinnabar. Try performing a manual "
+ "installation: https://github.com/glandium/git-cinnabar/wiki/"
+ "Mozilla:-A-git-workflow-for-Gecko-development"
+ )
+ if tempdir:
+ shutil.rmtree(str(tempdir))
+
+
+def add_microsoft_defender_antivirus_exclusions(dest, no_system_changes):
+ if no_system_changes:
+ return
+
+ if not WINDOWS:
+ return
+
+ powershell_exe = which("powershell")
+
+ if not powershell_exe:
+ return
+
+ def print_attempt_exclusion(path):
+ print(f"Attempting to add exclusion path to Microsoft Defender Antivirus for: {path}")
+
+ powershell_exe = str(powershell_exe)
+ paths = []
+
+ # mozilla-unified / clone dest
+ repo_dir = Path.cwd() / dest
+ paths.append(repo_dir)
+ print_attempt_exclusion(repo_dir)
+
+ # MOZILLABUILD
+ mozillabuild_dir = os.getenv("MOZILLABUILD")
+ if mozillabuild_dir:
+ paths.append(mozillabuild_dir)
+ print_attempt_exclusion(mozillabuild_dir)
+
+ # .mozbuild
+ mozbuild_dir = Path.home() / ".mozbuild"
+ paths.append(mozbuild_dir)
+ print_attempt_exclusion(mozbuild_dir)
+
+ args = ";".join(f"Add-MpPreference -ExclusionPath '{path}'" for path in paths)
+ command = f'-Command "{args}"'
+
+ # This will attempt to run as administrator by triggering a UAC prompt
+ # for admin credentials. If "No" is selected, no exclusions are added.
+ ctypes.windll.shell32.ShellExecuteW(None, "runas", powershell_exe, command, None, 0)
+
+
+def clone(options):
+ vcs = options.vcs
+ no_interactive = options.no_interactive
+ no_system_changes = options.no_system_changes
+
+ hg = which("hg")
+ if not hg:
+ print(
+ "Mercurial is not installed. Mercurial is required to clone "
+ "Thunderbird%s." % (", even when cloning with Git" if vcs == "git" else "")
+ )
+ try:
+ # We're going to recommend people install the Mercurial package with
+ # pip3. That will work if `pip3` installs binaries to a location
+ # that's in the PATH, but it might not be. To help out, if we CAN
+ # import "mercurial" (in which case it's already been installed),
+ # offer that as a solution.
+ import mercurial # noqa: F401
+
+ print(
+ "Hint: have you made sure that Mercurial is installed to a "
+ "location in your PATH?"
+ )
+ except ImportError:
+ print("Try installing hg with `pip3 install Mercurial`.")
+ return None
+
+ if vcs == "hg":
+ binary = hg
+ else:
+ binary = which(vcs)
+ if not binary:
+ print("Git is not installed.")
+ print("Try installing git using your system package manager.")
+ return None
+
+ dest = input_clone_dest(vcs, no_interactive)
+ if not dest:
+ return None
+
+ add_microsoft_defender_antivirus_exclusions(dest, no_system_changes)
+
+ if vcs == "hg":
+ clone_func = hg_clone
+ else:
+ clone_func = git_clone
+ watchman = which("watchman")
+
+ mc = clone_func(binary, "mozilla-unified", dest, watchman)
+ # Funny logic... the return value if successful needs to be the path
+ # to mozilla-central. Only return "cc" if cloning comm-central
+ # fails.
+ if mc == dest:
+ cc_dest = Path(dest) / "comm"
+ cc = clone_func(binary, "comm-central", cc_dest, watchman)
+ if cc:
+ return mc
+ else:
+ return cc
+ return mc
+
+
+def bootstrap(srcdir: Path, artifact_mode, no_interactive, no_system_changes):
+ args = [sys.executable, "mach"]
+
+ if no_interactive:
+ # --no-interactive is a global argument, not a command argument,
+ # so it needs to be specified before "bootstrap" is appended.
+ args += ["--no-interactive"]
+
+ args += ["bootstrap"]
+
+ if artifact_mode:
+ args += ["--application-choice", "Firefox for Desktop Artifact Mode"]
+ else:
+ args += ["--application-choice", "Firefox for Desktop"]
+ if no_system_changes:
+ args += ["--no-system-changes"]
+
+ print("Running `%s`" % " ".join(args))
+ return subprocess.call(args, cwd=str(srcdir))
+
+
+def mozconfig(srcdir):
+ """Build Thunderbird, not Firefox!"""
+ mozconfig = os.path.join(srcdir, "mozconfig")
+ with open(mozconfig, "a") as mfp:
+ mfp.write("ac_add_options --enable-project=comm/mail")
+ return True
+
+
+def main(args):
+ parser = OptionParser()
+ parser.add_option(
+ "--artifact-mode",
+ dest="artifact_mode",
+ help="Build Thunderbird in Artifact mode. "
+ "See https://firefox-source-docs.mozilla.org/contributing/build"
+ "/artifact_builds.html for details.",
+ )
+ parser.add_option(
+ "--vcs",
+ dest="vcs",
+ default="hg",
+ choices=["git", "hg"],
+ help="VCS (hg or git) to use for downloading the source code, "
+ "instead of using the default interactive prompt.",
+ )
+ parser.add_option(
+ "--no-interactive",
+ dest="no_interactive",
+ action="store_true",
+ help="Answer yes to any (Y/n) interactive prompts.",
+ )
+ parser.add_option(
+ "--no-system-changes",
+ dest="no_system_changes",
+ action="store_true",
+ help="Only executes actions that leave the system " "configuration alone.",
+ )
+
+ options, leftover = parser.parse_args(args)
+
+ try:
+ srcdir = clone(options)
+ if not srcdir:
+ return 1
+ print("Clone complete.")
+ print(
+ "If you need to run the tooling bootstrapping again, "
+ "then consider running './mach bootstrap' instead."
+ )
+ if not options.no_interactive:
+ remove_bootstrap_file = input(
+ "Unless you are going to have more local copies of Firefox source code, "
+ "this 'bootstrap.py' file is no longer needed and can be deleted. "
+ "Clean up the bootstrap.py file? (Y/n)"
+ )
+ if not remove_bootstrap_file:
+ remove_bootstrap_file = "y"
+ if options.no_interactive or remove_bootstrap_file == "y":
+ try:
+ Path(sys.argv[0]).unlink()
+ except FileNotFoundError:
+ print("File could not be found !")
+ bootstrap(
+ srcdir,
+ options.artifact_mode,
+ options.no_interactive,
+ options.no_system_changes,
+ )
+ return mozconfig(srcdir)
+ except Exception:
+ print("Could not bootstrap Thunderbird! Consider filing a bug.")
+ raise
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/comm/python/rocbuild/rocbuild/__init__.py b/comm/python/rocbuild/rocbuild/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/python/rocbuild/rocbuild/__init__.py
diff --git a/comm/python/rocbuild/rocbuild/notify.py b/comm/python/rocbuild/rocbuild/notify.py
new file mode 100644
index 0000000000..b7bc135ea4
--- /dev/null
+++ b/comm/python/rocbuild/rocbuild/notify.py
@@ -0,0 +1,34 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+"""
+Notification utility functions
+"""
+
+import os
+
+from taskcluster import Notify, optionsFromEnvironment
+
+TB_BUILD_ADDR = "tb-builds@thunderbird.net"
+
+
+def email_notification(subject, content, recipients=None):
+ # use proxy if configured, otherwise local credentials from env vars
+ if recipients is None:
+ recipients = [TB_BUILD_ADDR]
+
+ if "TASKCLUSTER_PROXY_URL" in os.environ:
+ notify_options = {"rootUrl": os.environ["TASKCLUSTER_PROXY_URL"]}
+ else:
+ notify_options = optionsFromEnvironment()
+
+ notify = Notify(notify_options)
+ for address in recipients:
+ notify.email(
+ {
+ "address": address,
+ "subject": subject,
+ "content": content,
+ }
+ )
diff --git a/comm/python/sites/tb_common.txt b/comm/python/sites/tb_common.txt
new file mode 100644
index 0000000000..932cf6a177
--- /dev/null
+++ b/comm/python/sites/tb_common.txt
@@ -0,0 +1,7 @@
+pth:comm/python/l10n
+pth:comm/python/mutlh
+pth:comm/python/rocbuild
+pth:comm/python/thirdroc
+pth:comm/taskcluster
+pth:comm/testing/marionette
+vendored:comm/third_party/python/fluent.migratetb
diff --git a/comm/python/thirdroc/rnp_generated.py b/comm/python/thirdroc/rnp_generated.py
new file mode 100644
index 0000000000..9c3390e4b9
--- /dev/null
+++ b/comm/python/thirdroc/rnp_generated.py
@@ -0,0 +1,116 @@
+# 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/.
+
+# 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 http://mozilla.org/MPL/2.0/.
+
+import argparse
+import os
+import sys
+
+from packaging.version import parse
+
+from mozbuild.preprocessor import Preprocessor
+from mozbuild.util import FileAvoidWrite, ensureParentDir
+
+from thirdroc.cmake_define_files import define_type, process_cmake_define_file
+
+
+def rnp_version(version_file, thunderbird_version):
+ """
+ Update RNP source files: generate version.h
+ :param string version_file:
+ """
+ with open(version_file) as fp:
+ version_str = fp.readline(512).strip()
+
+ version = parse(version_str)
+ version_major = version.major
+ version_minor = version.minor
+ version_patch = version.micro
+
+ version_full = f"{version_str}.MZLA.{thunderbird_version}"
+
+ defines = dict(
+ RNP_VERSION_MAJOR=version_major,
+ RNP_VERSION_MINOR=version_minor,
+ RNP_VERSION_PATCH=version_patch,
+ RNP_VERSION=version_str,
+ RNP_VERSION_FULL=version_full,
+ # Follow upstream's example when commit info is unavailable
+ RNP_VERSION_COMMIT_TIMESTAMP="0",
+ PACKAGE_STRING=f'"rnp {version_full}"',
+ )
+
+ return defines
+
+
+def rnp_preprocess(tmpl, dest, defines):
+ """
+ Generic preprocessing
+ :param BinaryIO tmpl: open filehandle (read) input
+ :param BinaryIO dest: open filehandle (write) output
+ :param dict defines: result of get_defines()
+ :return boolean:
+ """
+ pp = Preprocessor()
+ pp.setMarker("%")
+ pp.addDefines(defines)
+ pp.do_filter("substitution")
+ pp.out = dest
+ pp.do_include(tmpl, True)
+ return True
+
+
+def generate_version_h(output, template, defines):
+ """
+ Generate version.h for rnp from a the template file, write the
+ result to destination.
+ :param string template: path to template file (version.h.in)
+ :param string destination: path to write generated file (version.h)
+ :param dict defines: result of get_defines()
+ """
+ with open(template) as tmpl:
+ rnp_preprocess(tmpl, output, defines)
+
+
+def main(output, *argv):
+ parser = argparse.ArgumentParser(description="Preprocess RNP files.")
+
+ parser.add_argument("version_h_in", help="version.h.in")
+ parser.add_argument("config_h_in", help="config.h.in")
+ parser.add_argument("-m", type=str, dest="thunderbird_version", help="Thunderbird version")
+ parser.add_argument("-V", type=str, dest="version_file", help="Path to RNP version.txt")
+ parser.add_argument(
+ "-D",
+ type=define_type,
+ action="append",
+ dest="extra_defines",
+ default=[],
+ help="Additional defines not set at configure time.",
+ )
+
+ args = parser.parse_args(argv)
+
+ defines = rnp_version(args.version_file, args.thunderbird_version)
+
+ # "output" is an open filedescriptor for version.h
+ generate_version_h(output, args.version_h_in, defines)
+
+ # We must create the remaining output files ourselves. This requires
+ # creating the output directory directly if it doesn't already exist.
+ ensureParentDir(output.name)
+ parent_dir = os.path.dirname(output.name)
+
+ # For config.h, include defines set for version.h and extra -D args
+ config_h_defines = dict(args.extra_defines)
+ config_h_defines.update(defines)
+
+ with FileAvoidWrite(os.path.join(parent_dir, "config.h")) as fd:
+ process_cmake_define_file(fd, args.config_h_in, config_h_defines)
+
+
+if __name__ == "__main__":
+ sys.exit(main(*sys.argv))
diff --git a/comm/python/thirdroc/setup.py b/comm/python/thirdroc/setup.py
new file mode 100644
index 0000000000..2a4cf6d3b5
--- /dev/null
+++ b/comm/python/thirdroc/setup.py
@@ -0,0 +1,24 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+from setuptools import find_packages, setup
+
+VERSION = "0.1"
+
+setup(
+ author="MZLA Technologies",
+ author_email="MZLA Technologies Release Engineering",
+ name="thirdroc",
+ description="Utility for maintaining third party source code in Thunderbird",
+ license="MPL 2.0",
+ packages=find_packages(),
+ version=VERSION,
+ classifiers=[
+ "Development Status :: 3 - Alpha",
+ "Topic :: Software Development :: Build Tools",
+ "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: Implementation :: CPython",
+ ],
+)
diff --git a/comm/python/thirdroc/thirdroc/__init__.py b/comm/python/thirdroc/thirdroc/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/comm/python/thirdroc/thirdroc/__init__.py
diff --git a/comm/python/thirdroc/thirdroc/cmake_define_files.py b/comm/python/thirdroc/thirdroc/cmake_define_files.py
new file mode 100644
index 0000000000..2c53bf2d54
--- /dev/null
+++ b/comm/python/thirdroc/thirdroc/cmake_define_files.py
@@ -0,0 +1,102 @@
+# 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 http://mozilla.org/MPL/2.0/.
+
+import argparse
+import os
+import re
+import sys
+
+from buildconfig import topobjdir
+from mozbuild.backend.configenvironment import PartialConfigEnvironment
+
+
+def define_type(string):
+ vals = string.split("=", 1)
+ if len(vals) == 1:
+ vals.append(1)
+ elif vals[1].isdecimal():
+ vals[1] = int(vals[1])
+ return tuple(vals)
+
+
+def process_cmake_define_file(output, input_file, extra_defines):
+ """Creates the given config header. A config header is generated by
+ taking the corresponding source file and replacing some #define/#undef
+ occurrences:
+ "#undef NAME" is turned into "#define NAME VALUE"
+ "#cmakedefine NAME" is turned into "#define NAME VALUE"
+ "#define NAME" is unchanged
+ "#define NAME ORIGINAL_VALUE" is turned into "#define NAME VALUE"
+ "#undef UNKNOWN_NAME" is turned into "/* #undef UNKNOWN_NAME */"
+ "#cmakedefine UNKNOWN_NAME" is turned into "/* #undef UNKNOWN_NAME */"
+ Whitespaces are preserved.
+ """
+
+ path = os.path.abspath(input_file)
+
+ config = PartialConfigEnvironment(topobjdir)
+
+ defines = dict(config.defines.iteritems())
+ defines.update(extra_defines)
+
+ with open(path, "r") as input_file:
+ r = re.compile(
+ r'^\s*#\s*(?P<cmd>[a-z]+)(?:\s+(?P<name>\S+)(?:\s+(?P<value>("[^"]+"|\S+)))?)?',
+ re.U,
+ )
+ for line in input_file:
+ m = r.match(line)
+ if m:
+ cmd = m.group("cmd")
+ name = m.group("name")
+ value = m.group("value")
+ if name:
+ if cmd == "define":
+ if value and name in defines:
+ line = (
+ line[: m.start("value")]
+ + str(defines[name])
+ + line[m.end("value") :]
+ )
+ elif cmd in ("undef", "cmakedefine"):
+ if name in defines:
+ line = (
+ line[: m.start("cmd")]
+ + "define"
+ + line[m.end("cmd") : m.end("name")]
+ + " "
+ + str(defines[name])
+ + line[m.end("name") :]
+ )
+ else:
+ line = (
+ "/* #undef "
+ + line[m.start("name") : m.end("name")]
+ + " */"
+ + line[m.end("name") :]
+ )
+
+ output.write(line)
+
+
+def main(output, *argv):
+ parser = argparse.ArgumentParser(description="Process define files.")
+
+ parser.add_argument("input", help="Input define file.")
+ parser.add_argument(
+ "-D",
+ type=define_type,
+ action="append",
+ dest="extra_defines",
+ default=[],
+ help="Additional defines not set at configure time.",
+ )
+
+ args = parser.parse_args(argv)
+
+ return process_cmake_define_file(output, args.input, args.extra_defines)
+
+
+if __name__ == "__main__":
+ sys.exit(main(*sys.argv))
diff --git a/comm/python/thirdroc/thirdroc/rnp_symbols.py b/comm/python/thirdroc/thirdroc/rnp_symbols.py
new file mode 100644
index 0000000000..95f430f59c
--- /dev/null
+++ b/comm/python/thirdroc/thirdroc/rnp_symbols.py
@@ -0,0 +1,131 @@
+#!/usr/bin/python3
+# 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 http://mozilla.org/MPL/2.0/.
+"""
+Parse rnp/rnp.h header file and build a symbols file suitable
+for use with mozbuild.
+
+This script is meant to be run when the public C API of librnp adds or removes functions so that
+they can be exported by the shared library.
+
+Limitations: The regex that captures the function name is very basic and may need adjusting if
+the third_party/rnp/include/rnp/rnp.h format changes too much.
+Also note that APIs that are marked deprecated are not checked for.
+
+Dependencies: Only Python 3
+
+Running:
+ python3 rnp_symbols.py [-h] [rnp.h path] [rnp.symbols path]
+
+Both file path arguments are optional. By default, the header file will be
+read from "comm/third_party/rnp/include/rnp/rnp.h" and the symbols file will
+be written to "comm/third_party/rnp/rnp.symbols".
+
+Path arguments are relative to the current working directory, the defaults
+will be determined based on the location of this script.
+
+Either path argument can be '-' to use stdin or stdout respectively.
+"""
+
+import argparse
+import os
+import re
+import sys
+
+HERE = os.path.dirname(__file__)
+TOPSRCDIR = os.path.abspath(os.path.join(HERE, "../../../../"))
+THIRD_SRCDIR = os.path.join(TOPSRCDIR, "comm/third_party")
+HEADER_FILE_REL = "rnp/include/rnp/rnp.h"
+HEADER_FILE = os.path.join(THIRD_SRCDIR, HEADER_FILE_REL)
+SYMBOLS_FILE_REL = "rnp/rnp.symbols"
+SYMBOLS_FILE = os.path.join(THIRD_SRCDIR, SYMBOLS_FILE_REL)
+
+
+FUNC_DECL_RE = re.compile(r"^RNP_API\s+.*?([a-zA-Z0-9_]+)\(.*$")
+
+
+class FileArg:
+ """Based on argparse.FileType from the Python standard library.
+ Modified to not open the filehandles until the open() method is
+ called.
+ """
+
+ def __init__(self, mode="r"):
+ self._mode = mode
+ self._fp = None
+ self._file = None
+
+ def __call__(self, string):
+ # the special argument "-" means sys.std{in,out}
+ if string == "-":
+ if "r" in self._mode:
+ self._fp = sys.stdin.buffer if "b" in self._mode else sys.stdin
+ elif "w" in self._mode:
+ self._fp = sys.stdout.buffer if "b" in self._mode else sys.stdout
+ else:
+ raise ValueError(f"Invalid mode {self._mode} for stdin/stdout")
+ else:
+ if "r" in self._mode:
+ if not os.path.isfile(string):
+ raise ValueError(f"Cannot read file {string}, does not exist.")
+ elif "w" in self._mode:
+ if not os.access(string, os.W_OK):
+ raise ValueError(f"Cannot write file {string}, permission denied.")
+ self._file = string
+ return self
+
+ def open(self):
+ if self._fp:
+ return self._fp
+ return open(self._file, self._mode)
+
+
+def get_func_name(line):
+ """
+ Extract the function name from a RNP_API function declaration.
+ Examples:
+ RNP_API rnp_result_t rnp_enable_debug(const char *file);
+
+ RNP_API rnp_result_t rnp_ffi_create(rnp_ffi_t * ffi,
+ """
+ m = FUNC_DECL_RE.match(line)
+ return m.group(1)
+
+
+def extract_func_defs(filearg):
+ """
+ Look for RNP_API in the header file to find the names of the symbols that should be exported
+ """
+ with filearg.open() as fp:
+ for line in fp:
+ if line.startswith("RNP_API") and "RNP_DEPRECATED" not in line:
+ func_name = get_func_name(line)
+ yield func_name
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Update rnp.symbols file from rnp.h",
+ epilog="To use stdin or stdout pass '-' for the argument.",
+ )
+ parser.add_argument(
+ "header_file",
+ default=HEADER_FILE,
+ type=FileArg("r"),
+ nargs="?",
+ help=f"input path to rnp.h header file (default: {HEADER_FILE_REL})",
+ )
+ parser.add_argument(
+ "symbols_file",
+ default=SYMBOLS_FILE,
+ type=FileArg("w"),
+ nargs="?",
+ help=f"output path to symbols file (default: {SYMBOLS_FILE_REL})",
+ )
+
+ args = parser.parse_args()
+
+ with args.symbols_file.open() as out_fp:
+ for symbol in sorted(list(extract_func_defs(args.header_file))):
+ out_fp.write(f"{symbol}\n")